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; + } +}