diff --git a/CHANGELOG.md b/CHANGELOG.md index b00a434..641bb32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ ### Fixes +- `mkdb`, `mkcon`, `create database`, and `create container` now work on serverless accounts. They previously requested autoscale throughput even when `--scale` and `--ru` were omitted, which serverless accounts reject. Omitting both options now creates the resource without throughput settings; supplying either option on a serverless account fails with an explanation. Provisioned accounts keep the existing autoscale default of 1000 RU/s. ([#218](https://github.com/Azure/CosmosDBShell/issues/218)) - 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)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c8a156..bec53a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -156,6 +156,14 @@ You can develop and test without an Azure subscription by using the [Azure Cosmo dotnet run --project CosmosDBShell/CosmosDBShell.csproj -- --connect "https://localhost:8081" ``` +### Live Serverless Smoke Test + +The emulator cannot reproduce serverless behavior. To verify database and container creation against a real serverless account, set `COSMOSDB_SHELL_SERVERLESS_TEST_CONNECTION_STRING` to that account's connection string and run the `LiveServerless` test category. The test creates and then deletes a uniquely named database; without the variable, it is skipped. + +```bash +dotnet test CosmosDBShell.Tests/CosmosDBShell.Tests.csproj --filter "Category=LiveServerless" +``` + ## Submitting a Pull Request 1. Fork the repository and create a feature branch from `main`. diff --git a/CosmosDBShell.Tests/CommandTests/ServerlessCreationThroughputTests.cs b/CosmosDBShell.Tests/CommandTests/ServerlessCreationThroughputTests.cs new file mode 100644 index 0000000..ae2ec7b --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/ServerlessCreationThroughputTests.cs @@ -0,0 +1,349 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using System.Net; +using Azure; +using Azure.Core; +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; +using Azure.ResourceManager; +using Azure.ResourceManager.CosmosDB; +using Azure.ResourceManager.CosmosDB.Models; +using Microsoft.Azure.Cosmos; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +public class ServerlessCreationThroughputTests +{ + private const string ServerlessMessage = "Setting offer throughput or autopilot on container is not supported for serverless accounts."; + + [Fact] + public void CreateUpdateConfig_ServerlessWithoutOptions_OmitsThroughput() + { + Assert.Null(CosmosArmResourceProvider.CreateUpdateConfig(ServerlessByCapability(), null, null)); + Assert.Null(CosmosArmResourceProvider.CreateUpdateConfig(ServerlessByCapacityMode(), null, null)); + } + + [Theory] + [InlineData("manual", null)] + [InlineData("auto", null)] + [InlineData(" ", null)] + [InlineData("", null)] + [InlineData(null, 400)] + [InlineData("m", 1000)] + public void CreateUpdateConfig_ServerlessWithOptions_Throws(string? scale, int? ru) + { + Assert.Throws( + () => CosmosArmResourceProvider.CreateUpdateConfig(ServerlessByCapability(), scale, ru)); + } + + [Theory] + [InlineData(null, null, null, 1000)] + [InlineData("auto", 4000, null, 4000)] + [InlineData("manual", 400, 400, null)] + [InlineData("m", null, 1000, null)] + public void CreateUpdateConfig_Provisioned_KeepsExistingDefaults(string? scale, int? ru, int? manual, int? autoscale) + { + var config = CosmosArmResourceProvider.CreateUpdateConfig(Provisioned(), scale, ru); + + Assert.NotNull(config); + Assert.Equal(manual, config!.Throughput); + Assert.Equal(autoscale, config.AutoscaleMaxThroughput); + } + + [Fact] + public async Task ArmCreateDatabase_Serverless_SendsPayloadWithoutThroughput() + { + var (context, databases) = CreateArmContext(ServerlessByCapability()); + CosmosDBSqlDatabaseCreateOrUpdateContent? sent = null; + databases.CreateOrUpdateAsync(WaitUntil.Completed, "db", Arg.Do(c => sent = c), Arg.Any()) + .Returns(Substitute.For>()); + + await CosmosArmResourceProvider.CreateDatabaseAsync(context, "db", null, null, TestContext.Current.CancellationToken); + + var json = CosmosArmResourceProvider.WriteArmModel(Assert.IsType(sent)); + Assert.DoesNotContain("throughput", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("autoscaleSettings", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ArmCreateDatabase_ServerlessWithOptions_ThrowsBeforeSending() + { + var (context, databases) = CreateArmContext(ServerlessByCapacityMode()); + + await Assert.ThrowsAsync( + () => CosmosArmResourceProvider.CreateDatabaseAsync(context, "db", "manual", 400, TestContext.Current.CancellationToken)); + await databases.DidNotReceiveWithAnyArgs().CreateOrUpdateAsync(default, default!, default!, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ArmCreateDatabase_Provisioned_SendsDefaultAutoscale() + { + var (context, databases) = CreateArmContext(Provisioned()); + CosmosDBSqlDatabaseCreateOrUpdateContent? sent = null; + databases.CreateOrUpdateAsync(WaitUntil.Completed, "db", Arg.Do(c => sent = c), Arg.Any()) + .Returns(Substitute.For>()); + + await CosmosArmResourceProvider.CreateDatabaseAsync(context, "db", null, null, TestContext.Current.CancellationToken); + + Assert.Equal(1000, sent!.Options.AutoscaleMaxThroughput); + Assert.Contains("autoscaleSettings", CosmosArmResourceProvider.WriteArmModel(sent), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ArmCreateContainer_Serverless_SendsPayloadWithoutThroughput() + { + var (context, containers) = CreateArmContainerContext(ServerlessByCapacityMode()); + CosmosDBSqlContainerCreateOrUpdateContent? sent = null; + containers.CreateOrUpdateAsync(WaitUntil.Completed, "Items", Arg.Do(c => sent = c), Arg.Any()) + .Returns(Substitute.For>()); + + await CosmosArmResourceProvider.CreateContainerAsync( + context, "db", "Items", ["/tenantId", "/userId"], "/email", null, null, null, TestContext.Current.CancellationToken); + + var json = CosmosArmResourceProvider.WriteArmModel(Assert.IsType(sent)); + Assert.DoesNotContain("throughput", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("autoscaleSettings", json, StringComparison.OrdinalIgnoreCase); + Assert.Contains("/tenantId", json); + Assert.Contains("/userId", json); + Assert.Contains("/email", json); + } + + [Fact] + public async Task ArmCreateContainer_ServerlessWithOptions_ThrowsBeforeSending() + { + var (context, containers) = CreateArmContainerContext(ServerlessByCapability()); + + await Assert.ThrowsAsync( + () => CosmosArmResourceProvider.CreateContainerAsync( + context, "db", "Items", ["/pk"], null, null, "auto", 1000, TestContext.Current.CancellationToken)); + + await containers.DidNotReceiveWithAnyArgs().CreateOrUpdateAsync(default, default!, default!, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Fallback_ServerlessRejectionWithoutOptions_RetriesWithoutThroughput() + { + var sent = new List(); + + var result = await DataPlaneCosmosResourceOperations.CreateWithServerlessFallbackAsync(null, null, throughput => + { + sent.Add(throughput); + return sent.Count == 1 ? throw ServerlessRejection() : Task.FromResult("created"); + }); + + Assert.Equal("created", result); + Assert.Equal(2, sent.Count); + Assert.Equal(1000, sent[0]!.AutoscaleMaxThroughput); + Assert.Null(sent[1]); + } + + [Theory] + [InlineData("manual", 400)] + [InlineData("auto", null)] + [InlineData(" ", null)] + [InlineData(null, 1000)] + public async Task Fallback_ServerlessRejectionWithOptions_ThrowsWithoutRetrying(string? scale, int? ru) + { + var calls = 0; + + var ex = await Assert.ThrowsAsync(() => + DataPlaneCosmosResourceOperations.CreateWithServerlessFallbackAsync(scale, ru, _ => + { + calls++; + throw ServerlessRejection(); + })); + + Assert.Equal(1, calls); + Assert.IsType(ex.InnerException); + } + + public static TheoryData UnrelatedFailures => new() + { + new CosmosException("Request rate is large", HttpStatusCode.BadRequest, 0, "a", 0), + new CosmosException("The serverless account policy rejected the request.", HttpStatusCode.BadRequest, 0, "a", 0), + new CosmosException("serverless principal is not authorized", HttpStatusCode.Forbidden, 0, "a", 0), + new CosmosException("serverless token invalid", HttpStatusCode.Unauthorized, 0, "a", 0), + new OperationCanceledException(), + }; + + [Theory] + [MemberData(nameof(UnrelatedFailures))] + public async Task Fallback_UnrelatedFailure_PropagatesWithoutRetrying(Exception failure) + { + var calls = 0; + + var thrown = await Assert.ThrowsAnyAsync(() => + DataPlaneCosmosResourceOperations.CreateWithServerlessFallbackAsync(null, null, _ => + { + calls++; + throw failure; + })); + + Assert.Same(failure, thrown); + Assert.Equal(1, calls); + } + + [Fact] + public async Task Mkcon_ServerlessDataPlane_RetriesWithSameContainerDefinition() + { + using var shell = ShellInterpreter.CreateInstance(); + var (client, database) = CreateDataPlaneClient(); + var sentThroughput = new List(); + var sentProperties = new List(); + var created = Substitute.For(); + created.Container.Id.Returns("Items"); + database.CreateContainerIfNotExistsAsync( + Arg.Do(sentProperties.Add), + Arg.Do(sentThroughput.Add), + Arg.Any(), + Arg.Any()) + .Returns(_ => Task.FromException(ServerlessRejection()), _ => Task.FromResult(created)); + shell.State = new DatabaseState("db", client); + + var result = await new MakeContainerCommand + { + Name = "Items", + PartitionKey = "/tenantId,/userId", + UniqueKey = "/email", + IndexPolicy = "{\"indexingMode\":\"consistent\"}", + }.ExecuteAsync(shell, new CommandState(), "mkcon", TestContext.Current.CancellationToken); + + Assert.Equal("Items", ((ShellJson)result.Result!).Value.GetProperty("id").GetString()); + Assert.Equal(1000, sentThroughput[0]!.AutoscaleMaxThroughput); + Assert.Null(sentThroughput[1]); + Assert.Same(sentProperties[0], sentProperties[1]); + Assert.Equal(["/tenantId", "/userId"], sentProperties[1].PartitionKeyPaths); + Assert.Equal("/email", Assert.Single(Assert.Single(sentProperties[1].UniqueKeyPolicy.UniqueKeys).Paths)); + Assert.Equal(IndexingMode.Consistent, sentProperties[1].IndexingPolicy.IndexingMode); + } + + [Theory] + [InlineData("mkdb")] + [InlineData("create")] + public async Task CreateDatabase_ServerlessDataPlane_RetriesWithoutThroughput(string entryPoint) + { + using var shell = ShellInterpreter.CreateInstance(); + var (client, _) = CreateDataPlaneClient(); + var sent = new List(); + var created = Substitute.For(); + created.Database.Id.Returns("NewDb"); + client.CreateDatabaseIfNotExistsAsync("NewDb", Arg.Do(sent.Add), Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromException(ServerlessRejection()), _ => Task.FromResult(created)); + shell.State = new ConnectedState(client); + + CosmosCommand command = entryPoint == "mkdb" + ? new MakeDbCommand { Name = "NewDb" } + : new CreateCommand { Item = "db", Name = "NewDb" }; + await command.ExecuteAsync(shell, new CommandState(), entryPoint, TestContext.Current.CancellationToken); + + Assert.Equal(2, sent.Count); + Assert.Null(sent[1]); + } + + [Fact] + public async Task Mkcon_ServerlessWithExplicitRu_ReportsActionableError() + { + using var shell = ShellInterpreter.CreateInstance(); + var (client, database) = CreateDataPlaneClient(); + database.CreateContainerIfNotExistsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(ServerlessRejection()); + shell.State = new DatabaseState("db", client); + + var ex = await Assert.ThrowsAsync(() => new CreateCommand { Item = "container", Name = "Items", PartitionKey = "/pk", MaxRU = 400 } + .ExecuteAsync(shell, new CommandState(), "create container", TestContext.Current.CancellationToken)); + + Assert.Equal(MessageService.GetString("error-serverless_throughput_not_supported"), ex.Message); + await database.ReceivedWithAnyArgs(1).CreateContainerIfNotExistsAsync(default(ContainerProperties)!, default(ThroughputProperties?), default, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Mkdb_ProvisionedSharedThroughput_SendsRequestedManualThroughputOnce() + { + using var shell = ShellInterpreter.CreateInstance(); + var (client, _) = CreateDataPlaneClient(); + var sent = new List(); + var created = Substitute.For(); + created.Database.Id.Returns("Shared"); + client.CreateDatabaseIfNotExistsAsync("Shared", Arg.Do(sent.Add), Arg.Any(), Arg.Any()) + .Returns(created); + shell.State = new ConnectedState(client); + + await new MakeDbCommand { Name = "Shared", Scale = "manual", MaxRU = 400 } + .ExecuteAsync(shell, new CommandState(), "mkdb", TestContext.Current.CancellationToken); + + Assert.Equal(400, Assert.Single(sent)!.Throughput); + } + + [Fact] + public async Task Mkcon_ProvisionedAccount_KeepsDefaultAutoscale() + { + using var shell = ShellInterpreter.CreateInstance(); + var (client, database) = CreateDataPlaneClient(); + var sent = new List(); + var created = Substitute.For(); + created.Container.Id.Returns("Items"); + database.CreateContainerIfNotExistsAsync(Arg.Any(), Arg.Do(sent.Add), Arg.Any(), Arg.Any()) + .Returns(created); + shell.State = new DatabaseState("db", client); + + await new MakeContainerCommand { Name = "Items", PartitionKey = "/pk" } + .ExecuteAsync(shell, new CommandState(), "mkcon", TestContext.Current.CancellationToken); + + Assert.Equal(1000, Assert.Single(sent)!.AutoscaleMaxThroughput); + } + + private static CosmosException ServerlessRejection() => new(ServerlessMessage, HttpStatusCode.BadRequest, 0, "a", 1.5); + + private static (CosmosClient Client, Database Database) CreateDataPlaneClient() + { + var client = Substitute.For(); + var database = Substitute.For(); + var existing = Substitute.For(); + existing.StatusCode.Returns(HttpStatusCode.OK); + database.ReadAsync(Arg.Any(), Arg.Any()).Returns(existing); + client.GetDatabase(Arg.Any()).Returns(database); + return (client, database); + } + + private static (ArmCosmosContext Context, CosmosDBSqlDatabaseCollection Databases) CreateArmContext(CosmosDBAccountData data) + { + var account = Substitute.For(); + account.Data.Returns(data); + var databases = Substitute.For(); + account.GetCosmosDBSqlDatabases().Returns(databases); + var id = new ResourceIdentifier("/subscriptions/s/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/a"); + return (new ArmCosmosContext(Substitute.For(), id, "s", "rg", "a", new Uri("https://a.documents.azure.com"), account), databases); + } + + private static (ArmCosmosContext Context, CosmosDBSqlContainerCollection Containers) CreateArmContainerContext(CosmosDBAccountData data) + { + var (context, databases) = CreateArmContext(data); + var database = Substitute.For(); + databases.GetIfExistsAsync("db", Arg.Any()) + .Returns(Task.FromResult>( + Response.FromValue(database, Substitute.For()))); + var containers = Substitute.For(); + database.GetCosmosDBSqlContainers().Returns(containers); + return (context, containers); + } + + private static CosmosDBAccountData ServerlessByCapability() => ArmCosmosDBModelFactory.CosmosDBAccountData( + location: AzureLocation.WestUS, + capabilities: [new CosmosDBAccountCapability { Name = "EnableServerless" }]); + + private static CosmosDBAccountData ServerlessByCapacityMode() => ArmCosmosDBModelFactory.CosmosDBAccountData( + location: AzureLocation.WestUS, + capacityMode: CapacityMode.Serverless); + + private static CosmosDBAccountData Provisioned() => ArmCosmosDBModelFactory.CosmosDBAccountData( + location: AzureLocation.WestUS, + capabilities: [new CosmosDBAccountCapability { Name = "EnableServerlessLike" }], + capacityMode: CapacityMode.Provisioned); +} diff --git a/CosmosDBShell.Tests/Integration/ServerlessCreationSmokeTests.cs b/CosmosDBShell.Tests/Integration/ServerlessCreationSmokeTests.cs new file mode 100644 index 0000000..122c56b --- /dev/null +++ b/CosmosDBShell.Tests/Integration/ServerlessCreationSmokeTests.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Integration; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Util; + +// Opt-in: the emulator cannot establish serverless behavior, so this needs a real serverless account. +[Trait("Category", "LiveServerless")] +public class ServerlessCreationSmokeTests : IntegrationTestBase +{ + internal const string ConnectionStringVariable = "COSMOSDB_SHELL_SERVERLESS_TEST_CONNECTION_STRING"; + + [Fact] + public async Task MkdbAndMkcon_WithoutThroughputOptions_SucceedOnServerlessAccount() + { + var connectionString = Environment.GetEnvironmentVariable(ConnectionStringVariable); + Assert.SkipWhen(string.IsNullOrWhiteSpace(connectionString), $"Set {ConnectionStringVariable} to a serverless account connection string to run this test."); + + await Shell.ConnectAsync(connectionString!, null, token: TestContext.Current.CancellationToken); + var databaseName = $"shell-serverless-{Guid.NewGuid():N}"; + try + { + var database = await RunScriptAsync($"mkdb {databaseName}"); + Assert.False(database is ErrorCommandState, FormatError(database)); + + var container = await RunScriptAsync($"mkcon Items /pk --database {databaseName}"); + Assert.False(container is ErrorCommandState, FormatError(container)); + + var rejected = await RunScriptAsync($"mkcon Rejected /pk --database {databaseName} --ru 400"); + Assert.Equal(MessageService.GetString("error-serverless_throughput_not_supported"), GetErrorMessage(rejected)); + } + finally + { + await RunScriptAsync($"rmdb {databaseName} true"); + } + } +} diff --git a/CosmosDBShell.Tests/UtilTest/ThroughputErrorsTests.cs b/CosmosDBShell.Tests/UtilTest/ThroughputErrorsTests.cs index 046780c..1a08ef8 100644 --- a/CosmosDBShell.Tests/UtilTest/ThroughputErrorsTests.cs +++ b/CosmosDBShell.Tests/UtilTest/ThroughputErrorsTests.cs @@ -32,4 +32,23 @@ public void NullMessage_IsNotDetected() { Assert.False(ThroughputErrors.IsServerlessThroughputError(null)); } + + [Theory] + [InlineData("Setting offer throughput or autopilot on container is not supported for serverless accounts.")] + [InlineData("Setting offer throughput or autopilot on database is not supported for serverless accounts.")] + [InlineData("Provisioned THROUGHPUT is not supported for SERVERLESS accounts.")] + public void CreationThroughputRejection_IsDetected(string message) + { + Assert.True(ThroughputErrors.IsServerlessCreationThroughputError(message)); + } + + [Theory] + [InlineData("The serverless account policy rejected the request.")] + [InlineData("Reading or replacing offers is not supported for serverless accounts.")] + [InlineData("Setting throughput is not supported for this account.")] + [InlineData(null)] + public void UnrelatedCreationFailure_IsNotDetected(string? message) + { + Assert.False(ThroughputErrors.IsServerlessCreationThroughputError(message)); + } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs index 989136b..ef6a007 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs @@ -167,6 +167,10 @@ private async Task CreateContainerInDatabaseAsync(ConnectedState s { throw new CommandException("mkcon", MessageService.GetString("command-mkcon-error_invalid_index_policy"), ex); } + catch (ServerlessThroughputNotSupportedException ex) + { + throw new CommandException("mkcon", MessageService.GetString("error-serverless_throughput_not_supported"), ex); + } CosmosCompleteCommand.ClearContainers(); var commandState = new CommandState(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs index 481321b..c972f37 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs @@ -27,13 +27,7 @@ internal class MakeDbCommand : CosmosCommand, IStateVisitor ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) @@ -48,7 +42,16 @@ Task IStateVisitor.VisitDisconnect async Task IStateVisitor.VisitConnectedStateAsync(ConnectedState state, ShellInterpreter shell, CancellationToken token) { - var databaseName = await CosmosResourceFacade.CreateDatabaseAsync(state, this.Name ?? string.Empty, this.Scale, this.MaxRU, token); + string databaseName; + try + { + databaseName = await CosmosResourceFacade.CreateDatabaseAsync(state, this.Name ?? string.Empty, this.Scale, this.MaxRU, token); + } + catch (ServerlessThroughputNotSupportedException ex) + { + throw new CommandException("mkdb", MessageService.GetString("error-serverless_throughput_not_supported"), ex); + } + CosmosCompleteCommand.ClearDatabases(); var commandState = new CommandState(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosArmResourceProvider.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosArmResourceProvider.cs index 04b438e..1b6d98a 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosArmResourceProvider.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosArmResourceProvider.cs @@ -222,7 +222,7 @@ public static async Task CreateDatabaseAsync(ArmCos context.Account.Data.Location, new CosmosDBSqlDatabaseResourceInfo(databaseName)) { - Options = CreateUpdateConfig(scale, maxRu), + Options = CreateUpdateConfig(context.Account.Data, scale, maxRu), }; var operation = await context.Account.GetCosmosDBSqlDatabases().CreateOrUpdateAsync(WaitUntil.Completed, databaseName, content, token); @@ -273,7 +273,7 @@ public static async Task CreateContainerAsync( var content = new CosmosDBSqlContainerCreateOrUpdateContent(context.Account.Data.Location, resource) { - Options = CreateUpdateConfig(scale, maxRu), + Options = CreateUpdateConfig(context.Account.Data, scale, maxRu), }; var operation = await database.GetCosmosDBSqlContainers().CreateOrUpdateAsync(WaitUntil.Completed, containerName, content, token); @@ -297,11 +297,17 @@ public static IReadOnlyList GetPartitionKeyPaths(CosmosDBSqlContainerRes return container.Data.Resource.PartitionKey?.Paths?.ToArray() ?? []; } - public static CosmosDBCreateUpdateConfig CreateUpdateConfig(string? scale, int? maxRu) + public static CosmosDBCreateUpdateConfig? CreateUpdateConfig(CosmosDBAccountData account, string? scale, int? maxRu) { - var ru = maxRu ?? 1000; - if (string.Equals(scale, "manual", StringComparison.InvariantCultureIgnoreCase) || - string.Equals(scale, "m", StringComparison.InvariantCultureIgnoreCase)) + if (IsServerless(account)) + { + return CreationThroughput.IsSpecified(scale, maxRu) + ? throw new ServerlessThroughputNotSupportedException() + : null; + } + + var ru = maxRu ?? CreationThroughput.DefaultMaxRu; + if (CreationThroughput.IsManual(scale)) { return new CosmosDBCreateUpdateConfig { @@ -315,6 +321,12 @@ public static CosmosDBCreateUpdateConfig CreateUpdateConfig(string? scale, int? }; } + internal static bool IsServerless(CosmosDBAccountData account) + { + return account.CapacityMode == CapacityMode.Serverless + || account.Capabilities.Any(capability => string.Equals(capability.Name, "EnableServerless", StringComparison.OrdinalIgnoreCase)); + } + public static string WriteArmModel(T model) where T : IPersistableModel { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CreationThroughput.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CreationThroughput.cs new file mode 100644 index 0000000..da6744c --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CreationThroughput.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +using Microsoft.Azure.Cosmos; + +/// +/// Resolves the provisioned throughput requested by mkdb and mkcon. +/// +internal static class CreationThroughput +{ + internal const int DefaultMaxRu = 1000; + + internal static bool IsSpecified(string? scale, int? maxRu) => scale is not null || maxRu.HasValue; + + internal static bool IsManual(string? scale) => + string.Equals(scale, "manual", StringComparison.OrdinalIgnoreCase) || + string.Equals(scale, "m", StringComparison.OrdinalIgnoreCase); + + internal static ThroughputProperties CreateProperties(string? scale, int? maxRu) + { + var ru = maxRu ?? DefaultMaxRu; + return IsManual(scale) + ? ThroughputProperties.CreateManualThroughput(ru) + : ThroughputProperties.CreateAutoscaleThroughput(ru); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DataPlaneCosmosResourceOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DataPlaneCosmosResourceOperations.cs index c7beaeb..effa8cd 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DataPlaneCosmosResourceOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/DataPlaneCosmosResourceOperations.cs @@ -73,8 +73,10 @@ public async Task ContainerExistsAsync(string databaseName, string contain public async Task CreateDatabaseAsync(string databaseName, string? scale, int? maxRu, CancellationToken token) { - var throughput = CreateThroughputProperties(scale, maxRu); - var response = await client.CreateDatabaseIfNotExistsAsync(databaseName, throughput, cancellationToken: token); + var response = await CreateWithServerlessFallbackAsync( + scale, + maxRu, + throughput => client.CreateDatabaseIfNotExistsAsync(databaseName, throughput, cancellationToken: token)); RequestChargeContext.Record(response.RequestCharge); return response.Database.Id; } @@ -109,9 +111,11 @@ public async Task CreateContainerAsync( props.IndexingPolicy = ParseIndexingPolicy(indexPolicyJson); } - var throughput = CreateThroughputProperties(scale, maxRu); var database = client.GetDatabase(databaseName); - var response = await database.CreateContainerIfNotExistsAsync(props, throughput, cancellationToken: token); + var response = await CreateWithServerlessFallbackAsync( + scale, + maxRu, + throughput => database.CreateContainerIfNotExistsAsync(props, throughput, cancellationToken: token)); RequestChargeContext.Record(response.RequestCharge); return response.Container.Id; } @@ -452,15 +456,22 @@ private static ConflictResolutionPolicy BuildConflictResolutionPolicy(ConflictRe private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; - private static ThroughputProperties CreateThroughputProperties(string? scale, int? maxRu) + // The data plane cannot read the capacity mode, so serverless is detected from the service's rejection. + internal static async Task CreateWithServerlessFallbackAsync(string? scale, int? maxRu, Func> create) { - var ru = maxRu ?? 1000; - if (string.Equals(scale, "manual", StringComparison.OrdinalIgnoreCase) || - string.Equals(scale, "m", StringComparison.OrdinalIgnoreCase)) + try { - return ThroughputProperties.CreateManualThroughput(ru); + return await create(CreationThroughput.CreateProperties(scale, maxRu)); } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.BadRequest && ThroughputErrors.IsServerlessCreationThroughputError(ex.Message)) + { + if (CreationThroughput.IsSpecified(scale, maxRu)) + { + throw new ServerlessThroughputNotSupportedException(ex); + } - return ThroughputProperties.CreateAutoscaleThroughput(ru); + RequestChargeContext.Record(ex.RequestCharge); + return await create(null); + } } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ServerlessThroughputNotSupportedException.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ServerlessThroughputNotSupportedException.cs new file mode 100644 index 0000000..019a502 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ServerlessThroughputNotSupportedException.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ +namespace Azure.Data.Cosmos.Shell.Core; + +/// +/// Thrown when --scale or --ru is supplied while creating a database or +/// container on a serverless account, which cannot have provisioned throughput. +/// +internal sealed class ServerlessThroughputNotSupportedException : System.InvalidOperationException +{ + public ServerlessThroughputNotSupportedException(System.Exception? innerException = null) + : base("Provisioned throughput is not supported on a serverless account.", innerException) + { + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ThroughputErrors.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ThroughputErrors.cs index 187e190..7c96ac8 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ThroughputErrors.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ThroughputErrors.cs @@ -11,4 +11,13 @@ internal static bool IsServerlessThroughputError(string? message) return message is not null && message.Contains("serverless", StringComparison.OrdinalIgnoreCase); } + + // Narrower than IsServerlessThroughputError: creation retries without throughput, so an + // unrelated serverless failure must not be retried with a different request. + internal static bool IsServerlessCreationThroughputError(string? message) + { + return IsServerlessThroughputError(message) + && (message!.Contains("throughput", StringComparison.OrdinalIgnoreCase) + || message.Contains("autopilot", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 75b83ed..41224f5 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -192,6 +192,7 @@ error-startup-navigation-requires-connect = Startup navigation options require ' error-shell-not-initialized = Shell is not initialized error-unable_to_read_container = Unable to read container. error-arm-context-required = Database and container resource operations require Azure Resource Manager context. Reconnect with Entra ID and provide --subscription and --resource-group, or use --connect-subscription and --connect-resource-group at startup. Alternatively, use an identity that can discover the Cosmos DB account through ARM. +error-serverless_throughput_not_supported = Serverless accounts do not support provisioned throughput. Omit --scale and --ru to create the resource without throughput settings. error-arm-context-incomplete = Provide subscription and resource group together to use an explicit Azure Resource Manager account context. The account name is inferred from the endpoint. error-arm-context-ambiguous = Multiple Cosmos DB Azure Resource Manager accounts match the connected endpoint. Reconnect and provide subscription and resource group explicitly. error-arm-context-endpoint-mismatch = The Azure Resource Manager account endpoint '{ $armEndpoint }' does not match the connected Cosmos DB endpoint '{ $dataPlaneEndpoint }'. Reconnect with the subscription and resource group that own the connected account. @@ -503,8 +504,8 @@ command-import-error-csv_pk_conflict = CSV column '{ $column }' conflicts with t command-mkdb-description = Creates new database command-mkdb-description-name = The database name to create -command-mkdb-description-scale = Container scale (manual or auto) -command-mkdb-description-ru = Container Max RU/s (default: 1000) +command-mkdb-description-scale = Database throughput mode (manual or auto). Not supported on serverless accounts. +command-mkdb-description-ru = Database max RU/s (default: 1000; omitted on serverless accounts) command-mkdb-database_created = Created database { $db } command-mkdb-error-only_auto_or_manual_allowed = Only manual or autoscale are allowed. Not both. @@ -512,8 +513,8 @@ command-mkcon-description = Creates a new container in the current database. command-mkcon-description-name = The container to create. command-mkcon-description-partition_key = The partition key path(s) for the container. Use a single path (e.g. /categoryId) or comma-separated paths for hierarchical partition keys (e.g. /tenantId,/userId or /tenantId,/userId,/sessionId). command-mkcon-description-unique_key = The unique keys for the container to create. -command-mkcon-description-scale = Container scale (manual or auto) -command-mkcon-description-ru = Database Max RU/s (default: 1000) +command-mkcon-description-scale = Container throughput mode (manual or auto). Not supported on serverless accounts. +command-mkcon-description-ru = Container max RU/s (default: 1000; omitted on serverless accounts) command-mkcon-description-database = The database where the container should be created command-mkcon-CreatedContainer = Created container { $container } command-mkcon-error_partition_key_empty = Partition key path cannot be empty. Provide a path that starts with '/', for example: mkcon name /pk. @@ -837,8 +838,8 @@ command-create-description-item = The object to create item, container or databa command-create-description-name = The container or database name to create. command-create-description-partition_key = { command-mkcon-description-partition_key } command-create-description-unique_key = { command-mkcon-description-unique_key } -command-create-description-scale = { command-mkcon-description-scale } -command-create-description-ru = Database or Container Max RU/s (default: 1000) +command-create-description-scale = Database or container throughput mode (manual or auto). Not supported on serverless accounts. +command-create-description-ru = Database or container max RU/s (default: 1000; omitted on serverless accounts) command-create-description-data = JSON data for the item to create command-create-description-database = The database for the create operation command-create-description-container = The container for creating items diff --git a/README.md b/README.md index 719a134..98176ba 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A terminal-native shell for Azure Cosmos DB — navigate databases like a filesy - Inspect the connected identity with `whoami`, and probe data-plane access with `can-i` (both support `--format` table/json/csv) - Diagnose environment and connectivity with read-only `doctor` checks, versioned JSON reports, and optional query and ARM probes; use `doctor who` for known identity and access context ([troubleshooting](docs/commands.md#doctor)) - Create, query, replace, patch, delete: `mkdb`, `mkcon`, `mkitem`, `query`, `replace`, `patch`, `rm` +- Create databases and containers on serverless accounts with `mkdb` and `mkcon` without specifying `--scale` or `--ru` ([command details](docs/commands.md#mkdb)) - Inspect a query's execution plan and index usage with `query "" --explain` - Atomic multi-operation transactions on a single partition key: `batch` - Bulk roundtrip with `import` / `export` for JSON Lines and JSON array files, plus CSV import/export (CSV import coerces values to strings; `--partition-key` nests a CSV column under a nested partition key path) diff --git a/docs/commands.md b/docs/commands.md index dd6a476..a8365e5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -804,29 +804,41 @@ Database and container management commands prefer Azure Resource Manager when an Create database. ```text -Usage: mkdb name +Usage: mkdb name [options] Arguments: name The database name to create + +Options: + --scale Throughput mode: manual (m) or auto (default) + --ru Max RU/s (default: 1000) ``` +On provisioned accounts, omitting both options creates shared autoscale throughput with a maximum of 1000 RU/s. On serverless accounts, omit both options: the database is created without throughput settings, and supplying `--scale` or `--ru` fails with an explanation instead of sending a request the service rejects. + ### mkcon Create container. ```text -Usage: mkcon name partition_key [unique_key] +Usage: mkcon name partition_key [unique_key] [options] Arguments: name The container to create partition_key The partition key path. For hierarchical partition keys, use comma-separated paths such as /tenantId,/userId,/sessionId [unique_key] Unique key paths (Optional) +Options: + --scale Throughput mode: manual (m) or auto (default) + --ru Max RU/s (default: 1000) + Examples: mkcon Products /categoryId mkcon Orders /customerId,/orderId ``` +Throughput defaults and serverless handling match `mkdb`: serverless containers are created without throughput settings, and explicit `--scale` or `--ru` options are rejected. `create database` and `create container` behave the same way. + ### rmdb Remove database. diff --git a/docs/mcp.md b/docs/mcp.md index 94126d2..f3c726a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -89,6 +89,8 @@ The MCP confirmation applies even when a command is invoked with a force / no-pr Database and container resource actions are executed through Azure Resource Manager when an ARM context is attached (Entra ID connections). MCP sessions connected with account keys, emulator credentials, or static data-plane tokens fall back to the Cosmos DB data plane for these actions. +On serverless accounts, `mkdb`, `mkcon`, and their `create` aliases omit throughput when neither `--scale` nor `--ru` is supplied. Explicit throughput options are rejected on serverless accounts for both ARM and data-plane connections. See [database and container creation](commands.md#mkdb). + For deterministic ARM routing in multi-subscription environments, start the shell with `--connect-subscription` and `--connect-resource-group`. ### Data Exposure diff --git a/l10n/CosmosDBShell.json b/l10n/CosmosDBShell.json index e60f4e9..c2e1cfa 100644 --- a/l10n/CosmosDBShell.json +++ b/l10n/CosmosDBShell.json @@ -205,8 +205,8 @@ "command-create-description-item": "The object to create item, container or database.", "command-create-description-name": "The container or database name to create.", "command-create-description-partition_key": "{0}", - "command-create-description-ru": "Database or Container Max RU/s (default: 1000)", - "command-create-description-scale": "{0}", + "command-create-description-ru": "Database or container max RU/s (default: 1000; omitted on serverless accounts)", + "command-create-description-scale": "Database or container throughput mode (manual or auto). Not supported on serverless accounts.", "command-create-description-unique_key": "{0}", "command-create-error-container_name_required": "Create container requires a container name.", "command-create-error-database_name_required": "Create database requires a database name.", @@ -515,8 +515,8 @@ "command-mkcon-description-index_policy": "The indexing policy as a JSON string. Follows the Cosmos DB indexing policy schema.", "command-mkcon-description-name": "The container to create.", "command-mkcon-description-partition_key": "The partition key path(s) for the container. Use a single path (e.g. /categoryId) or comma-separated paths for hierarchical partition keys (e.g. /tenantId,/userId or /tenantId,/userId,/sessionId).", - "command-mkcon-description-ru": "Database Max RU/s (default: 1000)", - "command-mkcon-description-scale": "Container scale (manual or auto)", + "command-mkcon-description-ru": "Container max RU/s (default: 1000; omitted on serverless accounts)", + "command-mkcon-description-scale": "Container throughput mode (manual or auto). Not supported on serverless accounts.", "command-mkcon-description-unique_key": "The unique keys for the container to create.", "command-mkcon-error_invalid_index_policy": "Invalid indexing policy JSON. Please provide a valid Cosmos DB indexing policy.", "command-mkcon-error_partition_key_empty": "Partition key path cannot be empty. Provide a path that starts with \u0027/\u0027, for example: mkcon name /pk.", @@ -529,8 +529,8 @@ "command-mkdb-database_created": "Created database {0}", "command-mkdb-description": "Creates new database", "command-mkdb-description-name": "The database name to create", - "command-mkdb-description-ru": "Container Max RU/s (default: 1000)", - "command-mkdb-description-scale": "Container scale (manual or auto)", + "command-mkdb-description-ru": "Database max RU/s (default: 1000; omitted on serverless accounts)", + "command-mkdb-description-scale": "Database throughput mode (manual or auto). Not supported on serverless accounts.", "command-mkdb-error-only_auto_or_manual_allowed": "Only manual or autoscale are allowed. Not both.", "command-mkdb-example-1": "Create a new database with default settings", "command-mkdb-example-2": "Create database with autoscale and 4000 RU/s maximum throughput", @@ -1190,6 +1190,7 @@ "error-not_inside_database": "Not inside a database.", "error-param_parse": "Invalid value for \u0027{0}\u0027 valid values: {1}", "error-request_timeout": "The request timed out while communicating with Azure Cosmos DB. Check your network connection and try again. Run with --verbose to show full diagnostics.", + "error-serverless_throughput_not_supported": "Serverless accounts do not support provisioned throughput. Omit --scale and --ru to create the resource without throughput settings.", "error-session-request-charge-warning-threshold-invalid": "Variable \u0027$sessionRequestChargeWarningThreshold\u0027 must be a non-negative number. Set it to 0 to disable the warning.", "error-session-variable-read-only": "Variable \u0027${0}\u0027 is read-only.", "error-shell-not-initialized": "Shell is not initialized",