.NET: CosmosDB Actor State Storage (#262)

* Implement CosmosDB actor state storage.

* Fix.

* Minor fixes.

* Fixes.

* Make CosmosDB initialization be lazy.

* Remove unnecessary read from write path.

* Throw on empty writes.

* Add arg validation for read.

* Add CosmosIdSanitizer.

* Fix.

* Fix.

* Simplify doc IDs.

* Update comment.

* fb

* Make LazyCosmosContainer internal and add tests.

* Make test constants public and remove IVT.

* Use source generated JSON context for future nativeAOT support.

* Re-add dropped comments.
This commit is contained in:
Aditya Mandaleeka
2025-08-05 00:11:49 +00:00
committed by GitHub
parent 32f1132a5d
commit f5b35d8403
27 changed files with 2528 additions and 6 deletions
@@ -0,0 +1,82 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
/// <summary>
/// Root document for each actor that provides actor-level ETag semantics.
/// Every write operation updates this document to ensure a single ETag represents
/// the entire actor's state for optimistic concurrency control.
/// This document contains no actor state data. It only serves to track last modified
/// time and provide a single ETag for the actor's state.
///
/// Example structure:
/// {
/// "id": "rootdoc", // Root document ID (constant per actor partition)
/// "actorId": "actor-123", // Partition key (actor ID)
/// "lastModified": "2024-...", // Timestamp
/// }
/// </summary>
public sealed class ActorRootDocument
{
/// <summary>
/// The document ID.
/// </summary>
public string Id { get; set; } = default!;
/// <summary>
/// The actor ID.
/// </summary>
public string ActorId { get; set; } = default!;
/// <summary>
/// The last modified timestamp.
/// </summary>
public DateTimeOffset LastModified { get; set; }
}
/// <summary>
/// Actor state document that represents a single key-value pair in the actor's state.
/// Document Structure (one per actor key):
/// {
/// "id": "state_sanitizedkey", // Unique document ID for the state entry
/// "actorId": "actor-123", // Partition key (actor ID)
/// "key": "foo", // Logical key for the state entry
/// "value": { "bar": 42, "baz": "hello" } // Arbitrary JsonElement payload
/// }
/// </summary>
public sealed class ActorStateDocument
{
/// <summary>
/// The document ID.
/// </summary>
public string Id { get; set; } = default!;
/// <summary>
/// The actor ID.
/// </summary>
public string ActorId { get; set; } = default!;
/// <summary>
/// The logical key for the state entry.
/// </summary>
public string Key { get; set; } = default!;
/// <summary>
/// The value payload.
/// </summary>
public JsonElement Value { get; set; } = default!;
}
/// <summary>
/// Projection class for Cosmos DB queries to retrieve keys.
/// </summary>
public sealed class KeyProjection
{
/// <summary>
/// The key value.
/// </summary>
public string Key { get; set; } = default!;
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
/// <summary>
/// Source-generated JSON type information for Cosmos DB actor state documents.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ActorStateDocument))]
[JsonSerializable(typeof(ActorRootDocument))]
[JsonSerializable(typeof(KeyProjection))]
internal sealed partial class CosmosActorStateJsonContext : JsonSerializerContext;
@@ -0,0 +1,237 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Cosmos;
namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
/// <summary>
/// Cosmos DB implementation of actor state storage.
/// </summary>
public class CosmosActorStateStorage : IActorStateStorage
{
private readonly LazyCosmosContainer _lazyContainer;
private const string InitialEtag = "0"; // Initial ETag value when no state exists
/// <summary>
/// Constructs a new instance of <see cref="CosmosActorStateStorage"/> with the specified Cosmos DB container.
/// </summary>
/// <param name="container">The Cosmos DB container to use for storage.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="container"/> is null.</exception>
public CosmosActorStateStorage(Container container) => this._lazyContainer = new LazyCosmosContainer(container);
/// <summary>
/// This constructor is used by dependency injection to create an instance of <see cref="CosmosActorStateStorage"/>
/// with a lazy-loaded Cosmos container whose initialization is deferred until first access.
/// </summary>
/// <param name="lazyContainer">The lazy-loaded Cosmos container.</param>
/// <throws cref="ArgumentNullException">Thrown when <paramref name="lazyContainer"/> is null.</throws>
internal CosmosActorStateStorage(LazyCosmosContainer lazyContainer) =>
this._lazyContainer = lazyContainer ?? throw new ArgumentNullException(nameof(lazyContainer));
/// <summary>
/// Writes state changes to the actor's persistent storage.
/// </summary>
public async ValueTask<WriteResponse> WriteStateAsync(
ActorId actorId,
IReadOnlyCollection<ActorStateWriteOperation> operations,
string etag,
CancellationToken cancellationToken = default)
{
if (operations.Count == 0)
{
throw new InvalidOperationException("No operations provided for write. At least one operation is required.");
}
var container = await this._lazyContainer.GetContainerAsync().ConfigureAwait(false);
var batch = container.CreateTransactionalBatch(GetPartitionKey(actorId));
var actorIdStr = actorId.ToString();
// Add data operations to batch
foreach (var op in operations)
{
switch (op)
{
case SetValueOperation set:
var docId = GetDocumentId(set.Key);
var item = new ActorStateDocument
{
Id = docId,
ActorId = actorIdStr,
Key = set.Key,
Value = set.Value
};
batch.UpsertItem(item);
break;
case RemoveKeyOperation remove:
var docToRemove = GetDocumentId(remove.Key);
batch.DeleteItem(docToRemove);
break;
default:
throw new ArgumentException($"Unsupported write operation: {op.GetType().Name}");
}
}
// Add root document update to batch
var newRoot = new ActorRootDocument
{
Id = RootDocumentId,
ActorId = actorId.ToString(),
LastModified = DateTimeOffset.UtcNow,
};
if (string.IsNullOrEmpty(etag) || etag == InitialEtag)
{
// No eTag provided or initial eTag - create new root document (will fail if it already exists)
batch.CreateItem(newRoot);
}
else
{
// eTag provided - replace existing root document with eTag check
batch.ReplaceItem(RootDocumentId, newRoot, new TransactionalBatchItemRequestOptions { IfMatchEtag = etag });
}
try
{
var result = await batch.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (!result.IsSuccessStatusCode)
{
return new WriteResponse(eTag: string.Empty, success: false);
}
// Get the ETag from the root document operation (last operation in batch)
var rootResult = result[result.Count - 1];
return new WriteResponse(eTag: rootResult.ETag, success: true);
}
catch (CosmosException)
{
// If any operation in the batch fails, we return failure
return new WriteResponse(eTag: string.Empty, success: false);
}
}
/// <summary>
/// Reads state data from the actor's persistent storage.
/// </summary>
public async ValueTask<ReadResponse> ReadStateAsync(
ActorId actorId,
IReadOnlyCollection<ActorStateReadOperation> operations,
CancellationToken cancellationToken = default)
{
if (operations.Count == 0)
{
throw new InvalidOperationException("No operations provided for read. At least one operation is required.");
}
var container = await this._lazyContainer.GetContainerAsync().ConfigureAwait(false);
var results = new List<ActorReadResult>();
// Read root document first to get actor-level ETag
string actorETag = await this.GetActorETagAsync(container, actorId, cancellationToken).ConfigureAwait(false);
foreach (var op in operations)
{
switch (op)
{
case GetValueOperation get:
var id = GetDocumentId(get.Key);
try
{
var response = await container.ReadItemAsync<ActorStateDocument>(
id,
GetPartitionKey(actorId),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
results.Add(new GetValueResult(response.Resource.Value));
}
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
results.Add(new GetValueResult(null));
}
break;
case ListKeysOperation list:
QueryDefinition query;
if (!string.IsNullOrEmpty(list.KeyPrefix))
{
query = new QueryDefinition("SELECT c.key FROM c WHERE c.actorId = @actorId AND c.key != null AND STARTSWITH(c.key, @keyPrefix)")
.WithParameter("@actorId", actorId.ToString())
.WithParameter("@keyPrefix", list.KeyPrefix);
}
else
{
query = new QueryDefinition("SELECT c.key FROM c WHERE c.actorId = @actorId AND c.key != null")
.WithParameter("@actorId", actorId.ToString());
}
var requestOptions = new QueryRequestOptions
{
PartitionKey = GetPartitionKey(actorId),
MaxItemCount = -1 // Use dynamic page size
};
var iterator = container.GetItemQueryIterator<KeyProjection>(
query,
list.ContinuationToken,
requestOptions);
var keys = new List<string>();
string? continuationToken = null;
while (iterator.HasMoreResults)
{
var page = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
foreach (var projection in page)
{
keys.Add(projection.Key);
}
continuationToken = page.ContinuationToken;
}
results.Add(new ListKeysResult(keys, continuationToken));
break;
default:
throw new NotSupportedException($"Unsupported read operation: {op.GetType().Name}");
}
}
return new ReadResponse(actorETag, results);
}
private static string GetDocumentId(string key) => $"state_{CosmosIdSanitizer.Sanitize(key)}";
private const string RootDocumentId = "rootdoc";
private static PartitionKey GetPartitionKey(ActorId actorId)
=> new(actorId.ToString());
/// <summary>
/// Gets the current ETag for the actor's root document.
/// Returns a generated ETag if no root document exists.
/// </summary>
private async ValueTask<string> GetActorETagAsync(Container container, ActorId actorId, CancellationToken cancellationToken)
{
try
{
var rootResponse = await container.ReadItemAsync<ActorRootDocument>(
RootDocumentId,
GetPartitionKey(actorId),
cancellationToken: cancellationToken).ConfigureAwait(false);
return rootResponse.ETag;
}
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// No root document means no actor state exists
return Guid.NewGuid().ToString("N");
}
}
}
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
// CosmosIdSanitizer is used to sanitize Cosmos DB IDs by replacing characters that are
// not allowed in Cosmos DB IDs with a safe escape sequence. This implementation was
// inspired heavily by the one in Orleans, with some modifications made to enable
// targeting NET472.
internal static class CosmosIdSanitizer
{
private const char EscapeChar = '~';
public const char SeparatorChar = '_';
private static ReadOnlySpan<char> SanitizedCharacters => ['/', '\\', '?', '#', SeparatorChar, EscapeChar];
private static ReadOnlySpan<char> ReplacementCharacters => ['0', '1', '2', '3', '4', '5'];
public static string Sanitize(string input)
{
int extraChars = CountSanitizedCharacters(input.AsSpan());
if (extraChars == 0)
{
return input;
}
#if NET8_0_OR_GREATER
return string.Create(input.Length + extraChars, input, (output, state) =>
{
Encode(state.AsSpan(), output);
});
#else
var result = new char[input.Length + extraChars];
Encode(input.AsSpan(), result);
return new string(result, 0, input.Length + extraChars);
#endif
}
public static string Unsanitize(string input)
{
int escapeCount = CountEscapeCharacters(input.AsSpan());
if (escapeCount == 0)
{
return input;
}
#if NET8_0_OR_GREATER
return string.Create(input.Length - escapeCount, input, (output, state) =>
{
Decode(state.AsSpan(), output);
});
#else
var result = new char[input.Length - escapeCount];
Decode(input.AsSpan(), result);
return new string(result, 0, input.Length - escapeCount);
#endif
}
private static int CountSanitizedCharacters(ReadOnlySpan<char> input)
{
int count = 0;
foreach (var c in input)
{
if (SanitizedCharacters.IndexOf(c) >= 0)
{
count++;
}
}
return count;
}
private static int CountEscapeCharacters(ReadOnlySpan<char> input)
{
int count = 0;
foreach (var c in input)
{
if (c == EscapeChar)
{
count++;
}
}
return count;
}
private static void Encode(ReadOnlySpan<char> input, Span<char> output)
{
int j = 0;
foreach (var c in input)
{
int idx = SanitizedCharacters.IndexOf(c);
if (idx < 0)
{
output[j++] = c;
}
else
{
output[j++] = EscapeChar;
output[j++] = ReplacementCharacters[idx];
}
}
}
private static void Decode(ReadOnlySpan<char> input, Span<char> output)
{
int j = 0;
bool isEscaped = false;
foreach (var c in input)
{
if (isEscaped)
{
int idx = ReplacementCharacters.IndexOf(c);
if (idx < 0)
{
throw new ArgumentException("Input is not in a valid format: Encountered unsupported escape sequence");
}
output[j++] = SanitizedCharacters[idx];
isEscaped = false;
}
else if (c == EscapeChar)
{
isEscaped = true;
}
else
{
output[j++] = c;
}
}
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Cosmos;
namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
#pragma warning disable VSTHRD011 // Use AsyncLazy<T>
/// <summary>
/// A lazy wrapper around a Cosmos DB Container.
/// This avoids performing async I/O-bound operations (i.e. Cosmos DB setup) during
/// DI registration, deferring them until first access.
/// </summary>
internal sealed class LazyCosmosContainer
{
private readonly CosmosClient? _cosmosClient;
private readonly string? _databaseName;
private readonly string? _containerName;
private readonly Lazy<Task<Container>> _lazyContainer;
/// <summary>
/// LazyCosmosContainer constructor that initializes the container lazily.
/// </summary>
public LazyCosmosContainer(CosmosClient cosmosClient, string databaseName, string containerName)
{
this._cosmosClient = cosmosClient ?? throw new ArgumentNullException(nameof(cosmosClient));
this._databaseName = databaseName ?? throw new ArgumentNullException(nameof(databaseName));
this._containerName = containerName ?? throw new ArgumentNullException(nameof(containerName));
this._lazyContainer = new Lazy<Task<Container>>(this.InitializeContainerAsync, LazyThreadSafetyMode.ExecutionAndPublication);
}
/// <summary>
/// LazyCosmosContainer constructor that accepts an existing Container instance.
/// </summary>
public LazyCosmosContainer(Container container)
{
if (container is null)
{
throw new ArgumentNullException(nameof(container));
}
this._lazyContainer = new Lazy<Task<Container>>(() => Task.FromResult(container), LazyThreadSafetyMode.ExecutionAndPublication);
}
/// <summary>
/// Gets the Container, initializing it if necessary.
/// </summary>
public Task<Container> GetContainerAsync() => this._lazyContainer.Value;
private async Task<Container> InitializeContainerAsync()
{
// Create database if it doesn't exist
var database = await this._cosmosClient!.CreateDatabaseIfNotExistsAsync(this._databaseName!).ConfigureAwait(false);
var containerProperties = new ContainerProperties(this._containerName!, "/actorId")
{
Id = this._containerName!,
IndexingPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent,
Automatic = true
},
PartitionKeyPaths = ["/actorId"]
};
// Add composite index for efficient queries
containerProperties.IndexingPolicy.CompositeIndexes.Add(new Collection<CompositePath>
{
new() { Path = "/actorId", Order = CompositePathSortOrder.Ascending },
new() { Path = "/key", Order = CompositePathSortOrder.Ascending }
});
var container = await database.Database.CreateContainerIfNotExistsAsync(containerProperties).ConfigureAwait(false);
return container.Container;
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);IDE1006;IDE0130</NoWarn>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Cosmos" />
<PackageReference Include="Newtonsoft.Json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Microsoft.Extensions.AI.Agents.Runtime.Abstractions/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
#pragma warning disable VSTHRD002
/// <summary>
/// Extension methods for configuring Cosmos DB actor state storage in dependency injection.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Cosmos DB actor state storage to the service collection.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <param name="connectionString">The Cosmos DB connection string.</param>
/// <param name="databaseName">The database name to use for actor state storage.</param>
/// <param name="containerName">The container name to use for actor state storage. Defaults to "ActorState".</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddCosmosActorStateStorage(
this IServiceCollection services,
string connectionString,
string databaseName,
string containerName = "ActorState")
{
// Register CosmosClient as singleton
services.AddSingleton<CosmosClient>(serviceProvider =>
{
var cosmosClientOptions = new CosmosClientOptions
{
ApplicationName = "AgentFramework",
ConnectionMode = ConnectionMode.Direct,
ConsistencyLevel = ConsistencyLevel.Session,
UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = CosmosActorStateJsonContext.Default
}
};
return new CosmosClient(connectionString, cosmosClientOptions);
});
// Register LazyCosmosContainer as singleton
services.AddSingleton<LazyCosmosContainer>(serviceProvider =>
{
var cosmosClient = serviceProvider.GetRequiredService<CosmosClient>();
return new LazyCosmosContainer(cosmosClient, databaseName, containerName);
});
// Register the storage implementation
services.AddSingleton<IActorStateStorage>(serviceProvider =>
{
var lazyContainer = serviceProvider.GetRequiredService<LazyCosmosContainer>();
return new CosmosActorStateStorage(lazyContainer);
});
return services;
}
/// <summary>
/// Adds Cosmos DB actor state storage to the service collection using an existing CosmosClient from DI.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <param name="databaseName">The database name to use for actor state storage.</param>
/// <param name="containerName">The container name to use for actor state storage. Defaults to "ActorState".</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddCosmosActorStateStorage(
this IServiceCollection services,
string databaseName,
string containerName = "ActorState")
{
// Register LazyCosmosContainer as singleton using existing CosmosClient
services.AddSingleton<LazyCosmosContainer>(serviceProvider =>
{
var cosmosClient = serviceProvider.GetRequiredService<CosmosClient>();
return new LazyCosmosContainer(cosmosClient, databaseName, containerName);
});
// Register the storage implementation
services.AddSingleton<IActorStateStorage>(serviceProvider =>
{
var lazyContainer = serviceProvider.GetRequiredService<LazyCosmosContainer>();
return new CosmosActorStateStorage(lazyContainer);
});
return services;
}
}
@@ -239,11 +239,23 @@ internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDispos
// TODO: Turn send & update message operations into storage writes to outbox
var result = await this.Storage.WriteStateAsync(
this.ActorId,
[.. operations.Operations.OfType<ActorStateWriteOperation>()],
operations.ETag,
cancellationToken).ConfigureAwait(false);
IReadOnlyCollection<ActorStateWriteOperation> writeOps =
[.. operations.Operations.OfType<ActorStateWriteOperation>()];
WriteResponse result;
if (writeOps.Count == 0)
{
// Nothing to write
result = new WriteResponse(operations.ETag, success: true);
}
else
{
result = await this.Storage.WriteStateAsync(
this.ActorId,
writeOps,
operations.ETag,
cancellationToken).ConfigureAwait(false);
}
Log.WriteOperationCompleted(this._logger, this.ActorId.ToString(), result.Success);