Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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))
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
446 changes: 446 additions & 0 deletions CosmosDBShell.Tests/CommandTests/ServerlessCreationThroughputTests.cs

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions CosmosDBShell.Tests/Integration/ServerlessCreationSmokeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// ------------------------------------------------------------
// 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
{
using var cleanupTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var cleanup = await Shell.ExecuteCommandAsync($"rmdb {databaseName} true", cleanupTimeout.Token);
Assert.False(cleanup is ErrorCommandState, $"Cleanup of {databaseName} failed: {FormatError(cleanup)}");
}
}
}
19 changes: 19 additions & 0 deletions CosmosDBShell.Tests/UtilTest/ThroughputErrorsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ private async Task<CommandState> 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();
Expand Down
19 changes: 11 additions & 8 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,7 @@ internal class MakeDbCommand : CosmosCommand, IStateVisitor<CommandState, ShellI

public static ThroughputProperties CreateThroughputProperties(string? scale, int? maxru)
{
var ru = maxru ?? 1000;
if (string.Equals(scale, "manual", StringComparison.InvariantCultureIgnoreCase) || string.Equals(scale, "m", StringComparison.InvariantCultureIgnoreCase))
{
return ThroughputProperties.CreateManualThroughput(ru);
}

return ThroughputProperties.CreateAutoscaleThroughput(ru);
return CreationThroughput.CreateProperties(scale, maxru);
}

public async override Task<CommandState> ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token)
Expand All @@ -48,7 +42,16 @@ Task<CommandState> IStateVisitor<CommandState, ShellInterpreter>.VisitDisconnect

async Task<CommandState> IStateVisitor<CommandState, ShellInterpreter>.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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ public static async Task<CosmosDBSqlDatabaseResource> 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);
Expand Down Expand Up @@ -273,7 +273,7 @@ public static async Task<CosmosDBSqlContainerResource> 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);
Expand All @@ -297,11 +297,17 @@ public static IReadOnlyList<string> 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
{
Expand All @@ -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>(T model)
where T : IPersistableModel<T>
{
Expand Down
29 changes: 29 additions & 0 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CreationThroughput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace Azure.Data.Cosmos.Shell.Core;

using Microsoft.Azure.Cosmos;

/// <summary>
/// Resolves the provisioned throughput requested by <c>mkdb</c> and <c>mkcon</c>.
/// </summary>
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,17 @@ public async Task<bool> ContainerExistsAsync(string databaseName, string contain

public async Task<string> 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);
if (response.StatusCode == HttpStatusCode.OK && CreationThroughput.IsSpecified(scale, maxRu))
{
await ValidateExistingResourceThroughputAsync(
() => response.Database.ReadThroughputAsync(new RequestOptions(), token));
}

return response.Database.Id;
}

Expand Down Expand Up @@ -109,10 +117,18 @@ public async Task<string> 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);
if (response.StatusCode == HttpStatusCode.OK && CreationThroughput.IsSpecified(scale, maxRu))
{
await ValidateExistingResourceThroughputAsync(
() => response.Container.ReadThroughputAsync(new RequestOptions(), token));
}

return response.Container.Id;
}

Expand Down Expand Up @@ -452,15 +468,40 @@ private static ConflictResolutionPolicy BuildConflictResolutionPolicy(ConflictRe

private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;

private static ThroughputProperties CreateThroughputProperties(string? scale, int? maxRu)
private static async Task ValidateExistingResourceThroughputAsync(Func<Task<ThroughputResponse>> readThroughput)
{
try
{
var response = await readThroughput();
RequestChargeContext.Record(response.RequestCharge);
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.BadRequest && ThroughputErrors.IsServerlessThroughputError(ex.Message))
{
throw new ServerlessThroughputNotSupportedException(ex);
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Provisioned resources without dedicated throughput have no offer to read.
RequestChargeContext.Record(ex.RequestCharge);
}
}

// The data plane cannot read the capacity mode, so serverless is detected from the service's rejection.
internal static async Task<T> CreateWithServerlessFallbackAsync<T>(string? scale, int? maxRu, Func<ThroughputProperties?, Task<T>> 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));
Comment thread
mkrueger marked this conversation as resolved.
}
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------
namespace Azure.Data.Cosmos.Shell.Core;

/// <summary>
/// Thrown when <c>--scale</c> or <c>--ru</c> is supplied while creating a database or
/// container on a serverless account, which cannot have provisioned throughput.
/// </summary>
internal sealed class ServerlessThroughputNotSupportedException : System.InvalidOperationException
{
public ServerlessThroughputNotSupportedException(System.Exception? innerException = null)
: base("Provisioned throughput is not supported on a serverless account.", innerException)
{
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Loading
Loading