From 1f425ea27710e420623c0e67f788cfee4b0260ce Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 11:42:53 +0200 Subject: [PATCH 01/11] Make ACancellationBetweenTheAttempts deterministic with a fixed jitter The test cancels 200 ms after the first attempt fails and expects the delay before the second one to be longer. With the full jitter the delay of up to 10 minutes can be under 200 ms, about once in 3000 runs: the second attempt then goes first and the read succeeds, which is right, and the test failed. ChromaReadRetries gets an internal Jitter that the tests can set; the test sets it to 1, so the delay is 10 minutes and the cancellation always comes during it. The client and its API do not change. --- ChromaDB.Client.Tests/ReadRetriesTests.cs | 5 +++-- ChromaDB.Client/ChromaCollectionClient.cs | 4 ++-- ChromaDB.Client/ChromaReadRetries.cs | 4 ++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/ChromaDB.Client.Tests/ReadRetriesTests.cs b/ChromaDB.Client.Tests/ReadRetriesTests.cs index 7bbc6b2..62a4274 100644 --- a/ChromaDB.Client.Tests/ReadRetriesTests.cs +++ b/ChromaDB.Client.Tests/ReadRetriesTests.cs @@ -194,13 +194,14 @@ public void NoNull() Assert.That(() => new ChromaConfigurationOptions(Local).WithReadRetries(null!), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("readRetries")); } - // A cancellation during the delay after a failed attempt stops the read at once: no other attempt goes. + // A cancellation during the delay after a failed attempt stops the read at once: no other attempt goes. The jitter is 1, so the delay + // is 10 minutes: with a random one, a delay under the 200 ms of the cancellation would let the second attempt go first. [Test] public void ACancellationBetweenTheAttempts() { using var cancellation = new CancellationTokenSource(); var server = new Server(fail: 1, (HttpStatusCode.InternalServerError, FindingId)) { OnFail = () => cancellation.CancelAfter(TimeSpan.FromMilliseconds(200)) }; - var client = Client(server, Local, new ChromaReadRetries(2, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10))); + var client = Client(server, Local, new ChromaReadRetries(2, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)) { Jitter = () => 1 }); var watch = System.Diagnostics.Stopwatch.StartNew(); Assert.That(() => client.GetAsync(cancellationToken: cancellation.Token), Throws.InstanceOf()); Assert.That(watch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5)), "the delay stops at the cancellation"); diff --git a/ChromaDB.Client/ChromaCollectionClient.cs b/ChromaDB.Client/ChromaCollectionClient.cs index ff0161f..e9e4046 100644 --- a/ChromaDB.Client/ChromaCollectionClient.cs +++ b/ChromaDB.Client/ChromaCollectionClient.cs @@ -135,7 +135,7 @@ private async Task Read(Func> read, CancellationToken cancellation var retries = _httpClient.ReadRetries; if (attemptsBefore > 0) { - await Task.Delay(retries.Delay(attemptsBefore - 1, Jitter()), cancellationToken); + await Task.Delay(retries.Delay(attemptsBefore - 1, retries.Jitter?.Invoke() ?? Jitter()), cancellationToken); } for (var attempt = attemptsBefore + 1; ; attempt++) { @@ -145,7 +145,7 @@ private async Task Read(Func> read, CancellationToken cancellation } catch (ChromaException ex) when (attempt < retries.MaxAttempts && CanPass(ex) && handledElsewhere?.Invoke(ex) != true) { - await Task.Delay(retries.Delay(attempt - 1, Jitter()), cancellationToken); + await Task.Delay(retries.Delay(attempt - 1, retries.Jitter?.Invoke() ?? Jitter()), cancellationToken); } } } diff --git a/ChromaDB.Client/ChromaReadRetries.cs b/ChromaDB.Client/ChromaReadRetries.cs index bea0d71..2ed128c 100644 --- a/ChromaDB.Client/ChromaReadRetries.cs +++ b/ChromaDB.Client/ChromaReadRetries.cs @@ -63,6 +63,10 @@ public ChromaReadRetries(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay) /// public static ChromaReadRetries None { get; } = new(1, TimeSpan.Zero, TimeSpan.Zero); + // The random number from 0 to 1 of each delay; null takes the shared Random of the client. Only the tests set it, to make a delay + // certain. + internal Func? Jitter { get; init; } + // The delay before the attempt after the given number of attempts that went again, for a random number from 0 to 1. internal TimeSpan Delay(int retry, double random) { From 653873865cb424e82d8d944fe324a7f9a09c0f85 Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 12:00:21 +0200 Subject: [PATCH 02/11] ChromaReadRetries.MinDelay: a read never goes again at once The delay before a new attempt was a random time from zero to the cap, the smaller of MaxDelay and BaseDelay times 2 to the attempts that went again before, so a read could go again at once. It is now a random time from MinDelay to the cap. A new constructor takes MinDelay, from zero to BaseDelay; the constructor without it takes 0.8 times BaseDelay, the lower bound of the jitter of Azure.Core, which is never above the base and is zero for a base of zero. The default waits 40 to 50 ms, then 40 to 100 and 40 to 200 ms; None does not change. --- ChromaDB.Client.Tests/ReadRetriesTests.cs | 64 +++++++++++++++++++++-- ChromaDB.Client/ChromaReadRetries.cs | 37 ++++++++++--- ChromaDB.Client/PublicAPI.Unshipped.txt | 2 + docs/errors.md | 5 +- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/ChromaDB.Client.Tests/ReadRetriesTests.cs b/ChromaDB.Client.Tests/ReadRetriesTests.cs index 62a4274..4ac7cc4 100644 --- a/ChromaDB.Client.Tests/ReadRetriesTests.cs +++ b/ChromaDB.Client.Tests/ReadRetriesTests.cs @@ -137,6 +137,8 @@ public async Task TheDefault() Assert.That(new ChromaConfigurationOptions(Local).ReadRetries, Is.SameAs(ChromaReadRetries.Default)); Assert.That((ChromaReadRetries.Default.MaxAttempts, ChromaReadRetries.Default.BaseDelay, ChromaReadRetries.Default.MaxDelay), Is.EqualTo((4, TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(1)))); Assert.That((ChromaReadRetries.None.MaxAttempts, ChromaReadRetries.None.BaseDelay, ChromaReadRetries.None.MaxDelay), Is.EqualTo((1, TimeSpan.Zero, TimeSpan.Zero))); + Assert.That(ChromaReadRetries.Default.MinDelay, Is.EqualTo(TimeSpan.FromMilliseconds(40))); + Assert.That(ChromaReadRetries.None.MinDelay, Is.EqualTo(TimeSpan.Zero)); } // The setting stays through the other options. @@ -155,19 +157,39 @@ public void TheSettingStaysThroughWithMetadataValues() Assert.That(server.Reads, Is.EqualTo(1)); } - // Full jitter: a random time between zero and the smaller of the largest delay and the base times 2 to the attempts before. - [TestCase(0, 0.0, 0)] - [TestCase(0, 0.5, 50)] + // A random time between the smallest delay and the smaller of the largest delay and the base times 2 to the attempts before. Without + // a smallest delay, it is 0.8 times the base, here 80 ms. + [TestCase(0, 0.0, 80)] + [TestCase(0, 0.5, 90)] [TestCase(0, 1.0, 100)] + [TestCase(1, 0.0, 80)] [TestCase(1, 1.0, 200)] [TestCase(3, 1.0, 800)] [TestCase(4, 1.0, 1000)] - [TestCase(4, 0.5, 500)] + [TestCase(4, 0.5, 540)] [TestCase(62, 1.0, 1000)] + [TestCase(1000, 0.0, 80)] [TestCase(1000, 1.0, 1000)] public void TheDelay(int retry, double random, int milliseconds) => Assert.That(new ChromaReadRetries(5, TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(1)).Delay(retry, random), Is.EqualTo(TimeSpan.FromMilliseconds(milliseconds))); + [TestCase(0, 0.0, 20)] + [TestCase(0, 0.5, 60)] + [TestCase(0, 1.0, 100)] + [TestCase(2, 0.5, 210)] + [TestCase(4, 0.0, 20)] + public void TheDelayWithASmallestDelay(int retry, double random, int milliseconds) + => Assert.That(new ChromaReadRetries(5, TimeSpan.FromMilliseconds(20), TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(1)).Delay(retry, random), Is.EqualTo(TimeSpan.FromMilliseconds(milliseconds))); + + // The constructor without a smallest delay takes 0.8 times the base, the lower bound of the jitter of Azure.Core: never above the + // base, and zero with a base of zero. + [TestCase(0, 0)] + [TestCase(1, 0.8)] + [TestCase(50, 40)] + [TestCase(100, 80)] + public void TheSmallestDelayOfTheBase(int baseDelay, double minDelay) + => Assert.That(new ChromaReadRetries(2, TimeSpan.FromMilliseconds(baseDelay), TimeSpan.FromSeconds(1)).MinDelay, Is.EqualTo(TimeSpan.FromMilliseconds(minDelay))); + [TestCase(0, 0, 0)] [TestCase(1, -1, 0)] [TestCase(1, 0, -1)] @@ -175,6 +197,27 @@ public void TheDelay(int retry, double random, int milliseconds) public void WrongSettings(int attempts, int baseDelay, int maxDelay) => Assert.That(() => new ChromaReadRetries(attempts, TimeSpan.FromMilliseconds(baseDelay), TimeSpan.FromMilliseconds(maxDelay)), Throws.InstanceOf()); + // 0 <= minDelay <= baseDelay <= maxDelay, each error named by its parameter. + [TestCase(1, -1, 0, 0, "minDelay")] + [TestCase(1, 10, 5, 20, "minDelay")] + [TestCase(1, 0, -1, 0, "baseDelay")] + [TestCase(1, 0, 10, 5, "maxDelay")] + [TestCase(0, 0, 0, 0, "maxAttempts")] + public void WrongSmallestDelays(int attempts, int minDelay, int baseDelay, int maxDelay, string parameter) + => Assert.That(() => new ChromaReadRetries(attempts, TimeSpan.FromMilliseconds(minDelay), TimeSpan.FromMilliseconds(baseDelay), TimeSpan.FromMilliseconds(maxDelay)), + Throws.InstanceOf().With.Property("ParamName").EqualTo(parameter)); + + [TestCase(0, 0, 0)] + [TestCase(5, 5, 5)] + [TestCase(0, 10, 20)] + public void SmallestDelaysThatGo(int minDelay, int baseDelay, int maxDelay) + => Assert.That(new ChromaReadRetries(2, TimeSpan.FromMilliseconds(minDelay), TimeSpan.FromMilliseconds(baseDelay), TimeSpan.FromMilliseconds(maxDelay)).MinDelay, Is.EqualTo(TimeSpan.FromMilliseconds(minDelay))); + + // The constructor without a smallest delay names a negative base, as before. + [Test] + public void ANegativeBaseWithoutASmallestDelay() + => Assert.That(() => new ChromaReadRetries(2, TimeSpan.FromMilliseconds(-1), TimeSpan.Zero), Throws.InstanceOf().With.Property("ParamName").EqualTo("baseDelay")); + // The longest delay of Task.Delay on every platform of the client, Int32.MaxValue milliseconds, is the largest one: beyond it Task.Delay // would throw in the middle of a read. [Test] @@ -208,6 +251,19 @@ public void ACancellationBetweenTheAttempts() Assert.That(server.Reads, Is.EqualTo(1)); } + // A short delay, the smallest one of 20 ms: the second attempt goes before the cancellation and the read succeeds. + [Test] + public async Task AShortDelayGoesAgain() + { + using var cancellation = new CancellationTokenSource(); + var server = new Server(fail: 1, (HttpStatusCode.InternalServerError, FindingId)) { OnFail = () => cancellation.CancelAfter(TimeSpan.FromSeconds(10)) }; + var client = Client(server, Local, new ChromaReadRetries(2, TimeSpan.FromMilliseconds(20), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)) { Jitter = () => 0 }); + var watch = System.Diagnostics.Stopwatch.StartNew(); + await client.GetAsync(cancellationToken: cancellation.Token); + Assert.That(watch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5)), "the delay is the smallest one"); + Assert.That(server.Reads, Is.EqualTo(2)); + } + static Task Run(ChromaCollectionClient client, string read) => read switch { "get" => client.GetAsync(), diff --git a/ChromaDB.Client/ChromaReadRetries.cs b/ChromaDB.Client/ChromaReadRetries.cs index 2ed128c..759bcf5 100644 --- a/ChromaDB.Client/ChromaReadRetries.cs +++ b/ChromaDB.Client/ChromaReadRetries.cs @@ -1,9 +1,10 @@ namespace ChromaDB.Client; /// -/// How a read goes again after an error that can pass, with the exponential backoff with full jitter of AWS ("Exponential Backoff And -/// Jitter"): before each new attempt it waits a random time between zero and the smaller of MaxDelay and BaseDelay times 2 -/// to the number of the attempts that went again before. The reads are each request of GetAsync, PeekAsync, +/// How a read goes again after an error that can pass, with an exponential backoff with jitter: before each new attempt it waits a +/// random time between MinDelay and the smaller of MaxDelay and BaseDelay times 2 to the number of the attempts +/// that went again before. MinDelay keeps a read from going again at once; without it, it is 0.8 times BaseDelay, the +/// lower bound of the jitter of Azure.Core. The reads are each request of GetAsync, PeekAsync, /// QueryAsync, SearchAsync and CountAsync, and the reads that a write makes first, as the get of /// ChromaUpsertStrategy.SkipUnchangedEmbeddings, the get of ChromaConfigurationOptions.ReadsBeyondKeyQuota and the /// pages of ids of DeleteCollectionAsync with deleteRecordsFirst. The errors that can pass are an answer 429, @@ -15,13 +16,26 @@ namespace ChromaDB.Client; public sealed class ChromaReadRetries { /// - /// The attempts and the delays. + /// The attempts and the delays, with a smallest delay of 0.8 times baseDelay. /// /// How many times a read goes at most, the first included: 1 never goes again. /// The largest delay before the second attempt; each following one can double it. /// The largest delay before any attempt, at least baseDelay and at most Int32.MaxValue /// milliseconds, the longest delay of Task.Delay. public ChromaReadRetries(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay) + : this(maxAttempts, baseDelay < TimeSpan.Zero ? TimeSpan.Zero : TimeSpan.FromTicks(baseDelay.Ticks / 5 * 4), baseDelay, maxDelay) + { + } + + /// + /// The attempts and the delays. + /// + /// How many times a read goes at most, the first included: 1 never goes again. + /// The smallest delay before any attempt, from zero to baseDelay. + /// The largest delay before the second attempt; each following one can double it. + /// The largest delay before any attempt, at least baseDelay and at most Int32.MaxValue + /// milliseconds, the longest delay of Task.Delay. + public ChromaReadRetries(int maxAttempts, TimeSpan minDelay, TimeSpan baseDelay, TimeSpan maxDelay) { if (maxAttempts < 1) { @@ -33,7 +47,12 @@ public ChromaReadRetries(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay) { throw new ArgumentOutOfRangeException(baseDelay < TimeSpan.Zero ? nameof(baseDelay) : nameof(maxDelay), "The delays cannot be negative, the largest one is at least the base, and it is at most Int32.MaxValue milliseconds, the longest delay of Task.Delay."); } + if (minDelay < TimeSpan.Zero || minDelay > baseDelay) + { + throw new ArgumentOutOfRangeException(nameof(minDelay), "The smallest delay is from zero to the base."); + } MaxAttempts = maxAttempts; + MinDelay = minDelay; BaseDelay = baseDelay; MaxDelay = maxDelay; } @@ -43,6 +62,11 @@ public ChromaReadRetries(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay) /// public int MaxAttempts { get; } + /// + /// The smallest delay before any attempt. + /// + public TimeSpan MinDelay { get; } + /// /// The largest delay before the second attempt; each following one can double it. /// @@ -70,8 +94,9 @@ public ChromaReadRetries(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay) // The delay before the attempt after the given number of attempts that went again, for a random number from 0 to 1. internal TimeSpan Delay(int retry, double random) { - // The base times 2 to the retry, up to the largest delay, without going beyond a long. + // The base times 2 to the retry, up to the largest delay, without going beyond a long; never under the smallest delay, which is + // at most the base. var bound = retry >= 62 || BaseDelay.Ticks > MaxDelay.Ticks >> retry ? MaxDelay.Ticks : BaseDelay.Ticks << retry; - return TimeSpan.FromTicks((long)(bound * random)); + return TimeSpan.FromTicks(MinDelay.Ticks + (long)((bound - MinDelay.Ticks) * random)); } } diff --git a/ChromaDB.Client/PublicAPI.Unshipped.txt b/ChromaDB.Client/PublicAPI.Unshipped.txt index f0f815b..05652f0 100644 --- a/ChromaDB.Client/PublicAPI.Unshipped.txt +++ b/ChromaDB.Client/PublicAPI.Unshipped.txt @@ -61,8 +61,10 @@ ChromaDB.Client.ChromaConfigurationOptions.WithReadRetries(ChromaDB.Client.Chrom ChromaDB.Client.ChromaReadRetries ChromaDB.Client.ChromaReadRetries.BaseDelay.get -> System.TimeSpan ChromaDB.Client.ChromaReadRetries.ChromaReadRetries(int maxAttempts, System.TimeSpan baseDelay, System.TimeSpan maxDelay) -> void +ChromaDB.Client.ChromaReadRetries.ChromaReadRetries(int maxAttempts, System.TimeSpan minDelay, System.TimeSpan baseDelay, System.TimeSpan maxDelay) -> void ChromaDB.Client.ChromaReadRetries.MaxAttempts.get -> int ChromaDB.Client.ChromaReadRetries.MaxDelay.get -> System.TimeSpan +ChromaDB.Client.ChromaReadRetries.MinDelay.get -> System.TimeSpan static ChromaDB.Client.ChromaReadRetries.Default.get -> ChromaDB.Client.ChromaReadRetries! static ChromaDB.Client.ChromaReadRetries.None.get -> ChromaDB.Client.ChromaReadRetries! virtual ChromaDB.Client.ChromaCollectionClient.DeleteAllAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/docs/errors.md b/docs/errors.md index 681facc..b12ec1b 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -20,9 +20,12 @@ When a 0.x server rejects a request with validation errors, the message lists th A read changes nothing, so after an error that can pass it goes again: each request of `GetAsync`, `PeekAsync`, `QueryAsync`, `SearchAsync` and `CountAsync`, and the reads that a write makes first, as the get of `ChromaUpsertStrategy.SkipUnchangedEmbeddings`, the get of `ReadsBeyondKeyQuota` on Chroma Cloud and the pages of ids that `DeleteCollectionAsync` reads with `deleteRecordsFirst`. The errors that can pass are an answer `429`, `500`, `502`, `503` or `504`, other than a missing collection, a request that fails on the network, and an answer whose lists do not have one item for every record. Chroma 1.5.9 answers `500` "Error finding id" or "Error creating hnsw segment reader" to a get with embeddings while other clients write and delete the same records, or answers it without the embedding of a record, and the same get right after goes. After the last attempt the error stands; for an answer whose lists do not have one item for every record, it is a `ChromaException` that says it, like "The answer of the server to a get has 1 id and 0 embeddings". A timeout of the `HttpClient` does not go again: the `ChromaException` with its `TaskCanceledException` stands. A write never goes again: one that failed may have gone. -Before each new attempt the client waits with the exponential backoff with full jitter of AWS: a random time between zero and the smaller of the largest delay and the base delay times 2 to the attempts that went again before. `ChromaReadRetries.Default` makes 4 attempts at most, with a base delay of 50 ms and a largest delay of 1 second. `WithReadRetries` sets others, and `ChromaReadRetries.None` turns them off: +Before each new attempt the client waits with an exponential backoff with jitter: a random time between the smallest delay and the smaller of the largest delay and the base delay times 2 to the attempts that went again before. The smallest delay keeps a read from going again at once. Without it, it is 0.8 times the base delay, the lower bound of the jitter of Azure.Core; up to 2.17.0 it was zero. `ChromaReadRetries.Default` makes 4 attempts at most, with a smallest delay of 40 ms, a base delay of 50 ms and a largest delay of 1 second: it waits 40 to 50 ms, then 40 to 100 ms, then 40 to 200 ms. `WithReadRetries` sets others, with or without the smallest delay, and `ChromaReadRetries.None` turns them off: ```csharp var options = new ChromaConfigurationOptions(uri: "http://localhost:8000") .WithReadRetries(new ChromaReadRetries(maxAttempts: 6, baseDelay: TimeSpan.FromMilliseconds(100), maxDelay: TimeSpan.FromSeconds(2))); +var withSmallestDelay = new ChromaReadRetries(maxAttempts: 6, minDelay: TimeSpan.FromMilliseconds(20), baseDelay: TimeSpan.FromMilliseconds(100), maxDelay: TimeSpan.FromSeconds(2)); ``` + +The smallest delay goes from zero to the base delay, and the largest delay from the base delay to `Int32.MaxValue` milliseconds; other values throw an `ArgumentOutOfRangeException` that names the parameter. From e2d133ee7e1fb2b1967123feae1068d5f3e2989d Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 12:00:21 +0200 Subject: [PATCH 03/11] Version 2.18.0 --- .../ChromaDB.Client.DependencyInjection.csproj | 2 +- ChromaDB.Client/ChromaDB.Client.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj b/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj index da94960..5817d01 100644 --- a/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj +++ b/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj @@ -7,7 +7,7 @@ true true - 2.17.1 + 2.18.0 ChromaDB.Client.DependencyInjection $(PackageIdPrefix).Client.DependencyInjection diff --git a/ChromaDB.Client/ChromaDB.Client.csproj b/ChromaDB.Client/ChromaDB.Client.csproj index a6886dc..a3ce869 100644 --- a/ChromaDB.Client/ChromaDB.Client.csproj +++ b/ChromaDB.Client/ChromaDB.Client.csproj @@ -7,7 +7,7 @@ true true - 2.17.1 + 2.18.0 ChromaDB.Client $(PackageIdPrefix).Client From d3d67717c602497cedd5f5b92e96882e302b908c Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 12:16:54 +0200 Subject: [PATCH 04/11] Migration guide: what a real migration from 1.x had to do Just the address of the server works as the URI; the example takes the collection client from GetCollectionClient; the handlers that rewrote v1 routes or read the tenant and the database from auth/identity can go, with WithTenantAndDatabaseFromIdentityAsync for the credentials; dependency injection; and the behaviors that differ from 1.x: reads that go again, NaN distances, empty metadata. QueryAsync returns the embeddings only when asked, as in 1.x. --- v2-migration/MIGRATION_GUIDE_V2.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/v2-migration/MIGRATION_GUIDE_V2.md b/v2-migration/MIGRATION_GUIDE_V2.md index a5b2cf0..146cad1 100644 --- a/v2-migration/MIGRATION_GUIDE_V2.md +++ b/v2-migration/MIGRATION_GUIDE_V2.md @@ -43,6 +43,8 @@ var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/a var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/api/v2/"); ``` +Just the address of the server works too: `new ChromaConfigurationOptions(uri: "http://localhost:8000")` gets `/api/v2/`. A URI with another path is used as it is. + ### Step 3: update the package reference Reference the latest `ChromaDotNet.Client`: @@ -88,7 +90,8 @@ var client = new ChromaClient(configOptions, httpClient); // The methods end in Async var collection = await client.GetOrCreateCollectionAsync("my_collection"); -var collectionClient = new ChromaCollectionClient(collection, configOptions, httpClient); +// The client of the collection shares the options and the HttpClient of the client +var collectionClient = client.GetCollectionClient(collection); await collectionClient.AddAsync( ["doc1"], @@ -96,6 +99,25 @@ await collectionClient.AddAsync( ); ``` +`new ChromaCollectionClient(collection, configOptions, httpClient)` still works; `client.GetCollectionClient(collection)` gives the same client without repeating the options and the `HttpClient`. + +## Workarounds of 1.x to remove + +Some applications kept 1.x working on Chroma 1.x with a `DelegatingHandler` that rewrote the routes from `/api/v1/` to `/api/v2/`, added the tenant and the database to the path, or read them from `auth/identity`. 2.x sends the v2 routes itself, with the tenant and the database of the options (`default_tenant` and `default_database` when they are not set), so remove such a handler. To take the tenant and the database from the credentials, as on Chroma Cloud, call `await client.WithTenantAndDatabaseFromIdentityAsync()`: it returns a client with the ones that `auth/identity` gives. + +## Dependency injection + +`ChromaDotNet.Client.DependencyInjection` registers the client in `Microsoft.Extensions.DependencyInjection`: `services.AddChromaClient(_ => options)`, `AddKeyedChromaClient` for more than one server, tenant or database, and `serviceProvider.CreateChromaClient(options, httpClientName)` for a client kept elsewhere. The client sends its requests through `IHttpClientFactory`, so a DNS change of the server is seen. See [docs/dependency-injection.md](../docs/dependency-injection.md). + +## Behaviors to know + +These are not breaking changes, but they differ from 1.x: + +- **Reads go again.** After an error that can pass, like `500` or a request that fails on the network, a read goes again, up to 4 attempts by default, with a random delay of 40 ms or more; a write never goes again. `WithReadRetries(ChromaReadRetries.None)` turns it off. See [docs/errors.md](../docs/errors.md#reads-that-go-again). +- **Distances.** `ChromaCollectionQueryEntry.Distance` is `NaN` where Chroma sends no number, like the distance to an embedding beyond the range of a float on Chroma 1.x. +- **Empty metadata.** An empty metadata dictionary goes as no metadata, and every tested Chroma treats the two the same. +- **Embeddings of a query.** `QueryAsync` returns the embeddings only with `ChromaQueryInclude.Embeddings`, as in 1.x: by default it returns the metadata, the documents and the distances. + ## Troubleshooting ### "404 Not Found" errors From df93d1dc6905f3f6cefff77e8136b44b5b76d154 Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 12:32:42 +0200 Subject: [PATCH 05/11] Collections created when missing: GetCollectionClient(name, createIfMissing) and GetOrCreateCollectionClientAsync GetCollectionClient(name or definition, createIfMissing: true) sends no request: its first operation gets or creates the collection, with one request that the operations starting before it ends share, each with its own cancellation; a failure of it comes from the first operation, and the next one asks again. GetOrCreateCollectionClientAsync(name or definition) sends the request now, so its errors come from the call. Both keep the collection, and when it is deleted elsewhere they get or create it again and run the operation again once, never a write that went in part. createIfMissing: false is GetCollectionClient(name). --- .../CollectionClientByNameTests.cs | 160 ++++++++++++++++++ .../CreateIfMissingServerTests.cs | 37 ++++ ChromaDB.Client/ChromaClient.cs | 58 +++++++ ChromaDB.Client/ChromaCollectionClient.cs | 65 ++++++- ChromaDB.Client/PublicAPI.Unshipped.txt | 4 + docs/collections.md | 21 +++ 6 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 ChromaDB.Client.Tests/CreateIfMissingServerTests.cs diff --git a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs index 9ae027c..ee3e188 100644 --- a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs +++ b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs @@ -161,6 +161,150 @@ 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()); + server.CreateGate.SetResult(true); + Assert.That(await other, Is.EqualTo(3)); + Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(1)); + } + + // 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()); + 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().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 { ["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 { ["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()); + } + + // The requests on the collections, without the ones on the server, like its version. + static IEnumerable 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) @@ -176,6 +320,9 @@ sealed class FakeServer(string id) : HttpMessageHandler public bool NameMissing { get; set; } public string? Schema { get; set; } public Action? OnRequest { get; set; } + // get_or_create: answered when the gate opens, with an error for the first CreateFailures. + public TaskCompletionSource? CreateGate { get; init; } + public int CreateFailures { get; set; } public List Requests { get; } = []; public List<(string Path, JsonElement Body)> Bodies { get; } = []; @@ -187,6 +334,19 @@ protected override async Task SendAsync(HttpRequestMessage { Bodies.Add((path, JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone())); } + 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"}}}}""") : MissingId is { } missing && path.Contains(missing) ? MissingAnswer diff --git a/ChromaDB.Client.Tests/CreateIfMissingServerTests.cs b/ChromaDB.Client.Tests/CreateIfMissingServerTests.cs new file mode 100644 index 0000000..99c7adf --- /dev/null +++ b/ChromaDB.Client.Tests/CreateIfMissingServerTests.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// GetCollectionClient(name, createIfMissing: true) and GetOrCreateCollectionClientAsync on the server. +[TestFixture] +public class CreateIfMissingServerTests : ChromaTestsBase +{ + // The first write creates the collection; deleted elsewhere, it is created again, empty, and the write goes to it. + [Test] + public async Task CreatedAtTheFirstWriteAndAgainAfterADeletion() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var name = $"create{Guid.NewGuid():N}"; + try + { + var records = client.GetCollectionClient(name, createIfMissing: true); + Assert.That(await client.CollectionExistsAsync(name), Is.False); + await records.AddAsync(["a"], [new([1f, 0f])]); + Assert.That(await records.CountAsync(), Is.EqualTo(1)); + var first = records.Collection.Id; + await client.DeleteCollectionAsync(name); + await records.AddAsync(["b"], [new([0f, 1f])]); + Assert.That(records.Collection.Id, Is.Not.EqualTo(first)); + Assert.That((await records.GetAsync()).Select(x => x.Id), Is.EqualTo(new[] { "b" })); + var now = await client.GetOrCreateCollectionClientAsync(name); + Assert.That(now.Collection.Id, Is.EqualTo(records.Collection.Id)); + } + finally + { + if (await client.CollectionExistsAsync(name)) + { + await client.DeleteCollectionAsync(name); + } + } + } +} diff --git a/ChromaDB.Client/ChromaClient.cs b/ChromaDB.Client/ChromaClient.cs index 7dc939f..55d5bdc 100644 --- a/ChromaDB.Client/ChromaClient.cs +++ b/ChromaDB.Client/ChromaClient.cs @@ -352,6 +352,64 @@ public virtual ChromaCollectionClient GetCollectionClient(ChromaCollection colle public virtual ChromaCollectionClient GetCollectionClient(string name) => new(name, _options, _httpClient); + /// + /// A client for the records of the collection with the name, as GetCollectionClient(name). With createIfMissing, its first + /// operation gets or creates the collection, with one request that the operations that start before it ends share; it keeps the + /// collection, and when a request fails because the collection was deleted elsewhere, it gets or creates it again, empty, and runs + /// the operation again, once, unless part of a write already went. No request is sent now: an error of the creation comes from the + /// first operation, which the next one tries again. GetOrCreateCollectionClientAsync sends it now instead. + /// + /// The name of the collection. + /// Whether the client creates the collection when the name has none; false is GetCollectionClient(name). + /// The client of the records of the collection with the name. + public virtual ChromaCollectionClient GetCollectionClient(string name, bool createIfMissing) + => GetCollectionClient(new ChromaCollectionDefinition(name), createIfMissing); + + /// + /// As GetCollectionClient(name, createIfMissing), for the collection of the definition: with createIfMissing, the + /// collection is created with its metadata, configuration and schema, as GetOrCreateCollectionAsync(definition) does; without + /// it, only the name counts. The client does not take the embedding generator of the definition: give it with + /// WithEmbeddingGenerator. + /// + /// The collection to get or create. + /// Whether the client creates the collection when the name has none. + /// The client of the records of the collection. + public virtual ChromaCollectionClient GetCollectionClient(ChromaCollectionDefinition definition, bool createIfMissing) + { + if (definition is null) + { + throw new ArgumentNullException(nameof(definition)); + } + return createIfMissing + ? new(definition.Name, _options, _httpClient, cancellationToken => GetOrCreateCollectionAsync(definition, cancellationToken: cancellationToken)) + : GetCollectionClient(definition.Name); + } + + /// + /// Gets or creates the collection with the name now, and returns its client: an error of the request comes from this call. Then the + /// client works as the one of GetCollectionClient(name, createIfMissing: true), and creates the collection again if it is + /// deleted elsewhere. + /// + /// The name of the collection. + /// The token that cancels the operation. + /// The client of the records of the collection. + public virtual Task GetOrCreateCollectionClientAsync(string name, CancellationToken cancellationToken = default) + => GetOrCreateCollectionClientAsync(new ChromaCollectionDefinition(name), cancellationToken); + + /// + /// As GetOrCreateCollectionClientAsync(name), for the collection of the definition, created with its metadata, configuration + /// and schema. + /// + /// The collection to get or create. + /// The token that cancels the operation. + /// The client of the records of the collection. + public virtual async Task GetOrCreateCollectionClientAsync(ChromaCollectionDefinition definition, CancellationToken cancellationToken = default) + { + var client = GetCollectionClient(definition, createIfMissing: true); + await client.GetCollectionAsync(cancellationToken); + return client; + } + /// /// The same as the overload that takes a ChromaCollection, without getting the collection first: the requests on /// a collection need only its id. diff --git a/ChromaDB.Client/ChromaCollectionClient.cs b/ChromaDB.Client/ChromaCollectionClient.cs index e9e4046..0851c81 100644 --- a/ChromaDB.Client/ChromaCollectionClient.cs +++ b/ChromaDB.Client/ChromaCollectionClient.cs @@ -19,6 +19,11 @@ public class ChromaCollectionClient private readonly ChromaHttpClient _httpClient; private readonly bool _byName; private volatile bool _resolved; + // A client of GetCollectionClient(name, createIfMissing: true): gets or creates the collection instead of reading it. + private readonly Func>? _getOrCreate; + // The first read of a client made by name, which the operations that start before it ends share. + private Task? _firstRead; + private readonly object _firstReadLock = new(); private readonly string _tenant; private readonly string _database; private readonly Uri _server; @@ -63,6 +68,13 @@ internal ChromaCollectionClient(string name, ChromaConfigurationOptions options, _byName = true; } + // The client of the collection with the name, which gets or creates it: ChromaClient.GetCollectionClient(name, createIfMissing: true). + internal ChromaCollectionClient(string name, ChromaConfigurationOptions options, ChromaHttpClient httpClient, Func> getOrCreate) + : this(name, options, httpClient) + { + _getOrCreate = getOrCreate; + } + // The same client with another way of reading metadata values, a document copy key, a document deletion key or empty document // deletions; a client made by name starts from the collection read last, and reads it again by itself. private ChromaCollectionClient(ChromaCollectionClient client, ChromaHttpClient httpClient, string? documentCopyKey, string? documentDeletionKey, bool emptyDocumentDeletions) @@ -71,6 +83,7 @@ private ChromaCollectionClient(ChromaCollectionClient client, ChromaHttpClient h _httpClient = httpClient; _byName = client._byName; _resolved = client._resolved; + _getOrCreate = client._getOrCreate; _tenant = client._tenant; _database = client._database; _server = client._server; @@ -174,7 +187,7 @@ private async Task ByName(Func> body, CancellationToken cancellati var resolved = _resolved; if (!resolved) { - await Resolve(cancellationToken); + await ResolveOnce(cancellationToken); } try { @@ -205,8 +218,58 @@ private async Task TryResolve(CancellationToken cancellationToken) } } + // The first read is one request, which the operations that start before it ends wait for, each with its own cancellation: it goes on + // without the token of any of them. When it fails, each of them throws, and the next operation reads again. + private async Task ResolveOnce(CancellationToken cancellationToken) + { + Task read; + lock (_firstReadLock) + { + read = _firstRead ??= Resolve(CancellationToken.None); + } + try + { + await WaitWithCancellation(read, cancellationToken); + } + catch when (read.IsFaulted || read.IsCanceled) + { + lock (_firstReadLock) + { + if (_firstRead == read) + { + _firstRead = null; + } + } + throw; + } + } + + private static async Task WaitWithCancellation(Task task, CancellationToken cancellationToken) + { +#if NET8_0_OR_GREATER + await task.WaitAsync(cancellationToken); +#else + if (!cancellationToken.CanBeCanceled || task.IsCompleted) + { + await task; + return; + } + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(() => cancelled.TrySetCanceled(cancellationToken))) + { + await await Task.WhenAny(task, cancelled.Task); + } +#endif + } + private async Task Resolve(CancellationToken cancellationToken) { + if (_getOrCreate is { } getOrCreate) + { + _collection = await getOrCreate(cancellationToken); + _resolved = true; + return; + } var requestParams = new RequestQueryParams() .Insert("{collectionName}", _collection.Name) .Insert("{tenant}", _tenant) diff --git a/ChromaDB.Client/PublicAPI.Unshipped.txt b/ChromaDB.Client/PublicAPI.Unshipped.txt index 05652f0..b570968 100644 --- a/ChromaDB.Client/PublicAPI.Unshipped.txt +++ b/ChromaDB.Client/PublicAPI.Unshipped.txt @@ -68,3 +68,7 @@ ChromaDB.Client.ChromaReadRetries.MinDelay.get -> System.TimeSpan static ChromaDB.Client.ChromaReadRetries.Default.get -> ChromaDB.Client.ChromaReadRetries! static ChromaDB.Client.ChromaReadRetries.None.get -> ChromaDB.Client.ChromaReadRetries! virtual ChromaDB.Client.ChromaCollectionClient.DeleteAllAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetCollectionClient(ChromaDB.Client.Models.ChromaCollectionDefinition! definition, bool createIfMissing) -> ChromaDB.Client.ChromaCollectionClient! +virtual ChromaDB.Client.ChromaClient.GetCollectionClient(string! name, bool createIfMissing) -> ChromaDB.Client.ChromaCollectionClient! +virtual ChromaDB.Client.ChromaClient.GetOrCreateCollectionClientAsync(ChromaDB.Client.Models.ChromaCollectionDefinition! definition, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetOrCreateCollectionClientAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/docs/collections.md b/docs/collections.md index 1cd35c6..7b35ebc 100644 --- a/docs/collections.md +++ b/docs/collections.md @@ -61,3 +61,24 @@ var standalone = new ChromaCollectionClient(collectionId, "my_collection", optio ``` A collection client made by name reads the collection before its first request, which `GetCollectionAsync` returns. When a request fails on that id, it reads the collection again: if the name has another id, as when the collection was deleted and created again elsewhere, the operation runs again, once, on that collection, and otherwise the failure stands. The servers tell a collection gone in their own ways ([docs/COMPATIBILITY.md](COMPATIBILITY.md)), so the client compares the ids. + +## Collections created when missing + +Two ways give a client for the records of a collection that is created when the name has none: + +```csharp +// When the first operation goes: no request now +var lazy = client.GetCollectionClient("my_collection", createIfMissing: true); + +// Now: the request goes in this call +var now = await client.GetOrCreateCollectionClientAsync("my_collection"); + +// With the metadata, the configuration or the schema of a new collection +var defined = client.GetCollectionClient(new ChromaCollectionDefinition("my_collection") { Metadata = metadata }, createIfMissing: true); +``` + +- `GetCollectionClient(name, createIfMissing: true)` sends no request. Its first operation gets or creates the collection, with one request that the operations which start before it ends share; each of them keeps its own cancellation. An error of that request comes from the first operation, and the next one tries again. +- `GetOrCreateCollectionClientAsync(name)` sends the request now, so its error comes from that call; then the client works as the other one. +- Both keep the collection. When a request fails because the collection was deleted elsewhere, they get or create it again, empty, with the same definition, and run the operation again, once: never a write that went in part, which throws with the number of records that went. +- With `createIfMissing: false`, the client is the one of `GetCollectionClient(name)`, which never creates the collection. +- The client does not take the embedding generator of a definition: give it with `WithEmbeddingGenerator`. From c89afa66e0ffa64473e4e3b1f193a0d5813e1ea1 Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 12:35:35 +0200 Subject: [PATCH 06/11] AddChromaClientFromServices and AddKeyedChromaClientFromServices: options made from the services The options of AddChromaClient come from a function of the default options, called at the registration, so they cannot read IConfiguration or IOptions of the application. The new methods take a function of the IServiceProvider, called once when the client or its options are first resolved; a null it returns throws an InvalidOperationException. They have their own names: an overload of AddChromaClient would make AddChromaClient(_ => options) or AddChromaClient(null, builder => ...) ambiguous, and both compile as before. --- .../ChromaClientExtensions.cs | 55 ++++++++++ .../PublicAPI.Unshipped.txt | 2 + .../DependencyInjectionFromServicesTests.cs | 100 ++++++++++++++++++ docs/dependency-injection.md | 9 ++ v2-migration/MIGRATION_GUIDE_V2.md | 2 +- 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 ChromaDB.Client.Tests/DependencyInjectionFromServicesTests.cs diff --git a/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs b/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs index 972cdee..93ebabc 100644 --- a/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs +++ b/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs @@ -83,6 +83,61 @@ public static IServiceCollection AddKeyedChromaClient(this IServiceCollection se return services; } + /// + /// Registers a ChromaClient and its options as singletons, with options that configurationOptions makes from the + /// services when the client or the options are first resolved, like from IConfiguration or IOptions of the + /// application, also registered after this call. configureHttpClient configures the HttpClient of the client, like a + /// resilience handler, a proxy or a timeout. Returns the services, for more registrations. + /// + /// The services. + /// Makes the options of the client from the services. + /// Configures the HttpClient of the client; null for none. + /// The services, for more registrations. + public static IServiceCollection AddChromaClientFromServices(this IServiceCollection services, Func configurationOptions, Action? 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(), nameof(ChromaClient))); + return services; + } + + /// + /// As AddChromaClientFromServices, 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. + /// + /// The services. + /// The key of the client and of its options. + /// Makes the options of the client from the services. + /// Configures the HttpClient of the client; null for none. + /// The services, for more registrations. + public static IServiceCollection AddKeyedChromaClientFromServices(this IServiceCollection services, object serviceKey, Func configurationOptions, Action? 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(key), httpClientName)); + return services; + } + + private static ChromaConfigurationOptions MadeOptions(Func configurationOptions, IServiceProvider serviceProvider) + => configurationOptions(serviceProvider) ?? throw new InvalidOperationException("The function of the options of the ChromaClient returned null."); + /// /// A ChromaClient to keep, like a singleton of an application or of an integration, that sends each request with the /// current handler of IHttpClientFactory for httpClientName: the factory renews it after its handler lifetime, two diff --git a/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt b/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt index 7dc5c58..c54fcbc 100644 --- a/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt +++ b/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddChromaClientFromServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Func! configurationOptions, System.Action? configureHttpClient = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddKeyedChromaClientFromServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, object! serviceKey, System.Func! configurationOptions, System.Action? configureHttpClient = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/ChromaDB.Client.Tests/DependencyInjectionFromServicesTests.cs b/ChromaDB.Client.Tests/DependencyInjectionFromServicesTests.cs new file mode 100644 index 0000000..6e25c9f --- /dev/null +++ b/ChromaDB.Client.Tests/DependencyInjectionFromServicesTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using ChromaDB.Client.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// AddChromaClientFromServices and AddKeyedChromaClientFromServices: the options come from the services when the client is resolved. +[TestFixture] +public class DependencyInjectionFromServicesTests +{ + sealed record Settings(string Uri); + + // The options are made once, when the client or the options are resolved, from services registered also after the client. + [Test] + public void TheOptionsComeFromTheServices() + { + var services = new ServiceCollection(); + var calls = 0; + services.AddChromaClientFromServices(serviceProvider => + { + calls++; + return new ChromaConfigurationOptions(serviceProvider.GetRequiredService().Uri); + }); + services.AddSingleton(new Settings("http://chroma.example:8000")); + Assert.That(calls, Is.Zero); + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + Assert.That(client.Options.Uri, Is.EqualTo(new Uri("http://chroma.example:8000"))); + Assert.That(provider.GetRequiredService().Uri, Is.EqualTo(new Uri("http://chroma.example:8000"))); + Assert.That(provider.GetRequiredService(), Is.SameAs(client)); + Assert.That(calls, Is.EqualTo(1)); + } + + // The HttpClient of the client goes through the builder, as with AddChromaClient. + [Test] + public async Task TheHttpClientGoesThroughTheBuilder() + { + var handler = new Recorder(); + var services = new ServiceCollection(); + services.AddSingleton(new Settings("http://chroma.example:8000")); + services.AddChromaClientFromServices(serviceProvider => new ChromaConfigurationOptions(serviceProvider.GetRequiredService().Uri), + builder => builder.ConfigurePrimaryHttpMessageHandler(() => handler)); + using var provider = services.BuildServiceProvider(); + await provider.GetRequiredService().HeartbeatAsync(); + Assert.That(handler.Uris.Single(), Is.EqualTo(new Uri("http://chroma.example:8000/api/v2/heartbeat"))); + } + + [Test] + public async Task KeyedClientsFromTheServices() + { + var first = new Recorder(); + var second = new Recorder(); + var services = new ServiceCollection(); + services.AddKeyedSingleton("first", new Settings("http://first:8000")); + services.AddKeyedSingleton("second", new Settings("http://second:8000")); + services.AddKeyedChromaClientFromServices("first", serviceProvider => new ChromaConfigurationOptions(serviceProvider.GetRequiredKeyedService("first").Uri), + builder => builder.ConfigurePrimaryHttpMessageHandler(() => first)); + services.AddKeyedChromaClientFromServices("second", serviceProvider => new ChromaConfigurationOptions(serviceProvider.GetRequiredKeyedService("second").Uri), + builder => builder.ConfigurePrimaryHttpMessageHandler(() => second)); + using var provider = services.BuildServiceProvider(); + await provider.GetRequiredKeyedService("first").HeartbeatAsync(); + await provider.GetRequiredKeyedService("second").HeartbeatAsync(); + Assert.That(first.Uris.Single().Host, Is.EqualTo("first")); + Assert.That(second.Uris.Single().Host, Is.EqualTo("second")); + Assert.That(provider.GetRequiredKeyedService("second").Uri.Host, Is.EqualTo("second")); + } + + [Test] + public void WrongArguments() + { + var services = new ServiceCollection(); + Assert.That(() => services.AddChromaClientFromServices(null!), Throws.ArgumentNullException); + Assert.That(() => services.AddKeyedChromaClientFromServices(null!, _ => new ChromaConfigurationOptions()), Throws.ArgumentNullException); + Assert.That(() => services.AddKeyedChromaClientFromServices("k", null!), Throws.ArgumentNullException); + services.AddChromaClientFromServices(_ => null!); + using var provider = services.BuildServiceProvider(); + Assert.That(() => provider.GetRequiredService(), Throws.InvalidOperationException.With.Message.Contains("returned null")); + } + + [Test] + public void RegistrationsReturnTheServices() + { + var services = new ServiceCollection(); + Assert.That(services.AddChromaClientFromServices(_ => new ChromaConfigurationOptions()), Is.SameAs(services)); + Assert.That(services.AddKeyedChromaClientFromServices("k", _ => new ChromaConfigurationOptions(), _ => { }), Is.SameAs(services)); + } + + // Answers every request with a heartbeat, and records the URIs. + sealed class Recorder : HttpMessageHandler + { + public List Uris { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Uris.Add(request.RequestUri!); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"nanosecond heartbeat":1}""") }); + } + } +} diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index 0ce4af3..1647797 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -6,3 +6,12 @@ - The client's `HttpClient` sends each request through the current handler of `IHttpClientFactory`. The factory renews the handler after its lifetime, two minutes by default, so a DNS change of the server address is picked up on every target framework. After two minutes the client also asks again what it learned about the server, like its version. - An overload configures that `HttpClient`, for example with a resilience handler, a proxy or a timeout: `services.AddChromaClient(_ => options, builder => builder.AddStandardResilienceHandler())`. - `serviceProvider.CreateChromaClient(options, httpClientName)` creates a client the same way when it is kept elsewhere, like in an integration with an `HttpClient` name of its own. +- `AddChromaClientFromServices` and `AddKeyedChromaClientFromServices` make the options from the services, when the client or its options are first resolved: so the address can come from `IConfiguration` or `IOptions` of the application, also registered after the call. The function runs once, and a null it returns throws an `InvalidOperationException`. + +```csharp +services.AddChromaClientFromServices(serviceProvider => + new ChromaConfigurationOptions(serviceProvider.GetRequiredService()["Chroma:Uri"]!)); +services.AddKeyedChromaClientFromServices("archive", serviceProvider => + new ChromaConfigurationOptions(serviceProvider.GetRequiredService>().Value.ChromaUri), + builder => builder.AddStandardResilienceHandler()); +``` diff --git a/v2-migration/MIGRATION_GUIDE_V2.md b/v2-migration/MIGRATION_GUIDE_V2.md index 146cad1..f3aa9c1 100644 --- a/v2-migration/MIGRATION_GUIDE_V2.md +++ b/v2-migration/MIGRATION_GUIDE_V2.md @@ -107,7 +107,7 @@ Some applications kept 1.x working on Chroma 1.x with a `DelegatingHandler` that ## Dependency injection -`ChromaDotNet.Client.DependencyInjection` registers the client in `Microsoft.Extensions.DependencyInjection`: `services.AddChromaClient(_ => options)`, `AddKeyedChromaClient` for more than one server, tenant or database, and `serviceProvider.CreateChromaClient(options, httpClientName)` for a client kept elsewhere. The client sends its requests through `IHttpClientFactory`, so a DNS change of the server is seen. See [docs/dependency-injection.md](../docs/dependency-injection.md). +`ChromaDotNet.Client.DependencyInjection` registers the client in `Microsoft.Extensions.DependencyInjection`: `services.AddChromaClient(_ => options)`, `AddKeyedChromaClient` for more than one server, tenant or database, and `serviceProvider.CreateChromaClient(options, httpClientName)` for a client kept elsewhere. `AddChromaClientFromServices(serviceProvider => options)` makes the options from the services of the application, like `IConfiguration`, when the client is resolved. The client sends its requests through `IHttpClientFactory`, so a DNS change of the server is seen. See [docs/dependency-injection.md](../docs/dependency-injection.md). ## Behaviors to know From 5952266bf2d6c90424874e003a4f1ac7a94d1d66 Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 12:36:20 +0200 Subject: [PATCH 07/11] Trimming and NativeAOT test: a collection created when missing, and options made from the services --- Samples/ChromaDB.Client.TrimmingTest/Program.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Samples/ChromaDB.Client.TrimmingTest/Program.cs b/Samples/ChromaDB.Client.TrimmingTest/Program.cs index 3414b15..94140f0 100644 --- a/Samples/ChromaDB.Client.TrimmingTest/Program.cs +++ b/Samples/ChromaDB.Client.TrimmingTest/Program.cs @@ -199,10 +199,21 @@ await Check("DependencyInjection", async () => var services = new ServiceCollection(); services.AddChromaClient(_ => options); services.AddKeyedChromaClient("k", _ => options); + services.AddKeyedChromaClientFromServices("s", _ => options); using var provider = services.BuildServiceProvider(); await provider.GetRequiredKeyedService("k").HeartbeatAsync(); + await provider.GetRequiredKeyedService("s").HeartbeatAsync(); return (await provider.GetRequiredService().HeartbeatAsync()).NanosecondHeartbeat.ToString(); }); +await Check("Collection created when missing", async () => +{ + var missing = $"{name}m"; + var created = client.GetCollectionClient(missing, createIfMissing: true); + await created.AddAsync(["a"], [new([1f, 0f, 0f])]); + var count = await (await client.GetOrCreateCollectionClientAsync(missing)).CountAsync(); + await client.DeleteCollectionAsync(missing); + return Expect(count.ToString(), count == 1, "1"); +}); // chroma:document takes a text only (KD-58). Without reflection, a list derived from List, which the JSON context does not list, // would go as an array: it must be rejected before the request. await Check("chroma:document", async () => From c21f7fe14dcfe96d30b3f079f636bb18976ff504 Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 12:55:21 +0200 Subject: [PATCH 08/11] Do not keep a first read that failed after every operation waiting for it was cancelled When every operation waiting for the shared first read was cancelled and the read then failed, nothing let it go: the next operation threw that failure again without a request, and only the one after it asked again. A failed or cancelled first read is now let go when the next operation starts, so that operation sends a new request. --- .../CollectionClientByNameTests.cs | 17 +++++++++ ChromaDB.Client/ChromaCollectionClient.cs | 35 ++++++++++--------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs index ee3e188..a20a19d 100644 --- a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs +++ b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs @@ -213,6 +213,23 @@ public async Task ACancelledFirstOperationLeavesTheOthers() Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(1)); } + // 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()); + server.CreateGate.SetResult(true); + await Assert.ThatAsync(() => read, Throws.InstanceOf()); + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(2)); + } + // A creation that fails throws in the operation, and the next operation asks again. [Test] public async Task AFailedCreationGoesAgainAtTheNextOperation() diff --git a/ChromaDB.Client/ChromaCollectionClient.cs b/ChromaDB.Client/ChromaCollectionClient.cs index 0851c81..cde2ef9 100644 --- a/ChromaDB.Client/ChromaCollectionClient.cs +++ b/ChromaDB.Client/ChromaCollectionClient.cs @@ -24,6 +24,18 @@ public class ChromaCollectionClient // The first read of a client made by name, which the operations that start before it ends share. private Task? _firstRead; private readonly object _firstReadLock = new(); + + // The first read in flight, or null: only the tests read it, to wait for it. + internal Task? FirstRead + { + get + { + lock (_firstReadLock) + { + return _firstRead; + } + } + } private readonly string _tenant; private readonly string _database; private readonly Uri _server; @@ -219,29 +231,20 @@ private async Task TryResolve(CancellationToken cancellationToken) } // The first read is one request, which the operations that start before it ends wait for, each with its own cancellation: it goes on - // without the token of any of them. When it fails, each of them throws, and the next operation reads again. - private async Task ResolveOnce(CancellationToken cancellationToken) + // without the token of any of them. When it fails, each of them throws, and the next operation reads again, also when every one of + // them was cancelled before: a failed read is never waited for again. + private Task ResolveOnce(CancellationToken cancellationToken) { Task read; lock (_firstReadLock) { - read = _firstRead ??= Resolve(CancellationToken.None); - } - try - { - await WaitWithCancellation(read, cancellationToken); - } - catch when (read.IsFaulted || read.IsCanceled) - { - lock (_firstReadLock) + if (_firstRead is { IsFaulted: true } or { IsCanceled: true }) { - if (_firstRead == read) - { - _firstRead = null; - } + _firstRead = null; } - throw; + read = _firstRead ??= Resolve(CancellationToken.None); } + return WaitWithCancellation(read, cancellationToken); } private static async Task WaitWithCancellation(Task task, CancellationToken cancellationToken) From db860696be65dc1d9f7bdcb3780e7fff86c7075f Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 12:55:27 +0200 Subject: [PATCH 09/11] Read the collection a client gets or creates with its own way of reading metadata values The creation called GetOrCreateCollectionAsync of the ChromaClient the collection client came from, so a client of WithMetadataValues read the metadata of the collection the way of that ChromaClient: with an Inferred one, a date in the metadata came back as a DateTime under Exact. The creation now goes with the ChromaHttpClient of the collection client, as the read by name does. --- .../CollectionClientByNameTests.cs | 16 +++++++++++++++- ChromaDB.Client/ChromaClient.cs | 2 +- ChromaDB.Client/ChromaCollectionClient.cs | 9 +++++---- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs index a20a19d..360cfc9 100644 --- a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs +++ b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs @@ -230,6 +230,19 @@ public async Task AFailureAfterEveryWaiterIsCancelledIsNotKept() 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() @@ -340,6 +353,7 @@ sealed class FakeServer(string id) : HttpMessageHandler // get_or_create: answered when the gate opens, with an error for the first CreateFailures. public TaskCompletionSource? CreateGate { get; init; } public int CreateFailures { get; set; } + public string? CollectionMetadata { get; init; } public List Requests { get; } = []; public List<(string Path, JsonElement Body)> Bodies { get; } = []; @@ -365,7 +379,7 @@ protected override async Task SendAsync(HttpRequestMessage 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\"") diff --git a/ChromaDB.Client/ChromaClient.cs b/ChromaDB.Client/ChromaClient.cs index 55d5bdc..0628b3f 100644 --- a/ChromaDB.Client/ChromaClient.cs +++ b/ChromaDB.Client/ChromaClient.cs @@ -381,7 +381,7 @@ public virtual ChromaCollectionClient GetCollectionClient(ChromaCollectionDefini throw new ArgumentNullException(nameof(definition)); } return createIfMissing - ? new(definition.Name, _options, _httpClient, cancellationToken => GetOrCreateCollectionAsync(definition, cancellationToken: cancellationToken)) + ? new(definition.Name, _options, _httpClient, (httpClient, cancellationToken) => new ChromaClient(_options, httpClient).GetOrCreateCollectionAsync(definition, cancellationToken: cancellationToken)) : GetCollectionClient(definition.Name); } diff --git a/ChromaDB.Client/ChromaCollectionClient.cs b/ChromaDB.Client/ChromaCollectionClient.cs index cde2ef9..783a938 100644 --- a/ChromaDB.Client/ChromaCollectionClient.cs +++ b/ChromaDB.Client/ChromaCollectionClient.cs @@ -19,8 +19,9 @@ public class ChromaCollectionClient private readonly ChromaHttpClient _httpClient; private readonly bool _byName; private volatile bool _resolved; - // A client of GetCollectionClient(name, createIfMissing: true): gets or creates the collection instead of reading it. - private readonly Func>? _getOrCreate; + // A client of GetCollectionClient(name, createIfMissing: true): gets or creates the collection instead of reading it, with the + // ChromaHttpClient of this client, so with its way of reading metadata values. + private readonly Func>? _getOrCreate; // The first read of a client made by name, which the operations that start before it ends share. private Task? _firstRead; private readonly object _firstReadLock = new(); @@ -81,7 +82,7 @@ internal ChromaCollectionClient(string name, ChromaConfigurationOptions options, } // The client of the collection with the name, which gets or creates it: ChromaClient.GetCollectionClient(name, createIfMissing: true). - internal ChromaCollectionClient(string name, ChromaConfigurationOptions options, ChromaHttpClient httpClient, Func> getOrCreate) + internal ChromaCollectionClient(string name, ChromaConfigurationOptions options, ChromaHttpClient httpClient, Func> getOrCreate) : this(name, options, httpClient) { _getOrCreate = getOrCreate; @@ -269,7 +270,7 @@ private async Task Resolve(CancellationToken cancellationToken) { if (_getOrCreate is { } getOrCreate) { - _collection = await getOrCreate(cancellationToken); + _collection = await getOrCreate(_httpClient, cancellationToken); _resolved = true; return; } From ff54aed509dd801286b1046df6a0c9b16efe897e Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 13:13:39 +0200 Subject: [PATCH 10/11] An operation cancelled before it starts does not create the collection The first operation of a client that creates its collection started the shared request before it looked at its token, so an operation cancelled before it was called, or GetOrCreateCollectionClientAsync with such a token, still created the collection. The token is now checked first, and such an operation sends no request; the shared request stays independent of the waiters that are cancelled later. --- .../CollectionClientByNameTests.cs | 13 +++++++++++++ ChromaDB.Client/ChromaCollectionClient.cs | 2 ++ 2 files changed, 15 insertions(+) diff --git a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs index 360cfc9..a39dc76 100644 --- a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs +++ b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs @@ -213,6 +213,19 @@ public async Task ACancelledFirstOperationLeavesTheOthers() 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()); + await Assert.ThatAsync(() => client.GetOrCreateCollectionClientAsync("c", cancellation.Token), Throws.InstanceOf()); + 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() diff --git a/ChromaDB.Client/ChromaCollectionClient.cs b/ChromaDB.Client/ChromaCollectionClient.cs index 783a938..1bddbb6 100644 --- a/ChromaDB.Client/ChromaCollectionClient.cs +++ b/ChromaDB.Client/ChromaCollectionClient.cs @@ -236,6 +236,8 @@ private async Task TryResolve(CancellationToken cancellationToken) // them was cancelled before: a failed read is never waited for again. private Task ResolveOnce(CancellationToken cancellationToken) { + // An operation cancelled before it starts sends nothing, also not the creation of the collection. + cancellationToken.ThrowIfCancellationRequested(); Task read; lock (_firstReadLock) { From 98ba9f7bf2fc55021cd50b3352799062d8b6ab23 Mon Sep 17 00:00:00 2001 From: Laurianti Date: Wed, 21 Oct 2026 13:37:30 +0200 Subject: [PATCH 11/11] Record the requests of the fake server under a lock In FirstOperationsShareTheRequest the eight operations send their counts at the same time once the creation answers, and the fake server added each request to a List, which takes one writer at a time. Not seen in 5000 runs of the test, but such a write can lose an entry or throw: the requests and the bodies are now recorded under a lock. --- ChromaDB.Client.Tests/CollectionClientByNameTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs index a39dc76..e10dc87 100644 --- a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs +++ b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs @@ -373,10 +373,18 @@ sealed class FakeServer(string id) : HttpMessageHandler protected override async Task 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 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) {