mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
@@ -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!;
|
||||
}
|
||||
+19
@@ -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;
|
||||
+237
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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>
|
||||
+93
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user