diff --git a/src/DistributedLock.Core/Chaining/ChainedDistributedLock.cs b/src/DistributedLock.Core/Chaining/ChainedDistributedLock.cs
new file mode 100644
index 0000000..698c558
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/ChainedDistributedLock.cs
@@ -0,0 +1,40 @@
+namespace Medallion.Threading.Chaining;
+
+using Medallion.Threading.Internal;
+
+///
+/// A chained distributed lock which acquires multiple underlying locks
+///
+public class ChainedDistributedLock : IDistributedLock
+{
+ private readonly IDistributedLock[] _distributedLocks;
+
+ internal ChainedDistributedLock(string name, IDistributedLock[] distributedLocks)
+ {
+ this.Name = name ?? throw new ArgumentNullException(nameof(name));
+ this._distributedLocks = ChainedHelpers.Validate(distributedLocks, nameof(distributedLocks));
+ }
+
+ public string Name { get; }
+
+ public IDistributedSynchronizationHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) =>
+ SyncViaAsync.Run(static s => s.@this.AcquireAsync(s.timeout, s.cancellationToken), (@this: this, timeout, cancellationToken));
+
+ public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) =>
+ this.TryAcquireInternalAsync(timeout, cancellationToken).GetHandleOrTimeout();
+
+ public IDistributedSynchronizationHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) =>
+ SyncViaAsync.Run(static s => s.@this.TryAcquireAsync(s.timeout, s.cancellationToken), (@this: this, timeout, cancellationToken));
+
+ public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) =>
+ this.TryAcquireInternalAsync(timeout, cancellationToken).GetHandleOrDefault();
+
+ private ValueTask TryAcquireInternalAsync(
+ TimeoutValue timeout,
+ CancellationToken cancellationToken) =>
+ CompositeDistributedSynchronizationHandle.TryAcquireAllAsync(
+ this._distributedLocks,
+ static (l, t, c) => SyncViaAsync.IsSynchronous ? l.TryAcquire(t, c).AsValueTask() : l.TryAcquireAsync(t, c),
+ timeout,
+ cancellationToken);
+}
diff --git a/src/DistributedLock.Core/Chaining/ChainedDistributedLockProvider.cs b/src/DistributedLock.Core/Chaining/ChainedDistributedLockProvider.cs
new file mode 100644
index 0000000..e80ed8a
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/ChainedDistributedLockProvider.cs
@@ -0,0 +1,15 @@
+namespace Medallion.Threading.Chaining;
+public class ChainedDistributedLockProvider : IDistributedLockProvider
+{
+ public ChainedDistributedLockProvider(IDistributedLockProvider firstProvider, IDistributedLockProvider secondProvider, params IDistributedLockProvider[] additionalProviders) : this(lockProviders: [firstProvider, secondProvider, .. additionalProviders])
+ {
+ }
+ internal ChainedDistributedLockProvider(IDistributedLockProvider[] lockProviders)
+ {
+ this.Providers = lockProviders;
+ }
+
+ public IDistributedLockProvider[] Providers { get; }
+
+ public IDistributedLock CreateLock(string name) => new ChainedDistributedLock(name, this.Providers.Select(p => p.CreateLock(name)).ToArray());
+}
\ No newline at end of file
diff --git a/src/DistributedLock.Core/Chaining/ChainedDistributedReaderWriterLock.cs b/src/DistributedLock.Core/Chaining/ChainedDistributedReaderWriterLock.cs
new file mode 100644
index 0000000..d56d931
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/ChainedDistributedReaderWriterLock.cs
@@ -0,0 +1,63 @@
+namespace Medallion.Threading.Chaining;
+
+using Medallion.Threading.Internal;
+
+///
+/// A chained reader-writer lock which acquires multiple underlying reader-writer locks
+///
+public class ChainedDistributedReaderWriterLock : IDistributedReaderWriterLock
+{
+ private readonly IDistributedReaderWriterLock[] _distributedReaderWriterLocks;
+
+ internal ChainedDistributedReaderWriterLock(
+ string name,
+ IDistributedReaderWriterLock[] distributedReaderWriterLocks)
+ {
+ this.Name = name ?? throw new ArgumentNullException(nameof(name));
+ this._distributedReaderWriterLocks = ChainedHelpers.Validate(distributedReaderWriterLocks, nameof(distributedReaderWriterLocks));
+ }
+
+ public string Name { get; }
+
+ public IDistributedSynchronizationHandle AcquireReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) =>
+ SyncViaAsync.Run(static s => s.@this.AcquireReadLockAsync(s.timeout, s.cancellationToken), (@this: this, timeout, cancellationToken));
+
+ public ValueTask AcquireReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) =>
+ this.TryAcquireReadLockInternalAsync(timeout, cancellationToken).GetHandleOrTimeout();
+
+ public IDistributedSynchronizationHandle? TryAcquireReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) =>
+ SyncViaAsync.Run(static s => s.@this.TryAcquireReadLockAsync(s.timeout, s.cancellationToken), (@this: this, timeout, cancellationToken));
+
+ public ValueTask TryAcquireReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) =>
+ this.TryAcquireReadLockInternalAsync(timeout, cancellationToken).GetHandleOrDefault();
+
+ public IDistributedSynchronizationHandle AcquireWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) =>
+ SyncViaAsync.Run(static s => s.@this.AcquireWriteLockAsync(s.timeout, s.cancellationToken), (@this: this, timeout, cancellationToken));
+
+ public ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) =>
+ this.TryAcquireWriteLockInternalAsync(timeout, cancellationToken).GetHandleOrTimeout();
+
+ public IDistributedSynchronizationHandle? TryAcquireWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) =>
+ SyncViaAsync.Run(static s => s.@this.TryAcquireWriteLockAsync(s.timeout, s.cancellationToken), (@this: this, timeout, cancellationToken));
+
+ public ValueTask TryAcquireWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) =>
+ this.TryAcquireWriteLockInternalAsync(timeout, cancellationToken).GetHandleOrDefault();
+
+ private ValueTask TryAcquireReadLockInternalAsync(
+ TimeoutValue timeout,
+ CancellationToken cancellationToken) =>
+ CompositeDistributedSynchronizationHandle.TryAcquireAllAsync(
+ this._distributedReaderWriterLocks,
+ static (l, t, c) => SyncViaAsync.IsSynchronous ? l.TryAcquireReadLock(t, c).AsValueTask() : l.TryAcquireReadLockAsync(t, c),
+ timeout,
+ cancellationToken);
+
+ private ValueTask TryAcquireWriteLockInternalAsync(
+ TimeoutValue timeout,
+ CancellationToken cancellationToken) =>
+ CompositeDistributedSynchronizationHandle.TryAcquireAllAsync(
+ this._distributedReaderWriterLocks,
+ static (l, t, c) => SyncViaAsync.IsSynchronous ? l.TryAcquireWriteLock(t, c).AsValueTask() : l.TryAcquireWriteLockAsync(t, c),
+ timeout,
+ cancellationToken);
+}
diff --git a/src/DistributedLock.Core/Chaining/ChainedDistributedReaderWriterLockProvider.cs b/src/DistributedLock.Core/Chaining/ChainedDistributedReaderWriterLockProvider.cs
new file mode 100644
index 0000000..07e434f
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/ChainedDistributedReaderWriterLockProvider.cs
@@ -0,0 +1,15 @@
+namespace Medallion.Threading.Chaining;
+
+public class ChainedDistributedReaderWriterLockProvider : IDistributedReaderWriterLockProvider
+{
+ public ChainedDistributedReaderWriterLockProvider(IDistributedReaderWriterLockProvider firstProvider, IDistributedReaderWriterLockProvider secondProvider, params IDistributedReaderWriterLockProvider[] additionalProviders) : this(lockProviders: [firstProvider, secondProvider, .. additionalProviders])
+ {
+ }
+ internal ChainedDistributedReaderWriterLockProvider(IDistributedReaderWriterLockProvider[] lockProviders)
+ {
+ this.Providers = lockProviders;
+ }
+
+ public IDistributedReaderWriterLockProvider[] Providers { get; }
+ public IDistributedReaderWriterLock CreateReaderWriterLock(string name) => new ChainedDistributedReaderWriterLock(name, this.Providers.Select(p => p.CreateReaderWriterLock(name)).ToArray());
+}
diff --git a/src/DistributedLock.Core/Chaining/ChainedDistributedSemaphore.cs b/src/DistributedLock.Core/Chaining/ChainedDistributedSemaphore.cs
new file mode 100644
index 0000000..81ba0f3
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/ChainedDistributedSemaphore.cs
@@ -0,0 +1,43 @@
+using Medallion.Threading.Internal;
+
+namespace Medallion.Threading.Chaining;
+
+///
+/// A chained semaphore which acquires multiple underlying semaphores
+///
+public class ChainedDistributedSemaphore : IDistributedSemaphore
+{
+ private readonly IDistributedSemaphore[] _distributedSemaphores;
+
+ internal ChainedDistributedSemaphore(string name, int maxCount, IDistributedSemaphore[] distributedSemaphores)
+ {
+ this.Name = name ?? throw new ArgumentNullException(nameof(name));
+ this.MaxCount = maxCount;
+ this._distributedSemaphores = ChainedHelpers.Validate(distributedSemaphores, nameof(distributedSemaphores));
+ }
+
+ public string Name { get; }
+
+ public int MaxCount { get; }
+
+ public IDistributedSynchronizationHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) =>
+ SyncViaAsync.Run(static s => s.@this.AcquireAsync(s.timeout, s.cancellationToken), (@this: this, timeout, cancellationToken));
+
+ public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) =>
+ this.TryAcquireInternalAsync(timeout, cancellationToken).GetHandleOrTimeout();
+
+ public IDistributedSynchronizationHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) =>
+ SyncViaAsync.Run(static s => s.@this.TryAcquireAsync(s.timeout, s.cancellationToken), (@this: this, timeout, cancellationToken));
+
+ public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) =>
+ this.TryAcquireInternalAsync(timeout, cancellationToken).GetHandleOrDefault();
+
+ private ValueTask TryAcquireInternalAsync(
+ TimeoutValue timeout,
+ CancellationToken cancellationToken) =>
+ CompositeDistributedSynchronizationHandle.TryAcquireAllAsync(
+ this._distributedSemaphores,
+ static (s, t, c) => SyncViaAsync.IsSynchronous ? s.TryAcquire(t, c).AsValueTask() : s.TryAcquireAsync(t, c),
+ timeout,
+ cancellationToken);
+}
diff --git a/src/DistributedLock.Core/Chaining/ChainedDistributedSemaphoreProvider.cs b/src/DistributedLock.Core/Chaining/ChainedDistributedSemaphoreProvider.cs
new file mode 100644
index 0000000..197510e
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/ChainedDistributedSemaphoreProvider.cs
@@ -0,0 +1,16 @@
+namespace Medallion.Threading.Chaining;
+
+public class ChainedDistributedSemaphoreProvider : IDistributedSemaphoreProvider
+{
+ public ChainedDistributedSemaphoreProvider(IDistributedSemaphoreProvider firstProvider, IDistributedSemaphoreProvider secondProvider, params IDistributedSemaphoreProvider[] additionalProviders) : this(lockProviders: [firstProvider, secondProvider, .. additionalProviders])
+ {
+ }
+ internal ChainedDistributedSemaphoreProvider(IDistributedSemaphoreProvider[] lockProviders)
+ {
+ this.Providers = lockProviders;
+ }
+
+ internal IDistributedSemaphoreProvider[] Providers { get; }
+
+ public IDistributedSemaphore CreateSemaphore(string name, int maxCount) => new ChainedDistributedSemaphore(name, maxCount, this.Providers.Select(p => p.CreateSemaphore(name, maxCount)).ToArray());
+}
diff --git a/src/DistributedLock.Core/Chaining/ChainedHelpers.cs b/src/DistributedLock.Core/Chaining/ChainedHelpers.cs
new file mode 100644
index 0000000..a9eda79
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/ChainedHelpers.cs
@@ -0,0 +1,14 @@
+namespace Medallion.Threading.Chaining;
+
+internal static class ChainedHelpers
+{
+ public static T[] Validate(T[] primitives, string paramName)
+ where T : class
+ {
+ if (primitives is null) { throw new ArgumentNullException(paramName); }
+ if (primitives.Length == 0) { throw new ArgumentException("At least one underlying synchronization primitive is required.", paramName); }
+ if (Array.IndexOf(primitives, null) >= 0) { throw new ArgumentException("Must not contain null", paramName); }
+
+ return primitives;
+ }
+}
diff --git a/src/DistributedLock.Core/Chaining/DistributedLockProviderExtensions.cs b/src/DistributedLock.Core/Chaining/DistributedLockProviderExtensions.cs
new file mode 100644
index 0000000..a030f8e
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/DistributedLockProviderExtensions.cs
@@ -0,0 +1,61 @@
+namespace Medallion.Threading.Chaining;
+
+public static class DistributedLockProviderExtensions
+{
+
+ ///
+ /// Chains with such that locks created from the resulting provider are acquired first by then by .
+ ///
+ ///
+ ///
+ ///
+ public static IDistributedLockProvider PrecededBy(this IDistributedLockProvider provider, IDistributedLockProvider precedingProvider)
+ {
+ var providers = new List();
+ if (precedingProvider is ChainedDistributedLockProvider chained)
+ {
+ providers.AddRange(chained.Providers);
+ }
+ else
+ {
+ providers.Add(precedingProvider);
+ }
+ if (provider is ChainedDistributedLockProvider chained2)
+ {
+ providers.AddRange(chained2.Providers);
+ }
+ else
+ {
+ providers.Add(provider);
+ }
+ return new ChainedDistributedLockProvider(lockProviders: [.. providers]);
+ }
+
+ ///
+ /// Chains with such that locks created from the resulting provider are acquired first by , then by .
+ ///
+ ///
+ ///
+ ///
+ public static IDistributedLockProvider FollowedBy(this IDistributedLockProvider provider, IDistributedLockProvider followingProvider)
+ {
+ var providers = new List();
+ if (provider is ChainedDistributedLockProvider chained)
+ {
+ providers.AddRange(chained.Providers);
+ }
+ else
+ {
+ providers.Add(provider);
+ }
+ if (followingProvider is ChainedDistributedLockProvider chained2)
+ {
+ providers.AddRange(chained2.Providers);
+ }
+ else
+ {
+ providers.Add(followingProvider);
+ }
+ return new ChainedDistributedLockProvider([.. providers]);
+ }
+}
diff --git a/src/DistributedLock.Core/Chaining/DistributedReaderWriterLockProviderExtensions.cs b/src/DistributedLock.Core/Chaining/DistributedReaderWriterLockProviderExtensions.cs
new file mode 100644
index 0000000..9ba8d64
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/DistributedReaderWriterLockProviderExtensions.cs
@@ -0,0 +1,61 @@
+namespace Medallion.Threading.Chaining;
+
+public static class DistributedReaderWriterLockProviderExtensions
+{
+
+ ///
+ /// Chains with such that locks created from the resulting provider are acquired first by then by .
+ ///
+ ///
+ ///
+ ///
+ public static IDistributedReaderWriterLockProvider PrecededBy(this IDistributedReaderWriterLockProvider provider, IDistributedReaderWriterLockProvider precedingProvider)
+ {
+ var providers = new List();
+ if (precedingProvider is ChainedDistributedReaderWriterLockProvider chained)
+ {
+ providers.AddRange(chained.Providers);
+ }
+ else
+ {
+ providers.Add(precedingProvider);
+ }
+ if (provider is ChainedDistributedReaderWriterLockProvider chained2)
+ {
+ providers.AddRange(chained2.Providers);
+ }
+ else
+ {
+ providers.Add(provider);
+ }
+ return new ChainedDistributedReaderWriterLockProvider(lockProviders: [.. providers]);
+ }
+
+ ///
+ /// Chains with such that locks created from the resulting provider are acquired first by , then by .
+ ///
+ ///
+ ///
+ ///
+ public static IDistributedReaderWriterLockProvider FollowedBy(this IDistributedReaderWriterLockProvider provider, IDistributedReaderWriterLockProvider followingProvider)
+ {
+ var providers = new List();
+ if (provider is ChainedDistributedReaderWriterLockProvider chained)
+ {
+ providers.AddRange(chained.Providers);
+ }
+ else
+ {
+ providers.Add(provider);
+ }
+ if (followingProvider is ChainedDistributedReaderWriterLockProvider chained2)
+ {
+ providers.AddRange(chained2.Providers);
+ }
+ else
+ {
+ providers.Add(followingProvider);
+ }
+ return new ChainedDistributedReaderWriterLockProvider([.. providers]);
+ }
+}
diff --git a/src/DistributedLock.Core/Chaining/DistributedSemaphoreProviderExtensions.cs b/src/DistributedLock.Core/Chaining/DistributedSemaphoreProviderExtensions.cs
new file mode 100644
index 0000000..9d34c7e
--- /dev/null
+++ b/src/DistributedLock.Core/Chaining/DistributedSemaphoreProviderExtensions.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Medallion.Threading.Chaining;
+
+public static class DistributedSemaphoreProviderExtensions
+{
+ ///
+ /// Chains with such that locks created from the resulting provider are acquired first by , then by .
+ ///
+ ///
+ ///
+ ///
+ public static IDistributedSemaphoreProvider PrecededBy(this IDistributedSemaphoreProvider provider, IDistributedSemaphoreProvider precedingProvider)
+ {
+ var providers = new List();
+ if (precedingProvider is ChainedDistributedSemaphoreProvider chained)
+ {
+ providers.AddRange(chained.Providers);
+ }
+ else
+ {
+ providers.Add(precedingProvider);
+ }
+ if (provider is ChainedDistributedSemaphoreProvider chained2)
+ {
+ providers.AddRange(chained2.Providers);
+ }
+ else
+ {
+ providers.Add(provider);
+ }
+ return new ChainedDistributedSemaphoreProvider([.. providers]);
+ }
+
+ ///
+ /// Chains with such that locks created from the resulting provider are acquired first by , then by .
+ ///
+ ///
+ ///
+ ///
+ public static IDistributedSemaphoreProvider FollowedBy(this IDistributedSemaphoreProvider provider, IDistributedSemaphoreProvider followingProvider)
+ {
+ var providers = new List();
+ if (provider is ChainedDistributedSemaphoreProvider chained)
+ {
+ providers.AddRange(chained.Providers);
+ }
+ else
+ {
+ providers.Add(provider);
+ }
+ if (followingProvider is ChainedDistributedSemaphoreProvider chained2)
+ {
+ providers.AddRange(chained2.Providers);
+ }
+ else
+ {
+ providers.Add(followingProvider);
+ }
+ return new ChainedDistributedSemaphoreProvider([.. providers]);
+ }
+}
diff --git a/src/DistributedLock.Core/CompositeDistributedSynchronizationHandle.cs b/src/DistributedLock.Core/CompositeDistributedSynchronizationHandle.cs
index d567f0c..18869fb 100644
--- a/src/DistributedLock.Core/CompositeDistributedSynchronizationHandle.cs
+++ b/src/DistributedLock.Core/CompositeDistributedSynchronizationHandle.cs
@@ -4,11 +4,11 @@ namespace Medallion.Threading;
internal sealed class CompositeDistributedSynchronizationHandle : IDistributedSynchronizationHandle
{
- private RefBox<(IReadOnlyList Handles, CancellationTokenSource? HandleLostSource)>? _box;
+ private RefBox<(IReadOnlyList Handles, CancellationTokenSource? HandleLostSource, bool HandleLostSourceCreated)>? _box;
private CompositeDistributedSynchronizationHandle(IReadOnlyList handles)
{
- this._box = RefBox.Create((handles, default(CancellationTokenSource)));
+ this._box = RefBox.Create((handles, default(CancellationTokenSource), false));
}
public CancellationToken HandleLostToken
@@ -17,18 +17,26 @@ public CancellationToken HandleLostToken
{
var currentBox = Volatile.Read(ref this._box);
- if (currentBox != null
- && currentBox.Value.HandleLostSource is null
- && CreateLinkedCancellationTokenSource(currentBox.Value.Handles) is { } newHandleLostSource)
+ // HandleLostToken can be expensive to obtain on some providers, so we don't touch the underlying
+ // handles until we're asked for the token, and we only ever do so once. We track that with a
+ // separate flag rather than a null check because a null source is itself a valid memoized result
+ // (it means no underlying handle has a cancelable token), which we must not recompute on each read.
+ if (currentBox is { Value.HandleLostSourceCreated: false })
{
- var newBox = RefBox.Create(currentBox.Value with { HandleLostSource = newHandleLostSource });
+ var newHandleLostSource = CreateLinkedCancellationTokenSource(currentBox.Value.Handles);
+ var newBox = RefBox.Create(currentBox.Value with { HandleLostSource = newHandleLostSource, HandleLostSourceCreated = true });
var result = Interlocked.CompareExchange(ref this._box, newBox, comparand: currentBox);
if (result == currentBox) { currentBox = newBox; }
- else { newHandleLostSource.Dispose(); } // lost the race
+ else
+ {
+ // lost the race to another caller or to disposal
+ newHandleLostSource?.Dispose();
+ currentBox = result;
+ }
}
return currentBox is null
- ? throw this.ObjectDisposed()
+ ? throw this.ObjectDisposed()
: (currentBox.Value.HandleLostSource?.Token ?? CancellationToken.None);
}
}
@@ -53,6 +61,12 @@ public static IReadOnlyList FromNames(IReadOnlyL
return names.Select(n => create(state, n)).ToArray();
}
+ ///
+ /// Acquires all one at a time, in order, returning a composite handle on success
+ /// or the first primitive which could not be acquired on failure. All primitives share the
+ /// as a single overall deadline, and any primitives that were acquired are released
+ /// (in reverse order of acquisition) if the operation fails.
+ ///
public static async ValueTask TryAcquireAllAsync(
IReadOnlyList primitives,
Func> acquireFunc,
diff --git a/src/DistributedLock.Core/DistributedLock.Core.csproj b/src/DistributedLock.Core/DistributedLock.Core.csproj
index b21e5b6..a611a19 100644
--- a/src/DistributedLock.Core/DistributedLock.Core.csproj
+++ b/src/DistributedLock.Core/DistributedLock.Core.csproj
@@ -54,10 +54,10 @@
-
+
-
+
\ No newline at end of file
diff --git a/src/DistributedLock.Core/Internal/DistributedLockHelpers.cs b/src/DistributedLock.Core/Internal/DistributedLockHelpers.cs
index 7427b73..b3b8771 100644
--- a/src/DistributedLock.Core/Internal/DistributedLockHelpers.cs
+++ b/src/DistributedLock.Core/Internal/DistributedLockHelpers.cs
@@ -133,7 +133,7 @@ public static bool TryUpgradeToWriteLock(IDistributedLockUpgradeableHandle handl
SyncViaAsync.Run(t => t.handle.TryUpgradeToWriteLockAsync(t.timeout, t.cancellationToken), (handle, timeout, cancellationToken));
#endregion
- private static Exception LockTimeout(string? @object = null) => new TimeoutException($"Timeout exceeded when trying to acquire the {@object ?? "lock"}");
+ internal static Exception LockTimeout(string? @object = null) => new TimeoutException($"Timeout exceeded when trying to acquire the {@object ?? "lock"}");
public static async ValueTask ThrowTimeoutIfNull(this ValueTask task, string? @object = null) where T : class =>
await task.ConfigureAwait(false) ?? throw LockTimeout(@object);
diff --git a/src/DistributedLock.Tests/DistributedLock.Tests.csproj b/src/DistributedLock.Tests/DistributedLock.Tests.csproj
index be82219..c4859a6 100644
--- a/src/DistributedLock.Tests/DistributedLock.Tests.csproj
+++ b/src/DistributedLock.Tests/DistributedLock.Tests.csproj
@@ -15,6 +15,12 @@
false
+
+
+
+
+
+
diff --git a/src/DistributedLock.Tests/Tests/Chaining/ChainedDistributedLockProviderTests.cs b/src/DistributedLock.Tests/Tests/Chaining/ChainedDistributedLockProviderTests.cs
new file mode 100644
index 0000000..f9df721
--- /dev/null
+++ b/src/DistributedLock.Tests/Tests/Chaining/ChainedDistributedLockProviderTests.cs
@@ -0,0 +1,107 @@
+using Medallion.Threading.Chaining;
+using Moq;
+using NUnit.Framework;
+
+namespace Medallion.Threading.Tests.Chaining;
+
+[TestFixture]
+public class ChainedDistributedLockProviderTests
+{
+ private const string Name = "LockTest";
+
+ [Test]
+ public async Task Acquire_AllProvidersSucceed_ReturnsHandle()
+ {
+ var (p1, h1) = CreateProvider(true);
+ var (p2, h2) = CreateProvider(true);
+ var sut = new ChainedDistributedLockProvider(p1.Object, p2.Object);
+
+ await using (var handle = await sut.CreateLock(Name).AcquireAsync())
+ {
+ Assert.That(handle, Is.Not.Null);
+ }
+
+ h1.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ h2.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ }
+
+ [Test]
+ public void Acquire_PartialFailure_Throws()
+ {
+ var (p1, _) = CreateProvider(true);
+ var (p2, _) = CreateProvider(false);
+ var sut = new ChainedDistributedLockProvider(p1.Object, p2.Object);
+
+ Assert.ThrowsAsync(async () =>
+ {
+ await sut.CreateLock(Name).AcquireAsync();
+ });
+ }
+
+ [Test]
+ public async Task TryAcquire_AllProvidersSucceed_ReturnsHandle()
+ {
+ var (p1, h1) = CreateProvider(true);
+ var (p2, h2) = CreateProvider(true);
+ var sut = new ChainedDistributedLockProvider(p1.Object, p2.Object);
+
+ await using (var handle = await sut.CreateLock(Name).TryAcquireAsync())
+ {
+ Assert.That(handle, Is.Not.Null);
+ }
+ h1.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ h2.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ }
+
+ [Test]
+ public async Task TryAcquire_PartialFailure_ReturnsNull()
+ {
+ var (p1, _) = CreateProvider(true);
+ var (p2, _) = CreateProvider(false);
+ var sut = new ChainedDistributedLockProvider(p1.Object, p2.Object);
+
+ var handle = await sut.CreateLock(Name).TryAcquireAsync();
+ Assert.That(handle, Is.Null);
+ }
+
+ private static (Mock provider,
+ Lazy> handle) CreateProvider(bool succeed)
+ {
+ var provider = new Mock();
+ var lockMock = new Mock();
+ var handle = new Lazy>(() =>
+ {
+ var h = new Mock();
+ h.Setup(x => x.HandleLostToken).Returns(CancellationToken.None);
+ h.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask);
+ return h;
+ });
+
+ provider.Setup(p => p.CreateLock(Name)).Returns(lockMock.Object);
+
+ if (succeed)
+ {
+ lockMock.Setup(l => l.AcquireAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => handle.Value.Object);
+ lockMock.Setup(l => l.TryAcquireAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => handle.Value.Object);
+ lockMock.Setup(l => l.Acquire(It.IsAny(), It.IsAny()))
+ .Returns(() => handle.Value.Object);
+ lockMock.Setup(l => l.TryAcquire(It.IsAny(), It.IsAny()))
+ .Returns(() => handle.Value.Object);
+ }
+ else
+ {
+ lockMock.Setup(l => l.AcquireAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new TimeoutException());
+ lockMock.Setup(l => l.TryAcquireAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((IDistributedSynchronizationHandle?)null);
+ lockMock.Setup(l => l.Acquire(It.IsAny(), It.IsAny()))
+ .Throws(new TimeoutException());
+ lockMock.Setup(l => l.TryAcquire(It.IsAny(), It.IsAny()))
+ .Returns((IDistributedSynchronizationHandle?)null);
+ }
+
+ return (provider, handle);
+ }
+}
\ No newline at end of file
diff --git a/src/DistributedLock.Tests/Tests/Chaining/ChainedDistributedReaderWriterLockProviderTests.cs b/src/DistributedLock.Tests/Tests/Chaining/ChainedDistributedReaderWriterLockProviderTests.cs
new file mode 100644
index 0000000..be46675
--- /dev/null
+++ b/src/DistributedLock.Tests/Tests/Chaining/ChainedDistributedReaderWriterLockProviderTests.cs
@@ -0,0 +1,139 @@
+using Medallion.Threading.Chaining;
+using Moq;
+using NUnit.Framework;
+
+namespace Medallion.Threading.Tests.Chaining;
+
+[TestFixture]
+public class ChainedDistributedReaderWriterLockProviderTests
+{
+ private const string Name = "RwLockTest";
+
+ [Test]
+ public async Task AcquireRead_AllProvidersSucceed_ReturnsHandle()
+ {
+ var (p1, h1) = CreateProvider(true);
+ var (p2, h2) = CreateProvider(true);
+ var sut = new ChainedDistributedReaderWriterLockProvider(p1.Object, p2.Object);
+
+ await using (var handle = await sut.CreateReaderWriterLock(Name).AcquireReadLockAsync())
+ {
+ Assert.That(handle, Is.Not.Null);
+ }
+
+ h1.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ h2.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ }
+
+ [Test]
+ public void AcquireRead_PartialFailure_Throws()
+ {
+ var (p1, _) = CreateProvider(true);
+ var (p2, _) = CreateProvider(false);
+ var sut = new ChainedDistributedReaderWriterLockProvider(p1.Object, p2.Object);
+
+ Assert.ThrowsAsync(async () =>
+ {
+ await sut.CreateReaderWriterLock(Name).AcquireReadLockAsync();
+ });
+ }
+
+ [Test]
+ public async Task TryAcquireRead_AllProvidersSucceed_ReturnsHandle()
+ {
+ var (p1, h1) = CreateProvider(true);
+ var (p2, h2) = CreateProvider(true);
+ var sut = new ChainedDistributedReaderWriterLockProvider(p1.Object, p2.Object);
+
+ var handle = await sut.CreateReaderWriterLock(Name).TryAcquireReadLockAsync();
+ Assert.That(handle, Is.Not.Null);
+ if (handle != null) await using (handle) { }
+
+ h1.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ h2.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ }
+
+ [Test]
+ public async Task TryAcquireRead_PartialFailure_ReturnsNull()
+ {
+ var (p1, _) = CreateProvider(true);
+ var (p2, _) = CreateProvider(false);
+ var sut = new ChainedDistributedReaderWriterLockProvider(p1.Object, p2.Object);
+
+ var handle = await sut.CreateReaderWriterLock(Name).TryAcquireReadLockAsync();
+ Assert.That(handle, Is.Null);
+ }
+
+ [Test]
+ public async Task AcquireWrite_AllProvidersSucceed_ReturnsHandle()
+ {
+ var (p1, h1) = CreateProvider(true);
+ var (p2, h2) = CreateProvider(true);
+ var sut = new ChainedDistributedReaderWriterLockProvider(p1.Object, p2.Object);
+
+ await using (var handle = await sut.CreateReaderWriterLock(Name).AcquireWriteLockAsync())
+ {
+ Assert.That(handle, Is.Not.Null);
+ }
+
+ h1.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ h2.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ }
+
+ private static (Mock provider,
+ Lazy> handle) CreateProvider(bool succeed)
+ {
+ var provider = new Mock();
+ var rwLock = new Mock();
+ var handle = new Lazy>(() =>
+ {
+ var h = new Mock();
+ h.Setup(x => x.HandleLostToken).Returns(CancellationToken.None);
+ h.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask);
+ return h;
+ });
+
+ provider.Setup(p => p.CreateReaderWriterLock(Name)).Returns(rwLock.Object);
+
+ if (succeed)
+ {
+ rwLock.Setup(l => l.AcquireReadLockAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => handle.Value.Object);
+ rwLock.Setup(l => l.TryAcquireReadLockAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => handle.Value.Object);
+ rwLock.Setup(l => l.AcquireWriteLockAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => handle.Value.Object);
+ rwLock.Setup(l => l.TryAcquireWriteLockAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => handle.Value.Object);
+ rwLock.Setup(l => l.AcquireReadLock(It.IsAny(), It.IsAny()))
+ .Returns(() => handle.Value.Object);
+ rwLock.Setup(l => l.TryAcquireReadLock(It.IsAny(), It.IsAny()))
+ .Returns(() => handle.Value.Object);
+ rwLock.Setup(l => l.AcquireWriteLock(It.IsAny(), It.IsAny()))
+ .Returns(() => handle.Value.Object);
+ rwLock.Setup(l => l.TryAcquireWriteLock(It.IsAny(), It.IsAny()))
+ .Returns(() => handle.Value.Object);
+ }
+ else
+ {
+ rwLock.Setup(l => l.AcquireReadLockAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new TimeoutException());
+ rwLock.Setup(l => l.TryAcquireReadLockAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((IDistributedSynchronizationHandle?)null);
+ rwLock.Setup(l => l.AcquireWriteLockAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new TimeoutException());
+ rwLock.Setup(l => l.TryAcquireWriteLockAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((IDistributedSynchronizationHandle?)null);
+ rwLock.Setup(l => l.AcquireReadLock(It.IsAny(), It.IsAny()))
+ .Throws(new TimeoutException());
+ rwLock.Setup(l => l.TryAcquireReadLock(It.IsAny(), It.IsAny()))
+ .Returns((IDistributedSynchronizationHandle?)null);
+ rwLock.Setup(l => l.AcquireWriteLock(It.IsAny(), It.IsAny()))
+ .Throws(new TimeoutException());
+ rwLock.Setup(l => l.TryAcquireWriteLock(It.IsAny(), It.IsAny()))
+ .Returns((IDistributedSynchronizationHandle?)null);
+ }
+
+ return (provider, handle);
+ }
+}
\ No newline at end of file
diff --git a/src/DistributedLock.Tests/Tests/Chaining/ChainedDistributedSemaphoreProviderTests.cs b/src/DistributedLock.Tests/Tests/Chaining/ChainedDistributedSemaphoreProviderTests.cs
new file mode 100644
index 0000000..40471b2
--- /dev/null
+++ b/src/DistributedLock.Tests/Tests/Chaining/ChainedDistributedSemaphoreProviderTests.cs
@@ -0,0 +1,111 @@
+using Medallion.Threading.Chaining;
+using Moq;
+using NUnit.Framework;
+
+namespace Medallion.Threading.Tests.Chaining;
+
+[TestFixture]
+public class ChainedDistributedSemaphoreProviderTests
+{
+ private const string Name = "SemaphoreTest";
+
+ [Test]
+ public async Task Acquire_AllProvidersSucceed_ReturnsHandle()
+ {
+ var (p1, h1) = CreateProvider(true);
+ var (p2, h2) = CreateProvider(true);
+ var sut = new ChainedDistributedSemaphoreProvider(p1.Object, p2.Object);
+
+ await using (var handle = await sut.CreateSemaphore(Name, 1).AcquireAsync())
+ {
+ Assert.That(handle, Is.Not.Null);
+ }
+
+ h1.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ h2.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ }
+
+ [Test]
+ public void Acquire_PartialFailure_Throws()
+ {
+ var (p1, _) = CreateProvider(true);
+ var (p2, _) = CreateProvider(false);
+ var sut = new ChainedDistributedSemaphoreProvider(p1.Object, p2.Object);
+
+ Assert.ThrowsAsync(async () =>
+ {
+ await sut.CreateSemaphore(Name, 1).AcquireAsync();
+ });
+ }
+
+ [Test]
+ public async Task TryAcquire_AllProvidersSucceed_ReturnsHandle()
+ {
+ var (p1, h1) = CreateProvider(true);
+ var (p2, h2) = CreateProvider(true);
+ var sut = new ChainedDistributedSemaphoreProvider(p1.Object, p2.Object);
+
+ var handle = await sut.CreateSemaphore(Name, 1).TryAcquireAsync();
+ Assert.That(handle, Is.Not.Null);
+
+ if (handle != null)
+ {
+ await using (handle) { }
+ }
+
+ h1.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ h2.Value.Verify(x => x.DisposeAsync(), Times.Once);
+ }
+
+ [Test]
+ public async Task TryAcquire_PartialFailure_ReturnsNull()
+ {
+ var (p1, _) = CreateProvider(true);
+ var (p2, _) = CreateProvider(false);
+ var sut = new ChainedDistributedSemaphoreProvider(p1.Object, p2.Object);
+
+ var handle = await sut.CreateSemaphore(Name, 1).TryAcquireAsync();
+ Assert.That(handle, Is.Null);
+ }
+
+ private static (Mock provider,
+ Lazy> handle) CreateProvider(bool succeed)
+ {
+ var provider = new Mock();
+ var semaphore = new Mock();
+ var handle = new Lazy>(() =>
+ {
+ var h = new Mock();
+ h.Setup(x => x.HandleLostToken).Returns(CancellationToken.None);
+ h.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask);
+ return h;
+ });
+
+ provider.Setup(p => p.CreateSemaphore(Name, It.IsAny())).Returns(semaphore.Object);
+
+ if (succeed)
+ {
+ semaphore.Setup(s => s.AcquireAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => handle.Value.Object);
+ semaphore.Setup(s => s.TryAcquireAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => handle.Value.Object);
+ semaphore.Setup(s => s.Acquire(It.IsAny(), It.IsAny()))
+ .Returns(() => handle.Value.Object);
+ semaphore.Setup(s => s.TryAcquire(It.IsAny(), It.IsAny()))
+ .Returns(() => handle.Value.Object);
+ }
+ else
+ {
+ semaphore.Setup(s => s.AcquireAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new TimeoutException());
+ semaphore.Setup(s => s.TryAcquireAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((IDistributedSynchronizationHandle?)null);
+ semaphore.Setup(s => s.Acquire(It.IsAny(), It.IsAny()))
+ .Throws(new TimeoutException());
+ semaphore.Setup(s => s.TryAcquire(It.IsAny(), It.IsAny()))
+ .Returns((IDistributedSynchronizationHandle?)null);
+ }
+
+ return (provider, handle);
+ }
+}
\ No newline at end of file
diff --git a/src/DistributedLock.Tests/Tests/Core/CompositeDistributedSynchronizationHandleTest.cs b/src/DistributedLock.Tests/Tests/Core/CompositeDistributedSynchronizationHandleTest.cs
new file mode 100644
index 0000000..859cc56
--- /dev/null
+++ b/src/DistributedLock.Tests/Tests/Core/CompositeDistributedSynchronizationHandleTest.cs
@@ -0,0 +1,196 @@
+using Medallion.Threading.Chaining;
+using Moq;
+using NUnit.Framework;
+
+namespace Medallion.Threading.Tests.Core;
+
+[Category("CI")]
+public class CompositeDistributedSynchronizationHandleTest
+{
+ [Test]
+ public void TestDisposeMultipleTimes()
+ {
+ var handle1 = new Mock();
+ var handle2 = new Mock();
+ var sut = AcquireAll(Succeeds(handle1), Succeeds(handle2));
+
+ sut.Dispose();
+ handle1.Verify(h => h.Dispose(), Times.Once);
+ handle2.Verify(h => h.Dispose(), Times.Once);
+ sut.Dispose();
+ handle1.Verify(h => h.Dispose(), Times.Once);
+ handle2.Verify(h => h.Dispose(), Times.Once);
+
+ // HandleLostToken can be expensive on some providers, so it must never be touched implicitly
+ handle1.Verify(h => h.HandleLostToken, Times.Never);
+ handle2.Verify(h => h.HandleLostToken, Times.Never);
+ }
+
+ [Test]
+ public void TestHandleLostTokenIsLazy()
+ {
+ var handle1 = new Mock();
+ var handle2 = new Mock();
+ var sut = AcquireAll(Succeeds(handle1), Succeeds(handle2));
+
+ handle1.Verify(h => h.HandleLostToken, Times.Never);
+ handle2.Verify(h => h.HandleLostToken, Times.Never);
+
+ sut.HandleLostToken.ThrowIfCancellationRequested();
+
+ handle1.Verify(h => h.HandleLostToken, Times.Once);
+ handle2.Verify(h => h.HandleLostToken, Times.Once);
+
+ // the token is cached; the underlying handles are not consulted again
+ sut.HandleLostToken.ThrowIfCancellationRequested();
+ handle1.Verify(h => h.HandleLostToken, Times.Once);
+ handle2.Verify(h => h.HandleLostToken, Times.Once);
+
+ sut.Dispose();
+ }
+
+ [Test]
+ public void TestHandleLostTokenLinksUnderlyingTokens()
+ {
+ using var source = new CancellationTokenSource();
+ var handle1 = new Mock();
+ handle1.SetupGet(h => h.HandleLostToken).Returns(source.Token);
+ var handle2 = new Mock();
+
+ using var sut = AcquireAll(Succeeds(handle1), Succeeds(handle2));
+
+ var token = sut.HandleLostToken;
+ Assert.That(token.CanBeCanceled, Is.True);
+ Assert.That(token.IsCancellationRequested, Is.False);
+ source.Cancel();
+ Assert.That(token.IsCancellationRequested, Is.True);
+ }
+
+ [Test]
+ public void TestHandleLostTokenThrowsAfterDispose()
+ {
+ var sut = AcquireAll(
+ Succeeds(new Mock()),
+ Succeeds(new Mock()));
+ sut.Dispose();
+
+ Assert.Throws(() => sut.HandleLostToken.ThrowIfCancellationRequested());
+ }
+
+ private static IDistributedSynchronizationHandle AcquireAll(params TestPrimitive[] primitives)
+ {
+ var result = CompositeDistributedSynchronizationHandle.TryAcquireAllAsync(
+ primitives,
+ static (p, t, c) => p.TryAcquireAsync(t, c),
+ Timeout.InfiniteTimeSpan,
+ CancellationToken.None)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult();
+ var handle = result.GetHandleOrDefault();
+ Assert.That(handle, Is.Not.Null);
+ return handle!;
+ }
+
+ [Test]
+ public async Task TestAcquiresSequentiallyAndStopsAtFirstFailure()
+ {
+ List acquisitionOrder = [];
+ var acquired = new Mock();
+
+ TestPrimitive Recording(string name, bool succeeds) => new(
+ name,
+ (_, _) =>
+ {
+ acquisitionOrder.Add(name);
+ return new(succeeds ? acquired.Object : null);
+ });
+
+ var result = await TryAcquireAllAsync(
+ Timeout.InfiniteTimeSpan,
+ CancellationToken.None,
+ Recording("first", succeeds: true),
+ Recording("fails", succeeds: false),
+ Recording("never-attempted", succeeds: true));
+
+ Assert.That(result.GetHandleOrDefault(), Is.Null);
+ Assert.That(acquisitionOrder, Is.EqualTo(new[] { "first", "fails" }));
+ acquired.Verify(h => h.DisposeAsync(), Times.Once);
+ }
+
+ [Test]
+ public async Task TestTimeoutNamesTheFailedPrimitive()
+ {
+ var result = await TryAcquireAllAsync(
+ TimeSpan.FromSeconds(30),
+ CancellationToken.None,
+ new TestPrimitive("succeeds", (_, _) => new(new Mock().Object)),
+ new TestPrimitive("cannot-be-acquired", (_, _) => default));
+
+ var exception = Assert.Throws(() => _ = result.Handle);
+ Assert.That(exception!.Message, Does.Contain("cannot-be-acquired"));
+ }
+
+ [Test]
+ public void TestAcquisitionExceptionPropagatesAndDisposesAcquiredHandles()
+ {
+ var acquired = new Mock();
+
+ var task = TryAcquireAllAsync(
+ Timeout.InfiniteTimeSpan,
+ CancellationToken.None,
+ new TestPrimitive("acquired", (_, _) => new(acquired.Object)),
+ new TestPrimitive("throws", (_, _) => throw new InvalidOperationException("boom"))).AsTask();
+
+ var exception = Assert.ThrowsAsync(() => task);
+ Assert.That(exception!.Message, Is.EqualTo("boom"));
+ acquired.Verify(h => h.DisposeAsync(), Times.Once);
+ }
+
+ [Test]
+ public async Task TestSinglePrimitiveUsesFastPath()
+ {
+ var handle = new Mock();
+ var result = await TryAcquireAllAsync(
+ Timeout.InfiniteTimeSpan,
+ CancellationToken.None,
+ new TestPrimitive("only", (_, _) => new(handle.Object)));
+
+ // the single-primitive fast path returns the underlying handle rather than wrapping it
+ Assert.That(result.GetHandleOrDefault(), Is.SameAs(handle.Object));
+ }
+
+ [Test]
+ public void TestRequiresAtLeastOnePrimitive()
+ {
+ Assert.Throws(() => new ChainedDistributedLock("name", []));
+ Assert.Throws(() => new ChainedDistributedLock("name", null!));
+ Assert.Throws(
+ () => new ChainedDistributedLock("name", [new Mock().Object, null!]));
+ }
+
+ private static ValueTask TryAcquireAllAsync(
+ TimeSpan? timeout,
+ CancellationToken cancellationToken,
+ params TestPrimitive[] primitives) =>
+ CompositeDistributedSynchronizationHandle.TryAcquireAllAsync(
+ primitives,
+ static (p, t, c) => p.TryAcquireAsync(t, c),
+ timeout,
+ cancellationToken);
+
+ internal static TestPrimitive Succeeds(Mock handle) =>
+ new(handle.Object.GetHashCode().ToString(), (_, _) => new(handle.Object));
+
+ internal sealed class TestPrimitive(
+ string name,
+ Func> tryAcquireAsync)
+ {
+ public string Name => name;
+
+ public ValueTask TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
+ tryAcquireAsync(timeout, cancellationToken);
+
+ public override string ToString() => name;
+ }
+}