diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandContext.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandContext.java index 14a574f04a..357298406d 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandContext.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandContext.java @@ -2,7 +2,6 @@ import com.google.common.base.Preconditions; import io.micrometer.core.instrument.MeterRegistry; -import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; import io.stargate.sgv2.jsonapi.api.model.command.tracing.DefaultRequestTracing; import io.stargate.sgv2.jsonapi.api.model.command.tracing.RequestTracing; import io.stargate.sgv2.jsonapi.api.request.RequestContext; @@ -67,9 +66,6 @@ public class CommandContext implements LoggingMDCC // add to this list in the ctor. See {@link #addToMDC()} and {@link #removeFromMDC()} private final List loggingMDCContexts = new ArrayList<>(); - // see accessors - private FindAndRerankCommand.HybridLimits hybridLimits; - // used to track the features used in the command private final CommandFeatures commandFeatures; @@ -110,22 +106,6 @@ public static BuilderSupplier builderSupplier() { return new BuilderSupplier(); } - /** - * HACK: for https://github.com/stargate/data-api/issues/1961 This is a temporary work around for - * needing to pass the page size to the FindCollectionOperation when doing the inner finds for - * findAndRerank because they will only run the command once, and not multiple times to exhaust - * the cursor. - * - * @return - */ - public FindAndRerankCommand.HybridLimits getHybridLimits() { - return hybridLimits; - } - - public void setHybridLimits(FindAndRerankCommand.HybridLimits hybridLimits) { - this.hybridLimits = hybridLimits; - } - public SchemaT schemaObject() { return schemaObject; } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/reranking/IntermediateCollectionReadTask.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/reranking/IntermediateCollectionReadTask.java index 094783bae3..a53000cf92 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/reranking/IntermediateCollectionReadTask.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/reranking/IntermediateCollectionReadTask.java @@ -72,7 +72,7 @@ protected IntermediateReadResultSupplier buildResultSupplier( } Operation findOperation = - findCommandResolver.resolveCommand(commandContext, findCommand); + findCommandResolver.resolveCollectionCandidateCommand(commandContext, findCommand); return new IntermediateReadResultSupplier( () -> { commandContext diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilder.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilder.java index 30528cb169..3a1e94034d 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilder.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilder.java @@ -181,15 +181,6 @@ private TaskGroupAndDeferrables taskGroup = new TaskGroup<>(false); - // Hack: See https://github.com/stargate/data-api/issues/1961 - // copying the hybrid limits on the command context so the find command resolver can pick it up - // when the command runs later, so we can set the page size to be the same as the limit - commandContext.setHybridLimits( - getOrDefault( - command.options(), - FindAndRerankCommand.Options::hybridLimits, - FindAndRerankCommand.HybridLimits.DEFAULT)); - var includeScores = getOrDefault(command.options(), FindAndRerankCommand.Options::includeScores, false); var includeSortVector = diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindCommandResolver.java index db714bece1..aa4d5e919f 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindCommandResolver.java @@ -1,12 +1,12 @@ package io.stargate.sgv2.jsonapi.service.resolver; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Preconditions; import io.micrometer.core.instrument.MeterRegistry; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortClause; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortExpression; import io.stargate.sgv2.jsonapi.api.model.command.impl.FindCommand; -import io.stargate.sgv2.jsonapi.api.model.command.impl.FindOneCommand; import io.stargate.sgv2.jsonapi.api.v1.metrics.JsonApiMetricsConfig; import io.stargate.sgv2.jsonapi.config.OperationsConfig; import io.stargate.sgv2.jsonapi.exception.SortException; @@ -24,7 +24,7 @@ import java.util.List; import java.util.Map; -/** Resolves the {@link FindOneCommand } */ +/** Resolves collection and table {@link FindCommand}s. */ @ApplicationScoped public class FindCommandResolver implements CommandResolver { @@ -72,6 +72,34 @@ public Operation resolveTableCommand( @Override public Operation resolveCollectionCommand( CommandContext commandContext, FindCommand command) { + int pageSize = operationsConfig.defaultPageSize(); + return resolveCollectionRead( + commandContext, command, new CollectionReadLimits(pageSize, pageSize, pageSize)); + } + + /** + * Resolves an internal reranking read. The planner validates user-supplied hybridLimits before + * building the inner find, so a missing or non-positive limit here is an internal state error. + */ + public Operation resolveCollectionCandidateCommand( + CommandContext commandContext, FindCommand command) { + var options = command.options(); + Preconditions.checkState( + options != null && options.limit() != null && options.limit() > 0, + "Candidate reads require an explicit positive options.limit"); + return resolveCollectionRead( + commandContext, + command, + new CollectionReadLimits( + Math.min(options.limit(), operationsConfig.maxVectorSearchLimit()), + options.limit(), + operationsConfig.defaultPageSize())); + } + + private FindCollectionOperation resolveCollectionRead( + CommandContext commandContext, + FindCommand command, + CollectionReadLimits readLimits) { var resolvedDbLogicalExpression = collectionFilterResolver.resolve(commandContext, command).target(); @@ -128,18 +156,13 @@ public Operation resolveCollectionCommand( Math.min( limit, operationsConfig.maxVectorSearchLimit()); // Max vector search support is 1000 - // Hack: See https://github.com/stargate/data-api/issues/1961 - int pageSize = - commandContext.getHybridLimits() == null - ? operationsConfig.defaultPageSize() - : commandContext.getHybridLimits().vectorLimit(); return FindCollectionOperation.vsearch( commandContext, resolvedDbLogicalExpression, command.buildProjector(includeSimilarity), pageState, limit, - pageSize, + readLimits.vectorPageSize(), CollectionReadType.DOCUMENT, objectMapper, vector, @@ -149,18 +172,13 @@ public Operation resolveCollectionCommand( // BM25 search / sort? SortExpression bm25Expr = SortClauseUtil.resolveBM25Search(sortClause); if (bm25Expr != null) { - // Hack: See https://github.com/stargate/data-api/issues/1961 - int pageSize = - commandContext.getHybridLimits() == null - ? operationsConfig.defaultPageSize() - : commandContext.getHybridLimits().lexicalLimit(); return FindCollectionOperation.bm25Multi( commandContext, resolvedDbLogicalExpression, command.buildProjector(), pageState, limit, - pageSize, + readLimits.lexicalPageSize(), CollectionReadType.DOCUMENT, objectMapper, bm25Expr); @@ -169,16 +187,12 @@ public Operation resolveCollectionCommand( List orderBy = SortClauseUtil.resolveOrderBy(sortClause); // if orderBy present if (orderBy != null) { - // Hack: See https://github.com/stargate/data-api/issues/1961 - // not commandContext.getHybridLimits() becuase there is no limit for a non ANN or BM25 query return FindCollectionOperation.sorted( commandContext, resolvedDbLogicalExpression, command.buildProjector(), pageState, - // For in-memory sorting if no limit provided in the request will use - // documentConfig.defaultPageSize() as limit - Math.min(limit, operationsConfig.defaultPageSize()), + Math.min(limit, readLimits.sortedLimit()), // For in-memory sorting we read more data than needed, so defaultSortPageSize like 100 operationsConfig.defaultSortPageSize(), CollectionReadType.SORTED_DOCUMENT, @@ -188,8 +202,6 @@ public Operation resolveCollectionCommand( operationsConfig.maxDocumentSortCount(), includeSortVector); } - // Hack: See https://github.com/stargate/data-api/issues/1961 - // not commandContext.getHybridLimits() becuase there is no limit for a non ANN or BM25 query return FindCollectionOperation.unsorted( commandContext, resolvedDbLogicalExpression, @@ -201,4 +213,7 @@ public Operation resolveCollectionCommand( objectMapper, includeSortVector); } + + /** Concrete execution limits supplied by the caller, independent of query resolution. */ + private record CollectionReadLimits(int vectorPageSize, int lexicalPageSize, int sortedLimit) {} } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/reranking/FindAndRerankPlannerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/reranking/FindAndRerankPlannerTest.java index da95ab5d1c..f5b06b9234 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/reranking/FindAndRerankPlannerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/reranking/FindAndRerankPlannerTest.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.core.json.JsonReadFeature; import com.fasterxml.jackson.databind.ObjectMapper; import io.stargate.sgv2.jsonapi.TestConstants; +import io.stargate.sgv2.jsonapi.api.model.command.CommandErrorFactory; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.LegMode; import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; @@ -30,6 +31,7 @@ import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfig; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionRerankDef; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; +import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -38,6 +40,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; /** * Tests for {@link FindAndRerankPlanner}, plain JUnit without Quarkus. @@ -320,6 +323,29 @@ void failVectorizeSortWithoutVectorizeService() { () -> plan(hybridText("cheese"), RERANK_OPTIONS, VECTOR_LEXICAL)); } + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void nonPositiveUserLimitsReturnRequestErrors(int limit) { + for (var limitsJson : + List.of( + String.valueOf(limit), + "{'$vector': %d, '$lexical': 10}".formatted(limit), + "{'$vector': 10, '$lexical': %d}".formatted(limit))) { + var error = + assertPlanFails( + RequestException.class, + RequestException.Code.COMMAND_FIELD_VALUE_INVALID, + () -> + plan( + hybridObject(null, "cows", VECTOR), + "{'rerankQuery': 'q', 'rerankOn': 'body', 'hybridLimits': %s}" + .formatted(limitsJson), + VECTOR_LEXICAL)); + assertThat(CommandErrorFactory.create(error).errorCode()) + .isEqualTo(RequestException.Code.COMMAND_FIELD_VALUE_INVALID.name()); + } + } + @Test void failHybridLimitsOutOfBounds() { var expectedBounds = diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderBehaviorTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderBehaviorTest.java index 25f2f979aa..84fc4d55ef 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderBehaviorTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderBehaviorTest.java @@ -43,8 +43,7 @@ /** * Behavior baseline for {@link FindAndRerankOperationBuilder#build()}: pins the outcome for many * request shapes and collection configs. A row either builds an operation, and then pins the reads - * it runs (rank source and limit, in order) and the hybrid limits on the command context, or it - * pins the error code. + * it runs (rank source and limit, in order), or it pins the error code. * *

