diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d8cdf5..344a5919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixes +- Vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and object-shaped `DISTINCT` projections no longer fail with a continuation-token error. These query pipelines execute successfully but cannot export a resumable token, which was previously reported as a command failure. Such queries now return their documents; through MCP they keep reading until the requested limit instead of stopping after one page, and a truncated result is reported as `resultIncomplete` rather than as an exhausted result set. ([#219](https://github.com/Azure/CosmosDBShell/issues/219)) - Local emulator outages are now detected across Cosmos DB commands. Requests fail promptly with an error and return the shell to its disconnected state instead of leaving an unresponsive session labeled as connected. - A failed or cancelled export no longer destroys its destination file. Exports are written to a temporary file in the destination directory and moved into place only after they complete, so an existing file survives query failures, write failures, and cancellation. An abrupt process termination can leave an unfinished `.cosmos-export-*.tmp` file behind. ([#207](https://github.com/Azure/CosmosDBShell/pull/207)) - `export --max` no longer requests a further query page once the limit is reached, so the reported request charge no longer includes a page whose items were discarded. Query iterators are now disposed. ([#207](https://github.com/Azure/CosmosDBShell/pull/207)) diff --git a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs index b9db54fc..5cdc6e45 100644 --- a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs @@ -5,11 +5,16 @@ namespace CosmosShell.Tests.CommandTests; using System.Globalization; +using System.Net; +using System.Text; using System.Text.Json; using Azure.Data.Cosmos.Shell.Commands; using Azure.Data.Cosmos.Shell.Core; using Microsoft.Azure.Cosmos; +using NSubstitute; +using Spectre.Console; +[Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)] public class QueryCommandTests { private class TestServerSideMetrics : ServerSideMetrics @@ -544,4 +549,284 @@ public void ParseIndexPlan_RecognizedEmptyGroups_ReturnsAvailable() Assert.Empty(utilized); Assert.Empty(potential); } + + [Fact] + public void TryReadContinuationToken_ResponseWithoutToken_ReportsSupported() + { + using var response = new ResponseMessage(HttpStatusCode.OK); + + Assert.True(QueryCommand.TryReadContinuationToken(response, out var continuationToken)); + Assert.Null(continuationToken); + } + + [Fact] + public void TryReadContinuationToken_ResponseWithToken_ReturnsToken() + { + using var response = new PageResponse("{\"_count\":0,\"Documents\":[]}", () => "next-page"); + + Assert.True(QueryCommand.TryReadContinuationToken(response, out var continuationToken)); + Assert.Equal("next-page", continuationToken); + } + + [Theory] + [InlineData("Continuation tokens are not supported for the non streaming order by pipeline.")] + [InlineData("Continuation tokens are not supported by hybrid search.")] + [InlineData("DISTINCT queries only return continuation tokens when there is a matching ORDER BY clause.")] + public void TryReadContinuationToken_PipelineWithoutTokenSupport_ReportsUnsupported(string message) + { + using var response = new PageResponse("{\"_count\":0,\"Documents\":[]}", () => throw new ArgumentException(message)); + + Assert.False(QueryCommand.TryReadContinuationToken(response, out var continuationToken)); + Assert.Null(continuationToken); + } + + [Fact] + public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_ReturnsDocuments() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + NonResumablePage("Continuation tokens are not supported for the non streaming order by pipeline.", 1.5, "1", "2", "6")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT TOP 3 c.id FROM c ORDER BY VectorDistance(c.embedding, [1,0,0])", Max = 10 }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(["1", "2", "6"], ReadIds(result)); + Assert.Null(result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + Assert.Equal(1.5, result.RequestCharge); + } + + [Fact] + public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_AdvancesThroughEmptyPages() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "1"), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 2), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 3, "2", "6")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT TOP 3 c.id FROM c ORDER BY RANK FullTextScore(c.text, \"cosmos\")", Max = 10, IsMcpRequest = true }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(["1", "2", "6"], ReadIds(result)); + Assert.Equal(3, iterator.ReadCount); + Assert.Null(result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + Assert.Equal(6, result.RequestCharge); + } + + [Fact] + public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_ReportsIncompleteResultAtLimit() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "1", "2"), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "6")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT c.id FROM c ORDER BY RANK FullTextScore(c.text, \"cosmos\")", Max = 2, IsMcpRequest = true }; + + var output = await CaptureConsoleAsync(() => command.ExecuteQueryAsync(container, shell, CancellationToken.None)); + + Assert.Equal(["1", "2"], ReadIds(output.Result)); + Assert.Null(output.Result.ContinuationToken); + Assert.True(output.Result.IncompleteWithoutContinuation); + Assert.Contains("cannot be resumed", output.Text); + } + + [Fact] + public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_CancelledBetweenPages_ReportsIncompleteResult() + { + using var shell = ShellInterpreter.CreateInstance(); + using var cancellation = new CancellationTokenSource(); + using var iterator = new FakeFeedIterator( + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "1"), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "2")); + iterator.AfterRead = cancellation.Cancel; + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT c.id FROM c ORDER BY RANK FullTextScore(c.text, \"cosmos\")", Max = 10, IsMcpRequest = true }; + + var result = await command.ExecuteQueryAsync(container, shell, cancellation.Token); + + Assert.Equal(["1"], ReadIds(result)); + Assert.Equal(1, iterator.ReadCount); + Assert.Null(result.ContinuationToken); + Assert.True(result.IncompleteWithoutContinuation); + } + + [Fact] + public async Task ExecuteQueryAsync_ObjectShapedDistinctWithOrderBy_ReturnsDocumentsWithoutToken() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + NonResumableDocumentPage( + "DISTINCT queries only return continuation tokens when there is a matching ORDER BY clause.", + 1, + "{\"category\":\"A\"}", + "{\"category\":\"B\"}", + "{\"category\":\"C\"}")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT DISTINCT c.category FROM c ORDER BY c.category", Max = 10, IsMcpRequest = true }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(["A", "B", "C"], ReadValues(result, "category")); + Assert.Null(result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + } + + [Fact] + public async Task ExecuteQueryAsync_TokenExportRefusedOnLaterPage_DiscardsEarlierToken() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + ResumablePage("stale-token", 1, "1"), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "2")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT c.id FROM c", Max = 10 }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(["1", "2"], ReadIds(result)); + Assert.Equal(2, iterator.ReadCount); + Assert.Null(result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + } + + [Fact] + public async Task ExecuteQueryAsync_ResumablePage_KeepsSingleMcpPageAndToken() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + ResumablePage("next-page", 1, "A", "B"), + ResumablePage(null, 1, "C")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT DISTINCT VALUE c.category FROM c ORDER BY c.category", Max = 10, IsMcpRequest = true }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(1, iterator.ReadCount); + Assert.Equal("next-page", result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + } + + [Fact] + public async Task ExecuteQueryAsync_ResumableQueryAtLimit_ReportsLimitWithoutResumeWarning() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + ResumablePage("next-page", 1, "1", "2", "3")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT * FROM c", Max = 2 }; + + var output = await CaptureConsoleAsync(() => command.ExecuteQueryAsync(container, shell, CancellationToken.None)); + + Assert.Equal(["1", "2"], ReadIds(output.Result)); + Assert.Equal("next-page", output.Result.ContinuationToken); + Assert.False(output.Result.IncompleteWithoutContinuation); + Assert.Contains("Results limited to 2 items", output.Text); + Assert.DoesNotContain("cannot be resumed", output.Text); + } + + private static Container CreateContainer(FeedIterator iterator) + { + var container = Substitute.For(); + container.GetItemQueryStreamIterator(Arg.Any(), Arg.Any(), Arg.Any()).Returns(iterator); + return container; + } + + private static ResponseMessage NonResumablePage(string message, double requestCharge, params string[] ids) + { + return NonResumableDocumentPage(message, requestCharge, [.. ids.Select(id => $"{{\"id\":\"{id}\"}}")]); + } + + private static ResponseMessage NonResumableDocumentPage(string message, double requestCharge, params string[] documents) + { + return CreatePage(requestCharge, () => throw new ArgumentException(message), documents); + } + + private static ResponseMessage ResumablePage(string? continuationToken, double requestCharge, params string[] ids) + { + return CreatePage(requestCharge, () => continuationToken, [.. ids.Select(id => $"{{\"id\":\"{id}\"}}")]); + } + + private static ResponseMessage CreatePage(double requestCharge, Func continuationToken, string[] documents) + { + var response = new PageResponse($"{{\"_count\":{documents.Length},\"Documents\":[{string.Join(",", documents)}]}}", continuationToken); + response.Headers.Add("x-ms-request-charge", requestCharge.ToString(CultureInfo.InvariantCulture)); + return response; + } + + private static string[] ReadIds(CommandState state) + { + return ReadValues(state, "id"); + } + + private static string[] ReadValues(CommandState state, string property) + { + using var document = JsonDocument.Parse(state.GenerateOutputText()); + return [.. document.RootElement.GetProperty("values").EnumerateArray().Select(value => value.GetProperty(property).GetString()!)]; + } + + private static async Task<(CommandState Result, string Text)> CaptureConsoleAsync(Func> action) + { + var saved = AnsiConsole.Console; + using var writer = new StringWriter(); + try + { + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(writer), + }); + AnsiConsole.Console.Profile.Width = 200; + + var result = await action(); + return (result, writer.ToString()); + } + finally + { + AnsiConsole.Console = saved; + } + } + + private sealed class PageResponse : ResponseMessage + { + private readonly Func continuationToken; + + public PageResponse(string content, Func continuationToken) + : base(HttpStatusCode.OK) + { + this.continuationToken = continuationToken; + this.Content = new MemoryStream(Encoding.UTF8.GetBytes(content)); + } + + public override string ContinuationToken => this.continuationToken()!; + } + + private sealed class FakeFeedIterator : FeedIterator + { + private readonly Queue pages; + + public FakeFeedIterator(params ResponseMessage[] pages) + { + this.pages = new Queue(pages); + } + + public int ReadCount { get; private set; } + + public Action? AfterRead { get; set; } + + public override bool HasMoreResults => this.pages.Count > 0; + + public override Task ReadNextAsync(CancellationToken cancellationToken = default) + { + this.ReadCount++; + var page = this.pages.Dequeue(); + this.AfterRead?.Invoke(); + return Task.FromResult(page); + } + } } \ No newline at end of file diff --git a/CosmosDBShell.Tests/McpResponseFactoryTests.cs b/CosmosDBShell.Tests/McpResponseFactoryTests.cs index 5a85eab5..76b095c4 100644 --- a/CosmosDBShell.Tests/McpResponseFactoryTests.cs +++ b/CosmosDBShell.Tests/McpResponseFactoryTests.cs @@ -249,4 +249,37 @@ public void CreateSuccess_OmitsRequestChargeWhenNotSet() Assert.NotNull(result.StructuredContent); Assert.False(result.StructuredContent!.Value.TryGetProperty("requestCharge", out _)); } + + [Fact] + public void CreateSuccess_IncompletePage_MarksResultIncomplete() + { + var commandState = new CommandState + { + IsPage = true, + IncompleteWithoutContinuation = true, + Result = new ShellJson(JsonSerializer.SerializeToElement(new { result = "success" })), + }; + + var result = McpResponseFactory.CreateSuccess(commandState, new ConnectedState(null!)); + + Assert.NotNull(result.StructuredContent); + var structured = result.StructuredContent!.Value; + Assert.Equal(JsonValueKind.Null, structured.GetProperty("continuationToken").ValueKind); + Assert.True(structured.GetProperty("resultIncomplete").GetBoolean()); + } + + [Fact] + public void CreateSuccess_CompletePage_OmitsResultIncomplete() + { + var commandState = new CommandState + { + IsPage = true, + Result = new ShellJson(JsonSerializer.SerializeToElement(new { result = "success" })), + }; + + var result = McpResponseFactory.CreateSuccess(commandState, new ConnectedState(null!)); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent!.Value.TryGetProperty("resultIncomplete", out _)); + } } \ No newline at end of file diff --git a/CosmosDBShell.Tests/ToolOperationsTests.cs b/CosmosDBShell.Tests/ToolOperationsTests.cs index eee41f51..e856b596 100644 --- a/CosmosDBShell.Tests/ToolOperationsTests.cs +++ b/CosmosDBShell.Tests/ToolOperationsTests.cs @@ -48,6 +48,7 @@ public void GetTool_PagedMaxDescription_DocumentsSinglePageSemanticsWithoutChang var maxDescription = schema.GetProperty("properties").GetProperty("max").GetProperty("description").GetString(); Assert.Contains("continuationToken", maxDescription); + Assert.Contains("resultIncomplete", maxDescription); var shellDescription = factory.Options.Single(option => option.Name[0] == "max").GetDescription(commandName); Assert.DoesNotContain("continuationToken", shellDescription); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs index 82ae72d2..a6cdd7b4 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs @@ -35,7 +35,7 @@ internal enum MetricTarget ReadOnly = true, Idempotent = true, OpenWorld = true, - Description = "Executes a Cosmos DB NoSQL query against the current container and returns one bounded page of matching documents. Pass the returned continuationToken as continuation to retrieve the next page. Pass explain=true to return the query execution plan (utilized/potential indexes and a plain-language evaluation) instead of documents. Use the cosmos://docs/nosql-query-language resource for query syntax reference.")] + Description = "Executes a Cosmos DB NoSQL query against the current container and returns one bounded page of matching documents. Pass the returned continuationToken as continuation to retrieve the next page. Some plans cannot be paged at all, including vector ORDER BY, ORDER BY RANK, and DISTINCT projections such as SELECT DISTINCT c.category FROM c ORDER BY c.category; adding an ORDER BY clause does not make a DISTINCT query resumable. Those queries return a null continuationToken, and a result truncated by max is reported with resultIncomplete set to true, which means the result set is not exhausted and must be retried with a larger max or a narrower query. Pass explain=true to return the query execution plan (utilized/potential indexes and a plain-language evaluation) instead of documents. Use the cosmos://docs/nosql-query-language resource for query syntax reference.")] internal class QueryCommand : CosmosCommand, IPagedCommand { [CosmosParameter("query")] @@ -135,6 +135,31 @@ internal static bool PageExceedsLimit(int currentCount, JsonElement pageDocument return pageDocuments.GetArrayLength() > remainingCapacity; } + /// + /// Reads the continuation token of a successful query response. Pipelines such as + /// non-streaming ORDER BY, hybrid search, and DISTINCT execute normally but refuse to + /// export a resumable token, which the SDK reports by throwing from the property getter + /// rather than by failing the request. Whether a token is available is decided by the + /// query plan at runtime, so executing a query and being able to resume it are reported + /// separately instead of being inferred from the query text. + /// + /// The successful query response. + /// The exported token, or when none is available. + /// when the response can export a token; otherwise . + internal static bool TryReadContinuationToken(ResponseMessage response, out string? continuationToken) + { + try + { + continuationToken = response.ContinuationToken; + return true; + } + catch (ArgumentException) + { + continuationToken = null; + return false; + } + } + internal static CommandState CreateCommandState(string? outputFormat) { var state = new CommandState(); @@ -677,7 +702,7 @@ private async Task ExecuteExplainAsync(Container container, ShellI } } - private async Task ExecuteQueryAsync(Container container, ShellInterpreter shell, CancellationToken token) + internal async Task ExecuteQueryAsync(Container container, ShellInterpreter shell, CancellationToken token) { try { @@ -711,6 +736,7 @@ private async Task ExecuteQueryAsync(Container container, ShellInt using var feedIterator = container.GetItemQueryStreamIterator(this.Query, this.Continuation, options); var limitReached = false; + var continuationSupported = true; while (feedIterator.HasMoreResults) { @@ -750,7 +776,17 @@ private async Task ExecuteQueryAsync(Container container, ShellInt totalRequestCharge += pageRequestCharge; AnsiConsole.MarkupLine(MessageService.GetString("command-query-request_charge", new Dictionary { { "charge", pageRequestCharge.ToString("F2", CultureInfo.InvariantCulture) } })); - returnState.ContinuationToken = response.ContinuationToken; + if (TryReadContinuationToken(response, out var pageContinuationToken)) + { + returnState.ContinuationToken = pageContinuationToken; + } + else + { + // The pipeline cannot be resumed. Drop any token collected from an earlier + // page so the caller never receives one the service would reject. + continuationSupported = false; + returnState.ContinuationToken = null; + } var pageDocuments = queryDocument.RootElement.GetProperty("Documents"); var pageExceedsLimit = PageExceedsLimit(aggregatedDocuments.Count, pageDocuments, effectiveMaxItemCount); @@ -895,7 +931,7 @@ private async Task ExecuteQueryAsync(Container container, ShellInt GeneratePlainResultDocument(returnState, aggregatedDocuments); } - if (this.IsMcpRequest) + if (this.IsMcpRequest && continuationSupported) { break; } @@ -907,9 +943,17 @@ private async Task ExecuteQueryAsync(Container container, ShellInt } } + // Stopping early without a token leaves results the caller can never retrieve, + // whether the limit or cancellation ended the loop. + returnState.IncompleteWithoutContinuation = !continuationSupported && (limitReached || feedIterator.HasMoreResults); + if (limitReached && effectiveMaxItemCount.HasValue) { AnsiConsole.MarkupLine(MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", effectiveMaxItemCount.Value } })); + if (!continuationSupported) + { + AnsiConsole.MarkupLine(MessageService.GetString("command-query-no_continuation")); + } } returnState.RequestCharge = totalRequestCharge; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs index a5ad0fa2..701e3784 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs @@ -75,10 +75,19 @@ public OutputFormat OutputFormat internal bool IsPage { get; set; } /// - /// Gets or sets the token for retrieving the next page, or when the result is exhausted. + /// Gets or sets the token for retrieving the next page. A token + /// indicates exhaustion only when is false; + /// otherwise the result is truncated and cannot be resumed. /// internal string? ContinuationToken { get; set; } + /// + /// Gets or sets a value indicating whether the result stops short of the full result set and + /// cannot be resumed. Some Cosmos query pipelines complete successfully but never export a + /// continuation token, so a missing token alone must not be read as an exhausted result set. + /// + internal bool IncompleteWithoutContinuation { get; set; } + /// /// Gets or sets the Cosmos DB request charge (in RUs) consumed by the command, when applicable. /// Data-plane commands set this so consumers such as the MCP structured payload can report cost uniformly. diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs index 955acff7..d634a4ff 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs @@ -115,6 +115,11 @@ private static JsonObject CreateSuccessPayload(CommandState commandState) payload["continuationToken"] = commandState.ContinuationToken; } + if (commandState.IncompleteWithoutContinuation) + { + payload["resultIncomplete"] = true; + } + if (commandState.OutputFormat == OutputFormat.CSV) { var outputText = commandState.GenerateOutputText(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs index 3336f747..9d5f8a71 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs @@ -301,7 +301,7 @@ internal static bool TrySetContinuation(object command, string argumentName, Jso { var description = option.GetDescription(command.CommandName); return IsPagedMaxOption(command, option) - ? $"{description} Through MCP this must be positive and bounds a single page rather than the whole result set. Omitted or non-positive values use the default of {DefaultPageSize}. A call can return fewer items and still have more available; use continuationToken to detect the end." + ? $"{description} Through MCP this must be positive and bounds a single page rather than the whole result set. Omitted or non-positive values use the default of {DefaultPageSize}. A call can return fewer items and still have more available; a null continuationToken marks the end of the results unless the response sets resultIncomplete, which reports results that were cut off and cannot be resumed." : description; } diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 92b86425..291e43a6 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -268,6 +268,7 @@ command-query-description-database = The database to query against command-query-description-container = The container to query against command-query-description-explain = Show the query execution plan (index usage and a plain-language evaluation) instead of returning documents command-query-fetched = Fetched { $count } documents. +command-query-no_continuation = This query cannot be resumed: its execution plan does not return a continuation token, so the remaining results were not retrieved. Raise the limit with --max, use --max 0 for no limit, or narrow the query. command-query-request_charge = Request Charge: { $charge } RUs command-query-document_header = Document command-query-count_header = Count diff --git a/README.md b/README.md index 5ad63169..394aa051 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Packaging runs produce preview versions in the form `1.0.-preview.` | `--theme ` | Color theme profile to apply at startup (`default`, `light`, `dark`, `monochrome`). Falls back to `COSMOSDB_SHELL_THEME`. | | `--help` | Show help | -MCP `query` and container-item `ls` calls return bounded, resumable pages. They default to at most 100 items and include a continuation token for the next call; see [MCP pagination](docs/mcp.md#pagination). +MCP `query` and container-item `ls` calls return bounded pages. They default to at most 100 items and include a continuation token for the next call. Some query plans cannot be resumed and report `resultIncomplete` instead of a token; see [MCP pagination](docs/mcp.md#pagination). Examples: diff --git a/docs/commands.md b/docs/commands.md index 495261c7..dd6a4769 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -392,7 +392,9 @@ Options: `query` does not apply a default item limit. Use `--max ` to cap returned items when needed, or `--max 0` to disable the limit explicitly. -When called through MCP, `query` and container-item `ls` return one page at a time. `max` must be positive; omitted or non-positive values use the safe default of `100` items. The MCP result includes `continuationToken`; pass a non-null value back as the tool's `continuation` argument, with the same query and options, to retrieve the next page. A null token means there are no more pages. `continuation` is an MCP-only argument and is deliberately not a shell option, so interactive and scripted commands are unaffected and retain their existing multi-page behavior. +When called through MCP, `query` and container-item `ls` return one page at a time. `max` must be positive; omitted or non-positive values use the safe default of `100` items. The MCP result includes `continuationToken`; pass a non-null value back as the tool's `continuation` argument, with the same query and options, to retrieve the next page. A null token means there are no more pages, unless the result also sets `resultIncomplete`, which marks a truncated result that cannot be resumed. `continuation` is an MCP-only argument and is deliberately not a shell option, so interactive and scripted commands are unaffected and retain their existing multi-page behavior. + +Some query shapes execute normally but cannot be resumed: vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections whose execution plan cannot export a token never produce a continuation token. Whether a `DISTINCT` query is resumable is decided by its plan, not by the presence of an `ORDER BY` clause: `SELECT DISTINCT c.category FROM c ORDER BY c.category` is not resumable, while the scalar `SELECT DISTINCT VALUE c.category FROM c ORDER BY c.category` is. Such queries run to completion within the requested limit instead of failing. Because they cannot be paged, MCP calls keep reading the same query until the limit is reached rather than returning after one page. If the limit truncates the results, the shell reports that they cannot be resumed and the MCP result sets `resultIncomplete` to `true`; raise `--max`, use `--max 0` for no limit, or narrow the query to get the full result set. #### Explain a query diff --git a/docs/mcp.md b/docs/mcp.md index 8f8d1b62..6da729a0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -135,7 +135,8 @@ Both representations are always byte-for-byte equivalent. | ----- | ------------ | ----------- | | `result` | Commands that produce output | The command result as JSON (objects, arrays, or a scalar). Text-only results are represented as a JSON string. Failed transactional batches include their per-operation summary here alongside `error`. | | `outputText` | CSV output commands with non-empty text | The CSV rendering of the result. Omitted when the CSV output is empty or whitespace. | -| `continuationToken` | Paged `query` and container-item `ls` results | Opaque token for the next page, or `null` when no more results are available. Omitted for `query --explain` and database/container name listings, which are not paged. | +| `continuationToken` | Paged `query` and container-item `ls` results | Opaque token for the next page, or `null` when no more results are available — unless `resultIncomplete` is `true`, where a `null` token accompanies a truncated result that cannot be resumed. Omitted for `query --explain` and database/container name listings, which are not paged. | +| `resultIncomplete` | Truncated results that cannot be resumed | `true` when the result stops at the requested limit and the query cannot produce a continuation token. Omitted otherwise. | | `requestCharge` | Charged data-plane command results | The Cosmos DB request charge (in RUs) consumed by the command, as a number. This is omitted for commands that do not issue a billable request. | | `error` | Failed commands | The error message. | | `currentLocation` | Always | The shell's current navigation path (for example `/MyDatabase/MyContainer`), or `null` when disconnected. | @@ -157,11 +158,13 @@ session object also includes `session.requestChargeWarningThreshold`. ### Pagination -MCP calls to `query` and container-item `ls` return one Cosmos DB page per call. `max` must be positive; when it is omitted or non-positive, the server applies a safe default cap of `100` items. To continue, pass a non-null returned `continuationToken` as the next call's `continuation` argument while keeping the query and other options unchanged. The token is opaque; do not parse or edit it. When the token is `null`, the result set is exhausted: stop paging and do not send another request with `continuation`. +MCP calls to `query` and container-item `ls` return one Cosmos DB page per call. `max` must be positive; when it is omitted or non-positive, the server applies a safe default cap of `100` items. To continue, pass a non-null returned `continuationToken` as the next call's `continuation` argument while keeping the query and other options unchanged. The token is opaque; do not parse or edit it. When the token is `null` and the response does not set `resultIncomplete`, the result set is exhausted: stop paging and do not send another request with `continuation`. `continuation` is exposed only to MCP callers — there is no corresponding shell option, and the token is never echoed into the shell's command output. -Because `max` bounds a single page, a call can return fewer items than requested and still have more available; treat a non-null `continuationToken` as the only signal that more results exist. For `ls`, the `result.limitReached` flag reports that same condition and is kept for parity with shell and script output. +Because `max` bounds a single page, a call can return fewer items than requested and still have more available; while `resultIncomplete` is absent or `false`, treat a non-null `continuationToken` as the only signal that more results exist. For `ls`, the `result.limitReached` flag reports that same condition and is kept for parity with shell and script output. `ls` always produces resumable pages, so its results never set `resultIncomplete`. + +Some queries cannot be paged at all. Vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections whose plan cannot export a token execute normally but never return a continuation token. For `DISTINCT` this is decided by the query plan rather than by the SQL text: `SELECT DISTINCT c.category FROM c ORDER BY c.category` is not resumable even though it has an `ORDER BY`, while the scalar `SELECT DISTINCT VALUE c.category FROM c ORDER BY c.category` is. For those queries the server keeps reading until `max` is reached and returns a `null` token. If the limit truncated the results, the response also sets `resultIncomplete` to `true`; a `null` token combined with `resultIncomplete` means the result set was **not** exhausted and cannot be resumed. Retry with a larger `max` or a narrower query instead of sending a `continuation`. ```json { diff --git a/l10n/CosmosDBShell.json b/l10n/CosmosDBShell.json index 05904bc7..2e1942aa 100644 --- a/l10n/CosmosDBShell.json +++ b/l10n/CosmosDBShell.json @@ -647,6 +647,7 @@ "command-query-index_metrics": "Index Utilization Metric", "command-query-index_score": "Index Impact Score", "command-query-index_spec": "Index Spec", + "command-query-no_continuation": "This query cannot be resumed: its execution plan does not return a continuation token, so the remaining results were not retrieved. Raise the limit with --max, use --max 0 for no limit, or narrow the query.", "command-query-output": "Output", "command-query-query_preparation": "Query preparation", "command-query-request_charge": "Request Charge: {0} RUs",