From 99df94b593d43ed9de0b114179b2827368580e94 Mon Sep 17 00:00:00 2001 From: Cedrick Lunven Date: Thu, 17 Sep 2026 13:48:36 +0200 Subject: [PATCH] fix: updateMany does not proceed past first page (#106) nextPageState was read from data (always null for updateMany) instead of from status, where the API actually places it. This caused the pagination loop to terminate after the first 50 documents. - Read nextPageState from status via RESULT_NEXT_PAGE_STATE constant - Add unit tests (CollectionUpdateManyPaginationTest) covering the loop mechanics with a stubbed Collection subclass - Add integration test in AbstractCollectionIT that inserts 60 documents and verifies all are updated across two pages --- .../astra/client/collections/Collection.java | 5 +- .../command/AbstractCommandRunner.java | 2 + .../integration/AbstractCollectionIT.java | 26 +++ .../CollectionUpdateManyPaginationTest.java | 148 ++++++++++++++++++ 4 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 astra-db-java/src/test/java/com/datastax/astra/test/unit/collections/CollectionUpdateManyPaginationTest.java diff --git a/astra-db-java/src/main/java/com/datastax/astra/client/collections/Collection.java b/astra-db-java/src/main/java/com/datastax/astra/client/collections/Collection.java index d0ef2376..81153fde 100644 --- a/astra-db-java/src/main/java/com/datastax/astra/client/collections/Collection.java +++ b/astra-db-java/src/main/java/com/datastax/astra/client/collections/Collection.java @@ -2032,12 +2032,9 @@ public CollectionUpdateResult updateMany(Filter filter, Update update, Collectio .appendIfNotNull(OPTIONS_UPSERT, options.upsert()) .appendIfNotNull(OPTIONS_PAGE_STATE, nextPageState)); DataAPIResponse res = runCommand(cmd, options); - // Data - if (res.getData() != null) { - nextPageState = res.getData().getNextPageState(); - } // Status DataAPIStatus status = res.getStatus(); + nextPageState = (status != null) ? status.getString(RESULT_NEXT_PAGE_STATE) : null; if (status.containsKey(RESULT_MATCHED_COUNT)) { result.setMatchedCount(result.getMatchedCount() + status.getInteger(RESULT_MATCHED_COUNT)); } diff --git a/astra-db-java/src/main/java/com/datastax/astra/internal/command/AbstractCommandRunner.java b/astra-db-java/src/main/java/com/datastax/astra/internal/command/AbstractCommandRunner.java index 8b41ffb8..09da0fe2 100644 --- a/astra-db-java/src/main/java/com/datastax/astra/internal/command/AbstractCommandRunner.java +++ b/astra-db-java/src/main/java/com/datastax/astra/internal/command/AbstractCommandRunner.java @@ -123,6 +123,8 @@ public abstract class AbstractCommandRunner> impl /** parsing output json */ protected static final String RESULT_MORE_DATA = "moreData"; /** parsing output json */ + protected static final String RESULT_NEXT_PAGE_STATE = "nextPageState"; + /** parsing output json */ protected static final String RESULT_COUNT = "count"; // --- Build Requests -- diff --git a/astra-db-java/src/test/java/com/datastax/astra/test/integration/AbstractCollectionIT.java b/astra-db-java/src/test/java/com/datastax/astra/test/integration/AbstractCollectionIT.java index c48c36a6..5a2e29b6 100644 --- a/astra-db-java/src/test/java/com/datastax/astra/test/integration/AbstractCollectionIT.java +++ b/astra-db-java/src/test/java/com/datastax/astra/test/integration/AbstractCollectionIT.java @@ -1038,6 +1038,32 @@ void should_updateMany_returnZeroWhenNoMatch() { assertThat(res.getModifiedCount()).isEqualTo(0); } + @Test + @Order(50) + void should_updateMany_processAllPagesWhenMoreThanOnePage() throws TooManyDocumentsToCountException { + // Insert 60 documents — exceeds the 50-doc page limit so updateMany must follow nextPageState. + // Before the bug fix, only the first 50 would be updated. + collectionSimple.deleteAll(); + List docs = IntStream.range(0, 60) + .mapToObj(i -> new Document().id("page-test-" + i).append("status", "pending")) + .collect(Collectors.toList()); + collectionSimple.insertMany(docs); + + CollectionUpdateResult res = collectionSimple.updateMany( + Filters.eq("status", "pending"), + Updates.set("status", "done")); + + assertThat(res.getMatchedCount()).isEqualTo(60); + assertThat(res.getModifiedCount()).isEqualTo(60); + + // Verify every document was actually updated in the collection + long stillPending = collectionSimple.countDocuments(Filters.eq("status", "pending"), 100); + assertThat(stillPending).isZero(); + + long nowDone = collectionSimple.countDocuments(Filters.eq("status", "done"), 100); + assertThat(nowDone).isEqualTo(60); + } + // ========== findOneAndUpdate ========== @Test diff --git a/astra-db-java/src/test/java/com/datastax/astra/test/unit/collections/CollectionUpdateManyPaginationTest.java b/astra-db-java/src/test/java/com/datastax/astra/test/unit/collections/CollectionUpdateManyPaginationTest.java new file mode 100644 index 00000000..750bb73c --- /dev/null +++ b/astra-db-java/src/test/java/com/datastax/astra/test/unit/collections/CollectionUpdateManyPaginationTest.java @@ -0,0 +1,148 @@ +package com.datastax.astra.test.unit.collections; + +import com.datastax.astra.client.DataAPIDestination; +import com.datastax.astra.client.collections.Collection; +import com.datastax.astra.client.collections.CollectionOptions; +import com.datastax.astra.client.collections.commands.options.CollectionUpdateManyOptions; +import com.datastax.astra.client.collections.commands.results.CollectionUpdateResult; +import com.datastax.astra.client.collections.definition.documents.Document; +import com.datastax.astra.client.core.commands.Command; +import com.datastax.astra.client.core.query.Filter; +import com.datastax.astra.client.core.options.BaseOptions; +import com.datastax.astra.client.core.options.DataAPIClientOptions; +import com.datastax.astra.client.collections.commands.Update; +import com.datastax.astra.client.databases.Database; +import com.datastax.astra.client.databases.DatabaseOptions; +import com.datastax.astra.internal.api.DataAPIResponse; +import com.datastax.astra.internal.api.DataAPIStatus; +import org.junit.jupiter.api.Test; + +import java.util.ArrayDeque; +import java.util.Queue; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit test for issue #106 — updateMany does not proceed past first page. + * + *

The root cause was that {@code nextPageState} was read from {@code data} (always null + * for updateMany) instead of from {@code status} (where the API actually puts it). + * This test drives the fix without any real HTTP connection by subclassing {@link Collection} + * and overriding {@link Collection#runCommand} to return pre-built responses.

+ */ +class CollectionUpdateManyPaginationTest { + + // -------------------------------------------------- + // Helpers to build fake DataAPIResponse objects + // -------------------------------------------------- + + private static DataAPIResponse pageResponse(int matched, int modified, String nextPageState) { + DataAPIStatus status = new DataAPIStatus(); + status.setProperty("matchedCount", matched); + status.setProperty("modifiedCount", modified); + if (nextPageState != null) { + status.setProperty("nextPageState", nextPageState); + } + DataAPIResponse response = new DataAPIResponse(); + response.setStatus(status); + return response; + } + + // -------------------------------------------------- + // Controllable Collection subclass + // -------------------------------------------------- + + private static final DataAPIClientOptions CLIENT_OPTIONS = + new DataAPIClientOptions().destination(DataAPIDestination.HCD); + private static final Database STUB_DB = + new Database("http://localhost:8181/v1/default_keyspace", + new DatabaseOptions("test-token", CLIENT_OPTIONS)); + private static final CollectionOptions COL_OPTIONS = + new CollectionOptions("test-token", CLIENT_OPTIONS); + + /** + * Collection subclass that feeds responses from a queue instead of making HTTP calls. + * The {@code super()} call must be first, so we use static fields for the constructor args. + */ + static class StubCollection extends Collection { + + private final Queue responses; + + StubCollection(Queue responses) { + super(STUB_DB, "test_col", COL_OPTIONS, Document.class); + this.responses = responses; + } + + @Override + public DataAPIResponse runCommand(Command command, BaseOptions overridingOptions) { + DataAPIResponse next = responses.poll(); + if (next == null) { + throw new IllegalStateException("No more stubbed responses — unexpected runCommand call"); + } + return next; + } + } + + // -------------------------------------------------- + // Tests + // -------------------------------------------------- + + @Test + void updateMany_shouldProcessAllPagesWhenNextPageStateIsInStatus() { + // Two pages: first page signals more data via nextPageState in status, + // second page has no nextPageState (pagination ends). + Queue responses = new ArrayDeque<>(); + responses.add(pageResponse(50, 50, "PAGE_TOKEN_2")); + responses.add(pageResponse(30, 30, null)); + + StubCollection collection = new StubCollection(responses); + + CollectionUpdateResult result = collection.updateMany( + new Filter(), + new Update().set(new Document().append("status", "done")), + new CollectionUpdateManyOptions()); + + assertThat(result.getMatchedCount()).isEqualTo(80); + assertThat(result.getModifiedCount()).isEqualTo(80); + // All responses consumed — both pages were requested + assertThat(responses).isEmpty(); + } + + @Test + void updateMany_shouldStopAfterSinglePageWhenNoNextPageState() { + // Single page: no nextPageState in status → loop must terminate after one call. + Queue responses = new ArrayDeque<>(); + responses.add(pageResponse(20, 18, null)); + + StubCollection collection = new StubCollection(responses); + + CollectionUpdateResult result = collection.updateMany( + new Filter(), + new Update().set(new Document().append("active", true)), + new CollectionUpdateManyOptions()); + + assertThat(result.getMatchedCount()).isEqualTo(20); + assertThat(result.getModifiedCount()).isEqualTo(18); + assertThat(responses).isEmpty(); + } + + @Test + void updateMany_shouldHandleThreePagesCorrectly() { + // Three pages chained together. + Queue responses = new ArrayDeque<>(); + responses.add(pageResponse(50, 50, "TOKEN_2")); + responses.add(pageResponse(50, 50, "TOKEN_3")); + responses.add(pageResponse(10, 10, null)); + + StubCollection collection = new StubCollection(responses); + + CollectionUpdateResult result = collection.updateMany( + new Filter(), + new Update().set(new Document().append("processed", true)), + new CollectionUpdateManyOptions()); + + assertThat(result.getMatchedCount()).isEqualTo(110); + assertThat(result.getModifiedCount()).isEqualTo(110); + assertThat(responses).isEmpty(); + } +}