Some rows pin behaviour that is easy to change by accident: * @@ -294,13 +293,13 @@ static Stream rows() { "{'$hybrid': {'$vector': " + VECTOR_JSON + ", '$lexical': 'cows'}}", "{'rerankQuery': 'q', 'rerankOn': 'body', 'hybridLimits': {'$vector': 100, '$lexical': 25}}", Collection.VECTOR_LEXICAL, - success(100, 25, lexicalRead(25), vectorRead(100))), + success(lexicalRead(25), vectorRead(100))), row( "G23", "{'$hybrid': {'$vector': " + VECTOR_JSON + ", '$lexical': 'cows'}}", "{'rerankQuery': 'q', 'rerankOn': 'body', 'hybridLimits': 30}", Collection.VECTOR_LEXICAL, - success(30, 30, lexicalRead(30), vectorRead(30))), + success(lexicalRead(30), vectorRead(30))), // ---- lexical-only $hybrid ---- row( @@ -464,7 +463,7 @@ static Stream rows() { "{'$hybrid': {'$vectorize': 'cheese'}}", "{'hybridLimits': 30}", Collection.VECTORIZE_NO_LEXICAL, - success(30, 30, vectorRead(30))), + success(vectorRead(30))), row( "R42", "{'$hybrid': {'$vector': " + VECTOR_JSON + ", '$lexical': 'cows'}}", @@ -686,12 +685,6 @@ void buildOutcome(Row row) throws Exception { .toList()) .as(row.id() + " reads, in order") .containsExactlyElementsOf(success.reads()); - assertThat(commandContext.getHybridLimits().vectorLimit()) - .as(row.id() + " hybridLimits.$vector on the command context") - .isEqualTo(success.vectorLimit()); - assertThat(commandContext.getHybridLimits().lexicalLimit()) - .as(row.id() + " hybridLimits.$lexical on the command context") - .isEqualTo(success.lexicalLimit()); } case Failure failure -> { var ex = @@ -774,15 +767,9 @@ private static Row row( return new Row(id, sort, options, filter, projection, collection, expected); } - /** build() returns an operation with these reads, and the default hybrid limits. */ + /** build() returns an operation with these reads and their candidate limits. */ private static Expected success(ExpectedRead... reads) { - var defaults = FindAndRerankCommand.HybridLimits.DEFAULT; - return new Success(defaults.vectorLimit(), defaults.lexicalLimit(), List.of(reads)); - } - - /** build() returns an operation with these reads, and these hybrid limits. */ - private static Expected success(int vectorLimit, int lexicalLimit, ExpectedRead... reads) { - return new Success(vectorLimit, lexicalLimit, List.of(reads)); + return new Success(List.of(reads)); } private static Expected failure( @@ -857,12 +844,8 @@ public String toString() { private sealed interface Expected permits Success, Failure {} - /** - * build() returns an operation with these reads, and copies these hybrid limits onto the command - * context. - */ - private record Success(int vectorLimit, int lexicalLimit, List reads) - implements Expected {} + /** build() returns an operation with these reads and their candidate limits. */ + private record Success(List reads) implements Expected {} /** * build() throws exactly this API exception class with this code, and optionally with this diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderTest.java index 3e8a1ca989..63d40a016b 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderTest.java @@ -12,16 +12,23 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.smallrye.mutiny.Uni; import io.stargate.sgv2.jsonapi.TestConstants; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.CommandName; +import io.stargate.sgv2.jsonapi.api.model.command.CommandResult; import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; +import io.stargate.sgv2.jsonapi.api.model.command.impl.FindCommand; +import io.stargate.sgv2.jsonapi.api.model.command.tracing.RequestTracing; import io.stargate.sgv2.jsonapi.api.request.RequestContext; import io.stargate.sgv2.jsonapi.config.constants.RerankingConstants; import io.stargate.sgv2.jsonapi.exception.APIException; @@ -30,6 +37,8 @@ import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.exception.SortException; import io.stargate.sgv2.jsonapi.service.embedding.operation.EmbeddingProvider; +import io.stargate.sgv2.jsonapi.service.operation.Operation; +import io.stargate.sgv2.jsonapi.service.operation.collections.FindCollectionOperation; import io.stargate.sgv2.jsonapi.service.operation.embeddings.EmbeddingTask; import io.stargate.sgv2.jsonapi.service.operation.reranking.DeferredVectorize; import io.stargate.sgv2.jsonapi.service.operation.reranking.IntermediateCollectionReadTask; @@ -46,6 +55,7 @@ import java.util.Optional; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; @QuarkusTest @TestProfile(NoGlobalResourcesTestProfile.Impl.class) @@ -67,7 +77,7 @@ private static void validateOverride( } @Test - void keepsExplicitHybridLimitsAtMaximumPageSizeOnCommandContext() throws Exception { + void resolvesAsymmetricHybridLimitsFromInnerCommands() throws Exception { var commandContext = commandContext(); var command = command( @@ -84,14 +94,51 @@ void keepsExplicitHybridLimitsAtMaximumPageSizeOnCommandContext() throws Excepti } """); - new FindAndRerankOperationBuilder(commandContext) - .withCommand(command) - .withFindCommandResolver(findCommandResolver) - .build(); + var candidateResolver = mock(FindCommandResolver.class); + Operation emptyRead = mock(); + var emptyResult = CommandResult.multiDocumentBuilder(RequestTracing.NO_OP).build(); + when(emptyRead.execute(commandContext)) + .thenReturn(Uni.createFrom().item(() -> () -> emptyResult)); + when(candidateResolver.resolveCollectionCandidateCommand(eq(commandContext), any())) + .thenReturn(emptyRead); - assertThat(commandContext.getHybridLimits().vectorLimit()) - .isEqualTo(RerankingConstants.HybridSearchLimits.MAX); - assertThat(commandContext.getHybridLimits().lexicalLimit()).isEqualTo(25); + var operation = + new FindAndRerankOperationBuilder(commandContext) + .withCommand(command) + .withFindCommandResolver(candidateResolver) + .build(); + + var result = operation.execute(commandContext).await().indefinitely().get(); + assertThat(result.errors()).isEmpty(); + + var commands = ArgumentCaptor.forClass(FindCommand.class); + verify(candidateResolver, times(2)) + .resolveCollectionCandidateCommand(eq(commandContext), commands.capture()); + verify(candidateResolver, never()).resolveCommand(any(), any()); + + var reads = + commands.getAllValues().stream() + .map( + inner -> + findCommandResolver.resolveCollectionCandidateCommand(commandContext, inner)) + .map(FindCollectionOperation.class::cast) + .toList(); + assertThat(reads) + .filteredOn(read -> read.vector() != null) + .singleElement() + .satisfies( + read -> { + assertThat(read.limit()).isEqualTo(RerankingConstants.HybridSearchLimits.MAX); + assertThat(read.pageSize()).isEqualTo(RerankingConstants.HybridSearchLimits.MAX); + }); + assertThat(reads) + .filteredOn(read -> read.vector() == null) + .singleElement() + .satisfies( + read -> { + assertThat(read.limit()).isEqualTo(25); + assertThat(read.pageSize()).isEqualTo(25); + }); } @Test diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindCommandResolverTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindCommandResolverTest.java index 2740d0a292..7c776e8891 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindCommandResolverTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindCommandResolverTest.java @@ -1,6 +1,7 @@ package io.stargate.sgv2.jsonapi.service.resolver; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -24,6 +25,7 @@ import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; import jakarta.inject.Inject; import java.math.BigDecimal; +import java.util.Arrays; import java.util.Date; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -47,6 +49,117 @@ public void beforeEach() { commandContext = testConstants.collectionContext(); } + @Nested + class CollectionCandidateReads { + + @Test + void requireExplicitPositiveLimit() { + for (var options : + Arrays.asList( + null, + new FindCommand.Options(null, null, null, false, false), + new FindCommand.Options(0, null, null, false, false), + new FindCommand.Options(-1, null, null, false, false))) { + var command = new FindCommand(null, null, null, options); + assertThatThrownBy( + () -> resolver.resolveCollectionCandidateCommand(commandContext, command)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Candidate reads require an explicit positive options.limit"); + } + } + + @Test + void vectorReadUsesInnerLimitAsPageSize() throws Exception { + var context = + testConstants.collectionContext( + testConstants.COMMAND_NAME, + testConstants.VECTOR_COLLECTION_SCHEMA_OBJECT, + null, + null); + var command = + objectMapper.readValue( + """ + {"find": {"sort": {"$vector": [0.1, 0.2, 0.3]}, "options": {"limit": 100}}} + """, + FindCommand.class); + + assertThat(resolver.resolveCollectionCandidateCommand(context, command)) + .isInstanceOfSatisfying( + FindCollectionOperation.class, + read -> { + assertThat(read.limit()).isEqualTo(100); + assertThat(read.pageSize()).isEqualTo(100); + assertThat(read.vector()).containsExactly(0.1f, 0.2f, 0.3f); + }); + + assertThat(resolver.resolveCollectionCommand(context, command)) + .isInstanceOfSatisfying( + FindCollectionOperation.class, + read -> { + assertThat(read.limit()).isEqualTo(100); + assertThat(read.pageSize()).isEqualTo(operationsConfig.defaultPageSize()); + }); + } + + @Test + void vectorCandidatePageSizeRespectsVectorSearchLimit() throws Exception { + var context = + testConstants.collectionContext( + testConstants.COMMAND_NAME, + testConstants.VECTOR_COLLECTION_SCHEMA_OBJECT, + null, + null); + var command = + objectMapper.readValue( + """ + {"find": {"sort": {"$vector": [0.1, 0.2, 0.3]}, "options": {"limit": %d}}} + """ + .formatted(operationsConfig.maxVectorSearchLimit() + 1), + FindCommand.class); + + assertThat(resolver.resolveCollectionCandidateCommand(context, command)) + .isInstanceOfSatisfying( + FindCollectionOperation.class, + read -> { + assertThat(read.limit()).isEqualTo(operationsConfig.maxVectorSearchLimit()); + assertThat(read.pageSize()).isEqualTo(operationsConfig.maxVectorSearchLimit()); + }); + assertThat(resolver.resolveCollectionCommand(context, command)) + .isInstanceOfSatisfying( + FindCollectionOperation.class, + read -> { + assertThat(read.limit()).isEqualTo(operationsConfig.maxVectorSearchLimit()); + assertThat(read.pageSize()).isEqualTo(operationsConfig.defaultPageSize()); + }); + } + + @Test + void lexicalReadUsesInnerLimitAsPageSize() throws Exception { + var command = + objectMapper.readValue( + """ + {"find": {"sort": {"$lexical": "text"}, "options": {"limit": 25}}} + """, + FindCommand.class); + + assertThat(resolver.resolveCollectionCandidateCommand(commandContext, command)) + .isInstanceOfSatisfying( + FindCollectionOperation.class, + read -> { + assertThat(read.limit()).isEqualTo(25); + assertThat(read.pageSize()).isEqualTo(25); + }); + + assertThat(resolver.resolveCollectionCommand(commandContext, command)) + .isInstanceOfSatisfying( + FindCollectionOperation.class, + read -> { + assertThat(read.limit()).isEqualTo(25); + assertThat(read.pageSize()).isEqualTo(operationsConfig.defaultPageSize()); + }); + } + } + @Nested class FindCommandResolveCommand {