Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
69 changes: 63 additions & 6 deletions ChromaDB.Client.Tests/ReadRetriesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -155,26 +157,67 @@ 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)]
[TestCase(1, 10, 5)]
public void WrongSettings(int attempts, int baseDelay, int maxDelay)
=> Assert.That(() => new ChromaReadRetries(attempts, TimeSpan.FromMilliseconds(baseDelay), TimeSpan.FromMilliseconds(maxDelay)), Throws.InstanceOf<ArgumentOutOfRangeException>());

// 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<ArgumentOutOfRangeException>().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<ArgumentOutOfRangeException>().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]
Expand All @@ -194,19 +237,33 @@ 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<OperationCanceledException>());
Assert.That(watch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5)), "the delay stops at the cancellation");
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(),
Expand Down
4 changes: 2 additions & 2 deletions ChromaDB.Client/ChromaCollectionClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ private async Task<T> Read<T>(Func<Task<T>> 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++)
{
Expand All @@ -145,7 +145,7 @@ private async Task<T> Read<T>(Func<Task<T>> 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);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion ChromaDB.Client/ChromaDB.Client.csproj
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</PackageId>
<!-- -p:PackageIdPrefix=Name publishes the package as Name.Client. -->
<PackageId Condition="'$(PackageIdPrefix)' != ''">$(PackageIdPrefix).Client</PackageId>
Expand Down
41 changes: 35 additions & 6 deletions ChromaDB.Client/ChromaReadRetries.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
namespace ChromaDB.Client;

/// <summary>
/// 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 <c>MaxDelay</c> and <c>BaseDelay</c> times 2
/// to the number of the attempts that went again before. The reads are each request of <c>GetAsync</c>, <c>PeekAsync</c>,
/// 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 <c>MinDelay</c> and the smaller of <c>MaxDelay</c> and <c>BaseDelay</c> times 2 to the number of the attempts
/// that went again before. <c>MinDelay</c> keeps a read from going again at once; without it, it is 0.8 times <c>BaseDelay</c>, the
/// lower bound of the jitter of Azure.Core. The reads are each request of <c>GetAsync</c>, <c>PeekAsync</c>,
/// <c>QueryAsync</c>, <c>SearchAsync</c> and <c>CountAsync</c>, and the reads that a write makes first, as the get of
/// <c>ChromaUpsertStrategy.SkipUnchangedEmbeddings</c>, the get of <c>ChromaConfigurationOptions.ReadsBeyondKeyQuota</c> and the
/// pages of ids of <c>DeleteCollectionAsync</c> with <c>deleteRecordsFirst</c>. The errors that can pass are an answer <c>429</c>,
Expand All @@ -15,13 +16,26 @@ namespace ChromaDB.Client;
public sealed class ChromaReadRetries
{
/// <summary>
/// The attempts and the delays.
/// The attempts and the delays, with a smallest delay of 0.8 times <c>baseDelay</c>.
/// </summary>
/// <param name="maxAttempts">How many times a read goes at most, the first included: 1 never goes again.</param>
/// <param name="baseDelay">The largest delay before the second attempt; each following one can double it.</param>
/// <param name="maxDelay">The largest delay before any attempt, at least <c>baseDelay</c> and at most <c>Int32.MaxValue</c>
/// milliseconds, the longest delay of <c>Task.Delay</c>.</param>
public ChromaReadRetries(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay)
: this(maxAttempts, baseDelay < TimeSpan.Zero ? TimeSpan.Zero : TimeSpan.FromTicks(baseDelay.Ticks / 5 * 4), baseDelay, maxDelay)
{
}

/// <summary>
/// The attempts and the delays.
/// </summary>
/// <param name="maxAttempts">How many times a read goes at most, the first included: 1 never goes again.</param>
/// <param name="minDelay">The smallest delay before any attempt, from zero to <c>baseDelay</c>.</param>
/// <param name="baseDelay">The largest delay before the second attempt; each following one can double it.</param>
/// <param name="maxDelay">The largest delay before any attempt, at least <c>baseDelay</c> and at most <c>Int32.MaxValue</c>
/// milliseconds, the longest delay of <c>Task.Delay</c>.</param>
public ChromaReadRetries(int maxAttempts, TimeSpan minDelay, TimeSpan baseDelay, TimeSpan maxDelay)
{
if (maxAttempts < 1)
{
Expand All @@ -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;
}
Expand All @@ -43,6 +62,11 @@ public ChromaReadRetries(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay)
/// </summary>
public int MaxAttempts { get; }

/// <summary>
/// The smallest delay before any attempt.
/// </summary>
public TimeSpan MinDelay { get; }

/// <summary>
/// The largest delay before the second attempt; each following one can double it.
/// </summary>
Expand All @@ -63,11 +87,16 @@ public ChromaReadRetries(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay)
/// </summary>
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<double>? 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)
{
// 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));
}
}
2 changes: 2 additions & 0 deletions ChromaDB.Client/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>!
5 changes: 4 additions & 1 deletion docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading