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
55 changes: 55 additions & 0 deletions ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,61 @@ public static IServiceCollection AddKeyedChromaClient(this IServiceCollection se
return services;
}

/// <summary>
/// Registers a <c>ChromaClient</c> and its options as singletons, with options that <c>configurationOptions</c> makes from the
/// services when the client or the options are first resolved, like from <c>IConfiguration</c> or <c>IOptions</c> of the
/// application, also registered after this call. <c>configureHttpClient</c> configures the <c>HttpClient</c> of the client, like a
/// resilience handler, a proxy or a timeout. Returns the services, for more registrations.
/// </summary>
/// <param name="services">The services.</param>
/// <param name="configurationOptions">Makes the options of the client from the services.</param>
/// <param name="configureHttpClient">Configures the <c>HttpClient</c> of the client; null for none.</param>
/// <returns>The services, for more registrations.</returns>
public static IServiceCollection AddChromaClientFromServices(this IServiceCollection services, Func<IServiceProvider, ChromaConfigurationOptions> configurationOptions, Action<IHttpClientBuilder>? configureHttpClient = null)
{
if (configurationOptions is null)
{
throw new ArgumentNullException(nameof(configurationOptions));
}
services.AddSingleton(serviceProvider => MadeOptions(configurationOptions, serviceProvider));
var httpClientBuilder = services.AddHttpClient(nameof(ChromaClient));
configureHttpClient?.Invoke(httpClientBuilder);
services.AddSingleton(serviceProvider => serviceProvider.CreateChromaClient(serviceProvider.GetRequiredService<ChromaConfigurationOptions>(), nameof(ChromaClient)));
return services;
}

/// <summary>
/// As <c>AddChromaClientFromServices</c>, for a client and its options under a key, for an application that talks to more than
/// one server, tenant or database. Returns the services, for more registrations.
/// </summary>
/// <param name="services">The services.</param>
/// <param name="serviceKey">The key of the client and of its options.</param>
/// <param name="configurationOptions">Makes the options of the client from the services.</param>
/// <param name="configureHttpClient">Configures the <c>HttpClient</c> of the client; null for none.</param>
/// <returns>The services, for more registrations.</returns>
public static IServiceCollection AddKeyedChromaClientFromServices(this IServiceCollection services, object serviceKey, Func<IServiceProvider, ChromaConfigurationOptions> configurationOptions, Action<IHttpClientBuilder>? configureHttpClient = null)
{
// A null key would register the client without a key, next to the one of AddChromaClient.
if (serviceKey is null)
{
throw new ArgumentNullException(nameof(serviceKey));
}
if (configurationOptions is null)
{
throw new ArgumentNullException(nameof(configurationOptions));
}
// The type with the text, so that keys like 1 and "1" get an HttpClient each, as in AddKeyedChromaClient.
var httpClientName = $"{nameof(ChromaClient)}:{serviceKey.GetType().FullName}:{serviceKey}";
services.AddKeyedSingleton(serviceKey, (serviceProvider, _) => MadeOptions(configurationOptions, serviceProvider));
var httpClientBuilder = services.AddHttpClient(httpClientName);
configureHttpClient?.Invoke(httpClientBuilder);
services.AddKeyedSingleton(serviceKey, (serviceProvider, key) => serviceProvider.CreateChromaClient(serviceProvider.GetRequiredKeyedService<ChromaConfigurationOptions>(key), httpClientName));
return services;
}

private static ChromaConfigurationOptions MadeOptions(Func<IServiceProvider, ChromaConfigurationOptions> configurationOptions, IServiceProvider serviceProvider)
=> configurationOptions(serviceProvider) ?? throw new InvalidOperationException("The function of the options of the ChromaClient returned null.");

/// <summary>
/// A <c>ChromaClient</c> to keep, like a singleton of an application or of an integration, that sends each request with the
/// current handler of <c>IHttpClientFactory</c> for <c>httpClientName</c>: the factory renews it after its handler lifetime, two
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<IsPackable>true</IsPackable>
<!-- The analyzers of trimming and NativeAOT run on the target that has them. -->
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
<VersionPrefix>2.17.1</VersionPrefix>
<VersionPrefix>2.18.0</VersionPrefix>
<PackageId>ChromaDB.Client.DependencyInjection</PackageId>
<!-- -p:PackageIdPrefix=Name publishes the package as Name.Client.DependencyInjection. -->
<PackageId Condition="'$(PackageIdPrefix)' != ''">$(PackageIdPrefix).Client.DependencyInjection</PackageId>
Expand Down
2 changes: 2 additions & 0 deletions ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddChromaClientFromServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Func<System.IServiceProvider!, ChromaDB.Client.ChromaConfigurationOptions!>! configurationOptions, System.Action<Microsoft.Extensions.DependencyInjection.IHttpClientBuilder!>? configureHttpClient = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddKeyedChromaClientFromServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, object! serviceKey, System.Func<System.IServiceProvider!, ChromaDB.Client.ChromaConfigurationOptions!>! configurationOptions, System.Action<Microsoft.Extensions.DependencyInjection.IHttpClientBuilder!>? configureHttpClient = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
218 changes: 215 additions & 3 deletions ChromaDB.Client.Tests/CollectionClientByNameTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,193 @@ public async Task RunsAgainFromTheRecordsGiven()
Assert.That(upserts[1].GetProperty("metadatas").ValueKind, Is.EqualTo(JsonValueKind.Null));
}

// GetCollectionClient(name, createIfMissing: true) sends nothing until the first operation, which gets or creates the collection;
// the next operations use it.
[Test]
public async Task CreatesTheCollectionAtTheFirstOperation()
{
var server = new FakeServer(First);
var collection = Client(server).GetCollectionClient("c", createIfMissing: true);
Assert.That(server.Requests, Is.Empty);
Assert.That(await collection.CountAsync(), Is.EqualTo(3));
Assert.That(await collection.CountAsync(), Is.EqualTo(3));
Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "POST collections", $"GET collections/{First}/count", $"GET collections/{First}/count" }));
Assert.That(server.Bodies.Single(x => x.Path == "collections").Body.GetProperty("get_or_create").GetBoolean(), Is.True);
Assert.That(collection.Collection.Id, Is.EqualTo(Guid.Parse(First)));
}

[Test]
public async Task WithoutCreateIfMissingItReadsTheCollection()
{
var server = new FakeServer(First);
await Client(server).GetCollectionClient("c", createIfMissing: false).CountAsync();
await Client(server).GetCollectionClient(new ChromaCollectionDefinition("c"), createIfMissing: false).CountAsync();
Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "GET collections/c", $"GET collections/{First}/count", "GET collections/c", $"GET collections/{First}/count" }));
}

// First operations that start together share one request of the collection.
[Test]
public async Task FirstOperationsShareTheRequest()
{
var server = new FakeServer(First) { CreateGate = new(TaskCreationOptions.RunContinuationsAsynchronously) };
var collection = Client(server).GetCollectionClient("c", createIfMissing: true);
var counts = Enumerable.Range(0, 8).Select(_ => collection.CountAsync()).ToList();
server.CreateGate.SetResult(true);
Assert.That(await Task.WhenAll(counts), Is.All.EqualTo(3));
Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(1));
}

// A first operation cancelled while the collection is asked for stops at once; the others wait for the same request.
[Test]
public async Task ACancelledFirstOperationLeavesTheOthers()
{
var server = new FakeServer(First) { CreateGate = new(TaskCreationOptions.RunContinuationsAsynchronously) };
var collection = Client(server).GetCollectionClient("c", createIfMissing: true);
using var cancellation = new CancellationTokenSource();
var cancelled = collection.CountAsync(cancellation.Token);
var other = collection.CountAsync();
cancellation.Cancel();
await Assert.ThatAsync(() => cancelled.WaitAsync(TimeSpan.FromSeconds(5)), Throws.InstanceOf<OperationCanceledException>());
server.CreateGate.SetResult(true);
Assert.That(await other, Is.EqualTo(3));
Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(1));
}

// An operation cancelled before it starts sends no request, also not the creation, and GetOrCreateCollectionClientAsync neither.
[Test]
public async Task AnOperationCancelledBeforeItStartsCreatesNothing()
{
var server = new FakeServer(First);
var client = Client(server);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThatAsync(() => client.GetCollectionClient("c", createIfMissing: true).CountAsync(cancellation.Token), Throws.InstanceOf<OperationCanceledException>());
await Assert.ThatAsync(() => client.GetOrCreateCollectionClientAsync("c", cancellation.Token), Throws.InstanceOf<OperationCanceledException>());
Assert.That(server.Requests, Is.Empty);
}

// Every operation waiting for the first read is cancelled, and the read then fails: the next operation sends a new request.
[Test]
public async Task AFailureAfterEveryWaiterIsCancelledIsNotKept()
{
var server = new FakeServer(First) { CreateGate = new(TaskCreationOptions.RunContinuationsAsynchronously), CreateFailures = 1 };
var collection = Client(server).GetCollectionClient("c", createIfMissing: true);
using var cancellation = new CancellationTokenSource();
var cancelled = collection.CountAsync(cancellation.Token);
var read = collection.FirstRead!;
cancellation.Cancel();
await Assert.ThatAsync(() => cancelled.WaitAsync(TimeSpan.FromSeconds(5)), Throws.InstanceOf<OperationCanceledException>());
server.CreateGate.SetResult(true);
await Assert.ThatAsync(() => read, Throws.InstanceOf<ChromaException>());
Assert.That(await collection.CountAsync(), Is.EqualTo(3));
Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(2));
}

// The collection a client of WithMetadataValues gets or creates is read with its own way of reading metadata values, as when it is
// read by name.
[Test]
public async Task TheCreationReadsWithTheMetadataValuesOfTheClient()
{
var server = new FakeServer(First) { CollectionMetadata = """{"date":"2026-10-04"}""" };
var inferred = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000").WithMetadataValues(ChromaMetadataValues.Inferred), new HttpClient(server));
var exact = inferred.GetCollectionClient("c", createIfMissing: true).WithMetadataValues(ChromaMetadataValues.Exact);
Assert.That((await exact.GetCollectionAsync()).Metadata!["date"], Is.EqualTo("2026-10-04"));
var byName = inferred.GetCollectionClient("c").WithMetadataValues(ChromaMetadataValues.Exact);
Assert.That((await byName.GetCollectionAsync()).Metadata!["date"], Is.EqualTo("2026-10-04"));
}

// A creation that fails throws in the operation, and the next operation asks again.
[Test]
public async Task AFailedCreationGoesAgainAtTheNextOperation()
{
var server = new FakeServer(First) { CreateFailures = 1 };
var collection = Client(server).GetCollectionClient("c", createIfMissing: true);
await Assert.ThatAsync(() => collection.CountAsync(), Throws.InstanceOf<ChromaException>());
Assert.That(await collection.CountAsync(), Is.EqualTo(3));
Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(2));
}

// A collection deleted elsewhere is created again, empty, with the same definition, and the operation runs again on it, once.
[Test]
public async Task ACollectionDeletedElsewhereIsCreatedAgain()
{
var server = new FakeServer(First);
var collection = Client(server).GetCollectionClient("c", createIfMissing: true);
await collection.CountAsync();
(server.Id, server.MissingId) = (Second, First);
Assert.That(await collection.CountAsync(), Is.EqualTo(3));
Assert.That(OnTheCollections(server).Skip(2), Is.EqualTo(new[] { $"GET collections/{First}/count", "POST collections", $"GET collections/{Second}/count" }));
Assert.That(collection.Collection.Id, Is.EqualTo(Guid.Parse(Second)));
}

// A write that went in part is not run again on the collection created again.
[Test]
public async Task APartialWriteIsNotRunAgainOnACollectionCreatedAgain()
{
var server = new FakeServer(First);
server.OnRequest = path =>
{
if (path.EndsWith("/add"))
{
(server.Id, server.MissingId) = (Second, First);
}
};
var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(1), new HttpClient(server));
var collection = client.GetCollectionClient("c", createIfMissing: true);
await collection.CountAsync();
await Assert.ThatAsync(() => collection.AddAsync(["a", "b"], [new([1f]), new([2f])]), Throws.InstanceOf<ChromaException>().With.Message.Contains("1 of the 2 records"));
Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(1));
}

// The clients that the With methods return create the collection too.
[Test]
public async Task TheWithMethodsKeepTheCreation()
{
var server = new FakeServer(First);
var collection = Client(server).GetCollectionClient("c", createIfMissing: true).WithMetadataValues(ChromaMetadataValues.Exact).WithDocumentCopyKey("text");
await collection.CountAsync();
Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "POST collections", $"GET collections/{First}/count" }));
}

// The definition goes with the creation.
[Test]
public async Task TheDefinitionGoesWithTheCreation()
{
var server = new FakeServer(First);
var collection = Client(server).GetCollectionClient(new ChromaCollectionDefinition("c") { Metadata = new Dictionary<string, object> { ["k"] = "v" } }, createIfMissing: true);
await collection.CountAsync();
Assert.That(server.Bodies.Single(x => x.Path == "collections").Body.GetProperty("metadata").GetProperty("k").GetString(), Is.EqualTo("v"));
}

// GetOrCreateCollectionClientAsync gets or creates the collection now, and its errors come from it; then the client works as the
// one of GetCollectionClient(name, createIfMissing: true).
[Test]
public async Task GetOrCreateCollectionClientAsyncCreatesNow()
{
var server = new FakeServer(First);
var client = Client(server);
var collection = await client.GetOrCreateCollectionClientAsync("c");
Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "POST collections" }));
Assert.That(collection.Collection.Id, Is.EqualTo(Guid.Parse(First)));
await collection.CountAsync();
var defined = await client.GetOrCreateCollectionClientAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary<string, object> { ["k"] = "v" } });
Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "POST collections", $"GET collections/{First}/count", "POST collections" }));
Assert.That(server.Bodies.Last(x => x.Path == "collections").Body.GetProperty("metadata").GetProperty("k").GetString(), Is.EqualTo("v"));
(server.Id, server.MissingId) = (Second, First);
Assert.That(await defined.CountAsync(), Is.EqualTo(3));
Assert.That(defined.Collection.Id, Is.EqualTo(Guid.Parse(Second)));
}

[Test]
public async Task GetOrCreateCollectionClientAsyncThrowsAtOnce()
{
var server = new FakeServer(First) { CreateFailures = 1 };
await Assert.ThatAsync(() => Client(server).GetOrCreateCollectionClientAsync("c"), Throws.InstanceOf<ChromaException>());
}

// The requests on the collections, without the ones on the server, like its version.
static IEnumerable<string> OnTheCollections(FakeServer server) => server.Requests.Where(x => !x.Contains("/api/"));

const string Bm25Schema = """{"defaults":{},"keys":{"doc_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":{"type":"known","name":"chroma_bm25","config":{}},"source_key":"#document"}}}}}}""";

static ChromaClient Client(HttpMessageHandler handler, ChromaReadRetries? retries = null)
Expand All @@ -176,19 +363,44 @@ sealed class FakeServer(string id) : HttpMessageHandler
public bool NameMissing { get; set; }
public string? Schema { get; set; }
public Action<string>? OnRequest { get; set; }
// get_or_create: answered when the gate opens, with an error for the first CreateFailures.
public TaskCompletionSource<bool>? CreateGate { get; init; }
public int CreateFailures { get; set; }
public string? CollectionMetadata { get; init; }
public List<string> Requests { get; } = [];
public List<(string Path, JsonElement Body)> Bodies { get; } = [];

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri!.AbsolutePath.Replace("/api/v2/tenants/default_tenant/databases/default_database/", "");
Requests.Add($"{request.Method} {path}");
// The first operations of a test can send their requests at the same time: List<T> takes one writer at a time.
lock (Requests)
{
Requests.Add($"{request.Method} {path}");
}
if (request.Content is not null)
{
Bodies.Add((path, JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone()));
var parsed = JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone();
lock (Bodies)
{
Bodies.Add((path, parsed));
}
}
if (path == "collections" && request.Method == HttpMethod.Post)
{
if (CreateGate is { } gate)
{
await gate.Task;
}
if (CreateFailures > 0)
{
CreateFailures--;
return new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("""{"error":"InternalError","message":"Error creating the collection"}""") };
}
path = "collections/c";
}
var (status, body) = path == "collections/c" && NameMissing ? (HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [c] does not exist"}""")
: path == "collections/c" ? (HttpStatusCode.OK, $$$"""{"id":"{{{Id}}}","name":"c","configuration_json":{"hnsw":{"space":"l2"}},"schema":{{{Schema ?? "null"}}}}""")
: path == "collections/c" ? (HttpStatusCode.OK, $$$"""{"id":"{{{Id}}}","name":"c","configuration_json":{"hnsw":{"space":"l2"}},"metadata":{{{CollectionMetadata ?? "null"}}},"schema":{{{Schema ?? "null"}}}}""")
: MissingId is { } missing && path.Contains(missing) ? MissingAnswer
: path.EndsWith("/count") ? (HttpStatusCode.OK, "3")
: path.EndsWith("/version") ? (HttpStatusCode.OK, "\"1.5.9\"")
Expand Down
Loading
Loading