diff --git a/DistributedLock.Azure/AzureBlobLeaseDistributedLock.IDistributedLock.cs b/DistributedLock.Azure/AzureBlobLeaseDistributedLock.IDistributedLock.cs deleted file mode 100644 index 8314ce08..00000000 --- a/DistributedLock.Azure/AzureBlobLeaseDistributedLock.IDistributedLock.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Medallion.Threading.Internal; - -namespace Medallion.Threading.Azure -{ - public partial class AzureBlobLeaseDistributedLock - { - // AUTO-GENERATED - - IDistributedLockHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquire(timeout, cancellationToken); - IDistributedLockHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => - this.Acquire(timeout, cancellationToken); - ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - - /// - /// Attempts to acquire the lock synchronously. Usage: - /// - /// using (var handle = myLock.TryAcquire(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - public AzureBlobLeaseDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); - - /// - /// Acquires the lock synchronously, failing with if the attempt times out. Usage: - /// - /// using (myLock.Acquire(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - public AzureBlobLeaseDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.Acquire(this, timeout, cancellationToken); - - /// - /// Attempts to acquire the lock asynchronously. Usage: - /// - /// await using (var handle = await myLock.TryAcquireAsync(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - this.As>().InternalTryAcquireAsync(timeout, cancellationToken); - - /// - /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: - /// - /// await using (await myLock.AcquireAsync(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); - } -} \ No newline at end of file diff --git a/DistributedLock.Azure/AzureBlobLeaseDistributedLock.cs b/DistributedLock.Azure/AzureBlobLeaseDistributedLock.cs deleted file mode 100644 index 6d72e692..00000000 --- a/DistributedLock.Azure/AzureBlobLeaseDistributedLock.cs +++ /dev/null @@ -1,301 +0,0 @@ -using Azure; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Specialized; -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Azure -{ - /// - /// Implements a based on Azure blob leases - /// - public sealed partial class AzureBlobLeaseDistributedLock : IInternalDistributedLock - { - /// - /// Metadata marker used to indicate that a blob was created for distributed locking and therefore - /// should be destroyed upon release - /// - private static readonly string CreatedMetadataKey = $"__DistributedLock"; - - private readonly BlobClientWrapper _blobClient; - private readonly (TimeoutValue duration, TimeoutValue renewalCadence, TimeSpan minBusyWaitSleepTime, TimeSpan maxBusyWaitSleepTime) _options; - - /// - /// Constructs a lock that will lease the provided - /// - public AzureBlobLeaseDistributedLock(BlobBaseClient blobClient, Action? options = null) - { - this._blobClient = new BlobClientWrapper(blobClient ?? throw new ArgumentNullException(nameof(blobClient))); - this._options = AzureBlobLeaseOptionsBuilder.GetOptions(options); - } - - /// - /// Constructs a lock that will lease a blob based on within the provided . - /// - public AzureBlobLeaseDistributedLock(BlobContainerClient blobContainerClient, string name, Action? options = null) - { - if (blobContainerClient == null) { throw new ArgumentNullException(nameof(blobContainerClient)); } - if (name == null) { throw new ArgumentNullException(nameof(name)); } - - this._blobClient = new BlobClientWrapper(blobContainerClient.GetBlobClient(GetSafeName(name, blobContainerClient))); - this._options = AzureBlobLeaseOptionsBuilder.GetOptions(options); - } - - /// - /// Implements - /// - public string Name => this._blobClient.Name; - - bool IDistributedLock.IsReentrant => false; - - // implementation based on https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#blob-names - internal static string GetSafeName(string name, BlobContainerClient blobContainerClient) - { - var maxLength = IsStorageEmulator() ? 256 : 1024; - - return DistributedLockHelpers.ToSafeName(name, maxLength, s => ConvertToValidName(s)); - - // check based on - // https://docs.microsoft.com/en-us/azure/storage/common/storage-use-emulator#connect-to-the-emulator-account-using-the-well-known-account-name-and-key - bool IsStorageEmulator() => blobContainerClient.Uri.IsAbsoluteUri - && blobContainerClient.Uri.AbsoluteUri.StartsWith("http://127.0.0.1:10000/devstoreaccount1", StringComparison.Ordinal); - - static string ConvertToValidName(string name) - { - const int MaxSlashes = 253; // allowed to have up to 254 segments, which means 253 slashes - - if (name.Length == 0) { return "__EMPTY__"; } - - StringBuilder? builder = null; - var slashCount = 0; - for (var i = 0; i < name.Length; ++i) - { - var @char = name[i]; - - // enforce cap on # path segments and note that trailing slash or DOT are - // discouraged - - if ((@char == '/' || @char == '\\') - && (++slashCount > MaxSlashes || i == name.Length - 1)) - { - EnsureBuilder().Append("SLASH"); - } - else if (@char == '.' && i == name.Length - 1) - { - EnsureBuilder().Append("DOT"); - } - else - { - builder?.Append(@char); - } - - StringBuilder EnsureBuilder() => builder ??= new StringBuilder().Append(name, startIndex: 0, count: i); - } - - return builder?.ToString() ?? name; - } - } - - ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => - BusyWaitHelper.WaitAsync( - (@lock: this, leaseClient: this._blobClient.GetBlobLeaseClient()), - (state, token) => state.@lock.TryAcquireAsync(state.leaseClient, token, isRetryAfterCreate: false), - timeout, - minSleepTime: this._options.minBusyWaitSleepTime, - maxSleepTime: this._options.maxBusyWaitSleepTime, - cancellationToken - ); - - private async ValueTask TryAcquireAsync( - BlobLeaseClientWrapper leaseClient, - CancellationToken cancellationToken, - bool isRetryAfterCreate) - { - try { await leaseClient.AcquireAsync(this._options.duration, cancellationToken).ConfigureAwait(false); } - catch (RequestFailedException acquireException) - { - if (acquireException.ErrorCode == AzureErrors.LeaseAlreadyPresent) { return null; } - - if (acquireException.ErrorCode == AzureErrors.BlobNotFound) - { - // if we just created and it already doesn't exist again, just return null and retry later - if (isRetryAfterCreate) { return null; } - - // create the blob - var metadata = new Dictionary { [CreatedMetadataKey] = DateTime.UtcNow.ToString("o") }; // date value is just for debugging - try { await this._blobClient.CreateIfNotExistsAsync(metadata, cancellationToken).ConfigureAwait(false); } - catch (RequestFailedException createException) - { - // handle the race condition where we try to create and someone else creates it first - return createException.ErrorCode == AzureErrors.LeaseIdMissing - ? default(AzureBlobLeaseDistributedLockHandle?) - : throw new AggregateException($"Blob {this._blobClient.Name} does not exist and could not be created. See inner exceptions for details", acquireException, createException); - } - - try { return await this.TryAcquireAsync(leaseClient, cancellationToken, isRetryAfterCreate: true).ConfigureAwait(false); } - catch (Exception retryException) - { - // if the retry fails and we created, attempt deletion to clean things up - try { await this._blobClient.DeleteIfExistsAsync().ConfigureAwait(false); } - catch (Exception deletionException) - { - throw new AggregateException(retryException, deletionException); - } - - throw; - } - } - - throw; - } - - var shouldDeleteBlob = isRetryAfterCreate - || (await this._blobClient.GetMetadataAsync(leaseClient.LeaseId, cancellationToken).ConfigureAwait(false)).ContainsKey(CreatedMetadataKey); - - var internalHandle = new InternalHandle(leaseClient, ownsBlob: shouldDeleteBlob, @lock: this); - return new AzureBlobLeaseDistributedLockHandle(internalHandle); - } - - // todo remove - bool IInternalDistributedLock.WillGoAsync(TimeoutValue timeout, CancellationToken cancellationToken) => false; - - internal sealed class InternalHandle : IDistributedLockHandle - { - private readonly CancellationTokenSource _renewalCancellationSource = new CancellationTokenSource(); - private readonly BlobLeaseClientWrapper _leaseClient; - private readonly bool _ownsBlob; - private readonly AzureBlobLeaseDistributedLock _lock; - private readonly Task _renewalTask; - - public InternalHandle(BlobLeaseClientWrapper leaseClient, bool ownsBlob, AzureBlobLeaseDistributedLock @lock) - { - this._leaseClient = leaseClient; - this._ownsBlob = ownsBlob; - this._lock = @lock; - var handleLostSource = new CancellationTokenSource(); - this.HandleLostToken = handleLostSource.Token; - this._renewalTask = StartRenewalOrHandleCheckTask(new WeakReference(this), handleLostSource, this._renewalCancellationSource.Token); - - // static function to make sure we don't capture this - static Task StartRenewalOrHandleCheckTask(WeakReference weakThis, CancellationTokenSource handleLostSource, CancellationToken cancellationToken) => - Task.Run(() => RenewalOrHandleCheckLoop(weakThis, handleLostSource, cancellationToken)); - } - - public CancellationToken HandleLostToken { get; } - - private bool RenewalEnabled => !this._lock._options.renewalCadence.IsInfinite; - - public string LeaseId => this._leaseClient.LeaseId; - - public void Dispose() => SyncOverAsync.Run(@this => @this.DisposeAsync(), this, false); - - public async ValueTask DisposeAsync() - { - if (this._renewalCancellationSource.IsCancellationRequested) - { - return; // already disposed - } - - this._renewalCancellationSource.Cancel(); - if (SyncOverAsync.IsSynchronous) { this._renewalTask.GetAwaiter().GetResult(); } - else { await this._renewalTask.ConfigureAwait(false); } - this._renewalCancellationSource.Dispose(); - - // if we own the blob, release by just deleting it - if (this._ownsBlob) - { - await this._lock._blobClient.DeleteIfExistsAsync(leaseId: this._leaseClient.LeaseId).ConfigureAwait(false); - } - else - { - await this._leaseClient.ReleaseAsync().ConfigureAwait(false); - } - } - - private static async Task RenewalOrHandleCheckLoop( - WeakReference weakThis, - CancellationTokenSource handleLostSource, - CancellationToken cancellationToken) - { - if (!TryGetCadence(weakThis, out var cadence)) - { - handleLostSource.Dispose(); - return; - } - - while (true) - { - // avoid throwing since this will be canceled very commonly and since this task is awaited on dispose - await Task.Delay(cadence.InMilliseconds, cancellationToken).TryAwait(); - if (cancellationToken.IsCancellationRequested) - { - handleLostSource.Dispose(); - return; - } - - var renewOrCheckStatus = await TryRenewOrCheckHandleAsync(weakThis, cancellationToken).ConfigureAwait(false); - if (renewOrCheckStatus != RenewOrCheckStatus.Continue) - { - if (renewOrCheckStatus == RenewOrCheckStatus.HandleLost) - { - // offload cancel to a background thread to avoid hangs or errors - var ignored = Task.Run(() => - { - try { handleLostSource.Cancel(); } - finally { handleLostSource.Dispose(); } - }); - } - else - { - handleLostSource.Dispose(); - } - return; - } - } - - // separate function to avoid taking a strong reference - static bool TryGetCadence(WeakReference weakThis, out TimeoutValue renewalCadence) - { - if (weakThis.TryGetTarget(out var @this)) - { - renewalCadence = @this.RenewalEnabled ? @this._lock._options.renewalCadence : @this._lock._options.duration; - return true; - } - - renewalCadence = default; - return false; - } - } - - private static async ValueTask TryRenewOrCheckHandleAsync( - WeakReference weakThis, - CancellationToken cancellationToken) - { - if (!weakThis.TryGetTarget(out var @this)) { return RenewOrCheckStatus.HandleGarbageCollected; } - - var task = @this.RenewalEnabled - ? @this._leaseClient.RenewAsync(cancellationToken).AsTask() - // if we're not renewing, then just touch the blob using the lease to see if someone else has renewed it - : @this._lock._blobClient.GetMetadataAsync(@this._leaseClient.LeaseId, cancellationToken).AsTask(); - - await task.TryAwait(); - return task.Status == TaskStatus.RanToCompletion ? RenewOrCheckStatus.Continue - : cancellationToken.IsCancellationRequested ? RenewOrCheckStatus.Canceled - : RenewOrCheckStatus.HandleLost; - } - - private enum RenewOrCheckStatus - { - Continue, - Canceled, - HandleLost, - HandleGarbageCollected, - } - } - } -} diff --git a/DistributedLock.Azure/AzureBlobLeaseDistributedLockHandle.cs b/DistributedLock.Azure/AzureBlobLeaseDistributedLockHandle.cs deleted file mode 100644 index a9d69789..00000000 --- a/DistributedLock.Azure/AzureBlobLeaseDistributedLockHandle.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Azure -{ - /// - /// Implements - /// - public sealed class AzureBlobLeaseDistributedLockHandle : IDistributedLockHandle - { - private AzureBlobLeaseDistributedLock.InternalHandle? _internalHandle; - private IDisposable? _finalizerRegistration; - - internal AzureBlobLeaseDistributedLockHandle(AzureBlobLeaseDistributedLock.InternalHandle internalHandle) - { - this._internalHandle = internalHandle; - this._finalizerRegistration = ManagedFinalizerQueue.Instance.Register(this, internalHandle); - } - - /// - /// Implements - /// - public CancellationToken HandleLostToken => (this._internalHandle ?? throw this.ObjectDisposed()).HandleLostToken; - - /// - /// The underlying Azure lease ID - /// - public string LeaseId => (this._internalHandle ?? throw this.ObjectDisposed()).LeaseId; - - /// - /// Releases the lock - /// - public void Dispose() => SyncOverAsync.Run(@this => @this.DisposeAsync(), this, false); - - /// - /// Releases the lock asynchronously - /// - public ValueTask DisposeAsync() - { - Interlocked.Exchange(ref this._finalizerRegistration, null)?.Dispose(); - return Interlocked.Exchange(ref this._internalHandle, null)?.DisposeAsync() ?? default; - } - } -} diff --git a/DistributedLock.Azure/AzureBlobLeaseOptionsBuilder.cs b/DistributedLock.Azure/AzureBlobLeaseOptionsBuilder.cs deleted file mode 100644 index fa7e4388..00000000 --- a/DistributedLock.Azure/AzureBlobLeaseOptionsBuilder.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Azure -{ - /// - /// Specifies options for an Azure blob lease - /// - public sealed class AzureBlobLeaseOptionsBuilder - { - /// - /// From https://docs.microsoft.com/en-us/rest/api/storageservices/lease-blob: - /// "The lock duration can be 15 to 60 seconds, or can be infinite" - /// - internal static readonly TimeoutValue MinLeaseDuration = TimeSpan.FromSeconds(15), - MaxNonInfiniteLeaseDuration = TimeSpan.FromSeconds(60), - DefaultLeaseDuration = TimeSpan.FromSeconds(30); - - private TimeoutValue? _duration, _renewalCadence, _minBusyWaitSleepTime, _maxBusyWaitSleepTime; - - internal AzureBlobLeaseOptionsBuilder() { } - - /// - /// Specifies how long the lease will last, absent auto-renewal. - /// - /// If auto-renewal is enabled (the default), then a shorter duration means more frequent auto-renewal requests, - /// while an infinite duration means no auto-renewal requests. Furthermore, if the lease-holding process were to - /// exit without explicitly releasing, then duration determines how long other processes would need to wait in - /// order to acquire the lease. - /// - /// If auto-renewal is disabled, then duration determines how long the lease will be held. - /// - /// Defaults to 30s. - /// - public AzureBlobLeaseOptionsBuilder Duration(TimeSpan duration) - { - var durationTimeoutValue = new TimeoutValue(duration, nameof(duration)); - if (durationTimeoutValue.CompareTo(MinLeaseDuration) < 0 - || (!durationTimeoutValue.IsInfinite && durationTimeoutValue.CompareTo(MaxNonInfiniteLeaseDuration) > 0)) - { - throw new ArgumentOutOfRangeException(nameof(duration), duration, $"Must be infinite or in [{MinLeaseDuration}, {MaxNonInfiniteLeaseDuration}]"); - } - - this._duration = durationTimeoutValue; - return this; - } - - /// - /// Determines how frequently the lease will be renewed when held. More frequent renewal means more unnecessary requests - /// but also a lower chance of losing the lease due to the process hanging or otherwise failing to get its renewal request in - /// before the lease duration expires. - /// - /// To disable auto-renewal, specify - /// - /// Defaults to 1/3 of the specified lease duration (may be infinite). - /// - public AzureBlobLeaseOptionsBuilder RenewalCadence(TimeSpan renewalCadence) - { - this._renewalCadence = new TimeoutValue(renewalCadence, nameof(renewalCadence)); - return this; - } - - /// - /// Waiting to acquire a lease requires a busy wait that alternates acquire attempts and sleeps. - /// This determines how much time is spent sleeping between attempts. Lower values will raise the - /// volume of acquire requests under contention but will also raise the responsiveness (how long - /// it takes a waiter to notice that a contended the lease has become available). - /// - /// Specifying a range of values allows the implementation to select an actual value in the range - /// at random for each sleep. This helps avoid the case where two clients become "synchronized" - /// in such a way that results in one client monopolizing the lease. - /// - /// The default is [250ms, 1s] - /// - public AzureBlobLeaseOptionsBuilder BusyWaitSleepTime(TimeSpan min, TimeSpan max) - { - var minTimeoutValue = new TimeoutValue(min, nameof(min)); - var maxTimeoutValue = new TimeoutValue(max, nameof(max)); - - if (minTimeoutValue.IsInfinite) { throw new ArgumentOutOfRangeException(nameof(min), "may not be infinite"); } - if (maxTimeoutValue.IsInfinite || maxTimeoutValue.CompareTo(min) < 0) - { - throw new ArgumentOutOfRangeException(nameof(max), max, "must be non-infinite and greater than " + nameof(min)); - } - - this._minBusyWaitSleepTime = minTimeoutValue; - this._maxBusyWaitSleepTime = maxTimeoutValue; - return this; - } - - internal static (TimeoutValue duration, TimeoutValue renewalCadence, TimeSpan minBusyWaitSleepTime, TimeSpan maxBusyWaitSleepTime) GetOptions(Action? optionsBuilder) - { - AzureBlobLeaseOptionsBuilder? options; - if (optionsBuilder != null) - { - options = new AzureBlobLeaseOptionsBuilder(); - optionsBuilder(options); - - if (options._renewalCadence is { } renewalCadence && !renewalCadence.IsInfinite) - { - var duration = options._duration ?? DefaultLeaseDuration; - if (renewalCadence.CompareTo(duration) >= 0) - { - throw new ArgumentOutOfRangeException( - nameof(renewalCadence), - renewalCadence.TimeSpan, - $"{nameof(renewalCadence)} must not be larger than {nameof(duration)} ({duration}). To disable auto-renewal, specify {nameof(Timeout)}.{nameof(Timeout.InfiniteTimeSpan)}" - ); - } - } - } - else - { - options = null; - } - - var durationToUse = options?._duration ?? DefaultLeaseDuration; - return ( - duration: durationToUse, - renewalCadence: options?._renewalCadence ?? TimeSpan.FromMilliseconds(durationToUse.InMilliseconds / 3.0), - minBusyWaitSleepTime: options?._minBusyWaitSleepTime?.TimeSpan ?? TimeSpan.FromMilliseconds(250), - maxBusyWaitSleepTime: options?._maxBusyWaitSleepTime?.TimeSpan ?? TimeSpan.FromSeconds(1) - ); - } - } -} diff --git a/DistributedLock.Azure/AzureErrors.cs b/DistributedLock.Azure/AzureErrors.cs deleted file mode 100644 index e8089cee..00000000 --- a/DistributedLock.Azure/AzureErrors.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Azure; -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading.Tasks; - -namespace Medallion.Threading.Azure -{ - internal static class AzureErrors - { - public const string BlobNotFound = nameof(BlobNotFound), - LeaseAlreadyPresent = nameof(LeaseAlreadyPresent), - LeaseIdMissing = nameof(LeaseIdMissing); - } -} diff --git a/DistributedLock.Azure/BlobClientWrapper.cs b/DistributedLock.Azure/BlobClientWrapper.cs deleted file mode 100644 index 9dd45b30..00000000 --- a/DistributedLock.Azure/BlobClientWrapper.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using Azure.Storage.Blobs.Specialized; -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Azure -{ - /// - /// Adds support to - /// - internal class BlobClientWrapper - { - private readonly BlobBaseClient _blobClient; - - public BlobClientWrapper(BlobBaseClient blobClient) - { - this._blobClient = blobClient; - } - - public string Name => this._blobClient.Name; - - public BlobLeaseClientWrapper GetBlobLeaseClient() => new BlobLeaseClientWrapper(this._blobClient.GetBlobLeaseClient()); - - public async ValueTask> GetMetadataAsync(string leaseId, CancellationToken cancellationToken) - { - var conditions = new BlobRequestConditions { LeaseId = leaseId }; - var properties = SyncOverAsync.IsSynchronous - ? this._blobClient.GetProperties(conditions, cancellationToken) - : await this._blobClient.GetPropertiesAsync(conditions, cancellationToken).ConfigureAwait(false); - return properties.Value.Metadata; - } - - public ValueTask CreateIfNotExistsAsync(IDictionary metadata, CancellationToken cancellationToken) - { - switch (this._blobClient) - { - case BlobClient blobClient: - if (SyncOverAsync.IsSynchronous) - { - blobClient.Upload(Stream.Null, metadata: metadata, cancellationToken: cancellationToken); - return default; - } - return new ValueTask(blobClient.UploadAsync(Stream.Null, metadata: metadata, cancellationToken: cancellationToken)); - case BlockBlobClient blockBlobClient: - if (SyncOverAsync.IsSynchronous) - { - blockBlobClient.Upload(Stream.Null, metadata: metadata, cancellationToken: cancellationToken); - return default; - } - return new ValueTask(blockBlobClient.UploadAsync(Stream.Null, metadata: metadata, cancellationToken: cancellationToken)); - case PageBlobClient pageBlobClient: - if (SyncOverAsync.IsSynchronous) - { - pageBlobClient.CreateIfNotExists(size: 0, metadata: metadata, cancellationToken: cancellationToken); - return default; - } - return new ValueTask(pageBlobClient.CreateIfNotExistsAsync(size: 0, metadata: metadata, cancellationToken: cancellationToken)); - case AppendBlobClient appendBlobClient: - if (SyncOverAsync.IsSynchronous) - { - appendBlobClient.CreateIfNotExists(metadata: metadata, cancellationToken: cancellationToken); - return default; - } - return new ValueTask(appendBlobClient.CreateIfNotExistsAsync(metadata: metadata, cancellationToken: cancellationToken)); - default: - throw new InvalidOperationException( - this._blobClient.GetType() == typeof(BlobBaseClient) - ? $"Unable to create a lock blob given client type {typeof(BlobBaseClient)}. Either ensure that the blob exists or use a non-base client type such as {typeof(BlobClient)}" - + " which specifies the type of blob to create" - : $"Unexpected blob client type {this._blobClient.GetType()}" - ); - } - } - - public ValueTask DeleteIfExistsAsync(string? leaseId = null) - { - var conditions = leaseId != null ? new BlobRequestConditions { LeaseId = leaseId } : null; - if (SyncOverAsync.IsSynchronous) - { - this._blobClient.DeleteIfExists(conditions: conditions); - return default; - } - return new ValueTask(this._blobClient.DeleteIfExistsAsync(conditions: conditions)); - } - } -} diff --git a/DistributedLock.Azure/BlobLeaseClientWrapper.cs b/DistributedLock.Azure/BlobLeaseClientWrapper.cs deleted file mode 100644 index aca95c32..00000000 --- a/DistributedLock.Azure/BlobLeaseClientWrapper.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Azure.Storage.Blobs.Specialized; -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Azure -{ - /// - /// Adds support to - /// - internal sealed class BlobLeaseClientWrapper - { - private readonly BlobLeaseClient _blobLeaseClient; - - public BlobLeaseClientWrapper(BlobLeaseClient blobLeaseClient) - { - this._blobLeaseClient = blobLeaseClient; - } - - public string LeaseId => this._blobLeaseClient.LeaseId; - - public ValueTask AcquireAsync(TimeoutValue duration, CancellationToken cancellationToken) - { - if (SyncOverAsync.IsSynchronous) - { - this._blobLeaseClient.Acquire(duration.TimeSpan, cancellationToken: cancellationToken); - return default; - } - return new ValueTask(this._blobLeaseClient.AcquireAsync(duration.TimeSpan, cancellationToken: cancellationToken)); - } - - public ValueTask RenewAsync(CancellationToken cancellationToken) - { - if (SyncOverAsync.IsSynchronous) - { - this._blobLeaseClient.Renew(cancellationToken: cancellationToken); - return default; - } - return new ValueTask(this._blobLeaseClient.RenewAsync(cancellationToken: cancellationToken)); - } - - public ValueTask ReleaseAsync() - { - if (SyncOverAsync.IsSynchronous) - { - this._blobLeaseClient.Release(); - return default; - } - return new ValueTask(this._blobLeaseClient.ReleaseAsync()); - } - } -} diff --git a/DistributedLock.Azure/BusyWaitHelper.cs b/DistributedLock.Azure/BusyWaitHelper.cs deleted file mode 100644 index 94f60d89..00000000 --- a/DistributedLock.Azure/BusyWaitHelper.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Azure -{ - internal static class BusyWaitHelper - { - public static async ValueTask WaitAsync( - TState state, - Func> tryGetValue, - TimeoutValue timeout, - TimeSpan minSleepTime, - TimeSpan maxSleepTime, - CancellationToken cancellationToken) - where TResult : class - { - var initialResult = await tryGetValue(state, cancellationToken).ConfigureAwait(false); - if (initialResult != null || timeout.IsZero) - { - return initialResult; - } - - using var _ = CreateMergedCancellationTokenSourceSource(timeout, cancellationToken, out var mergedCancellationToken); - - var random = new Random(Guid.NewGuid().GetHashCode()); - var sleepRangeMillis = maxSleepTime.TotalMilliseconds - minSleepTime.TotalMilliseconds; - while (true) - { - var sleepTime = minSleepTime + TimeSpan.FromMilliseconds(random.NextDouble() * sleepRangeMillis); - try - { - await SyncOverAsync.Delay(sleepTime, mergedCancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (IsTimedOut()) - { - // if we time out while sleeping, always try one more time with just the regular token - return await tryGetValue(state, cancellationToken).ConfigureAwait(false); - } - - try - { - var result = await tryGetValue(state, mergedCancellationToken).ConfigureAwait(false); - if (result != null) { return result; } - } - catch (OperationCanceledException) when (IsTimedOut()) - { - return null; - } - } - - bool IsTimedOut() => - mergedCancellationToken.IsCancellationRequested && !cancellationToken.IsCancellationRequested; - } - - private static IDisposable? CreateMergedCancellationTokenSourceSource(TimeoutValue timeout, CancellationToken cancellationToken, out CancellationToken mergedCancellationToken) - { - if (timeout.IsInfinite) - { - mergedCancellationToken = cancellationToken; - return null; - } - - if (!cancellationToken.CanBeCanceled) - { - var timeoutSource = new CancellationTokenSource(millisecondsDelay: timeout.InMilliseconds); - mergedCancellationToken = timeoutSource.Token; - return timeoutSource; - } - - var mergedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - mergedSource.CancelAfter(timeout.InMilliseconds); - mergedCancellationToken = mergedSource.Token; - return mergedSource; - } - } -} diff --git a/DistributedLock.Core/AssemblyAttributes.cs b/DistributedLock.Core/AssemblyAttributes.cs deleted file mode 100644 index 30f8bfc6..00000000 --- a/DistributedLock.Core/AssemblyAttributes.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] - -// Note: we allow for internals sharing in release only. This allows us to have certain -// internal APIs which are public in DEBUG and internal in RELEASE. That way, we can't -// build in DEBUG if we rely on internal APIs that are not meant to be public -#if !DEBUG -[assembly: InternalsVisibleTo("DistributedLock.WaitHandles, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] -[assembly: InternalsVisibleTo("DistributedLock.SqlServer, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] -[assembly: InternalsVisibleTo("DistributedLock.Postgres, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] -[assembly: InternalsVisibleTo("DistributedLock.Azure, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] -#endif diff --git a/DistributedLock.Core/DeadlockException.cs b/DistributedLock.Core/DeadlockException.cs deleted file mode 100644 index 56a5cbce..00000000 --- a/DistributedLock.Core/DeadlockException.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; - -namespace Medallion.Threading -{ - /// - /// An exception that SOME distributed locks will throw under SOME deadlock conditions. Note that even locks - /// that throw this exception under some circumstances cannot detect ALL deadlock conditions - /// - [Serializable] - public sealed class DeadlockException - // for backwards compat - : InvalidOperationException - { - /// - /// Constructs a new instance of with a default message - /// - public DeadlockException() : this("A deadlock occurred") { } - - /// - /// Constructs an instance of with the given - /// - public DeadlockException(string message) : base(message) { } - - /// - /// Constructs an instance of with the given and - /// - public DeadlockException(string message, Exception innerException) : base(message, innerException) { } - - private DeadlockException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - } -} diff --git a/DistributedLock.Core/DistributedLockProviderExtensions.cs b/DistributedLock.Core/DistributedLockProviderExtensions.cs deleted file mode 100644 index 5cfe1c7c..00000000 --- a/DistributedLock.Core/DistributedLockProviderExtensions.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading -{ - /// - /// Productivity helper methods for - /// - public static class DistributedLockProviderExtensions - { - // todo review hiding of these methods vs. implementation-specific APIs - - /// - /// Equivalent to calling and then - /// - /// - public static IDistributedLockHandle? TryAcquire( - this IDistributedLockProvider provider, - string name, - TimeSpan timeout = default, - CancellationToken cancellationToken = default, - bool exactName = false) => - CreateLock(provider, name, exactName).TryAcquire(timeout, cancellationToken); - - /// - /// Equivalent to calling and then - /// - /// - public static IDistributedLockHandle Acquire( - this IDistributedLockProvider provider, - string name, - TimeSpan? timeout = default, - CancellationToken cancellationToken = default, - bool exactName = false) => - CreateLock(provider, name, exactName).Acquire(timeout, cancellationToken); - - /// - /// Equivalent to calling and then - /// - /// - public static ValueTask TryAcquireAsync( - this IDistributedLockProvider provider, - string name, - TimeSpan timeout = default, - CancellationToken cancellationToken = default, - bool exactName = false) => - CreateLock(provider, name, exactName).TryAcquireAsync(timeout, cancellationToken); - - /// - /// Equivalent to calling and then - /// - /// - public static ValueTask AcquireAsync( - this IDistributedLockProvider provider, - string name, - TimeSpan? timeout = default, - CancellationToken cancellationToken = default, - bool exactName = false) => - CreateLock(provider, name, exactName).AcquireAsync(timeout, cancellationToken); - - private static IDistributedLock CreateLock(IDistributedLockProvider provider, string name, bool exactName) => - (provider ?? throw new ArgumentNullException(nameof(provider))).CreateLock(name, exactName); - } -} diff --git a/DistributedLock.Core/IDistributedLock.cs b/DistributedLock.Core/IDistributedLock.cs deleted file mode 100644 index d95c9bb0..00000000 --- a/DistributedLock.Core/IDistributedLock.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading -{ - /// - /// A mutex synchronization primitive which can be used to coordinate access to a resource or critical region of code - /// across processes or systems. The scope and capabilities of the lock are dependent on the particular implementation - /// - public interface IDistributedLock - { - /// - /// A name that uniquely identifies the lock - /// - string Name { get; } - - /// - /// Whether the lock can be acquired multiple times by the same user. - /// Equivalent to - /// - bool IsReentrant { get; } - - /// - /// Attempts to acquire the lock synchronously. Usage: - /// - /// using (var handle = myLock.TryAcquire(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - IDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default); - - /// - /// Acquires the lock synchronously, failing with if the attempt times out. Usage: - /// - /// using (myLock.Acquire(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - IDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - - /// - /// Attempts to acquire the lock asynchronously. Usage: - /// - /// await using (var handle = await myLock.TryAcquireAsync(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); - - /// - /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: - /// - /// await using (await myLock.AcquireAsync(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - } -} diff --git a/DistributedLock.Core/IDistributedLockHandle.cs b/DistributedLock.Core/IDistributedLockHandle.cs deleted file mode 100644 index d5001024..00000000 --- a/DistributedLock.Core/IDistributedLockHandle.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Threading; - -namespace Medallion.Threading -{ - // todo IDistributedSynchronizationHandle? - - /// - /// A handle to a distributed lock or other synchronization primitive. To unlock/release, - /// simply dispose the handle - /// - public interface IDistributedLockHandle : IDisposable, IAsyncDisposable - { - /// - /// Gets a instance which may be used to - /// monitor whether the handle to the lock is lost before the handle is - /// disposed. - /// - /// For example, this could happen if the lock is backed by a - /// database and the connection to the database is disrupted. - /// - /// Not all lock types support this; those that don't will return - /// which can be detected by checking . - /// - /// For lock types that do support this, accessing this property may incur additional - /// costs, such as polling to detect connectivity loss. - /// - // TODO revisit naming - CancellationToken HandleLostToken { get; } - } -} diff --git a/DistributedLock.Core/IDistributedLockProvider.cs b/DistributedLock.Core/IDistributedLockProvider.cs deleted file mode 100644 index b9cb74da..00000000 --- a/DistributedLock.Core/IDistributedLockProvider.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Medallion.Threading -{ - // todo implementations - - /// - /// Acts as a factory for instances of a certain type. This interface may be - /// easier to use than in dependency injection scenarios. - /// - public interface IDistributedLockProvider - { - /// - /// Constructs an instance with the given . Unless - /// is set to true, will be used to - /// ensure that the name will work with the underlying locking system. - /// - IDistributedLock CreateLock(string name, bool exactName = false); - - /// - /// Given an arbitrary , determines whether can be safely - /// used as-is by the underlying provider (in other words, if it is safe to call - /// with exactName: true). - /// - /// If is safe to use, returns . Otherwise, returns a new name which - /// is safe to use and incorporates as much of the uniqueness of as possible. For example, this - /// may be a hash of that fits within the length and character requirements for the underlying - /// locking mechanism. - /// - string GetSafeLockName(string name); - } -} diff --git a/DistributedLock.Core/IDistributedUpgradeableReadLockHandle.cs b/DistributedLock.Core/IDistributedUpgradeableReadLockHandle.cs deleted file mode 100644 index f4dafccd..00000000 --- a/DistributedLock.Core/IDistributedUpgradeableReadLockHandle.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading -{ - /// - /// A that can be upgraded to a write lock - /// - public interface IDistributedLockUpgradeableHandle : IDistributedLockHandle - { - /// - /// Attempts to upgrade a WRITE lock synchronously. Not compatible with another WRITE lock or a UPGRADE lock - /// - bool TryUpgradeToWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); - - /// - /// Upgrades to a WRITE lock synchronously. Not compatible with another WRITE lock or a UPGRADE lock - /// - void UpgradeToWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - - /// - /// Attempts to upgrade a WRITE lock asynchronously. Not compatible with another WRITE lock or a UPGRADE lock - /// - ValueTask TryUpgradeToWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); - - /// - /// Upgrades to a WRITE lock asynchronously. Not compatible with another WRITE lock or a UPGRADE lock - /// - ValueTask UpgradeToWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - } -} diff --git a/DistributedLock.Core/IDistributedUpgradeableReaderWriterLock.cs b/DistributedLock.Core/IDistributedUpgradeableReaderWriterLock.cs deleted file mode 100644 index 205a4434..00000000 --- a/DistributedLock.Core/IDistributedUpgradeableReaderWriterLock.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading -{ - /// - /// Extends with the ability to take an "upgrade" lock. Like a read lock, an upgrade lock - /// allows for other concurrent read locks, but not for other upgrade or write locks. However, an upgrade lock can also be upgraded to a write - /// lock without releasing the underlying handle. - /// - public interface IDistributedUpgradeableReaderWriterLock : IDistributedReaderWriterLock - { - /// - /// Attempts to acquire an UPGRADE lock synchronously. Not compatible with another UPGRADE lock or a WRITE lock. Usage: - /// - /// using (var handle = myLock.TryAcquireUpgradeableReadLock(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - IDistributedLockUpgradeableHandle? TryAcquireUpgradeableReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); - - /// - /// Acquires an UPGRADE lock synchronously, failing with if the attempt times out. Not compatible with another UPGRADE lock or a WRITE lock. Usage: - /// - /// using (myLock.AcquireUpgradeableReadLock(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - IDistributedLockUpgradeableHandle AcquireUpgradeableReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - - /// - /// Attempts to acquire an UPGRADE lock asynchronously. Not compatible with another UPGRADE lock or a WRITE lock. Usage: - /// - /// await using (var handle = await myLock.TryAcquireUpgradeableReadLockAsync(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - ValueTask TryAcquireUpgradeableReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); - - /// - /// Acquires an UPGRADE lock asynchronously, failing with if the attempt times out. Not compatible with another UPGRADE lock or a WRITE lock. Usage: - /// - /// await using (await myLock.AcquireUpgradeableReadLockAsync(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - ValueTask AcquireUpgradeableReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - } -} diff --git a/DistributedLock.Core/Internal/AsyncLock.cs b/DistributedLock.Core/Internal/AsyncLock.cs deleted file mode 100644 index 5f74ea06..00000000 --- a/DistributedLock.Core/Internal/AsyncLock.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal -{ - /// - /// An async-based, -friendly mutex based on . We don't expose a - /// method because does not require disposal unless its - /// is accessed - /// - internal readonly struct AsyncLock - { - private readonly SemaphoreSlim _semaphore; - - private AsyncLock(SemaphoreSlim semaphore) - { - this._semaphore = semaphore; - } - - public static AsyncLock Create() => new AsyncLock(new SemaphoreSlim(initialCount: 1, maxCount: 1)); - - public async ValueTask AcquireAsync(CancellationToken cancellationToken) - { - var handle = await this.TryAcquireAsync(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - Invariant.Require(handle != null); - return handle!; - } - - public async ValueTask TryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) - { - var acquired = SyncOverAsync.IsSynchronous - ? this._semaphore.Wait(timeout.InMilliseconds, cancellationToken) - : await this._semaphore.WaitAsync(timeout.InMilliseconds, cancellationToken).ConfigureAwait(false); - return acquired ? new Handle(this._semaphore) : null; - } - - private sealed class Handle : IDisposable - { - private SemaphoreSlim? _semaphore; - - public Handle(SemaphoreSlim semaphore) - { - this._semaphore = semaphore; - } - - public void Dispose() => Interlocked.Exchange(ref this._semaphore, null)?.Release(); - } - } -} diff --git a/DistributedLock.Core/Internal/Data/ConnectionMonitor.cs b/DistributedLock.Core/Internal/Data/ConnectionMonitor.cs deleted file mode 100644 index edd2d89f..00000000 --- a/DistributedLock.Core/Internal/Data/ConnectionMonitor.cs +++ /dev/null @@ -1,445 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal.Data -{ - /// - /// Implements keepalive for a which is important for certain providers - /// such as SQL Azure. - /// - /// Also supports more active monitoring for the purposes of implementing - /// - internal sealed class ConnectionMonitor : IAsyncDisposable - { - /// - /// Weak reference to the underlying . We use a weak reference to - /// avoid the case where our background worker keeps the connection from being GC'd - /// and therefore keeps an abandoned handle from being released - /// - private readonly WeakReference _weakConnection; - /// - /// Caches a handler for so we - /// unregister it in - /// - private readonly StateChangeEventHandler? _stateChangedHandler; - - /// - /// Allows us to avoid running multiple concurrent queries on - /// - private readonly AsyncLock _connectionLock = AsyncLock.Create(); - - /// - /// Tracks whether the connection is externally-owned. For externally owned connections we cannot - /// run any background queries because this might violate threadsafety with whatever the connection - /// owner is doing - /// - private readonly bool _isExternallyOwnedConnection; - - private TimeoutValue _keepaliveCadence = Timeout.InfiniteTimeSpan; - private State _state; - private Dictionary? _monitoringHandleRegistrations; - private CancellationTokenSource? _monitorStateChangedTokenSource; - private Task _monitoringWorkerTask = Task.CompletedTask; - - public ConnectionMonitor(DatabaseConnection connection) - { - this._weakConnection = new WeakReference(connection); - this._isExternallyOwnedConnection = connection.IsExernallyOwned; - // stopped not autostopped here so that the statechange handler will not cause a start - this._state = connection.CanExecuteQueries ? State.Idle : State.Stopped; - Invariant.Require(this._state == State.Stopped || this._isExternallyOwnedConnection); - - if (connection.InnerConnection is DbConnection dbConnection) - { - dbConnection.StateChange += this._stateChangedHandler = this.OnConnectionStateChanged; - } - } - - /// - /// Protects access to all mutable state - /// - private object Lock => this._weakConnection; - - private bool HasRegisteredMonitoringHandlesNoLock => (this._monitoringHandleRegistrations?.Count).GetValueOrDefault() != 0; - - public async ValueTask AcquireConnectionLockAsync(CancellationToken cancellationToken) - { - while (true) - { - ValueTask connectionLockTask; - lock (this.Lock) - { - // If we're monitoring, then the connection will almost constantly be in use. - // Fire state changed to cancel that query and clear it up - if (this._state == State.Active && this.HasRegisteredMonitoringHandlesNoLock) - { - this.FireStateChangedNoLock(); - } - - // By starting the acquisition inside Lock, we should be guaranteed to get in before the worker look can - // take the lock again. This relies on AsyncLock.AcquireAsync being FIFO, which is currently true because of - // how SemaphoreSlim works. However, to be extra robust we do a try-wait here and retry on failure - connectionLockTask = this._connectionLock.TryAcquireAsync(TimeSpan.FromSeconds(2), cancellationToken); - } - - var handle = await connectionLockTask.ConfigureAwait(false); - if (handle != null) { return handle; } - } - } - - public void SetKeepaliveCadence(TimeoutValue keepaliveCadence) - { - Invariant.Require(!this._isExternallyOwnedConnection); - - lock (this.Lock) - { - Invariant.Require(this._state != State.Disposed); - - var originalKeepaliveCadence = this._keepaliveCadence; - this._keepaliveCadence = keepaliveCadence; - - if (!this.StartMonitorWorkerIfNeededNoLock() - && this._state == State.Active - && !this.HasRegisteredMonitoringHandlesNoLock - && keepaliveCadence.CompareTo(originalKeepaliveCadence) < 0) - { - // If we get here, then we already have an active worker performing - // keepalive on a longer cadence. Since that worker is likely asleep, - // we fire state changed to wake it up - this.FireStateChangedNoLock(); - } - } - } - - public IDatabaseConnectionMonitoringHandle GetMonitoringHandle() - { - lock (this.Lock) - { - // Since this will be called via non-thread-safe paths, we do a true - // dispose check. This error should never reach callers unless they are - // using non-thread-safe stuff concurrently - if (this._state == State.Disposed) { throw new ObjectDisposedException(this.GetType().ToString()); } - - // If the connection is already closed, we'll never see a state change - // event for the close so just return an already canceled handle - if (this._state == State.AutoStopped || this._state == State.Stopped) - { - return new AlreadyCanceledHandle(); - } - - // If the connection does not support state monitoring, we can't produce - // a monitoring handle - if (this._stateChangedHandler == null) - { - return NullHandle.Instance; - } - - var hadRegisteredMonitoringHandles = this.HasRegisteredMonitoringHandlesNoLock; - - var connectionLostTokenSource = new CancellationTokenSource(); - var handle = new MonitoringHandle(this, connectionLostTokenSource.Token); - (this._monitoringHandleRegistrations ??= new Dictionary()) - .Add(handle, connectionLostTokenSource); - - if (!this.StartMonitorWorkerIfNeededNoLock() - && !hadRegisteredMonitoringHandles - && this._state == State.Active) - { - // If we get here, it means we already had an active worker which was not monitoring (doing - // keepalive). That worker is likely asleep, so we fire state changed to wake it up and have it - // switch over to monitoring - this.FireStateChangedNoLock(); - } - - return handle; - } - } - - private void ReleaseMonitoringHandle(MonitoringHandle handle) - { - lock (this.Lock) - { - if (this._monitoringHandleRegistrations!.TryGetValue(handle, out var cancellationTokenSource)) - { - this._monitoringHandleRegistrations.Remove(handle); - cancellationTokenSource.Dispose(); - - // If we've removed the last reason to be monitoring, fire state changed to stop the monitoring process. - // Without this, the next query that attempts to acquire the connection lock will not think we are monitoring - // and therefore will not fire state change. Then, it will get stuck waiting for the monitoring query to complete - if (this._monitoringHandleRegistrations.Count == 0 && this._state == State.Active) - { - this.FireStateChangedNoLock(); - } - } - } - } - - private void OnConnectionStateChanged(object sender, StateChangeEventArgs args) - { - if (args.OriginalState == ConnectionState.Open && args.CurrentState != ConnectionState.Open) - { - lock (this.Lock) - { - if (this._state == State.Idle || this._state == State.Active) - { - this._state = State.AutoStopped; - this.CloseOrCancelMonitoringHandleRegistrationsNoLock(isCancel: true); - } - - Invariant.Require(!this.HasRegisteredMonitoringHandlesNoLock); - } - } - else if (args.OriginalState != ConnectionState.Open && args.CurrentState == ConnectionState.Open) - { - lock (this.Lock) - { - if (this._state == State.AutoStopped) - { - this.StartNoLock(); - } - } - } - } - - public void Start() - { - Invariant.Require(!this._isExternallyOwnedConnection); - - lock (this.Lock) - { - Invariant.Require(this._state == State.Stopped); - this.StartNoLock(); - } - } - - private void StartNoLock() - { - this._state = State.Idle; - this.StartMonitorWorkerIfNeededNoLock(); - } - - public ValueTask StopAsync() => this.StopOrDisposeAsync(isDispose: false); - public ValueTask DisposeAsync() => this.StopOrDisposeAsync(isDispose: true); - - private async ValueTask StopOrDisposeAsync(bool isDispose) - { - Task? task; - lock (this.Lock) - { - if (isDispose) - { - this._state = State.Disposed; - } - else - { - Invariant.Require(!this._isExternallyOwnedConnection); - Invariant.Require(this._state != State.Disposed); - this._state = State.Stopped; - } - - // If we have any registered monitoring handles, clear them out. - // We don't cancel them since if the helper was stopped that indicates - // proper disposal rather than loss of the connection - this.CloseOrCancelMonitoringHandleRegistrationsNoLock(isCancel: false); - - task = this._monitoringWorkerTask; - this._monitorStateChangedTokenSource?.Cancel(); - - // unsubscribe from state change tracking - if (this._stateChangedHandler != null - && this._weakConnection.TryGetTarget(out var connection)) - { - ((DbConnection)connection.InnerConnection).StateChange -= this._stateChangedHandler; - } - } - - if (task != null) - { - if (SyncOverAsync.IsSynchronous) { task.GetAwaiter().GetResult(); } - else { await task.ConfigureAwait(false); } - } - } - - private void CloseOrCancelMonitoringHandleRegistrationsNoLock(bool isCancel) - { - Invariant.Require(this._state == State.AutoStopped || this._state == State.Stopped || this._state == State.Disposed); - - if (this._monitoringHandleRegistrations == null) { return; } - - foreach (var kvp in this._monitoringHandleRegistrations) - { - var cancellationTokenSource = kvp.Value; - if (isCancel) - { - // cancel in a background thread in case we have hangs or errors - Task.Run(() => - { - try { cancellationTokenSource.Cancel(); } - finally { cancellationTokenSource.Dispose(); } - }); - } - else - { - cancellationTokenSource.Dispose(); - } - } - this._monitoringHandleRegistrations.Clear(); - } - - private bool StartMonitorWorkerIfNeededNoLock() - { - Invariant.Require(this._state != State.Disposed); - - // never monitor external connections - if (this._isExternallyOwnedConnection) { return false; } - - // If we're in the active state, we already have a worker. If we're not in the idle - // state, we're not supposed to be running - if (this._state != State.Idle) { return false; } - - // skip if there's nothing to do - if (this._keepaliveCadence.IsInfinite && !this.HasRegisteredMonitoringHandlesNoLock) { return false; } - - this._monitorStateChangedTokenSource = new CancellationTokenSource(); - // Set up the task as a continuation on the previous task to avoid concurrency in the case where the previous - // one is spinning down. If we change states in rapid succession we could end up with multiple tasks queued up - // but this shouldn't matter since when the active one ultimately stops all the others will follow in rapid succession - this._monitoringWorkerTask = this._monitoringWorkerTask - .ContinueWith((_, state) => ((ConnectionMonitor)state).MonitorWorkerLoop(), state: this) - .Unwrap(); - this._state = State.Active; - return true; - } - - private void FireStateChangedNoLock() - { - this._monitorStateChangedTokenSource!.Cancel(); - this._monitorStateChangedTokenSource.Dispose(); - this._monitorStateChangedTokenSource = new CancellationTokenSource(); - } - - private async Task MonitorWorkerLoop() - { - while (await this.TryKeepaliveOrMonitorAsync().ConfigureAwait(false)) - { - // just keep going - } - } - - private async Task TryKeepaliveOrMonitorAsync() - { - // get state - TimeoutValue keepaliveCadence; - bool isMonitoring; - CancellationToken stateChangedToken; - lock (this.Lock) - { - if (this._state != State.Active) { return false; } - - keepaliveCadence = this._keepaliveCadence; - isMonitoring = this.HasRegisteredMonitoringHandlesNoLock; - stateChangedToken = this._monitorStateChangedTokenSource!.Token; - } - - return await (isMonitoring ? this.DoMonitoringAsync(stateChangedToken) : this.DoKeepaliveAsync(keepaliveCadence, stateChangedToken)).ConfigureAwait(false); - } - - private async Task DoMonitoringAsync(CancellationToken cancellationToken) - { - if (!this._weakConnection.TryGetTarget(out var connection)) { return false; } - - // don't pass token here: this should finish quickly and we don't want to throw - using var _ = await this._connectionLock.AcquireAsync(CancellationToken.None).ConfigureAwait(false); - - // 1-min increments is kind of an arbitrary choice. We want to avoid this being too short since each time - // we "come up to breathe" that's a waste of resource. We also want to avoid this being too long since - // in case people have some kind of monitoring set up for hanging queries - await connection.SleepAsync( - sleepTime: TimeSpan.FromMinutes(1), - cancellationToken: cancellationToken, - executor: (command, token) => command.ExecuteNonQueryAsync(token, disallowAsyncCancellation: false, isConnectionMonitoringQuery: true) - ).TryAwait(); - - return true; - } - - private async Task DoKeepaliveAsync(TimeoutValue keepaliveCadence, CancellationToken stateChangedToken) - { - await Task.Delay(keepaliveCadence.InMilliseconds, stateChangedToken).TryAwait(); - if (stateChangedToken.IsCancellationRequested) { return true; } - - // retrieve only after the delay to avoid this reference longer than needed - if (!this._weakConnection.TryGetTarget(out var connection)) { return false; } - - // We do a zero-wait try-lock here because if the connection is in-use then someone is querying with it. In that case, - // There's no need for us to run a keepalive query. Since we are using zero timeout, we don't bother to pass the cancellationToken; - // this saves us from having to handle cancellation exceptions - using var connectionLockHandle = await this._connectionLock.TryAcquireAsync(TimeSpan.Zero, CancellationToken.None).ConfigureAwait(false); - if (connectionLockHandle != null) - { - using var command = connection.CreateCommand(); - command.SetCommandText("SELECT 0 /* DistributedLock connection keepalive */"); - // Since this query is very fast and non-blocking, we don't bother trying to cancel it. This avoids having - // to deal with the overhead of throwing exceptions within ExecuteNonQueryAsync() - await command.ExecuteNonQueryAsync(CancellationToken.None, disallowAsyncCancellation: false, isConnectionMonitoringQuery: true).AsTask().TryAwait(); - } - - return true; - } - - private sealed class MonitoringHandle : IDatabaseConnectionMonitoringHandle - { - private ConnectionMonitor? _monitor; - private readonly CancellationToken _connectionLostToken; - - public MonitoringHandle(ConnectionMonitor keepaliveHelper, CancellationToken cancellationToken) - { - this._monitor = keepaliveHelper; - this._connectionLostToken = cancellationToken; - } - - public CancellationToken ConnectionLostToken => Volatile.Read(ref this._monitor) != null ? this._connectionLostToken : throw new ObjectDisposedException("handle"); - - public void Dispose() => Interlocked.Exchange(ref this._monitor, null)?.ReleaseMonitoringHandle(this); - } - - private sealed class AlreadyCanceledHandle : IDatabaseConnectionMonitoringHandle - { - private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); - - public AlreadyCanceledHandle() - { - this._cancellationTokenSource.Cancel(); - } - - public CancellationToken ConnectionLostToken => this._cancellationTokenSource.Token; - - public void Dispose() => this._cancellationTokenSource.Dispose(); - } - - private sealed class NullHandle : IDatabaseConnectionMonitoringHandle - { - public static readonly NullHandle Instance = new NullHandle(); - - private NullHandle() { } - - public CancellationToken ConnectionLostToken => CancellationToken.None; - - public void Dispose() { } - } - - private enum State : byte - { - Idle, - Active, - AutoStopped, - Stopped, - Disposed, - } - } -} diff --git a/DistributedLock.Core/Internal/Data/DatabaseCommand.cs b/DistributedLock.Core/Internal/Data/DatabaseCommand.cs deleted file mode 100644 index ecdf8937..00000000 --- a/DistributedLock.Core/Internal/Data/DatabaseCommand.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Runtime.CompilerServices; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal.Data -{ - /// - /// Abstraction over for a - /// -#if DEBUG - public -#else - internal -#endif - sealed class DatabaseCommand : IDisposable - { - private readonly IDbCommand _command; - private readonly DatabaseConnection _connection; - - internal DatabaseCommand(IDbCommand command, DatabaseConnection connection) - { - this._command = command; - this._connection = connection; - } - - public IDataParameterCollection Parameters => this._command.Parameters; - - public void SetCommandText(string sql) => this._command.CommandText = sql; - - public void SetTimeout(TimeoutValue operationTimeout) - { - this._command.CommandTimeout = operationTimeout.IsInfinite - // use the infinite timeout of 0 - // (see https://msdn.microsoft.com/en-us/library/system.data.sqlclient.sqlcommand.commandtimeout%28v=vs.110%29.aspx) - ? 0 - // command timeout is in seconds. We always wait at least the given timeout plus a buffer - : operationTimeout.InSeconds + 30; - } - - public void SetCommandType(CommandType type) - { - this._command.CommandType = type; - } - - public IDbDataParameter AddParameter(string? name = null, object? value = null, DbType? type = null, ParameterDirection? direction = null) - { - var parameter = this._command.CreateParameter(); - if (name != null) { parameter.ParameterName = name; } - if (value != null) { parameter.Value = value; } - if (type != null) { parameter.DbType = type.Value; } - if (direction != null) { parameter.Direction = direction.Value; } - this._command.Parameters.Add(parameter); - return parameter; - } - - #region ---- Execution ---- - public ValueTask ExecuteNonQueryAsync(CancellationToken cancellationToken, bool disallowAsyncCancellation = false) => - this.ExecuteNonQueryAsync(cancellationToken, disallowAsyncCancellation, isConnectionMonitoringQuery: false); - - /// - /// Internal API for - /// - internal ValueTask ExecuteNonQueryAsync(CancellationToken cancellationToken, bool disallowAsyncCancellation, bool isConnectionMonitoringQuery) => - this.ExecuteAsync((c, t) => c.ExecuteNonQueryAsync(t), c => c.ExecuteNonQuery(), cancellationToken, disallowAsyncCancellation, isConnectionMonitoringQuery); - - public ValueTask ExecuteScalarAsync(CancellationToken cancellationToken, bool disallowAsyncCancellation = false) => - this.ExecuteAsync((c, t) => c.ExecuteScalarAsync(t), c => c.ExecuteScalar(), cancellationToken, disallowAsyncCancellation, isConnectionMonitoringQuery: false); - - private async ValueTask ExecuteAsync( - Func> executeAsync, - Func executeSync, - CancellationToken cancellationToken, - bool disallowAsyncCancellation, - bool isConnectionMonitoringQuery) - { - if (!SyncOverAsync.IsSynchronous && this._command is DbCommand dbCommand) - { - if (!cancellationToken.CanBeCanceled) - { - using var _ = await this.AcquireConnectionLockIfNeeded(isConnectionMonitoringQuery).ConfigureAwait(false); - await this.PrepareIfNeededAsync(CancellationToken.None).ConfigureAwait(false); - return await executeAsync(dbCommand, CancellationToken.None).ConfigureAwait(false); - } - else if (!disallowAsyncCancellation) - { - return await this.InternalExecuteAndPropagateCancellationAsync( - (dbCommand, executeAsync), - (state, cancellationToken) => state.executeAsync(state.dbCommand, cancellationToken).AsValueTask(), - cancellationToken, - isConnectionMonitoringQuery - ).ConfigureAwait(false); - } - else - { - // FALL THROUGH - - // note: we can't call ExecuteNonQueryAsync(cancellationToken) or even ExecuteNonQueryAsync() - // here because of a .NET bug (see https://github.com/dotnet/SqlClient/issues/44, - // https://stackoverflow.com/questions/48461567/canceling-query-with-while-loop-hangs-forever) - // The workaround is to fall back to sync cancellation and sync execution in this case - } - } - - if (cancellationToken.CanBeCanceled) - { - // check this first rather than rely on a race between the the cancellation registration and the - // command execution. Note that if SqlCommand.Cancel() is called before the command is executed, this has no effect - cancellationToken.ThrowIfCancellationRequested(); - - var commandBox = new StrongBox(this._command); - - // having the registration offload the cancel loop to a background thread is important, since - // registrations fire synchronously if the token is already canceled - using var registration = cancellationToken.Register(state => Task.Run(async () => - { - var commandBox = (StrongBox)state; - IDbCommand? command; - while ((command = Volatile.Read(ref commandBox.Value)) != null) - { - try { command.Cancel(); } - catch { /* just ignore errors here */ } - - await Task.Delay(1).ConfigureAwait(false); - } - }), state: commandBox); - - try - { - return await this.InternalExecuteAndPropagateCancellationAsync( - (command: this._command, executeSync), - (state, cancellationToken) => state.executeSync(state.command).AsValueTask(), - cancellationToken, - isConnectionMonitoringQuery - ).ConfigureAwait(false); - } - finally - { - // allows the cancellation loop to exit if it started - Volatile.Write(ref commandBox.Value, null); - } - } - - using var __ = await this.AcquireConnectionLockIfNeeded(isConnectionMonitoringQuery).ConfigureAwait(false); - return executeSync(this._command); - } - - private async ValueTask InternalExecuteAndPropagateCancellationAsync( - TState state, - Func> executeAsync, - CancellationToken cancellationToken, - bool isConnectionMonitoringQuery) - { - Invariant.Require(cancellationToken.CanBeCanceled); - - using var _ = await this.AcquireConnectionLockIfNeeded(isConnectionMonitoringQuery).ConfigureAwait(false); - await this.PrepareIfNeededAsync(cancellationToken).ConfigureAwait(false); - try - { - return await executeAsync(state, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - // Canceled SQL operations throw SqlException/InvalidOperationException instead of OCE. - // That means that downstream operations end up faulted instead of canceled. We - // wrap with OCE here to correctly propagate cancellation - when (cancellationToken.IsCancellationRequested && this._connection.IsCommandCancellationException(ex)) - { - throw new OperationCanceledException( - "Command was canceled", - ex, - cancellationToken - ); - } - } - - private ValueTask PrepareIfNeededAsync(CancellationToken cancellationToken) - { - if (this._connection.ShouldPrepareCommands) - { -#if NETSTANDARD2_1 - if (!SyncOverAsync.IsSynchronous && this._command is DbCommand dbCommand) - { - // todo does canceling prepareasync doom pg transaction? - return dbCommand.PrepareAsync(cancellationToken).AsValueTask(); - } -#elif !NETSTANDARD2_0 && !NET461 - ERROR -#endif - - this._command.Prepare(); - } - - return default; - } -#endregion - - public void Dispose() => this._command.Dispose(); - - // NOTE: we do not accept cancellation token here since the keepalive lock should never be held for very long except in - // bug scenarios (e. g. multi-threaded use of a connection) - private ValueTask AcquireConnectionLockIfNeeded(bool isConnectionMonitoringQuery) => - isConnectionMonitoringQuery - ? default(IDisposable?).AsValueTask() - : this._connection.ConnectionMonitor?.AcquireConnectionLockAsync(CancellationToken.None).Convert(To.ValueTask) - ?? default(IDisposable?).AsValueTask(); - } -} diff --git a/DistributedLock.Core/Internal/Data/DatabaseConnection.cs b/DistributedLock.Core/Internal/Data/DatabaseConnection.cs deleted file mode 100644 index 66b48a98..00000000 --- a/DistributedLock.Core/Internal/Data/DatabaseConnection.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal.Data -{ - /// - /// Abstraction over that abstracts away the varying async support - /// across platforms, smooths over cancellation behavior, and integrates with - /// -#if DEBUG - public -#else - internal -#endif - abstract class DatabaseConnection : IAsyncDisposable - { - private IDbTransaction? _transaction; - - protected DatabaseConnection(IDbConnection connection, bool isExternallyOwned) - { - this.InnerConnection = connection; - this.IsExernallyOwned = isExternallyOwned; - this.ConnectionMonitor = new ConnectionMonitor(this); - } - - protected DatabaseConnection(IDbTransaction transaction, bool isExternallyOwned) - : this(transaction.Connection ?? throw new InvalidOperationException("Cannot execute queries against a transaction that has been disposed"), isExternallyOwned) - { - this._transaction = transaction; - } - - internal ConnectionMonitor ConnectionMonitor { get; } - internal IDbConnection InnerConnection { get; } - - public bool HasTransaction => this._transaction != null; - - public bool IsExernallyOwned { get; } - - public abstract bool ShouldPrepareCommands { get; } - - internal bool CanExecuteQueries => this.InnerConnection.State == ConnectionState.Open && (this._transaction == null || this._transaction.Connection != null); - - internal void SetKeepaliveCadence(TimeoutValue cadence) => this.ConnectionMonitor.SetKeepaliveCadence(cadence); - - internal IDatabaseConnectionMonitoringHandle GetConnectionMonitoringHandle() => this.ConnectionMonitor.GetMonitoringHandle(); - - public DatabaseCommand CreateCommand() - { - var command = this.InnerConnection.CreateCommand(); - command.Transaction = this._transaction; - return new DatabaseCommand(command, this); - } - - // note: we could have this return an IAsyncDisposable which would allow you to close the transaction - // without closing the connection. However, we don't currently have any use-cases for that - public async ValueTask BeginTransactionAsync() -#pragma warning restore CS1998 - { - Invariant.Require(this._transaction == null); - - using var _ = await this.ConnectionMonitor.AcquireConnectionLockAsync(CancellationToken.None).ConfigureAwait(false); - - this._transaction = -#if NETSTANDARD2_1 - !SyncOverAsync.IsSynchronous && this.InnerConnection is DbConnection dbConnection - ? await dbConnection.BeginTransactionAsync().ConfigureAwait(false) - : -#elif NETSTANDARD2_0 || NET461 -#else - ERROR -#endif - this.InnerConnection.BeginTransaction(); - } - - public async ValueTask OpenAsync(CancellationToken cancellationToken) - { - if ((cancellationToken.CanBeCanceled || !SyncOverAsync.IsSynchronous) - && this.InnerConnection is DbConnection dbConnection) - { - await dbConnection.OpenAsync(cancellationToken).ConfigureAwait(false); - } - else - { - cancellationToken.ThrowIfCancellationRequested(); - this.InnerConnection.Open(); - } - - this.ConnectionMonitor.Start(); - } - - public ValueTask CloseAsync() => this.DisposeOrCloseAsync(isDispose: false); - public ValueTask DisposeAsync() => this.DisposeOrCloseAsync(isDispose: true); - - private async ValueTask DisposeOrCloseAsync(bool isDispose) - { - Invariant.Require(isDispose || !this.IsExernallyOwned); - - try - { - await (isDispose ? this.ConnectionMonitor.DisposeAsync() : this.ConnectionMonitor.StopAsync()).ConfigureAwait(false); - } - finally - { - if (!this.IsExernallyOwned) - { - try { await this.DisposeTransactionAsync(isClosingOrDisposingConnection: true).ConfigureAwait(false); } - finally - { -#if NETSTANDARD2_1 - if (!SyncOverAsync.IsSynchronous && this.InnerConnection is DbConnection dbConnection) - { - await (isDispose ? dbConnection.DisposeAsync() : dbConnection.CloseAsync().AsValueTask()).ConfigureAwait(false); - } - else - { - SyncDisposeConnection(); - } -#elif NETSTANDARD2_0 || NET461 - SyncDisposeConnection(); -#else - ERROR -#endif - } - } - } - - void SyncDisposeConnection() - { - if (isDispose) { this.InnerConnection.Dispose(); } - else { this.InnerConnection.Close(); } - } - } - - public ValueTask DisposeTransactionAsync() => this.DisposeTransactionAsync(isClosingOrDisposingConnection: false); - - private async ValueTask DisposeTransactionAsync(bool isClosingOrDisposingConnection) - { - var transaction = this._transaction; - if (transaction == null) { return; } - this._transaction = null; - - // we don't need the connection lock here if we're closing/disposing, since in that case we stop the monitor first - using var _ = isClosingOrDisposingConnection - ? null - : await this.ConnectionMonitor.AcquireConnectionLockAsync(CancellationToken.None).ConfigureAwait(false); - -#if NETSTANDARD2_1 - if (!SyncOverAsync.IsSynchronous && transaction is DbTransaction dbTransaction) - { - await dbTransaction.DisposeAsync().ConfigureAwait(false); - return; - } -#elif NETSTANDARD2_0 || NET461 -#else - ERROR -#endif - - transaction.Dispose(); - } - - public abstract bool IsCommandCancellationException(Exception exception); - - public abstract Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor); - } -} diff --git a/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs b/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs deleted file mode 100644 index cbc2ec05..00000000 --- a/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System; -using System.Data; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal.Data -{ - // todo logic in this class is pretty complex. Could we factor some of the branches out into a connectionpolicy object or similar? - - /// - /// Implements by giving each lock acquisition a dedicated - /// or - /// -#if DEBUG - public -#else - internal -#endif - sealed class DedicatedConnectionOrTransactionDbDistributedLock : IDbDistributedLock - { - private readonly string _name; - private readonly Func _connectionFactory; - private readonly bool _scopeToOwnedTransaction; - private readonly TimeoutValue _keepaliveCadence; - - public DedicatedConnectionOrTransactionDbDistributedLock(string name, Func externalConnectionFactory) - : this(name, externalConnectionFactory, useTransaction: false, keepaliveCadence: Timeout.InfiniteTimeSpan) - { - } - - public DedicatedConnectionOrTransactionDbDistributedLock( - string name, - Func connectionFactory, - bool useTransaction, - TimeoutValue keepaliveCadence) - { - this._name = name; - this._connectionFactory = connectionFactory; - this._scopeToOwnedTransaction = useTransaction; - this._keepaliveCadence = keepaliveCadence; - } - - public async ValueTask TryAcquireAsync( - TimeoutValue timeout, - IDbSynchronizationStrategy strategy, - CancellationToken cancellationToken, - IDistributedLockHandle? contextHandle) - where TLockCookie : class - { - // todo revisit how this works with managed finalization. We want to avoid the case where the upgraded handle and the original handle get - // finalized in the wrong order. Perhaps it would be simpler to make upgradestrategy its own different interface, and then make the idblock return - // a handle type that might be able to self-upgrade if it has the right internal strategy - - IDistributedLockHandle? result = null; - IAsyncDisposable? connectionResource = null; - try - { - DatabaseConnection connection; - bool transactionScoped; - if (contextHandle != null) - { - connection = GetContextHandleConnection(contextHandle); - transactionScoped = false; - } - else - { - connectionResource = connection = this._connectionFactory(); - if (connection.IsExernallyOwned) - { - Invariant.Require(!this._scopeToOwnedTransaction); - if (!connection.CanExecuteQueries) - { - throw new InvalidOperationException("The connection and/or transaction are disposed or closed"); - } - transactionScoped = false; - } - else - { - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - if (this._scopeToOwnedTransaction) - { - await connection.BeginTransactionAsync().ConfigureAwait(false); - } - transactionScoped = this._scopeToOwnedTransaction; - } - } - - var lockCookie = await strategy.TryAcquireAsync(connection, this._name, timeout, cancellationToken).ConfigureAwait(false); - if (lockCookie != null) - { - result = new Handle(connection, strategy, this._name, lockCookie, transactionScoped, connectionResource); - if (!this._keepaliveCadence.IsInfinite) - { - connection.SetKeepaliveCadence(this._keepaliveCadence); - } - } - } - finally - { - // if we fail to acquire or throw, make sure to clean up the connection - if (result == null && connectionResource != null) - { - await connectionResource.DisposeAsync().ConfigureAwait(false); - } - } - - return result; - } - - private DatabaseConnection GetContextHandleConnection(IDistributedLockHandle contextHandle) - where TLockCookie : class - { - var connection = ((Handle)contextHandle).Connection; - if (connection == null) { throw new ObjectDisposedException(nameof(contextHandle), "the provided handle is already disposed"); } - return connection; - } - - private sealed class Handle : IDistributedLockHandle - where TLockCookie : class - { - private InnerHandle? _innerHandle; - private IDisposable? _finalizer; - - public Handle( - DatabaseConnection connection, - IDbSynchronizationStrategy strategy, - string name, - TLockCookie lockCookie, - bool transactionScoped, - IAsyncDisposable? connectionResource) - { - this._innerHandle = new InnerHandle(connection, strategy, name, lockCookie, transactionScoped, connectionResource); - // we don't do managed finalization for externally-owned connections/transactions since it might violate thread-safety - // on those objects (we don't know when they're in use) - this._finalizer = connection.IsExernallyOwned ? null : ManagedFinalizerQueue.Instance.Register(this, this._innerHandle); - } - - public CancellationToken HandleLostToken => Volatile.Read(ref this._innerHandle)?.HandleLostToken ?? throw this.ObjectDisposed(); - - public DatabaseConnection? Connection => Volatile.Read(ref this._innerHandle)?.Connection; - - public void Dispose() => SyncOverAsync.Run(@this => @this.DisposeAsync(), this, willGoAsync: false); - - public ValueTask DisposeAsync() - { - Interlocked.Exchange(ref this._finalizer, null)?.Dispose(); - return Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; - } - - private sealed class InnerHandle : IAsyncDisposable - { - private static readonly object DisposedSentinel = new object(); - - private readonly IDbSynchronizationStrategy _strategy; - private readonly string _name; - private readonly TLockCookie _lockCookie; - private readonly bool _scopedToOwnedTransaction; - private readonly IAsyncDisposable? _connectionResource; - private object? _connectionMonitoringHandleOrDisposedSentinel; - - public InnerHandle( - DatabaseConnection connection, - IDbSynchronizationStrategy strategy, - string name, - TLockCookie lockCookie, - bool scopedToOwnTransaction, - IAsyncDisposable? connectionResource) - { - this.Connection = connection; - this._strategy = strategy; - this._name = name; - this._lockCookie = lockCookie; - this._scopedToOwnedTransaction = scopedToOwnTransaction; - this._connectionResource = connectionResource; - } - - public DatabaseConnection Connection { get; } - - public CancellationToken HandleLostToken - { - get - { - var existing = Volatile.Read(ref this._connectionMonitoringHandleOrDisposedSentinel); - - // if we don't have a handle and aren't disposed, try to make a handle - if (existing == null) - { - // tentatively create a new handle and try to assign it - var newHandle = this.Connection.GetConnectionMonitoringHandle(); - existing = Interlocked.CompareExchange(ref this._connectionMonitoringHandleOrDisposedSentinel, newHandle, comparand: null); - - if (existing == null) - { - // we won the race: use our new handle - return newHandle.ConnectionLostToken; - } - - // We lost the race: discard our new handle. - // Existing is now either a handle created in a race with us or the disposed sentinel - newHandle.Dispose(); - } - - if (existing == DisposedSentinel) - { - throw this.ObjectDisposed(); - } - - return ((IDatabaseConnectionMonitoringHandle)existing).ConnectionLostToken; - } - } - - public async ValueTask DisposeAsync() - { - var connectionMonitoringHandleOrDisposedSentinel = Interlocked.Exchange(ref this._connectionMonitoringHandleOrDisposedSentinel, DisposedSentinel); - if (connectionMonitoringHandleOrDisposedSentinel == DisposedSentinel) { return; } - - if (connectionMonitoringHandleOrDisposedSentinel is IDatabaseConnectionMonitoringHandle handle) - { - handle.Dispose(); - } - - try - { - // If we're not scoped to a transaction, explicit release is required regardless of whether - // we are about to dispose the connection due to connection pooling. For a pooled connection, - // simply calling Dispose() will not release the lock: it just returns the connection to the pool. - if (!(this._scopedToOwnedTransaction - // For external transaction-scoped locks, we're not about to dispose the transaction but if the transaction is - // dead (e. g. completed or rolled back) then we know the lock has been released. - || (this.Connection.IsExernallyOwned && this.Connection.HasTransaction && !this.Connection.CanExecuteQueries))) - { - await this._strategy.ReleaseAsync(this.Connection, this._name, this._lockCookie).ConfigureAwait(false); - } - } - finally - { - await (this._connectionResource?.DisposeAsync() ?? default).ConfigureAwait(false); - } - } - } - } - } -} diff --git a/DistributedLock.Core/Internal/Data/IDatabaseConnectionMonitoringHandle.cs b/DistributedLock.Core/Internal/Data/IDatabaseConnectionMonitoringHandle.cs deleted file mode 100644 index 954b8513..00000000 --- a/DistributedLock.Core/Internal/Data/IDatabaseConnectionMonitoringHandle.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Internal.Data -{ - internal interface IDatabaseConnectionMonitoringHandle : IDisposable - { - CancellationToken ConnectionLostToken { get; } - } -} diff --git a/DistributedLock.Core/Internal/Data/IDbDistributedLock.cs b/DistributedLock.Core/Internal/Data/IDbDistributedLock.cs deleted file mode 100644 index b5e1b4f7..00000000 --- a/DistributedLock.Core/Internal/Data/IDbDistributedLock.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Medallion.Threading.Internal; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal.Data -{ - /// - /// There are several strategies for implementing SQL-based locks; this interface - /// abstracts between them to keep the implementation of manageable - /// -#if DEBUG - public -#else - internal -#endif - interface IDbDistributedLock - { - // the contextHandle argument to this method is used when acquiring a nested lock, such as upgrading - // from an upgradeable read lock to a write lock. This allows the implementation to use the same connection - // for the nested lock - - ValueTask TryAcquireAsync(TimeoutValue timeout, IDbSynchronizationStrategy strategy, CancellationToken cancellationToken, IDistributedLockHandle? contextHandle) - where TLockCookie : class; - } -} diff --git a/DistributedLock.Core/Internal/Data/IDbSynchronizationStrategy.cs b/DistributedLock.Core/Internal/Data/IDbSynchronizationStrategy.cs deleted file mode 100644 index 8940055f..00000000 --- a/DistributedLock.Core/Internal/Data/IDbSynchronizationStrategy.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal.Data -{ - /// - /// Represents a "locking algorithm" implemented in SQL - /// -#if DEBUG - public -#else - internal -#endif - interface IDbSynchronizationStrategy - where TLockCookie : class - { - /// - /// True iff the lock taken by the algorithm can be upgraded on the same connection (basically for upgradeable read locks). - /// - /// We need this property because the multiplexing approach has to avoid multiplexing upgradeable locks since they may block - /// indefinitely on the held connection (which would prevent other locks on that connection from releasing) during an upgrade - /// operation. - /// - bool IsUpgradeable { get; } - - /// - /// Attempts to acquire the lock, returning either null for failure or a non-null state "cookie" on success - /// - ValueTask TryAcquireAsync(DatabaseConnection connection, string resourceName, TimeoutValue timeout, CancellationToken cancellationToken); - - ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, TLockCookie lockCookie); - } -} diff --git a/DistributedLock.Core/Internal/Data/MultiplexedConnectionLock.cs b/DistributedLock.Core/Internal/Data/MultiplexedConnectionLock.cs deleted file mode 100644 index 9f12abd9..00000000 --- a/DistributedLock.Core/Internal/Data/MultiplexedConnectionLock.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal.Data -{ - /// - /// Allows multiple SQL application locks to be taken on a single connection. - /// - /// This class is thread-safe except for - /// - internal sealed class MultiplexedConnectionLock : IAsyncDisposable - { - /// - /// Protects access to and - /// - private readonly AsyncLock _mutex = AsyncLock.Create(); - private readonly Dictionary _heldLocksToKeepaliveCadences = new Dictionary(); - private readonly DatabaseConnection _connection; - - public MultiplexedConnectionLock(DatabaseConnection connection) - { - this._connection = connection; - } - - public async ValueTask TryAcquireAsync( - string name, - TimeoutValue timeout, - IDbSynchronizationStrategy strategy, - TimeoutValue keepaliveCadence, - CancellationToken cancellationToken, - bool opportunistic) - where TLockCookie : class - { - using var mutextHandle = await this._mutex.TryAcquireAsync(opportunistic ? TimeSpan.Zero : Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - if (mutextHandle == null) - { - // mutex wasn't free, so just give up - Invariant.Require(opportunistic); - // The current lock is busy so we allow retry but on a different lock instance. We can't safely dispose - // since we never acquired the mutex so we can't check _heldLocks - return new Result(MultiplexedConnectionLockRetry.Retry, canSafelyDispose: false); - } - - try - { - if (this._heldLocksToKeepaliveCadences.ContainsKey(name)) - { - // we won't try to hold the same lock twice on one connection. At some point, we could - // support this case in-memory using a counter for each multiply-held lock name and being careful - // with modes - return this.GetFailureResultNoLock(isAlreadyHeld: true, opportunistic, timeout); - } - - if (!this._connection.CanExecuteQueries) - { - await this._connection.OpenAsync(cancellationToken).ConfigureAwait(false); - } - - var lockCookie = await strategy.TryAcquireAsync(this._connection, name, opportunistic ? TimeSpan.Zero : timeout, cancellationToken).ConfigureAwait(false); - if (lockCookie != null) - { - var handle = new Handle(this, strategy, name, lockCookie).WithManagedFinalizer(); - this._heldLocksToKeepaliveCadences.Add(name, keepaliveCadence); - if (!keepaliveCadence.IsInfinite) { this.SetKeepaliveCadenceNoLock(); } - return new Result(handle); - } - - // we failed to acquire the lock, so we should retry if we were being opportunistic and artificially - // shortened the timeout - return this.GetFailureResultNoLock(isAlreadyHeld: false, opportunistic, timeout); - } - finally - { - await this.CloseConnectionIfNeededNoLockAsync().ConfigureAwait(false); - } - } - - public ValueTask DisposeAsync() - { - Invariant.Require(this._heldLocksToKeepaliveCadences.Count == 0); - - return this._connection.DisposeAsync(); - } - - public async ValueTask GetIsInUseAsync() - { - using var mutexHandle = await this._mutex.TryAcquireAsync(TimeSpan.Zero, CancellationToken.None).ConfigureAwait(false); - return mutexHandle == null || this._heldLocksToKeepaliveCadences.Count != 0; - } - - private Result GetFailureResultNoLock(bool isAlreadyHeld, bool opportunistic, TimeoutValue timeout) - { - // only opportunistic acquisitions trigger retries - if (!opportunistic) - { - return new Result(MultiplexedConnectionLockRetry.NoRetry, canSafelyDispose: this._heldLocksToKeepaliveCadences.Count == 0); - } - - if (isAlreadyHeld) - { - // We're already holding the lock so we allow retry but on a different lock instance. - // We can't safely dispose because we're holding the lock - return new Result(MultiplexedConnectionLockRetry.Retry, canSafelyDispose: false); - } - - // if we get here, we failed due to a timeout - var isHoldingLocks = this._heldLocksToKeepaliveCadences.Count != 0; - - if (timeout.IsZero) - { - // if acquire timed out and the caller requested a zero timeout, that's conventional failure - // and we shouldn't retry - return new Result(MultiplexedConnectionLockRetry.NoRetry, canSafelyDispose: !isHoldingLocks); - } - - if (isHoldingLocks) - { - // if we're holding other locks, then we should retry on another lock - return new Result(MultiplexedConnectionLockRetry.Retry, canSafelyDispose: false); - } - - // If we're not holding anything, then it's safe to retry on this instance since we can't - // possibly block a release. It's also safe to dispose this lock, but that won't happen since - // we're going to re-try on it instead - return new Result(MultiplexedConnectionLockRetry.RetryOnThisLock, canSafelyDispose: true); - } - - private async ValueTask ReleaseAsync(IDbSynchronizationStrategy strategy, string name, TLockCookie lockCookie) - where TLockCookie : class - { - using var _ = await this._mutex.AcquireAsync(CancellationToken.None).ConfigureAwait(false); - try - { - await strategy.ReleaseAsync(this._connection, name, lockCookie).ConfigureAwait(false); - } - finally - { - if (this._heldLocksToKeepaliveCadences.TryGetValue(name, out var keepaliveCadence)) - { - this._heldLocksToKeepaliveCadences.Remove(name); - if (!keepaliveCadence.IsInfinite) - { - // note: we do this even if we're about to close the connection because we'll want - // the correct cadence set when and if we re-open - this.SetKeepaliveCadenceNoLock(); - } - } - await this.CloseConnectionIfNeededNoLockAsync().ConfigureAwait(false); - } - } - - private ValueTask CloseConnectionIfNeededNoLockAsync() - { - return this._heldLocksToKeepaliveCadences.Count == 0 && this._connection.CanExecuteQueries - ? this._connection.CloseAsync() - : default; - } - - private void SetKeepaliveCadenceNoLock() - { - TimeoutValue minCadence = Timeout.InfiniteTimeSpan; - foreach (var kvp in this._heldLocksToKeepaliveCadences) - { - if (kvp.Value.CompareTo(minCadence) < 0) - { - minCadence = kvp.Value; - } - } - this._connection.SetKeepaliveCadence(minCadence); - } - - public readonly struct Result - { - public Result(IDistributedLockHandle handle) - { - this.Handle = handle; - this.Retry = MultiplexedConnectionLockRetry.NoRetry; - this.CanSafelyDispose = false; // since we have handle - } - - public Result(MultiplexedConnectionLockRetry retry, bool canSafelyDispose) - { - this.Handle = null; - this.Retry = retry; - this.CanSafelyDispose = canSafelyDispose; - } - - public IDistributedLockHandle? Handle { get; } - public MultiplexedConnectionLockRetry Retry { get; } - public bool CanSafelyDispose { get; } - } - - private sealed class Handle : IDistributedLockHandle - where TLockCookie : class - { - private readonly string _name; - private RefBox<(MultiplexedConnectionLock @lock, IDbSynchronizationStrategy strategy, TLockCookie lockCookie, IDatabaseConnectionMonitoringHandle? monitoringHandle)>? _box; - - public Handle(MultiplexedConnectionLock @lock, IDbSynchronizationStrategy strategy, string name, TLockCookie lockCookie) - { - this._name = name; - this._box = RefBox.Create((@lock, strategy, lockCookie, default(IDatabaseConnectionMonitoringHandle))); - } - - public CancellationToken HandleLostToken - { - get - { - var existingBox = Volatile.Read(ref this._box); - - if (existingBox != null && existingBox.Value.monitoringHandle == null) - { - var newHandle = existingBox.Value.@lock._connection.ConnectionMonitor.GetMonitoringHandle(); - var newContents = existingBox.Value; - newContents.monitoringHandle = newHandle; - var newBox = RefBox.Create(newContents); - var newExistingBox = Interlocked.CompareExchange(ref this._box, newBox, comparand: existingBox); - if (newExistingBox == existingBox) - { - return newHandle.ConnectionLostToken; - } - - existingBox = newExistingBox; - } - - if (existingBox == null) { throw this.ObjectDisposed(); } - - // must exist here since we only clear the box on dispose or update the contents when creating a token - return existingBox.Value.monitoringHandle!.ConnectionLostToken; - } - } - - public ValueTask DisposeAsync() - { - if (RefBox.TryConsume(ref this._box, out var contents)) - { - contents.monitoringHandle?.Dispose(); - return contents.@lock.ReleaseAsync(contents.strategy, this._name, contents.lockCookie); - } - - return default; - } - - void IDisposable.Dispose() => SyncOverAsync.Run(@this => @this.DisposeAsync(), this, willGoAsync: false); - } - } - - internal enum MultiplexedConnectionLockRetry - { - NoRetry, - RetryOnThisLock, - Retry, - } -} diff --git a/DistributedLock.Core/Internal/Data/MultiplexedConnectionLockPool.cs b/DistributedLock.Core/Internal/Data/MultiplexedConnectionLockPool.cs deleted file mode 100644 index 328cea71..00000000 --- a/DistributedLock.Core/Internal/Data/MultiplexedConnectionLockPool.cs +++ /dev/null @@ -1,216 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Collections; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal.Data -{ - /// - /// Implements a pool of instances - /// -#if DEBUG - public -#else - internal -#endif - sealed class MultiplexedConnectionLockPool - { - private readonly AsyncLock _lock = AsyncLock.Create(); - - private readonly Dictionary> _poolsByConnectionString = - new Dictionary>(); - /// - /// The number of times we've called - /// since we last called - /// - private uint _storeCountSinceLastPrune; - /// - /// The number of s stored in - /// - private uint _pooledLockCount; - - public MultiplexedConnectionLockPool(Func connectionFactory) - { - this.ConnectionFactory = connectionFactory; - } - - internal Func ConnectionFactory { get; } - - public async ValueTask TryAcquireAsync( - string connectionString, - string name, - TimeoutValue timeout, - IDbSynchronizationStrategy strategy, - TimeoutValue keepaliveCadence, - CancellationToken cancellationToken) - where TLockCookie : class - { - // opportunistic phase: see if we can use a connection that is already holding a lock - // to acquire the current lock - var existingLock = await this.GetOrCreateLockAsync(connectionString).ConfigureAwait(false); - if (existingLock != null) - { - var canSafelyDisposeExistingLock = false; - try - { - var opportunisticResult = await TryAcquireAsync(existingLock, opportunistic: true).ConfigureAwait(false); - if (opportunisticResult.Handle != null) { return opportunisticResult.Handle; } - // this will always be false if handle is non-null, so we can set if afterwards - canSafelyDisposeExistingLock = opportunisticResult.CanSafelyDispose; - - switch (opportunisticResult.Retry) - { - case MultiplexedConnectionLockRetry.NoRetry: - return null; - case MultiplexedConnectionLockRetry.RetryOnThisLock: - var retryOnThisLockResult = await TryAcquireAsync(existingLock, opportunistic: false).ConfigureAwait(false); - canSafelyDisposeExistingLock = retryOnThisLockResult.CanSafelyDispose; - return retryOnThisLockResult.Handle; - case MultiplexedConnectionLockRetry.Retry: - break; - default: - throw new InvalidOperationException("unexpected retry"); - } - } - finally - { - // since we took this lock from the pool, always return it to the pool - await this.StoreOrDisposeLockAsync(connectionString, existingLock, shouldDispose: canSafelyDisposeExistingLock).ConfigureAwait(false); - } - } - - // normal phase: if we were not able to be opportunistic, ensure that we have a lock - var @lock = new MultiplexedConnectionLock(this.ConnectionFactory(connectionString)); - MultiplexedConnectionLock.Result? result = null; - try - { - result = await TryAcquireAsync(@lock, opportunistic: false).ConfigureAwait(false); - } - finally - { - // if we failed to even acquire a result on a brand new lock, then there's definitely no reason to store it - await this.StoreOrDisposeLockAsync(connectionString, @lock, shouldDispose: result?.CanSafelyDispose ?? true).ConfigureAwait(false); - } - return result.Value.Handle; - - ValueTask TryAcquireAsync(MultiplexedConnectionLock @lock, bool opportunistic) => - @lock.TryAcquireAsync(name, timeout, strategy, keepaliveCadence, cancellationToken, opportunistic); - } - - private async ValueTask GetOrCreateLockAsync(string connectionString) - { - using var _ = await this._lock.AcquireAsync(CancellationToken.None).ConfigureAwait(false); - - if (this._poolsByConnectionString.TryGetValue(connectionString, out var pool) && pool.Count != 0) - { - --this._pooledLockCount; - return pool.Dequeue(); - } - - return null; - } - - private async ValueTask StoreOrDisposeLockAsync(string connectionString, MultiplexedConnectionLock @lock, bool shouldDispose) - { - if (shouldDispose) - { - try { await @lock.DisposeAsync().ConfigureAwait(false); } - catch { /* swallow */ } - } - - using (await this._lock.AcquireAsync(CancellationToken.None).ConfigureAwait(false)) - { - ++this._storeCountSinceLastPrune; - - if (shouldDispose) - { - // If we're about to dispose the lock, check if it has an empty pool that can be removed from our dictionary. - // By itself this doesn't guarantee cleanup: after a successful acquire we'll have an empty lock left over that won't - // go away unless we use THAT connection string again. To help with this, we have pruning - if (this._poolsByConnectionString.TryGetValue(connectionString, out var pool) && pool.Count == 0) - { - this._poolsByConnectionString.Remove(connectionString); - } - } - else // otherwise, store the lock - { - ++this._pooledLockCount; - - if (this._poolsByConnectionString.TryGetValue(connectionString, out var existing)) - { - existing.Enqueue(@lock); - } - else - { - var newPool = new Queue(); - newPool.Enqueue(@lock); - this._poolsByConnectionString.Add(connectionString, newPool); - } - } - - if (this.IsDueForPruningNoLock()) - { - await this.PrunePoolsNoLockAsync().ConfigureAwait(false); - } - } - } - - private bool IsDueForPruningNoLock() - { - // Since pruning is expensive, we want to amortize its cost across many operations. The idea here is - // that each StoreOrDisposeLockAsync() call gives us one "ticket" that we can cache in later to justify - // some pruning work. The cost to prune is equal to the number of queues to scan plus the total number of - // items in each queue. Therefore we prune when we've built up enough tickets to "pay for" a pruning operation. - // The whole reason to prune is to avoid memory bloat (connection bloat isn't an issue since we only keep connections - // open when needed). So, we don't even consider pruning below a certain storage threshold - - var pruningCost = this._pooledLockCount + this._poolsByConnectionString.Count; - return pruningCost > 64 && this._storeCountSinceLastPrune >= pruningCost; - } - - // todo test - private async ValueTask PrunePoolsNoLockAsync() - { - this._storeCountSinceLastPrune = 0; // reset - - List? connectionStringsToRemove = null; - foreach (var kvp in this._poolsByConnectionString) - { - var pool = kvp.Value; - MultiplexedConnectionLock? firstRetainedLock = null; - while (pool.Count != 0 && pool.Peek() != firstRetainedLock) - { - var @lock = pool.Dequeue(); - if (await @lock.GetIsInUseAsync().ConfigureAwait(false)) - { - firstRetainedLock ??= @lock; - pool.Enqueue(@lock); - } - else - { - --this._pooledLockCount; - try { await @lock.DisposeAsync().ConfigureAwait(false); } - catch { /* swallow */ } - } - } - - if (pool.Count == 0) - { - (connectionStringsToRemove ??= new List()).Add(kvp.Key); - } - } - - if (connectionStringsToRemove != null) - { - foreach (var connectionStringToRemove in connectionStringsToRemove) - { - this._poolsByConnectionString.Remove(connectionStringToRemove); - } - } - } - } -} diff --git a/DistributedLock.Core/Internal/Data/OptimisticConnectionMultiplexingDbDistributedLock.cs b/DistributedLock.Core/Internal/Data/OptimisticConnectionMultiplexingDbDistributedLock.cs deleted file mode 100644 index 49818ac1..00000000 --- a/DistributedLock.Core/Internal/Data/OptimisticConnectionMultiplexingDbDistributedLock.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal.Data -{ - /// - /// Implements by multiplexing across connections where possible - /// -#if DEBUG - public -#else - internal -#endif - sealed class OptimisticConnectionMultiplexingDbDistributedLock : IDbDistributedLock - { - private readonly string _name, _connectionString; - private readonly MultiplexedConnectionLockPool _multiplexedConnectionLockPool; - private readonly TimeoutValue _keepaliveCadence; - private readonly IDbDistributedLock _fallbackLock; - - public OptimisticConnectionMultiplexingDbDistributedLock( - string name, - string connectionString, - MultiplexedConnectionLockPool multiplexedConnectionLockPool, - TimeoutValue keepaliveCadence) - { - this._name = name; - this._connectionString = connectionString; - this._multiplexedConnectionLockPool = multiplexedConnectionLockPool; - this._keepaliveCadence = keepaliveCadence; - this._fallbackLock = new DedicatedConnectionOrTransactionDbDistributedLock( - name, - () => this._multiplexedConnectionLockPool.ConnectionFactory(this._connectionString), - useTransaction: false, - keepaliveCadence: keepaliveCadence - ); - } - - public ValueTask TryAcquireAsync( - TimeoutValue timeout, - IDbSynchronizationStrategy strategy, - CancellationToken cancellationToken, - IDistributedLockHandle? contextHandle) - where TLockCookie : class - { - // cannot multiplex for updates, since we cannot predict whether or not there will be a request to elevate - // to an exclusive lock which asks for a long timeout - if (!strategy.IsUpgradeable && contextHandle == null) - { - return this._multiplexedConnectionLockPool.TryAcquireAsync(this._connectionString, this._name, timeout, strategy, keepaliveCadence: this._keepaliveCadence, cancellationToken); - } - - // otherwise, fall back to our fallback lock - return this._fallbackLock.TryAcquireAsync(timeout, strategy, cancellationToken, contextHandle); - } - } -} diff --git a/DistributedLock.Core/Internal/DistributedLockHelpers.cs b/DistributedLock.Core/Internal/DistributedLockHelpers.cs deleted file mode 100644 index 4483f667..00000000 --- a/DistributedLock.Core/Internal/DistributedLockHelpers.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal -{ - // todo revisit whether these should be copied or generated files vs internal APIs - -#if DEBUG - public -#else - internal -#endif - static class DistributedLockHelpers - { - public static string ToSafeName(string name, int maxNameLength, Func convertToValidName) - { - if (name == null) { throw new ArgumentNullException(nameof(name)); } - - var validBaseLockName = convertToValidName(name); - if (validBaseLockName == name && validBaseLockName.Length <= maxNameLength) - { - return name; - } - - using var sha = SHA512.Create(); - var hash = Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(name))); - - if (hash.Length >= maxNameLength) - { - return hash.Substring(0, length: maxNameLength); - } - - var prefix = validBaseLockName.Substring(0, Math.Min(validBaseLockName.Length, maxNameLength - hash.Length)); - return prefix + hash; - } - - // todo revisit API - public static async ValueTask Wrap(this ValueTask handleTask, Func factory) - where THandle : class - { - var handle = await handleTask.ConfigureAwait(false); - return handle != null ? factory(handle) : null; - } - - // todo consider removing this if we don't use it enough - internal static IDistributedLockHandle WithManagedFinalizer(this IDistributedLockHandle handle) - { - Invariant.Require(!(handle is ManagedFinalizationDistributedLockHandle)); - return new ManagedFinalizationDistributedLockHandle(handle); - } - - private sealed class ManagedFinalizationDistributedLockHandle : IDistributedLockHandle - { - private readonly IDistributedLockHandle _innerHandle; - private readonly IDisposable _finalizerRegistration; - - public ManagedFinalizationDistributedLockHandle(IDistributedLockHandle innerHandle) - { - this._innerHandle = innerHandle; - this._finalizerRegistration = ManagedFinalizerQueue.Instance.Register(this, innerHandle); - } - - public CancellationToken HandleLostToken => this._innerHandle.HandleLostToken; - - public void Dispose() => SyncOverAsync.Run(@this => @this.DisposeAsync(), this, false); - - public ValueTask DisposeAsync() - { - this._finalizerRegistration.Dispose(); - return this._innerHandle.DisposeAsync(); - } - } - - #region ---- IInternalDistributedLock implementations ---- - public static ValueTask AcquireAsync(IInternalDistributedLock @lock, TimeSpan? timeout, CancellationToken cancellationToken) - where THandle : class, IDistributedLockHandle => - @lock.InternalTryAcquireAsync(timeout, cancellationToken).ThrowTimeoutIfNull(); - - public static THandle Acquire(IInternalDistributedLock @lock, TimeSpan? timeout, CancellationToken cancellationToken) - where THandle : class, IDistributedLockHandle => - SyncOverAsync.Run( - state => AcquireAsync(state.@lock, state.timeout, state.cancellationToken), - (@lock, timeout, cancellationToken), - willGoAsync: @lock.WillGoAsync(timeout, cancellationToken) - ); - - public static THandle? TryAcquire(IInternalDistributedLock @lock, TimeSpan timeout, CancellationToken cancellationToken) - where THandle : class, IDistributedLockHandle => - SyncOverAsync.Run( - state => state.@lock.InternalTryAcquireAsync(state.timeout, state.cancellationToken), - (@lock, timeout, cancellationToken), - willGoAsync: @lock.WillGoAsync(timeout, cancellationToken) - ); - #endregion - - #region ---- IInternalDistributedReaderWriterLock implementations ---- - public static ValueTask AcquireAsync(IInternalDistributedReaderWriterLock @lock, TimeSpan? timeout, CancellationToken cancellationToken, bool isWrite) - where THandle : class, IDistributedLockHandle => - @lock.InternalTryAcquireAsync(timeout, cancellationToken, isWrite).ThrowTimeoutIfNull(); - - public static THandle Acquire(IInternalDistributedReaderWriterLock @lock, TimeSpan? timeout, CancellationToken cancellationToken, bool isWrite) - where THandle : class, IDistributedLockHandle => - SyncOverAsync.Run( - state => AcquireAsync(state.@lock, state.timeout, state.cancellationToken, state.isWrite), - (@lock, timeout, cancellationToken, isWrite), - willGoAsync: false - ); - - public static THandle? TryAcquire(IInternalDistributedReaderWriterLock @lock, TimeSpan timeout, CancellationToken cancellationToken, bool isWrite) - where THandle : class, IDistributedLockHandle => - SyncOverAsync.Run( - state => state.@lock.InternalTryAcquireAsync(state.timeout, state.cancellationToken, state.isWrite), - (@lock, timeout, cancellationToken, isWrite), - willGoAsync: false - ); - #endregion - - #region ---- IInternalDistributedUpgradeableReaderWriterLock implementations ---- - public static ValueTask AcquireUpgradeableReadLockAsync(IInternalDistributedUpgradeableReaderWriterLock @lock, TimeSpan? timeout, CancellationToken cancellationToken) - where THandle : class, IDistributedLockHandle - where TUpgradeableHandle : class, IDistributedLockUpgradeableHandle => - @lock.InternalTryAcquireUpgradeableReadLockAsync(timeout, cancellationToken).ThrowTimeoutIfNull(); - - public static TUpgradeableHandle AcquireUpgradeableReadLock(IInternalDistributedUpgradeableReaderWriterLock @lock, TimeSpan? timeout, CancellationToken cancellationToken) - where THandle : class, IDistributedLockHandle - where TUpgradeableHandle : class, IDistributedLockUpgradeableHandle => - SyncOverAsync.Run( - state => AcquireUpgradeableReadLockAsync(state.@lock, state.timeout, state.cancellationToken), - (@lock, timeout, cancellationToken), - willGoAsync: false - ); - - public static TUpgradeableHandle? TryAcquireUpgradeableReadLock(IInternalDistributedUpgradeableReaderWriterLock @lock, TimeSpan timeout, CancellationToken cancellationToken) - where THandle : class, IDistributedLockHandle - where TUpgradeableHandle : class, IDistributedLockUpgradeableHandle => - SyncOverAsync.Run( - state => state.@lock.InternalTryAcquireUpgradeableReadLockAsync(state.timeout, state.cancellationToken), - (@lock, timeout, cancellationToken), - willGoAsync: false - ); - #endregion - - #region ---- IDistributedLockUpgradeableHandle implementations ---- - public static ValueTask UpgradeToWriteLockAsync(IInternalDistributedLockUpgradeableHandle handle, TimeSpan? timeout, CancellationToken cancellationToken) => - handle.InternalTryUpgradeToWriteLockAsync(timeout, cancellationToken).ThrowTimeoutIfFalse(); - - public static void UpgradeToWriteLock(IDistributedLockUpgradeableHandle handle, TimeSpan? timeout, CancellationToken cancellationToken) => - SyncOverAsync.Run(t => t.handle.UpgradeToWriteLockAsync(t.timeout, t.cancellationToken), (handle, timeout, cancellationToken), false); - - public static bool TryUpgradeToWriteLock(IDistributedLockUpgradeableHandle handle, TimeSpan timeout, CancellationToken cancellationToken) => - SyncOverAsync.Run(t => t.handle.TryUpgradeToWriteLockAsync(t.timeout, t.cancellationToken), (handle, timeout, cancellationToken), false); - #endregion - - private static Exception LockTimeout() => new TimeoutException("Timeout exceeded when trying to acquire the lock"); - - public static async ValueTask ThrowTimeoutIfNull(this ValueTask task) where T : class => - await task.ConfigureAwait(false) ?? throw LockTimeout(); - - private static async ValueTask ThrowTimeoutIfFalse(this ValueTask task) - { - if (!await task.ConfigureAwait(false)) { throw LockTimeout(); } - } - } -} diff --git a/DistributedLock.Core/Internal/Helpers.cs b/DistributedLock.Core/Internal/Helpers.cs deleted file mode 100644 index dc88cc35..00000000 --- a/DistributedLock.Core/Internal/Helpers.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal -{ -#if DEBUG - public -#else - internal -#endif - static class Helpers - { - /// - /// Performs a type-safe cast - /// - public static T As(this T @this) => @this; - - /// - /// Performs a type-safe "cast" of a - /// - public static async ValueTask Convert(this ValueTask task, To.ValueTaskConversion _) - where TDerived : TBase => - await task.ConfigureAwait(false); - - public readonly struct TaskConversion - { - public TaskConversion To() => throw new InvalidOperationException(); - } - - public readonly struct TaskConversion { } - - internal static async ValueTask ConvertToVoid(this ValueTask task) => await task.ConfigureAwait(false); - - public static ValueTask AsValueTask(this Task task) => new ValueTask(task); - public static ValueTask AsValueTask(this Task task) => new ValueTask(task); - public static ValueTask AsValueTask(this T value) => new ValueTask(value); - - // todo rethink message here; should this return "handle" or something more generic since it might be an internal type? - public static ObjectDisposedException ObjectDisposed(this T _) where T : IAsyncDisposable => - throw new ObjectDisposedException(typeof(T).ToString()); - - public static NonThrowingAwaitable TryAwait(this TTask task) where TTask : Task => - new NonThrowingAwaitable(task); - - /// - /// Throwing exceptions is slow and our workflow has us canceling tasks in the common case. Using this special awaitable - /// allows for us to await those tasks without causing a thrown exception - /// - public readonly struct NonThrowingAwaitable : ICriticalNotifyCompletion - where TTask : Task - { - private readonly TTask _task; - private readonly ConfiguredTaskAwaitable.ConfiguredTaskAwaiter _taskAwaiter; - - public NonThrowingAwaitable(TTask task) - { - this._task = task; - this._taskAwaiter = task.ConfigureAwait(false).GetAwaiter(); - } - - public NonThrowingAwaitable GetAwaiter() => this; - - public bool IsCompleted => this._taskAwaiter.IsCompleted; - - public TTask GetResult() - { - // does NOT call _taskAwaiter.GetResult() since that could throw! - - Invariant.Require(this._task.IsCompleted); - return this._task; - } - - public void OnCompleted(Action continuation) => this._taskAwaiter.OnCompleted(continuation); - public void UnsafeOnCompleted(Action continuation) => this._taskAwaiter.UnsafeOnCompleted(continuation); - } - } - - /// - /// Assists with type inference for value task conversions - /// -#if DEBUG - public -#else - internal -#endif - static class To - { - public static ValueTaskConversion ValueTask => default; - - public readonly struct ValueTaskConversion { } - } -} diff --git a/DistributedLock.Core/Internal/IInternalDistributedLock.cs b/DistributedLock.Core/Internal/IInternalDistributedLock.cs deleted file mode 100644 index 0c231564..00000000 --- a/DistributedLock.Core/Internal/IInternalDistributedLock.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal -{ -#if DEBUG - public -#else - internal -#endif - interface IInternalDistributedLock : IDistributedLock - where THandle : class, IDistributedLockHandle - { - new THandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default); - new THandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - new ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); - new ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - - // internals - ValueTask InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken); - // todo remove - /// - /// In a sync-over-async scenario, determines whether the code will need to go async anyway - /// - bool WillGoAsync(TimeoutValue timeout, CancellationToken cancellationToken); - } -} diff --git a/DistributedLock.Core/Internal/IInternalDistributedLockUpgradeableHandle.cs b/DistributedLock.Core/Internal/IInternalDistributedLockUpgradeableHandle.cs deleted file mode 100644 index 5f544971..00000000 --- a/DistributedLock.Core/Internal/IInternalDistributedLockUpgradeableHandle.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal -{ -#if DEBUG - public -#else - internal -#endif - interface IInternalDistributedLockUpgradeableHandle : IDistributedLockUpgradeableHandle - { - ValueTask InternalTryUpgradeToWriteLockAsync(TimeoutValue timeout, CancellationToken cancellationToken); - } -} diff --git a/DistributedLock.Core/Internal/IInternalDistributedReaderWriterLock.cs b/DistributedLock.Core/Internal/IInternalDistributedReaderWriterLock.cs deleted file mode 100644 index d7c75d14..00000000 --- a/DistributedLock.Core/Internal/IInternalDistributedReaderWriterLock.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal -{ -#if DEBUG - public -#else - internal -#endif - interface IInternalDistributedReaderWriterLock : IDistributedReaderWriterLock - where THandle : class, IDistributedLockHandle - { - new THandle? TryAcquireReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); - new THandle AcquireReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - new ValueTask TryAcquireReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); - new ValueTask AcquireReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - new THandle? TryAcquireWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); - new THandle AcquireWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - new ValueTask TryAcquireWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); - new ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - - // internals - ValueTask InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken, bool isWrite); - } -} diff --git a/DistributedLock.Core/Internal/IInternalDistributedUpgradeableReaderWriterLock.cs b/DistributedLock.Core/Internal/IInternalDistributedUpgradeableReaderWriterLock.cs deleted file mode 100644 index e21d656c..00000000 --- a/DistributedLock.Core/Internal/IInternalDistributedUpgradeableReaderWriterLock.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal -{ -#if DEBUG - public -#else - internal -#endif - interface IInternalDistributedUpgradeableReaderWriterLock : IDistributedUpgradeableReaderWriterLock, IInternalDistributedReaderWriterLock - where THandle : class, IDistributedLockHandle - where TUpgradeableHandle : class, IDistributedLockUpgradeableHandle - { - new TUpgradeableHandle? TryAcquireUpgradeableReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); - new TUpgradeableHandle AcquireUpgradeableReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - new ValueTask TryAcquireUpgradeableReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); - new ValueTask AcquireUpgradeableReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - - // internals - ValueTask InternalTryAcquireUpgradeableReadLockAsync(TimeoutValue timeout, CancellationToken cancellationToken); - } -} diff --git a/DistributedLock.Core/Internal/Invariant.cs b/DistributedLock.Core/Internal/Invariant.cs deleted file mode 100644 index b1769a24..00000000 --- a/DistributedLock.Core/Internal/Invariant.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Diagnostics; - -namespace Medallion.Threading.Internal -{ -#if DEBUG - public -#else - internal -#endif - static class Invariant - { - [Conditional("DEBUG")] - public static void Require(bool condition, string? message = null) - { - if (!condition) - { - throw new InvalidOperationException(message ?? "invariant violated"); - } - } - } -} diff --git a/DistributedLock.Core/Internal/ManagedFinalizerQueue.cs b/DistributedLock.Core/Internal/ManagedFinalizerQueue.cs deleted file mode 100644 index 136f000b..00000000 --- a/DistributedLock.Core/Internal/ManagedFinalizerQueue.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal -{ - /// - /// Similar to finalization, but allows for arbitrary managed code to be run for cleanup - /// -#if DEBUG - public -#else - internal -#endif - sealed class ManagedFinalizerQueue - { - // 99% of the time, the finalizer will do nothing because people will dispose properly. The finalizer also must - // walk the full dictionary which in theory could be large if there is a lot of usage. Therefore, we don't want this to - // run too frequently. On the other hand, when something does go wrong we want to be able to recover in some reasonable period - // of time. 30s feels like is strikes a good balance here - internal static readonly TimeSpan FinalizerCadence = TimeSpan.FromSeconds( -#if DEBUG - 3 // to keep tests fast, use a much shorter cadence in debug -#else - 30 -#endif - ); - - public static readonly ManagedFinalizerQueue Instance = new ManagedFinalizerQueue(); - - private readonly ConcurrentDictionary _items = new ConcurrentDictionary(); - - // The state of this class can be described by 3 bits: - // _count: >0 or ==0 - // _finalizerTask: has cleared initializing bit or has not cleared it - // _initializing: 1 or 0 - // - // The following shows the possible states we could be in: - // _count | _finalizerTask | _initializing | nodes - // 0 | cleared | 0 | Initial state / finalizer getting ready to exit state. Register() => (>0, cleared, 1) - // 0 | cleared | 1 | If nothing changes, the finalizer will exit => (0, not cleared, 1). If something is registered => (>0, cleared, 1) - // 0 | not cleared | 0 | ERROR should never happen - // 0 | not cleared | 1 | Once our finalizer runs, it will => (0, cleared, 0). If something is registered => (>0, not cleared, 1) - // >0 | cleared | 0 | Finalizer is running. If count drops to zero => (0, cleared, 0) - // >0 | cleared | 1 | Means we dropped to 0 count but came back up before the finalizer exited. We now have a running finalizer and one queued - // >0 | not cleared | 0 | ERROR should never happen - // >0 | not cleared | 1 | Finalizer will run and transition to (>0, cleared, 0). Removal could transfer to (0, not cleared, 1) - - /// - /// Tracked separately from the dictionary since (a) ConcurrentDictionary's count is slow and (b) we need to know exactly when we - /// add one item to empty or remove one item from empty. We use a long to guarantee that we can't ever overflow (if there were really - /// 2^63 items in the queue, we'd be out of memory) - /// - private long _count; - - private Task _finalizerTask = Task.CompletedTask; - private int _finalizerTaskIsInitializing; - - private ManagedFinalizerQueue() { } - - /// - /// If is GC'd, will be run. - /// must be thread-safe. Disposing the returned - /// revokes the registration. Note that, for this to work, must not hold - /// a strong reference to . - /// - public IDisposable Register(object resource, IAsyncDisposable finalizer) - { - Invariant.Require(finalizer != resource); - - this._items.As>() - .Add(finalizer, new WeakReference(resource)); - - if (Interlocked.Increment(ref this._count) == 1) - { - this.StartFinalizerTask(); - } - - return new Registration(this, finalizer); - } - - private void StartFinalizerTask() - { - // If we're frequently adding and then removing a single item (probably a common case - // since most of the time people will dispose things and won't do too much distributed locking), - // we could end up thrashing where we create new finalizer tasks over and over again. To avoid - // this, we set the initializing flag, but in the case that it was already set we know that there - // is a task that is still getting ready; in that case we can just let that task continue to be - // the finalizer task: there's no need to replace it - if (Interlocked.Exchange(ref this._finalizerTaskIsInitializing, 1) != 0) - { - return; - } - - // This lock is only barely necessary. The race condition this solves for is the continuation task - // starting to run the finalizer loop AND clearing the initialization bit before we've assigned - // to this._finalizerTask. In that case, another thread could continue off the wrong task. The reason - // this is super unlikely is because the loop sleeps before clearing the bit, so things have to go horribly - // awry for this edge-case to occur. - lock (this._items) // lock _items just because it's an object we own - { - // When we get here, the previous finalizer should exit on its next iteration but it hasn't - // necessarily exited yet (and may not for some time). Therefore, we queue a task to run as a - // continuation so that we only have one finalizer loop at a time - this._finalizerTask = this._finalizerTask.ContinueWith( - (_, @this) => ((ManagedFinalizerQueue)@this).FinalizerLoop(), - state: this, - CancellationToken.None - ) - .Unwrap(); - } - } - - private async Task FinalizerLoop() - { - // Any new finalizer loop delays before doing anything else. We start the loop when we just added - // something, so there's little chance of it having something to do right away - await Task.Delay(FinalizerCadence).ConfigureAwait(false); - - // Clear the initializing flag. By doing this, we allow another task to be queued on top of us - var initializingFlag = Interlocked.Exchange(ref this._finalizerTaskIsInitializing, 0); - Invariant.Require(initializingFlag == 1); - - // Loop until there is nothing more to do - while (Volatile.Read(ref this._count) != 0) - { - // the main finalizer does not wait for item finalization since we don't want that to ever - // block or fault the main loop - await this.FinalizeAsync(waitForItemFinalization: false).ConfigureAwait(false); - await Task.Delay(FinalizerCadence).ConfigureAwait(false); - } - } - - private Task FinalizeAsync(bool waitForItemFinalization) - { - List? itemFinalizerTasks = null; - - // ConcurrentDictionary enumerator is safe to use concurrently with writes and is very inexpensive - // (lock-free and does not generate a snapshot copy) - foreach (var kvp in this._items) - { - if (!kvp.Value.IsAlive) - { - var itemFinalizerTask = this.TryRemove(kvp.Key, disposeKey: true); - if (waitForItemFinalization) - { - (itemFinalizerTasks ??= new List()).Add(itemFinalizerTask); - } - } - } - - return waitForItemFinalization ? Task.WhenAll(itemFinalizerTasks ?? Enumerable.Empty()) : Task.CompletedTask; - } - - /// - /// Forces finalization of anything that is eligible. Exposed for testing purposes only - /// - internal Task FinalizeAsync() => this.FinalizeAsync(waitForItemFinalization: true); - - private Task TryRemove(IAsyncDisposable key, bool disposeKey) - { - if (this._items.TryRemove(key, out _)) - { - Interlocked.Decrement(ref this._count); - if (disposeKey) - { - // DisposeAsync could throw, hang, etc. This must not block the finalizer thread. - // Therefore, we offload to a background thread and swallow exceptions - return Task.Run(() => key.DisposeAsync().AsTask()); - } - } - - return Task.CompletedTask; - } - - private sealed class Registration : IDisposable - { - private readonly ManagedFinalizerQueue _queue; - private IAsyncDisposable? _key; - - public Registration(ManagedFinalizerQueue queue, IAsyncDisposable key) - { - this._queue = queue; - this._key = key; - } - - public void Dispose() - { - var key = Interlocked.Exchange(ref this._key, null); - if (key != null) - { - // If the registration gets disposed, we don't need to dispose the key - // because that means it got disposed normally - this._queue.TryRemove(key, disposeKey: false); - } - } - } - } -} diff --git a/DistributedLock.Core/Internal/RefBox.cs b/DistributedLock.Core/Internal/RefBox.cs deleted file mode 100644 index 64e15720..00000000 --- a/DistributedLock.Core/Internal/RefBox.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Threading; - -namespace Medallion.Threading.Internal -{ - // todo use in more places or get rid of -#if DEBUG - public -#else - internal -#endif - sealed class RefBox where T : struct - { - private readonly T _value; - - internal RefBox(T value) - { - this._value = value; - } - - public ref readonly T Value => ref this._value; - } - -#if DEBUG - public -#else - internal -#endif - sealed class RefBox - { - public static RefBox Create(T value) where T : struct => new RefBox(value); - - public static bool TryConsume(ref RefBox? boxRef, out T value) - where T : struct - { - var box = Interlocked.Exchange(ref boxRef, null); - if (box != null) - { - value = box.Value; - return true; - } - - value = default; - return false; - } - } -} diff --git a/DistributedLock.Core/Internal/SyncOverAsync.cs b/DistributedLock.Core/Internal/SyncOverAsync.cs deleted file mode 100644 index 698e5703..00000000 --- a/DistributedLock.Core/Internal/SyncOverAsync.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Internal -{ - /// - /// Helps re-use code across sync and async pathways, leveraging the fact that async code will run synchronously - /// unless it actually encounters an async operation. Downstream code should use the - /// to choose between sync and async operations - /// -#if DEBUG - public -#else - internal -#endif - static class SyncOverAsync - { - [ThreadStatic] - private static bool _isSynchronous; - - public static bool IsSynchronous => _isSynchronous; - - // todo get rid of WillGoAsync, replace with a debug-only API for turning on these assertions that a test can use - // e. g. using (SyncOverAsync.SyncMode()) { Assert.DoesNotThrow(() => handle.Dispose()); } - - public static void Run(Func action, TState state, bool willGoAsync) - { - Run( - async s => - { - await s.action(s.state).ConfigureAwait(false); - return true; - }, - (action, state), - willGoAsync - ); - } - - public static TResult Run(Func> action, TState state, bool willGoAsync) - { - Invariant.Require(!_isSynchronous); - - if (willGoAsync) - { - var task = action(state); - Invariant.Require(!task.IsCompleted); - // call AsTask(), since https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask-1?view=netcore-3.1 - // says that we should not call GetAwaiter().GetResult() except on a completed ValueTask - return task.AsTask().GetAwaiter().GetResult(); - } - - try - { - _isSynchronous = true; - - var task = action(state); - Invariant.Require(task.IsCompleted); - return task.GetAwaiter().GetResult(); - } - finally - { - _isSynchronous = false; - } - } - - public static ValueTask Delay(TimeoutValue timeout, CancellationToken cancellationToken) - { - if (!IsSynchronous) - { - return Task.Delay(timeout.InMilliseconds, cancellationToken).AsValueTask(); - } - - if (cancellationToken.CanBeCanceled) - { - if (cancellationToken.WaitHandle.WaitOne(timeout.InMilliseconds)) - { - throw new OperationCanceledException("delay was canceled", cancellationToken); - } - } - else - { - Thread.Sleep(timeout.InMilliseconds); - } - - return default; - } - } -} diff --git a/DistributedLock.Core/Internal/TimeoutValue.cs b/DistributedLock.Core/Internal/TimeoutValue.cs deleted file mode 100644 index 0aa6c1cb..00000000 --- a/DistributedLock.Core/Internal/TimeoutValue.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Threading; - -namespace Medallion.Threading.Internal -{ - // todo test - /// - /// A type which can only store a valid timeout value - /// -#if DEBUG - public -#else - internal -#endif - readonly struct TimeoutValue : IEquatable, IComparable - { - public TimeoutValue(TimeSpan? timeout, string paramName = "timeout") - { - if (timeout is { } timeoutValue) - { - // based on Task.Wait(TimeSpan) - // https://referencesource.microsoft.com/#mscorlib/system/threading/Tasks/Task.cs,855657030ba22f78 - - var totalMilliseconds = (long)timeoutValue.TotalMilliseconds; - if (totalMilliseconds < -1 || totalMilliseconds > int.MaxValue) - { - throw new ArgumentOutOfRangeException( - paramName: paramName, - actualValue: timeoutValue, - message: $"Must be {nameof(Timeout)}.{nameof(Timeout.InfiniteTimeSpan)} ({Timeout.InfiniteTimeSpan}) or a non-negative value <= {TimeSpan.FromMilliseconds(int.MaxValue)})" - ); - } - - this.InMilliseconds = (int)totalMilliseconds; - } - else - { - this.InMilliseconds = Timeout.Infinite; - } - } - - public int InMilliseconds { get; } - public int InSeconds => this.IsInfinite ? throw new InvalidOperationException("infinite timeout cannot be converted to seconds") : this.InMilliseconds / 1000; - public bool IsInfinite => this.InMilliseconds == Timeout.Infinite; - public bool IsZero => this.InMilliseconds == 0; - public TimeSpan TimeSpan => TimeSpan.FromMilliseconds(this.InMilliseconds); - - public bool Equals(TimeoutValue that) => this.InMilliseconds == that.InMilliseconds; - public override bool Equals(object? obj) => obj is TimeoutValue that && this.Equals(that); - public override int GetHashCode() => this.InMilliseconds; - - public int CompareTo(TimeoutValue that) => - this.IsInfinite ? (that.IsInfinite ? 0 : 1) - : that.IsInfinite ? -1 - : this.InMilliseconds.CompareTo(that.InMilliseconds); - - public static bool operator ==(TimeoutValue a, TimeoutValue b) => a.Equals(b); - public static bool operator !=(TimeoutValue a, TimeoutValue b) => !(a == b); - - public static implicit operator TimeoutValue(TimeSpan? timeout) => new TimeoutValue(timeout); - - public override string ToString() => - this.IsInfinite ? "∞" - : this.IsZero ? "0" - : this.TimeSpan.ToString(); - } -} diff --git a/DistributedLock.Postgres/PostgresAdvisoryLock.cs b/DistributedLock.Postgres/PostgresAdvisoryLock.cs deleted file mode 100644 index e7337087..00000000 --- a/DistributedLock.Postgres/PostgresAdvisoryLock.cs +++ /dev/null @@ -1,258 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using Npgsql; -using System; -using System.Collections.Generic; -using System.Data; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Postgres -{ - /// - /// Implements using advisory locking functions - /// (see https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS) - /// - internal class PostgresAdvisoryLock : IDbSynchronizationStrategy - { - // matches SqlApplicationLock - private const int AlreadyHeldReturnCode = 103; - - private static readonly object Cookie = new object(); - - public static readonly PostgresAdvisoryLock ExclusiveLock = new PostgresAdvisoryLock(isShared: false), - SharedLock = new PostgresAdvisoryLock(isShared: true); - - private readonly bool _isShared; - - private PostgresAdvisoryLock(bool isShared) - { - this._isShared = isShared; - } - - /// - /// Advisory locks don't natively support upgradeable - /// - public bool IsUpgradeable => false; - - public async ValueTask TryAcquireAsync(DatabaseConnection connection, string resourceName, TimeoutValue timeout, CancellationToken cancellationToken) - { - const string SavePointName = "medallion_threading_postgres_advisory_lock_acquire"; - - var key = new PostgresAdvisoryLockKey(resourceName); - - var hasTransaction = await HasTransactionAsync(connection).ConfigureAwait(false); - if (hasTransaction) - { - // Our acquire command will use SET LOCAL to set up statement timeouts. This lasts until the end - // of the current transaction instead of just the current batch if we're in a transaction. To make sure - // we don't leak those settings, in the case of a transaction we first set up a save point which we can - // later roll back (taking the settings changes with it but NOT the lock). Because we can't confidently - // roll back a save point without knowing that it has been set up, we start the save point in its own - // query before we try-catch - using var setSavePointCommand = connection.CreateCommand(); - setSavePointCommand.SetCommandText("SAVEPOINT " + SavePointName); - await setSavePointCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - - using var acquireCommand = this.CreateAcquireCommand(connection, key, timeout); - - int acquireCommandResult; - try - { - acquireCommandResult = (int)await acquireCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - await RollBackTransactionTimeoutVariablesIfNeededAsync().ConfigureAwait(false); - - if (ex is PostgresException postgresException) - { - switch (postgresException.SqlState) - { - // lock_timeout error code from https://www.postgresql.org/docs/10/errcodes-appendix.html - case "55P03": - return null; - // deadlock_detected error code from https://www.postgresql.org/docs/10/errcodes-appendix.html - case "40P01": - throw new DeadlockException($"The request for the distributed lock failed with exit code '{postgresException.SqlState}' (deadlock_detected)", ex); - } - } - - if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) - { - // if we bailed in the middle of an acquire, make sure we didn't leave a lock behind - await this.ReleaseAsync(connection, key, isTry: true).ConfigureAwait(false); - } - - throw; - } - - await RollBackTransactionTimeoutVariablesIfNeededAsync().ConfigureAwait(false); - - switch (acquireCommandResult) - { - case 0: return null; - case 1: return Cookie; - case AlreadyHeldReturnCode: - // todo revisit this behavior. Probably should throw deadlock to be consistent with Semaphore. See also SqlApplicationLock - if (timeout.IsZero) { return null; } - if (timeout.IsInfinite) { throw new InvalidOperationException("Attempted to acquire a lock that is already held on the same connection"); } - await SyncOverAsync.Delay(timeout, cancellationToken).ConfigureAwait(false); - return null; - default: - throw new InvalidOperationException($"Unexpected return code {acquireCommandResult}"); - } - - async ValueTask RollBackTransactionTimeoutVariablesIfNeededAsync() - { - if (hasTransaction) - { - // attempt to clear the timeout variables we set - using var rollBackSavePointCommand = connection.CreateCommand(); - rollBackSavePointCommand.SetCommandText("ROLLBACK TO SAVEPOINT " + SavePointName); - await rollBackSavePointCommand.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - } - } - } - - private DatabaseCommand CreateAcquireCommand(DatabaseConnection connection, PostgresAdvisoryLockKey key, TimeoutValue timeout) - { - var command = connection.CreateCommand(); - - var commandText = new StringBuilder(); - - commandText.AppendLine("SET LOCAL statement_timeout = 0;"); - commandText.AppendLine($"SET LOCAL lock_timeout = {(timeout.IsZero || timeout.IsInfinite ? 0 : timeout.InMilliseconds)};"); - - if (connection.IsExernallyOwned) - { - commandText.Append($@" - SELECT - CASE WHEN EXISTS( - SELECT * - FROM pg_catalog.pg_locks l - JOIN pg_catalog.pg_database d - ON d.oid = l.database - WHERE l.locktype = 'advisory' - AND {AddPGLocksFilterParametersAndGetFilterExpression(command, key)} - AND l.pid = pg_catalog.pg_backend_pid() - AND d.datname = pg_catalog.current_database() - ) - THEN {AlreadyHeldReturnCode} - ELSE - " - ); - AppendAcquireFunctionCall(); - commandText.AppendLine().Append("END"); - } - else - { - commandText.Append("SELECT "); - AppendAcquireFunctionCall(); - } - commandText.Append(" AS result"); - - command.SetCommandText(commandText.ToString()); - command.SetTimeout(timeout); - - return command; - - void AppendAcquireFunctionCall() - { - // creates an expression like - // pg_try_advisory_lock(@key1, @key2)::int - // OR (SELECT 1 FROM (SELECT pg_advisory_lock(@key)) f) - var isTry = timeout.IsZero; - if (!isTry) { commandText.Append("(SELECT 1 FROM (SELECT "); } - commandText.Append("pg_catalog.pg"); - if (isTry) { commandText.Append("_try"); } - commandText.Append("_advisory"); - commandText.Append("_lock"); - if (this._isShared) { commandText.Append("_shared"); } - commandText.Append('(').Append(AddKeyParametersAndGetKeyArguments(command, key)).Append(')'); - if (isTry) { commandText.Append("::int"); } - else { commandText.Append(") f)"); } - } - } - - private static async ValueTask HasTransactionAsync(DatabaseConnection connection) - { - if (connection.HasTransaction) { return true; } - if (!connection.IsExernallyOwned) { return false; } - - // If the connection is externally owned, then it might be part of a transaction that we can't - // see. In that case, the only real way to detect it is to begin a new one - try - { - await connection.BeginTransactionAsync().ConfigureAwait(false); - } - catch (InvalidOperationException) - { - return true; - } - - await connection.DisposeTransactionAsync().ConfigureAwait(false); - return false; - } - - public ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, object lockCookie) => - this.ReleaseAsync(connection, new PostgresAdvisoryLockKey(resourceName), isTry: false); - - private async ValueTask ReleaseAsync(DatabaseConnection connection, PostgresAdvisoryLockKey key, bool isTry) - { - using var command = connection.CreateCommand(); - command.SetCommandText($"SELECT pg_catalog.pg_advisory_unlock{(this._isShared ? "_shared" : string.Empty)}({AddKeyParametersAndGetKeyArguments(command, key)})"); - var result = (bool)await command.ExecuteScalarAsync(CancellationToken.None).ConfigureAwait(false); - if (!isTry && !result) - { - throw new InvalidOperationException("Attempted to release a lock that was not held"); - } - } - - private static string AddKeyParametersAndGetKeyArguments(DatabaseCommand command, PostgresAdvisoryLockKey key) - { - if (key.HasSingleKey) - { - command.AddParameter("key", key.Key, DbType.Int64); - return "@key"; - } - else - { - var (key1, key2) = key.Keys; - command.AddParameter("key1", key1, DbType.Int32); - command.AddParameter("key2", key2, DbType.Int32); - return "@key1, @key2"; - } - } - - private static string AddPGLocksFilterParametersAndGetFilterExpression(DatabaseCommand command, PostgresAdvisoryLockKey key) - { - // From https://www.postgresql.org/docs/12/view-pg-locks.html - // Advisory locks can be acquired on keys consisting of either a single bigint value or two integer values. - // A bigint key is displayed with its high-order half in the classid column, its low-order half in the objid column, - // and objsubid equal to 1. The original bigint value can be reassembled with the expression (classid::bigint << 32) | objid::bigint. - // Integer keys are displayed with the first key in the classid column, the second key in the objid column, and objsubid equal to 2. - - string classIdParameter, objIdParameter, objSubId; - if (key.HasSingleKey) - { - // since Postgres seems to lack unchecked int conversions, it is simpler to just generate extra - // parameters to carry the split key info in this case - var (keyUpper32, keyLower32) = key.Keys; - command.AddParameter(classIdParameter = "keyUpper32", keyUpper32, DbType.Int32); - command.AddParameter(objIdParameter = "keyLower32", keyLower32, DbType.Int32); - objSubId = "1"; - } - else - { - classIdParameter = "key1"; - objIdParameter = "key2"; - objSubId = "2"; - } - - return $"(l.classid = @{classIdParameter} AND l.objid = @{objIdParameter} AND l.objsubid = {objSubId})"; - } - } -} diff --git a/DistributedLock.Postgres/PostgresAdvisoryLockKey.cs b/DistributedLock.Postgres/PostgresAdvisoryLockKey.cs deleted file mode 100644 index 26eab543..00000000 --- a/DistributedLock.Postgres/PostgresAdvisoryLockKey.cs +++ /dev/null @@ -1,274 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Globalization; -using System.Security.Cryptography; -using System.Text; - -namespace Medallion.Threading.Postgres -{ - /// - /// Acts as the "name" of a distributed lock in Postgres. Consists of one 64-bit value or two 32-bit values (the spaces do not overlap). - /// See https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS - /// - public readonly struct PostgresAdvisoryLockKey : IEquatable - { - private readonly long _key; - private readonly KeyEncoding _keyEncoding; - - /// - /// Constructs a key from a single 64-bit value - /// - public PostgresAdvisoryLockKey(long key) - { - this._key = key; - this._keyEncoding = KeyEncoding.Int64; - } - - /// - /// Constructs a key from a pair of 32-bit values - /// - public PostgresAdvisoryLockKey(int key1, int key2) - { - this._key = CombineKeys(key1, key2); - this._keyEncoding = KeyEncoding.Int32Pair; - } - - // todo should allow hashing be default (exact name)? - /// - /// Constructs a key based on a string . - /// - /// If the string is of the form "{16-digit hex}" or "{8-digit hex},{8-digit hex}", this will be parsed into numeric keys. - /// - /// If the string is an ascii string with 9 or fewer characters, it will be mapped to a key that does not collide with - /// any other key based on such a string or based on a 32-bit value. - /// - /// Other string names will be rejected unless is specified, in which case it will be hashed to - /// a 64-bit key value. - /// - public PostgresAdvisoryLockKey(string name, bool allowHashing = false) - { - if (name == null) { throw new ArgumentNullException(nameof(name)); } - - if (TryEncodeAscii(name, out this._key)) - { - this._keyEncoding = KeyEncoding.Ascii; - } - else if (TryEncodeHashString(name, out this._key, out var hasSeparator)) - { - this._keyEncoding = hasSeparator ? KeyEncoding.Int32Pair : KeyEncoding.Int64; - } - else if (allowHashing) - { - this._key = HashString(name); - this._keyEncoding = KeyEncoding.Int64; - } - else - { - throw new FormatException($"Name '{name}' could not be encoded as a {nameof(PostgresAdvisoryLockKey)}. Please specify {nameof(allowHashing)} or use one of the following formats:" - + $" or (1) a 0-{MaxAsciiLength} character string using only non-0 ASCII characters" - + $", (2) a {HashStringLength} character hex string, such as the result of Int64.MaxValue.ToString(\"x{HashStringLength}\")" - + $", or (3) a 2-part, {SeparatedHashStringLength} character string of the form XXXXXXXX{HashStringSeparator}XXXXXXXX, where the X's are {HashPartLength} hex strings" - + $" such as the result of Int32.MaxValue.ToString(\"x{HashPartLength}\")." - + " Note that each unique string provided for formats 1 and 2 will map to a unique hash value, with no collisions across formats. Format 3 strings use the same key space as 2."); - } - } - - internal bool HasSingleKey => this._keyEncoding == KeyEncoding.Int64; - - internal long Key - { - get - { - Invariant.Require(this.HasSingleKey); - return this._key; - } - } - - // note: we allow calling this even with a single key, since for - // pg_locks lookups we have to split the key anyway - internal (int key1, int key2) Keys => SplitKeys(this._key); - - /// - /// Implements - /// - public bool Equals(PostgresAdvisoryLockKey that) => this.ToTuple().Equals(that.ToTuple()); - - /// - /// Implements - /// - public override bool Equals(object obj) => obj is PostgresAdvisoryLockKey that && this.Equals(that); - - /// - /// Implements - /// - public override int GetHashCode() => this.ToTuple().GetHashCode(); - - /// - /// Provides equality based on - /// - public static bool operator ==(PostgresAdvisoryLockKey a, PostgresAdvisoryLockKey b) => a.Equals(b); - /// - /// Provides inequality based on - /// - public static bool operator !=(PostgresAdvisoryLockKey a, PostgresAdvisoryLockKey b) => !(a == b); - - private (long, bool) ToTuple() => (this._key, this.HasSingleKey); - - /// - /// Returns a string representation of the key that can be round-tripped through - /// - /// - public override string ToString() => this._keyEncoding switch - { - KeyEncoding.Int64 => ToHashString(this._key), - KeyEncoding.Int32Pair => ToHashString(SplitKeys(this._key)), - KeyEncoding.Ascii => ToAsciiString(this._key), - _ => throw new InvalidOperationException() - }; - - private static long CombineKeys(int key1, int key2) => unchecked(((long)key1 << (8 * sizeof(int))) | (uint)key2); - private static (int key1, int key2) SplitKeys(long key) => ((int)(key >> (8 * sizeof(int))), unchecked((int)(key & uint.MaxValue))); - - #region ---- Ascii ---- - // The ASCII encoding works as follows: - // Each ASCII char is 7 bits allowing for 9 chars = 63 bits in total. - // In order to differentiate between different-length strings with leading '\0', - // we additionally fill the next bit after the string ends with 0. We then fill any - // remaining bits with 1. Therefore the final 64 bit value is 0-9 7-bit characters followed - // by 0, followed by N=63-(7*length) 1s - - private const int AsciiCharBits = 7; - private const int MaxAsciiValue = (1 << AsciiCharBits) - 1; - internal const int MaxAsciiLength = (8 * sizeof(long)) / AsciiCharBits; - - private static bool TryEncodeAscii(string name, out long key) - { - if (name.Length > MaxAsciiLength) - { - key = default; - return false; - } - - // load the chars into result - var result = 0L; - foreach (var @char in name) - { - if (@char > MaxAsciiValue) - { - key = default; - return false; - } - - result = (result << AsciiCharBits) | @char; - } - - // add padding - result <<= 1; // load zero - for (var i = name.Length; i < MaxAsciiLength; ++i) - { - result = (result << AsciiCharBits) | MaxAsciiValue; // load 1s - } - - key = result; - return true; - } - - private static string ToAsciiString(long key) - { - // use unsigned to avoid signed shifts - var remainingKeyBits = unchecked((ulong)key); - - // unload padding 1s to determine length - var length = MaxAsciiLength; - while ((remainingKeyBits & MaxAsciiValue) == MaxAsciiValue) - { - --length; - remainingKeyBits >>= AsciiCharBits; - } - Invariant.Require((remainingKeyBits & 1) == 0, "last padding bit should be zero"); - remainingKeyBits >>= 1; // unload padding 0 - - var chars = new char[length]; - for (var i = length - 1; i >= 0; --i) - { - chars[i] = (char)(remainingKeyBits & MaxAsciiValue); - remainingKeyBits >>= AsciiCharBits; - } - - return new string(chars, startIndex: 0, length); - } - #endregion - - #region ---- Hashing ---- - private const char HashStringSeparator = ','; - internal const int HashPartLength = 8, // 8-byte hex numbers - HashStringLength = 16, // 2 hashes - SeparatedHashStringLength = HashStringLength + 1; // separated by comma - - private static bool TryEncodeHashString(string name, out long key, out bool hasSeparator) - { - if (name.Length == SeparatedHashStringLength && name[HashPartLength] == HashStringSeparator) - { - hasSeparator = true; - } - else - { - hasSeparator = false; - - if (name.Length != HashStringLength) - { - key = default; - return false; - } - } - - return TryParseHashKeys(name, out key); - - static bool TryParseHashKeys(string text, out long key) - { - if (TryParseHashKey(text.Substring(0, HashPartLength), out var key1) - && TryParseHashKey(text.Substring(text.Length - HashPartLength), out var key2)) - { - key = CombineKeys(key1, key2); - return true; - } - - key = default; - return false; - } - - static bool TryParseHashKey(string text, out int key) => - int.TryParse(text, NumberStyles.AllowHexSpecifier, NumberFormatInfo.InvariantInfo, out key); - } - - private static long HashString(string name) - { - // The hash result from SHA1 is too large, so we have to truncate (recommended practice and does not - // weaken the hash other than due to using fewer bytes) - - using var sha1 = SHA1.Create(); - var hashBytes = sha1.ComputeHash(Encoding.UTF8.GetBytes(name)); - - // We don't use BitConverter here because we want to be endianess-agnostic. - // However, this code replicates that result on little-endian - var result = 0L; - for (var i = sizeof(long) - 1; i >= 0; --i) - { - result = (result << 8) | hashBytes[i]; - } - return result; - } - - private static string ToHashString((int key1, int key2) keys) => FormattableString.Invariant($"{keys.key1:x8}{HashStringSeparator}{keys.key2:x8}"); - - private static string ToHashString(long key) => key.ToString("x16", NumberFormatInfo.InvariantInfo); - #endregion - - private enum KeyEncoding - { - Int64 = 0, - Int32Pair, - Ascii, - } - } -} diff --git a/DistributedLock.Postgres/PostgresConnectionOptionsBuilder.cs b/DistributedLock.Postgres/PostgresConnectionOptionsBuilder.cs deleted file mode 100644 index 0cef19cf..00000000 --- a/DistributedLock.Postgres/PostgresConnectionOptionsBuilder.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Postgres -{ - /// - /// Specifies options for connecting to and locking against a Postgres database - /// - public sealed class PostgresConnectionOptionsBuilder - { - private TimeoutValue? _keepaliveCadence; - private bool? _useMultiplexing; - - internal PostgresConnectionOptionsBuilder() { } - - /// - /// Some Postgres setups have automation in place which aggressively kills idle connections. - /// - /// To prevent this, this option sets the cadence at which we run a no-op "keepalive" query on a connection that is holding a lock. - /// Note that this still does not guarantee protection for the connection from all conditions where the governor might kill it. - /// - /// Defaults to , which disables keepalive. - /// - public PostgresConnectionOptionsBuilder KeepaliveCadence(TimeSpan keepaliveCadence) - { - this._keepaliveCadence = new TimeoutValue(keepaliveCadence, nameof(keepaliveCadence)); - return this; - } - - /// - /// This mode takes advantage of the fact that while "holding" a lock (or other synchronization primitive) - /// a connection is essentially idle. Thus, rather than creating a new connection for each held lock it is - /// often possible to multiplex a shared connection so that that connection can hold multiple locks at the same time. - /// - /// Multiplexing is on by default. - /// - /// This is implemented in such a way that releasing a lock held on such a connection will never be blocked by an - /// Acquire() call that is waiting to acquire a lock on that same connection. For this reason, the multiplexing - /// strategy is "optimistic": if the lock can't be acquired instantaneously on the shared connection, a new (shareable) - /// connection will be allocated. - /// - /// This option can improve performance and avoid connection pool starvation in high-load scenarios. It is also - /// particularly applicable to cases where - /// semantics are used with a zero-length timeout. - /// - public PostgresConnectionOptionsBuilder UseMultiplexing(bool useMultiplexing = true) - { - this._useMultiplexing = useMultiplexing; - return this; - } - - internal static (TimeoutValue keepaliveCadence, bool useMultiplexing) GetOptions(Action? optionsBuilder) - { - PostgresConnectionOptionsBuilder? options; - if (optionsBuilder != null) - { - options = new PostgresConnectionOptionsBuilder(); - optionsBuilder(options); - } - else - { - options = null; - } - - var keepaliveCadence = options?._keepaliveCadence ?? Timeout.InfiniteTimeSpan; - var useMultiplexing = options?._useMultiplexing ?? true; - - return (keepaliveCadence, useMultiplexing); - } - } -} diff --git a/DistributedLock.Postgres/PostgresDatabaseConnection.cs b/DistributedLock.Postgres/PostgresDatabaseConnection.cs deleted file mode 100644 index 4d70c595..00000000 --- a/DistributedLock.Postgres/PostgresDatabaseConnection.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using Npgsql; -using System; -using System.Collections.Generic; -using System.Data; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Postgres -{ - internal sealed class PostgresDatabaseConnection : DatabaseConnection - { - public PostgresDatabaseConnection(IDbConnection connection) - : base(connection, isExternallyOwned: true) - { - } - - public PostgresDatabaseConnection(IDbTransaction transaction) - : base(transaction, isExternallyOwned: true) - { - } - - public PostgresDatabaseConnection(string connectionString) - : base(new NpgsqlConnection(connectionString), isExternallyOwned: false) - { - } - - // see https://www.npgsql.org/doc/prepare.html - public override bool ShouldPrepareCommands => true; - - public override bool IsCommandCancellationException(Exception exception) => - exception is PostgresException postgresException - // cancellation error code from https://www.postgresql.org/docs/10/errcodes-appendix.html - && postgresException.SqlState == "57014"; - - public override async Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor) - { - Invariant.Require(sleepTime >= TimeSpan.Zero); - - // if we're in a transaction, we need to establish a savepoint so that we can roll back if we - // get canceled without the whole transaction being aborted - const string SavePointName = "medallion_threading_postgres_database_connection_sleep"; - - var hasTransaction = this.HasTransaction; - if (hasTransaction) - { - using var setSavePointCommand = this.CreateCommand(); - setSavePointCommand.SetCommandText("SAVEPOINT " + SavePointName); - await executor(setSavePointCommand, CancellationToken.None).ConfigureAwait(false); - } - - try - { - using var sleepCommand = this.CreateCommand(); - sleepCommand.SetCommandText("SELECT pg_catalog.pg_sleep(@sleepTimeSeconds)"); - sleepCommand.AddParameter("sleepTimeSeconds", sleepTime.TotalSeconds, DbType.Double); - sleepCommand.SetTimeout(sleepTime); - await executor(sleepCommand, cancellationToken).ConfigureAwait(false); - } - finally - { - if (hasTransaction) - { - using var rollBackSavePointCommand = this.CreateCommand(); - rollBackSavePointCommand.SetCommandText("ROLLBACK TO SAVEPOINT " + SavePointName); - await executor(rollBackSavePointCommand, CancellationToken.None).ConfigureAwait(false); - } - } - } - } -} diff --git a/DistributedLock.Postgres/PostgresDistributedLock.IDistributedLock.cs b/DistributedLock.Postgres/PostgresDistributedLock.IDistributedLock.cs deleted file mode 100644 index 8709c22a..00000000 --- a/DistributedLock.Postgres/PostgresDistributedLock.IDistributedLock.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Medallion.Threading.Internal; - -namespace Medallion.Threading.Postgres -{ - public partial class PostgresDistributedLock - { - // AUTO-GENERATED - - IDistributedLockHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquire(timeout, cancellationToken); - IDistributedLockHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => - this.Acquire(timeout, cancellationToken); - ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - - /// - /// Attempts to acquire the lock synchronously. Usage: - /// - /// using (var handle = myLock.TryAcquire(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the lock or null on failure - public PostgresDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); - - /// - /// Acquires the lock synchronously, failing with if the attempt times out. Usage: - /// - /// using (myLock.Acquire(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the lock - public PostgresDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.Acquire(this, timeout, cancellationToken); - - /// - /// Attempts to acquire the lock asynchronously. Usage: - /// - /// await using (var handle = await myLock.TryAcquireAsync(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the lock or null on failure - public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - this.As>().InternalTryAcquireAsync(timeout, cancellationToken); - - /// - /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: - /// - /// await using (await myLock.AcquireAsync(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the lock - public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); - } -} \ No newline at end of file diff --git a/DistributedLock.Postgres/PostgresDistributedLock.cs b/DistributedLock.Postgres/PostgresDistributedLock.cs deleted file mode 100644 index fd454cd4..00000000 --- a/DistributedLock.Postgres/PostgresDistributedLock.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using System; -using System.Collections.Generic; -using System.Data; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Postgres -{ - // todo integrate into all appropriate abstract test cases (will want a new provider concept to abstract away pool clearing, credentials, DbProviderFactory, etc) - - /// - /// Implements a distributed lock using Postgres advisory locks - /// (see https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS) - /// - public sealed partial class PostgresDistributedLock : IInternalDistributedLock - { - private readonly IDbDistributedLock _internalLock; - - // todo revisit API - /// - /// Constructs a lock with the given (effectively the lock name), , - /// and - /// - public PostgresDistributedLock(PostgresAdvisoryLockKey key, string connectionString, Action? options = null) - : this(key, CreateInternalLock(key, connectionString, options)) - { - } - - /// - /// Constructs a lock with the given (effectively the lock name) and . - /// - public PostgresDistributedLock(PostgresAdvisoryLockKey key, IDbConnection connection) - : this(key, CreateInternalLock(key, connection)) - { - } - - private PostgresDistributedLock(PostgresAdvisoryLockKey key, IDbDistributedLock internalLock) - { - this.Key = key; - this._internalLock = internalLock; - } - - // todo consider API with name - /// - /// The lock name - /// - public PostgresAdvisoryLockKey Key { get; } - - string IDistributedLock.Name => this.Key.ToString(); - - bool IDistributedLock.IsReentrant => false; - - /// - /// Equivalent to - /// - public static PostgresAdvisoryLockKey GetSafeName(string name) => new PostgresAdvisoryLockKey(name, allowHashing: true); - - ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => - this._internalLock.TryAcquireAsync(timeout, PostgresAdvisoryLock.ExclusiveLock, cancellationToken, contextHandle: null).Wrap(h => new PostgresDistributedLockHandle(h)); - - // todo remove - bool IInternalDistributedLock.WillGoAsync(TimeoutValue timeout, CancellationToken cancellationToken) => false; - - internal static IDbDistributedLock CreateInternalLock(PostgresAdvisoryLockKey key, string connectionString, Action? options) - { - if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } - - var (keepaliveCadence, useMultiplexing) = PostgresConnectionOptionsBuilder.GetOptions(options); - - if (useMultiplexing) - { - return new OptimisticConnectionMultiplexingDbDistributedLock(key.ToString(), connectionString, PostgresMultiplexedConnectionLockPool.Instance, keepaliveCadence); - } - - return new DedicatedConnectionOrTransactionDbDistributedLock(key.ToString(), () => new PostgresDatabaseConnection(connectionString), useTransaction: false, keepaliveCadence); - } - - internal static IDbDistributedLock CreateInternalLock(PostgresAdvisoryLockKey key, IDbConnection connection) - { - if (connection == null) { throw new ArgumentNullException(nameof(connection)); } - return new DedicatedConnectionOrTransactionDbDistributedLock(key.ToString(), () => new PostgresDatabaseConnection(connection)); - } - } -} diff --git a/DistributedLock.Postgres/PostgresDistributedLockHandle.cs b/DistributedLock.Postgres/PostgresDistributedLockHandle.cs deleted file mode 100644 index 86699d97..00000000 --- a/DistributedLock.Postgres/PostgresDistributedLockHandle.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Postgres -{ - // todo this whole file could be auto-generated - /// - /// Implements - /// - public sealed class PostgresDistributedLockHandle : IDistributedLockHandle - { - private IDistributedLockHandle? _innerHandle; - - internal PostgresDistributedLockHandle(IDistributedLockHandle innerHandle) - { - this._innerHandle = innerHandle; - } - - /// - /// Implements - /// - public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); - - /// - /// Releases the lock - /// - public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); - - /// - /// Releases the lock asynchronously - /// - public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; - } -} diff --git a/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.cs b/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.cs deleted file mode 100644 index 5fc86233..00000000 --- a/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using System; -using System.Collections.Generic; -using System.Data; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Postgres -{ - /// - /// Implements a distributed lock using Postgres advisory locks - /// (see https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS) - /// - public sealed partial class PostgresDistributedReaderWriterLock : IInternalDistributedReaderWriterLock - { - private readonly IDbDistributedLock _internalLock; - - // todo revisit API - /// - /// Constructs a lock with the given (effectively the lock name), , - /// and - /// - public PostgresDistributedReaderWriterLock(PostgresAdvisoryLockKey key, string connectionString, Action? options = null) - : this(key, PostgresDistributedLock.CreateInternalLock(key, connectionString, options)) - { - } - - /// - /// Constructs a lock with the given (effectively the lock name) and . - /// - public PostgresDistributedReaderWriterLock(PostgresAdvisoryLockKey key, IDbConnection connection) - : this(key, PostgresDistributedLock.CreateInternalLock(key, connection)) - { - } - - private PostgresDistributedReaderWriterLock(PostgresAdvisoryLockKey key, IDbDistributedLock internalLock) - { - this.Key = key; - this._internalLock = internalLock; - } - - // todo consider API with name - /// - /// Equivalent to - /// - public PostgresAdvisoryLockKey Key { get; } - - string IDistributedReaderWriterLock.Name => this.Key.ToString(); - - /// - /// Equivalent to TODO - /// - public static PostgresAdvisoryLockKey GetSafeName(string name) => PostgresDistributedLock.GetSafeName(name); - - bool IDistributedReaderWriterLock.IsReentrant => throw new NotImplementedException(); - - ValueTask IInternalDistributedReaderWriterLock.InternalTryAcquireAsync( - TimeoutValue timeout, - CancellationToken cancellationToken, - bool isWrite) => - this._internalLock.TryAcquireAsync(timeout, isWrite ? PostgresAdvisoryLock.ExclusiveLock : PostgresAdvisoryLock.SharedLock, cancellationToken, contextHandle: null) - .Wrap(h => new PostgresDistributedReaderWriterLockHandle(h)); - } -} diff --git a/DistributedLock.Postgres/PostgresDistributedReaderWriterLockHandle.cs b/DistributedLock.Postgres/PostgresDistributedReaderWriterLockHandle.cs deleted file mode 100644 index df22f50f..00000000 --- a/DistributedLock.Postgres/PostgresDistributedReaderWriterLockHandle.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Postgres -{ - /// - /// Implements - /// - public sealed class PostgresDistributedReaderWriterLockHandle : IDistributedLockHandle - { - private IDistributedLockHandle? _innerHandle; - - internal PostgresDistributedReaderWriterLockHandle(IDistributedLockHandle innerHandle) - { - this._innerHandle = innerHandle; - } - - /// - /// Implements - /// - public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); - - /// - /// Releases the lock - /// - public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); - - /// - /// Releases the lock asynchronously - /// - public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; - } -} diff --git a/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs b/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs deleted file mode 100644 index d012fc28..00000000 --- a/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Medallion.Threading.Internal.Data; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Postgres -{ - internal static class PostgresMultiplexedConnectionLockPool - { - public static readonly MultiplexedConnectionLockPool Instance = - new MultiplexedConnectionLockPool(s => new PostgresDatabaseConnection(s)); - } -} diff --git a/DistributedLock.SqlServer/SqlApplicationLock.cs b/DistributedLock.SqlServer/SqlApplicationLock.cs deleted file mode 100644 index 9b3180e0..00000000 --- a/DistributedLock.SqlServer/SqlApplicationLock.cs +++ /dev/null @@ -1,222 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using System; -using System.Data; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.SqlServer -{ - /// - /// Implements using sp_getapplock - /// - internal sealed class SqlApplicationLock : IDbSynchronizationStrategy - { - public const int TimeoutExitCode = -1, - AlreadyHeldExitCode = 103, - InvalidUpgradeExitCode = 104; - - public static readonly SqlApplicationLock SharedLock = new SqlApplicationLock(Mode.Shared), - UpdateLock = new SqlApplicationLock(Mode.Update), - ExclusiveLock = new SqlApplicationLock(Mode.Exclusive), - UpgradeLock = new SqlApplicationLock(Mode.Exclusive, isUpgrade: true); - - private static readonly object Cookie = new object(); - private readonly Mode _mode; - private readonly bool _isUpgrade; - - private SqlApplicationLock(Mode mode, bool isUpgrade = false) - { - Invariant.Require(!isUpgrade || mode == Mode.Exclusive); - - this._mode = mode; - this._isUpgrade = isUpgrade; - } - - bool IDbSynchronizationStrategy.IsUpgradeable => this._mode == Mode.Update; - - async ValueTask IDbSynchronizationStrategy.TryAcquireAsync( - DatabaseConnection connection, - string resourceName, - TimeoutValue timeout, - CancellationToken cancellationToken) - { - try - { - return await this.ExecuteAcquireCommandAsync(connection, resourceName, timeout, cancellationToken).ConfigureAwait(false) ? Cookie : null; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // If the command is canceled, I believe there's a slim chance that acquisition just completed before the cancellation went through. - // In that case, I'm pretty sure it won't be rolled back. Therefore, to be safe we issue a try-release - await ExecuteReleaseCommandAsync(connection, resourceName, isTry: true).ConfigureAwait(false); - throw; - } - } - - ValueTask IDbSynchronizationStrategy.ReleaseAsync(DatabaseConnection connection, string resourceName, object lockCookie) => - ExecuteReleaseCommandAsync(connection, resourceName, isTry: false); - - private async Task ExecuteAcquireCommandAsync(DatabaseConnection connection, string lockName, TimeoutValue timeout, CancellationToken cancellationToken) - { - using var command = this.CreateAcquireCommand(connection, lockName, timeout, out var returnValue); - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - return await ParseExitCodeAsync((int)returnValue.Value, timeout, cancellationToken).ConfigureAwait(false); - } - - private static async ValueTask ExecuteReleaseCommandAsync(DatabaseConnection connection, string lockName, bool isTry) - { - using var command = CreateReleaseCommand(connection, lockName, isTry, out var returnValue); - await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - await ParseExitCodeAsync((int)returnValue.Value, TimeSpan.Zero, CancellationToken.None).ConfigureAwait(false); - } - - private DatabaseCommand CreateAcquireCommand( - DatabaseConnection connection, - string lockName, - TimeoutValue timeout, - out IDbDataParameter returnValue) - { - var command = connection.CreateCommand(); - - if (connection.IsExernallyOwned || this._isUpgrade) - { - returnValue = command.AddParameter("Result", type: DbType.Int32, direction: ParameterDirection.Output); - - const string CurrentOwnerMode = "APPLOCK_MODE('public', @Resource, @LockOwner)", - GetAppLock = "EXEC @Result = dbo.sp_getapplock @Resource=@Resource, @LockMode=@LockMode, @LockOwner=@LockOwner, @LockTimeout=@LockTimeout, @DbPrincipal='public'"; - var alternateOwnerHasLockCheck = connection.IsExernallyOwned && connection.HasTransaction - ? " OR APPLOCK_MODE('public', @Resource, 'Session') != 'NoLock'" - : string.Empty; - - if (this._isUpgrade) - { - command.SetCommandText( - $@"DECLARE @Mode NVARCHAR(32) = {CurrentOwnerMode} - IF @Mode = 'NoLock' - SET @Result = {InvalidUpgradeExitCode} - ELSE IF @Mode != '{GetModeString(Mode.Update)}'{alternateOwnerHasLockCheck} - SET @Result = {AlreadyHeldExitCode} - ELSE - {GetAppLock}" - ); - } - else - { - command.SetCommandText( - $@"IF {CurrentOwnerMode} != 'NoLock'{alternateOwnerHasLockCheck} - SET @Result = {AlreadyHeldExitCode} - ELSE - {GetAppLock}" - ); - } - } - else - { - returnValue = command.AddParameter(type: DbType.Int32, direction: ParameterDirection.ReturnValue); - command.SetCommandText("dbo.sp_getapplock"); - command.SetCommandType(CommandType.StoredProcedure); - } - command.SetTimeout(timeout); - - command.AddParameter("Resource", lockName); - command.AddParameter("LockMode", GetModeString(this._mode)); - command.AddParameter("LockOwner", connection.HasTransaction ? "Transaction" : "Session"); - command.AddParameter("LockTimeout", timeout.InMilliseconds); - - return command; - } - - private static DatabaseCommand CreateReleaseCommand(DatabaseConnection connection, string lockName, bool isTry, out IDbDataParameter returnValue) - { - var command = connection.CreateCommand(); - if (isTry) - { - command.SetCommandText( - @"IF APPLOCK_MODE('public', @Resource, @LockOwner) != 'NoLock' - EXEC @Result = dbo.sp_releaseapplock @Resource, @LockOwner - ELSE - SET @Result = 0" - ); - } - else - { - command.SetCommandText("dbo.sp_releaseapplock"); - command.SetCommandType(CommandType.StoredProcedure); - } - - command.AddParameter("Resource", lockName); - command.AddParameter("LockOwner", connection.HasTransaction ? "Transaction" : "Session"); - - if (isTry) - { - returnValue = command.AddParameter("Result", type: DbType.Int32, direction: ParameterDirection.Output); - } - else - { - returnValue = command.AddParameter(type: DbType.Int32, direction: ParameterDirection.ReturnValue); - } - - return command; - } - - public static async ValueTask ParseExitCodeAsync(int exitCode, TimeoutValue timeout, CancellationToken cancellationToken) - { - // sp_getapplock exit codes documented at - // https://msdn.microsoft.com/en-us/library/ms189823.aspx - - switch (exitCode) - { - case 0: - case 1: - return true; - - case TimeoutExitCode: - return false; - - case -2: // canceled - throw new OperationCanceledException(GetErrorMessage(exitCode, "canceled")); - case -3: // deadlock - throw new DeadlockException(GetErrorMessage(exitCode, "deadlock")); - case -999: // parameter / unknown - throw new ArgumentException(GetErrorMessage(exitCode, "parameter validation or other error")); - - case InvalidUpgradeExitCode: // todo add test that hits this case (requires releasing on the connection or acquiring write before upgrade) - throw new InvalidOperationException("Cannot upgrade to an exclusive lock because the update lock is not held"); - case AlreadyHeldExitCode: - return timeout.IsZero ? false - : timeout.IsInfinite ? throw new InvalidOperationException("Attempted to acquire a lock that is already held on the same connection") - : await WaitThenReturnFalseAsync().ConfigureAwait(false); - - default: - if (exitCode <= 0) - throw new InvalidOperationException(GetErrorMessage(exitCode, "unknown")); - return true; // unknown success code - } - - async ValueTask WaitThenReturnFalseAsync() - { - await SyncOverAsync.Delay(timeout, cancellationToken).ConfigureAwait(false); - return false; - } - } - - private static string GetErrorMessage(int exitCode, string type) => $"The request for the distributed lock failed with exit code {exitCode} ({type})"; - - private static string GetModeString(Mode mode) => mode switch - { - Mode.Shared => "Shared", - Mode.Update => "Update", - Mode.Exclusive => "Exclusive", - _ => throw new ArgumentException(nameof(mode)), - }; - - private enum Mode - { - Shared, - Update, - Exclusive, - } - } -} diff --git a/DistributedLock.SqlServer/SqlConnectionOptionsBuilder.cs b/DistributedLock.SqlServer/SqlConnectionOptionsBuilder.cs deleted file mode 100644 index 2962054f..00000000 --- a/DistributedLock.SqlServer/SqlConnectionOptionsBuilder.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Data; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.SqlServer -{ - /// - /// Specifies options for connecting to and locking against a SQL database - /// - public sealed class SqlConnectionOptionsBuilder - { - private TimeoutValue? _keepaliveCadence; - private bool? _useTransaction, _useMultiplexing; - - internal SqlConnectionOptionsBuilder() { } - - /// - /// Using SQL Azure as a distributed synchronization provider can be challenging due to Azure's aggressive connection governor - /// which proactively kills idle connections. - /// - /// To prevent this, this option sets the cadence at which we run a no-op "keepalive" query on a connection that is holding a lock. - /// Note that this still does not guarantee protection for the connection from all conditions where the governor might kill it. - /// - /// To disable keepalive, set to . - /// - /// Defaults to 10 minutes based on Azure's 30 minute default behavior. - /// - /// For more information, see the dicussion on https://github.com/madelson/DistributedLock/issues/5 - /// - public SqlConnectionOptionsBuilder KeepaliveCadence(TimeSpan keepaliveCadence) - { - this._keepaliveCadence = new TimeoutValue(keepaliveCadence, nameof(keepaliveCadence)); - return this; - } - - /// - /// Whether the synchronization should use a transaction scope rather than a session scope. Defaults to false. - /// - /// Synchronizing based on a transaction is marginally less expensive than using a connection - /// because releasing requires only disposing the underlying . - /// The disadvantage is that using this strategy may lead to long-running transactions, which can be - /// problematic for databases using the full recovery model. - /// - public SqlConnectionOptionsBuilder UseTransaction(bool useTransaction = true) - { - this._useTransaction = useTransaction; - return this; - } - - /// - /// This mode takes advantage of the fact that while "holding" a lock (or other synchronization primitive) - /// a connection is essentially idle. Thus, rather than creating a new connection for each held lock it is - /// often possible to multiplex a shared connection so that that connection can hold multiple locks at the same time. - /// - /// Multiplexing is on by default. - /// - /// This is implemented in such a way that releasing a lock held on such a connection will never be blocked by an - /// Acquire() call that is waiting to acquire a lock on that same connection. For this reason, the multiplexing - /// strategy is "optimistic": if the lock can't be acquired instantaneously on the shared connection, a new (shareable) - /// connection will be allocated. - /// - /// This option can improve performance and avoid connection pool starvation in high-load scenarios. It is also - /// particularly applicable to cases where - /// semantics are used with a zero-length timeout. - /// - public SqlConnectionOptionsBuilder UseMultiplexing(bool useMultiplexing = true) - { - this._useMultiplexing = useMultiplexing; - return this; - } - - // todo access token? access token factory? - - internal static (TimeoutValue keepaliveCadence, bool useTransaction, bool useMultiplexing) GetOptions(Action? optionsBuilder) - { - SqlConnectionOptionsBuilder? options; - if (optionsBuilder != null) - { - options = new SqlConnectionOptionsBuilder(); - optionsBuilder(options); - } - else - { - options = null; - } - - var keepaliveCadence = options?._keepaliveCadence ?? TimeSpan.FromMinutes(10); - var useTransaction = options?._useTransaction ?? false; - var useMultiplexing = options?._useMultiplexing ?? true; - - if (useMultiplexing && useTransaction) - { - throw new ArgumentException(nameof(UseTransaction) + ": is not compatible with " + nameof(UseMultiplexing)); - } - - return (keepaliveCadence, useTransaction, useMultiplexing); - } - } -} diff --git a/DistributedLock.SqlServer/SqlDatabaseConnection.cs b/DistributedLock.SqlServer/SqlDatabaseConnection.cs deleted file mode 100644 index 6357d2c3..00000000 --- a/DistributedLock.SqlServer/SqlDatabaseConnection.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using Microsoft.Data.SqlClient; -using System; -using System.Collections.Generic; -using System.Data; -using System.Reflection; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.SqlServer -{ - internal sealed class SqlDatabaseConnection : DatabaseConnection - { - public SqlDatabaseConnection(IDbConnection connection, bool isExternallyOwned = true) - : base(connection, isExternallyOwned: isExternallyOwned) - { - } - - public SqlDatabaseConnection(IDbTransaction transaction) - : base(transaction, isExternallyOwned: true) - { - } - - public SqlDatabaseConnection(string connectionString) - : this(new SqlConnection(connectionString), isExternallyOwned: false) - { - } - - // SQLServer gets no benefit from this - public override bool ShouldPrepareCommands => false; - - public override bool IsCommandCancellationException(Exception exception) - { - const int CanceledNumber = 0; - - // fast path using default SqlClient - if (exception is SqlException sqlException && sqlException.Number == CanceledNumber) - { - return true; - } - - var exceptionType = exception.GetType(); - // since SqlException is sealed (as of 2020-01-26) - if (exceptionType.ToString() == "System.Data.SqlClient.SqlException") - { - var numberProperty = exceptionType - .GetProperty(nameof(SqlException.Number), BindingFlags.Public | BindingFlags.Instance); - Invariant.Require(numberProperty != null); - if (numberProperty != null) - { - return Equals(numberProperty.GetValue(exception), CanceledNumber); - } - } - - // this shows up when you call DbCommand.Cancel() - return exception is InvalidOperationException; - } - - public override Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor) - { - Invariant.Require(sleepTime >= TimeSpan.Zero && sleepTime < TimeSpan.FromDays(1)); - - var command = this.CreateCommand(); - command.SetCommandText(@"WAITFOR DELAY @delay"); - command.AddParameter("delay", sleepTime.ToString(@"hh\:mm\:ss\.fff"), DbType.AnsiStringFixedLength); - command.SetTimeout(sleepTime); - - return executor(command, cancellationToken).AsTask(); - } - } -} diff --git a/DistributedLock.SqlServer/SqlDistributedLock.IDistributedLock.cs b/DistributedLock.SqlServer/SqlDistributedLock.IDistributedLock.cs deleted file mode 100644 index 09eb21dd..00000000 --- a/DistributedLock.SqlServer/SqlDistributedLock.IDistributedLock.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Medallion.Threading.Internal; - -namespace Medallion.Threading.SqlServer -{ - public partial class SqlDistributedLock - { - // AUTO-GENERATED - - IDistributedLockHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquire(timeout, cancellationToken); - IDistributedLockHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => - this.Acquire(timeout, cancellationToken); - ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - - /// - /// Attempts to acquire the lock synchronously. Usage: - /// - /// using (var handle = myLock.TryAcquire(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the lock or null on failure - public SqlDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); - - /// - /// Acquires the lock synchronously, failing with if the attempt times out. Usage: - /// - /// using (myLock.Acquire(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the lock - public SqlDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.Acquire(this, timeout, cancellationToken); - - /// - /// Attempts to acquire the lock asynchronously. Usage: - /// - /// await using (var handle = await myLock.TryAcquireAsync(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the lock or null on failure - public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - this.As>().InternalTryAcquireAsync(timeout, cancellationToken); - - /// - /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: - /// - /// await using (await myLock.AcquireAsync(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the lock - public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); - } -} \ No newline at end of file diff --git a/DistributedLock.SqlServer/SqlDistributedLock.cs b/DistributedLock.SqlServer/SqlDistributedLock.cs deleted file mode 100644 index 434a4be5..00000000 --- a/DistributedLock.SqlServer/SqlDistributedLock.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using System; -using System.Data; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.SqlServer -{ - /// - /// Implements a distributed lock using a SQL server application lock - /// (see https://msdn.microsoft.com/en-us/library/ms189823.aspx) - /// - public sealed partial class SqlDistributedLock : IInternalDistributedLock - { - private readonly IDbDistributedLock _internalLock; - - // todo connection factory API (to allow for access tokens)? - - /// - /// Constructs a new lock using the provided . - /// - /// The provided will be used to connect to the database. - /// - /// Unless is specified, will be used to ensure name validity. - /// - public SqlDistributedLock(string name, string connectionString, Action? options = null, bool exactName = false) - : this(name, exactName, n => CreateInternalLock(n, connectionString, options)) - { - } - - /// - /// Constructs a new lock using the provided . - /// - /// The provided will be used to connect to the database and will provide lock scope. It is assumed to be externally managed and - /// will not be opened or closed. - /// - /// Unless is specified, will be used to ensure name validity. - /// - public SqlDistributedLock(string name, IDbConnection connection, bool exactName = false) - : this(name, exactName, n => CreateInternalLock(n, connection)) - { - } - - /// - /// Constructs a new lock using the provided . - /// - /// The provided will be used to connect to the database and will provide lock scope. It is assumed to be externally managed and - /// will not be committed or rolled back. - /// - /// Unless is specified, will be used to ensure name validity. - /// - public SqlDistributedLock(string name, IDbTransaction transaction, bool exactName = false) - : this(name, exactName, n => CreateInternalLock(n, transaction)) - { - } - - private SqlDistributedLock(string name, bool exactName, Func internalLockFactory) - { - if (exactName) - { - if (name == null) { throw new ArgumentNullException(nameof(name)); } - if (name.Length > MaxNameLength) { throw new FormatException($"{nameof(name)}: must be at most {MaxNameLength} characters"); } - this.Name = name; - } - else - { - this.Name = GetSafeName(name); - } - - this._internalLock = internalLockFactory(this.Name); - } - - /// - /// The maximum allowed length for lock names. See https://msdn.microsoft.com/en-us/library/ms189823.aspx - /// - public static int MaxNameLength => 255; - - // todo should this be the safe name or the user-provided name? Should we even expose this? - /// - /// Implements - /// - public string Name { get; } - - bool IDistributedLock.IsReentrant => throw new NotImplementedException("todo"); - - /// - /// Given , constructs a lock name which is safe for use with - /// - public static string GetSafeName(string name) => - DistributedLockHelpers.ToSafeName(name, MaxNameLength, s => s); - - bool IInternalDistributedLock.WillGoAsync(TimeoutValue timeout, CancellationToken cancellationToken) => false; - - ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => - this._internalLock.TryAcquireAsync(timeout, SqlApplicationLock.ExclusiveLock, cancellationToken, contextHandle: null).Wrap(h => new SqlDistributedLockHandle(h)); - - internal static IDbDistributedLock CreateInternalLock(string name, string connectionString, Action? optionsBuilder) - { - if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } - - var (keepaliveCadence, useTransaction, useMultiplexing) = SqlConnectionOptionsBuilder.GetOptions(optionsBuilder); - - if (useMultiplexing) - { - return new OptimisticConnectionMultiplexingDbDistributedLock(name, connectionString, SqlMultiplexedConnectionLockPool.Instance, keepaliveCadence); - } - - return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new SqlDatabaseConnection(connectionString), useTransaction: useTransaction, keepaliveCadence); - } - - internal static IDbDistributedLock CreateInternalLock(string name, IDbConnection connection) - { - if (connection == null) { throw new ArgumentNullException(nameof(connection)); } - return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new SqlDatabaseConnection(connection)); - } - - internal static IDbDistributedLock CreateInternalLock(string name, IDbTransaction transaction) - { - if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); } - return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new SqlDatabaseConnection(transaction)); - } - } -} diff --git a/DistributedLock.SqlServer/SqlDistributedLockHandle.cs b/DistributedLock.SqlServer/SqlDistributedLockHandle.cs deleted file mode 100644 index 3b0d90dc..00000000 --- a/DistributedLock.SqlServer/SqlDistributedLockHandle.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Medallion.Threading.Internal; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.SqlServer -{ - /// - /// Implements - /// - public sealed class SqlDistributedLockHandle : IDistributedLockHandle - { - private IDistributedLockHandle? _innerHandle; - - internal SqlDistributedLockHandle(IDistributedLockHandle innerHandle) - { - this._innerHandle = innerHandle; - } - - /// - /// Implements - /// - public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); - - /// - /// Releases the lock - /// - public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); - - /// - /// Releases the lock asynchronously - /// - public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; - } -} diff --git a/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.cs b/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.cs deleted file mode 100644 index 3346b259..00000000 --- a/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.cs +++ /dev/null @@ -1,117 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using System; -using System.Data; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.SqlServer -{ - // todo rename assembly to .SqlServer - - /// - /// Implements reader-writer lock semantics using a SQL server application lock - /// (see https://msdn.microsoft.com/en-us/library/ms189823.aspx). - /// - /// This class supports the following patterns: - /// * Multiple readers AND single writer (using and ) - /// * Multiple readers OR single writer (using and ) - /// * Upgradeable read locks similar to (using and ) - /// - public sealed partial class SqlDistributedReaderWriterLock : IInternalDistributedUpgradeableReaderWriterLock - { - private readonly IDbDistributedLock _internalLock; - - #region ---- Constructors ---- - /// - /// Constructs a new lock using the provided . - /// - /// The provided will be used to connect to the database. - /// - /// Unless is specified, will be used to ensure name validity. - /// - public SqlDistributedReaderWriterLock(string name, string connectionString, Action? options = null, bool exactName = false) - : this(name, exactName, n => SqlDistributedLock.CreateInternalLock(n, connectionString, options)) - { - } - - /// - /// Constructs a new lock using the provided . - /// - /// The provided will be used to connect to the database and will provide lock scope. It is assumed to be externally managed and - /// will not be opened or closed. - /// - /// Unless is specified, will be used to ensure name validity. - /// - public SqlDistributedReaderWriterLock(string name, IDbConnection connection, bool exactName = false) - : this(name, exactName, n => SqlDistributedLock.CreateInternalLock(n, connection)) - { - } - - /// - /// Constructs a new lock using the provided . - /// - /// The provided will be used to connect to the database and will provide lock scope. It is assumed to be externally managed and - /// will not be committed or rolled back. - /// - /// Unless is specified, will be used to ensure name validity. - /// - public SqlDistributedReaderWriterLock(string name, IDbTransaction transaction, bool exactName = false) - : this(name, exactName, n => SqlDistributedLock.CreateInternalLock(n, transaction)) - { - } - - private SqlDistributedReaderWriterLock(string name, bool exactName, Func internalLockFactory) - { - if (exactName) - { - if (name == null) { throw new ArgumentNullException(nameof(name)); } - if (name.Length > MaxNameLength) { throw new FormatException($"{nameof(name)}: must be at most {MaxNameLength} characters"); } - this.Name = name; - } - else - { - this.Name = GetSafeName(name); - } - - this._internalLock = internalLockFactory(this.Name); - } - #endregion - - /// - /// Implements - /// - public string Name { get; } - - bool IDistributedReaderWriterLock.IsReentrant => throw new NotImplementedException(); - - /// - /// The maximum allowed length for lock names. See https://msdn.microsoft.com/en-us/library/ms189823.aspx - /// - public static int MaxNameLength => SqlDistributedLock.MaxNameLength; - - /// - /// Given , constructs a lock name which is safe for use with - /// - public static string GetSafeName(string name) => SqlDistributedLock.GetSafeName(name); - - async ValueTask IInternalDistributedUpgradeableReaderWriterLock.InternalTryAcquireUpgradeableReadLockAsync( - TimeoutValue timeout, - CancellationToken cancellationToken) - { - var innerHandle = await this._internalLock - .TryAcquireAsync(timeout, SqlApplicationLock.UpdateLock, cancellationToken, contextHandle: null).ConfigureAwait(false); - return innerHandle != null ? new SqlDistributedReaderWriterLockUpgradeableHandle(innerHandle, this._internalLock) : null; - } - - async ValueTask IInternalDistributedReaderWriterLock.InternalTryAcquireAsync( - TimeoutValue timeout, - CancellationToken cancellationToken, - bool isWrite) - { - var innerHandle = await this._internalLock - .TryAcquireAsync(timeout, isWrite ? SqlApplicationLock.ExclusiveLock : SqlApplicationLock.SharedLock, cancellationToken, contextHandle: null).ConfigureAwait(false); - return innerHandle != null ? new SqlDistributedReaderWriterLockNonUpgradeableHandle(innerHandle) : null; - } - } -} diff --git a/DistributedLock.SqlServer/SqlDistributedReaderWriterLockHandle.cs b/DistributedLock.SqlServer/SqlDistributedReaderWriterLockHandle.cs deleted file mode 100644 index 4bcfd136..00000000 --- a/DistributedLock.SqlServer/SqlDistributedReaderWriterLockHandle.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.SqlServer -{ - /// - /// Implements - /// - public abstract class SqlDistributedReaderWriterLockHandle : IDistributedLockHandle - { - // forbid external inheritors - internal SqlDistributedReaderWriterLockHandle() { } - - /// - /// Implements - /// - public abstract CancellationToken HandleLostToken { get; } - - // todo should we have a common DisposeSyncOverAsync() extension for this? - /// - /// Releases the lock - /// - public void Dispose() => SyncOverAsync.Run(@this => @this.DisposeAsync(), this, willGoAsync: false); - - /// - /// Releases the lock asynchronously - /// - public abstract ValueTask DisposeAsync(); - } - - internal sealed class SqlDistributedReaderWriterLockNonUpgradeableHandle : SqlDistributedReaderWriterLockHandle - { - private IDistributedLockHandle? _innerHandle; - - internal SqlDistributedReaderWriterLockNonUpgradeableHandle(IDistributedLockHandle? handle) - { - this._innerHandle = handle; - } - - public override CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); - - public override ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; - } - - /// - /// Implements - /// - public sealed class SqlDistributedReaderWriterLockUpgradeableHandle : SqlDistributedReaderWriterLockHandle, IInternalDistributedLockUpgradeableHandle - { - private RefBox<(IDistributedLockHandle innerHandle, IDbDistributedLock @lock, IDistributedLockHandle? upgradedHandle)>? _box; - - internal SqlDistributedReaderWriterLockUpgradeableHandle(IDistributedLockHandle innerHandle, IDbDistributedLock @lock) - { - this._box = RefBox.Create((innerHandle, @lock, default(IDistributedLockHandle?))); - } - - /// - /// Implements - /// - public override CancellationToken HandleLostToken => (this._box ?? throw this.ObjectDisposed()).Value.innerHandle.HandleLostToken; - - /// - /// Releases the lock asynchronously - /// - public override async ValueTask DisposeAsync() - { - if (RefBox.TryConsume(ref this._box, out var contents)) - { - try { await (contents.upgradedHandle?.DisposeAsync() ?? default).ConfigureAwait(false); } - finally { await contents.innerHandle.DisposeAsync().ConfigureAwait(false); } - } - } - - /// - /// Implements - /// - public bool TryUpgradeToWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - DistributedLockHelpers.TryUpgradeToWriteLock(this, timeout, cancellationToken); - - /// - /// Implements - /// - public ValueTask TryUpgradeToWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - this.As().InternalTryUpgradeToWriteLockAsync(timeout, cancellationToken); - - /// - /// Implements - /// - public void UpgradeToWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.UpgradeToWriteLock(this, timeout, cancellationToken); - - /// - /// Implements - /// - public ValueTask UpgradeToWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.UpgradeToWriteLockAsync(this, timeout, cancellationToken); - - ValueTask IInternalDistributedLockUpgradeableHandle.InternalTryUpgradeToWriteLockAsync(TimeoutValue timeout, CancellationToken cancellationToken) - { - var box = this._box ?? throw this.ObjectDisposed(); - var contents = box.Value; - // todo ensure test for this - if (contents.upgradedHandle != null) { throw new InvalidOperationException("the lock has already been upgraded"); } - return TryPerformUpgradeAsync(); - - async ValueTask TryPerformUpgradeAsync() - { - var upgradedHandle = - await contents.@lock.TryAcquireAsync(timeout, SqlApplicationLock.UpgradeLock, cancellationToken, contextHandle: contents.innerHandle).ConfigureAwait(false); - if (upgradedHandle == null) - { - return false; - } - - contents.upgradedHandle = upgradedHandle; - var newBox = RefBox.Create(contents); - if (Interlocked.CompareExchange(ref this._box, newBox, comparand: box) != box) - { - await upgradedHandle.DisposeAsync().ConfigureAwait(false); - } - - return true; - } - } - } -} diff --git a/DistributedLock.SqlServer/SqlDistributedSemaphore.cs b/DistributedLock.SqlServer/SqlDistributedSemaphore.cs deleted file mode 100644 index 4bcb3a46..00000000 --- a/DistributedLock.SqlServer/SqlDistributedSemaphore.cs +++ /dev/null @@ -1,135 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using System; -using System.Data; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.SqlServer -{ - /// - /// Implements a distributed semaphore using SQL Server constructs. - /// - public class SqlDistributedSemaphore - { - private readonly IDbDistributedLock _internalLock; - private readonly SqlSemaphore _strategy; - - #region ---- Constructors ---- - /// - /// Creates a semaphore with name that can be acquired up to - /// times concurrently. The provided will be used to connect to the database. - /// - public SqlDistributedSemaphore(string name, int maxCount, string connectionString, Action? options = null) - : this(name, maxCount, n => SqlDistributedLock.CreateInternalLock(n, connectionString, options)) - { - } - - /// - /// Creates a semaphore with name that can be acquired up to - /// times concurrently. When acquired, the semaphore will be scoped to the given . - /// The is assumed to be externally managed: the will - /// not attempt to open, close, or dispose it - /// - public SqlDistributedSemaphore(string name, int maxCount, IDbConnection connection) - : this(name, maxCount, n => SqlDistributedLock.CreateInternalLock(n, connection)) - { - } - - /// - /// Creates a semaphore with name that can be acquired up to - /// times concurrently. When acquired, the semaphore will be scoped to the given . - /// The and its are assumed to be externally managed: - /// the will not attempt to open, close, commit, roll back, or dispose them - /// - public SqlDistributedSemaphore(string name, int maxCount, IDbTransaction transaction) - : this(name, maxCount, n => SqlDistributedLock.CreateInternalLock(n, transaction)) - { - } - - private SqlDistributedSemaphore(string name, int maxCount, Func createInternalLockFromName) - { - if (maxCount < 1) { throw new ArgumentOutOfRangeException(nameof(maxCount), maxCount, "must be positive"); } - - this.Name = name ?? throw new ArgumentNullException(nameof(name)); - this._strategy = new SqlSemaphore(maxCount); - this._internalLock = createInternalLockFromName(SqlSemaphore.ToSafeName(name)); - } - #endregion - - /// - /// Implements - /// - public string Name { get; } - - /// - /// Attempts to acquire a semaphore ticket synchronously. Usage: - /// - /// using (var handle = mySemaphore.TryAcquire(...)) - /// { - /// if (handle != null) { /* we have the ticket! */ } - /// } - /// // dispose releases the ticket if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the ticket or null on failure - public SqlDistributedSemaphoreHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - SyncOverAsync.Run(t => t.@this.TryAcquireAsync(t.timeout, t.cancellationToken), (@this: this, timeout, cancellationToken), false); - - /// - /// Acquires a semaphore ticket synchronously, failing with if the attempt times out. Usage: - /// - /// using (mySemaphore.Acquire(...)) - /// { - /// /* we have the ticket! */ - /// } - /// // dispose releases the ticket - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the ticket - public SqlDistributedSemaphoreHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - SyncOverAsync.Run(t => t.@this.AcquireAsync(t.timeout, t.cancellationToken), (@this: this, timeout, cancellationToken), false); - - /// - /// Attempts to acquire a semaphore ticket asynchronously. Usage: - /// - /// await using (var handle = await mySemaphore.TryAcquireAsync(...)) - /// { - /// if (handle != null) { /* we have the ticket! */ } - /// } - /// // dispose releases the ticket if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the ticket or null on failure - public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - this.TryAcquireInternalAsync(timeout, cancellationToken); - - /// - /// Acquires a semaphore ticket asynchronously, failing with if the attempt times out. Usage: - /// - /// await using (await mySemaphore.AcquireAsync(...)) - /// { - /// /* we have the ticket! */ - /// } - /// // dispose releases the ticket - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// A which can be used to release the ticket - public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - this.TryAcquireInternalAsync(timeout, cancellationToken).ThrowTimeoutIfNull(); - - private async ValueTask TryAcquireInternalAsync(TimeoutValue timeout, CancellationToken cancellationToken) - { - var handle = await this._internalLock.TryAcquireAsync(timeout, this._strategy, cancellationToken, contextHandle: null).ConfigureAwait(false); - return handle != null ? new SqlDistributedSemaphoreHandle(handle) : null; - } - } -} diff --git a/DistributedLock.SqlServer/SqlDistributedSemaphoreHandle.cs b/DistributedLock.SqlServer/SqlDistributedSemaphoreHandle.cs deleted file mode 100644 index bedefbd8..00000000 --- a/DistributedLock.SqlServer/SqlDistributedSemaphoreHandle.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Medallion.Threading.Internal; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.SqlServer -{ - /// - /// Implements - /// - public sealed class SqlDistributedSemaphoreHandle : IDistributedLockHandle - { - private IDistributedLockHandle? _innerHandle; - - internal SqlDistributedSemaphoreHandle(IDistributedLockHandle innerHandle) - { - this._innerHandle = innerHandle; - } - - /// - /// Implements - /// - public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); - - /// - /// Releases the semaphore - /// - public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); - - /// - /// Releases the semaphore asynchronously - /// - public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; - } -} diff --git a/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs b/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs deleted file mode 100644 index 75e59f9a..00000000 --- a/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Medallion.Threading.Internal.Data; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.SqlServer -{ - internal static class SqlMultiplexedConnectionLockPool - { - public static readonly MultiplexedConnectionLockPool Instance = - new MultiplexedConnectionLockPool(s => new SqlDatabaseConnection(s)); - } -} diff --git a/DistributedLock.SqlServer/SqlSemaphore.cs b/DistributedLock.SqlServer/SqlSemaphore.cs deleted file mode 100644 index a28042f7..00000000 --- a/DistributedLock.SqlServer/SqlSemaphore.cs +++ /dev/null @@ -1,535 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Internal.Data; -using System; -using System.Data; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.SqlServer -{ - internal sealed class SqlSemaphore : IDbSynchronizationStrategy - { - private readonly int _maxCount; - - public SqlSemaphore(int maxCount) - { - this._maxCount = maxCount; - } - - #region ---- Execution ---- - public async ValueTask TryAcquireAsync(DatabaseConnection connection, string resourceName, TimeoutValue timeout, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - string? markerTableName; - - // when we aren't supporting cancellation, we can use a simplified one-step algorithm. We treat a timeout of - // zero in the same way: since there is no blocking, we don't need to bother with explicit cancellation support - if (!cancellationToken.CanBeCanceled || timeout.IsZero) - { - using var command = CreateTextCommand(connection, operationTimeout: timeout); - command.SetCommandText(AcquireNonCancelableQuery.Value); - this.AddCommonParameters(command, resourceName, timeout: timeout); - await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - return await ProcessAcquireResultAsync(command.Parameters, timeout, cancellationToken, out markerTableName, out var ticketLockName).ConfigureAwait(false) - ? new Cookie(ticket: ticketLockName!, markerTable: markerTableName!) - : null; - } - - // cancelable case - - using (var command = CreateTextCommand(connection, operationTimeout: timeout)) - { - command.SetCommandText(AcquireCancelablePreambleQuery.Value); - this.AddCommonParameters(command, resourceName); - // preamble is non-cancelable - await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - if (await ProcessAcquireResultAsync(command.Parameters, timeout, cancellationToken, out markerTableName, out var ticketLockName).ConfigureAwait(false)) - { - return new Cookie(ticket: ticketLockName!, markerTable: markerTableName!); - } - } - - using (var command = CreateTextCommand(connection, operationTimeout: timeout)) - { - command.SetCommandText(AcquireCancelableQuery.Value); - this.AddCommonParameters(command, resourceName, timeout: timeout, markerTableName: markerTableName); - try - { - // see comments around disallowAsyncCancellation for why we pass this flag - await command.ExecuteNonQueryAsync(cancellationToken, disallowAsyncCancellation: true).ConfigureAwait(false); - } - catch when (cancellationToken.IsCancellationRequested) - { - // if we canceled the query, we need to perform cleanup to make sure we don't leave marker tables or held locks - - using (var cleanupCommand = CreateTextCommand(connection, operationTimeout: TimeSpan.Zero)) - { - cleanupCommand.SetCommandText(CancellationCleanupQuery.Value); - this.AddCommonParameters(cleanupCommand, resourceName, markerTableName: markerTableName); - await cleanupCommand.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - } - - throw; - } - - return await ProcessAcquireResultAsync(command.Parameters, timeout, cancellationToken, out markerTableName, out var ticketLockName).ConfigureAwait(false) - ? new Cookie(ticket: ticketLockName!, markerTable: markerTableName!) - : null; - } - } - - public async ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, Cookie lockCookie) - { - using var command = CreateTextCommand(connection, operationTimeout: Timeout.InfiniteTimeSpan); - command.SetCommandText(ReleaseQuery.Value); - this.AddCommonParameters(command, resourceName, markerTableName: lockCookie.MarkerTable, ticketLockName: lockCookie.Ticket); - await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); - } - - bool IDbSynchronizationStrategy.IsUpgradeable => false; - - public sealed class Cookie - { - public Cookie(string ticket, string markerTable) - { - this.Ticket = ticket ?? throw new ArgumentNullException(nameof(ticket)); - this.MarkerTable = markerTable ?? throw new ArgumentNullException(nameof(markerTable)); - } - - public string Ticket { get; } - public string MarkerTable { get; } - } - #endregion - - #region ---- Command Execution ---- - private static ValueTask ProcessAcquireResultAsync( - IDataParameterCollection parameters, - TimeoutValue timeout, - CancellationToken cancellationToken, - out string? markerTableName, - out string? ticketLockName) - { - var resultCode = (int)((IDbDataParameter)parameters[ResultCodeParameter]).Value; - switch (resultCode) - { - case SuccessCode: - ticketLockName = (string)((IDbDataParameter)parameters[TicketLockNameParameter]).Value; - markerTableName = (string)((IDbDataParameter)parameters[MarkerTableNameParameter]).Value; - return true.AsValueTask(); - case FinishedPreambleWithoutAcquiringCode: - ticketLockName = null; - markerTableName = (string)((IDbDataParameter)parameters[MarkerTableNameParameter]).Value; - return false.AsValueTask(); - case FailedToAcquireWithSpaceRemainingCode: - throw new InvalidOperationException($"An internal semaphore algorithm error ({resultCode}) occurred: failed to acquire a ticket despite indication that tickets are available"); - case BusyWaitTimeoutCode: - ticketLockName = markerTableName = null; - return false.AsValueTask(); - case AllTicketsHeldByCurrentSessionCode: - // whenever we hit this case, it's a deadlock. If the user asked us to wait forever, we just throw. However, - // if the user asked us to wait a specified amount of time we will wait in C#. There are other justifiable policies - // but this one seems relatively safe and likely to do what you want. It seems reasonable that no one intends to hang - // forever but also reasonable that someone should be able to test for lock acquisition without getting a throw - if (timeout.IsInfinite) - { - throw new DeadlockException("Deadlock detected: attempt to acquire the semaphore cannot succeed because all tickets are held by the current connection"); - } - - ticketLockName = markerTableName = null; - - async ValueTask DelayFalseAsync() - { - await SyncOverAsync.Delay(timeout, cancellationToken).ConfigureAwait(false); - return false; - } - return DelayFalseAsync(); - case SqlApplicationLock.TimeoutExitCode: - ticketLockName = markerTableName = null; - return false.AsValueTask(); - default: - ticketLockName = markerTableName = null; - return FailAsync(); - - async ValueTask FailAsync() - { - if (resultCode < 0) - { - await SqlApplicationLock.ParseExitCodeAsync(resultCode, timeout, cancellationToken).ConfigureAwait(false); - } - throw new InvalidOperationException($"Unexpected semaphore algorithm result code {resultCode}"); - } - } - } - - private static DatabaseCommand CreateTextCommand(DatabaseConnection connection, TimeoutValue operationTimeout) - { - var command = connection.CreateCommand(); - command.SetTimeout(operationTimeout); - return command; - } - - private void AddCommonParameters(DatabaseCommand command, string semaphoreName, TimeoutValue? timeout = null, string? markerTableName = null, string? ticketLockName = null) - { - command.AddParameter(SemaphoreNameParameter, semaphoreName); - command.AddParameter(MaxCountParameter, this._maxCount); - if (timeout.HasValue) - { - command.AddParameter(TimeoutMillisParameter, timeout.Value.InMilliseconds); - } - - command.AddParameter(ResultCodeParameter, type: DbType.Int32, direction: ParameterDirection.Output); - - var ticket = command.AddParameter(TicketLockNameParameter, ticketLockName, type: DbType.String); - if (ticketLockName == null) - { - ticket.Direction = ParameterDirection.Output; - } - const int MaxOutputStringLength = 8000; // plenty long enough - ticket.Size = MaxOutputStringLength; - - var markerTable = command.AddParameter(MarkerTableNameParameter, markerTableName, type: DbType.String); - if (markerTableName == null) - { - markerTable.Direction = ParameterDirection.Output; - } - markerTable.Size = MaxOutputStringLength; - } - #endregion - - #region ---- Naming ---- - public static string ToSafeName(string semaphoreName) - { - // the max table name length is 128 for global and 116 for local temp tables. While we don't use local temp tables - // currently, to be conservative we use the lower cap. We're using 115 not 116 to reflect the missing '#' which counts - // towards the limit - const int MaxTableNameLength = 115; - const string Suffix = "semaphore"; - // this accounts for various other things we pad onto the name: - // * Marker table adds SPID + "s" + WAITERNUMBER (10 + 1 + 10 = 21) - // * Ticket lock name adds TICKETNUMBER (10) - // * Intent table name adds "intent_" + SPID + "_" + TICKETNUMBER (7 + 10 + 1 + 10 = 28) - // We will use 30 as a safe number - const int AdditionalSuffixMaxLength = 30; - - var nameWithoutInvalidCharacters = ReplaceInvalidCharacters(semaphoreName); - // note that we hash the original name, not the replaced name. This makes us even more robust to collisions - var nameHash = HashName(semaphoreName); - var maxBaseNameLength = MaxTableNameLength - (nameHash.Length + Suffix.Length + AdditionalSuffixMaxLength); - var baseName = nameWithoutInvalidCharacters.Length <= maxBaseNameLength - ? nameWithoutInvalidCharacters - : nameWithoutInvalidCharacters.Substring(0, maxBaseNameLength); - return $"{baseName}{nameHash}{Suffix}"; - } - - private static string ReplaceInvalidCharacters(string semaphoreName) - { - StringBuilder? modifiedName = null; - for (var i = 0; i < semaphoreName.Length; ++i) - { - var @char = semaphoreName[i]; - if (!IsAsciiLetterOrDigit(@char)) - { - if (modifiedName == null) - { - modifiedName = new StringBuilder(); - for (var j = 0; j < i; ++j) { modifiedName.Append(semaphoreName[j]); } - } - - modifiedName.Append(((int)@char).ToString("x")); - } - else if (modifiedName != null) - { - modifiedName.Append(@char); - } - } - - return modifiedName?.ToString() ?? semaphoreName; - } - - private static bool IsAsciiLetterOrDigit(char @char) => ('a' <= @char && @char <= 'z') - || ('A' <= @char && @char <= 'Z') - || ('0' <= @char && @char <= '9'); - - private static string HashName(string name) - { - using var hashAlgorithm = SHA256.Create(); - var hashBytes = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(name)); - return BitConverter.ToString(hashBytes) - .Replace("-", string.Empty) - .ToLowerInvariant(); - } - #endregion - - #region ---- Query Generation ---- - private const string SemaphoreNameParameter = "semaphoreName", - MaxCountParameter = "maxCount", - ResultCodeParameter = "resultCode", - TimeoutMillisParameter = "timeoutMillis", - MarkerTableNameParameter = "markerTableName", - TicketLockNameParameter = "ticketLockName", - LockResultVariable = "lockResult", - LockScopeVariable = "lockScope", - PreambleLockNameVariable = "preambleLock", - BusyWaitLockNameVariable = "busyWaitLock"; - - private const int SuccessCode = 0, - FinishedPreambleWithoutAcquiringCode = 100, - FailedToAcquireWithSpaceRemainingCode = 101, - BusyWaitTimeoutCode = 102, - AllTicketsHeldByCurrentSessionCode = SqlApplicationLock.AlreadyHeldExitCode; - - // when we don't have to deal with cancellation, we can put everything in one big query to save on round trips - private static readonly Lazy AcquireNonCancelableQuery = new Lazy(() => Merge( - CreateCommonVariableDeclarationsSql(includePreambleLock: true, includeBusyWaitLock: true, includeTryAcquireOnceVariables: true), - CreateAcquirePreambleSql(willRetryInSeparateQueryAfterPreamble: null), - CreateAcquireSql(cancelable: false), - CreateCodaSql(includePreambleLockRelease: true, includeBusyWaitLockRelease: true) - )), - // for cancellation, we run the preamble first as non-cancellable followed by a cancelable busy wait. This - // ensures that we avoid the case where we create a marker table in the preamble and then cancel before returning it - AcquireCancelablePreambleQuery = new Lazy(() => Merge( - CreateCommonVariableDeclarationsSql(includePreambleLock: true, includeBusyWaitLock: false, includeTryAcquireOnceVariables: true), - CreateAcquirePreambleSql(willRetryInSeparateQueryAfterPreamble: true), - CreateCodaSql(includePreambleLockRelease: true, includeBusyWaitLockRelease: false) - )), - AcquireCancelableQuery = new Lazy(() => Merge( - CreateCommonVariableDeclarationsSql(includePreambleLock: false, includeBusyWaitLock: true, includeTryAcquireOnceVariables: true), - CreateAcquireSql(cancelable: true), - CreateCodaSql(includePreambleLockRelease: false, includeBusyWaitLockRelease: true) - )), - CancellationCleanupQuery = new Lazy(() => Merge( - CreateCommonVariableDeclarationsSql(includePreambleLock: false, includeBusyWaitLock: true, includeTryAcquireOnceVariables: false), - CreateCancellationCleanupSql(), - CreateCodaSql(includePreambleLockRelease: false, includeBusyWaitLockRelease: true) - )), - ReleaseQuery = new Lazy(() => Merge( - CreateCommonVariableDeclarationsSql(includePreambleLock: false, includeBusyWaitLock: false, includeTryAcquireOnceVariables: false), - CreateReleaseSql() - )); - - private const string IntentMarkerTablePrefix = "intent"; - - /// - /// Used for making comments in format strings - /// - private static readonly object? C = null; - - /// - /// The preamble is the first part of the acquire algorithm. It is not cancellation-safe - /// - private static string CreateAcquirePreambleSql(bool? willRetryInSeparateQueryAfterPreamble) - { - const string SpidCountSeparator = "s"; - // if everything is going smoothly then the preamble lock should never even come close to timing out since - // nothing blocking happens inside the preamble. However, to be safe we do eventually give up - var preambleLockTimeoutMillis = (int)TimeSpan.FromMinutes(1).TotalMilliseconds; - - return $@" - {C/* The preamble body executes inside a special lock. Since the preamble is designed to be - non-blocking we can wait for a long time on this lock without worrying about respecting - our timeout. We avoid waiting forever in case there are unexpected problems (e. g. a lock on sys.tables) */} - EXEC @{LockResultVariable} = sys.sp_getapplock @{PreambleLockNameVariable}, 'Exclusive', @{LockScopeVariable}, {preambleLockTimeoutMillis} - IF @{LockResultVariable} < 0 GOTO CODA - - {C/* First, we determine the number of existing waiters/holders so we know whether we will have to block or not. - At the same time, we determine a value for our marker table which has not been chosen yet. This value is 1 greater - than the greatest value that exists so far, so it can be > the count. The expression for determining this value is - somewhat complex. We are looking at table names like ##[sem name][spid][separator][value] and parsing out value. */} - DECLARE @waiterNumber INT, @waiterCount INT - SELECT TOP 1 @waiterNumber = ISNULL(MAX(CAST(SUBSTRING(name, CHARINDEX('{SpidCountSeparator}', name, LEN(@{SemaphoreNameParameter})) + 1, LEN(name)) AS INT) + 1), 0), - @waiterCount = COUNT(*) - {C/* The NOLOCK here is important: otherwise we'll be blocked by trying to read entries for marker tables created in transactions that aren't committed */} - FROM tempdb.sys.tables WITH(NOLOCK) - {C/* Prefix search here is important since it uses an index. We don't need escaping because we bound the name to use a fixed character set */} - WHERE name LIKE '##' + @{SemaphoreNameParameter} + '%' - - {C/* Create the marker table. This table is exists to give others a count of the number of waiting/holding processes. - we name our marker table using the form ##[sem name][spid][separator][value]. We use SPID over a random value since SPID values are typically small integers - that recycle over time; this means that we may be able to take advantage of SQL temp table caching. The reason we need SPID here at all is because if another - transaction creates and destroys a table of name X, we will be blocked if we try to create table X before the transaction ends. */} - SET @{MarkerTableNameParameter} = '##' + @{SemaphoreNameParameter} + CAST(@@SPID AS NVARCHAR(MAX)) + '{SpidCountSeparator}' + CAST(@waiterNumber AS NVARCHAR(MAX)) - DECLARE @createMarkerTableSql NVARCHAR(MAX) = 'CREATE TABLE ' + @{MarkerTableNameParameter} + ' (_ BIT)' - EXEC sp_executeSql @createMarkerTableSql - - {C/* If the number of waiters indicates that a space is free, we should be able to immediately acquire without blocking. */} - IF @waiterCount < @{MaxCountParameter} - BEGIN - {C/* may GOTO CODA; the CODA will release preamble lock */} - {CreateTryAcquireOnceSql(allowOneWait: false, cancelable: false)} - - SET @{ResultCodeParameter} = {FailedToAcquireWithSpaceRemainingCode} - GOTO CODA {C/* the CODA will release preamble lock */} - END - - {C/* If we get here, it means we finished the preamble without acquiring a ticket */} - {( - // if this is the end of the query, we have to set an exit code. If we are going to retry we indicate the special code that will trigger that and otherwise we indicate - // timeout. If this is not the end of the query, we just release the preamble lock and keep going - willRetryInSeparateQueryAfterPreamble.HasValue - ? $@"SET @{ResultCodeParameter} = {(willRetryInSeparateQueryAfterPreamble.Value ? FinishedPreambleWithoutAcquiringCode : SqlApplicationLock.TimeoutExitCode)}" - : $"EXEC sys.sp_releaseapplock @{PreambleLockNameVariable}, @{LockScopeVariable}" - )}"; - } - - private static string CreateAcquireSql(bool cancelable) - { - return $@" - {C/* The next step is to do a busy wait on all ticket locks. For fairness and to reduce resource usage, - use a "busy wait lock" to permit only one thread to busy wait at a time. */} - EXEC @{LockResultVariable} = sys.sp_getapplock @{BusyWaitLockNameVariable}, 'Exclusive', @{LockScopeVariable}, @{TimeoutMillisParameter} - IF @{LockResultVariable} < 0 GOTO CODA - - DECLARE @expiry DATETIME2 = CASE WHEN @{TimeoutMillisParameter} < 0 THEN NULL ELSE DATEADD(ms, @{TimeoutMillisParameter}, SYSUTCDATETIME()) END - WHILE 1 = 1 - BEGIN - {C/* may GOTO CODA; the CODA will release busy wait lock */} - {CreateTryAcquireOnceSql(allowOneWait: true, cancelable: cancelable)} - - IF SYSUTCDATETIME() > @expiry - BEGIN - SET @{ResultCodeParameter} = {BusyWaitTimeoutCode} - GOTO CODA - END - END"; - } - - private static string CreateCancellationCleanupSql() - { - // we check for the existence of an intent marker table since this indicates that we may have - // acquired a lock before being canceled and not have released it - - return $@" - DECLARE @intentMarkerTableName NVARCHAR(MAX) - SELECT TOP 1 @intentMarkerTableName = name - FROM tempdb.sys.tables WITH(NOLOCK) - WHERE name LIKE '##{IntentMarkerTablePrefix}\_' + CAST(@@SPID AS NVARCHAR(MAX)) + '\_%' ESCAPE '\' - - IF @intentMarkerTableName IS NOT NULL - BEGIN - SET @{TicketLockNameParameter} = RIGHT(@intentMarkerTableName, LEN(@intentMarkerTableName) - LEN('##{IntentMarkerTablePrefix}_' + CAST(@@SPID AS NVARCHAR(MAX)) + '_')) - IF APPLOCK_MODE('public', @{TicketLockNameParameter}, @{LockScopeVariable}) != 'NoLock' - EXEC sys.sp_releaseapplock @{TicketLockNameParameter}, @{LockScopeVariable} - - DECLARE @dropIntentMarkerTableSql NVARCHAR(MAX) = 'DROP TABLE ' + @intentMarkerTableName - EXEC sp_executeSql @dropIntentMarkerTableSql - END - "; - } - - private static string CreateReleaseSql() - { - return $@" - EXEC sys.sp_releaseAppLock @{TicketLockNameParameter}, @{LockScopeVariable} - DECLARE @dropMarkerTableSql NVARCHAR(MAX) = 'DROP TABLE ' + @{MarkerTableNameParameter} - EXEC sp_executeSql @dropMarkerTableSql"; - } - - private static string CreateCommonVariableDeclarationsSql( - bool includePreambleLock, - bool includeBusyWaitLock, - bool includeTryAcquireOnceVariables) - { - return $@" - DECLARE @{LockResultVariable} INT - , @{LockScopeVariable} NVARCHAR(32) = CASE @@TRANCOUNT WHEN 0 THEN 'Session' ELSE 'Transaction' END - {(includePreambleLock ? $", @{PreambleLockNameVariable} NVARCHAR(MAX) = 'preamble_' + @semaphoreName" : null)} - {(includeBusyWaitLock ? $", @{BusyWaitLockNameVariable} NVARCHAR(MAX) = 'busyWait_' + @semaphoreName" : null)} - {(includeTryAcquireOnceVariables ? @", @i INT, @baseTicketIndex INT, @anyNotHeld BIT" : null)}"; - } - - private static string CreateTryAcquireOnceSql(bool allowOneWait, bool cancelable) - { - return $@" - SET @i = 0 - {C/* Rather than always looping through tickets 0 .. N-1, we start at a random ticket. This should reduce looping in the average case and means - that if we are allowing a wait then the one ticket we wait on is randomized. */} - SET @baseTicketIndex = CAST(RAND() * @{MaxCountParameter} AS INT) - SET @anyNotHeld = 0 - WHILE @i < @{MaxCountParameter} - BEGIN - SET @{TicketLockNameParameter} = @{SemaphoreNameParameter} + CAST((@baseTicketIndex + @i) % @{MaxCountParameter} AS NVARCHAR(MAX)) - - {C/* Since app locks are reentrant on the same connection, we must do an explicit check to avoid taking the same ticket twice. - Additionally, if we are transaction-scoped we must check whether we hold the lock on EITHER the transaction or the session. */} - IF APPLOCK_MODE('public', @{TicketLockNameParameter}, @{LockScopeVariable}) = 'NoLock' - AND (@{LockScopeVariable} = 'Session' OR APPLOCK_MODE('public', @{TicketLockNameParameter}, 'Session') = 'NoLock') - BEGIN - {C/* "allowOneWait" will be specified when we are in a busy wait loop. To avoid burning CPU we pick the first unheld ticket we come - across and allow that wait to be 1ms instead of 0. This is preferable to doing WAITFOR since the wait will be broken if that ticket - becomes available. */} - {(allowOneWait ? "DECLARE @lockTimeoutMillis INT = CASE @anyNotHeld WHEN 0 THEN 1 ELSE 0 END" : null)} - SET @anyNotHeld = 1 - - {( - cancelable - // The intent marker supports robust cancellation by ensuring that we never leave a lingering lock on a connection. By creating a marker before - // any lock acquisition, we have a way of determining the case where the query is canceled right after acquiring a lock - ? $@"DECLARE @intentMarkerTableName NVARCHAR(MAX) = '##{IntentMarkerTablePrefix}_' + CAST(@@SPID AS NVARCHAR(MAX)) + '_' + @{TicketLockNameParameter} - DECLARE @createIntentMarkerTableSql NVARCHAR(MAX) = 'CREATE TABLE ' + @intentMarkerTableName + ' (_ BIT)' - EXEC sp_executeSql @createIntentMarkerTableSql" - : null - )} - - EXEC @{LockResultVariable} = sys.sp_getapplock @{TicketLockNameParameter}, 'Exclusive', @{LockScopeVariable}, {(allowOneWait ? "@lockTimeoutMillis" : "0")} - IF @{LockResultVariable} >= 0 - BEGIN - SET @{ResultCodeParameter} = {SuccessCode} - GOTO CODA - END - - {( - cancelable - // on any failed acquisition, drop the intent marker - ? $@"DECLARE @dropIntentMarkerTableSql NVARCHAR(MAX) = 'DROP TABLE ' + @intentMarkerTableName - EXEC sp_executeSql @dropIntentMarkerTableSql" - : null - )} - - {C/* on any unexpected lock failure, quit */} - IF @{LockResultVariable} < -1 GOTO CODA - END - SET @i = @i + 1 - END - {C/* detect this as a special case since it means we'll never succeed. We can handle in C# */} - IF @anyNotHeld = 0 - BEGIN - SET @{ResultCodeParameter} = {AllTicketsHeldByCurrentSessionCode} - GOTO CODA - END - "; - } - - private static string CreateCodaSql(bool includePreambleLockRelease, bool includeBusyWaitLockRelease) - { - return $@" - CODA: - {( - includePreambleLockRelease - ? $@"IF APPLOCK_MODE('public', @{PreambleLockNameVariable}, @{LockScopeVariable}) != 'NoLock' - EXEC sys.sp_releaseapplock @{PreambleLockNameVariable}, @{LockScopeVariable}" - : null - )} - {( - includeBusyWaitLockRelease - ? $@"IF APPLOCK_MODE('public', @{BusyWaitLockNameVariable}, @{LockScopeVariable}) != 'NoLock' - EXEC sys.sp_releaseapplock @{BusyWaitLockNameVariable}, @{LockScopeVariable}" - : null - )} - IF @{ResultCodeParameter} IS NULL AND @{LockResultVariable} < 0 - SET @{ResultCodeParameter} = @{LockResultVariable} - IF @{ResultCodeParameter} NOT IN ({SuccessCode}, {FinishedPreambleWithoutAcquiringCode}) - BEGIN - IF OBJECT_ID('tempdb..' + @{MarkerTableNameParameter}) IS NOT NULL - BEGIN - DECLARE @dropMarkerTableSql NVARCHAR(MAX) = 'DROP TABLE ' + @{MarkerTableNameParameter} - EXEC sp_executeSql @dropMarkerTableSql - END - END"; - } - - private static string Merge(params string[] parts) => string.Join(Environment.NewLine, parts); - #endregion - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs deleted file mode 100644 index 1ec3169b..00000000 --- a/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Medallion.Threading.Internal; -using NUnit.Framework; -using System; -using System.Diagnostics; -using System.Threading; - -namespace Medallion.Threading.Tests.Data -{ - public abstract class ConnectionStringStrategyTestCases - where TLockProvider : TestingLockProvider, new() - where TStrategy : TestingConnectionStringSynchronizationStrategy, new() - where TDb : ITestingPrimaryClientDb, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - /// - /// Tests that internally-owned connections are properly cleaned up by disposing the lock handle - /// - [Test] - public void TestConnectionDoesNotLeak() - { - // If the lock is based on a multi-ticket semaphore, then the first creation will claim N-1 connections. To avoid this messing with - // our count, we create a throwaway lock instance here to hold those connections using the default application name - this._lockProvider.CreateLock(nameof(TestConnectionDoesNotLeak)); - - // set a distinctive application name so that we can count how many connections are used - var applicationName = this._lockProvider.Strategy.SetUniqueApplicationName(); - - var @lock = this._lockProvider.CreateLock(nameof(TestConnectionDoesNotLeak)); - for (var i = 0; i < 30; ++i) - { - using (@lock.Acquire()) - { - this._lockProvider.Strategy.Db.CountActiveSessions(applicationName).ShouldEqual(1, this.GetType().Name); - } - // still alive due to pooling - this._lockProvider.Strategy.Db.CountActiveSessions(applicationName).ShouldEqual(1, this.GetType().Name); - } - - using (var connection = this._lockProvider.Strategy.Db.CreateConnection()) - { - this._lockProvider.Strategy.Db.ClearPool(connection); - } - - // checking immediately seems flaky; likely clear pool finishing - // doesn't guarantee that SQL will immediately reflect the clear - var maxWaitForPoolsToClear = TimeSpan.FromSeconds(5); - var stopwatch = Stopwatch.StartNew(); - do - { - var activeCount = this._lockProvider.Strategy.Db.CountActiveSessions(applicationName); - if (activeCount == 0) { return; } - Thread.Sleep(10); - } - while (stopwatch.Elapsed < maxWaitForPoolsToClear); - - Assert.Fail("Connection was not released"); - } - - [Test] - public void TestKeepaliveProtectsFromIdleSessionKiller() - { - var applicationName = this._lockProvider.Strategy.SetUniqueApplicationName(); - - this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromSeconds(.05); - var @lock = this._lockProvider.CreateLock(nameof(TestKeepaliveProtectsFromIdleSessionKiller)); - - using var idleSessionKiller = new IdleSessionKiller(this._lockProvider.Strategy.Db, applicationName, idleTimeout: TimeSpan.FromSeconds(.25)); - - var handle = @lock.Acquire(); - Thread.Sleep(TimeSpan.FromSeconds(1)); - Assert.DoesNotThrow(() => handle.Dispose()); - } - - /// - /// Demonstrates that we don't multi-thread the connection despite running keepalive queries - /// - [Test] - public void TestKeepaliveDoesNotCreateRaceCondition() - { - this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromMilliseconds(1); - - Assert.DoesNotThrow(() => - { - var @lock = this._lockProvider.CreateLock(nameof(TestKeepaliveDoesNotCreateRaceCondition)); - for (var i = 0; i < 25; ++i) - { - using (@lock.Acquire()) - { - Thread.Sleep(1); - } - } - }); - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs deleted file mode 100644 index f94edca1..00000000 --- a/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs +++ /dev/null @@ -1,84 +0,0 @@ -using NUnit.Framework; -using System; -using System.Data; -using System.Data.Common; -using System.Linq; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Data -{ - public abstract class ExternalConnectionOrTransactionStrategyTestCases - where TLockProvider : TestingLockProvider, new() - where TStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy, new() - where TDb : ITestingDb, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - [Test] - public void TestDeadlockDetection() - { - var timeout = TimeSpan.FromSeconds(30); - - using var barrier = new Barrier(participantCount: 2); - const string LockName1 = nameof(TestDeadlockDetection) + "_1", - LockName2 = nameof(TestDeadlockDetection) + "_2"; - - Task RunDeadlock(bool isFirst) - { - this._lockProvider.Strategy.StartAmbient(); - var lock1 = this._lockProvider.CreateLock(isFirst ? LockName1 : LockName2); - var lock2 = this._lockProvider.CreateLock(isFirst ? LockName2 : LockName1); - return Task.Run(async () => - { - using (await lock1.AcquireAsync(timeout)) - { - barrier.SignalAndWait(); - (await lock2.AcquireAsync(timeout)).Dispose(); - } - }); - } - - var tasks = new[] { RunDeadlock(isFirst: true), RunDeadlock(isFirst: false) }; - - Task.WhenAll(tasks).ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true, this.GetType().Name); - - var deadlockVictim = tasks.Single(t => t.IsFaulted); - Assert.IsInstanceOf(deadlockVictim.Exception!.GetBaseException()); // backwards compat check - Assert.IsInstanceOf(deadlockVictim.Exception.GetBaseException()); - - tasks.Count(t => t.Status == TaskStatus.RanToCompletion).ShouldEqual(1); - } - - /// - /// Currently, we leverage to track handle loss. This test - /// validates that the handler is properly removed when the lock handle is disposed - /// - [Test] - public void TestStateChangeHandlerIsNotLeaked() - { - this._lockProvider.Strategy.StartAmbient(); - - // creating this first assures that the Semaphore5 provider's handlers get included in initial - var @lock = this._lockProvider.CreateLock(nameof(TestStateChangeHandlerIsNotLeaked)); - - var initialHandler = GetStateChanged(this._lockProvider.Strategy.AmbientConnection!); - - using (@lock.Acquire()) - { - Assert.IsNotNull(GetStateChanged(this._lockProvider.Strategy.AmbientConnection!)); - } - - GetStateChanged(this._lockProvider.Strategy.AmbientConnection!).ShouldEqual(initialHandler); - - static StateChangeEventHandler? GetStateChanged(DbConnection connection) => - (StateChangeEventHandler?)typeof(DbConnection).GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .Single(f => f.FieldType == typeof(StateChangeEventHandler)) - .GetValue(connection); - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs deleted file mode 100644 index bc7cd116..00000000 --- a/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs +++ /dev/null @@ -1,64 +0,0 @@ -using NUnit.Framework; -using System; - -namespace Medallion.Threading.Tests.Data -{ - public abstract class ExternalConnectionStrategyTestCases - where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingDb, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - [Test] - public void TestCloseLockOnClosedConnection() - { - var nonAmbientConnectionLock = this._lockProvider.CreateLock(nameof(TestCloseLockOnClosedConnection)); - - // Disable pooling for the ambient connection. This is important because we want to show that the lock - // will get released; in reality for a pooled connection in this scenario the lock-holding connection will - // return to the pool and would get released the next time that connection was fetched from the pool - this._lockProvider.Strategy.Db.ConnectionStringBuilder["Pooling"] = false; - this._lockProvider.Strategy.StartAmbient(); - var ambientConnectionLock = this._lockProvider.CreateLock(nameof(TestCloseLockOnClosedConnection)); - - this._lockProvider.Strategy.AmbientConnection!.Close(); - - Assert.Catch(() => ambientConnectionLock.Acquire()); - - this._lockProvider.Strategy.AmbientConnection!.Open(); - - var handle = ambientConnectionLock.Acquire(); - - nonAmbientConnectionLock.IsHeld().ShouldEqual(true, this.GetType().Name); - - this._lockProvider.Strategy.AmbientConnection!.Close(); - - // Note: in version 1.0 we'd avoid throwing in this scenario. However, that approach could hide bugs because - // merely closing the connection doesn't release the lock: it just returns the connection to the pool where - // it will continue to hold the lock until it is used again. - Assert.Throws(() => handle.Dispose()); - - // lock can be re-acquired - nonAmbientConnectionLock.IsHeld().ShouldEqual(false); - } - - [Test] - public void TestIsNotScopedToTransaction() - { - var nonAmbientConnectionLock = this._lockProvider.CreateLock(nameof(TestIsNotScopedToTransaction)); - - this._lockProvider.Strategy.StartAmbient(); - - using var handle = this._lockProvider.CreateLock(nameof(TestIsNotScopedToTransaction)).Acquire(); - using (var transaction = this._lockProvider.Strategy.AmbientConnection!.BeginTransaction()) - { - transaction.Rollback(); - } - - nonAmbientConnectionLock.IsHeld().ShouldEqual(true, this.GetType().Name); - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs deleted file mode 100644 index 59fa0268..00000000 --- a/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs +++ /dev/null @@ -1,131 +0,0 @@ -using NUnit.Framework; -using System; -using System.Data.Common; -using System.Linq; -using System.Runtime.CompilerServices; - -namespace Medallion.Threading.Tests.Data -{ - public abstract class ExternalTransactionStrategyTestCases - where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingDb, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - [Test] - public void TestScopedToTransactionOnly() - { - this._lockProvider.Strategy.StartAmbient(); - - var ambientTransactionLock = this._lockProvider.CreateLock(nameof(TestScopedToTransactionOnly)); - using (ambientTransactionLock.Acquire()) - { - Assert.IsTrue(this._lockProvider.CreateLock(nameof(TestScopedToTransactionOnly)).IsHeld()); - - // create a lock of the same type on the underlying connection of the ambient transaction - using dynamic specificConnectionProvider = Activator.CreateInstance( - ReplaceGenericParameter(typeof(TLockProvider), this._lockProvider.Strategy.GetType(), typeof(SpecificConnectionStrategy)) - )!; - specificConnectionProvider.Strategy.Test = this; - Assert.Catch(() => ((IDistributedLock)specificConnectionProvider.CreateLock(nameof(TestScopedToTransactionOnly))).Acquire()); - } - - static Type ReplaceGenericParameter(Type type, Type old, Type @new) - { - if (type == old) { return @new; } - if (!type.IsConstructedGenericType) { return type; } - - var newGenericArguments = type.GetGenericArguments() - .Select(a => ReplaceGenericParameter(a, old, @new)) - .ToArray(); - return type.GetGenericTypeDefinition() - .MakeGenericType(newGenericArguments); - } - } - - /// - /// Special strategy designed to allow us to make connection-scoped locks using the same connection as - /// the ambient transaction from our own - /// - private class SpecificConnectionStrategy : TestingDbSynchronizationStrategy - { - public ExternalTransactionStrategyTestCases? Test { get; set; } - - public override TestingDbConnectionOptions GetConnectionOptions() => - new TestingDbConnectionOptions { Connection = this.Test!._lockProvider.Strategy.AmbientTransaction!.Connection }; - } - - public void TestCloseTransactionLockOnClosedConnectionOrTransaction([Values] bool closeConnection) - { - var lockName = closeConnection ? "Connection" : "Transaction"; - - var nonAmbientTransactionLock = this._lockProvider.CreateLock(lockName); - - // Disable pooling for the ambient connection. This is important because we want to show that the lock - // will get released; in reality for a pooled connection in this scenario the lock-holding connection will - // return to the pool and would get released the next time that connection was fetched from the pool - this._lockProvider.Strategy.Db.ConnectionStringBuilder["Pooling"] = false; - this._lockProvider.Strategy.StartAmbient(); - var ambientTransactionLock = this._lockProvider.CreateLock(lockName); - - using var handle = ambientTransactionLock.Acquire(); - Assert.IsTrue(nonAmbientTransactionLock.IsHeld()); - - if (closeConnection) - { - this._lockProvider.Strategy.AmbientTransaction!.Connection.Dispose(); - } - else - { - this._lockProvider.Strategy.AmbientTransaction!.Dispose(); - } - Assert.DoesNotThrow(handle.Dispose); - - // now lock can be re-acquired - Assert.IsFalse(nonAmbientTransactionLock.IsHeld()); - } - - [Test] - public void TestLockOnRolledBackTransaction() => this.TestLockOnCompletedTransactionHelper(t => t.Rollback()); - - [Test] - public void TestLockOnCommittedTransaction() => this.TestLockOnCompletedTransactionHelper(t => t.Commit()); - - [Test] - public void TestLockOnDisposedTransaction() => this.TestLockOnCompletedTransactionHelper(t => t.Dispose()); - - private void TestLockOnCompletedTransactionHelper(Action complete, [CallerMemberName] string lockName = "") - { - var nonAmbientTransactionLock = this._lockProvider.CreateLock(lockName); - - // Disable pooling for the ambient connection. This is important because we want to show that the lock - // will get released; in reality for a pooled connection in this scenario the lock-holding connection will - // return to the pool and would get released the next time that connection was fetched from the pool - this._lockProvider.Strategy.Db.ConnectionStringBuilder["Pooling"] = false; - this._lockProvider.Strategy.StartAmbient(); - var ambientTransactionLock = this._lockProvider.CreateLock(lockName); - - using var handle = ambientTransactionLock.Acquire(); - Assert.IsTrue(nonAmbientTransactionLock.IsHeld()); - - complete(this._lockProvider.Strategy.AmbientTransaction!); - - Assert.DoesNotThrow(handle.Dispose); - - // now lock can be re-acquired - Assert.IsFalse(nonAmbientTransactionLock.IsHeld()); - - if (this._lockProvider.Strategy.Db.SupportsTransactionScopedSynchronization) - { - Assert.Catch(() => ambientTransactionLock.Acquire()); - } - else - { - Assert.DoesNotThrow(() => ambientTransactionLock.Acquire().Dispose()); - } - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs deleted file mode 100644 index 2b5cf905..00000000 --- a/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs +++ /dev/null @@ -1,127 +0,0 @@ -using Medallion.Threading.Internal; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Data -{ - public abstract class MultiplexingConnectionStrategyTestCases - where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingPrimaryClientDb, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - /// - /// Similar to but demonstrates - /// the time-based cleanup loop rather than forcing a cleanup - /// - [Test] - // todo if we parallelize we need to make sure this test blocks anyone else from calling MFQ.FinalizeAsync() since that defeats the point - public void TestLockAbandonmentWithTimeBasedCleanupRun() - { - var lock1 = this._lockProvider.CreateLock(nameof(this.TestLockAbandonmentWithTimeBasedCleanupRun)); - var lock2 = this._lockProvider.CreateLock(nameof(this.TestLockAbandonmentWithTimeBasedCleanupRun)); - var handleReference = this.TestCleanupHelper(lock1, lock2); - - GC.Collect(); - GC.WaitForPendingFinalizers(); - handleReference.IsAlive.ShouldEqual(false); - - // We might get lucky and wait for less than the cadence based on how the timing works out. However, - // due to system load we might also need to wait longer than the cadence. To be safe, we wait for up - // to 2x the cadence but check in frequently to see if we can finish early. - var maxWait = TimeSpan.FromSeconds(2 * ManagedFinalizerQueue.FinalizerCadence.TotalSeconds); - var stopwatch = Stopwatch.StartNew(); - while (lock2.IsHeld()) - { - if (stopwatch.Elapsed > maxWait) - { - Assert.Fail(this.GetType().ToString()); - } - Thread.Sleep(TimeSpan.FromSeconds(.25)); - } - } - - [MethodImpl(MethodImplOptions.NoInlining)] // need to isolate for GC - private WeakReference TestCleanupHelper(IDistributedLock lock1, IDistributedLock lock2) - { - var handle = lock1.Acquire(); - - Assert.IsNull(lock2.TryAcquireAsync().Result); - - return new WeakReference(handle); - } - - /// - /// This method demonstrates how multiplexing can be used to hold many locks concurrently on one underlying connection. - /// - /// Note: I would like this test to actually leverage multiple threads, but this runs into issues because the current - /// implementation of optimistic multiplexing only makes one attempt to use a shared lock before opening a new connection. - /// This runs into problems because the attempt to use a shared lock can fail if, for example, a lock is being released on - /// that connection which means that the mutex for the connection can't be acquired without waiting. Once something like - /// this happens, we try to open a new connection which times out due to pool size limits - /// - [Test] - public void TestHighConcurrencyWithSmallPool() - { - const int LockNameCount = 20; - - // Pre-generate all locks we will use. This is necessary for our Semaphore5 strategy, where the first lock created - // takes 4 of the 5 tickets (and thus may need more connections than a single-connection pool can support). For other - // lock types this does nothing since creating a lock might open a connection but otherwise won't run any commands - for (var i = 0; i < LockNameCount; ++i) - { - this._lockProvider.CreateLock(MakeLockName(i)); - } - - // Multiplexing is not allowed for upgrade locks since the upgrade operation could block. Therefore - // we don't allow a lock provider based on a RW lock to use its upgrade lock as an exclusive lock - if (this._lockProvider is ITestingReaderWriterLockAsMutexProvider readerWriterAsMutexProvider) - { - readerWriterAsMutexProvider.DisableUpgradeLock = true; - } - - // assign a unique app name to make sure we'll own the entire pool - this._lockProvider.Strategy.SetUniqueApplicationName(); - this._lockProvider.Strategy.Db.MaxPoolSize = 1; - - async Task Test() - { - var random = new Random(12345); - - var heldLocks = new Dictionary(); - for (var i = 0; i < 1000; ++i) - { - var lockName = MakeLockName(random.Next(20)); - if (heldLocks.TryGetValue(lockName, out var existingHandle)) - { - existingHandle.Dispose(); - heldLocks.Remove(lockName); - } - else - { - var @lock = this._lockProvider.CreateLock(lockName); - var handle = await @lock.TryAcquireAsync(); - if (handle != null) { heldLocks.Add(lockName, handle); } - } - } - - foreach (var remainingHandle in heldLocks.Values) - { - remainingHandle.Dispose(); - } - }; - - Assert.IsTrue(Task.Run(Test).Wait(Debugger.IsAttached ? TimeSpan.FromMinutes(10) : TimeSpan.FromSeconds(10))); - - string MakeLockName(int i) => $"{nameof(TestHighConcurrencyWithSmallPool)}_{i}"; - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs deleted file mode 100644 index fc7b4b9f..00000000 --- a/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Medallion.Threading.Internal; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Data.Common; -using System.Diagnostics; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Tests.Data -{ - public abstract class OwnedConnectionStrategyTestCases - where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingPrimaryClientDb, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - /// - /// Tests that our idle session killer works, therefore validating our other tests that use it. - /// - /// We test this here rather than in - /// because (a) we don't need to repeat the test for both regular and multiplexed and (2) for owned-transaction the test won't - /// pass because you can safely Dispose a transaction on a killed SQL connection - /// - [Test] - public void TestIdleSessionKiller() - { - // This makes sure that for the Semaphore5 lock initial 4 tickets are taken with the default - // application name and therefore won't be counted or killed - this._lockProvider.CreateLock(nameof(TestIdleSessionKiller)); - - var applicationName = this._lockProvider.Strategy.SetUniqueApplicationName(); - var @lock = this._lockProvider.CreateLock(nameof(TestIdleSessionKiller)); - - // go through one acquire/dispose cycle to ensure all commands are prepared. Due to - // https://github.com/npgsql/npgsql/issues/2912 in Postgres, we get NRE on the post-kill Dispose() - // call rather than the DbException we expected. - @lock.Acquire().Dispose(); - - using var handle = @lock.Acquire(); - this._lockProvider.Strategy.Db.CountActiveSessions(applicationName).ShouldEqual(1); - - using var idleSessionKiller = new IdleSessionKiller(this._lockProvider.Strategy.Db, applicationName, idleTimeout: TimeSpan.FromSeconds(.1)); - var stopwatch = Stopwatch.StartNew(); - while (true) - { - Thread.Sleep(TimeSpan.FromSeconds(.02)); - if (this._lockProvider.Strategy.Db.CountActiveSessions(applicationName) == 0) - { - break; - } - if (stopwatch.Elapsed > TimeSpan.FromSeconds(5)) - { - Assert.Fail("Timed out waiting for idle session to be killed"); - } - } - - Assert.Catch(() => handle.Dispose()); - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs deleted file mode 100644 index d9221394..00000000 --- a/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Medallion.Threading.Internal; -using NUnit.Framework; -using System.Data; - -namespace Medallion.Threading.Tests.Data -{ - public abstract class OwnedTransactionStrategyTestCases - where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingPrimaryClientDb, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - /// - /// Validates that we use the default isolation level to avoid the problem described - /// here: https://msdn.microsoft.com/en-us/library/5ha4240h(v=vs.110).aspx - /// - /// From MSDN: - /// After a transaction is committed or rolled back, the isolation level of the transaction - /// persists for all subsequent commands that are in autocommit mode (the SQL Server default). - /// This can produce unexpected results, such as an isolation level of REPEATABLE READ persisting - /// and locking other users out of a row. To reset the isolation level to the default (READ COMMITTED), - /// execute the Transact-SQL SET TRANSACTION ISOLATION LEVEL READ COMMITTED statement, or call - /// SqlConnection.BeginTransaction followed immediately by SqlTransaction.Commit. For more - /// information on SQL Server isolation levels, see "Isolation Levels in the Database Engine" in SQL - /// Server Books Online. - /// - /// This obviously only applies to SQLServer currently. However, we might as well run this test against - /// other providers in case they have the same issue. - /// - [Test] - public void TestIsolationLevelLeakage() - { - // Pre-generate the lock we will use. This is necessary for our Semaphore5 strategy, where the first lock created - // takes 4 of the 5 tickets (and thus may need more connections than a single-connection pool can support). For other - // lock types this does nothing since creating a lock might open a connection but otherwise won't run any commands - this._lockProvider.CreateLock(nameof(TestIsolationLevelLeakage)); - - // use a unique pool of size 1 so we can reclaim the connection after we use it and test for leaks - this._lockProvider.Strategy.SetUniqueApplicationName(); - this._lockProvider.Strategy.Db.MaxPoolSize = 1; - - AssertHasDefaultIsolationLevel(); - - var @lock = this._lockProvider.CreateLock(nameof(TestIsolationLevelLeakage)); - @lock.Acquire().Dispose(); - AssertHasDefaultIsolationLevel(); - - @lock.AcquireAsync().Result.Dispose(); - AssertHasDefaultIsolationLevel(); - - void AssertHasDefaultIsolationLevel() - { - using var connection = this._lockProvider.Strategy.Db.CreateConnection(); - connection.Open(); - this._lockProvider.Strategy.Db.GetIsolationLevel(connection).ShouldEqual(IsolationLevel.ReadCommitted); - } - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/Data/SemaphoreSelfDeadlockTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/SemaphoreSelfDeadlockTestCases.cs deleted file mode 100644 index e5935ae5..00000000 --- a/DistributedLock.Tests/AbstractTestCases/Data/SemaphoreSelfDeadlockTestCases.cs +++ /dev/null @@ -1,75 +0,0 @@ -using NUnit.Framework; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Data -{ - /// - /// These cases test "self-deadlock", where a semaphore acquire cannot possibly succeed because the current connection owns - /// all tickets. Since this can only happen when a connection/transaction is re-used, we require - /// on our providers. - /// - public abstract class SemaphoreSelfDeadlockTestCases - where TSemaphoreProvider : TestingSemaphoreProvider, new() - where TStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy, new() - where TDb : ITestingDb, new() - { - private TSemaphoreProvider _semaphoreProvider = default!; - - [SetUp] public void SetUp() => this._semaphoreProvider = new TSemaphoreProvider(); - [TearDown] public void TearDown() => this._semaphoreProvider.Dispose(); - - [Test] - public void TestSelfDeadlockThrowsOnInfiniteWait() - { - var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestSelfDeadlockThrowsOnInfiniteWait), maxCount: 2); - semaphore.Acquire(); - semaphore.Acquire(); - var ex = Assert.Catch(() => semaphore.Acquire()); - ex.Message.Contains("Deadlock").ShouldEqual(true, ex.Message); - } - - [Test] - public void TestMultipleConnectionsCannotTriggerSelfDeadlock() - { - var semaphore1 = this._semaphoreProvider.CreateSemaphore(nameof(TestMultipleConnectionsCannotTriggerSelfDeadlock), maxCount: 2); - var semaphore2 = this._semaphoreProvider.CreateSemaphore(nameof(TestMultipleConnectionsCannotTriggerSelfDeadlock), maxCount: 2); - semaphore1.Acquire(); - semaphore2.Acquire(); - - var source = new CancellationTokenSource(); - var acquireTask = semaphore1.AcquireAsync(cancellationToken: source.Token).AsTask(); - acquireTask.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false); - source.Cancel(); - acquireTask.ContinueWith(t => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true); - acquireTask.Status.ShouldEqual(TaskStatus.Canceled); - } - - [Test] - public void TestSelfDeadlockWaitsOnSpecifiedTime() - { - var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestSelfDeadlockWaitsOnSpecifiedTime), maxCount: 1); - semaphore.Acquire(); - - var acquireTask = Task.Run(() => semaphore.TryAcquire(TimeSpan.FromSeconds(.2))); - acquireTask.Wait(TimeSpan.FromSeconds(.05)).ShouldEqual(false); - acquireTask.Wait(TimeSpan.FromSeconds(.3)).ShouldEqual(true); - acquireTask.Result.ShouldEqual(null); - } - - [Test] - public void TestSelfDeadlockWaitRespectsCancellation() - { - var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestSelfDeadlockWaitsOnSpecifiedTime), maxCount: 1); - semaphore.Acquire(); - - var source = new CancellationTokenSource(); - var acquireTask = semaphore.AcquireAsync(TimeSpan.FromSeconds(20), source.Token).AsTask(); - acquireTask.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false); - source.Cancel(); - acquireTask.ContinueWith(t => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true); - acquireTask.Status.ShouldEqual(TaskStatus.Canceled); - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs deleted file mode 100644 index 5fb76577..00000000 --- a/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Medallion.Threading.Internal; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Tests.Data -{ - public abstract class UpgradeableReaderWriterLockConnectionStringStrategyTestCases - where TLockProvider : TestingUpgradeableReaderWriterLockProvider, new() - where TStrategy : TestingConnectionStringSynchronizationStrategy, new() - where TDb : ITestingPrimaryClientDb, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - /// - /// Tests the logic where upgrading a connection stops and restarts the keepalive - /// - [Test] - public void TestKeepaliveProtectsFromIdleSessionKillerAfterFailedUpgrade() - { - var applicationName = this._lockProvider.Strategy.SetUniqueApplicationName(); - - this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromSeconds(.1); - var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestKeepaliveProtectsFromIdleSessionKillerAfterFailedUpgrade)); - - using var idleSessionKiller = new IdleSessionKiller(this._lockProvider.Strategy.Db, applicationName, idleTimeout: TimeSpan.FromSeconds(.25)); - - using (@lock.AcquireReadLock()) - { - var handle = @lock.AcquireUpgradeableReadLock(); - handle.TryUpgradeToWriteLock().ShouldEqual(false); - handle.TryUpgradeToWriteLockAsync().Result.ShouldEqual(false); - Thread.Sleep(TimeSpan.FromSeconds(1)); - Assert.DoesNotThrow(() => handle.Dispose()); - } - } - - /// - /// Demonstrates that we don't multi-thread the connection despite running keepalive queries - /// - /// This test is similar to , - /// but in this case we additionally test lock upgrading which must pause and restart the keepalive process. - /// - [Test] - public void TestKeepaliveDoesNotCreateRaceCondition() - { - this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromMilliseconds(1); - - Assert.DoesNotThrow(() => - { - var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestKeepaliveDoesNotCreateRaceCondition)); - for (var i = 0; i < 30; ++i) - { - using var handle = @lock.AcquireUpgradeableReadLockAsync().Result; - Thread.Sleep(1); - handle.UpgradeToWriteLock(); - Thread.Sleep(1); - } - }); - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs deleted file mode 100644 index 13fa1c7d..00000000 --- a/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs +++ /dev/null @@ -1,441 +0,0 @@ -using Medallion.Shell; -using Medallion.Threading.Internal; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests -{ - public abstract class DistributedLockCoreTestCases - where TLockProvider : TestingLockProvider, new() - where TStrategy : TestingSynchronizationStrategy, new() - { - private TLockProvider _lockProvider = default!; - private readonly List _cleanupActions = new List(); - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - - [TearDown] - public void TearDown() - { - this._cleanupActions.ForEach(a => a()); - this._cleanupActions.Clear(); - this._lockProvider.Dispose(); - } - - [Test] - public void BasicTest() - { - var @lock = this._lockProvider.CreateLock(nameof(BasicTest)); - var lock2 = this._lockProvider.CreateLock(nameof(BasicTest) + "2"); - - using (var handle = @lock.TryAcquire()) - { - Assert.IsNotNull(handle, this.GetType() + ": should be able to acquire new lock"); - - using (var nestedHandle = @lock.TryAcquire()) - { - Assert.IsNull(nestedHandle, "should not be reentrant"); - } - - using var nestedHandle2 = lock2.TryAcquire(); - Assert.IsNotNull(nestedHandle2, this.GetType() + ": should be able to acquire a different lock"); - } - - using (var handle = @lock.TryAcquire()) - { - Assert.IsNotNull(handle, this.GetType() + ": should be able to re-acquire after releasing"); - } - } - - [Test] - public async Task BasicAsyncTest() - { - // note: we intentionally have a mix of await using vs using and await - // vs .Result here to excercise various code paths - - var @lock = this._lockProvider.CreateLock(nameof(BasicAsyncTest)); - var lock2 = this._lockProvider.CreateLock(nameof(BasicAsyncTest) + "2"); - - await using (var handle = await @lock.TryAcquireAsync()) - { - Assert.IsNotNull(handle, this.GetType().Name); - - using (var nestedHandle = await @lock.TryAcquireAsync()) - { - Assert.IsNull(nestedHandle, this.GetType().Name); - } - - await using var nestedHandle2 = lock2.TryAcquireAsync().Result; - Assert.IsNotNull(nestedHandle2, this.GetType().Name); - } - - await using (var handle = await @lock.TryAcquireAsync()) - { - Assert.IsNotNull(handle, this.GetType().Name); - } - } - - [Test] - public void TestBadArguments() - { - var @lock = this._lockProvider.CreateLock(nameof(TestBadArguments)); - Assert.Catch(() => @lock.Acquire(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.AcquireAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.TryAcquire(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.TryAcquireAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.Acquire(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.AcquireAsync(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.TryAcquire(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.TryAcquireAsync(TimeSpan.FromSeconds(int.MaxValue))); - } - - [Test] - public void TestDisposeHandleIsIdempotent() - { - var @lock = this._lockProvider.CreateLock(nameof(TestDisposeHandleIsIdempotent)); - var handle = @lock.Acquire(TimeSpan.FromSeconds(30)); - Assert.IsNotNull(handle); - handle.Dispose(); - var handle2 = @lock.Acquire(TimeSpan.FromSeconds(30)); - Assert.DoesNotThrow(() => handle.Dispose()); - Assert.DoesNotThrow(() => handle2.Dispose()); - } - - [Test] - [NonParallelizable] // timing-sensitive - public void TestTimeouts() - { - var @lock = this._lockProvider.CreateLock(nameof(TestTimeouts)); - // acquire with a different lock instance to avoid reentrancy mattering - using (this._lockProvider.CreateLock(nameof(TestTimeouts)).Acquire()) - { - var timeout = TimeSpan.FromSeconds(.1); - var waitTime = TimeSpan.FromSeconds(.4); - - var syncAcquireTask = Task.Run(() => @lock.Acquire(timeout)); - syncAcquireTask.ContinueWith(_ => { }).Wait(waitTime).ShouldEqual(true, "sync acquire"); - Assert.IsInstanceOf(syncAcquireTask.Exception?.InnerException, "sync acquire"); - - var asyncAcquireTask = @lock.AcquireAsync(timeout).AsTask(); - asyncAcquireTask.ContinueWith(_ => { }).Wait(waitTime).ShouldEqual(true, "async acquire"); - Assert.IsInstanceOf(asyncAcquireTask.Exception!.InnerException, "async acquire"); - - var syncTryAcquireTask = Task.Run(() => @lock.TryAcquire(timeout)); - syncTryAcquireTask.Wait(waitTime).ShouldEqual(true, "sync tryAcquire"); - syncTryAcquireTask.Result.ShouldEqual(null, "sync tryAcquire"); - - var asyncTryAcquireTask = @lock.TryAcquireAsync(timeout).AsTask(); - asyncTryAcquireTask.Wait(waitTime).ShouldEqual(true, "async tryAcquire"); - asyncTryAcquireTask.Result.ShouldEqual(null, "async tryAcquire"); - } - } - - [Test] - public void CancellationTest() - { - var lockName = nameof(CancellationTest); - var @lock = this._lockProvider.CreateLock(lockName); - - var source = new CancellationTokenSource(); - using (var handle = this._lockProvider.CreateLock(lockName).Acquire()) - { - var blocked = @lock.AcquireAsync(cancellationToken: source.Token).AsTask(); - blocked.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false); - source.Cancel(); - blocked.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true, this.GetType().Name); - blocked.Status.ShouldEqual(TaskStatus.Canceled, (blocked.Exception ?? (object)"no exception").ToString()); - } - - // already canceled - source = new CancellationTokenSource(); - source.Cancel(); - Assert.Catch(() => @lock.Acquire(cancellationToken: source.Token)); - } - - [Test] - public void TestParallelism() - { - this._lockProvider.Strategy.PrepareForHighContention(); - - // NOTE: if this test fails for Postgres, we may need to raise the default connection limit. This can - // be done by setting max_connections in C:\Program Files\PostgreSQL\\data\postgresql.conf or - // /var/lib/pgsql//data/postgresql.conf and then restarting Postgres. - // See https://docs.alfresco.com/5.0/tasks/postgresql-config.html - - var counter = 0; - var tasks = Enumerable.Range(1, 100).Select(async _ => - { - var @lock = this._lockProvider.CreateLock("parallel_test"); - await using (await @lock.AcquireAsync()) - { - // increment going in - Interlocked.Increment(ref counter); - - // hang out for a bit to ensure concurrency - await Task.Delay(TimeSpan.FromMilliseconds(10)); - - // decrement and return on the way out (returns # inside the lock when this left ... should be 0) - return Interlocked.Decrement(ref counter); - } - }) - .ToList(); - - Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(30)).ShouldEqual(true, this.GetType().Name); - - tasks.ForEach(t => t.Result.ShouldEqual(0)); - } - - [Test] - [NonParallelizable] // takes locks with known names - public void TestGetSafeName() - { - Assert.Catch(() => this._lockProvider.GetSafeName(null!)); - - foreach (var name in new[] { string.Empty, new string('a', 1000), @"\\\\\", new string('\\', 1000) }) - { - var safeName = this._lockProvider.GetSafeName(name); - Assert.DoesNotThrow(() => this._lockProvider.CreateLockWithExactName(safeName).Acquire(TimeSpan.FromSeconds(10)).Dispose(), $"{this.GetType().Name}: could not acquire '{name}'"); - } - } - - [Test] - public void TestGetSafeLockNameIsCaseSensitive() - { - var longName1 = new string('a', 1000); - var longName2 = new string('a', longName1.Length - 1) + "A"; - StringComparer.OrdinalIgnoreCase.Equals(longName1, longName2).ShouldEqual(true, "sanity check"); - - Assert.AreNotEqual(this._lockProvider.GetSafeName(longName1), this._lockProvider.GetSafeName(longName2)); - } - - [Test] - public async Task TestLockNamesAreCaseSensitive() - { - // the goal here is to construct 2 valid lock names that differ only by case. We start by generating a hash name - // that is unique to this test yet stable across runs. Then we truncate it to avoid need for further hashing in Postgres - // (which only supports very short ASCII string names). Finally, we re-run through GetSafeName to pick up any special prefix - // that is needed (e. g. for wait handles) - using var sha1 = SHA1.Create(); - var uniqueHashName = Convert.ToBase64String(sha1.ComputeHash(Encoding.UTF8.GetBytes(this._lockProvider.GetUniqueSafeName()))); - var lowerName = this._lockProvider.GetSafeName($"{uniqueHashName.Substring(0, 6)}_a"); - var upperName = this._lockProvider.GetSafeName($"{uniqueHashName.Substring(0, 6)}_A"); - // make sure we succeeded in generating what we set out ot generate - Assert.AreNotEqual(lowerName, upperName); - Assert.IsTrue(StringComparer.OrdinalIgnoreCase.Equals(lowerName, upperName)); - - await using (await this._lockProvider.CreateLockWithExactName(lowerName).AcquireAsync()) - await using (var handle = await this._lockProvider.CreateLockWithExactName(upperName).TryAcquireAsync()) - { - Assert.IsNotNull(handle); - } - } - - [Test] - public void TestCanceledAlreadyThrowsForSyncAndDoesNotThrowForAsync() - { - using var source = new CancellationTokenSource(); - source.Cancel(); - - var @lock = this._lockProvider.CreateLock("already-canceled"); - - Assert.Catch(() => @lock.Acquire(cancellationToken: source.Token)); - Assert.Catch(() => @lock.TryAcquire(cancellationToken: source.Token)); - - var acquireTask = @lock.AcquireAsync(cancellationToken: source.Token).AsTask(); - acquireTask.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true); - acquireTask.IsCanceled.ShouldEqual(true, "acquire"); - - var tryAcquireTask = @lock.TryAcquireAsync(cancellationToken: source.Token).AsTask(); - tryAcquireTask.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true); - tryAcquireTask.IsCanceled.ShouldEqual(true, "tryAcquire"); - } - - [Test] - public async Task TestHandleLostTriggersCorrectly() - { - // pre-create the lock so that semaphore5 tickets don't get created on the connection - // we're going to kill - this._lockProvider.CreateLock(nameof(TestHandleLostTriggersCorrectly)); - - var handleLostHelper = this._lockProvider.Strategy.PrepareForHandleLost(); - - var @lock = this._lockProvider.CreateLock(nameof(TestHandleLostTriggersCorrectly)); - - using var handle = await @lock.AcquireAsync(); - - handle.HandleLostToken.CanBeCanceled.ShouldEqual(handleLostHelper != null); - Assert.IsFalse(handle.HandleLostToken.IsCancellationRequested); - - if (handleLostHelper != null) - { - using var canceledEvent = new ManualResetEventSlim(initialState: false); - using var registration = handle.HandleLostToken.Register(canceledEvent.Set); - - Assert.IsFalse(canceledEvent.Wait(TimeSpan.FromSeconds(.05))); - - handleLostHelper.Dispose(); - - Assert.IsTrue(canceledEvent.Wait(TimeSpan.FromSeconds(10))); - Assert.IsTrue(handle.HandleLostToken.IsCancellationRequested); - } - - // todo revisit behavior here and in similar cases; do we want to fail hard on Dispose if the handle is lost? - try { await handle.DisposeAsync(); } - catch { } - - Assert.Throws(() => handle.HandleLostToken.GetType()); - } - - [Test] - public async Task TestHandleLostReturnsAlreadyCanceledIfHandleAlreadyLost() - { - // pre-create the lock so that semaphore5 tickets don't get created on the connection - // we're going to kill - this._lockProvider.CreateLock(nameof(TestHandleLostReturnsAlreadyCanceledIfHandleAlreadyLost)); - - var handleLostHelper = this._lockProvider.Strategy.PrepareForHandleLost(); - if (handleLostHelper == null) - { - Assert.Pass(); - } - - var @lock = this._lockProvider.CreateLock(nameof(TestHandleLostReturnsAlreadyCanceledIfHandleAlreadyLost)); - - using var handle = await @lock.AcquireAsync(); - - handleLostHelper!.Dispose(); - - using var canceledEvent = new ManualResetEventSlim(initialState: false); - handle.HandleLostToken.Register(canceledEvent.Set); - Assert.IsTrue(canceledEvent.Wait(TimeSpan.FromSeconds(5))); - - // todo revisit - try { handle.Dispose(); } - catch { } - } - - [Test] - public void TestCanSafelyDisposeWhileMonitoring() - { - var @lock = this._lockProvider.CreateLock(nameof(TestCanSafelyDisposeWhileMonitoring)); - - using var handle = @lock.Acquire(); - - // force monitoring to happen - using var canceledEvent = new ManualResetEventSlim(initialState: false); - using var registration = handle.HandleLostToken.Register(canceledEvent.Set); - Assert.IsFalse(canceledEvent.Wait(TimeSpan.FromSeconds(.05))); - - Assert.DoesNotThrow(handle.Dispose); - } - - [Test] - public async Task TestLockAbandonment() - { - const string LockName = nameof(TestLockAbandonment); - - // pre-create the lock so that the semaphore5 provider will allocate the extra tickets - // against a connection that won't get cleand up when we force additional cleanup - this._lockProvider.CreateLock(LockName); - - this._lockProvider.Strategy.PrepareForHandleAbandonment(); - new Action(name => this._lockProvider.CreateLock(name).Acquire())(LockName); - GC.Collect(); - GC.WaitForPendingFinalizers(); - await ManagedFinalizerQueue.Instance.FinalizeAsync(); - this._lockProvider.Strategy.PerformAdditionalCleanupForHandleAbandonment(); - - using var handle = this._lockProvider.CreateLock(LockName).TryAcquire(); - Assert.IsNotNull(handle, this.GetType().Name); - } - - [Test] - public void TestCrossProcess() - { - var lockName = this._lockProvider.GetUniqueSafeName(); - var command = this.RunLockTaker(this._lockProvider, this._lockProvider.GetCrossProcessLockType(), lockName); - Assert.IsTrue(command.StandardOutput.ReadLineAsync().Wait(TimeSpan.FromSeconds(10))); - Assert.IsFalse(command.Task.Wait(TimeSpan.FromSeconds(.1))); - - var @lock = this._lockProvider.CreateLockWithExactName(lockName); - @lock.TryAcquire().ShouldEqual(null, this.GetType().Name); - - command.StandardInput.WriteLine("done"); - command.StandardInput.Flush(); - - using var handle = @lock.TryAcquire(TimeSpan.FromSeconds(10)); - Assert.IsNotNull(handle, this.GetType().Name); - - Assert.IsTrue(command.Task.Wait(TimeSpan.FromSeconds(10))); - } - - [Test] - public void TestCrossProcessAbandonment() - { - this.CrossProcessAbandonmentHelper(asyncWait: false, kill: false); - } - - [Test] - public void TestCrossProcessAbandonmentWithKill() - { - this.CrossProcessAbandonmentHelper(asyncWait: true, kill: true); - } - - private void CrossProcessAbandonmentHelper(bool asyncWait, bool kill) - { - var name = this._lockProvider.GetUniqueSafeName($"cpl-{asyncWait}-{kill}"); - var command = this.RunLockTaker(this._lockProvider, this._lockProvider.GetCrossProcessLockType(), name); - Assert.IsTrue(command.StandardOutput.ReadLineAsync().Wait(TimeSpan.FromSeconds(10))); - Assert.IsFalse(command.Task.IsCompleted); - - var @lock = this._lockProvider.CreateLockWithExactName(name); - - var acquireTask = asyncWait - ? @lock.TryAcquireAsync(TimeSpan.FromSeconds(20)).AsTask() - : Task.Run(() => @lock.TryAcquire(TimeSpan.FromSeconds(20))); - acquireTask.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false, this.GetType().Name); - - if (kill) - { - command.Kill(); - } - else - { - command.StandardInput.WriteLine("abandon"); - command.StandardInput.Flush(); - } - - using var handle = acquireTask.Result; - Assert.IsNotNull(handle, this.GetType().Name); - } - - private Command RunLockTaker(TLockProvider engine, params string[] args) - { - const string Configuration = -#if DEBUG - "Debug"; -#else - "Release"; -#endif - var exePath = Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "DistributedLockTaker", "bin", Configuration, TargetFramework.Current, "DistributedLockTaker.exe"); - var command = Command.Run(exePath, args, o => o.WorkingDirectory(TestContext.CurrentContext.TestDirectory).ThrowOnError(true)) - .RedirectStandardErrorTo(Console.Error); - this._cleanupActions.Add(() => - { - if (!command.Task.IsCompleted) - { - command.Kill(); - } - }); - return command; - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs deleted file mode 100644 index f89a6ac0..00000000 --- a/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs +++ /dev/null @@ -1,72 +0,0 @@ -using NUnit.Framework; -using System; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests -{ - public abstract class DistributedReaderWriterLockCoreTestCases - where TLockProvider : TestingReaderWriterLockProvider, new() - where TStrategy : TestingSynchronizationStrategy, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - [Test] - public void TestMultipleReadersSingleWriter() - { - IDistributedReaderWriterLock Lock() => - this._lockProvider.CreateReaderWriterLock(nameof(TestMultipleReadersSingleWriter)); - - using var readHandle1 = Lock().TryAcquireReadLockAsync().AsTask().Result; - Assert.IsNotNull(readHandle1, this.GetType().ToString()); - using var readHandle2 = Lock().TryAcquireReadLock(); - Assert.IsNotNull(readHandle2, this.GetType().ToString()); - - using var writeHandle1 = Lock().TryAcquireWriteLock(); - Assert.IsNull(writeHandle1); - - var writeHandleTask = Lock().AcquireWriteLockAsync().AsTask(); - Assert.IsFalse(writeHandleTask.Wait(TimeSpan.FromSeconds(.05))); - - readHandle1!.Dispose(); - Assert.IsFalse(writeHandleTask.Wait(TimeSpan.FromSeconds(.05))); - - readHandle2!.Dispose(); - Assert.IsTrue(writeHandleTask.Wait(TimeSpan.FromSeconds(10))); - using var writeHandle2 = writeHandleTask.Result; - - using var writeHandle3 = Lock().TryAcquireWriteLock(); - Assert.IsNull(writeHandle3); - - writeHandle2.Dispose(); - - using var writeHandle4 = Lock().TryAcquireWriteLock(); - Assert.IsNotNull(writeHandle4); - } - - [Test] - public void TestReaderWriterLockBadArguments() - { - var @lock = this._lockProvider.CreateReaderWriterLock(nameof(TestReaderWriterLockBadArguments)); - Assert.Catch(() => @lock.AcquireReadLock(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.AcquireReadLockAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.TryAcquireReadLock(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.TryAcquireReadLockAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.AcquireReadLock(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.AcquireReadLockAsync(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.TryAcquireReadLock(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.TryAcquireReadLockAsync(TimeSpan.FromSeconds(int.MaxValue))); - - Assert.Catch(() => @lock.AcquireWriteLock(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.AcquireWriteLockAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.TryAcquireWriteLock(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.TryAcquireWriteLockAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.AcquireWriteLock(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.AcquireWriteLockAsync(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.TryAcquireWriteLock(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.TryAcquireWriteLockAsync(TimeSpan.FromSeconds(int.MaxValue))); - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/DistributedSemaphoreCoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/DistributedSemaphoreCoreTestCases.cs deleted file mode 100644 index eb42c7e3..00000000 --- a/DistributedLock.Tests/AbstractTestCases/DistributedSemaphoreCoreTestCases.cs +++ /dev/null @@ -1,158 +0,0 @@ -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests -{ - public abstract class DistributedSemaphoreCoreTestCases - where TSemaphoreProvider : TestingSemaphoreProvider, new() - where TStrategy : TestingSynchronizationStrategy, new() - { - private static readonly TimeSpan LongTimeout = TimeSpan.FromSeconds(5); - - private TSemaphoreProvider _semaphoreProvider = default!; - - [SetUp] public void SetUp() => this._semaphoreProvider = new TSemaphoreProvider(); - [TearDown] public void TearDown() => this._semaphoreProvider.Dispose(); - - [Test] - public void TestConcurrencyHandling() - { - const int MaxCount = 3; - - var counter = 0; - var seenCounterValues = new HashSet(); - - const int Threads = 10; - const int Trials = 25; - var barrier = new Barrier(Threads); - var threads = Enumerable.Range(0, Threads) - .Select(_ => Task.Factory.StartNew(() => - { - var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestConcurrencyHandling), MaxCount); - - barrier.SignalAndWait(); - for (var i = 0; i < Trials; ++i) - { - using var _ = semaphore.Acquire(LongTimeout); - var newCounterValue = Interlocked.Increment(ref counter); - lock (seenCounterValues) { seenCounterValues.Add(newCounterValue); } - Thread.Sleep(10); - Interlocked.Decrement(ref counter); - } - }, - TaskCreationOptions.LongRunning // dedicated thread - )) - .ToArray(); - Task.WaitAll(threads); - - CollectionAssert.AreEquivalent(new[] { 1, 2, 3 }, seenCounterValues.ToArray()); - } - - [Test] - public void TestDrain() - { - var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestDrain), maxCount: 4); - var semaphore2 = this._semaphoreProvider.CreateSemaphore(nameof(TestDrain), maxCount: 4); - - var handles = new List { semaphore.Acquire(LongTimeout) }; - Assert.DoesNotThrow(() => semaphore2.Acquire().Dispose()); - while (handles.Count < 4) { handles.Add(semaphore.Acquire(LongTimeout)); } - - semaphore2.TryAcquire().ShouldEqual(null); - semaphore.TryAcquire().ShouldEqual(null); - - handles[0].Dispose(); - Assert.DoesNotThrow(() => semaphore2.Acquire().Dispose()); - - handles.ForEach(h => h.Dispose()); - } - - [Test] - public void TestHighTicketCount() - { - var semaphore = this._semaphoreProvider.CreateSemaphore($"s{new string('o', 1000)} many tickets!", int.MaxValue); - var handles = Enumerable.Range(0, 100) - .Select(_ => semaphore.Acquire(LongTimeout)) - .ToList(); - handles.ForEach(h => h.Dispose()); - } - - [Test] - public void TestSameNameDifferentCounts() - { - // if 2 semaphores have different views of what the max count is, things still kind of - // work. The semaphore with the higher count behaves normally. The semaphore with the lower - // count behaves normally when the number of contenders is below it's count. After that, it - // behaves unpredictably. For example, if we have counts 2 and 3 and the 3-semaphore holds 2 tickets, - // then the 2-semaphore might or might not be able to acquire a ticket depending on whether the - // 3-semaphore holds tickets 1&2 (no), 1&3 (yes), or 2&3 (yes). This test serves to document - // the behavior that is more well-defined - - var semaphore2 = this._semaphoreProvider.CreateSemaphore(nameof(TestSameNameDifferentCounts), 2); - var semaphore3 = this._semaphoreProvider.CreateSemaphore(nameof(TestSameNameDifferentCounts), 3); - - var handle1 = semaphore2.Acquire(LongTimeout); - var handle2 = semaphore3.Acquire(LongTimeout); - var handle3 = semaphore3.Acquire(LongTimeout); - semaphore2.TryAcquire().ShouldEqual(null); - semaphore3.TryAcquire().ShouldEqual(null); - - handle1.Dispose(); - handle1 = semaphore3.Acquire(LongTimeout); - - handle1.Dispose(); - handle2.Dispose(); - handle3.Dispose(); - } - - [Test] - [NonParallelizable] // somewhat perf-sensitive - public void TestSemaphoreParallelism() - { - const int MaxCount = 10; - - var counter = 0; - var maxCounterValue = 0; - var maxCounterValueLock = new object(); - var tasks = Enumerable.Range(1, 100).Select(async _ => - { - var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestSemaphoreParallelism), MaxCount); - using (await semaphore.AcquireAsync()) - { - // increment going in - var currentCounterValue = Interlocked.Increment(ref counter); - - lock (maxCounterValueLock) - { - maxCounterValue = Math.Max(maxCounterValue, currentCounterValue); - } - - // hang out for a bit to ensure concurrency - await Task.Delay(TimeSpan.FromMilliseconds(30)); - - // decrement and return on the way out (returns # inside the lock when this left ... should be 0) - return Interlocked.Decrement(ref counter); - } - }) - .ToList(); - - Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(30)).ShouldEqual(true, this.GetType().Name); - - tasks.ForEach(t => - { - (t.Result >= 0).ShouldEqual(true); - (t.Result <= MaxCount).ShouldEqual(true); - }); - Volatile.Read(ref counter).ShouldEqual(0); - - lock (maxCounterValueLock) - { - maxCounterValue.ShouldEqual(MaxCount, this.GetType().Name + ": should reach the maximum level of allowed concurrency"); - } - } - } -} diff --git a/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs deleted file mode 100644 index 7881fae4..00000000 --- a/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs +++ /dev/null @@ -1,152 +0,0 @@ -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests -{ - public abstract class DistributedUpgradeableReaderWriterLockCoreTestCases - where TLockProvider : TestingUpgradeableReaderWriterLockProvider, new() - where TStrategy : TestingSynchronizationStrategy, new() - { - private TLockProvider _lockProvider = default!; - - [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); - [TearDown] public void TearDown() => this._lockProvider.Dispose(); - - [Test] - public void TestMultipleReadersSingleWriter() - { - IDistributedUpgradeableReaderWriterLock Lock() => - this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestMultipleReadersSingleWriter)); - - using var readHandle1 = Lock().TryAcquireReadLockAsync().AsTask().Result; - Assert.IsNotNull(readHandle1, this.GetType().ToString()); - using var readHandle2 = Lock().TryAcquireReadLock(); - Assert.IsNotNull(readHandle2, this.GetType().ToString()); - - using (var handle = Lock().TryAcquireUpgradeableReadLock()) - { - Assert.IsNotNull(handle); - - using var readHandle3 = Lock().TryAcquireReadLock(); - Assert.IsNotNull(readHandle3); - - Lock().TryAcquireUpgradeableReadLock().ShouldEqual(null); - Lock().TryAcquireWriteLock().ShouldEqual(null); - - readHandle3!.Dispose(); - } - - readHandle1!.Dispose(); - readHandle2!.Dispose(); - - using var upgradeHandle = Lock().TryAcquireUpgradeableReadLock(); - Assert.IsNotNull(upgradeHandle); - } - - [Test] - public void TestUpgradeToWriteLock() - { - IDistributedUpgradeableReaderWriterLock Lock() => - this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestUpgradeToWriteLock)); - - var readHandle = Lock().AcquireReadLock(); - - Task readTask; - using (var upgradeableHandle = Lock().AcquireUpgradeableReadLockAsync().AsTask().Result) - { - upgradeableHandle.TryUpgradeToWriteLock().ShouldEqual(false); // read lock still held - - readHandle.Dispose(); - - upgradeableHandle.TryUpgradeToWriteLock().ShouldEqual(true); - - readTask = Lock().AcquireReadLockAsync().AsTask(); - readTask.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false, "write lock held"); - } - - readTask.Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true, "write lock released"); - readTask.Result.Dispose(); - } - - [Test] - public void TestReaderWriterLockBadArguments() - { - var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestReaderWriterLockBadArguments)); - Assert.Catch(() => @lock.AcquireUpgradeableReadLock(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.AcquireUpgradeableReadLockAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.TryAcquireUpgradeableReadLock(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.TryAcquireUpgradeableReadLockAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => @lock.AcquireUpgradeableReadLock(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.AcquireUpgradeableReadLockAsync(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.TryAcquireUpgradeableReadLock(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => @lock.TryAcquireUpgradeableReadLockAsync(TimeSpan.FromSeconds(int.MaxValue))); - - using var upgradeableHandle = @lock.AcquireUpgradeableReadLock(); - Assert.Catch(() => upgradeableHandle.UpgradeToWriteLock(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => upgradeableHandle.UpgradeToWriteLockAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => upgradeableHandle.TryUpgradeToWriteLock(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => upgradeableHandle.TryUpgradeToWriteLockAsync(TimeSpan.FromSeconds(-2))); - Assert.Catch(() => upgradeableHandle.UpgradeToWriteLock(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => upgradeableHandle.UpgradeToWriteLockAsync(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => upgradeableHandle.TryUpgradeToWriteLock(TimeSpan.FromSeconds(int.MaxValue))); - Assert.Catch(() => upgradeableHandle.TryUpgradeToWriteLockAsync(TimeSpan.FromSeconds(int.MaxValue))); - } - - [Test] - public void TestUpgradeableHandleDisposal() - { - var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestUpgradeableHandleDisposal)); - - var handle = @lock.AcquireUpgradeableReadLock(); - handle.Dispose(); - Assert.DoesNotThrow(() => handle.Dispose()); - Assert.Catch(() => handle.TryUpgradeToWriteLock()); - Assert.Catch(() => handle.TryUpgradeToWriteLockAsync()); - Assert.Catch(() => handle.UpgradeToWriteLock()); - Assert.Catch(() => handle.UpgradeToWriteLockAsync()); - } - - [Test] - public void TestUpgradeableHandleMultipleUpgrades() - { - var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestUpgradeableHandleMultipleUpgrades)); - - using var upgradeHandle = @lock.AcquireUpgradeableReadLock(); - upgradeHandle.UpgradeToWriteLock(); - Assert.Catch(() => upgradeHandle.TryUpgradeToWriteLock()); - } - - [Test] - public async Task TestCanUpgradeHandleWhileMonitoring() - { - var handleLostHelper = this._lockProvider.Strategy.PrepareForHandleLost(); - - var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestCanUpgradeHandleWhileMonitoring)); - - using var handle = await @lock.AcquireUpgradeableReadLockAsync(); - - // start monitoring - using var canceledEvent = new ManualResetEventSlim(initialState: false); - using var registration = handle.HandleLostToken.Register(canceledEvent.Set); - Assert.IsFalse(canceledEvent.Wait(TimeSpan.FromSeconds(.05))); - - Assert.DoesNotThrowAsync(() => handle.UpgradeToWriteLockAsync().AsTask()); - - Assert.IsFalse(canceledEvent.Wait(TimeSpan.FromSeconds(.05))); - - if (handleLostHelper != null) - { - handleLostHelper.Dispose(); - Assert.IsTrue(canceledEvent.Wait(TimeSpan.FromSeconds(10))); - } - - // todo revisit - try { await handle.DisposeAsync(); } - catch { } - } - } -} diff --git a/DistributedLock.Tests/DistributedLock.Tests.csproj b/DistributedLock.Tests/DistributedLock.Tests.csproj deleted file mode 100644 index e167f197..00000000 --- a/DistributedLock.Tests/DistributedLock.Tests.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - - netcoreapp3.1 - net471;netcoreapp3.1 - 8.0 - enable - Medallion.Threading.Tests - true - ..\DistributedLock.snk - true - 1591 - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseDistributedLockProvider.cs b/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseDistributedLockProvider.cs deleted file mode 100644 index 60374e83..00000000 --- a/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseDistributedLockProvider.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Azure.Storage.Blobs; -using Medallion.Threading.Azure; -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; - -namespace Medallion.Threading.Tests.Azure -{ - public sealed class TestingAzureBlobLeaseDistributedLockProvider : TestingLockProvider - { - private readonly HashSet _createdBlobs = new HashSet(); - - public override IDistributedLock CreateLockWithExactName(string name) - { - var client = new BlobClient(AzureCredentials.ConnectionString, this.Strategy.ContainerName, name); - if (this.Strategy.CreateBlobBeforeLockIsCreated) - { - lock (this._createdBlobs) - { - if (this._createdBlobs.Add(client.Uri)) - { - client.Upload(Stream.Null); - } - } - } - return new AzureBlobLeaseDistributedLock(client, this.Strategy.Options); - } - - public override string GetSafeName(string name) => - AzureBlobLeaseDistributedLock.GetSafeName(name, new BlobContainerClient(AzureCredentials.ConnectionString, this.Strategy.ContainerName)); - } -} diff --git a/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs b/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs deleted file mode 100644 index ab7631fe..00000000 --- a/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Specialized; -using Medallion.Threading.Azure; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Numerics; -using System.Security.Cryptography; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Tests.Azure -{ - public sealed class TestingAzureBlobLeaseSynchronizationStrategy : TestingSynchronizationStrategy - { - private readonly DisposableCollection _disposables = new DisposableCollection(); - - private static readonly Action DefaultTestingOptions = o => - // for test speed - o.BusyWaitSleepTime(TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(25)); - - public string ContainerName { get; set; } = AzureCredentials.DefaultBlobContainerName; - - public Action? Options { get; set; } = DefaultTestingOptions; - public bool CreateBlobBeforeLockIsCreated { get; set; } - - public override IDisposable? PrepareForHandleLost() - { - this.Options = o => - { - DefaultTestingOptions(o); - o.RenewalCadence(TimeSpan.FromMilliseconds(10)); - }; - - using var md5 = MD5.Create(); - this.ContainerName = $"distributed-lock-handle-lost-{new BigInteger(md5.ComputeHash(Encoding.UTF8.GetBytes(TargetFramework.Current + TestContext.CurrentContext.Test.FullName))):x}"; - var containerClient = new BlobContainerClient(AzureCredentials.ConnectionString, this.ContainerName); - containerClient.CreateIfNotExists(); - this._disposables.Add(() => containerClient.DeleteIfExists()); - return new HandleLostScope(this.ContainerName); - } - - public override void PrepareForHighContention() - { - this.Options = null; // reduces # of requests under high contention - this.CreateBlobBeforeLockIsCreated = true; - } - - public override void Dispose() - { - try { this._disposables.Dispose(); } - finally { base.Dispose(); } - } - - private class HandleLostScope : IDisposable - { - private readonly string _containerName; - - public HandleLostScope(string containerName) - { - this._containerName = containerName; - } - - public void Dispose() - { - var containerClient = new BlobContainerClient(AzureCredentials.ConnectionString, this._containerName); - foreach (var blob in containerClient.GetBlobs()) - { - var leaseClient = containerClient.GetBlobClient(blob.Name).GetBlobLeaseClient(); - leaseClient.Break(breakPeriod: TimeSpan.Zero); - } - } - } - } -} diff --git a/DistributedLock.Tests/Infrastructure/Data/ConnectionOptions.cs b/DistributedLock.Tests/Infrastructure/Data/ConnectionOptions.cs deleted file mode 100644 index 7044ebe4..00000000 --- a/DistributedLock.Tests/Infrastructure/Data/ConnectionOptions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data.Common; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Tests.Data -{ - /// - /// Determines how an ADO.NET-based distributed lock should manage its connection to the database - /// and its locking strategy - /// - public sealed class TestingDbConnectionOptions - { - public string? ConnectionString { get; set; } - public bool ConnectionStringUseMultiplexing { get; set; } - public bool ConnectionStringUseTransaction { get; set; } - public TimeSpan? ConnectionStringKeepaliveCadence { get; set; } - public DbConnection? Connection { get; set; } - public DbTransaction? Transaction { get; set; } - - public T Create( - Func fromConnectionString, - Func fromConnection, - Func fromTransaction) - { - if (this.ConnectionString != null) - { - return fromConnectionString(this.ConnectionString, (this.ConnectionStringUseMultiplexing, this.ConnectionStringUseTransaction, this.ConnectionStringKeepaliveCadence ?? Timeout.InfiniteTimeSpan)); - } - - if (this.Connection != null) - { - return fromConnection(this.Connection); - } - - if (this.Transaction != null) - { - return fromTransaction(this.Transaction); - } - - throw new InvalidOperationException("should never get here"); - } - } -} diff --git a/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs b/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs deleted file mode 100644 index e02621b8..00000000 --- a/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Text; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Data -{ - /// - /// Abstraction over an ADO.NET client for a database technology - /// - public interface ITestingDb - { - DbConnectionStringBuilder ConnectionStringBuilder { get; } - - // needed since different providers have different names for this key - public int MaxPoolSize { get; set; } - - int MaxApplicationNameLength { get; } - - bool SupportsTransactionScopedSynchronization { get; } - - DbConnection CreateConnection(); - - void ClearPool(DbConnection connection); - - int CountActiveSessions(string applicationName); - - IsolationLevel GetIsolationLevel(DbConnection connection); - } - - /// - /// Interface for the "primary" ADO.NET client for a particular DB backend. For now - /// this is just used to designate Microsoft.Data.SqlClient vs. System.Data.SqlClient - /// - public interface ITestingPrimaryClientDb : ITestingDb - { - Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince = null); - } -} diff --git a/DistributedLock.Tests/Infrastructure/Data/IdleSessionKiller.cs b/DistributedLock.Tests/Infrastructure/Data/IdleSessionKiller.cs deleted file mode 100644 index 08593a63..00000000 --- a/DistributedLock.Tests/Infrastructure/Data/IdleSessionKiller.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data.Common; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Data -{ - internal class IdleSessionKiller : IDisposable - { - private readonly CancellationTokenSource _cancellationTokenSource; - private readonly Task _task; - - public IdleSessionKiller(ITestingPrimaryClientDb db, string applicationName, TimeSpan idleTimeout) - { - this._cancellationTokenSource = new CancellationTokenSource(); - var cancellationToken = this._cancellationTokenSource.Token; - this._task = Task.Run(async () => - { - while (!cancellationToken.IsCancellationRequested) - { - var expirationDate = DateTimeOffset.Now - idleTimeout; - await db.KillSessionsAsync(applicationName, expirationDate); - await Task.Delay(TimeSpan.FromTicks(idleTimeout.Ticks / 2), cancellationToken); - } - }); - } - - public void Dispose() - { - this._cancellationTokenSource.Cancel(); - - // wait and swallow any OCE - try { this._task.Wait(); } - catch when (this._task.IsCanceled) { } - - this._cancellationTokenSource.Dispose(); - } - } -} diff --git a/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs b/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs deleted file mode 100644 index 24690f7f..00000000 --- a/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs +++ /dev/null @@ -1,234 +0,0 @@ -using Medallion.Threading.Internal; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Data.Common; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Tests.Data -{ - /// - /// Determines how an ADO.NET-based synchronization primitive should function - /// - public abstract class TestingDbSynchronizationStrategy : TestingSynchronizationStrategy - { - protected TestingDbSynchronizationStrategy(ITestingDb db) - { - this.Db = db; - } - - public ITestingDb Db { get; } - - public abstract TestingDbConnectionOptions GetConnectionOptions(); - - public string SetUniqueApplicationName(string baseName = "") - { - var applicationName = DistributedLockHelpers.ToSafeName( - $"{(baseName.Length > 0 ? baseName + "_" : string.Empty)}{TestContext.CurrentContext.Test.FullName}_{TargetFramework.Current}", - maxNameLength: this.Db.MaxApplicationNameLength, - s => s - ); - this.Db.ConnectionStringBuilder["Application Name"] = applicationName; - return applicationName; - } - } - - public abstract class TestingDbSynchronizationStrategy : TestingDbSynchronizationStrategy - where TDb : ITestingDb, new() - { - protected TestingDbSynchronizationStrategy() : base(new TDb()) { } - - public new TDb Db => (TDb)base.Db; - - public override void Dispose() - { - // if we have a uniquely-named connection, clear it's pool to avoid "leaking" connections into pools we'll never - // use again - if (!Equals(this.Db.ConnectionStringBuilder["Application Name"], new TDb().ConnectionStringBuilder["Application Name"])) - { - using var connection = this.Db.CreateConnection(); - this.Db.ClearPool(connection); - } - - base.Dispose(); - } - } - - public abstract class TestingConnectionStringSynchronizationStrategy : TestingDbSynchronizationStrategy - // since we're just going to be generating from connection strings, we only care about - // the primary ADO client for the database - where TDb : ITestingPrimaryClientDb, new() - { - protected abstract bool? UseMultiplexingNotTransaction { get; } - public TimeSpan? KeepaliveCadence { get; set; } - - public sealed override TestingDbConnectionOptions GetConnectionOptions() => - new TestingDbConnectionOptions - { - ConnectionString = this.Db.ConnectionStringBuilder.ConnectionString, - ConnectionStringUseMultiplexing = this.UseMultiplexingNotTransaction == true, - ConnectionStringUseTransaction = this.UseMultiplexingNotTransaction == false, - ConnectionStringKeepaliveCadence = this.KeepaliveCadence, - }; - - public sealed override IDisposable? PrepareForHandleLost() => - new HandleLostScope(this.SetUniqueApplicationName(nameof(PrepareForHandleLost)), this.Db); - - private class HandleLostScope : IDisposable - { - private string? _applicationName; - private readonly TDb _db; - - public HandleLostScope(string applicationName, TDb testingDb) - { - this._applicationName = applicationName; - this._db = testingDb; - } - - public void Dispose() - { - var applicationName = Interlocked.Exchange(ref this._applicationName, null); - if (applicationName != null) - { - this._db.KillSessionsAsync(applicationName).Wait(); - } - } - } - } - - public sealed class TestingConnectionMultiplexingSynchronizationStrategy : TestingConnectionStringSynchronizationStrategy - where TDb : ITestingPrimaryClientDb, new() - { - protected override bool? UseMultiplexingNotTransaction => true; - } - - public sealed class TestingOwnedConnectionSynchronizationStrategy : TestingConnectionStringSynchronizationStrategy - where TDb : ITestingPrimaryClientDb, new() - { - protected override bool? UseMultiplexingNotTransaction => null; - } - - public sealed class TestingOwnedTransactionSynchronizationStrategy : TestingConnectionStringSynchronizationStrategy - where TDb : ITestingPrimaryClientDb, new() - { - protected override bool? UseMultiplexingNotTransaction => false; - } - - public abstract class TestingExternalConnectionOrTransactionSynchronizationStrategy : TestingDbSynchronizationStrategy - where TDb : ITestingDb, new() - { - /// - /// Starts a new "ambient" connection or transaction that future locks will be created with - /// - public abstract void StartAmbient(); - - protected abstract void EndAmbient(); - - /// - /// If has been called, returns the current ambient connection - /// - public abstract DbConnection? AmbientConnection { get; } - - public sealed override IDisposable? PrepareForHandleLost() - { - this.StartAmbient(); - return this.AmbientConnection; - } - - public sealed override void PrepareForHandleAbandonment() => this.StartAmbient(); - - public sealed override void PerformAdditionalCleanupForHandleAbandonment() - { - this.AmbientConnection!.Dispose(); - using var connection = this.Db.CreateConnection(); - this.Db.ClearPool(connection); - this.EndAmbient(); - } - } - - public sealed class TestingExternalConnectionSynchronizationStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy - where TDb : ITestingDb, new() - { - private readonly DisposableCollection _disposables = new DisposableCollection(); - private DbConnection? _ambientConnection; - - public override DbConnection? AmbientConnection => this._ambientConnection; - - public override void StartAmbient() - { - // clear first so GetConnectionOptions will make a new connection - this._ambientConnection = null; - - this._ambientConnection = this.GetConnectionOptions().Connection; - } - - protected override void EndAmbient() => this._ambientConnection = null; - - public override TestingDbConnectionOptions GetConnectionOptions() - { - DbConnection connection; - if (this.AmbientConnection != null) - { - connection = this.AmbientConnection; - } - else - { - connection = this.Db.CreateConnection(); - this._disposables.Add(connection); - connection.Open(); - } - return new TestingDbConnectionOptions { Connection = connection }; - } - - public override void Dispose() - { - this._disposables.Dispose(); - base.Dispose(); - } - } - - public sealed class TestingExternalTransactionSynchronizationStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy - where TDb : ITestingDb, new() - { - private readonly DisposableCollection _disposables = new DisposableCollection(); - - public DbTransaction? AmbientTransaction { get; private set; } - public override DbConnection? AmbientConnection => this.AmbientTransaction?.Connection; - - public override void StartAmbient() - { - // clear first so GetConnectionOptions will make a new transaction - this.AmbientTransaction = null; - - this.AmbientTransaction = this.GetConnectionOptions().Transaction; - } - - protected override void EndAmbient() => this.AmbientTransaction = null; - - public override TestingDbConnectionOptions GetConnectionOptions() - { - DbTransaction transaction; - if (this.AmbientTransaction != null) - { - transaction = this.AmbientTransaction; - } - else - { - var connection = this.Db.CreateConnection(); - this._disposables.Add(connection); - connection.Open(); - transaction = connection.BeginTransaction(); - this._disposables.Add(transaction); - } - - return new TestingDbConnectionOptions { Transaction = transaction }; - } - - public override void Dispose() - { - this._disposables.Dispose(); - base.Dispose(); - } - } -} diff --git a/DistributedLock.Tests/Infrastructure/DisposableCollection.cs b/DistributedLock.Tests/Infrastructure/DisposableCollection.cs deleted file mode 100644 index 34b84f4f..00000000 --- a/DistributedLock.Tests/Infrastructure/DisposableCollection.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Tests -{ - internal sealed class DisposableCollection : IDisposable - { - private readonly object _lock = new object(); - private Stack? _resources = new Stack(); - - public void Add(IDisposable resource) - { - lock (this._lock) - { - (this._resources ?? throw new ObjectDisposedException(this.GetType().ToString())) - .Push(resource); - } - } - - public void Add(Action cleanupAction) => this.Add(new ReleaseAction(cleanupAction)); - - public void ClearAndDisposeAll() => this.InternalClearAndDisposeAll(isDispose: false); - - public void Dispose() => this.InternalClearAndDisposeAll(isDispose: true); - - private void InternalClearAndDisposeAll(bool isDispose) - { - lock (this._lock) - { - if (this._resources == null) - { - if (isDispose) { return; } - throw new ObjectDisposedException(this.GetType().ToString()); - } - - var exceptions = new List(); - while (this._resources.Count > 0) - { - try { this._resources.Pop().Dispose(); } - catch (Exception ex) { exceptions.Add(ex); } - } - - if (isDispose) - { - this._resources = null; - } - - if (exceptions.Any()) - { - throw new AggregateException(exceptions).Flatten(); - } - } - } - - private class ReleaseAction : IDisposable - { - private Action? _action; - - public ReleaseAction(Action action) { this._action = action; } - - public void Dispose() => Interlocked.Exchange(ref this._action, null)?.Invoke(); - } - } -} diff --git a/DistributedLock.Tests/Infrastructure/ITestingNameProvider.cs b/DistributedLock.Tests/Infrastructure/ITestingNameProvider.cs deleted file mode 100644 index 6d43e629..00000000 --- a/DistributedLock.Tests/Infrastructure/ITestingNameProvider.cs +++ /dev/null @@ -1,22 +0,0 @@ -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests -{ - public interface ITestingNameProvider - { - string GetSafeName(string name); - } - - internal static class TestingNameProviderExtensions - { - /// - /// Returns a name based on which is "namespaced" by the current - /// test and framework name, thus avoiding potential collisions between test cases - /// - public static string GetUniqueSafeName(this ITestingNameProvider provider, string baseName = "") => - provider.GetSafeName($"{baseName}_{TestContext.CurrentContext.Test.FullName}_{TargetFramework.Current}"); - } -} diff --git a/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs b/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs deleted file mode 100644 index 74d90019..00000000 --- a/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Tests.Data; -using Npgsql; -using NpgsqlTypes; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Text; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Postgres -{ - public sealed class TestingPostgresDb : ITestingPrimaryClientDb - { - internal static readonly string ConnectionString = PostgresCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory); - - private readonly NpgsqlConnectionStringBuilder _connectionStringBuilder = new NpgsqlConnectionStringBuilder(ConnectionString); - - public DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; - - public int MaxPoolSize { get => this._connectionStringBuilder.MaxPoolSize; set => this._connectionStringBuilder.MaxPoolSize = value; } - - // https://til.hashrocket.com/posts/8f87c65a0a-postgresqls-max-identifier-length-is-63-bytes - public int MaxApplicationNameLength => 63; - - /// - /// Technically Postgres does support this through xact advisory lock methods, but it is very unwieldy to use due to the transaction - /// abort semantics and largely unnecessary for our purposes since, unlike SQLServer, a connection-scoped Postgres lock can still - /// participate in an ongoing transaction. - /// - public bool SupportsTransactionScopedSynchronization => false; - - public void ClearPool(DbConnection connection) => NpgsqlConnection.ClearPool((NpgsqlConnection)connection); - - public int CountActiveSessions(string applicationName) - { - Invariant.Require(applicationName.Length <= this.MaxApplicationNameLength); - - using var connection = new NpgsqlConnection(ConnectionString); - connection.Open(); - using var command = connection.CreateCommand(); - command.CommandText = "SELECT COUNT(*)::int FROM pg_stat_activity WHERE application_name = @applicationName"; - command.Parameters.AddWithValue("applicationName", applicationName); - return (int)command.ExecuteScalar(); - } - - public IsolationLevel GetIsolationLevel(DbConnection connection) - { - using var command = connection.CreateCommand(); - // values based on https://www.postgresql.org/docs/12/transaction-iso.html - command.CommandText = "SELECT REPLACE(current_setting('transaction_isolation'), ' ', '')"; - return (IsolationLevel)Enum.Parse(typeof(IsolationLevel), (string)command.ExecuteScalar(), ignoreCase: true); - } - - public DbConnection CreateConnection() => new NpgsqlConnection(this.ConnectionStringBuilder.ConnectionString); - - public async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince) - { - using var connection = new NpgsqlConnection(ConnectionString); - await connection.OpenAsync(); - using var command = connection.CreateCommand(); - // based on https://stackoverflow.com/questions/13236160/is-there-a-timeout-for-idle-postgresql-connections - command.CommandText = @" - SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE application_name = @applicationName - AND ( - @idleSince IS NULL - OR (state = 'idle' AND state_change < @idleSince) - )"; - command.Parameters.AddWithValue("applicationName", applicationName); - command.Parameters.Add(new NpgsqlParameter("idleSince", idleSince ?? DBNull.Value.As()) { NpgsqlDbType = NpgsqlDbType.TimestampTz }); - - await command.ExecuteNonQueryAsync(); - } - } -} diff --git a/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresProviders.cs b/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresProviders.cs deleted file mode 100644 index 769b1bf9..00000000 --- a/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresProviders.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Medallion.Threading.Postgres; -using Medallion.Threading.Tests.Data; -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests.Postgres -{ - public sealed class TestingPostgresDistributedLockProvider : TestingLockProvider - where TStrategy : TestingDbSynchronizationStrategy, new() - { - public override IDistributedLock CreateLockWithExactName(string name) => - this.Strategy.GetConnectionOptions() - .Create( - (connectionString, options) => new PostgresDistributedLock( - new PostgresAdvisoryLockKey(name, allowHashing: false), - connectionString, - ToPostgresOptions(options) - ), - connection => new PostgresDistributedLock(new PostgresAdvisoryLockKey(name, allowHashing: false), connection), - transaction => new PostgresDistributedLock(new PostgresAdvisoryLockKey(name, allowHashing: false), transaction.Connection) - ); - - public override string GetSafeName(string name) => PostgresDistributedLock.GetSafeName(name).ToString(); - - internal static Action ToPostgresOptions((bool useMultiplexing, bool useTransaction, TimeSpan keepaliveCadence) options) => - o => o.UseMultiplexing(options.useMultiplexing).KeepaliveCadence(options.keepaliveCadence); - } - - public sealed class TestingPostgresDistributedReaderWriterLockProvider : TestingReaderWriterLockProvider - where TStrategy : TestingDbSynchronizationStrategy, new() - { - public override IDistributedReaderWriterLock CreateReaderWriterLockWithExactName(string name) => - this.Strategy.GetConnectionOptions() - .Create( - (connectionString, options) => - new PostgresDistributedReaderWriterLock( - new PostgresAdvisoryLockKey(name, allowHashing: false), - connectionString, - TestingPostgresDistributedLockProvider.ToPostgresOptions(options) - ), - connection => new PostgresDistributedReaderWriterLock(new PostgresAdvisoryLockKey(name, allowHashing: false), connection), - transaction => new PostgresDistributedReaderWriterLock(new PostgresAdvisoryLockKey(name, allowHashing: false), transaction.Connection) - ); - - public override string GetSafeName(string name) => PostgresDistributedReaderWriterLock.GetSafeName(name).ToString(); - } -} diff --git a/DistributedLock.Tests/Infrastructure/Shared/AzureCredentials.cs b/DistributedLock.Tests/Infrastructure/Shared/AzureCredentials.cs deleted file mode 100644 index 205539b0..00000000 --- a/DistributedLock.Tests/Infrastructure/Shared/AzureCredentials.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Azure.Storage.Blobs; -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests -{ - public static class AzureCredentials - { - // based on https://docs.microsoft.com/en-us/azure/storage/common/storage-use-emulator#connect-to-the-emulator-account-using-a-shortcut - public const string ConnectionString = "UseDevelopmentStorage=true"; - - public static string DefaultBlobContainerName { get; } = "distributed-lock-" + TargetFramework.Current.Replace('.', '-'); - } -} diff --git a/DistributedLock.Tests/Infrastructure/Shared/PostgresCredentials.cs b/DistributedLock.Tests/Infrastructure/Shared/PostgresCredentials.cs deleted file mode 100644 index 3441ec52..00000000 --- a/DistributedLock.Tests/Infrastructure/Shared/PostgresCredentials.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Npgsql; -using System; -using System.IO; - -namespace Medallion.Threading.Tests -{ - internal static class PostgresCredentials - { - private static (string username, string password) GetCredentials(string baseDirectory) - { - var file = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "credentials", "postgres.txt")); - if (!File.Exists(file)) { throw new InvalidOperationException($"Unable to find postgres credentials file {file}"); } - var lines = File.ReadAllLines(file); - if (lines.Length != 2) { throw new FormatException($"{file} must contain exactly 2 lines of text"); } - return (lines[0], lines[1]); - } - - public static string GetConnectionString(string baseDirectory) - { - var (username, password) = GetCredentials(baseDirectory); - - return new NpgsqlConnectionStringBuilder - { - Port = 5433, - Host = "localhost", - Database = "postgres", - Username = username, - Password = password, - PersistSecurityInfo = true, - ApplicationName = SqlServerCredentials.ApplicationName, - // set a high pool size so that we don't empty the pool through things like lock abandonment tests - MaxPoolSize = 500, - }.ConnectionString; - } - } -} diff --git a/DistributedLock.Tests/Infrastructure/Shared/SqlServerCredentials.cs b/DistributedLock.Tests/Infrastructure/Shared/SqlServerCredentials.cs deleted file mode 100644 index bea982f9..00000000 --- a/DistributedLock.Tests/Infrastructure/Shared/SqlServerCredentials.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests -{ - internal static class SqlServerCredentials - { - public static readonly string ApplicationName = $"{typeof(SqlServerCredentials).Assembly.GetName().Name} ({TargetFramework.Current})"; - - public static readonly string ConnectionString = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder - { - DataSource = @".\SQLEXPRESS", - InitialCatalog = "master", - IntegratedSecurity = true, - ApplicationName = ApplicationName, - // set a high pool size so that we don't empty the pool through things like lock abandonment tests - MaxPoolSize = 10000, - } - .ConnectionString; - } -} diff --git a/DistributedLock.Tests/Infrastructure/Shared/TargetFramework.cs b/DistributedLock.Tests/Infrastructure/Shared/TargetFramework.cs deleted file mode 100644 index d5125901..00000000 --- a/DistributedLock.Tests/Infrastructure/Shared/TargetFramework.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests -{ - internal static class TargetFramework - { - public const string Current = -#if NET471 - "net471"; -#elif NETCOREAPP3_1 - "netcoreapp3.1"; -#endif - } -} diff --git a/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs b/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs deleted file mode 100644 index a2dca553..00000000 --- a/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs +++ /dev/null @@ -1,123 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.Tests.Data; -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Text; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.SqlServer -{ - public interface ITestingSqlServerDb : ITestingDb { } - - public sealed class TestingSqlServerDb : ITestingSqlServerDb, ITestingPrimaryClientDb - { - internal static readonly string ConnectionString = SqlServerCredentials.ConnectionString; - - private readonly Microsoft.Data.SqlClient.SqlConnectionStringBuilder _connectionStringBuilder = - new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(ConnectionString); - - public DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; - - public int MaxPoolSize { get => this._connectionStringBuilder.MaxPoolSize; set => this._connectionStringBuilder.MaxPoolSize = value; } - - // https://stackoverflow.com/questions/5808332/sql-server-maximum-character-length-of-object-names/41502228 - public int MaxApplicationNameLength => 128; - - public bool SupportsTransactionScopedSynchronization => true; - - public void ClearPool(DbConnection connection) => Microsoft.Data.SqlClient.SqlConnection.ClearPool((Microsoft.Data.SqlClient.SqlConnection)connection); - - public int CountActiveSessions(string applicationName) - { - Invariant.Require(applicationName.Length <= this.MaxApplicationNameLength); - - using var connection = new Microsoft.Data.SqlClient.SqlConnection(ConnectionString); - connection.Open(); - using var command = connection.CreateCommand(); - command.CommandText = $@"SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE program_name = @applicationName"; - command.Parameters.AddWithValue("applicationName", applicationName); - return (int)command.ExecuteScalar(); - } - - public IsolationLevel GetIsolationLevel(DbConnection connection) - { - using var command = connection.CreateCommand(); - command.CommandText = @" - SELECT CASE transaction_isolation_level - WHEN 0 THEN 'Unspecified' - WHEN 1 THEN 'ReadUncommitted' - WHEN 2 THEN 'ReadCommitted' - WHEN 3 THEN 'RepeatableRead' - WHEN 4 THEN 'Serializable' - WHEN 5 THEN 'Snapshot' - ELSE 'Unknown' END AS isolationLevel - FROM sys.dm_exec_sessions - WHERE session_id = @@SPID"; - return (IsolationLevel)Enum.Parse(typeof(IsolationLevel), (string)command.ExecuteScalar()); - } - - public DbConnection CreateConnection() => new Microsoft.Data.SqlClient.SqlConnection(this.ConnectionStringBuilder.ConnectionString); - - public async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince) - { - using var connection = new Microsoft.Data.SqlClient.SqlConnection(ConnectionString); - await connection.OpenAsync(); - - var findIdleSessionsCommand = connection.CreateCommand(); - findIdleSessionsCommand.CommandText = @" - SELECT session_id FROM sys.dm_exec_sessions - WHERE session_id != @@SPID - AND program_name = @applicationName - AND ( - @idleSince IS NULL - OR ( - (last_request_start_time IS NULL OR last_request_start_time <= @idleSince) - AND (last_request_end_time IS NULL OR last_request_end_time <= @idleSince) - ) - )"; - findIdleSessionsCommand.Parameters.AddWithValue("applicationName", applicationName); - findIdleSessionsCommand.Parameters.AddWithValue("idleSince", idleSince?.DateTime ?? DBNull.Value.As()).SqlDbType = SqlDbType.DateTime; - - var spidsToKill = new List(); - using (var idleSessionsReader = await findIdleSessionsCommand.ExecuteReaderAsync()) - { - while (await idleSessionsReader.ReadAsync()) - { - spidsToKill.Add(idleSessionsReader.GetInt16(0)); - } - } - - foreach (var spid in spidsToKill) - { - using var killCommand = connection.CreateCommand(); - killCommand.CommandText = "KILL " + spid; - try { await killCommand.ExecuteNonQueryAsync(); } - catch (Exception ex) { Console.WriteLine($"Failed to kill {spid}: {ex}"); } - } - } - } - - public sealed class TestingSystemDataSqlServerDb : ITestingSqlServerDb - { - private readonly System.Data.SqlClient.SqlConnectionStringBuilder _connectionStringBuilder = - new System.Data.SqlClient.SqlConnectionStringBuilder(TestingSqlServerDb.ConnectionString); - - public DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; - - public int MaxPoolSize { get => this._connectionStringBuilder.MaxPoolSize; set => this._connectionStringBuilder.MaxPoolSize = value; } - - public int MaxApplicationNameLength => new TestingSqlServerDb().MaxApplicationNameLength; - - public bool SupportsTransactionScopedSynchronization => true; - - public void ClearPool(DbConnection connection) => System.Data.SqlClient.SqlConnection.ClearPool((System.Data.SqlClient.SqlConnection)connection); - - public int CountActiveSessions(string applicationName) => new TestingSqlServerDb().CountActiveSessions(applicationName); - - public IsolationLevel GetIsolationLevel(DbConnection connection) => new TestingSqlServerDb().GetIsolationLevel(connection); - - public DbConnection CreateConnection() => new System.Data.SqlClient.SqlConnection(this.ConnectionStringBuilder.ConnectionString); - } -} diff --git a/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerProviders.cs b/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerProviders.cs deleted file mode 100644 index 717014f8..00000000 --- a/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerProviders.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Medallion.Threading.SqlServer; -using Medallion.Threading.Tests.Data; - -namespace Medallion.Threading.Tests.SqlServer -{ - public sealed class TestingSqlDistributedLockProvider : TestingLockProvider - where TStrategy : TestingDbSynchronizationStrategy, new() - where TDb : ITestingSqlServerDb, new() - { - public override IDistributedLock CreateLockWithExactName(string name) => - this.Strategy.GetConnectionOptions() - .Create( - (connectionString, options) => new SqlDistributedLock(name, connectionString, ToSqlOptions(options), exactName: true), - connection => new SqlDistributedLock(name, connection, exactName: true), - transaction => new SqlDistributedLock(name, transaction, exactName: true)); - - public override string GetSafeName(string name) => SqlDistributedLock.GetSafeName(name); - - internal static Action ToSqlOptions((bool useMultiplexing, bool useTransaction, TimeSpan keepaliveCadence) options) => - o => o.UseMultiplexing(options.useMultiplexing).UseTransaction(options.useTransaction).KeepaliveCadence(options.keepaliveCadence); - } - - public sealed class TestingSqlDistributedReaderWriterLockProvider : TestingUpgradeableReaderWriterLockProvider - where TStrategy : TestingDbSynchronizationStrategy, new() - where TDb : ITestingSqlServerDb, new() - { - public override IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderWriterLockWithExactName(string name) => - this.Strategy.GetConnectionOptions() - .Create( - (connectionString, options) => - new SqlDistributedReaderWriterLock(name, connectionString, TestingSqlDistributedLockProvider.ToSqlOptions(options), exactName: true), - connection => new SqlDistributedReaderWriterLock(name, connection, exactName: true), - transaction => new SqlDistributedReaderWriterLock(name, transaction, exactName: true)); - - public override string GetSafeName(string name) => SqlDistributedReaderWriterLock.GetSafeName(name); - } - - public sealed class TestingSqlDistributedSemaphoreProvider : TestingSemaphoreProvider - where TStrategy : TestingDbSynchronizationStrategy, new() - where TDb : ITestingSqlServerDb, new() - { - public override SqlDistributedSemaphore CreateSemaphoreWithExactName(string name, int maxCount) => - this.Strategy.GetConnectionOptions() - .Create( - (connectionString, options) => - new SqlDistributedSemaphore(name, maxCount, connectionString, TestingSqlDistributedLockProvider.ToSqlOptions(options)), - connection => new SqlDistributedSemaphore(name, maxCount, connection), - transaction => new SqlDistributedSemaphore(name, maxCount, transaction)); - - public override string GetSafeName(string name) => name ?? throw new ArgumentNullException(nameof(name)); - } -} diff --git a/DistributedLock.Tests/Infrastructure/SupportsContinuousIntegrationAttribute.cs b/DistributedLock.Tests/Infrastructure/SupportsContinuousIntegrationAttribute.cs deleted file mode 100644 index e23268f9..00000000 --- a/DistributedLock.Tests/Infrastructure/SupportsContinuousIntegrationAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests -{ - /// - /// Indicates that a test infrastructure component supports being run in a remote continuous integration environment - /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] - internal class SupportsContinuousIntegrationAttribute : Attribute - { - } -} diff --git a/DistributedLock.Tests/Infrastructure/TestHelper.cs b/DistributedLock.Tests/Infrastructure/TestHelper.cs deleted file mode 100644 index 49e50722..00000000 --- a/DistributedLock.Tests/Infrastructure/TestHelper.cs +++ /dev/null @@ -1,19 +0,0 @@ -using NUnit.Framework; - -namespace Medallion.Threading.Tests -{ - internal static class TestHelper - { - public static T ShouldEqual(this T @this, T that, string? message = null) - { - Assert.AreEqual(actual: @this, expected: that, message: message); - return @this; - } - - public static bool IsHeld(this IDistributedLock @lock) - { - using var handle = @lock.TryAcquire(); - return handle == null; - } - } -} diff --git a/DistributedLock.Tests/Infrastructure/TestingLockProvider.cs b/DistributedLock.Tests/Infrastructure/TestingLockProvider.cs deleted file mode 100644 index 44dbdcab..00000000 --- a/DistributedLock.Tests/Infrastructure/TestingLockProvider.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests -{ - public abstract class TestingLockProvider : ITestingNameProvider, IDisposable - where TStrategy : TestingSynchronizationStrategy, new() - { - private readonly Lazy _lazyStrategy = new Lazy(() => new TStrategy()); - - public virtual TStrategy Strategy => this._lazyStrategy.Value; - - public abstract IDistributedLock CreateLockWithExactName(string name); - public abstract string GetSafeName(string name); - - public virtual string GetCrossProcessLockType() => this.CreateLock(string.Empty).GetType().Name; - public virtual void Dispose() => this.Strategy.Dispose(); - - /// - /// Returns a lock whose name is based on - /// - public IDistributedLock CreateLock(string baseName) => - this.CreateLockWithExactName(this.GetUniqueSafeName(baseName)); - } -} diff --git a/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockAsMutexProvider.cs b/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockAsMutexProvider.cs deleted file mode 100644 index 4538f7d1..00000000 --- a/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockAsMutexProvider.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests -{ - public interface ITestingReaderWriterLockAsMutexProvider - { - public bool DisableUpgradeLock { get; set; } - } - - public sealed class TestingReaderWriterLockAsMutexProvider : TestingLockProvider, ITestingReaderWriterLockAsMutexProvider - where TReaderWriterLockProvider : TestingReaderWriterLockProvider, new() - where TStrategy : TestingSynchronizationStrategy, new() - { - private readonly TReaderWriterLockProvider _readerWriterLockProvider = new TReaderWriterLockProvider(); - - public override TStrategy Strategy => this._readerWriterLockProvider.Strategy; - - public bool DisableUpgradeLock { get; set; } - - public override IDistributedLock CreateLockWithExactName(string name) => - new ReaderWriterLockAsMutex(this._readerWriterLockProvider.CreateReaderWriterLockWithExactName(name), this); - - public override string GetSafeName(string name) => this._readerWriterLockProvider.GetSafeName(name); - - public override string GetCrossProcessLockType() => - this._readerWriterLockProvider.GetCrossProcessLockType(ReaderWriterLockType.Write); - - public override void Dispose() - { - this._readerWriterLockProvider.Dispose(); - base.Dispose(); - } - - private bool GetShouldUseUpgradeLock() - { - return !this.DisableUpgradeLock - // intended to be random yet consistent across runs (assuming no changes) - && (Environment.StackTrace.Length % 2) == 1; - } - - private class ReaderWriterLockAsMutex : IDistributedLock - { - private readonly TestingReaderWriterLockAsMutexProvider _provider; - private readonly IDistributedReaderWriterLock _readerWriterLock; - - public ReaderWriterLockAsMutex(IDistributedReaderWriterLock readerWriterLock, TestingReaderWriterLockAsMutexProvider provider) - { - this._readerWriterLock = readerWriterLock; - this._provider = provider; - } - - string IDistributedLock.Name => this._readerWriterLock.Name; - - bool IDistributedLock.IsReentrant => this._readerWriterLock.IsReentrant; - - IDistributedLockHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => - this.ShouldUseUpgrade(out var upgradeable) - ? upgradeable.AcquireUpgradeableReadLock(timeout, cancellationToken) - : this._readerWriterLock.AcquireWriteLock(timeout, cancellationToken); - - ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.ShouldUseUpgrade(out var upgradeable) - ? upgradeable.AcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask) - : this._readerWriterLock.AcquireWriteLockAsync(timeout, cancellationToken); - - IDistributedLockHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => - this.ShouldUseUpgrade(out var upgradeable) - ? upgradeable.TryAcquireUpgradeableReadLock(timeout, cancellationToken) - : this._readerWriterLock.TryAcquireWriteLock(timeout, cancellationToken); - - ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.ShouldUseUpgrade(out var upgradeable) - ? upgradeable.TryAcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask) - : this._readerWriterLock.TryAcquireWriteLockAsync(timeout, cancellationToken); - - private bool ShouldUseUpgrade(out IDistributedUpgradeableReaderWriterLock upgradeable) - { - if (this._readerWriterLock is IDistributedUpgradeableReaderWriterLock upgradeableLock - && this._provider.GetShouldUseUpgradeLock()) - { - upgradeable = upgradeableLock; - return true; - } - - upgradeable = null!; - return false; - } - } - } -} diff --git a/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockProvider.cs b/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockProvider.cs deleted file mode 100644 index bcaf5abe..00000000 --- a/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockProvider.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests -{ - public abstract class TestingReaderWriterLockProvider : ITestingNameProvider, IDisposable - where TStrategy : TestingSynchronizationStrategy, new() - { - public TStrategy Strategy { get; } = new TStrategy(); - - public abstract IDistributedReaderWriterLock CreateReaderWriterLockWithExactName(string name); - public abstract string GetSafeName(string name); - - public virtual string GetCrossProcessLockType(ReaderWriterLockType type) => - type + this.CreateReaderWriterLock(string.Empty).GetType().Name; - - /// - /// Returns a lock whose name is based on - /// - public IDistributedReaderWriterLock CreateReaderWriterLock(string baseName) => - this.CreateReaderWriterLockWithExactName(this.GetUniqueSafeName(baseName)); - - public void Dispose() => this.Strategy.Dispose(); - } - - public abstract class TestingUpgradeableReaderWriterLockProvider : TestingReaderWriterLockProvider - where TStrategy : TestingSynchronizationStrategy, new() - { - public abstract IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderWriterLockWithExactName(string name); - - public sealed override IDistributedReaderWriterLock CreateReaderWriterLockWithExactName(string name) => - this.CreateUpgradeableReaderWriterLockWithExactName(name); - - /// - /// Returns a lock whose name is based on - /// - public IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderWriterLock(string baseName) => - this.CreateUpgradeableReaderWriterLockWithExactName(this.GetUniqueSafeName(baseName)); - } - - public enum ReaderWriterLockType - { - Read, - Write, - Upgrade, - } -} diff --git a/DistributedLock.Tests/Infrastructure/TestingSemaphoreAsMutexProvider.cs b/DistributedLock.Tests/Infrastructure/TestingSemaphoreAsMutexProvider.cs deleted file mode 100644 index 58a36f67..00000000 --- a/DistributedLock.Tests/Infrastructure/TestingSemaphoreAsMutexProvider.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Medallion.Threading.Internal; - -namespace Medallion.Threading.Tests -{ - public abstract class TestingSemaphoreAsMutexProvider : TestingLockProvider - where TSemaphoreProvider : TestingSemaphoreProvider, new() - where TStrategy : TestingSynchronizationStrategy, new() - { - private readonly TSemaphoreProvider _semaphoreProvider = new TSemaphoreProvider(); - private readonly DisposableCollection _disposables = new DisposableCollection(); - private readonly HashSet _mostlyDrainedSemaphoreNames = new HashSet(); - private readonly int _maxCount; - - protected TestingSemaphoreAsMutexProvider(int maxCount) - { - this._maxCount = maxCount; - this._disposables.Add(this._semaphoreProvider); - } - - public override TStrategy Strategy => this._semaphoreProvider.Strategy; - - public override string GetCrossProcessLockType() => $"{this._semaphoreProvider.GetCrossProcessLockType()}{this._maxCount}AsMutex"; - - public override IDistributedLock CreateLockWithExactName(string name) - { - var semaphore = this._semaphoreProvider.CreateSemaphoreWithExactName(name, this._maxCount); - lock (this._mostlyDrainedSemaphoreNames) - { - if (!this._mostlyDrainedSemaphoreNames.Contains(name)) - { - this._mostlyDrainedSemaphoreNames.Add(name); - - // If our max count is > 1, we'll acquire the extra tickets such that any resolved semaphore - // functions as a mutex - for (var i = 0; i < this._maxCount - 1; ++i) - { - this._disposables.Add( - semaphore.TryAcquire() - ?? throw new InvalidOperationException($"Failed to take ticket {i} of {semaphore.GetType()} {name}") - ); - } - } - } - - return new SemaphoreAsMutex(semaphore); - } - - public override string GetSafeName(string name) => this._semaphoreProvider.GetSafeName(name); - - public override void Dispose() - { - this._disposables.Dispose(); - base.Dispose(); - } - - private class SemaphoreAsMutex : IDistributedLock - { - private readonly Threading.SqlServer.SqlDistributedSemaphore _semaphore; - - public SemaphoreAsMutex(Threading.SqlServer.SqlDistributedSemaphore semaphore) - { - this._semaphore = semaphore; - } - - string IDistributedLock.Name => throw new NotImplementedException(); - - bool IDistributedLock.IsReentrant => throw new NotImplementedException(); - - IDistributedLockHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => - this._semaphore.Acquire(timeout, cancellationToken); - - ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this._semaphore.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - - IDistributedLockHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => - this._semaphore.TryAcquire(timeout, cancellationToken); - - ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this._semaphore.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - } - } - - public sealed class TestingSemaphore1AsMutexProvider : TestingSemaphoreAsMutexProvider - where TSemaphoreProvider : TestingSemaphoreProvider, new() - where TStrategy : TestingSynchronizationStrategy, new() - { - public TestingSemaphore1AsMutexProvider() : base(maxCount: 1) { } - } - - public sealed class TestingSemaphore5AsMutexProvider : TestingSemaphoreAsMutexProvider - where TSemaphoreProvider : TestingSemaphoreProvider, new() - where TStrategy : TestingSynchronizationStrategy, new() - { - public TestingSemaphore5AsMutexProvider() : base(maxCount: 5) { } - } -} diff --git a/DistributedLock.Tests/Infrastructure/TestingSemaphoreProvider.cs b/DistributedLock.Tests/Infrastructure/TestingSemaphoreProvider.cs deleted file mode 100644 index c3b981bf..00000000 --- a/DistributedLock.Tests/Infrastructure/TestingSemaphoreProvider.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests -{ - public abstract class TestingSemaphoreProvider : ITestingNameProvider, IDisposable - where TStrategy : TestingSynchronizationStrategy, new() - { - public TStrategy Strategy { get; } = new TStrategy(); - - public abstract Threading.SqlServer.SqlDistributedSemaphore CreateSemaphoreWithExactName(string name, int maxCount); - public abstract string GetSafeName(string name); - - public virtual string GetCrossProcessLockType() => - this.CreateSemaphoreWithExactName(string.Empty, maxCount: 1).GetType().Name; - - /// - /// Returns a semaphore whose name is based on - /// - public Threading.SqlServer.SqlDistributedSemaphore CreateSemaphore(string baseName, int maxCount) => - this.CreateSemaphoreWithExactName(this.GetUniqueSafeName(baseName), maxCount); - - public void Dispose() => this.Strategy.Dispose(); - } -} diff --git a/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs b/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs deleted file mode 100644 index 0e85eded..00000000 --- a/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests -{ - /// - /// Manages the underlying approach to synchronization. Having this class allows us to parameterize tests by - /// synchronization strategy (e. g. only connection string-based strategies) - /// - public abstract class TestingSynchronizationStrategy : IDisposable - { - public virtual void PrepareForHandleAbandonment() { } - public virtual void PerformAdditionalCleanupForHandleAbandonment() { } - public virtual IDisposable? PrepareForHandleLost() => null; - public virtual void PrepareForHighContention() { } - public virtual void Dispose() { } - } -} diff --git a/DistributedLock.Tests/Infrastructure/WaitHandles/TestingEventWaitHandleDistributedLockProvider.cs b/DistributedLock.Tests/Infrastructure/WaitHandles/TestingEventWaitHandleDistributedLockProvider.cs deleted file mode 100644 index feb54e75..00000000 --- a/DistributedLock.Tests/Infrastructure/WaitHandles/TestingEventWaitHandleDistributedLockProvider.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Medallion.Threading.WaitHandles; -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests.WaitHandles -{ - [SupportsContinuousIntegration] - public sealed class TestingEventWaitHandleDistributedLockProvider : TestingLockProvider - { - public override IDistributedLock CreateLockWithExactName(string name) => new EventWaitHandleDistributedLock(name, exactName: true); - - public override string GetSafeName(string name) => EventWaitHandleDistributedLock.GetSafeName(name); - } -} diff --git a/DistributedLock.Tests/Infrastructure/WaitHandles/TestingWaitHandlesSynchronizationStrategy.cs b/DistributedLock.Tests/Infrastructure/WaitHandles/TestingWaitHandlesSynchronizationStrategy.cs deleted file mode 100644 index 7906a10a..00000000 --- a/DistributedLock.Tests/Infrastructure/WaitHandles/TestingWaitHandlesSynchronizationStrategy.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests.WaitHandles -{ - [SupportsContinuousIntegration] - public sealed class TestingWaitHandlesSynchronizationStrategy : TestingSynchronizationStrategy - { - } -} diff --git a/DistributedLock.Tests/Tests/Azure/AzureBehaviorTest.cs b/DistributedLock.Tests/Tests/Azure/AzureBehaviorTest.cs deleted file mode 100644 index 80e355d0..00000000 --- a/DistributedLock.Tests/Tests/Azure/AzureBehaviorTest.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Azure; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Specialized; -using Medallion.Threading.Azure; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Azure -{ - /// - /// Demonstrates various behaviors of Azure blob storage that our implementation relies upon or takes into account - /// - public class AzureBehaviorTest - { - [Test] - public void TestAttemptToLeaseBlobIfDoesNotExist() - { - var blobClient = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, Guid.NewGuid().ToString()); - - Assert.Throws(() => blobClient.GetBlobLeaseClient().Acquire(TimeSpan.FromMinutes(1))) - .ErrorCode.ShouldEqual(AzureErrors.BlobNotFound); - - blobClient = new BlobClient(AzureCredentials.ConnectionString, "dne-container", Guid.NewGuid().ToString()); - Assert.Throws(() => blobClient.GetBlobLeaseClient().Acquire(TimeSpan.FromMinutes(1))) - .ErrorCode.ShouldEqual("ContainerNotFound"); - } - - [Test] - public void TestUploadToWrongBlobType([Values("block", "page", "append")] string actualType) - { - var blobName = Guid.NewGuid().ToString(); - - var createFunctions = new Dictionary - { - ["block"] = () => new BlockBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, blobName).Upload(Stream.Null), - ["page"] = () => new PageBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, blobName).CreateIfNotExists(size: 0), - ["append"] = () => new AppendBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, blobName).CreateIfNotExists() - }; - - // right now append blobs aren't fully supported, so we can't fully test this - if (actualType == "append") - { - Assert.Throws(() => createFunctions[actualType]()) - .ErrorCode.ShouldEqual("FeatureNotSupportedByEmulator"); - return; - } - else - { - createFunctions.Remove("append"); // to prevent it from failing below - } - - var baseClient = new BlobBaseClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, blobName); - - Assert.IsFalse(baseClient.Exists()); - createFunctions[actualType](); - Assert.IsTrue(baseClient.Exists()); - - foreach (var kvp in createFunctions) - { - if (kvp.Key == actualType) - { - Assert.DoesNotThrow(() => kvp.Value()); - } - else - { - Assert.Throws(() => kvp.Value()) - .ErrorCode.ShouldEqual("InvalidBlobType"); - } - } - } - - [Test] - public void TestSlashEquivalence() - { - var name = Guid.NewGuid() + "/a"; - - var blobClient1 = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name); - Assert.IsFalse(blobClient1.Exists()); - blobClient1.Upload(Stream.Null); - Assert.IsTrue(blobClient1.Exists()); - - var blobClient2 = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name.Replace('/', '\\')); - Assert.IsTrue(blobClient2.Exists()); - } - - [Test] - public void TestThrowsIfLeaseAlreadyHeld() - { - var name = nameof(TestThrowsIfLeaseAlreadyHeld) + Guid.NewGuid(); - var client1 = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name); - client1.Upload(Stream.Null); - var client2 = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name); - Assert.DoesNotThrow(() => client1.GetBlobLeaseClient().Acquire(TimeSpan.FromSeconds(15))); - Assert.Throws(() => client2.GetBlobLeaseClient().Acquire(TimeSpan.FromSeconds(15))) - .ErrorCode.ShouldEqual("LeaseAlreadyPresent"); - } - } -} diff --git a/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseDistributedLockTest.cs b/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseDistributedLockTest.cs deleted file mode 100644 index 7712bf26..00000000 --- a/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseDistributedLockTest.cs +++ /dev/null @@ -1,243 +0,0 @@ -using Azure; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using Azure.Storage.Blobs.Specialized; -using Medallion.Threading.Azure; -using Medallion.Threading.Internal; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Azure -{ - public class AzureBlobLeaseDistributedLockTest - { - [Test] - public void TestSafeNaming() - { - var names = new[] - { - string.Empty, - new string('a', 2000), - "a/", - "a\\", - string.Join("/", Enumerable.Repeat("a", 254)), - string.Join(@"\", Enumerable.Repeat("b", 254)), - new string('/', 254), - new string('\\', 254) - }; - - var containerClient = new BlobContainerClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName); - foreach (var name in names) - { - var @lock = new AzureBlobLeaseDistributedLock(containerClient, name); - Assert.DoesNotThrow(() => @lock.Acquire()); - } - } - - [Test] - public async Task TestLockOnDifferentBlobClientTypes( - [Values] BlobClientType type, - [Values] bool isAsync) - { - if (isAsync) - { - await TestAsync(); - } - else - { - SyncOverAsync.Run(_ => TestAsync(), default(object), willGoAsync: false); - } - - async ValueTask TestAsync() - { - using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); - var name = provider.GetUniqueSafeName(); - var client = CreateClient(type, name); - - if (client is AppendBlobClient appendClient) - { - Assert.That( - Assert.Throws(() => appendClient.CreateIfNotExists()).ToString(), - Does.Contain("This feature is not currently supported by the Storage Emulator") - ); - return; - } - if (client.GetType() == typeof(BlobBaseClient)) - { - // work around inability to do CreateIfNotExists for the base client - new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name).Upload(Stream.Null); - } - - var @lock = new AzureBlobLeaseDistributedLock(client); - await using var handle = await @lock.TryAcquireAsync(); - Assert.IsNotNull(handle); - await using var nestedHandle = await @lock.TryAcquireAsync(); - Assert.IsNull(nestedHandle); - } - } - - [Test] - public async Task TestWrapperCreateIfNotExists([Values] BlobClientType type) - { - using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); - var name = provider.GetUniqueSafeName(); - var client = CreateClient(type, name); - var wrapper = new BlobClientWrapper(client); - - var metadata = new Dictionary { ["abc"] = "123" }; - - if (client is AppendBlobClient) - { - Assert.That( - Assert.ThrowsAsync(async () => await wrapper.CreateIfNotExistsAsync(metadata, CancellationToken.None)).ToString(), - Does.Contain("This feature is not currently supported by the Storage Emulator") - ); - return; - } - if (client.GetType() == typeof(BlobBaseClient)) - { - Assert.That( - Assert.ThrowsAsync(async () => await wrapper.CreateIfNotExistsAsync(metadata, CancellationToken.None)).ToString(), - Does.Contain("Either ensure that the blob exists or use a non-base client type") - ); - return; - } - - await wrapper.CreateIfNotExistsAsync(metadata, CancellationToken.None); - Assert.IsTrue((await client.ExistsAsync()).Value); - CollectionAssert.AreEqual(metadata, (await client.GetPropertiesAsync()).Value.Metadata); - - Assert.DoesNotThrowAsync(async () => await wrapper.CreateIfNotExistsAsync(metadata, CancellationToken.None)); - Assert.IsTrue((await client.ExistsAsync()).Value); - } - - [Test] - public void TestCanUseLeaseIdForBlobOperations() - { - using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); - var name = provider.GetUniqueSafeName(); - var client = new PageBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name); - const int BlobSize = 512; - client.Create(size: BlobSize); - var @lock = new AzureBlobLeaseDistributedLock(client); - - using var handle = @lock.Acquire(); - Assert.Throws(() => client.UploadPages(new MemoryStream(new byte[BlobSize]), offset: 0)) - .ErrorCode.ShouldEqual(AzureErrors.LeaseIdMissing); - - Assert.DoesNotThrow( - () => client.UploadPages(new MemoryStream(new byte[BlobSize]), offset: 0, conditions: new PageBlobRequestConditions { LeaseId = handle.LeaseId }) - ); - - handle.Dispose(); - Assert.Throws(() => handle.LeaseId.ToString()); - } - - [Test] - public void TestThrowsIfContainerDoesNotExist() - { - using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); - provider.Strategy.ContainerName = "does-not-exist"; - var @lock = provider.CreateLock(nameof(TestThrowsIfContainerDoesNotExist)); - - Assert.Throws(() => @lock.TryAcquire()?.Dispose()) - .ErrorCode.ShouldEqual("ContainerNotFound"); - } - - [Test] - public void TestCanAcquireIfContainerLeased() - { - using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); - provider.Strategy.ContainerName = "leased-container" + TargetFramework.Current.Replace('.', '-'); - - var containerClient = new BlobContainerClient(AzureCredentials.ConnectionString, provider.Strategy.ContainerName); - var containerLeaseClient = new BlobLeaseClient(containerClient); - try - { - containerClient.CreateIfNotExists(); - containerLeaseClient.Acquire(TimeSpan.FromSeconds(60)); - - var @lock = provider.CreateLock(nameof(TestCanAcquireIfContainerLeased)); - - using var handle = @lock.TryAcquire(); - Assert.IsNotNull(handle); - } - finally - { - try { containerLeaseClient.Release(); } - finally { containerClient.DeleteIfExists(); } - } - } - - [Test] - public async Task TestSuccessfulRenewal() - { - using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); - provider.Strategy.Options = o => o.RenewalCadence(TimeSpan.FromSeconds(.05)); - var @lock = provider.CreateLock(nameof(TestSuccessfulRenewal)); - - using var handle = @lock.Acquire(); - await Task.Delay(TimeSpan.FromSeconds(.2)); // long enough for renewal to run - Assert.DoesNotThrow(handle.Dispose); // observes the result of the renewal task - } - - [Test] - public void TestTriggersHandleLostIfLeaseExpiresNaturally() - { - using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); - provider.Strategy.Options = o => o.RenewalCadence(Timeout.InfiniteTimeSpan).Duration(TimeSpan.FromSeconds(15)); - var @lock = provider.CreateLock(nameof(TestTriggersHandleLostIfLeaseExpiresNaturally)); - - using var handle = @lock.Acquire(); - using var @event = new ManualResetEventSlim(initialState: false); - using var registration = handle.HandleLostToken.Register(@event.Set); - using var faultingRegistration = handle.HandleLostToken.Register(() => throw new TimeZoneNotFoundException()); - - Assert.IsTrue(@event.Wait(TimeSpan.FromSeconds(15.1))); - - Assert.Throws(handle.Dispose) - .ErrorCode.ShouldEqual("LeaseLost"); - } - - [Test] - public void TestExitsDespiteLongSleepTime() - { - using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); - provider.Strategy.Options = o => o.BusyWaitSleepTime(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(1)); - var @lock = provider.CreateLock(nameof(TestExitsDespiteLongSleepTime)); - - using var handle1 = @lock.Acquire(); - - var handle2Task = @lock.TryAcquireAsync(TimeSpan.FromSeconds(2)).AsTask(); - Assert.IsFalse(handle2Task.Wait(TimeSpan.FromSeconds(.05))); - - handle1.Dispose(); - Assert.IsTrue(handle2Task.Wait(TimeSpan.FromSeconds(5))); - } - - private static BlobBaseClient CreateClient([Values] BlobClientType type, string name) => type switch - { - BlobClientType.Basic => new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), - BlobClientType.Block => new BlockBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), - BlobClientType.Page => new PageBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), - BlobClientType.Append => new AppendBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), - BlobClientType.Base => new BlobBaseClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), - _ => throw new ArgumentException(nameof(type)), - }; - - public enum BlobClientType - { - Basic, - Block, - Page, - Append, - Base - } - } -} diff --git a/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseOptionsBuilderTest.cs b/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseOptionsBuilderTest.cs deleted file mode 100644 index c1529bec..00000000 --- a/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseOptionsBuilderTest.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Medallion.Threading.Azure; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; - -namespace Medallion.Threading.Tests.Azure -{ - public class AzureBlobLeaseOptionsBuilderTest - { - [Test] - public void TestValidatesDuration() - { - var builder = new AzureBlobLeaseOptionsBuilder(); - - Assert.DoesNotThrow(() => builder.Duration(TimeSpan.FromSeconds(15))); - Assert.DoesNotThrow(() => builder.Duration(TimeSpan.FromSeconds(60))); - Assert.DoesNotThrow(() => builder.Duration(Timeout.InfiniteTimeSpan)); - Assert.Throws(() => builder.Duration(TimeSpan.FromSeconds(14))); - Assert.Throws(() => builder.Duration(TimeSpan.FromSeconds(61))); - } - - [Test] - public void TestValidatesRenewalCadence() - { - Assert.Throws(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(TimeSpan.FromSeconds(-1)))); - Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(TimeSpan.Zero))); - - Assert.Throws(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(TimeSpan.FromSeconds(30)))); - Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(TimeSpan.FromSeconds(3)))); - Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(Timeout.InfiniteTimeSpan))); - - Assert.Throws(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.Duration(TimeSpan.FromSeconds(60)).RenewalCadence(TimeSpan.FromSeconds(60.1)))); - Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.Duration(TimeSpan.FromSeconds(60)).RenewalCadence(TimeSpan.FromSeconds(59.9)))); - - Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.Duration(Timeout.InfiniteTimeSpan).RenewalCadence(Timeout.InfiniteTimeSpan))); - } - - [Test] - public void TestValidatesBusyWaitSleepTime() - { - var builder = new AzureBlobLeaseOptionsBuilder(); - - Assert.Throws(() => builder.BusyWaitSleepTime(Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(1))); - Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(-1), TimeSpan.FromSeconds(1))); - Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.MaxValue, TimeSpan.FromSeconds(1))); - Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan)); - Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(-1))); - Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(1), TimeSpan.MaxValue)); - - Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(1.1), TimeSpan.FromSeconds(1))); - - Assert.DoesNotThrow(() => builder.BusyWaitSleepTime(TimeSpan.Zero, TimeSpan.Zero)); - Assert.DoesNotThrow(() => builder.BusyWaitSleepTime(TimeSpan.FromMinutes(3), TimeSpan.FromMinutes(4))); - } - } -} diff --git a/DistributedLock.Tests/Tests/Azure/AzureSetUpFixture.cs b/DistributedLock.Tests/Tests/Azure/AzureSetUpFixture.cs deleted file mode 100644 index 3c1a18e5..00000000 --- a/DistributedLock.Tests/Tests/Azure/AzureSetUpFixture.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Azure.Storage.Blobs; -using Medallion.Shell; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text; - -namespace Medallion.Threading.Tests.Azure -{ - [SetUpFixture] - public class AzureSetUpFixture - { - private Command? _azureStorageEmulatorCommand; - - [OneTimeSetUp] - public void OneTimeSetUp() - { - var existingProcesses = Process.GetProcessesByName("AzureStorageEmulator"); - if (existingProcesses.Any()) - { - Console.WriteLine($"Emulator already running (PID={existingProcesses[0].Id})"); - foreach (var process in existingProcesses) { process.Dispose(); } - } - else - { - var emulatorExePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft SDKs", "Azure", "Storage Emulator", "AzureStorageEmulator.exe"); - if (!File.Exists(emulatorExePath)) - { - throw new FileNotFoundException($"Could not locate the AzureStorageEmulator at {emulatorExePath}. This is required to run Azure tests. See https://docs.microsoft.com/en-us/azure/storage/common/storage-use-emulator"); - } - - var command = Command.Run(emulatorExePath, new[] { "start" }, o => o.StartInfo(i => i.RedirectStandardInput = false)) - .RedirectTo(Console.Out) - .RedirectStandardErrorTo(Console.Error); - Console.WriteLine($"Launched AzureStorageEmulator (PID={command.ProcessId})"); - this._azureStorageEmulatorCommand = command; - } - - new BlobContainerClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName).CreateIfNotExists(); - } - - [OneTimeTearDown] - public void OneTimeTearDown() - { - new BlobContainerClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName).DeleteIfExists(); - - if (this._azureStorageEmulatorCommand != null) - { - if (this._azureStorageEmulatorCommand.Task.IsCompleted) - { - throw new InvalidOperationException($"AzureStorageEmulator exited unexpectedly with error code {this._azureStorageEmulatorCommand.Result.ExitCode}"); - } - - this._azureStorageEmulatorCommand.Kill(); - this._azureStorageEmulatorCommand.Wait(); - } - } - } -} diff --git a/DistributedLock.Tests/Tests/Core/DeadlockExceptionTest.cs b/DistributedLock.Tests/Tests/Core/DeadlockExceptionTest.cs deleted file mode 100644 index 85b3f164..00000000 --- a/DistributedLock.Tests/Tests/Core/DeadlockExceptionTest.cs +++ /dev/null @@ -1,28 +0,0 @@ -using NUnit.Framework; -using System; -using System.IO; -using System.Runtime.Serialization.Formatters.Binary; - -namespace Medallion.Threading.Tests.Core -{ - [Category("CI")] - public class DeadlockExceptionTest - { - [Test] - public void TestDeadlockExceptionSerialization() - { - void ThrowDeadlockException() => throw new DeadlockException(nameof(TestDeadlockExceptionSerialization), new InvalidOperationException("foo")); - var deadlockException = Assert.Throws(ThrowDeadlockException); - - var formatter = new BinaryFormatter(); - var stream = new MemoryStream(); - formatter.Serialize(stream, deadlockException); - - stream.Position = 0; - var deserialized = (DeadlockException)formatter.Deserialize(stream); - deserialized.Message.ShouldEqual(deadlockException.Message); - deserialized.StackTrace.ShouldEqual(deadlockException.StackTrace); - (deserialized.InnerException?.Message).ShouldEqual(deadlockException.InnerException?.Message); - } - } -} diff --git a/DistributedLock.Tests/Tests/Core/DistributedLockExtensionsTest.cs b/DistributedLock.Tests/Tests/Core/DistributedLockExtensionsTest.cs deleted file mode 100644 index ab45599d..00000000 --- a/DistributedLock.Tests/Tests/Core/DistributedLockExtensionsTest.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Moq; -using NUnit.Framework; -using System; -using System.Linq.Expressions; - -namespace Medallion.Threading.Tests.Core -{ - public class DistributedLockExtensionsTest - { - [Test] - public void TestArgumentValidation() - { - Assert.Throws(() => DistributedLockProviderExtensions.TryAcquireAsync(null!, "name")); - Assert.Throws(() => DistributedLockProviderExtensions.TryAcquire(null!, "name")); - Assert.Throws(() => DistributedLockProviderExtensions.AcquireAsync(null!, "name")); - Assert.Throws(() => DistributedLockProviderExtensions.Acquire(null!, "name")); - } - - [Test, Combinatorial] - public void TestCallThrough([Values] bool isTry, [Values] bool isAsync) - { - var mockLock = new Mock(); - var mockProvider = new Mock(); - mockProvider.Setup(p => p.CreateLock("name", false)) - .Returns(mockLock.Object) - .Verifiable(); - - if (isTry) - { - if (isAsync) - { - Test(p => p.TryAcquireAsync("name", default, default, false), l => l.TryAcquireAsync(default, default)); - } - else - { - Test(p => p.TryAcquire("name", default, default, false), l => l.TryAcquire(default, default)); - } - } - else - { - if (isAsync) - { - Test(p => p.AcquireAsync("name", default, default, false), l => l.AcquireAsync(default, default)); - } - else - { - Test(p => p.Acquire("name", default, default, false), l => l.Acquire(default, default)); - } - } - - void Test( - Expression> providerFunction, - Expression> lockFunction) - { - providerFunction.Compile()(mockProvider.Object); - - mockProvider.Verify(p => p.CreateLock("name", false), Times.Once); - mockLock.Verify(lockFunction, Times.Once()); - } - } - } -} diff --git a/DistributedLock.Tests/Tests/Core/InternalVisibilityTest.cs b/DistributedLock.Tests/Tests/Core/InternalVisibilityTest.cs deleted file mode 100644 index 9a79b2f4..00000000 --- a/DistributedLock.Tests/Tests/Core/InternalVisibilityTest.cs +++ /dev/null @@ -1,22 +0,0 @@ -using NUnit.Framework; -using System.Linq; - -namespace Medallion.Threading.Tests.Core -{ - [Category("CI")] - public class InternalVisibilityTest - { - [Test] - public void TestInternalNamespaceMethodsHaveCorrectVisibility() - { - var internalNamespaceTypes = typeof(IDistributedLock).Assembly.GetTypes() - .Where(t => t.Namespace?.Contains(".Internal") ?? false) - .ToList(); - Assert.IsNotEmpty(internalNamespaceTypes); - -#if !DEBUG - Assert.IsEmpty(internalNamespaceTypes.Where(t => t.IsPublic)); -#endif - } - } -} diff --git a/DistributedLock.Tests/Tests/Core/SyncOverAsyncTest.cs b/DistributedLock.Tests/Tests/Core/SyncOverAsyncTest.cs deleted file mode 100644 index 15d4d7d3..00000000 --- a/DistributedLock.Tests/Tests/Core/SyncOverAsyncTest.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Medallion.Threading.Internal; -using NUnit.Framework; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Core -{ - [Category("CI")] - public class SyncOverAsyncTest - { - [Test] - public void TestSyncOverAsyncVoid([Values] bool willGoAsync) - { - var currentThread = Thread.CurrentThread; - SyncOverAsync.Run<(int a, int b, Thread startingThread, bool expectAsync)>( - async state => await AddAsync(state.a, state.b, state.startingThread, state.expectAsync), - (1, 2, currentThread, willGoAsync), - willGoAsync: willGoAsync - ); - } - - [Test] - public void TestSyncOverAsyncWithResult([Values] bool willGoAsync) - { - var currentThread = Thread.CurrentThread; - var result = SyncOverAsync.Run( - async ((int a, int b, Thread startingThread, bool expectAsync) state) => await AddAsync(state.a, state.b, state.startingThread, state.expectAsync), - (1, 2, currentThread, willGoAsync), - willGoAsync: willGoAsync - ); - Assert.AreEqual(3, result); - } - - private async ValueTask AddAsync(int a, int b, Thread startingThread, bool expectAsync) - { - var result = await AddHelperAsync(a, expectAsync) + await AddHelperAsync(b, expectAsync); - Assert.AreEqual(expectAsync, Thread.CurrentThread != startingThread); - return result; - } - - private async ValueTask AddHelperAsync(int a, bool expectAsync) - { - Assert.AreNotEqual(expectAsync, SyncOverAsync.IsSynchronous); - - if (expectAsync) { await Task.Delay(1); } - else { Thread.Sleep(1); } - - return a; - } - } -} diff --git a/DistributedLock.Tests/Tests/Core/TimeoutValueTest.cs b/DistributedLock.Tests/Tests/Core/TimeoutValueTest.cs deleted file mode 100644 index 96bc4325..00000000 --- a/DistributedLock.Tests/Tests/Core/TimeoutValueTest.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Medallion.Threading.Internal; -using NUnit.Framework; -using System; -using System.Linq; -using System.Threading; - -namespace Medallion.Threading.Tests.Core -{ - [Category("CI")] - public class TimeoutValueTest - { - [Test] - public void TestArgumentValidation() - { - Assert.Throws(() => new TimeoutValue(TimeSpan.FromMilliseconds(-2))); - Assert.Throws(() => new TimeoutValue(TimeSpan.FromMilliseconds((long)int.MaxValue + 1))); - } - - [Test] - public void TestProperties() - { - Assert.IsTrue(default(TimeoutValue).IsZero); - Assert.IsFalse(default(TimeoutValue).IsInfinite); - Assert.AreEqual(0, default(TimeoutValue).InMilliseconds); - Assert.AreEqual(0, default(TimeoutValue).InSeconds); - - TimeoutValue infinite = Timeout.InfiniteTimeSpan; - Assert.IsFalse(infinite.IsZero); - Assert.IsTrue(infinite.IsInfinite); - Assert.AreEqual(-1, infinite.InMilliseconds); - Assert.Throws(() => infinite.InSeconds.ToString()); - - TimeoutValue normal = TimeSpan.FromSeconds(10.4); - Assert.IsFalse(normal.IsZero); - Assert.IsFalse(normal.IsInfinite); - Assert.AreEqual(10400, normal.InMilliseconds); - Assert.AreEqual(10, normal.InSeconds); - } - - [Test] - public void TestConversion() - { - Assert.AreEqual((TimeoutValue)default(TimeSpan?), new TimeoutValue(Timeout.InfiniteTimeSpan)); - - CheckEquality(Timeout.InfiniteTimeSpan); - CheckEquality(TimeSpan.FromSeconds(101.3)); - CheckEquality(TimeSpan.FromTicks(1)); - CheckEquality(TimeSpan.Zero); - - static void CheckEquality(TimeSpan value) => Assert.AreEqual((int)value.TotalMilliseconds, ((TimeoutValue)value).InMilliseconds); - } - - [Test] - public void TestEquality() - { - var timeSpans = new double[] { Timeout.Infinite, 0, 1, 1000, 10101 }.Select(TimeSpan.FromMilliseconds) - .ToArray(); - - foreach (var a in timeSpans) - { - foreach (var b in timeSpans) - { - TimeoutValue aValue = a, bValue = b; - - if (a == b) - { - Assert.IsTrue(aValue == bValue); - Assert.IsFalse(aValue != bValue); - Assert.IsTrue(aValue.Equals(bValue)); - Assert.IsTrue(aValue.Equals((object)bValue)); - Assert.IsTrue(Equals(aValue, bValue)); - Assert.AreEqual(aValue.GetHashCode(), bValue.GetHashCode()); - } - else - { - Assert.IsFalse(aValue == bValue); - Assert.IsTrue(aValue != bValue); - Assert.IsFalse(aValue.Equals(bValue)); - Assert.IsFalse(aValue.Equals((object)bValue)); - Assert.IsFalse(Equals(aValue, bValue)); - Assert.AreNotEqual(aValue.GetHashCode(), bValue.GetHashCode()); - } - } - } - } - - [Test] - public void TestComparison() - { - new TimeoutValue(Timeout.InfiniteTimeSpan).CompareTo(Timeout.InfiniteTimeSpan).ShouldEqual(0); - new TimeoutValue(TimeSpan.FromSeconds(1)).CompareTo(TimeSpan.FromSeconds(1)).ShouldEqual(0); - - new TimeoutValue(Timeout.InfiniteTimeSpan).CompareTo(TimeSpan.FromMilliseconds(int.MaxValue)).ShouldEqual(1); - new TimeoutValue(TimeSpan.FromMilliseconds(int.MaxValue)).CompareTo(Timeout.InfiniteTimeSpan).ShouldEqual(-1); - - new TimeoutValue(TimeSpan.Zero).CompareTo(TimeSpan.FromSeconds(1)).ShouldEqual(-1); - new TimeoutValue(TimeSpan.FromSeconds(1)).CompareTo(TimeSpan.Zero).ShouldEqual(1); - } - } -} diff --git a/DistributedLock.Tests/Tests/Data/SqlDatabaseConnectionTest.cs b/DistributedLock.Tests/Tests/Data/SqlDatabaseConnectionTest.cs deleted file mode 100644 index 921def0d..00000000 --- a/DistributedLock.Tests/Tests/Data/SqlDatabaseConnectionTest.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Medallion.Threading.Internal; -using Medallion.Threading.SqlServer; -using Medallion.Threading.Tests.SqlServer; -using NUnit.Framework; -using System; -using System.Data.Common; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Data -{ - // todo should this be extended to cover all DatabaseConnections? if not it should move out of Data - public class SqlDatabaseConnectionTest - { - [Test, Combinatorial] - public async Task TestExecuteNonQueryAlreadyCanceled( - [Values] bool isAsync, - [Values] bool isSystemDataSqlClient, - [Values] bool isFastQuery) - { - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); - - await using var connection = CreateConnection(isSystemDataSqlClient); - await connection.OpenAsync(CancellationToken.None); - using var command = connection.CreateCommand(); - command.SetCommandText( - isFastQuery - ? "SELECT 1" - : @"WHILE 1 = 1 - BEGIN - DECLARE @x INT = 1 - END" - ); - - if (isAsync) - { - Assert.CatchAsync(() => command.ExecuteNonQueryAsync(cancellationTokenSource.Token).AsTask()); - } - else - { - Assert.Catch(() => SyncOverAsync.Run(_ => command.ExecuteNonQueryAsync(cancellationTokenSource.Token), 0, false)); - } - } - - [Test, Combinatorial] - public async Task TestExecuteNonQueryCanCancel([Values] bool isAsync, [Values] bool isSystemDataSqlClient) - { - using var cancellationTokenSource = new CancellationTokenSource(); - - await using var connection = CreateConnection(isSystemDataSqlClient); - await connection.OpenAsync(CancellationToken.None); - using var command = connection.CreateCommand(); - command.SetCommandText(@" - WHILE 1 = 1 - BEGIN - DECLARE @x INT = 1 - END" - ); - - var task = Task.Run(async () => - { - if (isAsync) { await command.ExecuteNonQueryAsync(cancellationTokenSource.Token, disallowAsyncCancellation: true); } - else { SyncOverAsync.Run(_ => command.ExecuteNonQueryAsync(cancellationTokenSource.Token), 0, false); } - }); - Assert.IsFalse(task.Wait(TimeSpan.FromSeconds(.1))); - - cancellationTokenSource.Cancel(); - Assert.IsTrue(task.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(5))); - task.Status.ShouldEqual(TaskStatus.Canceled); - } - - private static SqlDatabaseConnection CreateConnection(bool isSystemDataSqlClient) => - new SqlDatabaseConnection( - isSystemDataSqlClient - ? new System.Data.SqlClient.SqlConnection(TestingSqlServerDb.ConnectionString).As() - : new Microsoft.Data.SqlClient.SqlConnection(TestingSqlServerDb.ConnectionString), - isExternallyOwned: false - ); - } -} diff --git a/DistributedLock.Tests/Tests/Postgres/PostgresAdvisoryLockKeyTest.cs b/DistributedLock.Tests/Tests/Postgres/PostgresAdvisoryLockKeyTest.cs deleted file mode 100644 index 1fe37471..00000000 --- a/DistributedLock.Tests/Tests/Postgres/PostgresAdvisoryLockKeyTest.cs +++ /dev/null @@ -1,150 +0,0 @@ -using Medallion.Threading.Postgres; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Medallion.Threading.Tests.Postgres -{ - public class PostgresAdvisoryLockKeyTest - { - [Test] - public void TestArgumentValidation() - { - Assert.Throws(() => new PostgresAdvisoryLockKey(null!)); - Assert.Throws(() => new PostgresAdvisoryLockKey(new string('A', PostgresAdvisoryLockKey.MaxAsciiLength + 1))); - Assert.Throws(() => new PostgresAdvisoryLockKey("漢字")); - } - - [Test] - public void TestDefault() - { - Assert.AreEqual(new string('0', 16), default(PostgresAdvisoryLockKey).ToString()); - Assert.IsTrue(default(PostgresAdvisoryLockKey).HasSingleKey); - Assert.AreEqual(0, default(PostgresAdvisoryLockKey).Key); - AssertEquality(new PostgresAdvisoryLockKey(0), default); - } - - [Test] - public void TestAscii() - { - var emptyKey = AssertRoundTrips(string.Empty); - Assert.IsFalse(emptyKey.HasSingleKey); - - var keys = new HashSet<(int, int)> { emptyKey.Keys }; - for (var i = (char)1; i < 128; ++i) - { - for (var j = 1; j <= PostgresAdvisoryLockKey.MaxAsciiLength; ++j) - { - var key = AssertRoundTrips(new string(i, j)); - Assert.IsFalse(key.HasSingleKey); - Assert.IsTrue(keys.Add(key.Keys)); - } - } - } - - [Test] - public void TestInt64Construction() - { - var key = new PostgresAdvisoryLockKey(1); - Assert.IsTrue(key.HasSingleKey); - Assert.AreEqual(1L, key.Key); - Assert.AreEqual("0000000000000001", key.ToString()); - AssertEquality(key, new PostgresAdvisoryLockKey(key.ToString())); - } - - [Test] - public void TestInt32PairConstruction() - { - var key = new PostgresAdvisoryLockKey(3, -1); - Assert.IsFalse(key.HasSingleKey); - Assert.AreEqual((3, -1), key.Keys); - Assert.AreEqual("00000003,ffffffff", key.ToString()); - AssertEquality(key, new PostgresAdvisoryLockKey(key.ToString())); - } - - [Test] - public void TestNameHashing() - { - var key = new PostgresAdvisoryLockKey(new string('漢', 2 * PostgresAdvisoryLockKey.MaxAsciiLength), allowHashing: true); - Assert.IsTrue(key.HasSingleKey); - Assert.AreEqual(-5707277204051710361, key.Key); - AssertEquality(key, new PostgresAdvisoryLockKey(key.ToString())); - } - - [Test] - public void TestEquality() - { - AssertEquality(new PostgresAdvisoryLockKey(long.MinValue), new PostgresAdvisoryLockKey(long.MinValue)); - AssertInequality(new PostgresAdvisoryLockKey(long.MinValue), new PostgresAdvisoryLockKey(long.MinValue + 1)); - - AssertEquality(new PostgresAdvisoryLockKey(int.MinValue, int.MaxValue), new PostgresAdvisoryLockKey(int.MinValue, int.MaxValue)); - AssertInequality(new PostgresAdvisoryLockKey(int.MinValue, int.MaxValue), new PostgresAdvisoryLockKey(int.MinValue, int.MaxValue - 1)); - - AssertEquality(new PostgresAdvisoryLockKey("base38"), new PostgresAdvisoryLockKey("base38")); - AssertInequality(new PostgresAdvisoryLockKey("base38"), new PostgresAdvisoryLockKey("base37")); - - AssertEquality(new PostgresAdvisoryLockKey("ASCII"), new PostgresAdvisoryLockKey("ASCII")); - AssertInequality(new PostgresAdvisoryLockKey("ASCII"), new PostgresAdvisoryLockKey("ASCIi")); - - AssertEquality(new PostgresAdvisoryLockKey("some very long name", allowHashing: true), new PostgresAdvisoryLockKey("some very long name", allowHashing: true)); - AssertInequality(new PostgresAdvisoryLockKey("some very long name", allowHashing: true), new PostgresAdvisoryLockKey("same very long name", allowHashing: true)); - - var names = new[] { "base38", "base37", "ASCII", "ASCIi", "some very long name", "same very long name" }; - foreach (var name1 in names) - foreach (var name2 in names.Where(n => n != name1)) - { - AssertInequality(new PostgresAdvisoryLockKey(name1, allowHashing: true), new PostgresAdvisoryLockKey(name2, allowHashing: true)); - } - - AssertEquality(new PostgresAdvisoryLockKey(new string('0', 16)), new PostgresAdvisoryLockKey(0)); - AssertEquality(new PostgresAdvisoryLockKey("00000000,00000000"), new PostgresAdvisoryLockKey(0, 0)); - AssertEquality(new PostgresAdvisoryLockKey(new string('\0', PostgresAdvisoryLockKey.MaxAsciiLength)), new PostgresAdvisoryLockKey(0, 0)); - AssertInequality(new PostgresAdvisoryLockKey(0), new PostgresAdvisoryLockKey(0, 0)); - } - - private static void AssertInequality(PostgresAdvisoryLockKey a, PostgresAdvisoryLockKey b) - { - Assert.AreNotEqual(a, b); - Assert.IsFalse(a == b); - Assert.IsTrue(a != b); - Assert.AreNotEqual(a.GetHashCode(), b.GetHashCode()); - if (a.HasSingleKey && b.HasSingleKey) - { - Assert.AreNotEqual(a.Key, b.Key); - } - else if (!a.HasSingleKey && !b.HasSingleKey) - { - Assert.AreNotEqual(a.Keys, b.Keys); - } - } - - private static void AssertEquality(PostgresAdvisoryLockKey a, PostgresAdvisoryLockKey b) - { - Assert.AreEqual(a, b); - Assert.IsTrue(a == b); - Assert.IsFalse(a != b); - Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); - if (a.HasSingleKey) - { - Assert.AreEqual(a.Key, b.Key); - } - else - { - Assert.AreEqual(a.Keys, b.Keys); - } - } - - private static PostgresAdvisoryLockKey AssertRoundTrips(string name) - { - var key1 = new PostgresAdvisoryLockKey(name); - var key2 = new PostgresAdvisoryLockKey(key1.ToString()); - var key3 = new PostgresAdvisoryLockKey(name, allowHashing: true); - AssertEquality(key1, key2); - AssertEquality(key1, key3); - Assert.AreEqual(key1.ToString(), key2.ToString()); - Assert.AreEqual(key1.ToString(), key3.ToString()); - return key1; - } - } -} diff --git a/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs b/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs deleted file mode 100644 index cbfd32f4..00000000 --- a/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs +++ /dev/null @@ -1,168 +0,0 @@ -using Npgsql; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Postgres -{ - /// - /// This class contains tests which demonstrate specific Postgres/Npgsql behaviors which our implementations - /// rely on or account for. These should be tested through the normal set of test cases, but having this here - /// is convenient as a demonstration / documentation - /// - public class PostgresBehaviorTest - { - /// - /// This test justifies why we do not need to have Postgres locks that take in a . - /// Compare this behavior to - /// - [Test] - public async Task TestPostgresCommandAutomaticallyParticipatesInTransaction() - { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); - await connection.OpenAsync(); - - using var transaction = -#if NETCOREAPP3_1 - await connection.BeginTransactionAsync(); -#elif NET471 - connection.BeginTransaction(); -#endif - - using var commandInTransaction = connection.CreateCommand(); - commandInTransaction.Transaction = transaction; - commandInTransaction.CommandText = @"SHOW statement_timeout; CREATE TABLE foo (id INT); SET LOCAL statement_timeout = 2020;"; - (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual("0"); - - using var commandOutsideTransaction = connection.CreateCommand(); - Assert.IsNull(commandOutsideTransaction.Transaction); - commandOutsideTransaction.CommandText = "SELECT COUNT(*) FROM foo"; - (await commandOutsideTransaction.ExecuteScalarAsync()).ShouldEqual(0); - - commandOutsideTransaction.CommandText = "SHOW statement_timeout"; - (await commandOutsideTransaction.ExecuteScalarAsync()).ShouldEqual("2020ms"); - - commandInTransaction.CommandText = "SELECT COUNT(*) FROM foo"; - (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual(0); - - commandInTransaction.CommandText = "SHOW statement_timeout"; - (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual("2020ms"); - } - - [Test] - public Task TestTransactionCancellationRecovery() => - this.TestTransactionCancellationOrTimeoutRecovery(useTimeout: false); - - [Test] - public Task TestTransactionTimeoutRecovery() => - this.TestTransactionCancellationOrTimeoutRecovery(useTimeout: true); - - /// - /// Demonstrates how we can leverage save points to recover from otherwise destroyed transactions - /// - private async Task TestTransactionCancellationOrTimeoutRecovery(bool useTimeout) - { - Assert.ThrowsAsync(() => RunTransactionWithAbortAsync(useSavePoint: false)); - await RunTransactionWithAbortAsync(useSavePoint: true); - - async Task RunTransactionWithAbortAsync(bool useSavePoint) - { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); - await connection.OpenAsync(); - - using (connection.BeginTransaction()) - { - var command = connection.CreateCommand(); - - if (useSavePoint) - { - command.CommandText = "SAVEPOINT cancellationRecovery"; - await command.ExecuteNonQueryAsync(); - } - - command.CommandText = "SELECT pg_sleep(10)"; - using var cancellationTokenSource = new CancellationTokenSource(); - if (useTimeout) { command.CommandText = "SET LOCAL statement_timeout = 100; " + command.CommandText; } - else { cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(.5)); } - - Assert.ThrowsAsync(() => command.ExecuteNonQueryAsync(cancellationTokenSource.Token)); - - if (useSavePoint) - { - command.CommandText = "ROLLBACK TO SAVEPOINT cancellationRecovery"; - await command.ExecuteNonQueryAsync(); - } - - command.CommandText = "SHOW statement_timeout"; - (await command.ExecuteScalarAsync()).ShouldEqual("0"); - } - } - } - - [Test] - public async Task TestCanDetectTransactionWithBeginTransactionException() - { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); - await connection.OpenAsync(); - - Assert.DoesNotThrow(() => connection.BeginTransaction().Dispose()); - - using var transaction = connection.BeginTransaction(); - - var ex = Assert.Throws(() => connection.BeginTransaction().Dispose()); - Assert.That(ex.Message, Does.Contain("A transaction is already in progress")); - } - - [Test] - public async Task TestDoesNotDetectConnectionBreakViaState() - { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); - await connection.OpenAsync(); - - using var getPidCommand = connection.CreateCommand(); - getPidCommand.CommandText = "SELECT pg_backend_pid()"; - var pid = (int)(await getPidCommand.ExecuteScalarAsync()); - - var stateChangedEvent = new ManualResetEventSlim(initialState: false); - connection.StateChange += (_, _2) => stateChangedEvent.Set(); - - // kill the connection from the back end - using var killingConnection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); - await killingConnection.OpenAsync(); - using var killCommand = killingConnection.CreateCommand(); - killCommand.CommandText = $"SELECT pg_terminate_backend({pid})"; - await killCommand.ExecuteNonQueryAsync(); - - Assert.IsFalse(stateChangedEvent.Wait(TimeSpan.FromSeconds(.1))); - - Assert.Throws(() => getPidCommand.ExecuteScalar()); - Assert.IsTrue(stateChangedEvent.Wait(TimeSpan.FromSeconds(5))); - } - - // replicates https://github.com/npgsql/npgsql/issues/2912 - [Test] - public async Task TestPrepareThrowsNullReferenceExceptionOnTerminatedConnection() - { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); - await connection.OpenAsync(); - - using var getPidCommand = connection.CreateCommand(); - getPidCommand.CommandText = "SELECT pg_backend_pid()"; - var pid = (int)(await getPidCommand.ExecuteScalarAsync()); - - // kill the connection from the back end - using var killingConnection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); - await killingConnection.OpenAsync(); - using var killCommand = killingConnection.CreateCommand(); - killCommand.CommandText = $"SELECT pg_terminate_backend({pid})"; - await killCommand.ExecuteNonQueryAsync(); - - await Task.Delay(10); - - Assert.ThrowsAsync(() => getPidCommand.PrepareAsync()); - } - } -} diff --git a/DistributedLock.Tests/Tests/Postgres/PostgresConnectionOptionsBuilderTest.cs b/DistributedLock.Tests/Tests/Postgres/PostgresConnectionOptionsBuilderTest.cs deleted file mode 100644 index 4c051aa2..00000000 --- a/DistributedLock.Tests/Tests/Postgres/PostgresConnectionOptionsBuilderTest.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Medallion.Threading.Postgres; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests.Postgres -{ - public class PostgresConnectionOptionsBuilderTest - { - [Test] - public void TestValidatesArguments() - { - var builder = new PostgresConnectionOptionsBuilder(); - Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.FromMilliseconds(-2))); - Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.MaxValue)); - } - - [Test] - public void TestDefaults() - { - var options = PostgresConnectionOptionsBuilder.GetOptions(null); - Assert.IsTrue(options.keepaliveCadence.IsInfinite); - Assert.IsTrue(options.useMultiplexing); - options.ShouldEqual(PostgresConnectionOptionsBuilder.GetOptions(o => { })); - } - } -} diff --git a/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs b/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs deleted file mode 100644 index 148529c9..00000000 --- a/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Medallion.Threading.Postgres; -using Npgsql; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Data.Common; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.Postgres -{ - public class PostgresDistributedLockTest - { - // todo promote some cases to abstract postgres or general cases - - [Test] - public async Task TestInt64AndInt32PairKeyNamespacesAreDifferent() - { - var connectionString = TestingPostgresDb.ConnectionString; - var key1 = new PostgresAdvisoryLockKey(0); - var key2 = new PostgresAdvisoryLockKey(0, 0); - var @lock1 = new PostgresDistributedLock(key1, connectionString); - var @lock2 = new PostgresDistributedLock(key2, connectionString); - - using var handle1 = await lock1.TryAcquireAsync(); - Assert.IsNotNull(handle1); - - using var handle2 = await lock2.TryAcquireAsync(); - Assert.IsNotNull(handle2); - } - - [Test] - public async Task TestWorksWithAmbientTransaction() - { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); - await connection.OpenAsync(); - - var connectionLock = new PostgresDistributedLock(new PostgresAdvisoryLockKey("AmbTrans"), connection); - var otherLock = new PostgresDistributedLock(connectionLock.Key, TestingPostgresDb.ConnectionString); - using var otherLockHandle = await otherLock.AcquireAsync(); - - using (var transaction = connection.BeginTransaction()) - { - using var transactionCommand = connection.CreateCommand(); - transactionCommand.Transaction = transaction; - - transactionCommand.CommandText = "SET LOCAL statement_timeout = 1010"; - await transactionCommand.ExecuteNonQueryAsync(); - - using (var timedOutHandle = await connectionLock.TryAcquireAsync(TimeSpan.FromSeconds(.2))) - { - Assert.IsNull(timedOutHandle); - } - - (await GetTimeoutAsync(transactionCommand)).ShouldEqual("1010ms"); - - var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(.3)); - var task = connectionLock.AcquireAsync(cancellationToken: cancellationTokenSource.Token).AsTask(); - task.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(5)).ShouldEqual(true); - task.Status.ShouldEqual(TaskStatus.Canceled); - - (await GetTimeoutAsync(transactionCommand)).ShouldEqual("1010ms"); - } - - using var connectionCommand = connection.CreateCommand(); - (await GetTimeoutAsync(connectionCommand)).ShouldEqual("0"); - - static Task GetTimeoutAsync(NpgsqlCommand command) - { - command.CommandText = "SHOW statement_timeout"; - return command.ExecuteScalarAsync(); - } - } - - // todo idle pruning interval? - } -} diff --git a/DistributedLock.Tests/Tests/SqlServer/SqlConnectionOptionsBuilderTest.cs b/DistributedLock.Tests/Tests/SqlServer/SqlConnectionOptionsBuilderTest.cs deleted file mode 100644 index d8ac6c79..00000000 --- a/DistributedLock.Tests/Tests/SqlServer/SqlConnectionOptionsBuilderTest.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Medallion.Threading.SqlServer; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Text; - -namespace Medallion.Threading.Tests.SqlServer -{ - public class SqlConnectionOptionsBuilderTest - { - [Test] - public void TestValidatesArguments() - { - var builder = new SqlConnectionOptionsBuilder(); - Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.FromMilliseconds(-2))); - Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.MaxValue)); - - Assert.Throws(() => SqlConnectionOptionsBuilder.GetOptions(o => o.UseMultiplexing().UseTransaction())); - } - - [Test] - public void TestDefaults() - { - var options = SqlConnectionOptionsBuilder.GetOptions(null); - options.keepaliveCadence.ShouldEqual(TimeSpan.FromMinutes(10)); - Assert.IsTrue(options.useMultiplexing); - Assert.IsFalse(options.useTransaction); - options.ShouldEqual(SqlConnectionOptionsBuilder.GetOptions(o => { })); - } - } -} diff --git a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs deleted file mode 100644 index dddd15ab..00000000 --- a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Medallion.Threading.SqlServer; -using Microsoft.Data.SqlClient; -using NUnit.Framework; -using System; -using System.Data; -using System.Threading.Tasks; - -namespace Medallion.Threading.Tests.SqlServer -{ - public class SqlDistributedLockTest - { - [Test] - public void TestBadConstructorArguments() - { - Assert.Catch(() => new SqlDistributedLock(null!, TestingSqlServerDb.ConnectionString)); - Assert.Catch(() => new SqlDistributedLock(null!, TestingSqlServerDb.ConnectionString, exactName: true)); - Assert.Catch(() => new SqlDistributedLock("a", default(string)!)); - Assert.Catch(() => new SqlDistributedLock("a", default(IDbTransaction)!)); - Assert.Catch(() => new SqlDistributedLock("a", default(IDbConnection)!)); - Assert.Catch(() => new SqlDistributedLock(new string('a', SqlDistributedLock.MaxNameLength + 1), TestingSqlServerDb.ConnectionString, exactName: true)); - Assert.DoesNotThrow(() => new SqlDistributedLock(new string('a', SqlDistributedLock.MaxNameLength), TestingSqlServerDb.ConnectionString, exactName: true)); - } - - [Test] - public void TestGetSafeLockNameCompat() - { - SqlDistributedLock.GetSafeName("").ShouldEqual(""); - SqlDistributedLock.GetSafeName("abc").ShouldEqual("abc"); - SqlDistributedLock.GetSafeName("\\").ShouldEqual("\\"); - SqlDistributedLock.GetSafeName(new string('a', SqlDistributedLock.MaxNameLength)).ShouldEqual(new string('a', SqlDistributedLock.MaxNameLength)); - SqlDistributedLock.GetSafeName(new string('\\', SqlDistributedLock.MaxNameLength)).ShouldEqual(@"\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"); - SqlDistributedLock.GetSafeName(new string('x', SqlDistributedLock.MaxNameLength + 1)).ShouldEqual("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxA3SOHbN+Zq/qt/fpO9dxauQ3kVj8wfeEbknAYembWJG1Xuf4CL0Dmx3u+dAWHzkFMdjQhlRnlAXtiH7ZMFjjsg=="); - } - - /// - /// This test justifies why we have constructors for SQL Server locks that take in a . - /// Otherwise, you can't have a lock use the same connection as a transaction you're working on. Compare to - /// - /// - [Test] - public async Task TestSqlCommandMustParticipateInTransaction() - { - using var connection = new SqlConnection(TestingSqlServerDb.ConnectionString); - await connection.OpenAsync(); - - using var transaction = connection.BeginTransaction(); - - using var commandInTransaction = connection.CreateCommand(); - commandInTransaction.Transaction = transaction; - commandInTransaction.CommandText = @"CREATE TABLE foo (id INT); SELECT 1"; - (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual(1); - - using var commandOutsideTransaction = connection.CreateCommand(); - commandOutsideTransaction.CommandText = "SELECT 2"; - var exception = Assert.ThrowsAsync(() => commandOutsideTransaction.ExecuteScalarAsync()); - Assert.That(exception.Message, Does.Contain("requires the command to have a transaction when the connection assigned to the command is in a pending local transaction")); - - commandInTransaction.CommandText = "SELECT COUNT(*) FROM foo"; - (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual(0); - } - } -} diff --git a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs deleted file mode 100644 index 14966a4a..00000000 --- a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using NUnit.Framework; -using System; -using System.Data.Common; -using Medallion.Threading.SqlServer; - -namespace Medallion.Threading.Tests.SqlServer -{ - public sealed class SqlDistributedReaderWriterLockTest - { - [Test] - public void TestBadConstructorArguments() - { - Assert.Catch(() => new SqlDistributedReaderWriterLock(null!, TestingSqlServerDb.ConnectionString)); - Assert.Catch(() => new SqlDistributedReaderWriterLock(null!, TestingSqlServerDb.ConnectionString, exactName: true)); - Assert.Catch(() => new SqlDistributedReaderWriterLock("a", default(string)!)); - Assert.Catch(() => new SqlDistributedReaderWriterLock("a", default(DbTransaction)!)); - Assert.Catch(() => new SqlDistributedReaderWriterLock("a", default(DbConnection)!)); - Assert.Catch(() => new SqlDistributedReaderWriterLock(new string('a', SqlDistributedReaderWriterLock.MaxNameLength + 1), TestingSqlServerDb.ConnectionString, exactName: true)); - Assert.DoesNotThrow(() => new SqlDistributedReaderWriterLock(new string('a', SqlDistributedReaderWriterLock.MaxNameLength), TestingSqlServerDb.ConnectionString, exactName: true)); - } - - [Test] - public void TestGetSafeLockNameCompat() - { - SqlDistributedReaderWriterLock.MaxNameLength.ShouldEqual(SqlDistributedLock.MaxNameLength); - - var cases = new[] - { - string.Empty, - "abc", - "\\", - new string('a', SqlDistributedLock.MaxNameLength), - new string('\\', SqlDistributedLock.MaxNameLength), - new string('x', SqlDistributedLock.MaxNameLength + 1) - }; - - foreach (var lockName in cases) - { - // should be compatible with SqlDistributedLock - SqlDistributedReaderWriterLock.GetSafeName(lockName).ShouldEqual(SqlDistributedLock.GetSafeName(lockName)); - } - } - } -} diff --git a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs deleted file mode 100644 index c659bbff..00000000 --- a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Medallion.Threading.SqlServer; -using Microsoft.Data.SqlClient; -using NUnit.Framework; -using System; -using System.Data; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace Medallion.Threading.Tests.SqlServer -{ - public sealed class SqlDistributedSemaphoreTest - { - [Test] - public void TestBadConstructorArguments() - { - Assert.Catch(() => new SqlDistributedSemaphore(null!, 1, TestingSqlServerDb.ConnectionString)); - Assert.Catch(() => new SqlDistributedSemaphore("a", -1, TestingSqlServerDb.ConnectionString)); - Assert.Catch(() => new SqlDistributedSemaphore("a", 0, TestingSqlServerDb.ConnectionString)); - Assert.Catch(() => new SqlDistributedSemaphore("a", 1, default(string)!)); - Assert.Catch(() => new SqlDistributedSemaphore("a", 1, default(IDbConnection)!)); - Assert.Catch(() => new SqlDistributedSemaphore("a", 1, default(IDbTransaction)!)); - - var random = new Random(1234); - var bytes = new byte[10000]; - random.NextBytes(bytes); - Assert.DoesNotThrow(() => new SqlDistributedSemaphore(Encoding.UTF8.GetString(bytes), int.MaxValue, TestingSqlServerDb.ConnectionString)); - } - - [Test] - public void TestNameMangling() - { - static string ToSafeNameChecked(string name) - { - var safeName = SqlSemaphore.ToSafeName(name); - (safeName.Length > 0).ShouldEqual(true, "was: " + safeName); - // max name length here based on constants in SqlSemaphore.cs - (safeName.Length <= (115 - 19)).ShouldEqual(true, "was: " + safeName); - Regex.IsMatch(safeName, @"^[a-zA-Z0-9]+$").ShouldEqual(true, "was: " + safeName); - return safeName; - } - - ToSafeNameChecked(string.Empty); - ToSafeNameChecked("a b"); - ToSafeNameChecked(new string('a', 1000)); - ToSafeNameChecked(string.Join(string.Empty, Enumerable.Range(0, byte.MaxValue).Select(i => (char)i))); - - Assert.AreNotEqual(ToSafeNameChecked(new string('b', 500)), ToSafeNameChecked(new string('b', 499) + "B")); - - ToSafeNameChecked(new string('x', 200)).Length.ShouldEqual(115 - 30); - - Enumerable.Range(0, 1000) - .Select(i => ToSafeNameChecked(i.ToString())) - .Distinct() - .Count() - .ShouldEqual(1000); - } - - [Test] - public void TestNameManglingCompatibility() - { - SqlSemaphore.ToSafeName(string.Empty).ShouldEqual("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855semaphore"); - SqlSemaphore.ToSafeName("a_simple_name").ShouldEqual("a5fsimple5fn5becacaa1afce7173bf71d20caf31364c2b10c21f7490c942fdc45467aba2d2asemaphore"); - SqlSemaphore.ToSafeName("a").ShouldEqual("aca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bbsemaphore"); - SqlSemaphore.ToSafeName("A").ShouldEqual("A559aead08264d5795d3909718cdd05abd49572e84fe55590eef31a88a08fdffdsemaphore"); - SqlSemaphore.ToSafeName("0").ShouldEqual("05feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9semaphore"); - SqlSemaphore.ToSafeName("!?#").ShouldEqual("213f231be5b6313c68d3c674c3b17246eaaa3222fe5bc23d9173ac6f58319c6004d6bfsemaphore"); - SqlSemaphore.ToSafeName(string.Join(string.Empty, Enumerable.Range(0, byte.MaxValue).Select(i => (char)i))) - .ShouldEqual("0123456789ab7fb98786c16c175d232ab161b5e604c5792e6befd4e1e8d4ecac9d568a6db524semaphore"); - } - - [Test] - public void TestTicketsTakenOnBothConnectionAndTransactionForThatConnection() - { - using var connection = new SqlConnection(TestingSqlServerDb.ConnectionString); - connection.Open(); - - var semaphore1 = new SqlDistributedSemaphore( - UniqueSemaphoreName(nameof(TestTicketsTakenOnBothConnectionAndTransactionForThatConnection)), - 2, - connection - ); - var handle1 = semaphore1.Acquire(); - - using var transaction = connection.BeginTransaction(); - var semaphore2 = new SqlDistributedSemaphore( - UniqueSemaphoreName(nameof(TestTicketsTakenOnBothConnectionAndTransactionForThatConnection)), - 2, - transaction - ); - var handle2 = semaphore2.Acquire(); - semaphore2.TryAcquire().ShouldEqual(null); - var ex = Assert.Catch(() => semaphore2.Acquire()); - ex.Message.Contains("Deadlock").ShouldEqual(true, ex.ToString()); - } - - private static string UniqueSemaphoreName(string baseName) => $"{baseName}_{TargetFramework.Current}"; - } -} diff --git a/DistributedLock.Tests/Tests/TestSetupTest.cs b/DistributedLock.Tests/Tests/TestSetupTest.cs deleted file mode 100644 index 5d89f8cd..00000000 --- a/DistributedLock.Tests/Tests/TestSetupTest.cs +++ /dev/null @@ -1,200 +0,0 @@ -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text.RegularExpressions; - -namespace Medallion.Threading.Tests -{ - [Category("CI")] - public class TestSetupTest - { - [Test] - public void VerifyAllTestsAreCreated() - { - var testCaseClasses = this.GetType().Assembly - .GetTypes() - .Where( - t => t.IsAbstract - && t.IsClass - && t.IsGenericTypeDefinition - && t.GetMethods().Any(m => m.GetCustomAttributes(inherit: false).Any(a => a is TestAttribute)) - ) - .ToArray(); - - var expectedTestTypes = testCaseClasses.SelectMany(this.GetPossibleGenericInstantiations) - .ToArray(); - - var combinatorialTestsFile = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "Tests", "CombinatorialTests.cs")); - - var expectedTestContents = -$@"using Medallion.Threading.Tests.Data; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -{string.Join( - Environment.NewLine + Environment.NewLine, - expectedTestTypes.Select(GetTestClassDeclaration) - .GroupBy(t => t.@namespace, t => t.declaration) - .OrderBy(g => g.Key) - .Select(g => -$@"namespace {g.Key} -{{ -{string.Join(Environment.NewLine, g.OrderBy(s => s).Select(s => " " + s))} -}}") -)}"; - - var existingContents = File.Exists(combinatorialTestsFile) ? File.ReadAllText(combinatorialTestsFile) : null; - if (expectedTestContents != existingContents) - { - File.WriteAllText(combinatorialTestsFile, expectedTestContents); - Assert.Fail("Updated " + combinatorialTestsFile - + $"**** EXPECTED **** \r\n{expectedTestContents}\r\n **** FOUND **** {existingContents ?? "NULL"}"); - } - } - - private static (string declaration, string @namespace) GetTestClassDeclaration(Type testClassType) - { - static string GetTestClassName(Type type) - { - return type.IsGenericType - ? $"{RemoveProviderSuffix(RemoveGenericMarkers(type.Name))}_{string.Join("_", type.GetGenericArguments().Select(GetTestClassName))}" - : RemoveProviderSuffix(type.Name); - - static string RemoveProviderSuffix(string name) - { - var ProviderSuffix = "Provider"; - return name.EndsWith(ProviderSuffix) ? name.Substring(0, name.Length - ProviderSuffix.Length) : name; - } - } - - static string GetCSharpName(Type type) - { - return type.IsGenericType - ? $"{RemoveGenericMarkers(type.Name)}<{string.Join(", ", type.GetGenericArguments().Select(GetCSharpName))}>" - : type.Name; - } - - // remove words that are very common and therefore don't add much to the name - var testClassName = Regex.Replace(GetTestClassName(testClassType), "Distributed|Lock|Testing|TestCases", string.Empty) + "Test"; - - var supportsContinousIntegration = testClassType.GetGenericArguments() - .All(a => a.GetCustomAttribute() != null); - - var declaration = $@"{(supportsContinousIntegration ? "[Category(\"CI\")] " : null)}public class {testClassName} : {GetCSharpName(testClassType)} {{ }}"; - - var namespaces = TraverseDepthFirst(testClassType, t => t.GetGenericArguments()) - .Select(t => t.Namespace ?? string.Empty) - .Distinct() - .Where(ns => ns.StartsWith(typeof(TestSetupTest).Namespace!)) - .ToList(); - if (namespaces.Count > 1) { namespaces.RemoveAll(ns => ns == typeof(TestSetupTest).Namespace); } - if (namespaces.Count > 1) { namespaces.RemoveAll(ns => ns.EndsWith(".Data")); } - if (namespaces.Count > 1) { Assert.Fail(string.Join(", ", namespaces)); } - return (declaration, namespaces.Single()); - } - - private static string RemoveGenericMarkers(string name) => Regex.Replace(name, @"`\d+", string.Empty); - - private Type[] GetPossibleGenericInstantiations(Type genericTypeDefinition) - { - var genericParameterTypes = genericTypeDefinition.GetGenericArguments() - .Select(this.GetTypesForGenericParameter) - .ToArray(); - var allCombinations = TraverseDepthFirst( - root: (index: 0, value: Enumerable.Empty()), - children: t => t.index == genericParameterTypes.Length - ? Enumerable.Empty<(int index, IEnumerable value)>() - : genericParameterTypes[t.index].Select(type => (index: t.index + 1, value: t.value.Append(type))) - ) - .Where(t => t.index == genericParameterTypes.Length) - .Select(t => MakeGenericTypeOrDefault(genericTypeDefinition, t.value.ToArray())) - .Where(t => t != null).Select(t => t!) - .ToArray(); - return allCombinations; - } - - private Type[] GetTypesForGenericParameter(Type genericParameter) - { - var constraints = genericParameter.GetGenericParameterConstraints(); - return this.GetType().Assembly - .GetTypes() - // This doesn't support all fancy constraints like class or new() - // see https://stackoverflow.com/questions/4864496/checking-if-an-object-meets-a-generic-parameter-constraint. - // It also does attempt to enforce cross-constraint rules (e. g. T : Foo[V]). The idea is to identify cases - // that might match - .Where(t => !t.IsNestedPrivate && !t.IsAbstract && constraints.All(c => IsDerivedFromOrDerivedFromGenericOf(derived: t, @base: c))) - .SelectMany(t => t.IsGenericTypeDefinition ? this.GetPossibleGenericInstantiations(t) : new[] { t }) - .ToArray(); - } - - /// - /// Attempts to construct a generic type. While we do filter down the types we try based on the generic constraints, - /// we currently make no attempt to do cross-generic-parameter optimization such as when one generic constraint is - /// dependent on another generic parameter (e. g. T : Foo[V]). In these cases, we fall back to the native validation - /// - private static Type? MakeGenericTypeOrDefault(Type genericTypeDefininition, Type[] genericArguments) - { - try { return genericTypeDefininition.MakeGenericType(genericArguments); } - catch (ArgumentException) { return null; } - } - - private static bool IsDerivedFromOrDerivedFromGenericOf(Type derived, Type @base) - { - if (@base.IsAssignableFrom(derived)) { return true; } - if (!@base.IsGenericType) { return false; } - - var baseDefinition = @base.GetGenericTypeDefinition(); - return TraverseAlong(derived, t => t.BaseType) - .Concat(derived.GetInterfaces()) - .Any(t => t.IsConstructedGenericType && t.GetGenericTypeDefinition() == baseDefinition); - } - - // simplified versions of Traverse methods since Traverse is not strong-named - - private static IEnumerable TraverseDepthFirst(T root, Func> children) - { - yield return root; - - var stack = new Stack>(); - stack.Push(children(root).GetEnumerator()); - - try - { - while (true) - { - if (stack.Peek().MoveNext()) - { - yield return stack.Peek().Current; - stack.Push(children(stack.Peek().Current).GetEnumerator()); - } - else - { - stack.Peek().Dispose(); - stack.Pop(); - if (stack.Count == 0) { break; } - } - } - } - finally - { - while (stack.Count > 0) { stack.Pop().Dispose(); } - } - } - - private static IEnumerable TraverseAlong(T? root, Func next) - where T : class - { - for (T? node = root; node != null; node = next(node)) - { - yield return node; - } - } - } -} diff --git a/DistributedLock.Tests/Tests/WaitHandles/EventWaitHandleDistributedLockTest.cs b/DistributedLock.Tests/Tests/WaitHandles/EventWaitHandleDistributedLockTest.cs deleted file mode 100644 index 33a8cc35..00000000 --- a/DistributedLock.Tests/Tests/WaitHandles/EventWaitHandleDistributedLockTest.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Medallion.Threading.WaitHandles; -using NUnit.Framework; -using System; - -namespace Medallion.Threading.Tests.WaitHandles -{ - [Category("CI")] - public class EventWaitHandleDistributedLockTest - { - [TestCase(null, NameStyle.Exact, ExpectedResult = typeof(ArgumentNullException))] - [TestCase(null, NameStyle.Safe, ExpectedResult = typeof(ArgumentNullException))] - [TestCase("abc", NameStyle.Exact, ExpectedResult = typeof(FormatException))] - [TestCase(@"gLoBaL\weirdPrefixCasing", NameStyle.Exact, ExpectedResult = typeof(FormatException))] - [TestCase(@"global\weirdPrefixCasing2", NameStyle.Exact, ExpectedResult = typeof(FormatException))] - [TestCase("", NameStyle.AddPrefix, ExpectedResult = typeof(FormatException))] - [TestCase(@"a\b", NameStyle.AddPrefix, ExpectedResult = typeof(FormatException))] - public Type TestBadName(string? name, NameStyle nameStyle) - { - if (name != null) - { - this.TestWorkingName(name, NameStyle.Safe); // should always work - } - - return Assert.Catch(() => CreateLock(name!, nameStyle)).GetType(); - } - - [TestCase(" \t", NameStyle.AddPrefix)] - [TestCase("/a/b/c", NameStyle.AddPrefix)] - [TestCase("\r\n", NameStyle.AddPrefix)] - public void TestWorkingName(string name, NameStyle nameStyle) => - Assert.DoesNotThrow(() => CreateLock(name, nameStyle).Acquire().Dispose()); - - [Test] - public void TestMaxLengthNames() - { - var maxLengthName = EventWaitHandleDistributedLock.GlobalPrefix - + new string('a', EventWaitHandleDistributedLock.MaxNameLength - EventWaitHandleDistributedLock.GlobalPrefix.Length); - this.TestWorkingName(maxLengthName, NameStyle.Exact); - this.TestBadName(maxLengthName + "a", NameStyle.Exact); - } - - [Test] - public void TestGarbageCollection() - { - var @lock = CreateLock("gc_test", NameStyle.AddPrefix); - WeakReference AbandonLock() => new WeakReference(@lock.Acquire()); - - var weakHandle = AbandonLock(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - - weakHandle.IsAlive.ShouldEqual(false); - using var handle = @lock.TryAcquire(); - Assert.IsNotNull(handle); - } - - [Test] - public void TestGetSafeLockNameCompat() - { - // stored separately for testing compat - const int MaxNameLengthWithoutGlobalPrefix = 253; - (EventWaitHandleDistributedLock.MaxNameLength - EventWaitHandleDistributedLock.GlobalPrefix.Length) - .ShouldEqual(MaxNameLengthWithoutGlobalPrefix); - - EventWaitHandleDistributedLock.GetSafeName("").ShouldEqual(@"Global\EMPTYz4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg=="); - EventWaitHandleDistributedLock.GetSafeName("abc").ShouldEqual(@"Global\abc"); - EventWaitHandleDistributedLock.GetSafeName("\\").ShouldEqual(@"Global\_CgzRFsLFf7El/ZraEx9sqWRYeplYohSBSmI9sYIe1c4y2u7ECFoU4x2QCjV7HiVJMZsuDMLIz7r8akpKr+viAw=="); - EventWaitHandleDistributedLock.GetSafeName(new string('a', MaxNameLengthWithoutGlobalPrefix)) - .ShouldEqual(@"Global\" + new string('a', MaxNameLengthWithoutGlobalPrefix)); - EventWaitHandleDistributedLock.GetSafeName(new string('\\', MaxNameLengthWithoutGlobalPrefix)) - .ShouldEqual(@"Global\_____________________________________________________________________________________________________________________________________________________________________Y7DJXlpJeJjeX5XAOWV+ka/3ONBj5dHhKWcSH4pd5AC9YHFm+l1gBArGpBSBn3WcX00ArcDtKw7g24kJaHLifQ=="); - EventWaitHandleDistributedLock.GetSafeName(new string('x', MaxNameLengthWithoutGlobalPrefix + 1)) - .ShouldEqual(@"Global\xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxsrCnXZ1XHiT//dOSBfAU0iC4Gtnlr0dQACBUK8Ev2OdRYJ9jcvbiqVCv/rjyPemTW9AvOonkdr0B2bG04gmeYA=="); - } - - private static EventWaitHandleDistributedLock CreateLock(string name, NameStyle nameStyle) => - new EventWaitHandleDistributedLock( - (nameStyle == NameStyle.AddPrefix ? EventWaitHandleDistributedLock.GlobalPrefix + name : name), - abandonmentCheckCadence: TimeSpan.FromSeconds(.3), - exactName: nameStyle != NameStyle.Safe - ); - - public enum NameStyle - { - Exact, - AddPrefix, - Safe, - } - } -} diff --git a/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.IDistributedLock.cs b/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.IDistributedLock.cs deleted file mode 100644 index d226d583..00000000 --- a/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.IDistributedLock.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Medallion.Threading.Internal; - -namespace Medallion.Threading.WaitHandles -{ - public partial class EventWaitHandleDistributedLock - { - // AUTO-GENERATED - - IDistributedLockHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquire(timeout, cancellationToken); - IDistributedLockHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => - this.Acquire(timeout, cancellationToken); - ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); - - /// - /// Attempts to acquire the lock synchronously. Usage: - /// - /// using (var handle = myLock.TryAcquire(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - public EventWaitHandleDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); - - /// - /// Acquires the lock synchronously, failing with if the attempt times out. Usage: - /// - /// using (myLock.Acquire(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - public EventWaitHandleDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.Acquire(this, timeout, cancellationToken); - - /// - /// Attempts to acquire the lock asynchronously. Usage: - /// - /// await using (var handle = await myLock.TryAcquireAsync(...)) - /// { - /// if (handle != null) { /* we have the lock! */ } - /// } - /// // dispose releases the lock if we took it - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to 0 - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - this.As>().InternalTryAcquireAsync(timeout, cancellationToken); - - /// - /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: - /// - /// await using (await myLock.AcquireAsync(...)) - /// { - /// /* we have the lock! */ - /// } - /// // dispose releases the lock - /// - /// - /// How long to wait before giving up on the acquisition attempt. Defaults to - /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); - } -} \ No newline at end of file diff --git a/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.cs b/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.cs deleted file mode 100644 index 58ab2a65..00000000 --- a/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.cs +++ /dev/null @@ -1,199 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Security.AccessControl; -using System.Security.Principal; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.WaitHandles -{ - /// - /// A distributed lock based on a global on Windows. - /// - public sealed partial class EventWaitHandleDistributedLock : IInternalDistributedLock - { - internal const string GlobalPrefix = @"Global\"; - private static readonly TimeoutValue DefaultAbandonmentCheckCadence = TimeSpan.FromSeconds(2); - - private readonly TimeoutValue _abandonmentCheckCadence; - - /// - /// Constructs a lock with the given . - /// - /// specifies how frequently we refresh our object in case it is abandoned by - /// its original owner. The default is 2s. - /// - /// Unless is specified, will be called on the provided . - /// - public EventWaitHandleDistributedLock(string name, TimeSpan? abandonmentCheckCadence = null, bool exactName = false) - { - if (exactName) - { - if (name == null) { throw new ArgumentNullException(nameof(name)); } - - if (name.Length > MaxNameLength) { throw new FormatException($"{nameof(name)}: must be at most {MaxNameLength} characters"); } - if (!name.StartsWith(GlobalPrefix, StringComparison.Ordinal)) { throw new FormatException($"{nameof(name)}: must start with '{GlobalPrefix}'"); } - if (name == GlobalPrefix) { throw new FormatException($"{nameof(name)} must not be exactly '{GlobalPrefix}'"); } - if (name.IndexOf('\\', startIndex: GlobalPrefix.Length) >= 0) { throw new FormatException(nameof(name) + @": must not contain '\'"); } - - this.Name = name; - } - else - { - this.Name = GetSafeName(name); - } - - if (abandonmentCheckCadence.HasValue) - { - this._abandonmentCheckCadence = new TimeoutValue(abandonmentCheckCadence, nameof(abandonmentCheckCadence)); - if (this._abandonmentCheckCadence.IsZero) { throw new ArgumentOutOfRangeException(nameof(abandonmentCheckCadence), "must not be zero"); } - } - else { this._abandonmentCheckCadence = DefaultAbandonmentCheckCadence; } - } - - /// - /// The maximum allowed length for lock names - /// - // 260 based on LINQPad experimentation - public static int MaxNameLength => 260; - - /// - /// Implements - /// - public string Name { get; } - - bool IDistributedLock.IsReentrant => false; - - /// - /// Equivalent to - /// - public static string GetSafeName(string name) - { - if (name == null) { throw new ArgumentNullException(nameof(name)); } - - // Note: the reason we don't add GlobalPrefix inside the ToSafeLockName callback - // is for backwards compat with the SystemDistributedLock.GetSafeLockName in 1.0. - // In that version, the global prefix was not exposed as part of the name, and as - // such it was not accounted for in the hashing performed by ToSafeLockName. - - if (name.StartsWith(GlobalPrefix, StringComparison.Ordinal)) - { - var suffix = name.Substring(GlobalPrefix.Length); - var safeSuffix = ConvertToSafeSuffix(suffix); - return safeSuffix == suffix ? name : GlobalPrefix + safeSuffix; - } - - return GlobalPrefix + ConvertToSafeSuffix(name); - - static string ConvertToSafeSuffix(string suffix) => DistributedLockHelpers.ToSafeName( - suffix, - MaxNameLength - GlobalPrefix.Length, - s => s.Length == 0 ? "EMPTY" : s.Replace('\\', '_') - ); - } - - async ValueTask IInternalDistributedLock.InternalTryAcquireAsync( - TimeoutValue timeout, - CancellationToken cancellationToken) - { - var @event = this.CreateEvent(); - var cleanup = true; - try - { - if (this._abandonmentCheckCadence.IsInfinite) - { - // no abandonment check: just acquire once - if (await @event.WaitOneAsync(timeout, cancellationToken).ConfigureAwait(false)) - { - cleanup = false; - return new EventWaitHandleDistributedLockHandle(@event); - } - return null; - } - - if (timeout.IsInfinite) - { - // infinite timeout: just loop forever with the abandonment check - while (true) - { - if (await @event.WaitOneAsync(this._abandonmentCheckCadence, cancellationToken).ConfigureAwait(false)) - { - cleanup = false; - return new EventWaitHandleDistributedLockHandle(@event); - } - - // refresh the event in case it was abandoned by the original owner - RefreshEvent(); - } - } - - // fixed timeout: loop in abandonment check chunks - var elapsedMillis = 0; - do - { - var nextWaitMillis = Math.Min(this._abandonmentCheckCadence.InMilliseconds, timeout.InMilliseconds - elapsedMillis); - if (await @event.WaitOneAsync(TimeSpan.FromMilliseconds(nextWaitMillis), cancellationToken).ConfigureAwait(false)) - { - cleanup = false; - return new EventWaitHandleDistributedLockHandle(@event); - } - - elapsedMillis += nextWaitMillis; - - // refresh the event in case it was abandoned by the original owner - RefreshEvent(); - } - while (elapsedMillis < timeout.InMilliseconds); - - return null; - } - catch - { - // just in case we fail to create a scope or something - cleanup = true; - throw; - } - finally - { - if (cleanup) - { - @event.Dispose(); - } - } - - void RefreshEvent() - { - @event.Dispose(); - @event = this.CreateEvent(); - } - } - - private EventWaitHandle CreateEvent() - { - // based on http://stackoverflow.com/questions/2590334/creating-a-cross-process-eventwaithandle - var security = new EventWaitHandleSecurity(); - // allow anyone to wait on and signal this lock - security.AddAccessRule( - new EventWaitHandleAccessRule( - new SecurityIdentifier(WellKnownSidType.WorldSid, domainSid: null), - EventWaitHandleRights.FullControl, // doesn't seem to work without this :-/ - AccessControlType.Allow - ) - ); - - var @event = new EventWaitHandle( - // if we create, start as unlocked - initialState: true, - // allow only one thread to hold the lock - mode: EventResetMode.AutoReset, - name: this.Name, - createdNew: out _ - ); - @event.SetAccessControl(security); - - return @event; - } - - bool IInternalDistributedLock.WillGoAsync(TimeoutValue timeout, CancellationToken cancellationToken) => false; - } -} diff --git a/DistributedLock.WaitHandles/EventWaitHandleDistributedLockHandle.cs b/DistributedLock.WaitHandles/EventWaitHandleDistributedLockHandle.cs deleted file mode 100644 index 248e85b6..00000000 --- a/DistributedLock.WaitHandles/EventWaitHandleDistributedLockHandle.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.WaitHandles -{ - /// - /// See - /// - public sealed class EventWaitHandleDistributedLockHandle : IDistributedLockHandle - { - private EventWaitHandle? _event; - - internal EventWaitHandleDistributedLockHandle(EventWaitHandle @event) - { - this._event = @event; - } - - CancellationToken IDistributedLockHandle.HandleLostToken => - Volatile.Read(ref this._event) != null ? CancellationToken.None : throw this.ObjectDisposed(); - - /// - /// Releases the lock - /// - public void Dispose() - { - var @event = Interlocked.Exchange(ref this._event, null); - if (@event != null) - { - @event.Set(); // signal - @event.Dispose(); - } - } - - /// - /// Releases the lock asynchronously - /// - public ValueTask DisposeAsync() - { - this.Dispose(); - return default; - } - } -} diff --git a/DistributedLock.WaitHandles/WaitHandleExtensions.cs b/DistributedLock.WaitHandles/WaitHandleExtensions.cs deleted file mode 100644 index 155d5719..00000000 --- a/DistributedLock.WaitHandles/WaitHandleExtensions.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Medallion.Threading.Internal; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Threading.WaitHandles -{ - internal static class WaitHandleExtensions - { - public static async ValueTask WaitOneAsync(this WaitHandle waitHandle, TimeoutValue timeout, CancellationToken cancellationToken) - { - return SyncOverAsync.IsSynchronous - ? waitHandle.InternalWaitOne(timeout, cancellationToken) - : await waitHandle.InternalWaitOneAsync(timeout, cancellationToken).ConfigureAwait(false); - } - - private static bool InternalWaitOne(this WaitHandle waitHandle, TimeoutValue timeout, CancellationToken cancellationToken) - { - if (!cancellationToken.CanBeCanceled) - { - return waitHandle.WaitOne(timeout.InMilliseconds); - } - - // if, upon entering the method we are already both canceled and signaled, this check - // ensures that we cancel - cancellationToken.ThrowIfCancellationRequested(); - - // cancellable wait based on - // http://www.thomaslevesque.com/2015/06/04/async-and-cancellation-support-for-wait-handles/ - var index = WaitHandle.WaitAny(new[] { waitHandle, cancellationToken.WaitHandle }, timeout.InMilliseconds); - return index switch - { - // timeout - WaitHandle.WaitTimeout => false, - // event - 0 => true, - // canceled - _ => throw new OperationCanceledException(cancellationToken), - }; - } - - // based on http://www.thomaslevesque.com/2015/06/04/async-and-cancellation-support-for-wait-handles/ - private static async ValueTask InternalWaitOneAsync(this WaitHandle waitHandle, TimeoutValue timeout, CancellationToken cancellationToken) - { - RegisteredWaitHandle? registeredHandle = null; - CancellationTokenRegistration tokenRegistration = default; - try - { - var taskCompletionSource = new TaskCompletionSource(); - // if, upon entering the method we are already both canceled and signaled, - // putting this first ensures that we cancel - tokenRegistration = cancellationToken.Register( - state => ((TaskCompletionSource)state).TrySetCanceled(), - state: taskCompletionSource - ); - registeredHandle = ThreadPool.RegisterWaitForSingleObject( - waitHandle, - (state, timedOut) => ((TaskCompletionSource)state).TrySetResult(!timedOut), - state: taskCompletionSource, - millisecondsTimeOutInterval: timeout.InMilliseconds, - executeOnlyOnce: true - ); - return await taskCompletionSource.Task.ConfigureAwait(false); - } - finally - { - // this is different from the referenced site, but I think this is more correct: - // the handle passed to unregister is a handle to be signaled, not the one to unregister - // (that one is already captured by the registered handle). See - // http://referencesource.microsoft.com/#mscorlib/system/threading/threadpool.cs,065408fc096354fd - registeredHandle?.Unregister(null); - tokenRegistration.Dispose(); - } - } - } -} diff --git a/DistributedLockCodeGen/CodeGenHelpers.cs b/DistributedLockCodeGen/CodeGenHelpers.cs deleted file mode 100644 index 620811a1..00000000 --- a/DistributedLockCodeGen/CodeGenHelpers.cs +++ /dev/null @@ -1,31 +0,0 @@ -using NUnit.Framework; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; - -namespace DistributedLockCodeGen -{ - internal static class CodeGenHelpers - { - public static string SolutionDirectory => Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..")); - - public static IEnumerable EnumerateSolutionFiles() => Directory.EnumerateFiles(SolutionDirectory, "*.csproj", SearchOption.AllDirectories) - .Select(Path.GetDirectoryName) - .Where(d => !Regex.IsMatch(Path.GetFileName(d), "^(DistributedLock|DistributedLock.Tests|DistributedLockCodeGen)$", RegexOptions.IgnoreCase)) - .SelectMany(d => Directory.EnumerateFiles(d, "*.cs", SearchOption.AllDirectories)); - - public static bool HasPublicType(string code, out (string typeName, bool isInterface) info) - { - var match = Regex.Match(code, @"\n( |\t)public.*?(class|interface)\s+(?\w+)"); - if (match.Success) - { - info = (typeName: match.Groups["name"].Value, isInterface: match.Value.Contains("interface")); - return true; - } - - info = default; - return false; - } - } -} diff --git a/DistributedLockCodeGen/DistributedLockCodeGen.csproj b/DistributedLockCodeGen/DistributedLockCodeGen.csproj deleted file mode 100644 index ebf81e62..00000000 --- a/DistributedLockCodeGen/DistributedLockCodeGen.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - netcoreapp3.1 - - false - enable - true - ..\DistributedLock.snk - - - - - - - - - diff --git a/DistributedLockCodeGen/DocCommentGenerator.cs b/DistributedLockCodeGen/DocCommentGenerator.cs deleted file mode 100644 index 63b2dd04..00000000 --- a/DistributedLockCodeGen/DocCommentGenerator.cs +++ /dev/null @@ -1,127 +0,0 @@ -using NUnit.Framework; -using System; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace DistributedLockCodeGen -{ - [Category("CI")] - public class DocCommentGenerator - { - [Test] - public void GenerateDocComments() - { - var changes = CodeGenHelpers.EnumerateSolutionFiles() - .Select(f => (file: f, code: File.ReadAllText(f))) - .Select(t => (t.file, t.code, updatedCode: AddDocComments(t.code))) - .Where(t => t.updatedCode != t.code) - .ToList(); - changes.ForEach(t => File.WriteAllText(t.file, t.updatedCode)); - Assert.IsEmpty(changes.Select(t => t.file)); - } - - internal static string AddDocComments(string code) - { - if (!CodeGenHelpers.HasPublicType(code, out var typeInfo)) { return code; } - - var acquireMethods = Regex.Matches( - code, - $@"(?([ \t]+///.*?\n)*)(? |\t\t){(typeInfo.isInterface ? "" : "public ")}(?\S+) (?([a-zA-Z])+)\(" - + @"((?\S+) (?([a-zA-Z])+)( = \S+)(, )?)+\)" - ); - - var updatedCode = code; - foreach (var acquireMethod in acquireMethods.Cast()) - { - var name = acquireMethod.Groups["name"].Value; - if (!name.Contains("Acquire")) { continue; } - - var isTry = name.StartsWith("Try"); - var isAsync = name.EndsWith("Async"); - var lockType = name.Contains("Upgradeable") ? LockType.Upgrade - : name.Contains("Write") ? LockType.Write - : name.Contains("Read") ? LockType.Read - : typeInfo.typeName.Contains("Semaphore") ? LockType.Semaphore - : LockType.Mutex; - - var @object = lockType == LockType.Semaphore ? "semaphore" : "lock"; - var handleObject = lockType == LockType.Semaphore ? "ticket" : "lock"; - - var lineStart = acquireMethod.Groups["indent"].Value + "/// "; - var docComment = new StringBuilder(); - - docComment.AppendLine(lineStart + ""); - - docComment.Append(lineStart); - if (isTry) { docComment.Append("Attempts to acquire "); } - else { docComment.Append("Acquires "); } - docComment.Append(lockType switch - { - LockType.Read => "a READ lock ", - LockType.Upgrade => "an UPGRADE lock ", - LockType.Write => "a WRITE lock ", - LockType.Mutex => "the lock ", - LockType.Semaphore => "a semaphore ticket ", - _ => throw new NotSupportedException() - }); - docComment.Append(isAsync ? "asynchronously" : "synchronously"); - docComment.Append(isTry ? ". " : ", failing with if the attempt times out. "); - docComment.Append(lockType switch - { - LockType.Read => "Multiple readers are allowed. Not compatible with a WRITE lock. ", - LockType.Upgrade => "Not compatible with another UPGRADE lock or a WRITE lock. ", - LockType.Write => "Not compatible with another WRITE lock or an UPGRADE lock. ", - LockType.Mutex => "", - LockType.Semaphore => "", - _ => throw new NotSupportedException(), - }); - docComment.AppendLine("Usage: "); - - docComment.Append(lineStart).AppendLine(""); - docComment.Append(lineStart).Append($" {(isAsync ? "await " : "")}using (") - .Append(isTry ? "var handle = " : "") - .Append(isAsync ? "await " : "") - .Append($"my{char.ToUpper(@object[0])}{@object.Substring(1)}.") - .AppendLine($"{name}(...))"); - docComment.Append(lineStart).AppendLine(" {"); - docComment.Append(lineStart).Append(' ', 8) - .Append(isTry ? "if (handle != null) { " : "") - .Append($"/* we have the {handleObject}! */") - .AppendLine(isTry ? " }" : ""); - docComment.Append(lineStart).AppendLine(" }"); - docComment.Append(lineStart) - .Append($" // dispose releases the {handleObject}") - .AppendLine(isTry ? " if we took it" : ""); - docComment.Append(lineStart).AppendLine(""); - - docComment.Append(lineStart).AppendLine(""); - - docComment.Append(lineStart).Append("How long to wait before giving up on the acquisition attempt. ") - .AppendLine($"Defaults to {(isTry ? "0" : "")}"); - docComment.Append(lineStart).AppendLine("Specifies a token by which the wait can be canceled"); - - var returnType = acquireMethod.Groups["returnType"].Value; - if (returnType.StartsWith("ValueTask<")) { returnType = returnType.Replace("ValueTask<", "").TrimEnd('>'); } - var useAn = "aeiou".Contains(char.ToLower(returnType[0])); - docComment.Append(lineStart).Append($"A{(useAn ? "n" : "")} which can be used to release the {handleObject}") - .Append(isTry ? " or null on failure" : "") - .AppendLine(""); - - updatedCode = updatedCode.Replace(acquireMethod.Value, docComment + acquireMethod.Value.Substring(acquireMethod.Groups["docComment"].Length)); - } - - return updatedCode; - } - } - - internal enum LockType - { - Mutex, - Read, - Write, - Upgrade, - Semaphore, - } -} diff --git a/DistributedLockCodeGen/GenerateIDistributedLockImplementations.cs b/DistributedLockCodeGen/GenerateIDistributedLockImplementations.cs deleted file mode 100644 index 167e23b5..00000000 --- a/DistributedLockCodeGen/GenerateIDistributedLockImplementations.cs +++ /dev/null @@ -1,200 +0,0 @@ -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace DistributedLockCodeGen -{ - [Category("CI")] - public class GenerateIDistributedLockImplementations - { - [Test] - public void GenerateForIDistributedLock() - { - var files = CodeGenHelpers.EnumerateSolutionFiles() - .Where(f => f.IndexOf("DistributedLock.Core", StringComparison.OrdinalIgnoreCase) < 0) - .Where(f => f.EndsWith("DistributedLock.cs", StringComparison.OrdinalIgnoreCase) && Path.GetFileName(f)[0] != 'I'); - - var errors = new List(); - foreach (var file in files) - { - var lockCode = File.ReadAllText(file); - if (lockCode.Contains("AUTO-GENERATED") - || !CodeGenHelpers.HasPublicType(lockCode, out _)) - { - continue; - } - - if (!lockCode.Contains(": IInternalDistributedLock<")) - { - errors.Add($"{file} does not implement the expected interface"); - continue; - } - - var lockType = Path.GetFileNameWithoutExtension(file); - var handleType = lockType + "Handle"; - - var explicitImplementations = new StringBuilder(); - const string Interface = "IDistributedLock", InterfaceHandle = Interface + "Handle"; - foreach (var method in new[] { "TryAcquire", "Acquire", "TryAcquireAsync", "AcquireAsync" }) - { - AppendExplicitInterfaceMethod(explicitImplementations, Interface, method, InterfaceHandle); - } - - var @namespace = Regex.Match(lockCode, @"\nnamespace (?\S+)").Groups["namespace"].Value; - var code = -$@"using System; -using System.Threading; -using System.Threading.Tasks; -using Medallion.Threading.Internal; - -namespace {@namespace} -{{ - public partial class {lockType} - {{ - // AUTO-GENERATED - -{explicitImplementations} - public {handleType}? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); - - public {handleType} Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.Acquire(this, timeout, cancellationToken); - - public ValueTask<{handleType}?> TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => - this.As>().InternalTryAcquireAsync(timeout, cancellationToken); - - public ValueTask<{handleType}> AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => - DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); - }} -}}"; - code = DocCommentGenerator.AddDocComments(code); - - var outputPath = Path.Combine(Path.GetDirectoryName(file)!, Path.GetFileNameWithoutExtension(file) + ".IDistributedLock.cs"); - if (!File.Exists(outputPath) || File.ReadAllText(outputPath) != code) - { - File.WriteAllText(outputPath, code); - errors.Add($"updated {file}"); - } - } - - Assert.IsEmpty(errors); - } - - [Test] - public void GenerateForIDistributedReaderWriterLock() - { - var files = CodeGenHelpers.EnumerateSolutionFiles() - .Where(f => f.IndexOf("DistributedLock.Core", StringComparison.OrdinalIgnoreCase) < 0) - .Where(f => Regex.IsMatch(Path.GetFileName(f), @"Distributed.*?ReaderWriterLock\.cs$", RegexOptions.IgnoreCase)); - - var errors = new List(); - foreach (var file in files) - { - var lockCode = File.ReadAllText(file); - if (lockCode.Contains("AUTO-GENERATED") - || !CodeGenHelpers.HasPublicType(lockCode, out _)) - { - continue; - } - - bool isUpgradeable; - if (lockCode.Contains(": IInternalDistributedUpgradeableReaderWriterLock<")) - { - isUpgradeable = true; - } - else if (lockCode.Contains(": IInternalDistributedReaderWriterLock<")) - { - isUpgradeable = false; - } - else - { - errors.Add($"{file} does not implement the expected interface"); - continue; - } - - var lockType = Path.GetFileNameWithoutExtension(file); - - var explicitImplementations = new StringBuilder(); - var publicMethods = new StringBuilder(); - foreach (var methodLockType in new[] { LockType.Read, LockType.Upgrade, LockType.Write }.Where(t => isUpgradeable || t != LockType.Upgrade)) - foreach (var isAsync in new[] { false, true }) - foreach (var isTry in new[] { true, false }) - { - var upgradeableText = methodLockType == LockType.Upgrade ? "Upgradeable" : ""; - var handleType = lockType + upgradeableText + "Handle"; - - var methodName = $"{(isTry ? "Try" : "")}Acquire{upgradeableText}{(methodLockType == LockType.Write ? "Write" : "Read")}Lock{(isAsync ? "Async" : "")}"; - AppendExplicitInterfaceMethod( - explicitImplementations, - $"IDistributed{upgradeableText}ReaderWriterLock", - methodName, - $"IDistributedLock{upgradeableText}Handle" - ); - - var simplifiedMethodName = methodLockType == LockType.Upgrade ? methodName : methodName.Replace("ReadLock", "").Replace("WriteLock", ""); - - publicMethods.AppendLine() - .Append(' ', 8).Append("public ") - .Append(isAsync ? "ValueTask<" : "").Append(handleType).Append(isTry ? "?" : "").Append(isAsync ? ">" : "").Append(' ') - .Append(methodName) - .Append("(").Append("TimeSpan").Append(isTry ? "" : "?").AppendLine($" timeout = {(isTry ? "default" : "null")}, CancellationToken cancellationToken = default) =>") - .Append(' ', 12) - .Append( - isTry && isAsync - ? $"this.As>()" - + $".Internal{simplifiedMethodName}(timeout, cancellationToken" - : $"DistributedLockHelpers.{simplifiedMethodName}(this, timeout, cancellationToken" - ) - .Append(methodLockType == LockType.Read ? ", isWrite: false" : methodLockType == LockType.Write ? ", isWrite: true" : "") - .AppendLine(");"); - } - - var @namespace = Regex.Match(lockCode, @"\nnamespace (?\S+)").Groups["namespace"].Value; - var code = -$@"using System; -using System.Threading; -using System.Threading.Tasks; -using Medallion.Threading.Internal; - -namespace {@namespace} -{{ - public partial class {lockType} - {{ - // AUTO-GENERATED - -{explicitImplementations}{publicMethods} - }} -}}"; - code = DocCommentGenerator.AddDocComments(code); - - var outputPath = Path.Combine(Path.GetDirectoryName(file)!, $"{Path.GetFileNameWithoutExtension(file)}.IDistributed{(isUpgradeable ? "Upgradeable" : "")}ReaderWriterLock.cs"); - if (!File.Exists(outputPath) || File.ReadAllText(outputPath) != code) - { - File.WriteAllText(outputPath, code); - errors.Add($"updated {file}"); - } - } - - Assert.IsEmpty(errors); - } - - private static void AppendExplicitInterfaceMethod(StringBuilder code, string @interface, string method, string returnType) - { - var isAsync = method.EndsWith("Async"); - var isTry = method.StartsWith("Try"); - var returnTypeToUse = isTry ? returnType + "?" : returnType; - - code.Append(' ', 8) - .Append(isAsync ? $"ValueTask<{returnTypeToUse}>" : returnTypeToUse) - .AppendLine($" {@interface}.{method}(TimeSpan{(isTry ? string.Empty : "?")} timeout, CancellationToken cancellationToken) =>") - .Append(' ', 12) - .Append($"this.{method}(timeout, cancellationToken)") - .Append(isAsync ? $".Convert(To<{returnTypeToUse}>.ValueTask)" : string.Empty) - .AppendLine(";"); - } - } -} diff --git a/DistributedLockTaker/Program.cs b/DistributedLockTaker/Program.cs deleted file mode 100644 index 9cd246d3..00000000 --- a/DistributedLockTaker/Program.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using Medallion.Threading.SqlServer; -using Medallion.Threading.WaitHandles; -using Medallion.Threading.Postgres; -using Medallion.Threading.Tests; -using Medallion.Threading.Azure; -using Azure.Storage.Blobs; -#if NET471 -using System.Data.SqlClient; -#elif NETCOREAPP3_1 -using Microsoft.Data.SqlClient; -#endif - -namespace DistributedLockTaker -{ - internal static class Program - { - public static int Main(string[] args) - { - var type = args[0]; - var name = args[1]; - IDisposable? handle; - switch (type) - { - case nameof(SqlDistributedLock): - handle = new SqlDistributedLock(name, SqlServerCredentials.ConnectionString).Acquire(); - break; - case "Write" + nameof(SqlDistributedReaderWriterLock): - handle = new SqlDistributedReaderWriterLock(name, SqlServerCredentials.ConnectionString).AcquireWriteLock(); - break; - case nameof(SqlDistributedSemaphore) + "1AsMutex": - handle = new SqlDistributedSemaphore(name, maxCount: 1, connectionString: SqlServerCredentials.ConnectionString).Acquire(); - break; - case nameof(SqlDistributedSemaphore) + "5AsMutex": - handle = new SqlDistributedSemaphore(name, maxCount: 5, connectionString: SqlServerCredentials.ConnectionString).Acquire(); - break; - case nameof(PostgresDistributedLock): - handle = new PostgresDistributedLock(new PostgresAdvisoryLockKey(name), PostgresCredentials.GetConnectionString(Environment.CurrentDirectory)).Acquire(); - break; - case "Write" + nameof(PostgresDistributedReaderWriterLock): - handle = new PostgresDistributedReaderWriterLock(new PostgresAdvisoryLockKey(name), PostgresCredentials.GetConnectionString(Environment.CurrentDirectory)).AcquireWriteLock(); - break; - case nameof(EventWaitHandleDistributedLock): - handle = new EventWaitHandleDistributedLock(name).Acquire(); - break; - case nameof(AzureBlobLeaseDistributedLock): - handle = new AzureBlobLeaseDistributedLock( - new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), - o => o.Duration(TimeSpan.FromSeconds(15)) - ) - .Acquire(); - break; - default: - Console.Error.WriteLine($"type: {type}"); - return 123; - } - - Console.WriteLine("Acquired"); - Console.Out.Flush(); - - if (Console.ReadLine() != "abandon") - { - handle.Dispose(); - } - - return 0; - } - } -} diff --git a/README.md b/README.md index c25cc16f..f2c511f3 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,249 @@ # DistributedLock -DistributedLock is a lightweight .NET library that makes it easy to set up and use system-wide or fully-distributed locks. - -DistributedLock is available for download as a [NuGet package](https://www.nuget.org/packages/DistributedLock). [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.svg?style=flat)](https://www.nuget.org/packages/DistributedLock/) - -[Release notes](#release-notes) - -## Features - -- [System-wide locks](#system-wide-locks) -- [Fully-distributed locks](#fully-distributed-locks) -- [Semaphores](#semaphores) -- [Reader-writer locks](#reader-writer-locks) -- [Safe naming](#naming-locks) -- [Try Semantics](#trylock) -- [Async](#async) -- [Timeouts](#timeouts) -- [Cancellation](#cancellation) -- [Connection management](#connection-management) - -## System-wide locks - -System-wide locks are great for synchronizing between processes or .NET application domains: +DistributedLock is a .NET library that provides robust and easy-to-use distributed mutexes, reader-writer locks, and semaphores based on a variety of underlying technologies. +With DistributedLock, synchronizing access to a region of code across multiple applications/machines is as simple as: ```C# -var myLock = new SystemDistributedLock("SystemLock"); - -using (myLock.Acquire()) +await using (await myDistributedLock.AcquireAsync()) { - // this block of code is protected by the lock! + // I hold the lock here } ``` -## Fully-distributed locks +## Implementations -DistributedLock allows you to easily leverage MSFT SQLServer's application lock functionality to provide synchronization between machines in a distributed environment. To use this functionality, you'll need a SQLServer connection string: +DistributedLock contains implementations based on various technologies; you can install implementation packages individually or just install the [DistributedLock NuGet package](https://www.nuget.org/packages/DistributedLock) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.svg?style=flat)](https://www.nuget.org/packages/DistributedLock/), a ["meta" package](https://endjin.com/blog/2020/09/streamline-dependency-management-with-nuget-meta-packages) which includes all implementations as dependencies. *Note that each package is versioned independently according to SemVer*. -```C# -var connectionString = ConfigurationManager.ConnectionStrings["MyDatabase"].ConnectionString; -var myLock = new SqlDistributedLock("SqlLock", connectionString); +- **[DistributedLock.SqlServer](docs/DistributedLock.SqlServer.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.SqlServer.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.SqlServer/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.SqlServer.html) (uses Microsoft SQL Server) +- **[DistributedLock.Postgres](docs/DistributedLock.Postgres.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Postgres.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Postgres/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.Postgres.html) (uses Postgresql) +- **[DistributedLock.MySql](docs/DistributedLock.MySql.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.MySql.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.MySql/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.MySql.html) (uses MySQL or MariaDB) +- **[DistributedLock.Oracle](docs/DistributedLock.Oracle.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Oracle.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Oracle/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.Oracle.html) (uses Oracle) +- **[DistributedLock.Redis](docs/DistributedLock.Redis.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Redis.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Redis/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.Redis.html) (uses Redis) +- **[DistributedLock.Azure](docs/DistributedLock.Azure.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Azure.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Azure/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.Azure.html) (uses Azure blobs) +- **[DistributedLock.MongoDB](docs/DistributedLock.MongoDB.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.MongoDB.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.MongoDB/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.MongoDB.html) (uses MongoDB) +- **[DistributedLock.ZooKeeper](docs/DistributedLock.ZooKeeper.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.ZooKeeper.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.ZooKeeper/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.ZooKeeper.html) (uses Apache ZooKeeper) +- **[DistributedLock.FileSystem](docs/DistributedLock.FileSystem.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.FileSystem.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.FileSystem/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.FileSystem.html) (uses lock files) +- **[DistributedLock.WaitHandles](docs/DistributedLock.WaitHandles.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.WaitHandles.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.WaitHandles/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.WaitHandles.html) (*Windows only*: uses operating system global `WaitHandle`s) -using (myLock.Acquire()) -{ - // this block of code is protected by the lock! -} -``` +**Click on the name** of any of the above packages to see the documentation specific to that implementation, or read on for general documentation that applies to all implementations. -As of version 1.1.0, `SqlDistributedLock`s can now be scoped to existing `IDbTransaction` and/or `IDbConnection` objects as an alternative to passing a connection string directly (in which case the lock manages its own connection). +The [DistributedLock.Core](https://www.nuget.org/packages/DistributedLock.Core) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Core.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Core/) package contains common code and abstractions and is referenced by all implementations. -## Semaphores +## Synchronization primitives -DistributedLock contains an implementation of a distributed [semaphore](https://en.wikipedia.org/wiki/Semaphore_(programming)) with an API similar to the framework's non-distributed [SemaphoreSlim](https://msdn.microsoft.com/en-us/library/system.threading.semaphoreslim(v=vs.110).aspx) class. Since the implementation is based on [SQLServer application locks](https://msdn.microsoft.com/en-us/library/ms189823.aspx), this can be used to synchronize across different machines. +- Locks: provide exclusive access to a region of code +- [Reader-writer locks](docs/Reader-writer%20locks.md): a lock with multiple levels of access. The lock can be held concurrently either by any number of "readers" or by a single "writer". +- [Semaphores](docs/Semaphores.md): similar to a lock, but can be held by up to N users concurrently instead of just one. -The semaphore acts like a lock that can be acquired by a fixed number of processes/threads simultaneously instead of a single process/thread. This capability is frequently used to "throttle" access to some resource such as a database or email server, generally with the goal of preventing it from becoming overloaded. In such cases, a classic mutex lock is inappropriate because we *do* want to allow concurrent access and simply want to cap the level of concurrency. For example: +While all implementations support locks, the other primitives are only supported by some implementations. See the [implementation-specific documentation pages](docs) for details. -```C# -var semaphore = new SqlDistributedSemaphore("ComputeDatabase", 5, connectionString); -using (semaphore.Acquire()) -{ - // only 5 callers can be inside this block concurrently - UseComputeDatabase(); -} -``` +## Basic usage -## Reader-writer locks +### Names -DistributedLock contains an implementation of a distributed [reader-writer lock](https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock) with an API similar to the framework's non-distributed [ReaderWriterLockSlim](https://msdn.microsoft.com/en-us/library/system.threading.readerwriterlockslim(v=vs.110).aspx) class. Since the implementation is based on [SQLServer application locks](https://msdn.microsoft.com/en-us/library/ms189823.aspx), this can be used to synchronize across different machines. +Because distributed locks (and other distributed synchronization primitives) are not isolated to a single process, their identity is based on their name which is provided through the constructor. Different underlying technologies have different restrictions on name format; however, DistributedLock largely allows you to ignore these by escaping/hashing names that would otherwise be invalid. -The reader-writer lock allows for *multiple readers or one writer.* Furthermore, at most one reader can be in *upgradeable read mode*, which allows for upgrading from read mode to write mode without relinquishing the read lock. Here's an example showing how a reader-writer lock could synchronize access to a distributed cache: +### Acquire -```C# -class DistributedCache -{ - private readonly SqlDistributedReaderWriterLock cacheLock = - new SqlDistributedReaderWriterLock("DistributedCache", connectionString); - - public string Get(string key) - { - using (this.cacheLock.AcquireReadLock()) - { - return /* read from cache */ - } - } - - public void Add(string key, string value) - { - using (this.cacheLock.AcquireWriteLock()) - { - /* write to cache */ - } - } - - public void AddOrUpdate(string key, string value) - { - using (var upgradeableHandle = this.cache.AcquireUpgradeableReadLock()) - { - if (/* read from cache */) { return; } - - upgradeableHandle.UpgradeToWriteLock(); - - /* write to cache */ - } - } -} -``` - -## Naming locks - -For all types of locks, the name of the lock defines its identity within its scope. While in general most names will work, the names are ultimately constrained by the underlying technologies used for locking. If you don't want to worry (particularly if when generating names dynamically), you can use the GetSafeLockName method each lock type to convert an arbitrary string into a consistent valid lock name: +All synchronization primitives support the same basic access pattern. The `Acquire` method returns a "handle" object that represents holding the lock. When the handle is disposed, the lock is released: ```C# -string baseName = // arbitrary logic -var lockName = SqlDistributedLock.GetSafeLockName(baseName); -var myLock = new SqlDistributedLock(lockName); +var myDistributedLock = new SqlDistributedLock(name, connectionString); // e. g. if we are using SQL Server +using (myDistributedLock.Acquire()) +{ + // we hold the lock here +} // implicit Dispose() call from using block releases it here ``` -## Other features - -### TryLock +### TryAcquire -All locks support a "try" mechanism so that you can attempt to claim the lock without committing to it: +While `Acquire` will block until the lock is available, there is also a `TryAcquire` variant which returns `null` if the lock could not be acquired (due to being held elsewhere): ```C# -using (var handle = myLock.TryAcquire()) +using (var handle = myDistributedLock.TryAcquire()) { if (handle != null) { - // I have the lock! + // we acquired the lock :-) } else { - // someone else has it! + // someone else has it :-( } } ``` -### Async +### async support -All locks support async acquisition so that you don't need to consume threads waiting for locks to become available: +`async` versions of both of these methods are also supported. These are preferred when you are writing async code since they will not consume a thread while waiting for the lock. If you are using C#8 or higher, you can also dispose of handles asynchronously: ```C# -using (await myLock.AcquireAsync()) -{ - // this block of code is protected by the lock! - - // locks can be used to protect async code - await webClient.DownloadStringAsync(...); -} +await using (await myDistributedLock.AcquireAsync()) { ... } ``` -Note that because of this locks do not have thread affinity unlike the Monitor and Mutex .NET synchronization classes and are not re-entrant. - ### Timeouts -All lock methods support specifying timeouts after which point TryAcquire calls will return null and Acquire calls will throw a TimeoutException: +Additionally, all of these methods support an optional `timeout` parameter. `timeout` determines how long `Acquire` will wait before failing with a `TimeoutException` and how long `TryAcquire` will wait before returning null. The default `timeout` for `Acquire` is `Timeout.InfiniteTimeSpan` while for `TryAcquire` the default `timeout` is `TimeSpan.Zero`. + +### Cancellation + +Finally, the methods take an optional `CancellationToken` parameter, which allows for the acquire operation to be interrupted via cancellation. Note that this won't cancel the hold on the lock once the acquire succeeds. + +## Providers + +For applications that use [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection), DistributedLock's providers make it easy to separate out the specification of a lock's (or other primitive's) name from its other settings (such as a database connection string). For example in an ASP.NET Core app you might do: ```C# -// wait up to 5 seconds to acquire the lock -using (var handle = myLock.TryAcquire(TimeSpan.FromSeconds(5))) +// in your Startup.cs: +services.AddSingleton(_ => new PostgresDistributedSynchronizationProvider(myConnectionString)); +services.AddTransient(); + +// in SomeService.cs +public class SomeService { - if (handle != null) + private readonly IDistributedLockProvider _synchronizationProvider; + + public SomeService(IDistributedLockProvider synchronizationProvider) { - // I have the lock! + this._synchronizationProvider = synchronizationProvider; } - else + + public void InitializeUserAccount(int id) { - // timed out waiting for someone else to give it up + // use the provider to construct a lock + var @lock = this._synchronizationProvider.CreateLock($"UserAccount{id}"); + using (@lock.Acquire()) + { + // do stuff + } + + // ALTERNATIVELY, for common use-cases extension methods allow this to be done with a single call + using (this._synchronizationProvider.AcquireLock($"UserAccount{id}")) + { + // do stuff + } } } ``` -### Cancellation +## Other topics -All lock methods support passing a CancellationToken which, if triggered, will break out of the wait: +- [Interfaces](docs/Other%20topics.md#interfaces) +- [Detecting handle loss](docs/Other%20topics.md#detecting-handle-loss) +- [Handle abandonment](docs/Other%20topics.md#handle-abandonment) +- [Composite locks](docs/Other%20topics.md#composite-locking) +- [Safety of distributed locking](docs/Other%20topics.md#safety-of-distributed-locking) -``` -// acquire the lock, unless someone cancels us -CancellationToken token = ... -using (myLock.Acquire(cancellationToken: token)) -{ - // this block of code is protected by the lock! -} -``` - -### Connection management +## Contributing -When using SQL-based locks, DistributedLock exposes several options for managing the underlying connection/transaction that scopes the lock: -- Explicit: you can pass in the IDbConnection/IDbTransaction instance that provides lock scope. This is useful when you don't have access to a connection string or -when you want the locking to be tied closely to other SQL operations being performed. -- Connection: the lock internally manages a `SqlConnection` instance. The lock is released by calling [sp_releaseapplock](https://msdn.microsoft.com/en-us/library/ms178602.aspx) after which the connection is disposed. This is the default mode. -- Transaction: the lock internally manages a `SqlTransaction` instance. The lock is released by disposing the transaction. -- Connection Multiplexing: the library internally manages a pool of `SqlConnection` instances, each of which may be used to hold multiple locks -simultaneously. This is particularly helpful for high-load scenarios since it can drastically reduce load on the underlying connection pool. -- Azure: similar to the "Connection" strategy, but also automatically issues periodic background queries on the underlying connection to keep it from looking idle to the Azure connection governor. See [#5](https://github.com/madelson/DistributedLock/issues/5) for more details. +Contributions are welcome! If you are interested in contributing towards a new or existing issue, please let me know via comments on the issue so that I can help you get started and avoid wasted effort on your part. -Most of the time, you'll want to use the default connection strategy. See more details about the various strategies [here](https://github.com/madelson/DistributedLock/blob/master/DistributedLock/Sql/SqlDistributedLockConnectionStrategy.cs). +Setup steps for working with the repository locally are documented [here](docs/Developing%20DistributedLock.md). ## Release notes + +- 2.8.3 + - Bump MongoDB.Driver to avoid pulling in vulnerable packages ([#281](https://github.com/madelson/DistributedLock/pull/281), DistributedLock.MongoDB 1.0.2). Thanks [@Thynix](https://github.com/Thynix) for implementing! +- 2.8.2 + - Fix support for Postgres instances with timeout settings using units other than millisecond ([#277](https://github.com/madelson/DistributedLock/issues/277), DistributedLock.Postgres 1.3.1) + - Reduce allocations in MongoDB locks ([#276](https://github.com/madelson/DistributedLock/pull/276), DistributedLock.MongoDB 1.0.1). Thanks [@joesdu](https://github.com/joesdu) for implementing! +- 2.8.1 + - Fix connection monitoring query on Oracle. Thanks [@matthew-marston](https://github.com/matthew-marston) for implementing! ([#271](https://github.com/madelson/DistributedLock/issues/271), DistributedLock.Oracle 1.0.5) + - Bump Microsoft.Data.SqlClient version ([#273](https://github.com/madelson/DistributedLock/issues/273), DistributedLock.SqlServer 1.0.7) + - Improve `SqlDistributedSemaphore` deadlock detection ([#264](https://github.com/madelson/DistributedLock/issues/264), DistributedLock.SqlServer 1.0.7) +- 2.8.0 + - Add MongoDB support! Thanks [@joesdu](https://github.com/joesdu) for implementing! ([#121](https://github.com/madelson/DistributedLock/issues/121), DistributedLock.MongoDB 1.0) + - Add composite lock support. Thanks [@moeen](https://github.com/moeen) for implementing! ([#236](https://github.com/madelson/DistributedLock/issues/236), DistributedLock.Core 1.0.9) +- 2.7.1 + - Improve compatibility with Redis clusters that require keys in Lua scripts to be passed via the KEYS array. Thanks [@pengweiqhca](https://github.com/pengweiqhca) for reporting a identifying the fix! ([#254](https://github.com/madelson/DistributedLock/issues/254), DistributedLock.Redis 1.1.1) +- 2.7 + - Add support for fetching a Redis-based semaphore's current available count. Thanks [@teesoftech](https://github.com/teesofttech) for implementing! ([#234](https://github.com/madelson/DistributedLock/issues/234), DistributedLock.Redis 1.1) +- 2.6 + - Add support for acquiring transaction-scoped Postgres locks using externally-owned transactions. Thanks [@Tzachi009](https://github.com/Tzachi009) for implementing! ([#213](https://github.com/madelson/DistributedLock/issues/213), DistributedLock.Postgres 1.3) +- 2.5.1 + - Increase efficiency of Azure blob locks when the blob does not exist. Thanks [@richardkooiman](https://github.com/richardkooiman) for implementing! ([#227](https://github.com/madelson/DistributedLock/pull/227), DistributedLock.Azure 1.0.2) + - Improve error handling in race condition scenarios for Azure blobs. Thanks [@MartinDembergerR9](https://github.com/MartinDembergerR9) for implementing! ([#228](https://github.com/madelson/DistributedLock/pull/228), DistributedLock.Azure 1.0.2) + - Bump Microsoft.Data.SqlClient to 5.2.2 to avoid vulnerability. Thanks [@steve85](https://github.com/steve85) for implementing! ([#229](https://github.com/madelson/DistributedLock/pull/229), DistributedLock.SqlServer 1.0.6) + - Bump Oracle.ManagedDataAccess to latest to avoid bringing in vulnerable packages (DistributedLock.Core 1.0.8, DistributedLock.Oracle 1.0.4) + - Bump Npgsql to latest patch to avoid bringing in vulnerable packages (DistributedLock.Postgres 1.2.1) + - Improve directory creation concurrency handling for `FileDistributedLock` (DistributedLock.FileSystem 1.0.3) +- 2.5 + - Add support for creating Postgres locks off `DbDataSource` which is helpful for apps using `NpgsqlMultiHostDataSource`. Thanks [davidngjy](https://github.com/davidngjy) for implementing! ([#153](https://github.com/madelson/DistributedLock/issues/153), DistributedLock.Postgres 1.2.0) + - Upgrade Npgsql to 8.0.3 to avoid vulnerability. Thanks [@Meir017](https://github.com/Meir017)/[@davidngjy](https://github.com/davidngjy) for implementing! ([#218](https://github.com/madelson/DistributedLock/issues/218), DistributedLock.Postgres 1.2.0) + - Fix Postgres race condition with connection keepalive enabled ([#216](https://github.com/madelson/DistributedLock/issues/216), DistributedLock.Core 1.0.7) + - Upgrade Microsoft.Data.SqlClient to 5.2.1 to avoid vulnerability ([#210](https://github.com/madelson/DistributedLock/issues/210), DistributedLock.SqlServer 1.0.5) +- 2.4 + - Add support for transaction-scoped locking in Postgres using `pg_advisory_xact_lock` which is helpful when using PgBouncer ([#168](https://github.com/madelson/DistributedLock/issues/168), DistributedLock.Postgres 1.1.0) + - Improve support for newer versions of StackExchange.Redis, especially when using the default backlog policy ([#162](https://github.com/madelson/DistributedLock/issues/162), DistributedLock.Redis 1.0.3). Thanks [@Bartleby2718](https://github.com/Bartleby2718) for helping with this! + - Drop `net461` support (`net462` remains supported). Thanks [@Bartleby2718](https://github.com/Bartleby2718) for implementing! + - Reduce occurrence of `UnobservedTaskException`s thrown by the library ([#192](https://github.com/madelson/DistributedLock/issues/192), DistributedLock.Core 1.0.6) + - Update dependencies to modern versions without known issues/vulnerabilities ([#111](https://github.com/madelson/DistributedLock/issues/111)/[#177](https://github.com/madelson/DistributedLock/issues/177)/[#184](https://github.com/madelson/DistributedLock/issues/184)/[#185](https://github.com/madelson/DistributedLock/issues/185), all packages). Thanks [@Bartleby2718](https://github.com/Bartleby2718) for helping with this! + - Improve directory creation concurrency handling for `FileDistributedLock` on Linux/.NET 8 ([#195](https://github.com/madelson/DistributedLock/issues/195), DistributedLock.FileSystem 1.0.2) + - Allow using transaction-scoped locks in SQL Server without explicitly disabling multiplexing ([#189](https://github.com/madelson/DistributedLock/issues/189), DistributedLock.SqlServer 1.0.4) + - New API documentation on [dndocs](https://dndocs.com/). Thanks [@NeuroXiq](https://github.com/NeuroXiq)! + - New documentation for contributors to get the project running locally (see [Contributing](#contributing)) +- 2.3.4 + - Support Npgsql 8.0's [ExecuteScalar breaking change](https://github.com/npgsql/npgsql/issues/5143) ([#174](https://github.com/madelson/DistributedLock/issues/174), DistributedLock.Postgres 1.0.5). Thanks [@Kaffeetasse](https://github.com/Kaffeetasse) for diagnosing and fixing! +- 2.3.3 + - Update Microsoft.Data.SqlClient due to vulnerabilities ([#149](https://github.com/madelson/DistributedLock/issues/149), DistributedLock.SqlServer 1.0.3) + - Update versions of Oracle.ManagedDataAccess and Oracle.ManagedDataAccess.Core due to vulnerabilities (DistributedLock.Oracle 1.0.2) +- 2.3.2 + - Work around underlying Postgres race condition when waiting on advisory locks with a short non-zero timeout ([#147](https://github.com/madelson/DistributedLock/issues/147), DistributedLock.Postgres 1.0.4). Thanks [@Tzachi009](https://github.com/Tzachi009) for reporting and isolating the issue! +- 2.3.1 + - Fixed concurrency issue with `HandleLostToken` for relational database locks ([#133](https://github.com/madelson/DistributedLock/issues/133), DistributedLock.Core 1.0.5, DistributedLock.MySql 1.0.1, DistributedLock.Oracle 1.0.1, DistributedLock.Postgres 1.0.3, DistributedLock.SqlServer 1.0.2). Thanks [@OskarKlintrot](https://github.com/OskarKlintrot) for testing! + - Fixed misleading error message why trying to disable auto-extension in Redis ([#130](https://github.com/madelson/DistributedLock/issues/130), DistributedLock.Redis 1.0.2) + - Fixed concurrency issue with canceling async waits on `WaitHandle`s ([#120](https://github.com/madelson/DistributedLock/issues/120), DistributedLock.WaitHandles 1.0.1) +- 2.3.0 + - Added Oracle-based implementation ([#45](https://github.com/madelson/DistributedLock/issues/45), DistributedLock.Oracle 1.0.0). Thanks [@odin568](https://github.com/odin568) for testing! + - Made file-based locking more robust to transient `UnauthorizedAccessException`s ([#106](https://github.com/madelson/DistributedLock/issues/106) & [#109](https://github.com/madelson/DistributedLock/issues/109), DistributedLock.FileSystem 1.0.1) + - Work around cancellation bug in Npgsql command preparation ([#112](https://github.com/madelson/DistributedLock/issues/112), DistributedLock.Postgres 1.0.2) +- 2.2.0 + - Added MySQL/MariaDB-based implementation ([#95](https://github.com/madelson/DistributedLock/issues/95), DistributedLock.MySql 1.0.0). Thanks [@theplacefordev](https://github.com/theplacefordev) for testing! +- 2.1.0 + - Added ZooKeeper-based implementation ([#41](https://github.com/madelson/DistributedLock/issues/41), DistributedLock.ZooKeeper 1.0.0) +- 2.0.2 + - Fixed bug where `HandleLostToken` would hang when accessed on a SqlServer or Postgres lock handle that used keepalive ([#85](https://github.com/madelson/DistributedLock/issues/85), DistributedLock.Core 1.0.1) + - Fixed bug where broken database connections could result in future lock attempts failing when using SqlServer or Postgres locks with multiplexing ([#83](https://github.com/madelson/DistributedLock/issues/83), DistributedLock.Core 1.0.1) + - Updated Npgsql dependency to 5.x to take advantage of various bugfixes ([#61](https://github.com/madelson/DistributedLock/issues/61), DistributedLock.Postgres 1.0.1) +- 2.0.1 + - Fixed Redis lock behavior when using a database with `WithKeyPrefix` ([#66](https://github.com/madelson/DistributedLock/issues/66), DistributedLock.Redis 1.0.1). Thanks [@skomis-mm](https://github.com/skomis-mm) for contributing! +- 2.0.0 (see also [Migrating from 1.x to 2.x](docs/Migrating%20from%201.x%20to%202.x.md#migrating-from-1x-to-2x)) + - Revamped package structure so that DistributedLock is now an umbrella package and each implementation technology has its own package (BREAKING CHANGE) + - Added Postgresql-based locking ([#56](https://github.com/madelson/DistributedLock/issues/56), DistributedLock.Postgres 1.0.0) + - Added Redis-based locking ([#24](https://github.com/madelson/DistributedLock/issues/24), DistributedLock.Redis 1.0.0) + - Added Azure blob-based locking ([#42](https://github.com/madelson/DistributedLock/issues/42), DistributedLock.Azure 1.0.0) + - Added file-based locking ([#28](https://github.com/madelson/DistributedLock/issues/28), DistributedLock.FileSystem 1.0.0) + - Added provider classes for improved IOC integration ([#13](https://github.com/madelson/DistributedLock/issues/13)) + - Added strong naming to assemblies. Thanks [@pedropaulovc](https://github.com/pedropaulovc) for contributing! ([#47](https://github.com/madelson/DistributedLock/issues/47), BREAKING CHANGE) + - Made lock handles implement `IAsyncDisposable` in addition to `IDisposable` [#20](https://github.com/madelson/DistributedLock/issues/20), BREAKING CHANGE) + - Exposed implementation-agnostic interfaces (e. g. `IDistributedLock`) for all synchronization primitives ([#10](https://github.com/madelson/DistributedLock/issues/10)) + - Added `HandleLostToken` API for tracking if a lock's underlying connection dies ([#6](https://github.com/madelson/DistributedLock/issues/6), BREAKING CHANGE) + - Added SourceLink support ([#57](https://github.com/madelson/DistributedLock/issues/57)) + - Removed `GetSafeName` API in favor of safe naming by default (BREAKING CHANGE) + - Renamed "SystemDistributedLock" to "EventWaitHandleDistributedLock" (DistributedLock.WaitHandles 1.0.0) + - Stopped supporting net45 (BREAKING CHANGE) + - Removed `DbConnection` and `DbTransaction` constructors form `SqlDistributedLock`, leaving the constructors that take `IDbConnection`/`IDbTransaction` ([#35](https://github.com/madelson/DistributedLock/issues/35), BREAKING CHANGE) + - Changed methods returning `Task` to instead return `ValueTask`, making it so that `using (@lock.AcquireAsync()) { ... } without an `await` no longer compiles (#34, BREAKING CHANGE) + - Changed `UpgradeableLockHandle.UpgradeToWriteLock` to return `void` ([#33](https://github.com/madelson/DistributedLock/issues/33), BREAKING CHANGE) + - Switched to Microsoft.Data.SqlClient by default for all target frameworks (BREAKING CHANGE) + - Changed all locking implementations to be non-reentrant (BREAKING CHANGE) - 1.5.0 - - Added cross-platform support via Microsoft.Data.SqlClient ([#25](https://github.com/madelson/DistributedLock/issues/25)). This feature is available for .NET Standard >= 2.0. Thanks to [@alesebi91](https://github.com/alesebi91) for helping with the implementation and testing! - - Added C#8 nullable annotations ([#31](https://github.com/madelson/DistributedLock/issues/31)) - - Fixed minor bug in connection multiplexing which could lead to more lock contention ([#32](https://github.com/madelson/DistributedLock/issues/32)) + - Added cross-platform support via Microsoft.Data.SqlClient ([#25](https://github.com/madelson/DistributedLock/issues/25)). This feature is available for .NET Standard >= 2.0. Thanks to [@alesebi91](https://github.com/alesebi91) for helping with the implementation and testing! + - Added C#8 nullable annotations ([#31](https://github.com/madelson/DistributedLock/issues/31)) + - Fixed minor bug in connection multiplexing which could lead to more lock contention ([#32](https://github.com/madelson/DistributedLock/issues/32)) - 1.4.0 - - Added a SQL-based distributed semaphore ([#7](https://github.com/madelson/DistributedLock/issues/7)) - - Fix bug where SqlDistributedLockConnectionStrategy.Azure would leak connections, relying on GC to reclaim them ([#14](https://github.com/madelson/DistributedLock/issues/14)). Thanks [zavalita1](https://github.com/zavalita1) for investigating this issue! - - Throw a specific exception type (`DeadlockException`) rather than the generic `InvalidOperationException` when a deadlock is detected ([#11](https://github.com/madelson/DistributedLock/issues/11)) + - Added a SQL-based distributed semaphore ([#7](https://github.com/madelson/DistributedLock/issues/7)) + - Fix bug where SqlDistributedLockConnectionStrategy.Azure would leak connections, relying on GC to reclaim them ([#14](https://github.com/madelson/DistributedLock/issues/14)). Thanks [zavalita1](https://github.com/zavalita1) for investigating this issue! + - Throw a specific exception type (`DeadlockException`) rather than the generic `InvalidOperationException` when a deadlock is detected ([#11](https://github.com/madelson/DistributedLock/issues/11)) - 1.3.1 Minor fix to avoid "leaking" isolation level changes in transaction-based locks ([#8](https://github.com/madelson/DistributedLock/issues/8)). Also switched to the VS2017 project file format - 1.3.0 Added an Azure connection strategy to keep lock connections from becoming idle and being reclaimed by Azure's connection governor ([#5](https://github.com/madelson/DistributedLock/issues/5)) - 1.2.0 - - Added a SQL-based distributed reader-writer lock - - .NET Core support via .NET Standard - - Changed the default locking scope for SQL distributed lock to be a connection rather than a transaction, avoiding cases where long-running transactions can block backups - - Allowed for customization of the SQL distributed lock connection strategy when connecting via a connection string - - Added a new connection strategy which allows for multiplexing multiple held locks onto one connection - - Added IDbConnection/IDbTransaction constructors ([#3](https://github.com/madelson/DistributedLock/issues/3)) + - Added a SQL-based distributed reader-writer lock + - .NET Core support via .NET Standard + - Changed the default locking scope for SQL distributed lock to be a connection rather than a transaction, avoiding cases where long-running transactions can block backups + - Allowed for customization of the SQL distributed lock connection strategy when connecting via a connection string + - Added a new connection strategy which allows for multiplexing multiple held locks onto one connection + - Added IDbConnection/IDbTransaction constructors ([#3](https://github.com/madelson/DistributedLock/issues/3)) - 1.1.0 Added support for SQL distributed locks scoped to existing connections/transactions - 1.0.1 Minor fix when using infinite timeouts - 1.0.0 Initial release diff --git a/appveyor.yml b/appveyor.yml index c817ee53..d4cd381d 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,13 +1,21 @@ version: 1.0.{build} -image: Visual Studio 2019 +image: + # Ubuntu2204 needed for .NET 8 for now (see https://www.appveyor.com/docs/linux-images-software/) + - Ubuntu2204 + - Visual Studio 2022 -before_build: - - nuget restore -build: - verbosity: minimal +build_script: + - dotnet build src/DistributedLock.sln -c Release -# only test categories that don't depend on external things -test: - categories: - - CI \ No newline at end of file +test_script: + - dotnet test src/DistributedLock.sln -c Release -f net8.0 --no-build --filter TestCategory=CI + +for: + - + matrix: + only: + - + image: "Visual Studio 2022" + test_script: + - dotnet test src/DistributedLock.sln -c Release --no-build --filter "TestCategory=CI|TestCategory=CIWindows" \ No newline at end of file diff --git a/docs/Developing DistributedLock.md b/docs/Developing DistributedLock.md new file mode 100644 index 00000000..8a3b3dbb --- /dev/null +++ b/docs/Developing DistributedLock.md @@ -0,0 +1,174 @@ +# Developing DistributedLock + +## Installing back-ends for testing + +DistributedLock has a variety of back-ends; to be able to develop and run tests against all of them you'll need to install a good amount of software. + +### Azure + +For the Azure back-end, [Azurite](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite) is used for local development. See [here](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite?tabs=visual-studio%2Cblob-storage#install-azurite) for how to install. + +### MySQL + +The MySQL driver covers both MySQL and MariaDB; so we'll need to install both. + +#### MariaDB + +The MariaDB installer can be downloaded [here](https://mariadb.org/download/?t=mariadb&p=mariadb&os=windows&cpu=x86_64&pkg=msi&m=acorn). + +After downloading, you'll need to enable the performance_schema which is used by DistributedLock's tests. You can do this by adding the following to your my.ini/my.cnf file (C:\Program Files\MariaDB {version}\data\my.ini on windows): + +```ini +# activates the performance_schema tables which are needed by DistributedLock tests +performance_schema=ON +``` + +After doing this, restart MariaDB (on Windows, do this in the Services app). + +Next, create the `distributed_lock` database and a user for the tests to run as: + +```sql +CREATE DATABASE distributed_lock; +CREATE USER 'DistributedLock'@'localhost' IDENTIFIED BY ''; +GRANT ALL PRIVILEGES ON distributed_lock.* TO 'DistributedLock'@'localhost'; +GRANT SELECT ON performance_schema.* TO 'DistributedLock'@'localhost'; +``` + +(Windows) If you don't want MariaDB always running on your machine, set the Startup type to "Manual" for `MariaDB`. + +Finally, add your username (DistributedLock) and password to `DistributedLock.Tests/credentials/mariadb.txt`, with the username on line 1 and the password on line 2. + +#### MySQL + +You can install MySQL from [here](https://dev.mysql.com/downloads/mysql/). Run on port 3307 to avoid conflicting with MariaDB. + +(Windows) If you don't want MySQL always running on your machine, set the Startup type to "Manual" for `MySQL{Version}`. + +Add your username and password to `DistributedLock.Tests/credentials/mysql.txt`, with the username on line 1 and the password on line 2. + +### Oracle + +You can install Oracle from [here](https://www.oracle.com/database/technologies/oracle-database-software-downloads.html#db_free). It claims not to support Windows 11 Home, but it seems to install and work fine. + +Add your username (e.g. SYSTEM) and password to `DistributedLock.Tests/credentials/oracle.txt`, with the username on line 1 and the password on line 2. + +(Windows) If the Oracle tests fail with `ORA-12541: TNS:no listener`, you may have to start the `OracleOraDB21Home1TNSListener` service in services.svc and/or restart the `OracleServiceXE`. After starting these it can take a few minutes for the DB to come online. + +### Postgres + +You can install Postgres from [here](https://www.enterprisedb.com/downloads/postgres-postgresql-downloads). + +In `C:\Program Files\PostgreSQL\\data\postgresql.conf`, update `max_connections` to 200. + +(Windows) If you don't want Postgres always running on your machine, set the Startup type to "Manual" for `postgresql-x64-{VERSION} - PostgresSQL Server {VERSION}`. + +Add your username (e.g. postgres) and password to `DistributedLock.Tests/credentials/postgres.txt`, with the username on line 1 and the password on line 2. + +### SQL Server + +Download SQL developer edition from [here](https://www.microsoft.com/en-us/sql-server/sql-server-downloads). + +(Windows) If you don't want SQLServer always running on your machine, set the Startup type to "Manual" for `SQL Server (MSSQLSERVER)`. + +The tests connect via integrated security. + +### Redis + +Install Redis locally. On Windows, install it via WSL as described [here](https://developer.redis.com/create/windows/). + +You do not need it running as a service: the tests will start and stop instances automatically. + + +### MongoDB + +The recommended approach for MongoDB is to use Docker (e.g. Docker desktop on Windows). To spin up an instance without manual initialization. The test suite assumes docker is running and will try to start the container with: + +```bat +docker run -d -p 27017:27017 --name distributed-lock-mong mongo:latest +``` + +
+Advanced setup options +

+You can download the MongoDB Community Server from [here](https://www.mongodb.com/try/download/community). + +Or use `docker compose` to start a replica set environment: + +```yaml +services: + mongo_primary: + image: bitnami/mongodb:latest + container_name: mongo_primary + environment: + - TZ=UTC + - MONGODB_ADVERTISED_HOSTNAME=host.docker.internal + - MONGODB_REPLICA_SET_MODE=primary + - MONGODB_REPLICA_SET_NAME=rs0 + - MONGODB_ROOT_USER=yourUsername + - MONGODB_ROOT_PASSWORD=yourPassword + - MONGODB_REPLICA_SET_KEY=yourKey + ports: + - "27017:27017" + volumes: + - "mongodb_master_data:/bitnami/mongodb" + + mongo_secondary: + image: bitnami/mongodb:latest + container_name: mongo_secondary + depends_on: + - mongo_primary + environment: + - TZ=UTC + - MONGODB_ADVERTISED_HOSTNAME=host.docker.internal + - MONGODB_REPLICA_SET_MODE=secondary + - MONGODB_REPLICA_SET_NAME=rs0 + - MONGODB_INITIAL_PRIMARY_PORT_NUMBER=27017 + - MONGODB_INITIAL_PRIMARY_HOST=host.docker.internal + - MONGODB_INITIAL_PRIMARY_ROOT_USER=yourUsername + - MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=yourPassword + - MONGODB_REPLICA_SET_KEY=yourKey + ports: + - "27018:27017" + + mongo_arbiter: + image: bitnami/mongodb:latest + container_name: mongo_arbiter + depends_on: + - mongo_primary + environment: + - TZ=UTC + - MONGODB_ADVERTISED_HOSTNAME=host.docker.internal + - MONGODB_REPLICA_SET_MODE=arbiter + - MONGODB_REPLICA_SET_NAME=rs0 + - MONGODB_INITIAL_PRIMARY_PORT_NUMBER=27017 + - MONGODB_INITIAL_PRIMARY_HOST=host.docker.internal + - MONGODB_INITIAL_PRIMARY_ROOT_USER=yourUsername + - MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=yourPassword + - MONGODB_REPLICA_SET_KEY=yourKey + ports: + - "27019:27017" + +volumes: + mongodb_master_data: + driver: local +``` + +The tests default to `mongodb://localhost:27017`. To use a custom connection string (e.g. for credentials), place it in `DistributedLock.Tests/credentials/mongodb.txt`. + +If you're using a replica set or sharded cluster, your connection string might look like this: + +``` +mongodb://yourUsername:yourPassword@host.docker.internal:27017,host.docker.internal:27018,host.docker.internal:27019/?replicaSet=rs0&authSource=admin&serverSelectionTimeoutMS=1000 +``` +

+
+ +### ZooKeeper + +Download a ZooKeeper installation by going to [https://zookeeper.apache.org/](https://zookeeper.apache.org/)->Documentation->Release ...->Getting Started->Download->stable. + +Extract the zip archive, and within it copy `zoo_sample.cfg` to `zoo.cfg`. + +Add the full path of the extracted directory (the one containing README.md, bin, conf, etc) to `DistributedLock.Tests/credentials/zookeeper.txt` as a single line. + +Also, install Java Development Kit (JDK) because ZooKeeper runs on Java. diff --git a/docs/DistributedLock.Azure.md b/docs/DistributedLock.Azure.md new file mode 100644 index 00000000..41827317 --- /dev/null +++ b/docs/DistributedLock.Azure.md @@ -0,0 +1,38 @@ +# DistributedLock.Azure + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.Azure) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Azure.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Azure/) + +The DistributedLock.Azure package offers distributed locks based on [Azure blob leases](https://docs.microsoft.com/en-us/rest/api/storageservices/lease-blob). For example: + +```C# +var container = new BlobContainerClient(myAzureConnectionString, "my-locking-container-name"); +var @lock = new AzureBlobLeaseDistributedLock(container, "MyLockName"); +await using (var handle = await @lock.TryAcquireAsync()) +{ + if (handle != null) { /* I have the lock */ } +} +``` + +## APIs + +- The `AzureBlobLeaseDistributedLock` class implements the `IDistributedLock` interface. +- The `AzureBlobLeaseDistributedSynchronizationProvider` class implements the `IDistributedLockProvider` interface. + +## Implementation notes + +`AzureBlobLeaseDistributedLock`s can be constructed either from a `BlobContainerClient` and a name, which will cause it to lease a blob in the provided container with a name based on the provided name. If you know exactly which blob you'd like to lease, another constructor lets you pass a `BlobBaseClient` instead. + +Because of how Azure leases work, the acquire operation cannot truly block. If waiting to acquire a lock that is not available, the implementation will periodically sleep and retry until the lease can be taken or the acquire timeout elapses. Because of this, these locks are maximally efficient when using `TryAcquire` semantics with a timeout of zero. + +Blob leases in Azure have built-in expirations. However while an `AzureBlobLeaseDistributedLock` is held it will periodically renew the lease in the background. Therefore, it is generally safe to ignore the problem of lease duration. + +## Options + +In addition to specifying the blob to be leased, several tuning options are provided. You should not need to change these options most of the time. + +- `Duration` changes the blob lease duration requested under the hood +- `RenewalCadence` changes how frequently auto-renewal re-ups the lease duration while holding the lock +- `BusyWaitSleepTime` specifies a range of times that the implementation will sleep between attempts to acquire a lease that is currently held by someone else. A random number in the range will be chosen for each sleep. If you expect contention, lowering these values may increase the responsiveness (how quickly a lock detects that it can now take the lease) but will increase the number of API calls made to Azure. Raising the values will have the reverse effects. + + + diff --git a/docs/DistributedLock.FileSystem.md b/docs/DistributedLock.FileSystem.md new file mode 100644 index 00000000..15bce1f5 --- /dev/null +++ b/docs/DistributedLock.FileSystem.md @@ -0,0 +1,33 @@ +# DistributedLock.FileSystem + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.FileSystem) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.FileSystem.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.FileSystem/) + +The DistributedLock.FileSystem package offers distributed locks based on file handles/locks. For example: + +```C# +var lockFileDirectory = new DirectoryInfo(Environment.CurrentDirectory); // choose where the lock files will live +var @lock = new FileDistributedLock(lockFileDirectory, "MyLockName"); +await using (var handle = await @lock.TryAcquireAsync()) +{ + if (handle != null) { /* I have the lock */ } +} +``` + +## APIs + +- The `FileDistributedLock` class implements the `IDistributedLock` interface. +- The `FileDistributedSynchronizationProvider` class implements the `IDistributedLockProvider` interface. + +## Implementation notes + +Because they are based on files, these locks are used to coordinate between processes on the same machine (as opposed to across machines). In some cases, it may be possible to coordinate across machines by specifying the path of a networked file. However, this should be tested because the network file system may not truly support locking. + +`FileDistributedLock`s can be constructed either from a base `DirectoryInfo` and a `name`, which will cause it to create a file *based on* `name` in the specified directory. If you know exactly which file you'd like to lock on, you can pass a `FileInfo` instead. + +Because of how exclusive file handles work in .NET, the acquire operation cannot truly block. If waiting to acquire a lock that is not available, the implementation will periodically sleep and retry until the lease can be taken or the acquire timeout elapses. Because of this, these locks are maximally efficient when using `TryAcquire` semantics with a timeout of zero. + +## Options + +File-based locks have no additional configuration options. + + diff --git a/docs/DistributedLock.MongoDB.md b/docs/DistributedLock.MongoDB.md new file mode 100644 index 00000000..a580d9bc --- /dev/null +++ b/docs/DistributedLock.MongoDB.md @@ -0,0 +1,316 @@ +# DistributedLock.MongoDB + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.MongoDB) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.MongoDB.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.MongoDB/) + +The DistributedLock.MongoDB package offers distributed locks based on [MongoDB](https://www.mongodb.com/). For example: + +```C# +var client = new MongoClient("mongodb://localhost:27017"); +var database = client.GetDatabase("myDatabase"); +var @lock = new MongoDistributedLock("myLockName", database); +await using (await @lock.AcquireAsync()) +{ + // I have the lock +} +``` + +## APIs + +- The `MongoDistributedLock` class implements the `IDistributedLock` interface. +- The `MongoDistributedSynchronizationProvider` class implements the `IDistributedLockProvider` interface. + +## Implementation notes + +MongoDB-based locks use MongoDB's document upsert and update operations to implement distributed locking. The implementation works as follows: + +1. **Acquisition**: Attempts to insert or update a document with the lock key and a unique lock ID. +2. **Extension**: Automatically extends the lock expiry while held to prevent timeout. +3. **Release**: Deletes the lock document when disposed. +4. **Expiry**: Locks automatically expire if not extended, allowing recovery from crashed processes. + +MongoDB locks can be constructed with an `IMongoDatabase` and an optional collection name. If no collection name is specified, locks will be stored in a collection named `"distributed.locks"`. The collection will automatically have an index created on the `expiresAt` field for efficient queries. + +When using the provider pattern, you can create multiple locks with different names from the same provider: + +```C# +var client = new MongoClient(connectionString); +var database = client.GetDatabase("myDatabase"); +var provider = new MongoDistributedSynchronizationProvider(database); + +var lock1 = provider.CreateLock("lock1"); +var lock2 = provider.CreateLock("lock2"); + +await using (await lock1.AcquireAsync()) +{ + // Do work with lock1 +} +``` + +**NOTE**: Lock extension happens automatically in the background while the lock is held. If lock extension fails (for example, due to network issues), the `HandleLostToken` will be signaled to notify you that the lock may have been lost. + +## Options + +In addition to specifying the name and database, several tuning options are available: + +- `Expiry` determines how long the lock will be initially claimed for. Because of automatic extension, locks can be held for longer than this value. Defaults to 30 seconds. +- `ExtensionCadence` determines how frequently the hold on the lock will be renewed to the full `Expiry`. Defaults to 1/3 of `Expiry` (approximately 10 seconds when using the default expiry). +- `BusyWaitSleepTime` specifies a range of times that the implementation will sleep between attempts to acquire a lock that is currently held by someone else. A random time in the range will be chosen for each sleep. If you expect contention, lowering these values may increase responsiveness (how quickly a lock detects that it can now be taken) but will increase the number of calls made to MongoDB. Raising the values will have the reverse effects. Defaults to a range of 10ms to 800ms. + +Example of using options: + +```C# +var @lock = new MongoDistributedLock( + "MyLockName", + database, + options => options + .Expiry(TimeSpan.FromSeconds(30)) + .ExtensionCadence(TimeSpan.FromSeconds(10)) + .BusyWaitSleepTime( + min: TimeSpan.FromMilliseconds(10), + max: TimeSpan.FromMilliseconds(800)) +); +``` + +You can also specify a custom collection name: + +```C# +var @lock = new MongoDistributedLock("MyLockName", database, "MyCustomLocks"); +``` + +## Stale lock cleanup + +Stale locks from crashed processes will automatically expire based on the `Expiry` setting. MongoDB's built-in TTL index support ensures that expired lock documents are cleaned up automatically by the database. This means that if a process crashes while holding a lock, the lock will become available again after the expiry time has elapsed. + +## Architecture & Design + +### Lock Lifecycle Diagram + +```mermaid +stateDiagram-v2 + [*] --> Waiting: Create Lock + + Waiting --> Acquiring: Call AcquireAsync() + Waiting --> Expired: TTL Cleanup + + Acquiring --> Acquired: Successfully Acquired Lock + Acquiring --> Waiting: Lock Held by Others + + Acquired --> Extending: Background Extension Task + Extending --> Acquired: Extension Successful + Extending --> Lost: Extension Failed + + Acquired --> Releasing: Dispose Handle + Releasing --> Released: Lock Document Deleted + + Lost --> [*]: HandleLostToken Signaled + Released --> [*]: Lock Released + Expired --> [*]: Stale Lock Cleaned Up +``` + +### Lock Acquisition Process + +```mermaid +flowchart TD + A["Client Requests Lock"] --> B["Call FindOneAndUpdateAsync"] + B --> C{"Lock Document Exists?"} + + C -->|Yes| D{"Document Expired?"} + C -->|No| E["Create New Lock"] + + D -->|Yes| F["Replace Old Lock"] + D -->|No| G["Lock Held by Other Process"] + + E --> H["Set LockId = GUID"] + F --> H + + H --> I["Set expiresAt = Now + Expiry"] + I --> J["Set acquiredAt = Now"] + J --> K["Increment fencingToken"] + K --> L["Verify Our LockId"] + + L --> M{"LockId Matches?"} + M -->|Yes| N["✅ Lock Acquired"] + M -->|No| O["❌ Another Process Won Race"] + + G --> P["Sleep Random Time"] + O --> P + P --> Q["Retry Acquire"] + Q --> B + + N --> R["Start Background Extension Task"] + R --> S["Return LockHandle"] +``` + +### Component Architecture + +```mermaid +graph TB + Client["Client Code"] + Lock["MongoDistributedLock"] + Provider["MongoDistributedSynchronizationProvider"] + Options["MongoDistributedSynchronizationOptionsBuilder"] + Handle["MongoDistributedLockHandle"] + + MongoDB[("MongoDB Database")] + Collection["distributed_locks Collection"] + TTLIndex["TTL Index on expiresAt"] + + Client -->|Creates| Lock + Client -->|Uses| Provider + Provider -->|Creates| Lock + Lock -->|Configured by| Options + Lock -->|Returns| Handle + Handle -->|Manages| Lock + + Lock -->|Reads/Writes| MongoDB + Handle -->|Auto-extends via| Lock + + MongoDB -->|Contains| Collection + Collection -->|Has| TTLIndex + TTLIndex -->|Cleans up| Collection + + style MongoDB fill:#13aa52 + style Collection fill:#3fa796 + style TTLIndex fill:#5fbf8c + style Handle fill:#ffa500 + style Lock fill:#4a90e2 +``` + +### Lock State Machine (Single Document in MongoDB) + +```mermaid +graph LR + NonExistent["Document Doesn't Exist"] + Active["Active Lock by Process A"] + Extending["Auto-Extending by Process A"] + Expired["Expired (expiresAt < now)"] + Deleted["Deleted"] + + NonExistent -->|Acquire Attempt| Active + Active -->|Background Task| Extending + Extending -->|Success| Active + Extending -->|Failure| Expired + Active -->|Release| Deleted + Expired -->|Other Process Acquires| Active + Expired -->|TTL Cleanup| Deleted + + style Active fill:#90EE90 + style Extending fill:#87CEEB + style Expired fill:#FFB6C1 + style Deleted fill:#D3D3D3 +``` + +## How It Works + +MongoDB distributed locks use MongoDB's atomic document operations to implement safe, distributed locking: + +### Acquisition Algorithm + +The lock acquisition uses a single `FindOneAndUpdateAsync` operation with an aggregation pipeline to atomically: + +1. Check if the lock document exists and is expired +2. If expired or missing, acquire the lock by: + - Setting a unique `lockId` (GUID) + - Recording the `acquiredAt` timestamp + - Setting the `expiresAt` time based on the configured expiry + - Incrementing the `fencingToken` for ordering guarantees +3. If still held by another process, leave it unchanged + +The fencing token ensures that even if a lock holder loses its connection, any operations it performs using that token will be safely rejected. + +### Lock Maintenance + +Once acquired, the lock is automatically extended in the background at the configured `ExtensionCadence` to prevent premature expiration while the process is still running. + +### Release + +The lock is released by deleting the lock document when the handle is disposed. + +### Stale Lock Cleanup + +A TTL (Time-To-Live) index on the `expiresAt` field ensures MongoDB automatically removes expired lock documents. This provides automatic cleanup of stale locks from crashed or disconnected processes without requiring manual intervention. + +### Multi-Process Lock Interaction + +The following diagram shows how multiple processes interact with the same lock: + +```mermaid +sequenceDiagram + participant PA as Process A + participant PB as Process B + participant DB as MongoDB + + PA->>DB: FindOneAndUpdateAsync (try acquire) + Note over PA,DB: expiresAt < now or missing + DB->>PA: ✅ Lock Acquired (lockId=UUID-A, token=1) + + PB->>DB: FindOneAndUpdateAsync (try acquire) + Note over PB,DB: expiresAt > now (held by A) + DB->>PB: ❌ Lock Not Acquired + + PB->>PB: Wait & Retry + + PA->>PA: Background: ExtensionCadence timer + PA->>DB: Update: Extend expiresAt += Expiry + DB->>PA: ✅ Extension Successful + + PA->>PA: Do Critical Work + + PB->>DB: FindOneAndUpdateAsync (retry) + DB->>PB: ❌ Still held by A + PB->>PB: Wait & Retry + + PA->>PA: Dispose Handle + PA->>DB: Delete Lock Document + DB->>PA: ✅ Deleted + + PB->>DB: FindOneAndUpdateAsync (retry) + Note over PB,DB: Document missing (A released) + DB->>PB: ✅ Lock Acquired (lockId=UUID-B, token=2) + PB->>PB: Do Critical Work +``` + +### Fencing Token Mechanism + +The fencing token ensures that even if a process loses its lock (due to network partition, crash, or timeout), it cannot perform operations on protected resources: + +```mermaid +sequenceDiagram + participant PA as Process A + participant Resource as Protected Resource + participant DB as MongoDB Lock + + PA->>DB: Acquire Lock → token=1 + DB->>PA: ✅ Granted + + PA->>Resource: Operation with token=1 + Resource->>Resource: Accept (token >= last_seen) + + Note over DB: Network Partition + + PB->>DB: Acquire Lock → token=2 + DB->>PB: ✅ Granted + + PB->>Resource: Operation with token=2 + Resource->>Resource: Accept (token >= last_seen) + + PA->>Resource: Operation with token=1 (stale) + Resource->>Resource: Reject ❌ (token < last_seen) +``` + +This mechanism prevents the "split brain" scenario where two processes both believe they hold the lock. + +### Performance Considerations + +- Lock acquisition requires 1 MongoDB operation. Additionally, the first lock acquisition for a given collection will attempt to create the TTL index on the collection in a fire-and-forget manner +- Lock extension happens in the background at `ExtensionCadence` intervals +- Under contention, adaptive backoff reduces the load on MongoDB compared to fixed random intervals +- The `expiresAt` TTL index keeps the collection clean without manual maintenance. This is not required for correctness, but without it (or some equivalent process), connectivity errors or process crashes will result in orphaned lock documents. + +### Notes + +- The lock collection will have an index on the `expiresAt` field for efficient queries +- Lock extension happens automatically in the background +- If lock extension fails, the `HandleLostToken` will be signaled +- Stale locks (from crashed processes) will automatically expire based on the expiry setting + diff --git a/docs/DistributedLock.MySql.md b/docs/DistributedLock.MySql.md new file mode 100644 index 00000000..e6cd264d --- /dev/null +++ b/docs/DistributedLock.MySql.md @@ -0,0 +1,36 @@ +# DistributedLock.MySql + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.MySql) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.MySql.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.MySql/) + +The DistributedLock.MySql package offers distributed synchronization primitives based on [MySQL/MariaDB user locks](https://dev.mysql.com/doc/refman/5.7/en/locking-functions.html). For example: + +```C# +var @lock = new MySqlDistributedLock("mylockname", connectionString); +await using (await @lock.AcquireAsync()) +{ + // I have the lock +} +``` + +## APIs + +- The `MySqlDistributedLock` class implements the `IDistributedLock` interface. +- The `MySqlDistributedSynchronizationProvider` class implements the `IDistributedLockProvider` and `IDistributedReaderWriterLockProvider` interfaces. + +## Implementation notes + +MySQL-based locks have been tested against and work with both the [MySQL](https://www.mysql.com/) and [MariaDB](https://mariadb.org/). + +MySQL-based locks locks can be constructed with a `connectionString`, an `IDbConnection` or an `IDbTransaction` as a means of connecting to the database. In most cases, using a `connectionString` is preferred because it allows for the library to efficiently multiplex connections under the hood and eliminates the risk that the passed-in `IDbConnection` gets used in a way that disrupts the locking process. Using an `IDbTransaction` is generally equivalent to using an `IDbConnection` (the lock is still connection-scoped), but it allows the lock to participate in an ongoing transaction. **NOTE that since `IDbConnection`/`IDbTransaction` objects are not thread-safe, lock objects constructed with them can only be used by one thread at a time.** + +Natively, MySQL's locking functions are case-insensitive with respect to the lock name. Since the DistributedLock library as a whole uses case-sensitive names, lock names containing uppercase characters will be transformed/hashed under the hood (as will empty names or names that are too long). If your program needs to coordinate with other code that is using `GET_LOCK` directly, be sure to express the name in lower case and to pass `exactName: true` when constructing the lock instance (in `exactName` mode, an invalid name will throw an exception rather than silently being transformed into a valid one). + +## Options + +In addition to specifying the `name`, several tuning options are available for `connectionString`-based locks: + +- `KeepaliveCadence` allows you to have the implementation periodically issue a cheap query on a connection holding a lock. This helps in configurations which are set up to aggressively kill idle connections. Defaults to OFF (`Timeout.InfiniteTimeSpan`). +- `UseMultiplexing` allows the implementation to re-use connections under the hood to hold multiple locks under certain scenarios, leading to lower resource consumption. This behavior defaults to ON. **Note that this behavior must be disabled if you are using a version of MySQL older than 5.7** (see [here](https://github.com/madelson/DistributedLock/issues/123) and [here](https://dev.mysql.com/doc/refman/5.6/en/locking-functions.html) for more). Otherwise, you should not disable it unless you suspect that it is causing issues for you (please file an issue here if so!). + + + diff --git a/docs/DistributedLock.Oracle.md b/docs/DistributedLock.Oracle.md new file mode 100644 index 00000000..9447cb19 --- /dev/null +++ b/docs/DistributedLock.Oracle.md @@ -0,0 +1,46 @@ +# DistributedLock.Oracle + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.Oracle) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Oracle.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Oracle/) + +The DistributedLock.Oracle package offers distributed synchronization primitives based on Oracle's [DBMS_LOCK package](https://docs.oracle.com/database/121/ARPLS/d_lock.htm). For example: + +```C# +var @lock = new OracleDistributedLock("MyLockName", connectionString); +using (@lock.Acquire()) +{ + // I have the lock +} +``` + +## Setup + +Because the library uses Oracle's DBMS_LOCK package under the hood, **you may need to permission your user to that package**. If you encounter an error like `identifier 'SYS.DBMS_LOCK' must be declared ORA-06550`, configure your Oracle user like so: + +```SQL +connect as sys +grant execute on SYS.DBMS_LOCK to someuser; +``` + +See [this StackOverflow question](https://stackoverflow.com/questions/10870787/oracle-pl-sql-dbms-lock-error) for more info. + +## APIs + +- The `OracleDistributedLock` class implements the `IDistributedLock` interface. +- The `OracleDistributedReaderWriterLock` class implements the `IDistributedUpgradeableReaderWriterLock` interface. +- The `OracleDistributedSynchronizationProvider` class implements the `IDistributedLockProvider` and `IDistributedUpgradeableReaderWriterLockProvider` interfaces. + +## Implementation notes + +Oracle-based locks locks can be constructed with a connectionString or an `IDbConnection` as a means of connecting to the database. In most cases, using a connectionString is preferred because it allows for the library to efficiently multiplex connections under the hood and eliminates the risk that the passed-in `IDbConnection` gets used in a way that disrupts the locking process. **NOTE that since `IDbConnection` objects are not thread-safe, lock objects constructed with them can only be used by one thread at a time.** + +The classes in this package support async operations per the common distributed lock and ADO.NET interfaces. However, as of 2021-12-14, the Oracle .NET client libraries do not support true async IO. Therefore, if you are using the Oracle-based implementation you might get slightly better performance out of the synchronous APIs (e. g. `OracleDistributedLock.Acquire()` instead of `OracleDistributedLock.AcquireAsync()`). + +## Options + +In addition to specifying the `key`, several tuning options are available for `connectionString`-based locks: + +- `KeepaliveCadence` allows you to have the implementation periodically issue a cheap query on a connection holding a lock. This helps in configurations which are set up to aggressively kill idle connections. Defaults to OFF (`Timeout.InfiniteTimeSpan`). +- `UseMultiplexing` allows the implementation to re-use connections under the hood to hold multiple locks under certain scenarios, leading to lower resource consumption. This behavior defaults to ON; you should not disable it unless you suspect that it is causing issues for you (please file an issue here if so!). + + + diff --git a/docs/DistributedLock.Postgres.md b/docs/DistributedLock.Postgres.md new file mode 100644 index 00000000..6ff94031 --- /dev/null +++ b/docs/DistributedLock.Postgres.md @@ -0,0 +1,53 @@ +# DistributedLock.Postgres + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.Postgres) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Postgres.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Postgres/) + +The DistributedLock.Postgres package offers distributed synchronization primitives based on [PostgreSQL advisory locks](https://www.postgresql.org/docs/9.4/explicit-locking.html#ADVISORY-LOCKS). For example: + +```C# +var @lock = new PostgresDistributedLock(new PostgresAdvisoryLockKey("MyLockName", allowHashing: true), connectionString); +await using (await @lock.AcquireAsync()) +{ + // I have the lock +} +``` + +## APIs + +- The `PostgresDistributedLock` class implements the `IDistributedLock` interface. +- The `PostgresDistributedReaderWriterLock` class implements the `IDistributedReaderWriterLock` interface. +- The `PostgresDistributedSynchronizationProvider` class implements the `IDistributedLockProvider` and `IDistributedReaderWriterLockProvider` interfaces. + +As of version 1.3, an additional set of static APIs on `PostgresDistributedLock` allows you to leverage transaction-scoped locking with an existing `IDbTransaction` instance. Since Postgres offers no way to explicitly release transaction-scoped locks and the caller controls the transaction, these locks are acquire-only and do not need a using block. For example: +```C# +using (var transaction = connection.BeginTransaction()) +{ + ... + // acquires the lock; it will be held until the transaction ends + await PostgresDistributedLock.AcquireWithTransactionAsync(key, transaction); + ... +} +``` + +## Implementation notes + +Under the hood, [Postgres advisory locks can be based on either one 64-bit integer value or a pair of 32-bit integer values](https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS). Because of this, rather than taking in a name the lock constructors take a `PostgresAdvisoryLockKey` object which can be constructed in several ways: +- Passing a single `long` value. +- Passing a pair of `int` values. +- Passing a 16-character hex string (e. g. `"00000003ffffffff"`) which will be parsed as a `long`. +- Passing a pair of comma-separated 8-character hex strings (e. g. `"00000003,ffffffff"`) which will be parsed as a pair of `int`s. +- Passing an ASCII string with 0-9 characters, which will be mapped to a `long` based on a custom scheme. +- Passing an arbitrary string with the `allowHashing` option set to `true` which will be hashed to a `long`. Note that hashing will only be used if other methods of interpreting the string fail. + +In addition to specifying the `key`, Postgres-based locks allow you to specify either a `connectionString`, an `IDbConnection`, or a `DbDataSource` as a means of connecting to the database. In most cases, using a `connectionString` is preferred because it allows for the library to efficiently multiplex connections under the hood and, in the case of `IDbConnection`, eliminates the risk that the passed-in `IDbConnection` gets used in a way that disrupts the locking process. **NOTE that since `IDbConnection` objects are not thread-safe, lock objects constructed with them can only be used by one thread at a time.** + +## Options + +In addition to specifying the `key`, several tuning options are available for `connectionString`-based locks: + +- `KeepaliveCadence` allows you to have the implementation periodically issue a cheap query on a connection holding a lock. This helps in configurations which are set up to aggressively kill idle connections. Defaults to OFF (`Timeout.InfiniteTimeSpan`). +- `UseTransaction` scopes the lock to an internally-managed transaction under the hood (otherwise it is connection-scoped). Defaults to FALSE because this mode is not compatible with multiplexing and thus consumes more connections. +- `UseMultiplexing` allows the implementation to re-use connections under the hood to hold multiple locks under certain scenarios, leading to lower resource consumption. This behavior defaults to ON; you should not disable it unless you suspect that it is causing issues for you (please file an issue here if so!). + + + diff --git a/docs/DistributedLock.Redis.md b/docs/DistributedLock.Redis.md new file mode 100644 index 00000000..d7395054 --- /dev/null +++ b/docs/DistributedLock.Redis.md @@ -0,0 +1,47 @@ +# DistributedLock.Redis + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.Redis) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Redis.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.Redis/) + +The DistributedLock.Redis package offers distributed synchronization primitives based on [Redis](https://redis.io/). For example: + +```C# +var connection = await ConnectionMultiplexer.ConnectAsync(connectionString); // uses StackExchange.Redis +var @lock = new RedisDistributedLock("MyLockName", connection.GetDatabase()); +await using (var handle = await @lock.TryAcquireAsync()) +{ + if (handle != null) { /* I have the lock */ } +} +``` + +## APIs + +- The `RedisDistributedLock` class implements the `IDistributedLock` interface. +- The `RedisDistributedReaderWriterLock` class implements the `IDistributedReaderWriterLock` interface +- The `RedisDistributedSemaphore` class implements the `IDistributedSemaphore` interface +- The `RedisDistributedSynchronizationProvider` class implements the `IDistributedLockProvider`, `IDistributedReaderWriterLockProvider`, and `IDistributedSemaphoreProvider` interfaces. + +## Implementation notes + +The `RedisDistributedLock` and `RedisDistributedReaderWriterLock` classes implement the [RedLock algorithm](https://redis.io/topics/distlock). This allows you to increase the robustness of those locks by constructing the lock with a set of databases instead of just a single database. The lock is only considered aquired if it is successfully acquired on more than half of the databases. + +The `RedisDistributedSemaphore` implementation is loosely based on [this algorithm](https://redislabs.com/ebook/part-2-core-concepts/chapter-6-application-components-in-redis/6-3-counting-semaphores/). Note that `RedisDistributedSemaphore` does not support multiple databases, because the RedLock algorithm does not work with semaphores.1 When calling `CreateSemaphore()` on a `RedisDistributedSynchronizationProvider` that has been constructed with multiple databases, the first database in the list will be used. + +Both RedLock and the semaphore algorithm mentioned above claim locks for only a specified period of time. While DistributedLock does this under the hood, it also periodically extends its hold behind the scenes to ensure that the object is not released until the handle returned by `Acquire` is disposed. + +Some Redis synchronization primitives take in a `string name` as their name and others take in a `RedisKey key`. In the former case, one or more Redis keys will be created on the database with `name` as a prefix. In the latter case, the exact key will be used. Make sure your names/keys don't collide with Redis keys you're using for other purposes! + +Because of how Redis locks work, the acquire operation cannot truly block. If waiting to acquire a lock or other primitive that is not available, the implementation will periodically sleep and retry until the lease can be taken or the acquire timeout elapses. Because of this, these classes are maximally efficient when using `TryAcquire` semantics with a timeout of zero. + +As of 1.0.1, Redis-based primitives support the use of `IDatabase.WithKeyPrefix(keyPrefix)` for key space isolation. In such cases all underlying keys will implicitly include the key prefix. Therefore, two locks with the same name targeting the same underlying Redis instance but with different prefixes will not see each other. + +## Options + +In addition to specifying the name/key and database(s), some additional tuning options are available. + +- `Expiry` determines how long the lock will be *initially* claimed for (because of auto-extension, locks can be held for longer). Defaults to 30s. +- `MinValidityTime` determines what fraction of `Expiry` still has to remain when the locking operation completes to consider it a success. This is mostly relevant when acquiring a lock across multiple databases (e. g. if we immediately succeed on database 1 and eventually succeed on database 2 after 30s have elapsed, then our hold on database 1 will have expired). Defaults to 90% of the `Expiry`. +- `ExtensionCadence` determines how frequently the hold on the lock will be renewed to the full `Expiry`. Defaults to 1/3 of `MinValidityTime`. +- `BusyWaitSleepTime` specifies a range of times that the implementation will sleep between attempts to acquire a lock that is currently held by someone else. A random number in the range will be chosen for each sleep. If you expect contention, lowering these values may increase the responsiveness (how quickly a lock detects that it can now be taken) but will increase the number of calls made to Redis. Raising the values will have the reverse effects. + + +1 The reason RedLock does not work with semaphores is that entering a semaphore on a majority of databases does not guarantee that the semaphore's invariant is preserved. For example, imagine a two-count semaphore with three databases (1, 2, and 3) and three users (A, B, and C). We could find ourselves in the following situation: on database 1, users A and B have entered. On database 2, users B and C have entered. On database 3, users A and C have entered. Here all users believe they have entered the semaphore because they've succeeded on two out of three databases. diff --git a/docs/DistributedLock.SqlServer.md b/docs/DistributedLock.SqlServer.md new file mode 100644 index 00000000..d3245283 --- /dev/null +++ b/docs/DistributedLock.SqlServer.md @@ -0,0 +1,32 @@ +# DistributedLock.SqlServer + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.SqlServer) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.SqlServer.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.SqlServer/) + +The DistributedLock.SqlServer package offers distributed locks based on [Microsoft SQL Server application locks](https://docs.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql?view=sql-server-ver15). For example: + +```C# +var @lock = new SqlDistributedLock("MyLockName", connectionString); +await using (await @lock.AcquireAsync()) +{ + // I have the lock +} +``` + +## APIs + +- The `SqlDistributedLock` class implements the `IDistributedLock` interface. +- The `SqlDistributedReaderWriterLock` class implements the `IDistributedUpgradeableReaderWriterLock` interface. +- The `SqlDistributedSemaphore` class implements the `IDistributedSemaphore` interface. +- The `SqlDistributedSynchronizationProvider` class implements the `IDistributedLockProvider`, `IDistributedUpgradeableReaderWriterLockProvider`, and `IDistributedSemaphoreProvider` interfaces. + +## Implementation notes + +SQL-based locks can be constructed with a connection string, an `IDbConnection`, or an `IDbTransaction`. When a connection is passed, the lock will be scoped to that connection and when a transaction is passed the lock will be scoped to that transaction. In most cases, using a `connectionString` is preferred because it allows for the library to efficiently multiplex connections under the hood and eliminates the risk that the passed-in `IDbConnection`/`IDbTransaction` gets used in a way that disrupts the locking process. **NOTE that since `IDbConnection`/`IDbTransaction` objects are not thread-safe, lock objects constructed with them can only be used by one thread at a time.** + +## Options + +When connecting using a `connectionString`, several other tuning options can also be specified. + +- `KeepaliveCadence` configures the frequency at which an innocuous query will be issued on the connection while the lock is being held. The purpose of automatic keepalive is to prevent SQL Azure's aggressive connection governor from killing "idle" lock-holding connections. Defaults to 10 minutes. +- `UseTransaction` scopes the lock to an internally-managed transaction under the hood (otherwise it is connection-scoped). Defaults to FALSE because this mode is not compatible with multiplexing and thus consumes more connections. It can also lead to long-running transactions which can be disruptive on databases using the full recovery model. +- `UseMultiplexing` allows the implementation to re-use connections under the hood to hold multiple locks under certain scenarios, leading to lower resource consumption. This behavior defaults to ON except in the case where `UseTransaction` is set to TRUE since the two are not compatible. You should only manually disable `UseMultiplexing` for troubleshooting purposes if you suspect it is causing issues. diff --git a/docs/DistributedLock.WaitHandles.md b/docs/DistributedLock.WaitHandles.md new file mode 100644 index 00000000..e7d003db --- /dev/null +++ b/docs/DistributedLock.WaitHandles.md @@ -0,0 +1,27 @@ +# DistributedLock.WaitHandles + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.WaitHandles) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.Azure.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.WaitHandles/) + +The DistributedLock.WaitHandles package offers distributed locks based on [global WaitHandles in Windows](https://docs.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-createeventa?redirectedfrom=MSDN). **This library only works on Windows.** For example: + +```C# +var @lock = new EventWaitHandleDistributedLock("MyLockName"); +await using (await @lock.AcquireAsync()) +{ + // I have the lock! +} +``` + +## APIs + +- The `EventWaitHandleDistributedLock` class implements the `IDistributedLock` interface. +- The `WaitHandleDistributedSemaphore` class implements the `IDistributedSemaphore` interface. +- The `WaitHandleDistributedSynchronizationProvider` class implements the `IDistributedLockProvider` and `IDistributedSemaphoreProvider` interfaces. + +## Implementation notes + +Because they are based on global `EventWaitHandle`s/`Semaphore`s, **these classes are used to coordinate between processes on the same machine** (as opposed to across machines). + +## Options + +The optional `abandonmentCheckCadence` argument specifies how frequently the implementation will check to see if the original holder of a lock/semaphore abandoned it without properly releasing it while waiting for it to become available. Defaults to 2s. diff --git a/docs/DistributedLock.ZooKeeper.md b/docs/DistributedLock.ZooKeeper.md new file mode 100644 index 00000000..63690c5b --- /dev/null +++ b/docs/DistributedLock.ZooKeeper.md @@ -0,0 +1,34 @@ +# DistributedLock.ZooKeeper + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.ZooKeeper) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.ZooKeeper.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.ZooKeeper/) + +The DistributedLock.ZooKeeper package offers distributed locks based on [Apache ZooKeeper](https://zookeeper.apache.org/). For example: + +```C# +var @lock = new ZooKeeperDistributedLock("MyLockName", connectionString); +await using (await @lock.AcquireAsync()) +{ + // I have the lock +} +``` + +## APIs + +- The `ZooKeeperDistributedLock` class implements the `IDistributedLock` interface. +- The `ZooKeeperDistributedReaderWriterLock` class implements the `IDistributedReaderWriterLock` interface. +- The `ZooKeeperDistributedSemaphore` class implements the `IDistributedSemaphore` interface. +- The `ZooKeeperDistributedSynchronizationProvider` class implements the `IDistributedLockProvider`, `IDistributedReaderWriterLockProvider`, and `IDistributedSemaphoreProvider` interfaces. + +## Implementation notes + +ZooKeeper-based locks leverage ZooKeeper's recommended [recipes](https://zookeeper.apache.org/doc/r3.1.2/recipes.html) for distributed synchronization. + +By leveraging ZooKeeper watches under the hood, these recipes allow for very efficient event-driven waits when acquiring. + +## Options + +- `SessionTimeout` configures the underlying session timeout value for ZooKeeper connections. Because the underlying ZooKeeper client periodically renews the session, this value generally will not impact behavior. Lower values mean that locks will be released more quickly following a crash of the lock-holding process, but also increase the risk that transient connection issues will result in a dropped lock. Defaults to 20s. +- `ConnectTimeout` configures how long to wait when establishing a connection to ZooKeeper. Defaults to 15s. +- `AddAuthInfo` allows you to specify additional auth information to be added to the ZooKeeper session. This option can be specified multiple times to add multiple auth schemes. +- `AddAccessControl` configures the ZooKeeper ACL for nodes created by the lock. This option can be specified multiple times to add multiple ACL entries. If left unspecified, the ACL used is (world, anyone). + diff --git a/docs/Migrating from 1.x to 2.x.md b/docs/Migrating from 1.x to 2.x.md new file mode 100644 index 00000000..acd1eb5a --- /dev/null +++ b/docs/Migrating from 1.x to 2.x.md @@ -0,0 +1,52 @@ +# Migrating from 1.x to 2.x + +The 2.0 release of DistributedLock is a significant departure from the 1.x series in several ways. While a large percentage of source code written for 1.x should still compile against 2.x, you will almost certainly encounter some issues and this section is intended to help navigate them. + +### Package structure +First off, with the addition of many new locking technologies the package has been broken up into sub-packages which can be installed independently. This allows users to avoid bloating their dependency trees by just installing the providers they actually use. If you've been using SQL Server-based locks, for example, you may want to remove the DistributedLock package and instead install just the DistributedLock.SqlServer package when you upgrade. + +### Safe naming +In 1.x, it was often necessary to call an API like `SqlDistributedLock.GetSafeName()` before constructing a lock instance to ensure that the name you passed in was compatible with the underlying technology. In 2.0, safe naming is enabled by default; instead using the exact name (which is occasionally helpful if you have non-C# code trying to take the same lock) is a constructor option: + +```C# +// 1.x +new SqlDistributedLock(SqlDistributedLock.GetSafeName(name), connectionString); // safe name +new SqlDistributedLock(name, connectionString); // exact name (rare) + +// 2.0 +new SqlDistributedLock(name, connectionString) // safe name +new SqlDistributedLock(name, connectionString, exactName: true) // exact name (rare) +``` + +### SystemDistributedLock renamed +1.x's `SystemDistributedLock` has been renamed to `EventWaitHandleDistributedLock` to emphasize its reliance on that Windows-only technology. Furthermore, 2.0 contains `FileDistributedLock` which offers an alternative system-scoped locking mechanism. + +### SQL Server connection options +1.x offered an [enum-based approach to configuring how the lock connected to the database](https://github.com/madelson/DistributedLock/tree/release-1.5#connection-management). In 2.0, this has been replaced with a more flexible options argument. Note that Azure-focused "keepalive" behavior and connection multiplexing have been enabled by default in 2.0. + +### ValueTask return values +The 1.x `AcquireAsync` methods return `Task`. The problem with this API is that it made it easy to write incorrect code that looked correct because `Task` implements `IDisposable`. 2.0 switches to `ValueTask` (which wasn't available when the 1.x APIs were created) to avoid this problem. + +```C# +// 1.x +// Forgetting 'await' means that the using block is disposing the Task and not the lock handle. +// We will likely enter the block before the handle is acquired and will never release the handle! +using (myLock.AcquireAsync()) { } + +// 2.0 +// this code will not compile (since ValueTask is not IDisposable) +using (myLock.AcquireAsync()) { } +``` + +If you need a `Task`, you can simply call `.AsTask()` on the returned `ValueTask`. If you are doing anything other than immediately awaiting the task, I recommend reading [Microsoft's documentation on how ValueTasks can be used](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask-1?view=net-5.0#remarks). + +### Async disposal +Since the release of 1.x, Microsoft added the `IAsyncDisposable` interface as an async-friendly alternative to `IDisposable`. If you were previously using async locking, you can now switch to async disposing for additional async goodness! + +```C# +// 1.x and 2.0 (acquires asynchronously, releases synchronously) +using (await myLock.AcquireAsync()) { } + +// 2.0 only (acquires and releases asynchronously) +await using (await myLock.AcquireAsync()) { } +``` diff --git a/docs/Other topics.md b/docs/Other topics.md new file mode 100644 index 00000000..8f314944 --- /dev/null +++ b/docs/Other topics.md @@ -0,0 +1,62 @@ +## Interfaces + +The underlying technology behind a particular locking primitive (e. g. SQL Server vs. the file system) affects its behavior and performance. Therefore, in most cases you'll want to use the specific concrete classes (e. g. `SqlDistributedLock`) when writing code with distributed locks. + +However, in some cases you may want to write code that is agnostic to the specific locking technology. For example, swapping out a fully-distributed implementation for a local-system-based or mock implementation during testing might improve test performance and simplify setup. + +For purposes such as this, DistributedLock provides an interface for each of the primitives that can be used in place of the concrete classes. These are: `IDistributedLock`, `IDistributedReaderWriterLock`, `IDistributedUpgradeableReaderWriterLock`, and `IDistributedSemaphore`. + +Similarly, there is a set of interfaces defined for locking providers: `IDistributedLockProvider`, `IDistributedReaderWriterLockProvider`, `IDistributedUpgradeableReaderWriterLockProvider`, and `IDistributedSemaphoreProvider`. Each technology typically has a single provider class which implements all appropriate interfaces. + +## Detecting handle loss + +Sometimes, your code's hold on a lock can be disrupted due to a disruption in the underlying technology. For example, if you are holding a Postgres-based lock and the underlying database connection is killed, your code will no longer be holding the lock. Most such disruptions will result in a failure when the lock handle is disposed, but some may not. + +In most cases, this sort of disruption is rare and not worth worrying about. However, some lock types allow for early detection of such problems through the `HandleLostToken` interface. This is a `CancellationToken` on the returned lock handle which will be canceled if the handle detects that its hold on the lock has been disrupted. **Accessing the HandleLostToken can force a handle to perform additional background work under the hood** (e. g. polling), so don't use this feature unless you think you need it. + +```C# +using var handle = myLock.Acquire(); + +if (!handle.HandleLostToken.CanBeCanceled) { Console.WriteLine("Implementation does not support lost handle detection"); } + +handle.HandeLostToken.Register(() => Console.WriteLine("Lock was lost!")); +``` + +## Handle abandonment + +Any code that acquires a distributed lock or other primitive should be sure to dispose of it upon completion of its work to ensure that other parts of the system are not blocked. + +However, in a large and complex system there is always risk that this doesn't happen, either through sloppily written code, a bug that causes an exception to occur in an unexpected place, or the handle-holding process crashing. + +To provide additional protection against the "leaking" of lock handles, DistributedLock's primitives are designed so that a handle being garbage collected without being disposed or a handle-holding process exiting unexpectedly **will not cause a lock to be held forever**. This helps ensure that systems built on distributed locking are robust to unexpected failures. + +## Composite locking + +Sometimes, you need to acquire multiple fine-grained locks in an all-or-nothing manner (e.g. acquiring 2 per-account locks before doing an operation that affects both). Since DistributedLock.Core 1.0.9, the library now supports this via provider extension methods. For example: + +``` +IDistributedLockProvider provider = ... +await using (var handle = await provider.TryAcquireAllLocksAsync(new[] { "lockName1", "lockName2", ... }, timeout, cancellationToken)) +{ + if (handle != null) + { + // all locks successfully acquired! + } +} +``` + +An equivalent operation is supported for read locks, write locks, and semaphores. + +NOTE: the locks will be acquired in the order provided. It is up to the caller to ensure ordering consistency across operations to prevent deadlocks (e.g. you might sort the lock names before acquiring). + +## Safety of distributed locking + +Distributed locking is one of the easiest ways to add robustness to a distributed system without overly-complex design. However, the nature of the approach means that for certain scenarios it may not always be the best fit. + +For example, whenever we are using one technology to protect access to another technology, there is a (likely very small) risk that the locking technology suffers an outage (see section on detecting handle loss), and briefly allows concurrent access to a resource if it comes back online and starts granting new handles while old lost handles are still in use. Timeout-based locking approaches such as Redis locks and Azure leases have an inherent risk that an extended hang on the machine holding the lock could cause the timeout to expire before the lock can be automatically-renewed (a network outage could cause the same issue). + +In some cases, tying together the locking technology with the underlying resource can provide additional safety. For example, when using a SQLServer or Postgres lock to protect a resource on the same database it is possible to use the same `DbConnection` for both the locking operation and the data modification. Combined with database transactions, this guarantees the integrity of the locking. + +In other cases, this sort of unification isn't possible. If any violation of the locking guarantees is unacceptable, you may have to consider more complex approaches such as the techniques discussed in [this article](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html). In many cases, you simply won't be able to achieve true safety because of constraints driven by the resources you are trying to protect. + +As mentioned at the start, the distributed locking approaches offered by this library are, in my experience, good enough for a large number of real-life scenarios. Furthermore, they are easy to use correctly and easy to reason about. However, it is worth being aware of any technology's limitations! \ No newline at end of file diff --git a/docs/Reader-writer locks.md b/docs/Reader-writer locks.md new file mode 100644 index 00000000..98d8e588 --- /dev/null +++ b/docs/Reader-writer locks.md @@ -0,0 +1,90 @@ +# Reader-writer locks + +DistributedLock's implementations of [reader-writer locks](https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock) are similar to the framework's non-distributed [ReaderWriterLockSlim](https://msdn.microsoft.com/en-us/library/system.threading.readerwriterlockslim(v=vs.110).aspx) class. + +## Basics + +A reader-writer lock allows for *EITHER multiple readers OR one writer* to hold the lock at any given time. This is useful for protecting resources that are normally safe for concurrent access but need to sometimes be locked, such as when changes are being made. For example, a distributed reader-writer lock could be used to provide thread-safety in a distributed cache: + +```C# +class DistributedCache +{ + // uses the SQLServer implementation, but others are available as well + private readonly SqlDistributedReaderWriterLock _cacheLock = + new SqlDistributedReaderWriterLock("DistributedCache", connectionString); + + /// + /// If key is present in the cache, returns the associated value. If key is not present, generates a new + /// value with the provided valueFactory, stores that value in the cache, and returns the generated value. + /// + public async Task GetOrCreateAsync(string key, Func valueFactory) + { + // first, take the read lock to avoid blocking the cache in the case of a cache hit + await using (await this._cacheLock.AcquireReadLockAsync()) + { + var cached = await this.GetValueOrDefaultNoLockAsync(key); + if (cached != null) { return cached; } // cache hit + } + + // seems like we'll need to write to the cache; take the write lock + await using (await this._cacheLock.AcquireWriteLockAsync()) + { + // double-check: the value might have been written by another process + // while we were waiting to get the write lock + var cached = await this.GetValueOrDefaultNoLockAsync(key); + if (cached != null) { return cached; } // cache hit + + var generated = valueFactory(key); + await this.SetValueAsync(key, generated); + return generated; + } + } + + private async Task GetValueOrDefaultNoLockAsync(string key) { /* reads from underlying storage */ } + + private async Task SetValueAsync(string key, object value) { /* writes to underlying storage */ } +} +``` + +This approach is more efficient than simply wrapping the entire operation in a regular distributed lock because cache hits don't block each other. + +Writers are given precedence over readers so that a stream of overlapping readers cannot lock out a queued writer forever. + +## Upgradeable reader-writer locks + +Some reader-writer lock implementations further support acquiring an *upgradeable read* lock. When acquired, this lock blocks other writers and upgradeable readers but does not block other readers. Furthermore, an upgradeable read lock can be upgraded to a write lock without having to be released first (with a regular read lock, you must release it before acquiring a write lock. + +It may seem tempting to use an upgradeable read lock instead of both a read lock and a write lock in the cache scenario describe above, but the [problem with this](https://ayende.com/blog/4349/using-readerwriterlockslims-enterupgradeablereadlock) is that it would only allow one caller inside GetOrCreate at any giving time. + +In some cases, though, it is useful to be able to block writers for a time without blocking readers. Consider the following example of a checkout system where we want to protect modification of the shopping cart data model with a lock: + +```C# +class ShoppingCartService +{ + public ShoppingCartDetails GetDetails(Guid cartId) + { + using (this.GetCartLock(cartId).AcquireReadLock()) + { + // read from cart data model + } + } + + public void Checkout(Guid cartId) + { + using var handle = this.GetCartLock(cartId).AcquireUpgradeableReadLock(); + + // This makes some API calls to other systems and can be slow. We want an upgradeable + // read lock because we don't want to call Submit() multiple times for the same cart, but we + // don't need to block readers of the cart data model because we're not editing it + var submissionInfo = SubmitOrder(cartId); + + // now it's time to edit the cart data model, so upgrade to a write lock + handle.UpgradeToWriteLock(); + + // write to cart data model + } + + private SqlDistributedReaderWriterLock GetCartLock(Guid cartId) => + new SqlDistributedReaderWriterLock("Cart_" + cartId, connectionString); +} +``` diff --git a/docs/Semaphores.md b/docs/Semaphores.md new file mode 100644 index 00000000..cfe518a7 --- /dev/null +++ b/docs/Semaphores.md @@ -0,0 +1,17 @@ +# Semaphores + +DistributedLock's implementation of distributed semaphore have an API similar to the framework's non-distributed [SemaphoreSlim](https://msdn.microsoft.com/en-us/library/system.threading.semaphoreslim(v=vs.110).aspx) class. + +The semaphore acts like a lock that can be acquired by a fixed number of processes/threads simultaneously instead of a single process/thread. This capability is frequently used to "throttle" access to some resource such as a database or email server, generally with the goal of preventing it from becoming overloaded. In such cases, a distributed mutex lock is inappropriate because we do want to allow concurrent access and simply want to cap the level of concurrency. For example: + +```C# +// uses the Redis implementation; others are available +var semaphore = new RedisDistributedSemaphore("ComputeDatabase", maxCount: 5, database: database); +using (semaphore.Acquire()) +{ + // only 5 callers can be inside this block concurrently + UseComputeDatabase(); +} +``` + +Whenever a distributed semaphore is created, you must specify the max count value as well as the name. Specifying the same name with different max count values on different instances of a semaphore has undefined and unpredictable results, so make sure to use the same max count value for each instance of a particular semaphore. diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 00000000..7b9458de --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,226 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = true:warning +dotnet_style_qualification_for_field = true +dotnet_style_qualification_for_method = true:warning +dotnet_style_qualification_for_property = true:warning + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = all + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = false:warning +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = false +csharp_style_expression_bodied_methods = false +csharp_style_expression_bodied_operators = false +csharp_style_expression_bodied_properties = true + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_style_prefer_readonly_struct = true + +# Code-block preferences +csharp_prefer_braces = true:warning +csharp_prefer_simple_using_statement = true +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:suggestion +csharp_style_prefer_top_level_statements = true + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = true +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false:warning +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:warning +csharp_style_allow_embedded_statements_on_same_line_experimental = true + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 00000000..7f4a6cc9 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,13 @@ + + + true + + true + package.readme.md + + + + + + + \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 00000000..1972bbab --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props new file mode 100644 index 00000000..f598ff88 --- /dev/null +++ b/src/Directory.Packages.props @@ -0,0 +1,33 @@ + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DistributedLock.Postgres/AssemblyAttributes.cs b/src/DistributedLock.Azure/AssemblyAttributes.cs similarity index 100% rename from DistributedLock.Postgres/AssemblyAttributes.cs rename to src/DistributedLock.Azure/AssemblyAttributes.cs diff --git a/src/DistributedLock.Azure/AzureBlobLeaseDistributedLock.IDistributedLock.cs b/src/DistributedLock.Azure/AzureBlobLeaseDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..bef3fb80 --- /dev/null +++ b/src/DistributedLock.Azure/AzureBlobLeaseDistributedLock.IDistributedLock.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Azure; + +public partial class AzureBlobLeaseDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public AzureBlobLeaseDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public AzureBlobLeaseDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.Azure/AzureBlobLeaseDistributedLock.cs b/src/DistributedLock.Azure/AzureBlobLeaseDistributedLock.cs new file mode 100644 index 00000000..914d5c0c --- /dev/null +++ b/src/DistributedLock.Azure/AzureBlobLeaseDistributedLock.cs @@ -0,0 +1,225 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Specialized; +using Medallion.Threading.Internal; +using System.Text; + +namespace Medallion.Threading.Azure; + +/// +/// Implements a based on Azure blob leases +/// +public sealed partial class AzureBlobLeaseDistributedLock : IInternalDistributedLock +{ + /// + /// Metadata marker used to indicate that a blob was created for distributed locking and therefore + /// should be destroyed upon release + /// + private static readonly string CreatedMetadataKey = $"__DistributedLock"; + + private readonly BlobClientWrapper _blobClient; + private readonly (TimeoutValue duration, TimeoutValue renewalCadence, TimeoutValue minBusyWaitSleepTime, TimeoutValue maxBusyWaitSleepTime) _options; + + /// + /// Constructs a lock that will lease the provided + /// + public AzureBlobLeaseDistributedLock(BlobBaseClient blobClient, Action? options = null) + { + this._blobClient = new BlobClientWrapper(blobClient ?? throw new ArgumentNullException(nameof(blobClient))); + this._options = AzureBlobLeaseOptionsBuilder.GetOptions(options); + } + + /// + /// Constructs a lock that will lease a blob based on within the provided . + /// + public AzureBlobLeaseDistributedLock(BlobContainerClient blobContainerClient, string name, Action? options = null) + { + if (blobContainerClient == null) { throw new ArgumentNullException(nameof(blobContainerClient)); } + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + this._blobClient = new BlobClientWrapper(blobContainerClient.GetBlobClient(GetSafeName(name, blobContainerClient))); + this._options = AzureBlobLeaseOptionsBuilder.GetOptions(options); + } + + /// + /// Implements + /// + public string Name => this._blobClient.Name; + + // implementation based on https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#blob-names + internal static string GetSafeName(string name, BlobContainerClient blobContainerClient) + { + var maxLength = IsStorageEmulator() ? 256 : 1024; + + return DistributedLockHelpers.ToSafeName(name, maxLength, ConvertToValidName); + + // check based on + // https://docs.microsoft.com/en-us/azure/storage/common/storage-use-emulator#connect-to-the-emulator-account-using-the-well-known-account-name-and-key + bool IsStorageEmulator() => blobContainerClient.Uri.IsAbsoluteUri + && blobContainerClient.Uri.AbsoluteUri.StartsWith("http://127.0.0.1:10000/devstoreaccount1", StringComparison.Ordinal); + + static string ConvertToValidName(string name) + { + const int MaxSlashes = 253; // allowed to have up to 254 segments, which means 253 slashes + + if (name.Length == 0) { return "__EMPTY__"; } + + StringBuilder? builder = null; + var slashCount = 0; + for (var i = 0; i < name.Length; ++i) + { + var @char = name[i]; + + // enforce cap on # path segments and note that trailing slash or DOT are + // discouraged + + if ((@char == '/' || @char == '\\') + && (++slashCount > MaxSlashes || i == name.Length - 1)) + { + EnsureBuilder().Append("SLASH"); + } + else if (@char == '.' && i == name.Length - 1) + { + EnsureBuilder().Append("DOT"); + } + else + { + builder?.Append(@char); + } + + StringBuilder EnsureBuilder() => builder ??= new StringBuilder().Append(name, startIndex: 0, count: i); + } + + return builder?.ToString() ?? name; + } + } + + ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + BusyWaitHelper.WaitAsync( + (@lock: this, leaseClient: this._blobClient.GetBlobLeaseClient()), + (state, token) => state.@lock.TryAcquireAsync(state.leaseClient, token, isRetryAfterCreate: false), + timeout, + minSleepTime: this._options.minBusyWaitSleepTime, + maxSleepTime: this._options.maxBusyWaitSleepTime, + cancellationToken + ); + + private async ValueTask TryAcquireAsync( + BlobLeaseClientWrapper leaseClient, + CancellationToken cancellationToken, + bool isRetryAfterCreate) + { + using var response = await leaseClient.AcquireAsync(this._options.duration, cancellationToken).ConfigureAwait(false); + if (response.IsError) + { + var acquireException = new RequestFailedException(response); + + if (acquireException.ErrorCode == AzureErrors.LeaseAlreadyPresent) { return null; } + + if (acquireException.ErrorCode == AzureErrors.BlobNotFound) + { + // if we just created and it already doesn't exist again, just return null and retry later + if (isRetryAfterCreate) { return null; } + + // create the blob + var metadata = new Dictionary { [CreatedMetadataKey] = DateTime.UtcNow.ToString("o") }; // date value is just for debugging + try { await this._blobClient.CreateIfNotExistsAsync(metadata, cancellationToken).ConfigureAwait(false); } + catch (RequestFailedException createException) + { + // handle the race condition where we try to create and someone else creates it first + return createException.ErrorCode == AzureErrors.LeaseIdMissing + ? default + : throw new AggregateException($"Blob {this._blobClient.Name} does not exist and could not be created. See inner exceptions for details", acquireException, createException); + } + + try { return await this.TryAcquireAsync(leaseClient, cancellationToken, isRetryAfterCreate: true).ConfigureAwait(false); } + catch (Exception retryException) + { + // if the retry fails and we created, attempt deletion to clean things up + try { await this._blobClient.DeleteIfExistsAsync().ConfigureAwait(false); } + catch (RequestFailedException deletionException) when (deletionException.ErrorCode == AzureErrors.LeaseIdMissing) + { + // Handle the race condition where we try to delete and someone else acquired it: + // in that case only the original Exception from TryAcquireAsync should be thrown. + } + catch (Exception deletionException) + { + throw new AggregateException(retryException, deletionException); + } + + throw; + } + } + + throw acquireException; + } + + var shouldDeleteBlob = isRetryAfterCreate + || (await this._blobClient.GetMetadataAsync(leaseClient.LeaseId, cancellationToken).ConfigureAwait(false)).ContainsKey(CreatedMetadataKey); + + var internalHandle = new InternalHandle(leaseClient, ownsBlob: shouldDeleteBlob, @lock: this); + return new AzureBlobLeaseDistributedLockHandle(internalHandle); + } + + internal sealed class InternalHandle : IDistributedSynchronizationHandle, LeaseMonitor.ILeaseHandle + { + private readonly BlobLeaseClientWrapper _leaseClient; + private readonly bool _ownsBlob; + private readonly AzureBlobLeaseDistributedLock _lock; + private readonly LeaseMonitor _leaseMonitor; + + public InternalHandle(BlobLeaseClientWrapper leaseClient, bool ownsBlob, AzureBlobLeaseDistributedLock @lock) + { + this._leaseClient = leaseClient; + this._ownsBlob = ownsBlob; + this._lock = @lock; + this._leaseMonitor = new LeaseMonitor(this); + } + + public CancellationToken HandleLostToken => this._leaseMonitor.HandleLostToken; + + private bool RenewalEnabled => !this._lock._options.renewalCadence.IsInfinite; + + public string LeaseId => this._leaseClient.LeaseId; + + TimeoutValue LeaseMonitor.ILeaseHandle.LeaseDuration => this._lock._options.duration; + + TimeoutValue LeaseMonitor.ILeaseHandle.MonitoringCadence => this.RenewalEnabled ? this._lock._options.renewalCadence : this._lock._options.duration; + + public void Dispose() => this.DisposeSyncViaAsync(); + + public async ValueTask DisposeAsync() + { + // note that we're not trying to be idempotent here since we'll be wrapped + // by AzureBlobLeaseDistributedLockHandle which provides idempotence + + await this._leaseMonitor.DisposeAsync().ConfigureAwait(false); + + // if we own the blob, release by just deleting it + if (this._ownsBlob) + { + await this._lock._blobClient.DeleteIfExistsAsync(leaseId: this._leaseClient.LeaseId).ConfigureAwait(false); + } + else + { + await this._leaseClient.ReleaseAsync().ConfigureAwait(false); + } + } + + async Task LeaseMonitor.ILeaseHandle.RenewOrValidateLeaseAsync(CancellationToken cancellationToken) + { + var task = this.RenewalEnabled + ? this._leaseClient.RenewAsync(cancellationToken).AsTask() + // if we're not renewing, then just touch the blob using the lease to see if someone else has renewed it + : this._lock._blobClient.GetMetadataAsync(this._leaseClient.LeaseId, cancellationToken).AsTask(); + + await task.TryAwait(); + cancellationToken.ThrowIfCancellationRequested(); // if the cancellation caused failure, don't confuse that with losing the handle + return task.Status == TaskStatus.RanToCompletion + ? this.RenewalEnabled + ? LeaseMonitor.LeaseState.Renewed + : LeaseMonitor.LeaseState.Held + : LeaseMonitor.LeaseState.Lost; + } + } +} diff --git a/src/DistributedLock.Azure/AzureBlobLeaseDistributedLockHandle.cs b/src/DistributedLock.Azure/AzureBlobLeaseDistributedLockHandle.cs new file mode 100644 index 00000000..e92a332e --- /dev/null +++ b/src/DistributedLock.Azure/AzureBlobLeaseDistributedLockHandle.cs @@ -0,0 +1,46 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Azure; + +/// +/// Implements +/// +public sealed class AzureBlobLeaseDistributedLockHandle : IDistributedSynchronizationHandle +{ + private AzureBlobLeaseDistributedLock.InternalHandle? _internalHandle; + private IDisposable? _finalizerRegistration; + + internal AzureBlobLeaseDistributedLockHandle(AzureBlobLeaseDistributedLock.InternalHandle internalHandle) + { + this._internalHandle = internalHandle; + // Because this is a lease, managed finalization mostly won't be strictly necessary here. Where it comes in handy is: + // (1) Ensuring blob deletion if we own the blob + // (2) Helping release infinite-duration leases (rare case) + // (3) In testing, avoiding having to wait 15+ seconds for lease expiration + this._finalizerRegistration = ManagedFinalizerQueue.Instance.Register(this, internalHandle); + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => (this._internalHandle ?? throw this.ObjectDisposed()).HandleLostToken; + + /// + /// The underlying Azure lease ID + /// + public string LeaseId => (this._internalHandle ?? throw this.ObjectDisposed()).LeaseId; + + /// + /// Releases the lock + /// + public void Dispose() => this.DisposeSyncViaAsync(); + + /// + /// Releases the lock asynchronously + /// + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref this._finalizerRegistration, null)?.Dispose(); + return Interlocked.Exchange(ref this._internalHandle, null)?.DisposeAsync() ?? default; + } +} diff --git a/src/DistributedLock.Azure/AzureBlobLeaseDistributedSynchronizationProvider.cs b/src/DistributedLock.Azure/AzureBlobLeaseDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..417dba39 --- /dev/null +++ b/src/DistributedLock.Azure/AzureBlobLeaseDistributedSynchronizationProvider.cs @@ -0,0 +1,28 @@ +using Azure.Storage.Blobs; + +namespace Medallion.Threading.Azure; + +/// +/// Implements for +/// +public sealed class AzureBlobLeaseDistributedSynchronizationProvider : IDistributedLockProvider +{ + private readonly BlobContainerClient _blobContainerClient; + private readonly Action? _options; + + /// + /// Constructs a provider that scopes blobs within the provided and uses the provided . + /// + public AzureBlobLeaseDistributedSynchronizationProvider(BlobContainerClient blobContainerClient, Action? options = null) + { + this._blobContainerClient = blobContainerClient ?? throw new ArgumentNullException(nameof(blobContainerClient)); + this._options = options; + } + + /// + /// Constructs an with the given . + /// + public AzureBlobLeaseDistributedLock CreateLock(string name) => new(this._blobContainerClient, name, this._options); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); +} diff --git a/src/DistributedLock.Azure/AzureBlobLeaseOptionsBuilder.cs b/src/DistributedLock.Azure/AzureBlobLeaseOptionsBuilder.cs new file mode 100644 index 00000000..419fe0a4 --- /dev/null +++ b/src/DistributedLock.Azure/AzureBlobLeaseOptionsBuilder.cs @@ -0,0 +1,124 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Azure; + +/// +/// Specifies options for an Azure blob lease +/// +public sealed class AzureBlobLeaseOptionsBuilder +{ + /// + /// From https://docs.microsoft.com/en-us/rest/api/storageservices/lease-blob: + /// "The lock duration can be 15 to 60 seconds, or can be infinite" + /// + internal static readonly TimeoutValue MinLeaseDuration = TimeSpan.FromSeconds(15), + MaxNonInfiniteLeaseDuration = TimeSpan.FromSeconds(60), + DefaultLeaseDuration = TimeSpan.FromSeconds(30); + + private TimeoutValue? _duration, _renewalCadence, _minBusyWaitSleepTime, _maxBusyWaitSleepTime; + + internal AzureBlobLeaseOptionsBuilder() { } + + /// + /// Specifies how long the lease will last, absent auto-renewal. + /// + /// If auto-renewal is enabled (the default), then a shorter duration means more frequent auto-renewal requests, + /// while an infinite duration means no auto-renewal requests. Furthermore, if the lease-holding process were to + /// exit without explicitly releasing, then duration determines how long other processes would need to wait in + /// order to acquire the lease. + /// + /// If auto-renewal is disabled, then duration determines how long the lease will be held. + /// + /// Defaults to 30s. + /// + public AzureBlobLeaseOptionsBuilder Duration(TimeSpan duration) + { + var durationTimeoutValue = new TimeoutValue(duration, nameof(duration)); + if (durationTimeoutValue.CompareTo(MinLeaseDuration) < 0 + || (!durationTimeoutValue.IsInfinite && durationTimeoutValue.CompareTo(MaxNonInfiniteLeaseDuration) > 0)) + { + throw new ArgumentOutOfRangeException(nameof(duration), duration, $"Must be infinite or in [{MinLeaseDuration}, {MaxNonInfiniteLeaseDuration}]"); + } + + this._duration = durationTimeoutValue; + return this; + } + + /// + /// Determines how frequently the lease will be renewed when held. More frequent renewal means more unnecessary requests + /// but also a lower chance of losing the lease due to the process hanging or otherwise failing to get its renewal request in + /// before the lease duration expires. + /// + /// To disable auto-renewal, specify + /// + /// Defaults to 1/3 of the specified lease duration (may be infinite). + /// + public AzureBlobLeaseOptionsBuilder RenewalCadence(TimeSpan renewalCadence) + { + this._renewalCadence = new TimeoutValue(renewalCadence, nameof(renewalCadence)); + return this; + } + + /// + /// Waiting to acquire a lease requires a busy wait that alternates acquire attempts and sleeps. + /// This determines how much time is spent sleeping between attempts. Lower values will raise the + /// volume of acquire requests under contention but will also raise the responsiveness (how long + /// it takes a waiter to notice that a contended the lease has become available). + /// + /// Specifying a range of values allows the implementation to select an actual value in the range + /// at random for each sleep. This helps avoid the case where two clients become "synchronized" + /// in such a way that results in one client monopolizing the lease. + /// + /// The default is [250ms, 1s] + /// + public AzureBlobLeaseOptionsBuilder BusyWaitSleepTime(TimeSpan min, TimeSpan max) + { + var minTimeoutValue = new TimeoutValue(min, nameof(min)); + var maxTimeoutValue = new TimeoutValue(max, nameof(max)); + + if (minTimeoutValue.IsInfinite) { throw new ArgumentOutOfRangeException(nameof(min), "may not be infinite"); } + if (maxTimeoutValue.IsInfinite || maxTimeoutValue.CompareTo(min) < 0) + { + throw new ArgumentOutOfRangeException(nameof(max), max, "must be non-infinite and greater than " + nameof(min)); + } + + this._minBusyWaitSleepTime = minTimeoutValue; + this._maxBusyWaitSleepTime = maxTimeoutValue; + return this; + } + + internal static (TimeoutValue duration, TimeoutValue renewalCadence, TimeoutValue minBusyWaitSleepTime, TimeoutValue maxBusyWaitSleepTime) GetOptions(Action? optionsBuilder) + { + AzureBlobLeaseOptionsBuilder? options; + if (optionsBuilder != null) + { + options = new AzureBlobLeaseOptionsBuilder(); + optionsBuilder(options); + + if (options._renewalCadence is { } renewalCadence && !renewalCadence.IsInfinite) + { + var duration = options._duration ?? DefaultLeaseDuration; + if (renewalCadence.CompareTo(duration) >= 0) + { + throw new ArgumentOutOfRangeException( + nameof(renewalCadence), + renewalCadence.TimeSpan, + $"{nameof(renewalCadence)} must not be larger than {nameof(duration)} ({duration}). To disable auto-renewal, specify {nameof(Timeout)}.{nameof(Timeout.InfiniteTimeSpan)}" + ); + } + } + } + else + { + options = null; + } + + var durationToUse = options?._duration ?? DefaultLeaseDuration; + return ( + duration: durationToUse, + renewalCadence: options?._renewalCadence ?? (durationToUse.IsInfinite ? Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(durationToUse.InMilliseconds / 3.0)), + minBusyWaitSleepTime: options?._minBusyWaitSleepTime ?? TimeSpan.FromMilliseconds(250), + maxBusyWaitSleepTime: options?._maxBusyWaitSleepTime ?? TimeSpan.FromSeconds(1) + ); + } +} diff --git a/src/DistributedLock.Azure/AzureErrors.cs b/src/DistributedLock.Azure/AzureErrors.cs new file mode 100644 index 00000000..01eef28b --- /dev/null +++ b/src/DistributedLock.Azure/AzureErrors.cs @@ -0,0 +1,8 @@ +namespace Medallion.Threading.Azure; + +internal static class AzureErrors +{ + public const string BlobNotFound = nameof(BlobNotFound), + LeaseAlreadyPresent = nameof(LeaseAlreadyPresent), + LeaseIdMissing = nameof(LeaseIdMissing); +} diff --git a/src/DistributedLock.Azure/BlobClientWrapper.cs b/src/DistributedLock.Azure/BlobClientWrapper.cs new file mode 100644 index 00000000..6d9839a2 --- /dev/null +++ b/src/DistributedLock.Azure/BlobClientWrapper.cs @@ -0,0 +1,85 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Blobs.Specialized; +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Azure; + +/// +/// Adds support to +/// +internal class BlobClientWrapper +{ + private readonly BlobBaseClient _blobClient; + + public BlobClientWrapper(BlobBaseClient blobClient) + { + this._blobClient = blobClient; + } + + public string Name => this._blobClient.Name; + + public BlobLeaseClientWrapper GetBlobLeaseClient() => new(this._blobClient.GetBlobLeaseClient()); + + public async ValueTask> GetMetadataAsync(string leaseId, CancellationToken cancellationToken) + { + var conditions = new BlobRequestConditions { LeaseId = leaseId }; + var properties = SyncViaAsync.IsSynchronous + ? this._blobClient.GetProperties(conditions, cancellationToken) + : await this._blobClient.GetPropertiesAsync(conditions, cancellationToken).ConfigureAwait(false); + return properties.Value.Metadata; + } + + public ValueTask CreateIfNotExistsAsync(IDictionary metadata, CancellationToken cancellationToken) + { + switch (this._blobClient) + { + case BlobClient blobClient: + if (SyncViaAsync.IsSynchronous) + { + blobClient.Upload(Stream.Null, metadata: metadata, cancellationToken: cancellationToken); + return default; + } + return new ValueTask(blobClient.UploadAsync(Stream.Null, metadata: metadata, cancellationToken: cancellationToken)); + case BlockBlobClient blockBlobClient: + if (SyncViaAsync.IsSynchronous) + { + blockBlobClient.Upload(Stream.Null, metadata: metadata, cancellationToken: cancellationToken); + return default; + } + return new ValueTask(blockBlobClient.UploadAsync(Stream.Null, metadata: metadata, cancellationToken: cancellationToken)); + case PageBlobClient pageBlobClient: + if (SyncViaAsync.IsSynchronous) + { + pageBlobClient.CreateIfNotExists(size: 0, metadata: metadata, cancellationToken: cancellationToken); + return default; + } + return new ValueTask(pageBlobClient.CreateIfNotExistsAsync(size: 0, metadata: metadata, cancellationToken: cancellationToken)); + case AppendBlobClient appendBlobClient: + if (SyncViaAsync.IsSynchronous) + { + appendBlobClient.CreateIfNotExists(metadata: metadata, cancellationToken: cancellationToken); + return default; + } + return new ValueTask(appendBlobClient.CreateIfNotExistsAsync(metadata: metadata, cancellationToken: cancellationToken)); + default: + throw new InvalidOperationException( + this._blobClient.GetType() == typeof(BlobBaseClient) + ? $"Unable to create a lock blob given client type {typeof(BlobBaseClient)}. Either ensure that the blob exists or use a non-base client type such as {typeof(BlobClient)}" + + " which specifies the type of blob to create" + : $"Unexpected blob client type {this._blobClient.GetType()}" + ); + } + } + + public ValueTask DeleteIfExistsAsync(string? leaseId = null) + { + var conditions = leaseId != null ? new BlobRequestConditions { LeaseId = leaseId } : null; + if (SyncViaAsync.IsSynchronous) + { + this._blobClient.DeleteIfExists(conditions: conditions); + return default; + } + return new ValueTask(this._blobClient.DeleteIfExistsAsync(conditions: conditions)); + } +} diff --git a/src/DistributedLock.Azure/BlobLeaseClientWrapper.cs b/src/DistributedLock.Azure/BlobLeaseClientWrapper.cs new file mode 100644 index 00000000..e0ea120d --- /dev/null +++ b/src/DistributedLock.Azure/BlobLeaseClientWrapper.cs @@ -0,0 +1,46 @@ +using Azure; +using Azure.Storage.Blobs.Specialized; +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Azure; + +/// +/// Adds support to +/// +internal sealed class BlobLeaseClientWrapper(BlobLeaseClient blobLeaseClient) +{ + public string LeaseId => blobLeaseClient.LeaseId; + + public ValueTask AcquireAsync(TimeoutValue duration, CancellationToken cancellationToken) + { + RequestContext requestContext = new() + { + CancellationToken = cancellationToken, + ErrorOptions = ErrorOptions.NoThrow + }; + + return SyncViaAsync.IsSynchronous + ? new ValueTask(blobLeaseClient.Acquire(duration.TimeSpan, conditions: null, requestContext)) + : new ValueTask(blobLeaseClient.AcquireAsync(duration.TimeSpan, conditions: null, requestContext)); + } + + public ValueTask RenewAsync(CancellationToken cancellationToken) + { + if (SyncViaAsync.IsSynchronous) + { + blobLeaseClient.Renew(cancellationToken: cancellationToken); + return default; + } + return new ValueTask(blobLeaseClient.RenewAsync(cancellationToken: cancellationToken)); + } + + public ValueTask ReleaseAsync() + { + if (SyncViaAsync.IsSynchronous) + { + blobLeaseClient.Release(); + return default; + } + return new ValueTask(blobLeaseClient.ReleaseAsync()); + } +} \ No newline at end of file diff --git a/DistributedLock.Azure/DistributedLock.Azure.csproj b/src/DistributedLock.Azure/DistributedLock.Azure.csproj similarity index 69% rename from DistributedLock.Azure/DistributedLock.Azure.csproj rename to src/DistributedLock.Azure/DistributedLock.Azure.csproj index b69da01f..e4adae2b 100644 --- a/DistributedLock.Azure/DistributedLock.Azure.csproj +++ b/src/DistributedLock.Azure/DistributedLock.Azure.csproj @@ -1,22 +1,23 @@ - netstandard2.0;netstandard2.1;net461 + netstandard2.0;netstandard2.1;net462 Medallion.Threading.Azure True 4 Latest enable + enable - 1.0.0-alpha01 + 1.0.2 1.0.0.0 Michael Adelson - TODO + Provides a distributed locking implementation based on Azure blob leases Copyright © 2020 Michael Adelson MIT - distributed lock async waithandle mutex sql postgres + distributed lock async azure blob lease https://github.com/madelson/DistributedLock https://github.com/madelson/DistributedLock 1.0.0.0 @@ -30,6 +31,8 @@ True True + + embedded @@ -39,10 +42,14 @@ - + + + + + \ No newline at end of file diff --git a/src/DistributedLock.Azure/PublicAPI.Shipped.txt b/src/DistributedLock.Azure/PublicAPI.Shipped.txt new file mode 100644 index 00000000..f4b8a6ba --- /dev/null +++ b/src/DistributedLock.Azure/PublicAPI.Shipped.txt @@ -0,0 +1,21 @@ +#nullable enable +Medallion.Threading.Azure.AzureBlobLeaseDistributedLock +Medallion.Threading.Azure.AzureBlobLeaseDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Azure.AzureBlobLeaseDistributedLockHandle! +Medallion.Threading.Azure.AzureBlobLeaseDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Azure.AzureBlobLeaseDistributedLock.AzureBlobLeaseDistributedLock(Azure.Storage.Blobs.BlobContainerClient! blobContainerClient, string! name, System.Action? options = null) -> void +Medallion.Threading.Azure.AzureBlobLeaseDistributedLock.AzureBlobLeaseDistributedLock(Azure.Storage.Blobs.Specialized.BlobBaseClient! blobClient, System.Action? options = null) -> void +Medallion.Threading.Azure.AzureBlobLeaseDistributedLock.Name.get -> string! +Medallion.Threading.Azure.AzureBlobLeaseDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Azure.AzureBlobLeaseDistributedLockHandle? +Medallion.Threading.Azure.AzureBlobLeaseDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Azure.AzureBlobLeaseDistributedLockHandle +Medallion.Threading.Azure.AzureBlobLeaseDistributedLockHandle.Dispose() -> void +Medallion.Threading.Azure.AzureBlobLeaseDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.Azure.AzureBlobLeaseDistributedLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.Azure.AzureBlobLeaseDistributedLockHandle.LeaseId.get -> string! +Medallion.Threading.Azure.AzureBlobLeaseDistributedSynchronizationProvider +Medallion.Threading.Azure.AzureBlobLeaseDistributedSynchronizationProvider.AzureBlobLeaseDistributedSynchronizationProvider(Azure.Storage.Blobs.BlobContainerClient! blobContainerClient, System.Action? options = null) -> void +Medallion.Threading.Azure.AzureBlobLeaseDistributedSynchronizationProvider.CreateLock(string! name) -> Medallion.Threading.Azure.AzureBlobLeaseDistributedLock! +Medallion.Threading.Azure.AzureBlobLeaseOptionsBuilder +Medallion.Threading.Azure.AzureBlobLeaseOptionsBuilder.BusyWaitSleepTime(System.TimeSpan min, System.TimeSpan max) -> Medallion.Threading.Azure.AzureBlobLeaseOptionsBuilder! +Medallion.Threading.Azure.AzureBlobLeaseOptionsBuilder.Duration(System.TimeSpan duration) -> Medallion.Threading.Azure.AzureBlobLeaseOptionsBuilder! +Medallion.Threading.Azure.AzureBlobLeaseOptionsBuilder.RenewalCadence(System.TimeSpan renewalCadence) -> Medallion.Threading.Azure.AzureBlobLeaseOptionsBuilder! \ No newline at end of file diff --git a/src/DistributedLock.Azure/PublicAPI.Unshipped.txt b/src/DistributedLock.Azure/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/DistributedLock.Azure/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/DistributedLock.Azure/packages.lock.json b/src/DistributedLock.Azure/packages.lock.json new file mode 100644 index 00000000..a4c67c2f --- /dev/null +++ b/src/DistributedLock.Azure/packages.lock.json @@ -0,0 +1,542 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "Azure.Storage.Blobs": { + "type": "Direct", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.36.0", + "contentHash": "vwqFZdHS4dzPlI7FFRkPx9ctA+aGGeRev3gnzG8lntWvKMmBhAmulABi1O9CEvS3/jzYt7yA+0pqVdxkpAd7dQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Memory.Data": "1.0.2", + "System.Net.Http": "4.3.4", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "JGkzeqgBsiZwKJZ1IxPNsDFZDhUvuEdX8L8BDC8N3KOj+6zMcNU28CNN59TpZE/VJYy9cP+5M+sbxtWJx3/xtw==", + "dependencies": { + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.6.0" + } + }, + "System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.4", + "contentHash": "aOa2d51SEbmM+H+Csw7yJOuNZoHkrP2XnAurye5HWYgGVVU54YZDvsLUYRv6h18X3sPnjNCANmN7ZhIPiqMcjA==", + "dependencies": { + "System.Security.Cryptography.X509Certificates": "4.3.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==" + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "System.Security.Cryptography.Primitives": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==" + }, + "System.Security.Cryptography.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==" + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "iTUgB/WtrZ1sWZs84F2hwyQhiRH6QNjQv2DkwrH+WP6RoFga2Q1m3f9/Q7FG8cck8AdHitQkmkXSY8qylcDmuA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "4.7.1", + "System.Text.Encodings.Web": "4.7.1", + "System.Threading.Tasks.Extensions": "4.5.4", + "System.ValueTuple": "4.5.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.ValueTuple": { + "type": "CentralTransitive", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + } + }, + ".NETStandard,Version=v2.0": { + "Azure.Storage.Blobs": { + "type": "Direct", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.36.0", + "contentHash": "vwqFZdHS4dzPlI7FFRkPx9ctA+aGGeRev3gnzG8lntWvKMmBhAmulABi1O9CEvS3/jzYt7yA+0pqVdxkpAd7dQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Memory.Data": "1.0.2", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "JGkzeqgBsiZwKJZ1IxPNsDFZDhUvuEdX8L8BDC8N3KOj+6zMcNU28CNN59TpZE/VJYy9cP+5M+sbxtWJx3/xtw==", + "dependencies": { + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.6.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "iTUgB/WtrZ1sWZs84F2hwyQhiRH6QNjQv2DkwrH+WP6RoFga2Q1m3f9/Q7FG8cck8AdHitQkmkXSY8qylcDmuA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "4.7.1", + "System.Text.Encodings.Web": "4.7.1", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + } + }, + ".NETStandard,Version=v2.1": { + "Azure.Storage.Blobs": { + "type": "Direct", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.36.0", + "contentHash": "vwqFZdHS4dzPlI7FFRkPx9ctA+aGGeRev3gnzG8lntWvKMmBhAmulABi1O9CEvS3/jzYt7yA+0pqVdxkpAd7dQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Memory.Data": "1.0.2", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "JGkzeqgBsiZwKJZ1IxPNsDFZDhUvuEdX8L8BDC8N3KOj+6zMcNU28CNN59TpZE/VJYy9cP+5M+sbxtWJx3/xtw==", + "dependencies": { + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.6.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "iTUgB/WtrZ1sWZs84F2hwyQhiRH6QNjQv2DkwrH+WP6RoFga2Q1m3f9/Q7FG8cck8AdHitQkmkXSY8qylcDmuA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "4.7.1", + "System.Text.Encodings.Web": "4.7.1", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.Core/AssemblyAttributes.cs b/src/DistributedLock.Core/AssemblyAttributes.cs new file mode 100644 index 00000000..888ed9ef --- /dev/null +++ b/src/DistributedLock.Core/AssemblyAttributes.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] + +// Note: we allow for internals sharing in release only. This allows us to have certain +// internal APIs which are public in DEBUG and internal in RELEASE. That way, we can't +// build in DEBUG if we rely on internal APIs that are not meant to be public +#if !DEBUG +[assembly: InternalsVisibleTo("DistributedLock.WaitHandles, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.SqlServer, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.Postgres, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.Azure, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.FileSystem, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.Redis, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.ZooKeeper, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.MySql, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.Oracle, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.MongoDB, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +#endif diff --git a/src/DistributedLock.Core/CompositeDistributedSynchronizationHandle.cs b/src/DistributedLock.Core/CompositeDistributedSynchronizationHandle.cs new file mode 100644 index 00000000..d567f0cd --- /dev/null +++ b/src/DistributedLock.Core/CompositeDistributedSynchronizationHandle.cs @@ -0,0 +1,228 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading; + +internal sealed class CompositeDistributedSynchronizationHandle : IDistributedSynchronizationHandle +{ + private RefBox<(IReadOnlyList Handles, CancellationTokenSource? HandleLostSource)>? _box; + + private CompositeDistributedSynchronizationHandle(IReadOnlyList handles) + { + this._box = RefBox.Create((handles, default(CancellationTokenSource))); + } + + public CancellationToken HandleLostToken + { + get + { + var currentBox = Volatile.Read(ref this._box); + + if (currentBox != null + && currentBox.Value.HandleLostSource is null + && CreateLinkedCancellationTokenSource(currentBox.Value.Handles) is { } newHandleLostSource) + { + var newBox = RefBox.Create(currentBox.Value with { HandleLostSource = newHandleLostSource }); + var result = Interlocked.CompareExchange(ref this._box, newBox, comparand: currentBox); + if (result == currentBox) { currentBox = newBox; } + else { newHandleLostSource.Dispose(); } // lost the race + } + + return currentBox is null + ? throw this.ObjectDisposed() + : (currentBox.Value.HandleLostSource?.Token ?? CancellationToken.None); + } + } + + public void Dispose() => this.DisposeSyncViaAsync(); + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref this._box, null) is { } box) + { + try { await DisposeHandlesAsync(box.Value.Handles).ConfigureAwait(false); } + finally { box.Value.HandleLostSource?.Dispose(); } + } + } + + public static IReadOnlyList FromNames(IReadOnlyList names, TState state, Func create) + { + if (names is null) { throw new ArgumentNullException(nameof(names)); } + if (names.Count == 0) { throw new ArgumentException("At least one lock name is required.", nameof(names)); } + if (names.Contains(null)) { throw new ArgumentException("Must not contain null", nameof(names)); } + + return names.Select(n => create(state, n)).ToArray(); + } + + public static async ValueTask TryAcquireAllAsync( + IReadOnlyList primitives, + Func> acquireFunc, + TimeoutValue timeout, + CancellationToken cancellationToken) + where TPrimitive : class + { + if (primitives.Count == 1) + { + return new(await acquireFunc(primitives[0], timeout.TimeSpan, cancellationToken).ConfigureAwait(false) ?? primitives[0].As()); + } + + TimeoutTracker timeoutTracker = new(timeout); + List handles = new(primitives.Count); + CompositeDistributedSynchronizationHandle? result = null; + + try + { + foreach (var primitive in primitives) + { + var handle = await acquireFunc(primitive, timeoutTracker.Remaining, cancellationToken) + .ConfigureAwait(false); + + if (handle is null) + { + return new(primitive); // failure + } + + handles.Add(handle); + } + + result = new(handles); + } + finally + { + if (result is null) + { + await DisposeHandlesAsync(handles).ConfigureAwait(false); + } + } + + return new(result); + } + + public readonly struct AcquireResult(object handleOrFailedPrimitive) + { + public IDistributedSynchronizationHandle? GetHandleOrDefault() => + handleOrFailedPrimitive as IDistributedSynchronizationHandle; + public IDistributedSynchronizationHandle Handle => + this.GetHandleOrDefault() ?? throw new TimeoutException($"Timed out acquiring '{this.GetFailedName()}'"); + + private string GetFailedName() => handleOrFailedPrimitive switch + { + IDistributedLock @lock => @lock.Name, + IDistributedReaderWriterLock @lock => @lock.Name, + IDistributedSemaphore semaphore => semaphore.Name, + _ => handleOrFailedPrimitive.ToString()! + }; + } + + private static CancellationTokenSource? CreateLinkedCancellationTokenSource(IReadOnlyList handles) + { + var cancellableTokens = handles + .Select(h => h.HandleLostToken) + .Where(t => t.CanBeCanceled) + .ToArray(); + + return cancellableTokens.Length > 0 + ? CancellationTokenSource.CreateLinkedTokenSource(cancellableTokens) + : null; + } + + private static async ValueTask DisposeHandlesAsync(IReadOnlyList handles) + { + List? exceptions = null; + // release in reverse order of acquisition + for (var i = handles.Count - 1; i >= 0; i--) + { + try + { + // in most cases Dispose() will call DisposeSyncViaAsync() anyway, but we need to do this to be + // robust to externally-implemented handle types that aren't sync-via-async friendly + if (SyncViaAsync.IsSynchronous) { handles[i].Dispose(); } + else { await handles[i].DisposeAsync().ConfigureAwait(false); } + } + catch (Exception ex) + { + (exceptions ??= []).Add(ex); + } + } + + if (exceptions != null) + { + throw new AggregateException(exceptions); + } + } + + private readonly struct TimeoutTracker(TimeoutValue timeout) + { + private readonly System.Diagnostics.Stopwatch? _stopwatch = timeout.IsInfinite + ? null + : System.Diagnostics.Stopwatch.StartNew(); + + public TimeSpan Remaining => + this._stopwatch is { Elapsed: var elapsed } + ? elapsed >= timeout.TimeSpan + ? TimeSpan.Zero + : timeout.TimeSpan - elapsed + : Timeout.InfiniteTimeSpan; + } +} + +internal static class CompositeDistributedLockHandleExtensions +{ + public static async ValueTask GetHandleOrDefault( + this ValueTask @this) => + (await @this.ConfigureAwait(false)).GetHandleOrDefault(); + + public static async ValueTask GetHandleOrTimeout( + this ValueTask @this) => + (await @this.ConfigureAwait(false)).Handle; + + public static ValueTask TryAcquireAllLocksInternalAsync( + this IDistributedLockProvider provider, + IReadOnlyList names, + TimeoutValue timeout, + CancellationToken cancellationToken) => + CompositeDistributedSynchronizationHandle.TryAcquireAllAsync( + CompositeDistributedSynchronizationHandle.FromNames(names, provider ?? throw new ArgumentNullException(nameof(provider)), static (p, n) => p.CreateLock(n)), + static (p, t, c) => SyncViaAsync.IsSynchronous ? p.TryAcquire(t, c).AsValueTask() : p.TryAcquireAsync(t, c), + timeout, cancellationToken); + + public static ValueTask TryAcquireAllReadLocksInternalAsync( + this IDistributedReaderWriterLockProvider provider, + IReadOnlyList names, + TimeoutValue timeout, + CancellationToken cancellationToken) => + CompositeDistributedSynchronizationHandle.TryAcquireAllAsync( + CompositeDistributedSynchronizationHandle.FromNames( + names, + provider ?? throw new ArgumentNullException(nameof(provider)), + static (p, n) => p.CreateReaderWriterLock(n)), + static (p, t, c) => SyncViaAsync.IsSynchronous ? p.TryAcquireReadLock(t, c).AsValueTask() : p.TryAcquireReadLockAsync(t, c), + timeout, cancellationToken); + + public static ValueTask TryAcquireAllWriteLocksInternalAsync( + this IDistributedReaderWriterLockProvider provider, + IReadOnlyList names, + TimeoutValue timeout, + CancellationToken cancellationToken) => + CompositeDistributedSynchronizationHandle.TryAcquireAllAsync( + CompositeDistributedSynchronizationHandle.FromNames( + names, + provider ?? throw new ArgumentNullException(nameof(provider)), + static (p, n) => p.CreateReaderWriterLock(n)), + static (p, t, c) => SyncViaAsync.IsSynchronous ? p.TryAcquireWriteLock(t, c).AsValueTask() : p.TryAcquireWriteLockAsync(t, c), + timeout, cancellationToken); + + public static ValueTask TryAcquireAllSemaphoresInternalAsync( + this IDistributedSemaphoreProvider provider, + IReadOnlyList names, + int maxCount, + TimeoutValue timeout, + CancellationToken cancellationToken) => + CompositeDistributedSynchronizationHandle.TryAcquireAllAsync( + CompositeDistributedSynchronizationHandle.FromNames( + names, + (provider: provider ?? throw new ArgumentNullException(nameof(provider)), maxCount), + static (s, n) => s.provider.CreateSemaphore(n, s.maxCount)), + static (p, t, c) => SyncViaAsync.IsSynchronous ? p.TryAcquire(t, c).AsValueTask() : p.TryAcquireAsync(t, c), + timeout, + cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.Core/DeadlockException.cs b/src/DistributedLock.Core/DeadlockException.cs new file mode 100644 index 00000000..917b0bf6 --- /dev/null +++ b/src/DistributedLock.Core/DeadlockException.cs @@ -0,0 +1,31 @@ +namespace Medallion.Threading; + +/// +/// An exception that SOME distributed locks will throw under SOME deadlock conditions. Note that even locks +/// that throw this exception under some circumstances cannot detect ALL deadlock conditions +/// +[Serializable] +public sealed class DeadlockException + // for backwards compat + : InvalidOperationException +{ + /// + /// Constructs a new instance of with a default message + /// + public DeadlockException() : this("A deadlock occurred") { } + + /// + /// Constructs an instance of with the given + /// + public DeadlockException(string message) : base(message) { } + + /// + /// Constructs an instance of with the given and + /// + public DeadlockException(string message, Exception innerException) : base(message, innerException) { } + +#if NET8_0_OR_GREATER + [Obsolete] // calls obsolete constructor +#endif + private DeadlockException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) : base(info, context) { } +} diff --git a/DistributedLock.Core/DistributedLock.Core.csproj b/src/DistributedLock.Core/DistributedLock.Core.csproj similarity index 55% rename from DistributedLock.Core/DistributedLock.Core.csproj rename to src/DistributedLock.Core/DistributedLock.Core.csproj index f92f93bc..b21e5b66 100644 --- a/DistributedLock.Core/DistributedLock.Core.csproj +++ b/src/DistributedLock.Core/DistributedLock.Core.csproj @@ -1,22 +1,23 @@ - netstandard2.0;netstandard2.1;net461 + net8.0;netstandard2.0;netstandard2.1;net462 Medallion.Threading True 4 Latest enable + enable - 1.0.0 + 1.0.9 1.0.0.0 Michael Adelson - TODO description + Core interfaces and utilities that support the DistributedLock.* family of packages Copyright © 2020 Michael Adelson MIT - TODO tags + distributed lock async mutex reader writer semaphore https://github.com/madelson/DistributedLock https://github.com/madelson/DistributedLock 1.0.0.0 @@ -30,6 +31,11 @@ True True + + embedded + + true + true @@ -38,8 +44,20 @@ TRACE;DEBUG - - + + + + + + + + + + + + + + + - \ No newline at end of file diff --git a/src/DistributedLock.Core/DistributedLockProviderExtensions.cs b/src/DistributedLock.Core/DistributedLockProviderExtensions.cs new file mode 100644 index 00000000..be307746 --- /dev/null +++ b/src/DistributedLock.Core/DistributedLockProviderExtensions.cs @@ -0,0 +1,75 @@ +// AUTO-GENERATED + +using Medallion.Threading.Internal; + +namespace Medallion.Threading; + +/// +/// Productivity helper methods for +/// +public static class DistributedLockProviderExtensions +{ + # region Single Lock Methods + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle? TryAcquireLock(this IDistributedLockProvider provider, string name, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateLock(name).TryAcquire(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle AcquireLock(this IDistributedLockProvider provider, string name, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateLock(name).Acquire(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask TryAcquireLockAsync(this IDistributedLockProvider provider, string name, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateLock(name).TryAcquireAsync(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask AcquireLockAsync(this IDistributedLockProvider provider, string name, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateLock(name).AcquireAsync(timeout, cancellationToken); + + # endregion + + # region Composite Lock Methods + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static IDistributedSynchronizationHandle? TryAcquireAllLocks(this IDistributedLockProvider provider, IReadOnlyList names, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(static s => s.provider.TryAcquireAllLocksAsync(s.names, s.timeout, s.cancellationToken), (provider, names, timeout, cancellationToken)); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static IDistributedSynchronizationHandle AcquireAllLocks(this IDistributedLockProvider provider, IReadOnlyList names, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(static s => s.provider.AcquireAllLocksAsync(s.names, s.timeout, s.cancellationToken), (provider, names, timeout, cancellationToken)); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static ValueTask TryAcquireAllLocksAsync(this IDistributedLockProvider provider, IReadOnlyList names, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + provider.TryAcquireAllLocksInternalAsync(names, timeout, cancellationToken).GetHandleOrDefault(); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static ValueTask AcquireAllLocksAsync(this IDistributedLockProvider provider, IReadOnlyList names, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + provider.TryAcquireAllLocksInternalAsync(names, timeout, cancellationToken).GetHandleOrTimeout(); + + # endregion +} \ No newline at end of file diff --git a/src/DistributedLock.Core/DistributedReaderWriterLockProviderExtensions.cs b/src/DistributedLock.Core/DistributedReaderWriterLockProviderExtensions.cs new file mode 100644 index 00000000..cf8876a9 --- /dev/null +++ b/src/DistributedLock.Core/DistributedReaderWriterLockProviderExtensions.cs @@ -0,0 +1,131 @@ +// AUTO-GENERATED + +using Medallion.Threading.Internal; + +namespace Medallion.Threading; + +/// +/// Productivity helper methods for +/// +public static class DistributedReaderWriterLockProviderExtensions +{ + # region Single Lock Methods + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle? TryAcquireReadLock(this IDistributedReaderWriterLockProvider provider, string name, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateReaderWriterLock(name).TryAcquireReadLock(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle AcquireReadLock(this IDistributedReaderWriterLockProvider provider, string name, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateReaderWriterLock(name).AcquireReadLock(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask TryAcquireReadLockAsync(this IDistributedReaderWriterLockProvider provider, string name, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateReaderWriterLock(name).TryAcquireReadLockAsync(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask AcquireReadLockAsync(this IDistributedReaderWriterLockProvider provider, string name, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateReaderWriterLock(name).AcquireReadLockAsync(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle? TryAcquireWriteLock(this IDistributedReaderWriterLockProvider provider, string name, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateReaderWriterLock(name).TryAcquireWriteLock(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle AcquireWriteLock(this IDistributedReaderWriterLockProvider provider, string name, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateReaderWriterLock(name).AcquireWriteLock(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask TryAcquireWriteLockAsync(this IDistributedReaderWriterLockProvider provider, string name, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateReaderWriterLock(name).TryAcquireWriteLockAsync(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask AcquireWriteLockAsync(this IDistributedReaderWriterLockProvider provider, string name, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateReaderWriterLock(name).AcquireWriteLockAsync(timeout, cancellationToken); + + # endregion + + # region Composite Lock Methods + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static IDistributedSynchronizationHandle? TryAcquireAllReadLocks(this IDistributedReaderWriterLockProvider provider, IReadOnlyList names, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(static s => s.provider.TryAcquireAllReadLocksAsync(s.names, s.timeout, s.cancellationToken), (provider, names, timeout, cancellationToken)); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static IDistributedSynchronizationHandle AcquireAllReadLocks(this IDistributedReaderWriterLockProvider provider, IReadOnlyList names, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(static s => s.provider.AcquireAllReadLocksAsync(s.names, s.timeout, s.cancellationToken), (provider, names, timeout, cancellationToken)); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static ValueTask TryAcquireAllReadLocksAsync(this IDistributedReaderWriterLockProvider provider, IReadOnlyList names, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + provider.TryAcquireAllReadLocksInternalAsync(names, timeout, cancellationToken).GetHandleOrDefault(); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static ValueTask AcquireAllReadLocksAsync(this IDistributedReaderWriterLockProvider provider, IReadOnlyList names, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + provider.TryAcquireAllReadLocksInternalAsync(names, timeout, cancellationToken).GetHandleOrTimeout(); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static IDistributedSynchronizationHandle? TryAcquireAllWriteLocks(this IDistributedReaderWriterLockProvider provider, IReadOnlyList names, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(static s => s.provider.TryAcquireAllWriteLocksAsync(s.names, s.timeout, s.cancellationToken), (provider, names, timeout, cancellationToken)); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static IDistributedSynchronizationHandle AcquireAllWriteLocks(this IDistributedReaderWriterLockProvider provider, IReadOnlyList names, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(static s => s.provider.AcquireAllWriteLocksAsync(s.names, s.timeout, s.cancellationToken), (provider, names, timeout, cancellationToken)); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static ValueTask TryAcquireAllWriteLocksAsync(this IDistributedReaderWriterLockProvider provider, IReadOnlyList names, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + provider.TryAcquireAllWriteLocksInternalAsync(names, timeout, cancellationToken).GetHandleOrDefault(); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static ValueTask AcquireAllWriteLocksAsync(this IDistributedReaderWriterLockProvider provider, IReadOnlyList names, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + provider.TryAcquireAllWriteLocksInternalAsync(names, timeout, cancellationToken).GetHandleOrTimeout(); + + # endregion +} \ No newline at end of file diff --git a/src/DistributedLock.Core/DistributedSemaphoreProviderExtensions.cs b/src/DistributedLock.Core/DistributedSemaphoreProviderExtensions.cs new file mode 100644 index 00000000..578348ae --- /dev/null +++ b/src/DistributedLock.Core/DistributedSemaphoreProviderExtensions.cs @@ -0,0 +1,75 @@ +// AUTO-GENERATED + +using Medallion.Threading.Internal; + +namespace Medallion.Threading; + +/// +/// Productivity helper methods for +/// +public static class DistributedSemaphoreProviderExtensions +{ + # region Single Lock Methods + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle? TryAcquireSemaphore(this IDistributedSemaphoreProvider provider, string name, int maxCount, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateSemaphore(name, maxCount).TryAcquire(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle AcquireSemaphore(this IDistributedSemaphoreProvider provider, string name, int maxCount, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateSemaphore(name, maxCount).Acquire(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask TryAcquireSemaphoreAsync(this IDistributedSemaphoreProvider provider, string name, int maxCount, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateSemaphore(name, maxCount).TryAcquireAsync(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask AcquireSemaphoreAsync(this IDistributedSemaphoreProvider provider, string name, int maxCount, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateSemaphore(name, maxCount).AcquireAsync(timeout, cancellationToken); + + # endregion + + # region Composite Lock Methods + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static IDistributedSynchronizationHandle? TryAcquireAllSemaphores(this IDistributedSemaphoreProvider provider, IReadOnlyList names, int maxCount, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(static s => s.provider.TryAcquireAllSemaphoresAsync(s.names, s.maxCount, s.timeout, s.cancellationToken), (provider, names, maxCount, timeout, cancellationToken)); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static IDistributedSynchronizationHandle AcquireAllSemaphores(this IDistributedSemaphoreProvider provider, IReadOnlyList names, int maxCount, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(static s => s.provider.AcquireAllSemaphoresAsync(s.names, s.maxCount, s.timeout, s.cancellationToken), (provider, names, maxCount, timeout, cancellationToken)); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static ValueTask TryAcquireAllSemaphoresAsync(this IDistributedSemaphoreProvider provider, IReadOnlyList names, int maxCount, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + provider.TryAcquireAllSemaphoresInternalAsync(names, maxCount, timeout, cancellationToken).GetHandleOrDefault(); + + /// + /// Equivalent to calling for each name in and then + /// on each created instance, combining the results into a composite handle. + /// + public static ValueTask AcquireAllSemaphoresAsync(this IDistributedSemaphoreProvider provider, IReadOnlyList names, int maxCount, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + provider.TryAcquireAllSemaphoresInternalAsync(names, maxCount, timeout, cancellationToken).GetHandleOrTimeout(); + + # endregion +} \ No newline at end of file diff --git a/src/DistributedLock.Core/DistributedUpgradeableReaderWriterLockProviderExtensions.cs b/src/DistributedLock.Core/DistributedUpgradeableReaderWriterLockProviderExtensions.cs new file mode 100644 index 00000000..577db81a --- /dev/null +++ b/src/DistributedLock.Core/DistributedUpgradeableReaderWriterLockProviderExtensions.cs @@ -0,0 +1,50 @@ +// AUTO-GENERATED + +using Medallion.Threading.Internal; + +namespace Medallion.Threading; + +/// +/// Productivity helper methods for +/// +public static class DistributedUpgradeableReaderWriterLockProviderExtensions +{ + # region Single Lock Methods + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedLockUpgradeableHandle? TryAcquireUpgradeableReadLock(this IDistributedUpgradeableReaderWriterLockProvider provider, string name, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateUpgradeableReaderWriterLock(name).TryAcquireUpgradeableReadLock(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedLockUpgradeableHandle AcquireUpgradeableReadLock(this IDistributedUpgradeableReaderWriterLockProvider provider, string name, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateUpgradeableReaderWriterLock(name).AcquireUpgradeableReadLock(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask TryAcquireUpgradeableReadLockAsync(this IDistributedUpgradeableReaderWriterLockProvider provider, string name, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateUpgradeableReaderWriterLock(name).TryAcquireUpgradeableReadLockAsync(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask AcquireUpgradeableReadLockAsync(this IDistributedUpgradeableReaderWriterLockProvider provider, string name, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateUpgradeableReaderWriterLock(name).AcquireUpgradeableReadLockAsync(timeout, cancellationToken); + + # endregion + + # region Composite Lock Methods + + // Composite methods are not supported for IDistributedUpgradeableReaderWriterLock + // because a composite acquire operation must be able to roll back and upgrade does not support that. + + # endregion +} \ No newline at end of file diff --git a/src/DistributedLock.Core/IDistributedLock.cs b/src/DistributedLock.Core/IDistributedLock.cs new file mode 100644 index 00000000..2a8c7576 --- /dev/null +++ b/src/DistributedLock.Core/IDistributedLock.cs @@ -0,0 +1,73 @@ +namespace Medallion.Threading; + +/// +/// A mutex synchronization primitive which can be used to coordinate access to a resource or critical region of code +/// across processes or systems. The scope and capabilities of the lock are dependent on the particular implementation +/// +public interface IDistributedLock +{ + /// + /// A name that uniquely identifies the lock + /// + string Name { get; } + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + IDistributedSynchronizationHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + IDistributedSynchronizationHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); +} diff --git a/src/DistributedLock.Core/IDistributedLockProvider.cs b/src/DistributedLock.Core/IDistributedLockProvider.cs new file mode 100644 index 00000000..6d39b663 --- /dev/null +++ b/src/DistributedLock.Core/IDistributedLockProvider.cs @@ -0,0 +1,14 @@ +// AUTO-GENERATED +namespace Medallion.Threading; + +/// +/// Acts as a factory for instances of a certain type. This interface may be +/// easier to use than in dependency injection scenarios. +/// +public interface IDistributedLockProvider +{ + /// + /// Constructs an instance with the given . + /// + IDistributedLock CreateLock(string name); +} \ No newline at end of file diff --git a/src/DistributedLock.Core/IDistributedReaderWriterLock.cs b/src/DistributedLock.Core/IDistributedReaderWriterLock.cs new file mode 100644 index 00000000..2579875a --- /dev/null +++ b/src/DistributedLock.Core/IDistributedReaderWriterLock.cs @@ -0,0 +1,132 @@ +namespace Medallion.Threading; + +/// +/// Provides distributed locking functionality comparable to +/// +public interface IDistributedReaderWriterLock +{ + /// + /// A name that uniquely identifies the lock + /// + string Name { get; } + + /// + /// Attempts to acquire a READ lock synchronously. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// using (var handle = myLock.TryAcquireReadLock(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + IDistributedSynchronizationHandle? TryAcquireReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires a READ lock synchronously, failing with if the attempt times out. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// using (myLock.AcquireReadLock(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + IDistributedSynchronizationHandle AcquireReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + /// + /// Attempts to acquire a READ lock asynchronously. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// await using (var handle = await myLock.TryAcquireReadLockAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + ValueTask TryAcquireReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires a READ lock asynchronously, failing with if the attempt times out. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// await using (await myLock.AcquireReadLockAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + ValueTask AcquireReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + /// + /// Attempts to acquire a WRITE lock synchronously. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// using (var handle = myLock.TryAcquireWriteLock(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + IDistributedSynchronizationHandle? TryAcquireWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires a WRITE lock synchronously, failing with if the attempt times out. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// using (myLock.AcquireWriteLock(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + IDistributedSynchronizationHandle AcquireWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + /// + /// Attempts to acquire a WRITE lock asynchronously. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// await using (var handle = await myLock.TryAcquireWriteLockAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + ValueTask TryAcquireWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires a WRITE lock asynchronously, failing with if the attempt times out. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// await using (await myLock.AcquireWriteLockAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); +} diff --git a/src/DistributedLock.Core/IDistributedReaderWriterLockProvider.cs b/src/DistributedLock.Core/IDistributedReaderWriterLockProvider.cs new file mode 100644 index 00000000..57ac3d66 --- /dev/null +++ b/src/DistributedLock.Core/IDistributedReaderWriterLockProvider.cs @@ -0,0 +1,14 @@ +// AUTO-GENERATED +namespace Medallion.Threading; + +/// +/// Acts as a factory for instances of a certain type. This interface may be +/// easier to use than in dependency injection scenarios. +/// +public interface IDistributedReaderWriterLockProvider +{ + /// + /// Constructs an instance with the given . + /// + IDistributedReaderWriterLock CreateReaderWriterLock(string name); +} \ No newline at end of file diff --git a/src/DistributedLock.Core/IDistributedSemaphore.cs b/src/DistributedLock.Core/IDistributedSemaphore.cs new file mode 100644 index 00000000..07ec6c91 --- /dev/null +++ b/src/DistributedLock.Core/IDistributedSemaphore.cs @@ -0,0 +1,79 @@ +namespace Medallion.Threading; + +/// +/// A synchronization primitive which restricts access to a resource or critical section of code to a fixed number of concurrent threads/processes. +/// Compare to . +/// +public interface IDistributedSemaphore +{ + /// + /// A name that uniquely identifies the semaphore + /// + string Name { get; } + + /// + /// The maximum number of "tickets" available for the semaphore (ie the number of processes which can acquire + /// the semaphore concurrently). + /// + int MaxCount { get; } + + /// + /// Attempts to acquire a semaphore ticket synchronously. Usage: + /// + /// using (var handle = mySemaphore.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the ticket! */ } + /// } + /// // dispose releases the ticket if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the ticket or null on failure + IDistributedSynchronizationHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires a semaphore ticket synchronously, failing with if the attempt times out. Usage: + /// + /// using (mySemaphore.Acquire(...)) + /// { + /// /* we have the ticket! */ + /// } + /// // dispose releases the ticket + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the ticket + IDistributedSynchronizationHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + /// + /// Attempts to acquire a semaphore ticket asynchronously. Usage: + /// + /// await using (var handle = await mySemaphore.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the ticket! */ } + /// } + /// // dispose releases the ticket if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the ticket or null on failure + ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires a semaphore ticket asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await mySemaphore.AcquireAsync(...)) + /// { + /// /* we have the ticket! */ + /// } + /// // dispose releases the ticket + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the ticket + ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); +} diff --git a/src/DistributedLock.Core/IDistributedSemaphoreProvider.cs b/src/DistributedLock.Core/IDistributedSemaphoreProvider.cs new file mode 100644 index 00000000..71f65b43 --- /dev/null +++ b/src/DistributedLock.Core/IDistributedSemaphoreProvider.cs @@ -0,0 +1,14 @@ +// AUTO-GENERATED +namespace Medallion.Threading; + +/// +/// Acts as a factory for instances of a certain type. This interface may be +/// easier to use than in dependency injection scenarios. +/// +public interface IDistributedSemaphoreProvider +{ + /// + /// Constructs an instance with the given . + /// + IDistributedSemaphore CreateSemaphore(string name, int maxCount); +} \ No newline at end of file diff --git a/src/DistributedLock.Core/IDistributedSynchronizationHandle.cs b/src/DistributedLock.Core/IDistributedSynchronizationHandle.cs new file mode 100644 index 00000000..785f0fea --- /dev/null +++ b/src/DistributedLock.Core/IDistributedSynchronizationHandle.cs @@ -0,0 +1,27 @@ +namespace Medallion.Threading; + +/// +/// A handle to a distributed lock or other synchronization primitive. To unlock/release, +/// simply dispose the handle. +/// +public interface IDistributedSynchronizationHandle + : IDisposable, IAsyncDisposable +{ + /// + /// Gets a instance which may be used to + /// monitor whether the handle to the lock is lost before the handle is + /// disposed. + /// + /// For example, this could happen if the lock is backed by a + /// database and the connection to the database is disrupted. + /// + /// Not all lock types support this; those that don't will return + /// which can be detected by checking . + /// + /// For lock types that do support this, accessing this property may incur additional + /// costs, such as polling to detect connectivity loss. In general, it is only recommended + /// when you (a) will be holding a lock for a long time, (b) have experienced/expect flakiness in holding + /// a lock, and (c) are very sensitive to the lock semantics being violated. + /// + CancellationToken HandleLostToken { get; } +} diff --git a/src/DistributedLock.Core/IDistributedUpgradeableReadLockHandle.cs b/src/DistributedLock.Core/IDistributedUpgradeableReadLockHandle.cs new file mode 100644 index 00000000..328db65c --- /dev/null +++ b/src/DistributedLock.Core/IDistributedUpgradeableReadLockHandle.cs @@ -0,0 +1,27 @@ +namespace Medallion.Threading; + +/// +/// A that can be upgraded to a write lock +/// +public interface IDistributedLockUpgradeableHandle : IDistributedSynchronizationHandle +{ + /// + /// Attempts to upgrade a WRITE lock synchronously. Not compatible with another WRITE lock or a UPGRADE lock + /// + bool TryUpgradeToWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Upgrades to a WRITE lock synchronously. Not compatible with another WRITE lock or a UPGRADE lock + /// + void UpgradeToWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + /// + /// Attempts to upgrade a WRITE lock asynchronously. Not compatible with another WRITE lock or a UPGRADE lock + /// + ValueTask TryUpgradeToWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Upgrades to a WRITE lock asynchronously. Not compatible with another WRITE lock or a UPGRADE lock + /// + ValueTask UpgradeToWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); +} diff --git a/src/DistributedLock.Core/IDistributedUpgradeableReaderWriterLock.cs b/src/DistributedLock.Core/IDistributedUpgradeableReaderWriterLock.cs new file mode 100644 index 00000000..f42f1637 --- /dev/null +++ b/src/DistributedLock.Core/IDistributedUpgradeableReaderWriterLock.cs @@ -0,0 +1,69 @@ +namespace Medallion.Threading; + +/// +/// Extends with the ability to take an "upgrade" lock. Like a read lock, an upgrade lock +/// allows for other concurrent read locks, but not for other upgrade or write locks. However, an upgrade lock can also be upgraded to a write +/// lock without releasing the underlying handle. +/// +public interface IDistributedUpgradeableReaderWriterLock : IDistributedReaderWriterLock +{ + /// + /// Attempts to acquire an UPGRADE lock synchronously. Not compatible with another UPGRADE lock or a WRITE lock. Usage: + /// + /// using (var handle = myLock.TryAcquireUpgradeableReadLock(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + IDistributedLockUpgradeableHandle? TryAcquireUpgradeableReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires an UPGRADE lock synchronously, failing with if the attempt times out. Not compatible with another UPGRADE lock or a WRITE lock. Usage: + /// + /// using (myLock.AcquireUpgradeableReadLock(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + IDistributedLockUpgradeableHandle AcquireUpgradeableReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + /// + /// Attempts to acquire an UPGRADE lock asynchronously. Not compatible with another UPGRADE lock or a WRITE lock. Usage: + /// + /// await using (var handle = await myLock.TryAcquireUpgradeableReadLockAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + ValueTask TryAcquireUpgradeableReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + + /// + /// Acquires an UPGRADE lock asynchronously, failing with if the attempt times out. Not compatible with another UPGRADE lock or a WRITE lock. Usage: + /// + /// await using (await myLock.AcquireUpgradeableReadLockAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + ValueTask AcquireUpgradeableReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); +} diff --git a/src/DistributedLock.Core/IDistributedUpgradeableReaderWriterLockProvider.cs b/src/DistributedLock.Core/IDistributedUpgradeableReaderWriterLockProvider.cs new file mode 100644 index 00000000..e74999f4 --- /dev/null +++ b/src/DistributedLock.Core/IDistributedUpgradeableReaderWriterLockProvider.cs @@ -0,0 +1,14 @@ +// AUTO-GENERATED +namespace Medallion.Threading; + +/// +/// Acts as a factory for instances of a certain type. This interface may be +/// easier to use than in dependency injection scenarios. +/// +public interface IDistributedUpgradeableReaderWriterLockProvider: IDistributedReaderWriterLockProvider +{ + /// + /// Constructs an instance with the given . + /// + IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderWriterLock(string name); +} \ No newline at end of file diff --git a/src/DistributedLock.Core/Internal/AsyncLock.cs b/src/DistributedLock.Core/Internal/AsyncLock.cs new file mode 100644 index 00000000..6c31c3f6 --- /dev/null +++ b/src/DistributedLock.Core/Internal/AsyncLock.cs @@ -0,0 +1,40 @@ +namespace Medallion.Threading.Internal; + +/// +/// An async-based, -friendly mutex based on . We don't expose a +/// method because does not require disposal unless its +/// is accessed +/// +internal readonly struct AsyncLock +{ + private readonly SemaphoreSlim _semaphore; + + private AsyncLock(SemaphoreSlim semaphore) + { + this._semaphore = semaphore; + } + + public static AsyncLock Create() => new(new SemaphoreSlim(initialCount: 1, maxCount: 1)); + + public async ValueTask AcquireAsync(CancellationToken cancellationToken) + { + var handle = await this.TryAcquireAsync(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + Invariant.Require(handle != null); + return handle!; + } + + public async ValueTask TryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) + { + var acquired = SyncViaAsync.IsSynchronous + ? this._semaphore.Wait(timeout.InMilliseconds, cancellationToken) + : await this._semaphore.WaitAsync(timeout.InMilliseconds, cancellationToken).ConfigureAwait(false); + return acquired ? new Handle(this._semaphore) : null; + } + + private sealed class Handle(SemaphoreSlim semaphore) : IDisposable + { + private SemaphoreSlim? _semaphore = semaphore; + + public void Dispose() => Interlocked.Exchange(ref this._semaphore, null)?.Release(); + } +} diff --git a/src/DistributedLock.Core/Internal/BusyWaitHelper.cs b/src/DistributedLock.Core/Internal/BusyWaitHelper.cs new file mode 100644 index 00000000..9dd7931c --- /dev/null +++ b/src/DistributedLock.Core/Internal/BusyWaitHelper.cs @@ -0,0 +1,80 @@ +namespace Medallion.Threading.Internal; + +#if DEBUG +public +#else +internal +#endif + static class BusyWaitHelper +{ + public static async ValueTask WaitAsync( + TState state, + Func> tryGetValue, + TimeoutValue timeout, + TimeoutValue minSleepTime, + TimeoutValue maxSleepTime, + CancellationToken cancellationToken) + where TResult : class + { + Invariant.Require(minSleepTime.CompareTo(maxSleepTime) <= 0); + Invariant.Require(!maxSleepTime.IsInfinite); + + var initialResult = await tryGetValue(state, cancellationToken).ConfigureAwait(false); + if (initialResult != null || timeout.IsZero) + { + return initialResult; + } + + using var _ = CreateMergedCancellationTokenSourceSource(timeout, cancellationToken, out var mergedCancellationToken); + + var random = new Random(Guid.NewGuid().GetHashCode()); + var sleepRangeMillis = maxSleepTime.InMilliseconds - minSleepTime.InMilliseconds; + while (true) + { + var sleepTime = minSleepTime.TimeSpan + TimeSpan.FromMilliseconds(random.NextDouble() * sleepRangeMillis); + try + { + await SyncViaAsync.Delay(sleepTime, mergedCancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (IsTimedOut()) + { + // if we time out while sleeping, always try one more time with just the regular token + return await tryGetValue(state, cancellationToken).ConfigureAwait(false); + } + + try + { + var result = await tryGetValue(state, mergedCancellationToken).ConfigureAwait(false); + if (result != null) { return result; } + } + catch (OperationCanceledException) when (IsTimedOut()) + { + return null; + } + } + + bool IsTimedOut() => + mergedCancellationToken.IsCancellationRequested && !cancellationToken.IsCancellationRequested; + } + + private static IDisposable? CreateMergedCancellationTokenSourceSource(TimeoutValue timeout, CancellationToken cancellationToken, out CancellationToken mergedCancellationToken) + { + if (timeout.IsInfinite) + { + mergedCancellationToken = cancellationToken; + return null; + } + + if (!cancellationToken.CanBeCanceled) + { + var timeoutSource = new CancellationTokenSource(millisecondsDelay: timeout.InMilliseconds); + mergedCancellationToken = timeoutSource.Token; + return timeoutSource; + } + + var mergedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + mergedSource.CancelAfter(timeout.InMilliseconds); + mergedCancellationToken = mergedSource.Token; + return mergedSource; + } +} diff --git a/src/DistributedLock.Core/Internal/Data/ConnectionMonitor.cs b/src/DistributedLock.Core/Internal/Data/ConnectionMonitor.cs new file mode 100644 index 00000000..41d796f5 --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/ConnectionMonitor.cs @@ -0,0 +1,449 @@ +using System.Data; +using System.Data.Common; + +namespace Medallion.Threading.Internal.Data; + +/// +/// Implements keepalive for a which is important for certain providers +/// such as SQL Azure. +/// +/// Also supports more active monitoring for the purposes of implementing +/// +internal sealed class ConnectionMonitor : IAsyncDisposable +{ + /// + /// Weak reference to the underlying . We use a weak reference to + /// avoid the case where our background worker keeps the connection from being GC'd + /// and therefore keeps an abandoned handle from being released + /// + private readonly WeakReference _weakConnection; + /// + /// Caches a handler for so we + /// unregister it in + /// + private readonly StateChangeEventHandler? _stateChangedHandler; + + /// + /// Allows us to avoid running multiple concurrent queries on + /// + private readonly AsyncLock _connectionLock = AsyncLock.Create(); + + /// + /// Tracks whether the connection is externally-owned. For externally owned connections we cannot + /// run any background queries because this might violate threadsafety with whatever the connection + /// owner is doing + /// + private readonly bool _isExternallyOwnedConnection; + + private TimeoutValue _keepaliveCadence = Timeout.InfiniteTimeSpan; + private State _state; + private Dictionary? _monitoringHandleRegistrations; + private CancellationTokenSource? _monitorStateChangedTokenSource; + private Task _monitoringWorkerTask = Task.CompletedTask; + + public ConnectionMonitor(DatabaseConnection connection) + { + this._weakConnection = new WeakReference(connection); + this._isExternallyOwnedConnection = connection.IsExernallyOwned; + // stopped not autostopped here so that the statechange handler will not cause a start + this._state = connection.CanExecuteQueries ? State.Idle : State.Stopped; + Invariant.Require(this._state == State.Stopped || this._isExternallyOwnedConnection); + + if (connection.InnerConnection is DbConnection dbConnection) + { + dbConnection.StateChange += this._stateChangedHandler = this.OnConnectionStateChanged; + } + } + + /// + /// Protects access to all mutable state + /// + private object Lock => this._weakConnection; + + private bool HasRegisteredMonitoringHandlesNoLock => (this._monitoringHandleRegistrations?.Count).GetValueOrDefault() != 0; + + public async ValueTask AcquireConnectionLockAsync(CancellationToken cancellationToken) + { + while (true) + { + ValueTask connectionLockTask; + lock (this.Lock) + { + // If we're monitoring, then the connection will almost constantly be in use. + // Fire state changed to cancel that query and clear it up + if (this._state == State.Active && this.HasRegisteredMonitoringHandlesNoLock) + { + this.FireStateChangedNoLock(); + } + + // By starting the acquisition inside Lock, we should be guaranteed to get in before the worker look can + // take the lock again. This relies on AsyncLock.AcquireAsync being FIFO, which is currently true because of + // how SemaphoreSlim works. However, to be extra robust we do a try-wait here and retry on failure + connectionLockTask = this._connectionLock.TryAcquireAsync(TimeSpan.FromSeconds(2), cancellationToken); + } + + var handle = await connectionLockTask.ConfigureAwait(false); + if (handle != null) { return handle; } + } + } + + public void SetKeepaliveCadence(TimeoutValue keepaliveCadence) + { + Invariant.Require(!this._isExternallyOwnedConnection); + + lock (this.Lock) + { + Invariant.Require(this._state != State.Disposed); + + var originalKeepaliveCadence = this._keepaliveCadence; + this._keepaliveCadence = keepaliveCadence; + + if (!this.StartMonitorWorkerIfNeededNoLock() + && this._state == State.Active + && !this.HasRegisteredMonitoringHandlesNoLock + && keepaliveCadence.CompareTo(originalKeepaliveCadence) < 0) + { + // If we get here, then we already have an active worker performing + // keepalive on a longer cadence. Since that worker is likely asleep, + // we fire state changed to wake it up + this.FireStateChangedNoLock(); + } + } + } + + public IDatabaseConnectionMonitoringHandle GetMonitoringHandle() + { + lock (this.Lock) + { + // Since this will be called via non-thread-safe paths, we do a true + // dispose check. This error should never reach callers unless they are + // using non-thread-safe stuff concurrently + if (this._state == State.Disposed) { throw new ObjectDisposedException(this.GetType().ToString()); } + + // If the connection is already closed, we'll never see a state change + // event for the close so just return an already canceled handle + if (this._state == State.AutoStopped || this._state == State.Stopped) + { + return new AlreadyCanceledHandle(); + } + + // If the connection does not support state monitoring, we can't produce + // a monitoring handle + if (this._stateChangedHandler == null) + { + return NullHandle.Instance; + } + + var hadRegisteredMonitoringHandles = this.HasRegisteredMonitoringHandlesNoLock; + + var connectionLostTokenSource = new CancellationTokenSource(); + var handle = new MonitoringHandle(this, connectionLostTokenSource.Token); + (this._monitoringHandleRegistrations ??= []) + .Add(handle, connectionLostTokenSource); + + if (!this.StartMonitorWorkerIfNeededNoLock() + && !hadRegisteredMonitoringHandles + && this._state == State.Active) + { + // If we get here, it means we already had an active worker which was not monitoring (doing + // keepalive). That worker is likely asleep, so we fire state changed to wake it up and have it + // switch over to monitoring + this.FireStateChangedNoLock(); + } + + return handle; + } + } + + private void ReleaseMonitoringHandle(MonitoringHandle handle) + { + lock (this.Lock) + { + if (this._monitoringHandleRegistrations!.TryGetValue(handle, out var cancellationTokenSource)) + { + this._monitoringHandleRegistrations.Remove(handle); + cancellationTokenSource.Dispose(); + + // If we've removed the last reason to be monitoring, fire state changed to stop the monitoring process. + // Without this, the next query that attempts to acquire the connection lock will not think we are monitoring + // and therefore will not fire state change. Then, it will get stuck waiting for the monitoring query to complete + if (this._monitoringHandleRegistrations.Count == 0 && this._state == State.Active) + { + this.FireStateChangedNoLock(); + } + } + } + } + + private void OnConnectionStateChanged(object sender, StateChangeEventArgs args) + { + if (args.OriginalState == ConnectionState.Open && args.CurrentState != ConnectionState.Open) + { + lock (this.Lock) + { + if (this._state == State.Idle || this._state == State.Active) + { + this._state = State.AutoStopped; + this.CloseOrCancelMonitoringHandleRegistrationsNoLock(isCancel: true); + } + + Invariant.Require(!this.HasRegisteredMonitoringHandlesNoLock); + } + } + else if (args.OriginalState != ConnectionState.Open && args.CurrentState == ConnectionState.Open) + { + lock (this.Lock) + { + if (this._state == State.AutoStopped) + { + this.StartNoLock(); + } + } + } + } + + public void Start() + { + Invariant.Require(!this._isExternallyOwnedConnection); + + lock (this.Lock) + { + Invariant.Require(this._state == State.Stopped); + this.StartNoLock(); + } + } + + private void StartNoLock() + { + this._state = State.Idle; + this.StartMonitorWorkerIfNeededNoLock(); + } + + public ValueTask StopAsync() => this.StopOrDisposeAsync(isDispose: false); + public ValueTask DisposeAsync() => this.StopOrDisposeAsync(isDispose: true); + + private async ValueTask StopOrDisposeAsync(bool isDispose) + { + Task? task; + lock (this.Lock) + { + if (isDispose) + { + this._state = State.Disposed; + } + else + { + Invariant.Require(!this._isExternallyOwnedConnection); + Invariant.Require(this._state != State.Disposed); + this._state = State.Stopped; + } + + // If we have any registered monitoring handles, clear them out. + // We don't cancel them since if the helper was stopped that indicates + // proper disposal rather than loss of the connection + this.CloseOrCancelMonitoringHandleRegistrationsNoLock(isCancel: false); + + task = this._monitoringWorkerTask; + + // Note: synchronous cancel here should be safe because we've already set + // the state to disposed above which the monitoring loop will check if it + // takes over the Cancel() thread. + this._monitorStateChangedTokenSource?.Cancel(); + + // If disposing, unsubscribe from state change tracking. + if (isDispose + && this._stateChangedHandler != null + && this._weakConnection.TryGetTarget(out var connection)) + { + ((DbConnection)connection.InnerConnection).StateChange -= this._stateChangedHandler; + } + } + + if (task != null) + { + await task.AwaitSyncOverAsync().ConfigureAwait(false); + } + } + + private void CloseOrCancelMonitoringHandleRegistrationsNoLock(bool isCancel) + { + Invariant.Require(this._state == State.AutoStopped || this._state == State.Stopped || this._state == State.Disposed); + + if (this._monitoringHandleRegistrations == null) { return; } + + foreach (var kvp in this._monitoringHandleRegistrations) + { + var cancellationTokenSource = kvp.Value; + if (isCancel) + { + // cancel in a background thread in case we have hangs or errors + Task.Run(() => + { + try { cancellationTokenSource.Cancel(); } + finally { cancellationTokenSource.Dispose(); } + }); + } + else + { + cancellationTokenSource.Dispose(); + } + } + this._monitoringHandleRegistrations.Clear(); + } + + private bool StartMonitorWorkerIfNeededNoLock() + { + Invariant.Require(this._state != State.Disposed); + + // never monitor external connections + if (this._isExternallyOwnedConnection) { return false; } + + // If we're in the active state, we already have a worker. If we're not in the idle + // state, we're not supposed to be running + if (this._state != State.Idle) { return false; } + + // skip if there's nothing to do + if (this._keepaliveCadence.IsInfinite && !this.HasRegisteredMonitoringHandlesNoLock) { return false; } + + this._monitorStateChangedTokenSource = new CancellationTokenSource(); + // Set up the task as a continuation on the previous task to avoid concurrency in the case where the previous + // one is spinning down. If we change states in rapid succession we could end up with multiple tasks queued up + // but this shouldn't matter since when the active one ultimately stops all the others will follow in rapid succession + this._monitoringWorkerTask = this._monitoringWorkerTask + .ContinueWith((_, state) => ((ConnectionMonitor)state!).MonitorWorkerLoop(), state: this) + .Unwrap(); + this._state = State.Active; + return true; + } + + private void FireStateChangedNoLock() + { + var monitorStateChangedTokenSource = this._monitorStateChangedTokenSource!; + this._monitorStateChangedTokenSource = new CancellationTokenSource(); + // Canceling asynchronously is important because the Cancel() thread can end up + // running continuations inside the monitoring loop (e. g. see + // https://github.com/madelson/DistributedLock/issues/85). Now that we set the new + // token source before canceling the old one we should avoid that particular issue, but + // it is still safer and easier to reason about not to have that happen. This also ensures + // that FireStateChangedNoLock() always returns quickly, even if the monitoring loop + // were to do some synchronous work on the continuation thread. + Task.Run(() => + { + try { monitorStateChangedTokenSource.Cancel(); } + finally { monitorStateChangedTokenSource.Dispose(); } + }); + } + + private async Task MonitorWorkerLoop() + { + while (await this.TryKeepaliveOrMonitorAsync().ConfigureAwait(false)) + { + // just keep going + } + } + + private async Task TryKeepaliveOrMonitorAsync() + { + // get state + TimeoutValue keepaliveCadence; + bool isMonitoring; + CancellationToken stateChangedToken; + lock (this.Lock) + { + if (this._state != State.Active) { return false; } + + keepaliveCadence = this._keepaliveCadence; + isMonitoring = this.HasRegisteredMonitoringHandlesNoLock; + stateChangedToken = this._monitorStateChangedTokenSource!.Token; + } + + return await (isMonitoring ? this.DoMonitoringAsync(stateChangedToken) : this.DoKeepaliveAsync(keepaliveCadence, stateChangedToken)).ConfigureAwait(false); + } + + private async Task DoMonitoringAsync(CancellationToken cancellationToken) + { + if (!this._weakConnection.TryGetTarget(out var connection)) { return false; } + + // don't pass token here: this should finish quickly and we don't want to throw + using var _ = await this._connectionLock.AcquireAsync(CancellationToken.None).ConfigureAwait(false); + + // 1-min increments is kind of an arbitrary choice. We want to avoid this being too short since each time + // we "come up to breathe" that's a waste of resources. We also want to avoid this being too long since + // in case people have some kind of monitoring set up for hanging queries + await connection.SleepAsync( + sleepTime: TimeSpan.FromMinutes(1), + cancellationToken: cancellationToken, + executor: (command, token) => command.ExecuteNonQueryAsync(token, disallowAsyncCancellation: false, isConnectionMonitoringQuery: true) + ).TryAwait(); + + return true; + } + + private async Task DoKeepaliveAsync(TimeoutValue keepaliveCadence, CancellationToken stateChangedToken) + { + await Task.Delay(keepaliveCadence.InMilliseconds, stateChangedToken).TryAwait(); + if (stateChangedToken.IsCancellationRequested) { return true; } + + // retrieve only after the delay to avoid this reference longer than needed + if (!this._weakConnection.TryGetTarget(out var connection)) { return false; } + + // We do a zero-wait try-lock here because if the connection is in-use then someone is querying with it. In that case, + // There's no need for us to run a keepalive query. Since we are using zero timeout, we don't bother to pass the cancellationToken; + // this saves us from having to handle cancellation exceptions + using var connectionLockHandle = await this._connectionLock.TryAcquireAsync(TimeSpan.Zero, CancellationToken.None).ConfigureAwait(false); + if (connectionLockHandle != null) + { + using var command = connection.CreateCommand(); + command.SetCommandText("SELECT 0 /* DistributedLock connection keepalive */"); + // Since this query is very fast and non-blocking, we don't bother trying to cancel it. This avoids having + // to deal with the overhead of throwing exceptions within ExecuteNonQueryAsync() + await command.ExecuteNonQueryAsync(CancellationToken.None, disallowAsyncCancellation: false, isConnectionMonitoringQuery: true).AsTask().TryAwait(); + } + + return true; + } + + private sealed class MonitoringHandle(ConnectionMonitor keepaliveHelper, CancellationToken cancellationToken) : IDatabaseConnectionMonitoringHandle + { + private ConnectionMonitor? _monitor = keepaliveHelper; + private readonly CancellationToken _connectionLostToken = cancellationToken; + + public CancellationToken ConnectionLostToken => Volatile.Read(ref this._monitor) != null ? this._connectionLostToken : throw new ObjectDisposedException("handle"); + + public void Dispose() => Interlocked.Exchange(ref this._monitor, null)?.ReleaseMonitoringHandle(this); + } + + private sealed class AlreadyCanceledHandle : IDatabaseConnectionMonitoringHandle + { + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + public AlreadyCanceledHandle() + { + this._cancellationTokenSource.Cancel(); + } + + public CancellationToken ConnectionLostToken => this._cancellationTokenSource.Token; + + public void Dispose() => this._cancellationTokenSource.Dispose(); + } + + private sealed class NullHandle : IDatabaseConnectionMonitoringHandle + { + public static readonly NullHandle Instance = new(); + + private NullHandle() { } + + public CancellationToken ConnectionLostToken => CancellationToken.None; + + public void Dispose() { } + } + + private enum State : byte + { + Idle, + Active, + AutoStopped, + Stopped, + Disposed, + } +} diff --git a/src/DistributedLock.Core/Internal/Data/DatabaseCommand.cs b/src/DistributedLock.Core/Internal/Data/DatabaseCommand.cs new file mode 100644 index 00000000..54ff5b82 --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/DatabaseCommand.cs @@ -0,0 +1,204 @@ +using System.Data; +using System.Data.Common; +using System.Runtime.CompilerServices; + +namespace Medallion.Threading.Internal.Data; + +/// +/// Abstraction over for a +/// +#if DEBUG +public +#else +internal +#endif +sealed class DatabaseCommand : IDisposable +{ + private readonly IDbCommand _command; + private readonly DatabaseConnection _connection; + + internal DatabaseCommand(IDbCommand command, DatabaseConnection connection) + { + this._command = command; + this._connection = connection; + } + + public IDataParameterCollection Parameters => this._command.Parameters; + + public void SetCommandText(string sql) => this._command.CommandText = sql; + + public void SetTimeout(TimeoutValue operationTimeout) + { + this._command.CommandTimeout = operationTimeout.IsInfinite + // use the infinite timeout of 0 + // (see https://msdn.microsoft.com/en-us/library/system.data.sqlclient.sqlcommand.commandtimeout%28v=vs.110%29.aspx) + ? 0 + // command timeout is in seconds. We always wait at least the given timeout plus a buffer + : operationTimeout.InSeconds + 30; + } + + public void SetCommandType(CommandType type) + { + this._command.CommandType = type; + } + + public IDbDataParameter AddParameter(string? name = null, object? value = null, DbType? type = null, ParameterDirection? direction = null) + { + var parameter = this._command.CreateParameter(); + if (name != null) { parameter.ParameterName = name; } + if (value != null) { parameter.Value = value; } + if (type != null) { parameter.DbType = type.Value; } + if (direction != null) { parameter.Direction = direction.Value; } + this._command.Parameters.Add(parameter); + return parameter; + } + + #region ---- Execution ---- + public ValueTask ExecuteNonQueryAsync(CancellationToken cancellationToken, bool disallowAsyncCancellation = false) => + this.ExecuteNonQueryAsync(cancellationToken, disallowAsyncCancellation, isConnectionMonitoringQuery: false); + + /// + /// Internal API for + /// + internal ValueTask ExecuteNonQueryAsync(CancellationToken cancellationToken, bool disallowAsyncCancellation, bool isConnectionMonitoringQuery) => + this.ExecuteAsync((c, t) => c.ExecuteNonQueryAsync(t), c => c.ExecuteNonQuery(), cancellationToken, disallowAsyncCancellation, isConnectionMonitoringQuery); + + public ValueTask ExecuteScalarAsync(CancellationToken cancellationToken, bool disallowAsyncCancellation = false) => + this.ExecuteAsync((c, t) => c.ExecuteScalarAsync(t), c => c.ExecuteScalar(), cancellationToken, disallowAsyncCancellation, isConnectionMonitoringQuery: false); + + private async ValueTask ExecuteAsync( + Func> executeAsync, + Func executeSync, + CancellationToken cancellationToken, + bool disallowAsyncCancellation, + bool isConnectionMonitoringQuery) + { + if (!SyncViaAsync.IsSynchronous && this._command is DbCommand dbCommand) + { + if (!cancellationToken.CanBeCanceled) + { + using var _ = await this.AcquireConnectionLockIfNeeded(isConnectionMonitoringQuery).ConfigureAwait(false); + await this.PrepareIfNeededAsync(CancellationToken.None).ConfigureAwait(false); + return await executeAsync(dbCommand, CancellationToken.None).ConfigureAwait(false); + } + else if (!disallowAsyncCancellation) + { + return await this.InternalExecuteAndPropagateCancellationAsync( + (dbCommand, executeAsync), + (state, cancellationToken) => state.executeAsync(state.dbCommand, cancellationToken).AsValueTask(), + cancellationToken, + isConnectionMonitoringQuery + ).ConfigureAwait(false); + } + else + { + // FALL THROUGH + + // note: we can't call ExecuteNonQueryAsync(cancellationToken) or even ExecuteNonQueryAsync() + // here because of a .NET bug (see https://github.com/dotnet/SqlClient/issues/44, + // https://stackoverflow.com/questions/48461567/canceling-query-with-while-loop-hangs-forever) + // The workaround is to fall back to sync cancellation and sync execution in this case + } + } + + if (cancellationToken.CanBeCanceled) + { + // check this first rather than rely on a race between the the cancellation registration and the + // command execution. Note that if SqlCommand.Cancel() is called before the command is executed, this has no effect + cancellationToken.ThrowIfCancellationRequested(); + + var commandBox = new StrongBox(this._command); + + // having the registration offload the cancel loop to a background thread is important, since + // registrations fire synchronously if the token is already canceled + using var registration = cancellationToken.Register(state => Task.Run(async () => + { + var commandBox = (StrongBox)state!; + IDbCommand? command; + while ((command = Volatile.Read(ref commandBox.Value)) != null) + { + try { command.Cancel(); } + catch { /* just ignore errors here */ } + + await Task.Delay(1).ConfigureAwait(false); + } + }), state: commandBox); + + try + { + return await this.InternalExecuteAndPropagateCancellationAsync( + (command: this._command, executeSync), + (state, cancellationToken) => state.executeSync(state.command).AsValueTask(), + cancellationToken, + isConnectionMonitoringQuery + ).ConfigureAwait(false); + } + finally + { + // allows the cancellation loop to exit if it started + Volatile.Write(ref commandBox.Value, null); + } + } + + using var __ = await this.AcquireConnectionLockIfNeeded(isConnectionMonitoringQuery).ConfigureAwait(false); + return executeSync(this._command); + } + + private async ValueTask InternalExecuteAndPropagateCancellationAsync( + TState state, + Func> executeAsync, + CancellationToken cancellationToken, + bool isConnectionMonitoringQuery) + { + Invariant.Require(cancellationToken.CanBeCanceled); + + using var _ = await this.AcquireConnectionLockIfNeeded(isConnectionMonitoringQuery).ConfigureAwait(false); + await this.PrepareIfNeededAsync(cancellationToken).ConfigureAwait(false); + try + { + return await executeAsync(state, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + // Canceled SQL operations throw SqlException/InvalidOperationException instead of OCE. + // That means that downstream operations end up faulted instead of canceled. We + // wrap with OCE here to correctly propagate cancellation + when (cancellationToken.IsCancellationRequested && this._connection.IsCommandCancellationException(ex)) + { + throw new OperationCanceledException( + "Command was canceled", + ex, + cancellationToken + ); + } + } + + private ValueTask PrepareIfNeededAsync(CancellationToken cancellationToken) + { + if (this._connection.ShouldPrepareCommands) + { +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1 + if (!SyncViaAsync.IsSynchronous && this._command is DbCommand dbCommand) + { + return dbCommand.PrepareAsync(cancellationToken).AsValueTask(); + } +#elif !NETSTANDARD2_0 && !NETFRAMEWORK + ERROR +#endif + + this._command.Prepare(); + } + + return default; + } +#endregion + + public void Dispose() => this._command.Dispose(); + + // NOTE: we do not accept cancellation token here since the keepalive lock should never be held for very long except in + // bug scenarios (e. g. multi-threaded use of a connection) + private ValueTask AcquireConnectionLockIfNeeded(bool isConnectionMonitoringQuery) => + isConnectionMonitoringQuery + ? default(IDisposable?).AsValueTask() + : this._connection.ConnectionMonitor?.AcquireConnectionLockAsync(CancellationToken.None).Convert(To.ValueTask) + ?? default(IDisposable?).AsValueTask(); +} diff --git a/src/DistributedLock.Core/Internal/Data/DatabaseConnection.cs b/src/DistributedLock.Core/Internal/Data/DatabaseConnection.cs new file mode 100644 index 00000000..adb3a1c1 --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/DatabaseConnection.cs @@ -0,0 +1,174 @@ +using System.Data; +using System.Data.Common; + +namespace Medallion.Threading.Internal.Data; + +/// +/// Abstraction over that abstracts away the varying async support +/// across platforms, smooths over cancellation behavior, and integrates with +/// +#if DEBUG +public +#else +internal +#endif + abstract class DatabaseConnection : IAsyncDisposable +{ + private IDbTransaction? _transaction; + + protected DatabaseConnection(IDbConnection connection, bool isExternallyOwned) + { + this.InnerConnection = connection; + this.IsExernallyOwned = isExternallyOwned; + this.ConnectionMonitor = new ConnectionMonitor(this); + } + + protected DatabaseConnection(IDbTransaction transaction, bool isExternallyOwned) + : this(transaction.Connection ?? throw new InvalidOperationException("Cannot execute queries against a transaction that has been disposed"), isExternallyOwned) + { + this._transaction = transaction; + } + + internal ConnectionMonitor ConnectionMonitor { get; } + internal IDbConnection InnerConnection { get; } + + public bool HasTransaction => this._transaction != null; + + public bool IsExernallyOwned { get; } + + public abstract bool ShouldPrepareCommands { get; } + + internal bool CanExecuteQueries => this.InnerConnection.State == ConnectionState.Open && (this._transaction == null || this._transaction.Connection != null); + + internal void SetKeepaliveCadence(TimeoutValue cadence) => this.ConnectionMonitor.SetKeepaliveCadence(cadence); + + internal IDatabaseConnectionMonitoringHandle GetConnectionMonitoringHandle() => this.ConnectionMonitor.GetMonitoringHandle(); + + public DatabaseCommand CreateCommand() + { + IDbCommand command; + // Because of Npgsql's command recycling (https://github.com/npgsql/npgsql/blob/main/src/Npgsql/NpgsqlConnection.cs#L566), + // CreateCommand() is not actually thread-safe. Ideally this would use this.ConnectionMonitor.AcquireConnectionLockAsync + // like other operations, but that requires a change to the Core internal API so I'm leaving it for #217. For the current + // issue with Npgsql, merely synchronizing access to this method should be good enough, and ConnectionMonitor makes a + // fine lock object that isn't being used elsewhere (#216) + lock (this.ConnectionMonitor) { command = this.InnerConnection.CreateCommand(); } + command.Transaction = this._transaction; + return new DatabaseCommand(command, this); + } + + // note: we could have this return an IAsyncDisposable which would allow you to close the transaction + // without closing the connection. However, we don't currently have any use-cases for that + public async ValueTask BeginTransactionAsync() + { + Invariant.Require(!this.HasTransaction); + + using var _ = await this.ConnectionMonitor.AcquireConnectionLockAsync(CancellationToken.None).ConfigureAwait(false); + + this._transaction = +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1 + !SyncViaAsync.IsSynchronous && this.InnerConnection is DbConnection dbConnection + ? await dbConnection.BeginTransactionAsync().ConfigureAwait(false) + : +#elif NETSTANDARD2_0 || NETFRAMEWORK +#else + ERROR +#endif + this.InnerConnection.BeginTransaction(); + } + + public async ValueTask OpenAsync(CancellationToken cancellationToken) + { + if ((cancellationToken.CanBeCanceled || !SyncViaAsync.IsSynchronous) + && this.InnerConnection is DbConnection dbConnection) + { + try { await dbConnection.OpenAsync(cancellationToken).ConfigureAwait(false); } + // Oracle can throw OracleException instead of OCE here + catch (Exception ex) when (cancellationToken.IsCancellationRequested && this.IsCommandCancellationException(ex)) + { + throw new OperationCanceledException("Connection open canceled", ex, cancellationToken); + } + } + else + { + cancellationToken.ThrowIfCancellationRequested(); + this.InnerConnection.Open(); + } + + this.ConnectionMonitor.Start(); + } + + public ValueTask CloseAsync() => this.DisposeOrCloseAsync(isDispose: false); + public ValueTask DisposeAsync() => this.DisposeOrCloseAsync(isDispose: true); + + private async ValueTask DisposeOrCloseAsync(bool isDispose) + { + Invariant.Require(isDispose || !this.IsExernallyOwned); + + try + { + await (isDispose ? this.ConnectionMonitor.DisposeAsync() : this.ConnectionMonitor.StopAsync()).ConfigureAwait(false); + } + finally + { + if (!this.IsExernallyOwned) + { + try { await this.DisposeTransactionAsync(isClosingOrDisposingConnection: true).ConfigureAwait(false); } + finally + { +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1 + if (!SyncViaAsync.IsSynchronous && this.InnerConnection is DbConnection dbConnection) + { + await (isDispose ? dbConnection.DisposeAsync() : dbConnection.CloseAsync().AsValueTask()).ConfigureAwait(false); + } + else + { + SyncDisposeConnection(); + } +#elif NETSTANDARD2_0 || NETFRAMEWORK + SyncDisposeConnection(); +#else + ERROR +#endif + } + } + } + + void SyncDisposeConnection() + { + if (isDispose) { this.InnerConnection.Dispose(); } + else { this.InnerConnection.Close(); } + } + } + + public ValueTask DisposeTransactionAsync() => this.DisposeTransactionAsync(isClosingOrDisposingConnection: false); + + private async ValueTask DisposeTransactionAsync(bool isClosingOrDisposingConnection) + { + var transaction = this._transaction; + if (transaction == null) { return; } + this._transaction = null; + + // we don't need the connection lock here if we're closing/disposing, since in that case we stop the monitor first + using var _ = isClosingOrDisposingConnection + ? null + : await this.ConnectionMonitor.AcquireConnectionLockAsync(CancellationToken.None).ConfigureAwait(false); + +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1 + if (!SyncViaAsync.IsSynchronous && transaction is DbTransaction dbTransaction) + { + await dbTransaction.DisposeAsync().ConfigureAwait(false); + return; + } +#elif NETSTANDARD2_0 || NETFRAMEWORK +#else + ERROR +#endif + + transaction.Dispose(); + } + + public abstract bool IsCommandCancellationException(Exception exception); + + public abstract Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor); +} diff --git a/src/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs b/src/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs new file mode 100644 index 00000000..2c5de36f --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs @@ -0,0 +1,232 @@ +using System.Data; + +namespace Medallion.Threading.Internal.Data; + +/// +/// Implements by giving each lock acquisition a dedicated +/// or +/// +#if DEBUG +public +#else +internal +#endif +sealed class DedicatedConnectionOrTransactionDbDistributedLock : IDbDistributedLock +{ + private readonly string _name; + private readonly Func _connectionFactory; + private readonly bool _transactionScopedIfPossible; + private readonly TimeoutValue _keepaliveCadence; + + /// + /// Constructs an instance using the given EXTERNALLY OWNED . + /// + public DedicatedConnectionOrTransactionDbDistributedLock(string name, Func externalConnectionFactory) + // MA: useTransaction:true here is a bit weird. However, in practice this value does not impact the external connection + // flow so it doesn't matter what the value is. + : this(name, externalConnectionFactory, useTransaction: true, keepaliveCadence: Timeout.InfiniteTimeSpan) + { + } + + public DedicatedConnectionOrTransactionDbDistributedLock( + string name, + Func connectionFactory, + bool useTransaction, + TimeoutValue keepaliveCadence) + { + this._name = name; + this._connectionFactory = connectionFactory; + this._transactionScopedIfPossible = useTransaction; + this._keepaliveCadence = keepaliveCadence; + } + + public async ValueTask TryAcquireAsync( + TimeoutValue timeout, + IDbSynchronizationStrategy strategy, + CancellationToken cancellationToken, + IDistributedSynchronizationHandle? contextHandle) + where TLockCookie : class + { + IDistributedSynchronizationHandle? result = null; + IAsyncDisposable? connectionResource = null; + try + { + DatabaseConnection connection; + if (contextHandle != null) + { + connection = this.GetContextHandleConnection(contextHandle); + } + else + { + connectionResource = connection = this._connectionFactory(); + if (connection.IsExernallyOwned) + { + if (!connection.CanExecuteQueries) + { + throw new InvalidOperationException("The connection and/or transaction are disposed or closed"); + } + } + else + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + if (this._transactionScopedIfPossible) // for an internally-owned connection, we must create the transaction + { + await connection.BeginTransactionAsync().ConfigureAwait(false); + } + } + } + + var lockCookie = await strategy.TryAcquireAsync(connection, this._name, timeout, cancellationToken).ConfigureAwait(false); + if (lockCookie != null) + { + result = new Handle(connection, strategy, this._name, lockCookie, transactionScoped: this._transactionScopedIfPossible && connection.HasTransaction, connectionResource); + if (!this._keepaliveCadence.IsInfinite) + { + connection.SetKeepaliveCadence(this._keepaliveCadence); + } + } + } + finally + { + // if we fail to acquire or throw, make sure to clean up the connection + if (result == null && connectionResource != null) + { + await connectionResource.DisposeAsync().ConfigureAwait(false); + } + } + + return result; + } + + private DatabaseConnection GetContextHandleConnection(IDistributedSynchronizationHandle contextHandle) + where TLockCookie : class + { + var connection = ((Handle)contextHandle).Connection; + if (connection == null) { throw new ObjectDisposedException(nameof(contextHandle), "the provided handle is already disposed"); } + return connection; + } + + private sealed class Handle : IDistributedSynchronizationHandle + where TLockCookie : class + { + private InnerHandle? _innerHandle; + private IDisposable? _finalizer; + + public Handle( + DatabaseConnection connection, + IDbSynchronizationStrategy strategy, + string name, + TLockCookie lockCookie, + bool transactionScoped, + IAsyncDisposable? connectionResource) + { + this._innerHandle = new InnerHandle(connection, strategy, name, lockCookie, transactionScoped, connectionResource); + // we don't do managed finalization for externally-owned connections/transactions since it might violate thread-safety + // on those objects (we don't know when they're in use) + this._finalizer = connection.IsExernallyOwned ? null : ManagedFinalizerQueue.Instance.Register(this, this._innerHandle); + } + + public CancellationToken HandleLostToken => Volatile.Read(ref this._innerHandle)?.HandleLostToken ?? throw this.ObjectDisposed(); + + public DatabaseConnection? Connection => Volatile.Read(ref this._innerHandle)?.Connection; + + public void Dispose() => this.DisposeSyncViaAsync(); + + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref this._finalizer, null)?.Dispose(); + return Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; + } + + private sealed class InnerHandle : IAsyncDisposable + { + private static readonly object DisposedSentinel = new(); + + private readonly IDbSynchronizationStrategy _strategy; + private readonly string _name; + private readonly TLockCookie _lockCookie; + private readonly bool _transactionScoped; + private readonly IAsyncDisposable? _connectionResource; + private object? _connectionMonitoringHandleOrDisposedSentinel; + + public InnerHandle( + DatabaseConnection connection, + IDbSynchronizationStrategy strategy, + string name, + TLockCookie lockCookie, + bool transactionScoped, + IAsyncDisposable? connectionResource) + { + this.Connection = connection; + this._strategy = strategy; + this._name = name; + this._lockCookie = lockCookie; + this._transactionScoped = transactionScoped; + this._connectionResource = connectionResource; + } + + public DatabaseConnection Connection { get; } + + public CancellationToken HandleLostToken + { + get + { + var existing = Volatile.Read(ref this._connectionMonitoringHandleOrDisposedSentinel); + + // if we don't have a handle and aren't disposed, try to make a handle + if (existing == null) + { + // tentatively create a new handle and try to assign it + var newHandle = this.Connection.GetConnectionMonitoringHandle(); + existing = Interlocked.CompareExchange(ref this._connectionMonitoringHandleOrDisposedSentinel, newHandle, comparand: null); + + if (existing == null) + { + // we won the race: use our new handle + return newHandle.ConnectionLostToken; + } + + // We lost the race: discard our new handle. + // Existing is now either a handle created in a race with us or the disposed sentinel + newHandle.Dispose(); + } + + if (existing == DisposedSentinel) + { + throw this.ObjectDisposed(); + } + + return ((IDatabaseConnectionMonitoringHandle)existing).ConnectionLostToken; + } + } + + public async ValueTask DisposeAsync() + { + var connectionMonitoringHandleOrDisposedSentinel = Interlocked.Exchange(ref this._connectionMonitoringHandleOrDisposedSentinel, DisposedSentinel); + if (connectionMonitoringHandleOrDisposedSentinel == DisposedSentinel) { return; } + + if (connectionMonitoringHandleOrDisposedSentinel is IDatabaseConnectionMonitoringHandle handle) + { + handle.Dispose(); + } + + try + { + // For transaction-scoped locks, we can sometimes skip the explicit release step. This comes up when either + // (a) We own the connection and therefore the transaction. In this case we're about to dispose the transaction and release that way + // (b) The transaction is dead (e. g. completed or rolled back) in which case the lock has already been released + var canSkipExplicitRelease = + this._transactionScoped && (!this.Connection.IsExernallyOwned || !this.Connection.CanExecuteQueries); + if (!canSkipExplicitRelease) + { + await this._strategy.ReleaseAsync(this.Connection, this._name, this._lockCookie).ConfigureAwait(false); + } + } + finally + { + await (this._connectionResource?.DisposeAsync() ?? default).ConfigureAwait(false); + } + } + } + } +} diff --git a/src/DistributedLock.Core/Internal/Data/IDatabaseConnectionMonitoringHandle.cs b/src/DistributedLock.Core/Internal/Data/IDatabaseConnectionMonitoringHandle.cs new file mode 100644 index 00000000..a4000c5c --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/IDatabaseConnectionMonitoringHandle.cs @@ -0,0 +1,6 @@ +namespace Medallion.Threading.Internal.Data; + +internal interface IDatabaseConnectionMonitoringHandle : IDisposable +{ + CancellationToken ConnectionLostToken { get; } +} diff --git a/src/DistributedLock.Core/Internal/Data/IDbDistributedLock.cs b/src/DistributedLock.Core/Internal/Data/IDbDistributedLock.cs new file mode 100644 index 00000000..775abce3 --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/IDbDistributedLock.cs @@ -0,0 +1,20 @@ +namespace Medallion.Threading.Internal.Data; + +/// +/// There are several strategies for implementing SQL-based locks; this interface +/// abstracts between them to keep the implementation of manageable +/// +#if DEBUG +public +#else +internal +#endif +interface IDbDistributedLock +{ + // the contextHandle argument to this method is used when acquiring a nested lock, such as upgrading + // from an upgradeable read lock to a write lock. This allows the implementation to use the same connection + // for the nested lock + + ValueTask TryAcquireAsync(TimeoutValue timeout, IDbSynchronizationStrategy strategy, CancellationToken cancellationToken, IDistributedSynchronizationHandle? contextHandle) + where TLockCookie : class; +} diff --git a/src/DistributedLock.Core/Internal/Data/IDbSynchronizationStrategy.cs b/src/DistributedLock.Core/Internal/Data/IDbSynchronizationStrategy.cs new file mode 100644 index 00000000..bc1249cb --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/IDbSynchronizationStrategy.cs @@ -0,0 +1,29 @@ +namespace Medallion.Threading.Internal.Data; + +/// +/// Represents a "locking algorithm" implemented in SQL +/// +#if DEBUG +public +#else +internal +#endif +interface IDbSynchronizationStrategy + where TLockCookie : class +{ + /// + /// True iff the lock taken by the algorithm can be upgraded on the same connection (basically for upgradeable read locks). + /// + /// We need this property because the multiplexing approach has to avoid multiplexing upgradeable locks since they may block + /// indefinitely on the held connection (which would prevent other locks on that connection from releasing) during an upgrade + /// operation. + /// + bool IsUpgradeable { get; } + + /// + /// Attempts to acquire the lock, returning either null for failure or a non-null state "cookie" on success + /// + ValueTask TryAcquireAsync(DatabaseConnection connection, string resourceName, TimeoutValue timeout, CancellationToken cancellationToken); + + ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, TLockCookie lockCookie); +} diff --git a/src/DistributedLock.Core/Internal/Data/MultiplexedConnectionLock.cs b/src/DistributedLock.Core/Internal/Data/MultiplexedConnectionLock.cs new file mode 100644 index 00000000..ad8a3cd4 --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/MultiplexedConnectionLock.cs @@ -0,0 +1,289 @@ +namespace Medallion.Threading.Internal.Data; + +/// +/// Allows multiple SQL application locks to be taken on a single connection. +/// +/// This class is thread-safe except for +/// +internal sealed class MultiplexedConnectionLock : IAsyncDisposable +{ + /// + /// Protects access to and + /// + private readonly AsyncLock _mutex = AsyncLock.Create(); + private readonly Dictionary _heldLocksToKeepaliveCadences = []; + private readonly DatabaseConnection _connection; + /// + /// Tracks whether we've successfully opened the connection. We track this explicity instead of just looking at + /// because we want to make sure we close() explicitly for every + /// open() and also we want to make sure we do not try to re-open a broken connection. + /// + private bool _connectionOpened; + + public MultiplexedConnectionLock(DatabaseConnection connection) + { + this._connection = connection; + } + + private bool IsConnectionBrokenNoLock => this._connectionOpened && !this._connection.CanExecuteQueries; + + public async ValueTask TryAcquireAsync( + string name, + TimeoutValue timeout, + IDbSynchronizationStrategy strategy, + TimeoutValue keepaliveCadence, + CancellationToken cancellationToken, + bool opportunistic) + where TLockCookie : class + { + using var mutexHandle = await this._mutex.TryAcquireAsync(opportunistic ? TimeSpan.Zero : Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + if (mutexHandle == null) + { + // mutex wasn't free, so just give up + Invariant.Require(opportunistic); + // The current lock is busy so we allow retry but on a different lock instance. We can't safely dispose + // since we never acquired the mutex so we can't check _heldLocks + return new Result(MultiplexedConnectionLockRetry.Retry, canSafelyDispose: false); + } + + // This is technically redundant with the similar catch block below, but avoids needing to have + // to attempt a query on a connection that we know is broken. + if (opportunistic && this.IsConnectionBrokenNoLock) { return this.GetAlreadyBrokenResultNoLock(); } + + try + { + if (this._heldLocksToKeepaliveCadences.ContainsKey(name)) + { + // we won't try to hold the same lock twice on one connection. At some point, we could + // support this case in-memory using a counter for each multiply-held lock name and being careful + // with modes + return this.GetFailureResultNoLock(isAlreadyHeld: true, opportunistic, timeout); + } + + if (!this._connectionOpened) + { + await this._connection.OpenAsync(cancellationToken).ConfigureAwait(false); + this._connectionOpened = true; + } + + var lockCookie = await strategy.TryAcquireAsync(this._connection, name, opportunistic ? TimeSpan.Zero : timeout, cancellationToken).ConfigureAwait(false); + if (lockCookie != null) + { + var handle = new ManagedFinalizationDistributedLockHandle(new Handle(this, strategy, name, lockCookie)); + this._heldLocksToKeepaliveCadences.Add(name, keepaliveCadence); + if (!keepaliveCadence.IsInfinite) { this.SetKeepaliveCadenceNoLock(); } + return new Result(handle); + } + + // we failed to acquire the lock, so we should retry if we were being opportunistic and artificially + // shortened the timeout + return this.GetFailureResultNoLock(isAlreadyHeld: false, opportunistic, timeout); + } + // never punish for the connection being broken already (see https://github.com/madelson/DistributedLock/issues/83) + catch when (opportunistic && this.IsConnectionBrokenNoLock) + { + return this.GetAlreadyBrokenResultNoLock(); + } + finally + { + await this.CloseConnectionIfNeededNoLockAsync().ConfigureAwait(false); + } + } + + public ValueTask DisposeAsync() + { + Invariant.Require(this._heldLocksToKeepaliveCadences.Count == 0); + + return this._connection.DisposeAsync(); + } + + public async ValueTask GetIsInUseAsync() + { + using var mutexHandle = await this._mutex.TryAcquireAsync(TimeSpan.Zero, CancellationToken.None).ConfigureAwait(false); + return mutexHandle == null || this._heldLocksToKeepaliveCadences.Count != 0; + } + + private Result GetAlreadyBrokenResultNoLock() => + // Retry on any already-broken connection to avoid "leaking" the killing or death of connections. We want there to be no observable + // results (other than perf) of multiplexing vs. not. + new(MultiplexedConnectionLockRetry.Retry, canSafelyDispose: this._heldLocksToKeepaliveCadences.Count == 0); + + private Result GetFailureResultNoLock(bool isAlreadyHeld, bool opportunistic, TimeoutValue timeout) + { + // only opportunistic acquisitions trigger retries + if (!opportunistic) + { + return new Result(MultiplexedConnectionLockRetry.NoRetry, canSafelyDispose: this._heldLocksToKeepaliveCadences.Count == 0); + } + + if (isAlreadyHeld) + { + // We're already holding the lock so we allow retry but on a different lock instance. + // We can't safely dispose because we're holding the lock + return new Result(MultiplexedConnectionLockRetry.Retry, canSafelyDispose: false); + } + + // if we get here, we failed due to a timeout + var isHoldingLocks = this._heldLocksToKeepaliveCadences.Count != 0; + + if (timeout.IsZero) + { + // if acquire timed out and the caller requested a zero timeout, that's conventional failure + // and we shouldn't retry + return new Result(MultiplexedConnectionLockRetry.NoRetry, canSafelyDispose: !isHoldingLocks); + } + + if (isHoldingLocks) + { + // if we're holding other locks, then we should retry on another lock + return new Result(MultiplexedConnectionLockRetry.Retry, canSafelyDispose: false); + } + + // If we're not holding anything, then it's safe to retry on this instance since we can't + // possibly block a release. It's also safe to dispose this lock, but that won't happen since + // we're going to re-try on it instead + return new Result(MultiplexedConnectionLockRetry.RetryOnThisLock, canSafelyDispose: true); + } + + private async ValueTask ReleaseAsync(IDbSynchronizationStrategy strategy, string name, TLockCookie lockCookie) + where TLockCookie : class + { + using var _ = await this._mutex.AcquireAsync(CancellationToken.None).ConfigureAwait(false); + try + { + await strategy.ReleaseAsync(this._connection, name, lockCookie).ConfigureAwait(false); + } + finally + { + if (this._heldLocksToKeepaliveCadences.TryGetValue(name, out var keepaliveCadence)) + { + this._heldLocksToKeepaliveCadences.Remove(name); + if (!keepaliveCadence.IsInfinite) + { + // note: we do this even if we're about to close the connection because we'll want + // the correct cadence set when and if we re-open + this.SetKeepaliveCadenceNoLock(); + } + } + await this.CloseConnectionIfNeededNoLockAsync().ConfigureAwait(false); + } + } + + private async ValueTask CloseConnectionIfNeededNoLockAsync() + { + if (this._connectionOpened && this._heldLocksToKeepaliveCadences.Count == 0) + { + await this._connection.CloseAsync().ConfigureAwait(false); + this._connectionOpened = false; + } + } + + private void SetKeepaliveCadenceNoLock() + { + TimeoutValue minCadence = Timeout.InfiniteTimeSpan; + foreach (var kvp in this._heldLocksToKeepaliveCadences) + { + if (kvp.Value.CompareTo(minCadence) < 0) + { + minCadence = kvp.Value; + } + } + this._connection.SetKeepaliveCadence(minCadence); + } + + public readonly struct Result + { + public Result(IDistributedSynchronizationHandle handle) + { + this.Handle = handle; + this.Retry = MultiplexedConnectionLockRetry.NoRetry; + this.CanSafelyDispose = false; // since we have handle + } + + public Result(MultiplexedConnectionLockRetry retry, bool canSafelyDispose) + { + this.Handle = null; + this.Retry = retry; + this.CanSafelyDispose = canSafelyDispose; + } + + public IDistributedSynchronizationHandle? Handle { get; } + public MultiplexedConnectionLockRetry Retry { get; } + public bool CanSafelyDispose { get; } + } + + private sealed class Handle : IDistributedSynchronizationHandle + where TLockCookie : class + { + private readonly string _name; + private RefBox<(MultiplexedConnectionLock @lock, IDbSynchronizationStrategy strategy, TLockCookie lockCookie, IDatabaseConnectionMonitoringHandle? monitoringHandle)>? _box; + + public Handle(MultiplexedConnectionLock @lock, IDbSynchronizationStrategy strategy, string name, TLockCookie lockCookie) + { + this._name = name; + this._box = RefBox.Create((@lock, strategy, lockCookie, default(IDatabaseConnectionMonitoringHandle))); + } + + public CancellationToken HandleLostToken + { + get + { + var currentBox = Volatile.Read(ref this._box); + + if (currentBox != null && currentBox.Value.monitoringHandle == null) + { + var newHandle = currentBox.Value.@lock._connection.ConnectionMonitor.GetMonitoringHandle(); + var newBox = RefBox.Create(currentBox.Value with { monitoringHandle = newHandle }); + var result = Interlocked.CompareExchange(ref this._box, newBox, comparand: currentBox); + if (result == currentBox) { currentBox = newBox; } + else { newHandle.Dispose(); } // lost the race + } + + // Now the handle must exist or we must be disposed + return currentBox?.Value.monitoringHandle!.ConnectionLostToken ?? throw this.ObjectDisposed(); + } + } + + public ValueTask DisposeAsync() + { + if (RefBox.TryConsume(ref this._box, out var contents)) + { + contents.monitoringHandle?.Dispose(); + return contents.@lock.ReleaseAsync(contents.strategy, this._name, contents.lockCookie); + } + + return default; + } + + void IDisposable.Dispose() => this.DisposeSyncViaAsync(); + } + + private sealed class ManagedFinalizationDistributedLockHandle : IDistributedSynchronizationHandle + { + private readonly IDistributedSynchronizationHandle _innerHandle; + private readonly IDisposable _finalizerRegistration; + + public ManagedFinalizationDistributedLockHandle(IDistributedSynchronizationHandle innerHandle) + { + this._innerHandle = innerHandle; + this._finalizerRegistration = ManagedFinalizerQueue.Instance.Register(this, innerHandle); + } + + public CancellationToken HandleLostToken => this._innerHandle.HandleLostToken; + + public void Dispose() => this.DisposeSyncViaAsync(); + + public ValueTask DisposeAsync() + { + this._finalizerRegistration.Dispose(); + return this._innerHandle.DisposeAsync(); + } + } +} + +internal enum MultiplexedConnectionLockRetry +{ + NoRetry, + RetryOnThisLock, + Retry, +} diff --git a/src/DistributedLock.Core/Internal/Data/MultiplexedConnectionLockPool.cs b/src/DistributedLock.Core/Internal/Data/MultiplexedConnectionLockPool.cs new file mode 100644 index 00000000..fc968650 --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/MultiplexedConnectionLockPool.cs @@ -0,0 +1,206 @@ +namespace Medallion.Threading.Internal.Data; + +/// +/// Implements a pool of instances +/// +#if DEBUG +public +#else +internal +#endif + sealed class MultiplexedConnectionLockPool +{ + private readonly AsyncLock _lock = AsyncLock.Create(); + + private readonly Dictionary> _poolsByConnectionString = []; + + /// + /// The number of times we've called + /// since we last called + /// + private uint _storeCountSinceLastPrune; + /// + /// The number of s stored in + /// + private uint _pooledLockCount; + + public MultiplexedConnectionLockPool(Func connectionFactory) + { + this.ConnectionFactory = connectionFactory; + } + + internal Func ConnectionFactory { get; } + + public async ValueTask TryAcquireAsync( + string connectionString, + string name, + TimeoutValue timeout, + IDbSynchronizationStrategy strategy, + TimeoutValue keepaliveCadence, + CancellationToken cancellationToken) + where TLockCookie : class + { + // opportunistic phase: see if we can use a connection that is already holding a lock + // to acquire the current lock + var existingLock = await this.GetExistingLockOrDefaultAsync(connectionString).ConfigureAwait(false); + if (existingLock != null) + { + var canSafelyDisposeExistingLock = false; + try + { + var opportunisticResult = await TryAcquireAsync(existingLock, opportunistic: true).ConfigureAwait(false); + if (opportunisticResult.Handle != null) { return opportunisticResult.Handle; } + // this will always be false if handle is non-null, so we can set if afterwards + canSafelyDisposeExistingLock = opportunisticResult.CanSafelyDispose; + + switch (opportunisticResult.Retry) + { + case MultiplexedConnectionLockRetry.NoRetry: + return null; + case MultiplexedConnectionLockRetry.RetryOnThisLock: + var retryOnThisLockResult = await TryAcquireAsync(existingLock, opportunistic: false).ConfigureAwait(false); + canSafelyDisposeExistingLock = retryOnThisLockResult.CanSafelyDispose; + return retryOnThisLockResult.Handle; + case MultiplexedConnectionLockRetry.Retry: + break; + default: + throw new InvalidOperationException("unexpected retry"); + } + } + finally + { + // since we took this lock from the pool, always return it to the pool + await this.StoreOrDisposeLockAsync(connectionString, existingLock, shouldDispose: canSafelyDisposeExistingLock).ConfigureAwait(false); + } + } + + // normal phase: if we were not able to be opportunistic, ensure that we have a lock + var @lock = new MultiplexedConnectionLock(this.ConnectionFactory(connectionString)); + MultiplexedConnectionLock.Result? result = null; + try + { + result = await TryAcquireAsync(@lock, opportunistic: false).ConfigureAwait(false); + Invariant.Require(result!.Value.Retry == MultiplexedConnectionLockRetry.NoRetry, "Acquire on fresh lock should not recommend a retry"); + } + finally + { + // if we failed to even acquire a result on a brand new lock, then there's definitely no reason to store it + await this.StoreOrDisposeLockAsync(connectionString, @lock, shouldDispose: result?.CanSafelyDispose ?? true).ConfigureAwait(false); + } + return result.Value.Handle; + + ValueTask TryAcquireAsync(MultiplexedConnectionLock @lock, bool opportunistic) => + @lock.TryAcquireAsync(name, timeout, strategy, keepaliveCadence, cancellationToken, opportunistic); + } + + private async ValueTask GetExistingLockOrDefaultAsync(string connectionString) + { + using var _ = await this._lock.AcquireAsync(CancellationToken.None).ConfigureAwait(false); + + if (this._poolsByConnectionString.TryGetValue(connectionString, out var pool) && pool.Count != 0) + { + --this._pooledLockCount; + return pool.Dequeue(); + } + + return null; + } + + private async ValueTask StoreOrDisposeLockAsync(string connectionString, MultiplexedConnectionLock @lock, bool shouldDispose) + { + if (shouldDispose) + { + try { await @lock.DisposeAsync().ConfigureAwait(false); } + catch { /* swallow */ } + } + + using (await this._lock.AcquireAsync(CancellationToken.None).ConfigureAwait(false)) + { + ++this._storeCountSinceLastPrune; + + if (shouldDispose) + { + // If we're about to dispose the lock, check if it has an empty pool that can be removed from our dictionary. + // By itself this doesn't guarantee cleanup: after a successful acquire we'll have an empty lock left over that won't + // go away unless we use THAT connection string again. To help with this, we have pruning + if (this._poolsByConnectionString.TryGetValue(connectionString, out var pool) && pool.Count == 0) + { + this._poolsByConnectionString.Remove(connectionString); + } + } + else // otherwise, store the lock + { + ++this._pooledLockCount; + + if (this._poolsByConnectionString.TryGetValue(connectionString, out var existing)) + { + existing.Enqueue(@lock); + } + else + { + var newPool = new Queue(); + newPool.Enqueue(@lock); + this._poolsByConnectionString.Add(connectionString, newPool); + } + } + + if (this.IsDueForPruningNoLock()) + { + await this.PrunePoolsNoLockAsync().ConfigureAwait(false); + } + } + } + + private bool IsDueForPruningNoLock() + { + // Since pruning is expensive, we want to amortize its cost across many operations. The idea here is + // that each StoreOrDisposeLockAsync() call gives us one "ticket" that we can cache in later to justify + // some pruning work. The cost to prune is equal to the number of queues to scan plus the total number of + // items in each queue. Therefore we prune when we've built up enough tickets to "pay for" a pruning operation. + // The whole reason to prune is to avoid memory bloat (connection bloat isn't an issue since we only keep connections + // open when needed). So, we don't even consider pruning below a certain storage threshold + + var pruningCost = this._pooledLockCount + this._poolsByConnectionString.Count; + return pruningCost > 64 && this._storeCountSinceLastPrune >= pruningCost; + } + + private async ValueTask PrunePoolsNoLockAsync() + { + this._storeCountSinceLastPrune = 0; // reset + + List? connectionStringsToRemove = null; + foreach (var kvp in this._poolsByConnectionString) + { + var pool = kvp.Value; + MultiplexedConnectionLock? firstRetainedLock = null; + while (pool.Count != 0 && pool.Peek() != firstRetainedLock) + { + var @lock = pool.Dequeue(); + if (await @lock.GetIsInUseAsync().ConfigureAwait(false)) + { + firstRetainedLock ??= @lock; + pool.Enqueue(@lock); + } + else + { + --this._pooledLockCount; + try { await @lock.DisposeAsync().ConfigureAwait(false); } + catch { /* swallow */ } + } + } + + if (pool.Count == 0) + { + (connectionStringsToRemove ??= new List()).Add(kvp.Key); + } + } + + if (connectionStringsToRemove != null) + { + foreach (var connectionStringToRemove in connectionStringsToRemove) + { + this._poolsByConnectionString.Remove(connectionStringToRemove); + } + } + } +} diff --git a/src/DistributedLock.Core/Internal/Data/OptimisticConnectionMultiplexingDbDistributedLock.cs b/src/DistributedLock.Core/Internal/Data/OptimisticConnectionMultiplexingDbDistributedLock.cs new file mode 100644 index 00000000..deb72111 --- /dev/null +++ b/src/DistributedLock.Core/Internal/Data/OptimisticConnectionMultiplexingDbDistributedLock.cs @@ -0,0 +1,53 @@ +namespace Medallion.Threading.Internal.Data; + +/// +/// Implements by multiplexing across connections where possible +/// +#if DEBUG +public +#else +internal +#endif +sealed class OptimisticConnectionMultiplexingDbDistributedLock : IDbDistributedLock +{ + private readonly string _name, _connectionString; + private readonly MultiplexedConnectionLockPool _multiplexedConnectionLockPool; + private readonly TimeoutValue _keepaliveCadence; + private readonly IDbDistributedLock _fallbackLock; + + public OptimisticConnectionMultiplexingDbDistributedLock( + string name, + string connectionString, + MultiplexedConnectionLockPool multiplexedConnectionLockPool, + TimeoutValue keepaliveCadence) + { + this._name = name; + this._connectionString = connectionString; + this._multiplexedConnectionLockPool = multiplexedConnectionLockPool; + this._keepaliveCadence = keepaliveCadence; + this._fallbackLock = new DedicatedConnectionOrTransactionDbDistributedLock( + name, + () => this._multiplexedConnectionLockPool.ConnectionFactory(this._connectionString), + useTransaction: false, + keepaliveCadence: keepaliveCadence + ); + } + + public ValueTask TryAcquireAsync( + TimeoutValue timeout, + IDbSynchronizationStrategy strategy, + CancellationToken cancellationToken, + IDistributedSynchronizationHandle? contextHandle) + where TLockCookie : class + { + // cannot multiplex for updates, since we cannot predict whether or not there will be a request to elevate + // to an exclusive lock which asks for a long timeout + if (!strategy.IsUpgradeable && contextHandle == null) + { + return this._multiplexedConnectionLockPool.TryAcquireAsync(this._connectionString, this._name, timeout, strategy, keepaliveCadence: this._keepaliveCadence, cancellationToken); + } + + // otherwise, fall back to our fallback lock + return this._fallbackLock.TryAcquireAsync(timeout, strategy, cancellationToken, contextHandle); + } +} diff --git a/src/DistributedLock.Core/Internal/DistributedLockHelpers.cs b/src/DistributedLock.Core/Internal/DistributedLockHelpers.cs new file mode 100644 index 00000000..7427b731 --- /dev/null +++ b/src/DistributedLock.Core/Internal/DistributedLockHelpers.cs @@ -0,0 +1,145 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Medallion.Threading.Internal; + +#if DEBUG +public +#else +internal +#endif +static class DistributedLockHelpers +{ + public static string ToSafeName(string name, int maxNameLength, Func convertToValidName) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + var validBaseLockName = convertToValidName(name); + if (validBaseLockName == name && validBaseLockName.Length <= maxNameLength) + { + return name; + } + + using var sha = SHA512.Create(); + var hash = Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(name))); + + if (hash.Length >= maxNameLength) + { + return hash.Substring(0, length: maxNameLength); + } + + var prefix = validBaseLockName.Substring(0, Math.Min(validBaseLockName.Length, maxNameLength - hash.Length)); + return prefix + hash; + } + + public static async ValueTask Wrap(this ValueTask handleTask, Func factory) + where THandle : class + { + var handle = await handleTask.ConfigureAwait(false); + return handle != null ? factory(handle) : null; + } + + #region ---- IInternalDistributedLock implementations ---- + public static ValueTask AcquireAsync(IInternalDistributedLock @lock, TimeSpan? timeout, CancellationToken cancellationToken) + where THandle : class, IDistributedSynchronizationHandle => + @lock.InternalTryAcquireAsync(timeout, cancellationToken).ThrowTimeoutIfNull(); + + public static THandle Acquire(IInternalDistributedLock @lock, TimeSpan? timeout, CancellationToken cancellationToken) + where THandle : class, IDistributedSynchronizationHandle => + SyncViaAsync.Run( + state => AcquireAsync(state.@lock, state.timeout, state.cancellationToken), + (@lock, timeout, cancellationToken) + ); + + public static THandle? TryAcquire(IInternalDistributedLock @lock, TimeSpan timeout, CancellationToken cancellationToken) + where THandle : class, IDistributedSynchronizationHandle => + SyncViaAsync.Run( + state => state.@lock.InternalTryAcquireAsync(state.timeout, state.cancellationToken), + (@lock, timeout, cancellationToken) + ); + #endregion + + #region ---- IInternalDistributedReaderWriterLock implementations ---- + public static ValueTask AcquireAsync(IInternalDistributedReaderWriterLock @lock, TimeSpan? timeout, CancellationToken cancellationToken, bool isWrite) + where THandle : class, IDistributedSynchronizationHandle => + @lock.InternalTryAcquireAsync(timeout, cancellationToken, isWrite).ThrowTimeoutIfNull(); + + public static THandle Acquire(IInternalDistributedReaderWriterLock @lock, TimeSpan? timeout, CancellationToken cancellationToken, bool isWrite) + where THandle : class, IDistributedSynchronizationHandle => + SyncViaAsync.Run( + state => AcquireAsync(state.@lock, state.timeout, state.cancellationToken, state.isWrite), + (@lock, timeout, cancellationToken, isWrite) + ); + + public static THandle? TryAcquire(IInternalDistributedReaderWriterLock @lock, TimeSpan timeout, CancellationToken cancellationToken, bool isWrite) + where THandle : class, IDistributedSynchronizationHandle => + SyncViaAsync.Run( + state => state.@lock.InternalTryAcquireAsync(state.timeout, state.cancellationToken, state.isWrite), + (@lock, timeout, cancellationToken, isWrite) + ); + #endregion + + #region ---- IInternalDistributedUpgradeableReaderWriterLock implementations ---- + public static ValueTask AcquireUpgradeableReadLockAsync(IInternalDistributedUpgradeableReaderWriterLock @lock, TimeSpan? timeout, CancellationToken cancellationToken) + where THandle : class, IDistributedSynchronizationHandle + where TUpgradeableHandle : class, IDistributedLockUpgradeableHandle => + @lock.InternalTryAcquireUpgradeableReadLockAsync(timeout, cancellationToken).ThrowTimeoutIfNull(); + + public static TUpgradeableHandle AcquireUpgradeableReadLock(IInternalDistributedUpgradeableReaderWriterLock @lock, TimeSpan? timeout, CancellationToken cancellationToken) + where THandle : class, IDistributedSynchronizationHandle + where TUpgradeableHandle : class, IDistributedLockUpgradeableHandle => + SyncViaAsync.Run( + state => AcquireUpgradeableReadLockAsync(state.@lock, state.timeout, state.cancellationToken), + (@lock, timeout, cancellationToken) + ); + + public static TUpgradeableHandle? TryAcquireUpgradeableReadLock(IInternalDistributedUpgradeableReaderWriterLock @lock, TimeSpan timeout, CancellationToken cancellationToken) + where THandle : class, IDistributedSynchronizationHandle + where TUpgradeableHandle : class, IDistributedLockUpgradeableHandle => + SyncViaAsync.Run( + state => state.@lock.InternalTryAcquireUpgradeableReadLockAsync(state.timeout, state.cancellationToken), + (@lock, timeout, cancellationToken) + ); + #endregion + + #region ---- IInternalDistributedSemaphore implementations ---- + public static ValueTask AcquireAsync(IInternalDistributedSemaphore @lock, TimeSpan? timeout, CancellationToken cancellationToken) + where THandle : class, IDistributedSynchronizationHandle => + @lock.InternalTryAcquireAsync(timeout, cancellationToken).ThrowTimeoutIfNull(@object: "semaphore"); + + public static THandle Acquire(IInternalDistributedSemaphore @lock, TimeSpan? timeout, CancellationToken cancellationToken) + where THandle : class, IDistributedSynchronizationHandle => + SyncViaAsync.Run( + state => AcquireAsync(state.@lock, state.timeout, state.cancellationToken), + (@lock, timeout, cancellationToken) + ); + + public static THandle? TryAcquire(IInternalDistributedSemaphore @lock, TimeSpan timeout, CancellationToken cancellationToken) + where THandle : class, IDistributedSynchronizationHandle => + SyncViaAsync.Run( + state => state.@lock.InternalTryAcquireAsync(state.timeout, state.cancellationToken), + (@lock, timeout, cancellationToken) + ); + #endregion + + #region ---- IDistributedLockUpgradeableHandle implementations ---- + public static ValueTask UpgradeToWriteLockAsync(IInternalDistributedLockUpgradeableHandle handle, TimeSpan? timeout, CancellationToken cancellationToken) => + handle.InternalTryUpgradeToWriteLockAsync(timeout, cancellationToken).ThrowTimeoutIfFalse(); + + public static void UpgradeToWriteLock(IDistributedLockUpgradeableHandle handle, TimeSpan? timeout, CancellationToken cancellationToken) => + SyncViaAsync.Run(t => t.handle.UpgradeToWriteLockAsync(t.timeout, t.cancellationToken), (handle, timeout, cancellationToken)); + + public static bool TryUpgradeToWriteLock(IDistributedLockUpgradeableHandle handle, TimeSpan timeout, CancellationToken cancellationToken) => + 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"}"); + + public static async ValueTask ThrowTimeoutIfNull(this ValueTask task, string? @object = null) where T : class => + await task.ConfigureAwait(false) ?? throw LockTimeout(@object); + + private static async ValueTask ThrowTimeoutIfFalse(this ValueTask task) + { + if (!await task.ConfigureAwait(false)) { throw LockTimeout(); } + } +} diff --git a/src/DistributedLock.Core/Internal/Helpers.cs b/src/DistributedLock.Core/Internal/Helpers.cs new file mode 100644 index 00000000..84a2916b --- /dev/null +++ b/src/DistributedLock.Core/Internal/Helpers.cs @@ -0,0 +1,124 @@ +using System.Runtime.CompilerServices; + +namespace Medallion.Threading.Internal; + +#if DEBUG +public +#else +internal +#endif +static class Helpers +{ + /// + /// Performs a type-safe cast + /// + public static T As(this T @this) => @this; + + /// + /// Performs a type-safe "cast" of a + /// + public static async ValueTask Convert(this ValueTask task, To.ValueTaskConversion _) + where TDerived : TBase => + await task.ConfigureAwait(false); + + public readonly struct TaskConversion { } + + internal static async ValueTask ConvertToVoid(this ValueTask task) => await task.ConfigureAwait(false); + + public static ValueTask AsValueTask(this Task task) => new(task); + public static ValueTask AsValueTask(this Task task) => new(task); + public static ValueTask AsValueTask(this T value) => new(value); + + public static Task SafeCreateTask(Func> taskFactory, TState state) => + InternalSafeCreateTask, TResult>(taskFactory, state); + + public static Task SafeCreateTask(Func taskFactory, TState state) => + InternalSafeCreateTask(taskFactory, state); + + private static TTask InternalSafeCreateTask(Func taskFactory, TState state) + where TTask : Task + { + try { return taskFactory(state); } + catch (OperationCanceledException) + { + // don't use Task.FromCanceled here because oce.CancellationToken is not guaranteed to + // have IsCancellationRequested which FromCanceled requires + var canceledTaskBuilder = new TaskCompletionSource(); + canceledTaskBuilder.SetCanceled(); + return (TTask)canceledTaskBuilder.Task.As(); + } + catch (Exception ex) { return (TTask)Task.FromException(ex).As(); } + } + + public static ObjectDisposedException ObjectDisposed(this T _) where T : IAsyncDisposable => + throw new ObjectDisposedException(typeof(T).ToString()); + + public static NonThrowingAwaitable TryAwait(this TTask task) where TTask : Task => + new(task); + + /// + /// Throwing exceptions is slow and our workflow has us canceling tasks in the common case. Using this special awaitable + /// allows for us to await those tasks without causing a thrown exception + /// + public readonly struct NonThrowingAwaitable : ICriticalNotifyCompletion + where TTask : Task + { + private readonly TTask _task; + private readonly ConfiguredTaskAwaitable.ConfiguredTaskAwaiter _taskAwaiter; + + public NonThrowingAwaitable(TTask task) + { + this._task = task; +#if NET8_0_OR_GREATER + this._taskAwaiter = task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing).GetAwaiter(); +#else + this._taskAwaiter = task.ConfigureAwait(false).GetAwaiter(); +#endif + } + + public NonThrowingAwaitable GetAwaiter() => this; + + public bool IsCompleted => this._taskAwaiter.IsCompleted; + + public TTask GetResult() + { + Invariant.Require(this._task.IsCompleted); + +#if NET8_0_OR_GREATER + this._taskAwaiter.GetResult(); +#else + // Does NOT call _taskAwaiter.GetResult() since that could throw! + // We do, however, access the Exception property to avoid hitting UnobservedTaskException. + if (this._task.IsFaulted) { _ = this._task.Exception; } +#endif + + return this._task; + } + + public void OnCompleted(Action continuation) => this._taskAwaiter.OnCompleted(continuation); + public void UnsafeOnCompleted(Action continuation) => this._taskAwaiter.UnsafeOnCompleted(continuation); + } + + [Obsolete("Will be removed in DistributedLock.Core 1.1")] + public static bool TryGetValue(this T? nullable, out T value) + where T : struct + { + value = nullable.GetValueOrDefault(); + return nullable.HasValue; + } +} + +/// +/// Assists with type inference for value task conversions +/// +#if DEBUG +public +#else +internal +#endif +static class To +{ + public static ValueTaskConversion ValueTask => default; + + public readonly struct ValueTaskConversion { } +} diff --git a/src/DistributedLock.Core/Internal/IInternalDistributedLock.cs b/src/DistributedLock.Core/Internal/IInternalDistributedLock.cs new file mode 100644 index 00000000..0b866b6e --- /dev/null +++ b/src/DistributedLock.Core/Internal/IInternalDistributedLock.cs @@ -0,0 +1,18 @@ +namespace Medallion.Threading.Internal; + +#if DEBUG +public +#else +internal +#endif +interface IInternalDistributedLock : IDistributedLock + where THandle : class, IDistributedSynchronizationHandle +{ + new THandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new THandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + new ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + // internals + ValueTask InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken); +} diff --git a/src/DistributedLock.Core/Internal/IInternalDistributedLockUpgradeableHandle.cs b/src/DistributedLock.Core/Internal/IInternalDistributedLockUpgradeableHandle.cs new file mode 100644 index 00000000..5aff0949 --- /dev/null +++ b/src/DistributedLock.Core/Internal/IInternalDistributedLockUpgradeableHandle.cs @@ -0,0 +1,11 @@ +namespace Medallion.Threading.Internal; + +#if DEBUG +public +#else +internal +#endif +interface IInternalDistributedLockUpgradeableHandle : IDistributedLockUpgradeableHandle +{ + ValueTask InternalTryUpgradeToWriteLockAsync(TimeoutValue timeout, CancellationToken cancellationToken); +} diff --git a/src/DistributedLock.Core/Internal/IInternalDistributedReaderWriterLock.cs b/src/DistributedLock.Core/Internal/IInternalDistributedReaderWriterLock.cs new file mode 100644 index 00000000..6ffc15be --- /dev/null +++ b/src/DistributedLock.Core/Internal/IInternalDistributedReaderWriterLock.cs @@ -0,0 +1,22 @@ +namespace Medallion.Threading.Internal; + +#if DEBUG +public +#else +internal +#endif +interface IInternalDistributedReaderWriterLock : IDistributedReaderWriterLock + where THandle : class, IDistributedSynchronizationHandle +{ + new THandle? TryAcquireReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new THandle AcquireReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + new ValueTask TryAcquireReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new ValueTask AcquireReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + new THandle? TryAcquireWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new THandle AcquireWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + new ValueTask TryAcquireWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + // internals + ValueTask InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken, bool isWrite); +} diff --git a/src/DistributedLock.Core/Internal/IInternalDistributedSemaphore.cs b/src/DistributedLock.Core/Internal/IInternalDistributedSemaphore.cs new file mode 100644 index 00000000..dad79851 --- /dev/null +++ b/src/DistributedLock.Core/Internal/IInternalDistributedSemaphore.cs @@ -0,0 +1,18 @@ +namespace Medallion.Threading.Internal; + +#if DEBUG +public +#else +internal +#endif + interface IInternalDistributedSemaphore : IDistributedSemaphore + where THandle : class, IDistributedSynchronizationHandle +{ + new THandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new THandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + new ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + // internals + ValueTask InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken); +} diff --git a/src/DistributedLock.Core/Internal/IInternalDistributedUpgradeableReaderWriterLock.cs b/src/DistributedLock.Core/Internal/IInternalDistributedUpgradeableReaderWriterLock.cs new file mode 100644 index 00000000..24dbf56b --- /dev/null +++ b/src/DistributedLock.Core/Internal/IInternalDistributedUpgradeableReaderWriterLock.cs @@ -0,0 +1,19 @@ +namespace Medallion.Threading.Internal; + +#if DEBUG +public +#else +internal +#endif +interface IInternalDistributedUpgradeableReaderWriterLock : IDistributedUpgradeableReaderWriterLock, IInternalDistributedReaderWriterLock + where THandle : class, IDistributedSynchronizationHandle + where TUpgradeableHandle : class, IDistributedLockUpgradeableHandle +{ + new TUpgradeableHandle? TryAcquireUpgradeableReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new TUpgradeableHandle AcquireUpgradeableReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + new ValueTask TryAcquireUpgradeableReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + new ValueTask AcquireUpgradeableReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + // internals + ValueTask InternalTryAcquireUpgradeableReadLockAsync(TimeoutValue timeout, CancellationToken cancellationToken); +} diff --git a/src/DistributedLock.Core/Internal/Invariant.cs b/src/DistributedLock.Core/Internal/Invariant.cs new file mode 100644 index 00000000..f48e4a6f --- /dev/null +++ b/src/DistributedLock.Core/Internal/Invariant.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; + +namespace Medallion.Threading.Internal; + +#if DEBUG +public +#else +internal +#endif +static class Invariant +{ + [Conditional("DEBUG")] + public static void Require(bool condition, string? message = null) + { + if (!condition) + { + throw new InvalidOperationException(message ?? "invariant violated"); + } + } +} diff --git a/src/DistributedLock.Core/Internal/LeaseMonitor.cs b/src/DistributedLock.Core/Internal/LeaseMonitor.cs new file mode 100644 index 00000000..71549128 --- /dev/null +++ b/src/DistributedLock.Core/Internal/LeaseMonitor.cs @@ -0,0 +1,151 @@ +using System.Diagnostics; + +namespace Medallion.Threading.Internal; + +/// +/// Utility for monitoring/renewing a fixed length "lease" lock +/// +#if DEBUG +public +#else +internal +#endif + sealed class LeaseMonitor : IDisposable, IAsyncDisposable +{ + private readonly CancellationTokenSource _disposalSource = new(), + _handleLostSource = new(); + + private readonly ILeaseHandle _leaseHandle; + private readonly Task _monitoringTask; + private Task? _cancellationTask; + + public LeaseMonitor(ILeaseHandle leaseHandle) + { + Invariant.Require(leaseHandle.LeaseDuration.CompareTo(leaseHandle.MonitoringCadence) >= 0); + + this._leaseHandle = leaseHandle; + this._monitoringTask = CreateMonitoringLoopTask(new WeakReference(this), leaseHandle.MonitoringCadence, this._disposalSource.Token); + } + + public CancellationToken HandleLostToken => this._handleLostSource.Token; + + public void Dispose() => this.DisposeSyncViaAsync(); + + public async ValueTask DisposeAsync() + { + try + { + if (!this._disposalSource.IsCancellationRequested) // idempotent + { + this._disposalSource.Cancel(); + } + + await this._monitoringTask.AwaitSyncOverAsync().ConfigureAwait(false); + } + finally + { + if (this._cancellationTask != null) + { + _ = this._cancellationTask.ContinueWith((_, state) => ((CancellationTokenSource)state!).Dispose(), state: this._handleLostSource); + } + else + { + this._handleLostSource.Dispose(); + } + this._disposalSource.Dispose(); + } + } + + private static Task CreateMonitoringLoopTask(WeakReference weakMonitor, TimeoutValue monitoringCadence, CancellationToken disposalToken) + { + return Task.Run(() => MonitoringLoop()); + + async Task MonitoringLoop() + { + var leaseLifetime = Stopwatch.StartNew(); + do + { + // wait until the next monitoring check + await Task.Delay(monitoringCadence.InMilliseconds, disposalToken).TryAwait(); + } + while (!disposalToken.IsCancellationRequested && await RunMonitoringLoopIterationAsync(weakMonitor, leaseLifetime).ConfigureAwait(false)); + } + } + + private static async Task RunMonitoringLoopIterationAsync(WeakReference weakMonitor, Stopwatch leaseLifetime) + { + // if the monitor has been GC'd, just exit + if (!weakMonitor.TryGetTarget(out var monitor)) { return false; } + + // lease expired + if (monitor._leaseHandle.LeaseDuration.CompareTo(leaseLifetime.Elapsed) < 0) + { + OnHandleLost(); + return false; + } + + var leaseState = await monitor.CheckLeaseAsync().ConfigureAwait(false); + switch (leaseState) + { + case LeaseState.Lost: + OnHandleLost(); + return false; + + case LeaseState.Renewed: + leaseLifetime.Restart(); + return true; + + // If the lease is held but not renewed or if we don't know (e. g. due to transient failure), + // then just continue. We can't yet say that it is lost but it isn't renewed so we can't reset + // the lifetime either. + case LeaseState.Held: + case LeaseState.Unknown: + return true; + + default: + throw new InvalidOperationException("should never get here"); + } + + // offload cancel to a background thread to avoid hangs or errors + void OnHandleLost() => monitor._cancellationTask = Task.Run(() => monitor._handleLostSource.Cancel()); + } + + private async Task CheckLeaseAsync() + { + var renewOrValidateTask = Helpers.SafeCreateTask(state => state.leaseHandle.RenewOrValidateLeaseAsync(state.Token), (leaseHandle: this._leaseHandle, this._disposalSource.Token)); + await renewOrValidateTask.TryAwait(); + return this._disposalSource.IsCancellationRequested || renewOrValidateTask.Status != TaskStatus.RanToCompletion + ? LeaseState.Unknown + : renewOrValidateTask.Result; + } + + public interface ILeaseHandle + { + TimeoutValue LeaseDuration { get; } + TimeoutValue MonitoringCadence { get; } + Task RenewOrValidateLeaseAsync(CancellationToken cancellationToken); + } + + public enum LeaseState + { + /// + /// The lease is known to be still held but was not renewed + /// + Held, + + /// + /// The lease has been renewed for + /// + Renewed, + + /// + /// The lease is known to no longer be held + /// + Lost, + + /// + /// The lease may or may not be held any longer + /// + Unknown, + } +} diff --git a/src/DistributedLock.Core/Internal/ManagedFinalizerQueue.cs b/src/DistributedLock.Core/Internal/ManagedFinalizerQueue.cs new file mode 100644 index 00000000..85251a19 --- /dev/null +++ b/src/DistributedLock.Core/Internal/ManagedFinalizerQueue.cs @@ -0,0 +1,190 @@ +using System.Collections.Concurrent; + +namespace Medallion.Threading.Internal; + +/// +/// Similar to finalization, but allows for arbitrary managed code to be run for cleanup +/// +#if DEBUG +public +#else +internal +#endif +sealed class ManagedFinalizerQueue +{ + // 99% of the time, the finalizer will do nothing because people will dispose properly. The finalizer also must + // walk the full dictionary which in theory could be large if there is a lot of usage. Therefore, we don't want this to + // run too frequently. On the other hand, when something does go wrong we want to be able to recover in some reasonable period + // of time. 30s feels like is strikes a good balance here + internal static readonly TimeSpan FinalizerCadence = TimeSpan.FromSeconds( +#if DEBUG + 3 // to keep tests fast, use a much shorter cadence in debug +#else + 30 +#endif + ); + + public static readonly ManagedFinalizerQueue Instance = new(); + + private readonly ConcurrentDictionary _items = new(); + + // The state of this class can be described by 3 bits: + // _count: >0 or ==0 + // _finalizerTask: has cleared initializing bit or has not cleared it + // _initializing: 1 or 0 + // + // The following shows the possible states we could be in: + // _count | _finalizerTask | _initializing | nodes + // 0 | cleared | 0 | Initial state / finalizer getting ready to exit state. Register() => (>0, cleared, 1) + // 0 | cleared | 1 | If nothing changes, the finalizer will exit => (0, not cleared, 1). If something is registered => (>0, cleared, 1) + // 0 | not cleared | 0 | ERROR should never happen + // 0 | not cleared | 1 | Once our finalizer runs, it will => (0, cleared, 0). If something is registered => (>0, not cleared, 1) + // >0 | cleared | 0 | Finalizer is running. If count drops to zero => (0, cleared, 0) + // >0 | cleared | 1 | Means we dropped to 0 count but came back up before the finalizer exited. We now have a running finalizer and one queued + // >0 | not cleared | 0 | ERROR should never happen + // >0 | not cleared | 1 | Finalizer will run and transition to (>0, cleared, 0). Removal could transfer to (0, not cleared, 1) + + /// + /// Tracked separately from the dictionary since (a) ConcurrentDictionary's count is slow and (b) we need to know exactly when we + /// add one item to empty or remove one item from empty. We use a long to guarantee that we can't ever overflow (if there were really + /// 2^63 items in the queue, we'd be out of memory) + /// + private long _count; + + private Task _finalizerTask = Task.CompletedTask; + private int _finalizerTaskIsInitializing; + + private ManagedFinalizerQueue() { } + + /// + /// If is GC'd, will be run. + /// must be thread-safe. Disposing the returned + /// revokes the registration. Note that, for this to work, must not hold + /// a strong reference to . + /// + public IDisposable Register(object resource, IAsyncDisposable finalizer) + { + Invariant.Require(finalizer != resource); + + this._items.As>() + .Add(finalizer, new WeakReference(resource)); + + if (Interlocked.Increment(ref this._count) == 1) + { + this.StartFinalizerTask(); + } + + return new Registration(this, finalizer); + } + + private void StartFinalizerTask() + { + // If we're frequently adding and then removing a single item (probably a common case + // since most of the time people will dispose things and won't do too much distributed locking), + // we could end up thrashing where we create new finalizer tasks over and over again. To avoid + // this, we set the initializing flag, but in the case that it was already set we know that there + // is a task that is still getting ready; in that case we can just let that task continue to be + // the finalizer task: there's no need to replace it + if (Interlocked.Exchange(ref this._finalizerTaskIsInitializing, 1) != 0) + { + return; + } + + // This lock is only barely necessary. The race condition this solves for is the continuation task + // starting to run the finalizer loop AND clearing the initialization bit before we've assigned + // to this._finalizerTask. In that case, another thread could continue off the wrong task. The reason + // this is super unlikely is because the loop sleeps before clearing the bit, so things have to go horribly + // awry for this edge-case to occur. + lock (this._items) // lock _items just because it's an object we own + { + // When we get here, the previous finalizer should exit on its next iteration but it hasn't + // necessarily exited yet (and may not for some time). Therefore, we queue a task to run as a + // continuation so that we only have one finalizer loop at a time + this._finalizerTask = this._finalizerTask.ContinueWith( + (_, @this) => ((ManagedFinalizerQueue)@this!).FinalizerLoop(), + state: this, + CancellationToken.None + ) + .Unwrap(); + } + } + + private async Task FinalizerLoop() + { + // Any new finalizer loop delays before doing anything else. We start the loop when we just added + // something, so there's little chance of it having something to do right away + await Task.Delay(FinalizerCadence).ConfigureAwait(false); + + // Clear the initializing flag. By doing this, we allow another task to be queued on top of us + var initializingFlag = Interlocked.Exchange(ref this._finalizerTaskIsInitializing, 0); + Invariant.Require(initializingFlag == 1); + + // Loop until there is nothing more to do + while (Volatile.Read(ref this._count) != 0) + { + // the main finalizer does not wait for item finalization since we don't want that to ever + // block or fault the main loop + await this.FinalizeAsync(waitForItemFinalization: false).ConfigureAwait(false); + await Task.Delay(FinalizerCadence).ConfigureAwait(false); + } + } + + private Task FinalizeAsync(bool waitForItemFinalization) + { + List? itemFinalizerTasks = null; + + // ConcurrentDictionary enumerator is safe to use concurrently with writes and is very inexpensive + // (lock-free and does not generate a snapshot copy) + foreach (var kvp in this._items) + { + if (!kvp.Value.IsAlive) + { + var itemFinalizerTask = this.TryRemove(kvp.Key, disposeKey: true); + if (waitForItemFinalization) + { + (itemFinalizerTasks ??= []).Add(itemFinalizerTask); + } + } + } + + return waitForItemFinalization ? Task.WhenAll(itemFinalizerTasks ?? Enumerable.Empty()) : Task.CompletedTask; + } + + /// + /// Forces finalization of anything that is eligible. Exposed for testing purposes only + /// + internal Task FinalizeAsync() => this.FinalizeAsync(waitForItemFinalization: true); + + private Task TryRemove(IAsyncDisposable key, bool disposeKey) + { + if (this._items.TryRemove(key, out _)) + { + Interlocked.Decrement(ref this._count); + if (disposeKey) + { + // DisposeAsync could throw, hang, etc. This must not block the finalizer thread. + // Therefore, we offload to a background thread and swallow exceptions + return Task.Run(() => key.DisposeAsync().AsTask()); + } + } + + return Task.CompletedTask; + } + + private sealed class Registration(ManagedFinalizerQueue queue, IAsyncDisposable key) : IDisposable + { + private readonly ManagedFinalizerQueue _queue = queue; + private IAsyncDisposable? _key = key; + + public void Dispose() + { + var key = Interlocked.Exchange(ref this._key, null); + if (key != null) + { + // If the registration gets disposed, we don't need to dispose the key + // because that means it got disposed normally + this._queue.TryRemove(key, disposeKey: false); + } + } + } +} diff --git a/src/DistributedLock.Core/Internal/RefBox.cs b/src/DistributedLock.Core/Internal/RefBox.cs new file mode 100644 index 00000000..718cee47 --- /dev/null +++ b/src/DistributedLock.Core/Internal/RefBox.cs @@ -0,0 +1,52 @@ +namespace Medallion.Threading.Internal; + +/// +/// Wraps a value tuple to be a read/write reference +/// +#if DEBUG +public +#else +internal +#endif +sealed class RefBox where T : struct +{ + private readonly T _value; + + internal RefBox(T value) + { + this._value = value; + } + + public ref readonly T Value => ref this._value; +} + +/// +/// Simplifies storing state in certain s. +/// +#if DEBUG +public +#else +internal +#endif +sealed class RefBox +{ + public static RefBox Create(T value) where T : struct => new(value); + + /// + /// Thread-safely checks if is non-null and if so sets it to null and outputs + /// the value as . + /// + public static bool TryConsume(ref RefBox? boxRef, out T value) + where T : struct + { + var box = Interlocked.Exchange(ref boxRef, null); + if (box != null) + { + value = box.Value; + return true; + } + + value = default; + return false; + } +} diff --git a/src/DistributedLock.Core/Internal/SyncViaAsync.cs b/src/DistributedLock.Core/Internal/SyncViaAsync.cs new file mode 100644 index 00000000..1a1df322 --- /dev/null +++ b/src/DistributedLock.Core/Internal/SyncViaAsync.cs @@ -0,0 +1,125 @@ +namespace Medallion.Threading.Internal; + +/// +/// Helps re-use code across sync and async pathways, leveraging the fact that async code will run synchronously +/// unless it actually encounters an async operation. Downstream code should use the +/// to choose between sync and async operations. +/// +/// This class does not incur the overhead of the sync-over-async anti-pattern; the only overhead is using s +/// in a synchronous manner. +/// +#if DEBUG +public +#else +internal +#endif +static class SyncViaAsync +{ + [ThreadStatic] + private static bool _isSynchronous; + + public static bool IsSynchronous => _isSynchronous; + + /// + /// Runs synchronously + /// + public static void Run(Func action, TState state) + { + Run( + static async s => + { + await s.action(s.state).ConfigureAwait(false); + return true; + }, + (action, state) + ); + } + + /// + /// Runs synchronously + /// + public static TResult Run(Func> action, TState state) + { + // Currently composites is the one place where we are reentrant wrt SyncViaAsync + Invariant.Require(!_isSynchronous || Environment.StackTrace.Contains(nameof(CompositeDistributedSynchronizationHandle))); + + var wasSynchronous = _isSynchronous; + try + { + _isSynchronous = true; + + var task = action(state); + Invariant.Require(task.IsCompleted); + + // this should never happen (and can't in the debug build). However, to make absolutely sure we have this as + // fallback logic for the release build + if (!task.IsCompleted) + { + // call AsTask(), since https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask-1?view=netcore-3.1 + // says that we should not call GetAwaiter().GetResult() except on a completed ValueTask + return task.AsTask().GetAwaiter().GetResult(); + } + + return task.GetAwaiter().GetResult(); + } + finally + { + _isSynchronous = wasSynchronous; + } + } + + /// + /// A -compatible implementation of . + /// + public static ValueTask Delay(TimeoutValue timeout, CancellationToken cancellationToken) + { + if (!IsSynchronous) + { + return Task.Delay(timeout.InMilliseconds, cancellationToken).AsValueTask(); + } + + if (cancellationToken.CanBeCanceled) + { + if (cancellationToken.WaitHandle.WaitOne(timeout.InMilliseconds)) + { + throw new OperationCanceledException("delay was canceled", cancellationToken); + } + } + else + { + Thread.Sleep(timeout.InMilliseconds); + } + + return default; + } + + /// + /// For a type which implements both and , + /// provides an implementation of using . + /// + public static void DisposeSyncViaAsync(this TDisposable disposable) + where TDisposable : IAsyncDisposable, IDisposable => + Run(@this => @this.DisposeAsync(), disposable); + + /// + /// In synchronous mode, performs a blocking wait on the provided . In asynchronous mode, + /// returns the as a . + /// + public static ValueTask AwaitSyncOverAsync(this Task task) => + IsSynchronous ? task.GetAwaiter().GetResult().AsValueTask() : task.AsValueTask(); + + /// + /// In synchronous mode, performs a blocking wait on the provided . In asynchronous mode, + /// returns the as a . + /// + public static ValueTask AwaitSyncOverAsync(this Task task) + { + if (IsSynchronous) + { + task.GetAwaiter().GetResult(); + return default; + } + + return task.AsValueTask(); + } +} diff --git a/src/DistributedLock.Core/Internal/TimeoutValue.cs b/src/DistributedLock.Core/Internal/TimeoutValue.cs new file mode 100644 index 00000000..ff5997cf --- /dev/null +++ b/src/DistributedLock.Core/Internal/TimeoutValue.cs @@ -0,0 +1,62 @@ +namespace Medallion.Threading.Internal; + +/// +/// A type which can only store a valid timeout value +/// +#if DEBUG +public +#else +internal +#endif +readonly struct TimeoutValue : IEquatable, IComparable +{ + public TimeoutValue(TimeSpan? timeout, string paramName = "timeout") + { + if (timeout is { } timeoutValue) + { + // based on Task.Wait(TimeSpan) + // https://referencesource.microsoft.com/#mscorlib/system/threading/Tasks/Task.cs,855657030ba22f78 + + var totalMilliseconds = (long)timeoutValue.TotalMilliseconds; + if (totalMilliseconds < -1 || totalMilliseconds > int.MaxValue) + { + throw new ArgumentOutOfRangeException( + paramName: paramName, + actualValue: timeoutValue, + message: $"Must be {nameof(Timeout)}.{nameof(Timeout.InfiniteTimeSpan)} ({Timeout.InfiniteTimeSpan}) or a non-negative value <= {TimeSpan.FromMilliseconds(int.MaxValue)})" + ); + } + + this.InMilliseconds = (int)totalMilliseconds; + } + else + { + this.InMilliseconds = Timeout.Infinite; + } + } + + public int InMilliseconds { get; } + public int InSeconds => this.IsInfinite ? throw new InvalidOperationException("infinite timeout cannot be converted to seconds") : this.InMilliseconds / 1000; + public bool IsInfinite => this.InMilliseconds == Timeout.Infinite; + public bool IsZero => this.InMilliseconds == 0; + public TimeSpan TimeSpan => TimeSpan.FromMilliseconds(this.InMilliseconds); + + public bool Equals(TimeoutValue that) => this.InMilliseconds == that.InMilliseconds; + public override bool Equals(object? obj) => obj is TimeoutValue that && this.Equals(that); + public override int GetHashCode() => this.InMilliseconds; + + public int CompareTo(TimeoutValue that) => + this.IsInfinite ? (that.IsInfinite ? 0 : 1) + : that.IsInfinite ? -1 + : this.InMilliseconds.CompareTo(that.InMilliseconds); + + public static bool operator ==(TimeoutValue a, TimeoutValue b) => a.Equals(b); + public static bool operator !=(TimeoutValue a, TimeoutValue b) => !(a == b); + + public static implicit operator TimeoutValue(TimeSpan? timeout) => new(timeout); + + public override string ToString() => + this.IsInfinite ? "∞" + : this.IsZero ? "0" + : this.TimeSpan.ToString(); +} diff --git a/src/DistributedLock.Core/PublicAPI.Shipped.txt b/src/DistributedLock.Core/PublicAPI.Shipped.txt new file mode 100644 index 00000000..ddeaca34 --- /dev/null +++ b/src/DistributedLock.Core/PublicAPI.Shipped.txt @@ -0,0 +1,88 @@ +#nullable enable +Medallion.Threading.DeadlockException +Medallion.Threading.DeadlockException.DeadlockException() -> void +Medallion.Threading.DeadlockException.DeadlockException(string! message) -> void +Medallion.Threading.DeadlockException.DeadlockException(string! message, System.Exception! innerException) -> void +Medallion.Threading.DistributedLockProviderExtensions +Medallion.Threading.DistributedReaderWriterLockProviderExtensions +Medallion.Threading.DistributedSemaphoreProviderExtensions +Medallion.Threading.DistributedUpgradeableReaderWriterLockProviderExtensions +Medallion.Threading.IDistributedLock +Medallion.Threading.IDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +Medallion.Threading.IDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedLock.Name.get -> string! +Medallion.Threading.IDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +Medallion.Threading.IDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedLockProvider +Medallion.Threading.IDistributedLockProvider.CreateLock(string! name) -> Medallion.Threading.IDistributedLock! +Medallion.Threading.IDistributedLockUpgradeableHandle +Medallion.Threading.IDistributedLockUpgradeableHandle.TryUpgradeToWriteLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +Medallion.Threading.IDistributedLockUpgradeableHandle.TryUpgradeToWriteLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedLockUpgradeableHandle.UpgradeToWriteLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +Medallion.Threading.IDistributedLockUpgradeableHandle.UpgradeToWriteLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedReaderWriterLock +Medallion.Threading.IDistributedReaderWriterLock.AcquireReadLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +Medallion.Threading.IDistributedReaderWriterLock.AcquireReadLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedReaderWriterLock.AcquireWriteLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +Medallion.Threading.IDistributedReaderWriterLock.AcquireWriteLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedReaderWriterLock.Name.get -> string! +Medallion.Threading.IDistributedReaderWriterLock.TryAcquireReadLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +Medallion.Threading.IDistributedReaderWriterLock.TryAcquireReadLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedReaderWriterLock.TryAcquireWriteLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +Medallion.Threading.IDistributedReaderWriterLock.TryAcquireWriteLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedReaderWriterLockProvider +Medallion.Threading.IDistributedReaderWriterLockProvider.CreateReaderWriterLock(string! name) -> Medallion.Threading.IDistributedReaderWriterLock! +Medallion.Threading.IDistributedSemaphore +Medallion.Threading.IDistributedSemaphore.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +Medallion.Threading.IDistributedSemaphore.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedSemaphore.MaxCount.get -> int +Medallion.Threading.IDistributedSemaphore.Name.get -> string! +Medallion.Threading.IDistributedSemaphore.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +Medallion.Threading.IDistributedSemaphore.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedSemaphoreProvider +Medallion.Threading.IDistributedSemaphoreProvider.CreateSemaphore(string! name, int maxCount) -> Medallion.Threading.IDistributedSemaphore! +Medallion.Threading.IDistributedSynchronizationHandle +Medallion.Threading.IDistributedSynchronizationHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.IDistributedUpgradeableReaderWriterLock +Medallion.Threading.IDistributedUpgradeableReaderWriterLock.AcquireUpgradeableReadLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedLockUpgradeableHandle! +Medallion.Threading.IDistributedUpgradeableReaderWriterLock.AcquireUpgradeableReadLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedUpgradeableReaderWriterLock.TryAcquireUpgradeableReadLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedLockUpgradeableHandle? +Medallion.Threading.IDistributedUpgradeableReaderWriterLock.TryAcquireUpgradeableReadLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.IDistributedUpgradeableReaderWriterLockProvider +Medallion.Threading.IDistributedUpgradeableReaderWriterLockProvider.CreateUpgradeableReaderWriterLock(string! name) -> Medallion.Threading.IDistributedUpgradeableReaderWriterLock! +static Medallion.Threading.DistributedLockProviderExtensions.AcquireLock(this Medallion.Threading.IDistributedLockProvider! provider, string! name, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +static Medallion.Threading.DistributedLockProviderExtensions.AcquireLockAsync(this Medallion.Threading.IDistributedLockProvider! provider, string! name, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedLockProviderExtensions.TryAcquireLock(this Medallion.Threading.IDistributedLockProvider! provider, string! name, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +static Medallion.Threading.DistributedLockProviderExtensions.TryAcquireLockAsync(this Medallion.Threading.IDistributedLockProvider! provider, string! name, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedLockProviderExtensions.AcquireAllLocksAsync(this Medallion.Threading.IDistributedLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedLockProviderExtensions.TryAcquireAllLocksAsync(this Medallion.Threading.IDistributedLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedLockProviderExtensions.AcquireAllLocks(this Medallion.Threading.IDistributedLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +static Medallion.Threading.DistributedLockProviderExtensions.TryAcquireAllLocks(this Medallion.Threading.IDistributedLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.AcquireReadLock(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, string! name, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.AcquireReadLockAsync(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, string! name, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.AcquireWriteLock(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, string! name, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.AcquireWriteLockAsync(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, string! name, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.TryAcquireReadLock(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, string! name, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.TryAcquireReadLockAsync(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, string! name, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.TryAcquireWriteLock(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, string! name, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.TryAcquireWriteLockAsync(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, string! name, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.AcquireAllReadLocks(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.AcquireAllReadLocksAsync(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.AcquireAllWriteLocks(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.AcquireAllWriteLocksAsync(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.TryAcquireAllReadLocks(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.TryAcquireAllReadLocksAsync(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.TryAcquireAllWriteLocks(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +static Medallion.Threading.DistributedReaderWriterLockProviderExtensions.TryAcquireAllWriteLocksAsync(this Medallion.Threading.IDistributedReaderWriterLockProvider! provider, System.Collections.Generic.IReadOnlyList! names, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedSemaphoreProviderExtensions.AcquireSemaphore(this Medallion.Threading.IDistributedSemaphoreProvider! provider, string! name, int maxCount, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +static Medallion.Threading.DistributedSemaphoreProviderExtensions.AcquireSemaphoreAsync(this Medallion.Threading.IDistributedSemaphoreProvider! provider, string! name, int maxCount, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedSemaphoreProviderExtensions.TryAcquireSemaphore(this Medallion.Threading.IDistributedSemaphoreProvider! provider, string! name, int maxCount, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +static Medallion.Threading.DistributedSemaphoreProviderExtensions.TryAcquireSemaphoreAsync(this Medallion.Threading.IDistributedSemaphoreProvider! provider, string! name, int maxCount, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedSemaphoreProviderExtensions.AcquireAllSemaphores(this Medallion.Threading.IDistributedSemaphoreProvider! provider, System.Collections.Generic.IReadOnlyList! names, int maxCount, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle! +static Medallion.Threading.DistributedSemaphoreProviderExtensions.AcquireAllSemaphoresAsync(this Medallion.Threading.IDistributedSemaphoreProvider! provider, System.Collections.Generic.IReadOnlyList! names, int maxCount, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedSemaphoreProviderExtensions.TryAcquireAllSemaphores(this Medallion.Threading.IDistributedSemaphoreProvider! provider, System.Collections.Generic.IReadOnlyList! names, int maxCount, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedSynchronizationHandle? +static Medallion.Threading.DistributedSemaphoreProviderExtensions.TryAcquireAllSemaphoresAsync(this Medallion.Threading.IDistributedSemaphoreProvider! provider, System.Collections.Generic.IReadOnlyList! names, int maxCount, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedUpgradeableReaderWriterLockProviderExtensions.AcquireUpgradeableReadLock(this Medallion.Threading.IDistributedUpgradeableReaderWriterLockProvider! provider, string! name, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedLockUpgradeableHandle! +static Medallion.Threading.DistributedUpgradeableReaderWriterLockProviderExtensions.AcquireUpgradeableReadLockAsync(this Medallion.Threading.IDistributedUpgradeableReaderWriterLockProvider! provider, string! name, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.DistributedUpgradeableReaderWriterLockProviderExtensions.TryAcquireUpgradeableReadLock(this Medallion.Threading.IDistributedUpgradeableReaderWriterLockProvider! provider, string! name, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.IDistributedLockUpgradeableHandle? +static Medallion.Threading.DistributedUpgradeableReaderWriterLockProviderExtensions.TryAcquireUpgradeableReadLockAsync(this Medallion.Threading.IDistributedUpgradeableReaderWriterLockProvider! provider, string! name, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask \ No newline at end of file diff --git a/src/DistributedLock.Core/PublicAPI.Unshipped.txt b/src/DistributedLock.Core/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/DistributedLock.Core/packages.lock.json b/src/DistributedLock.Core/packages.lock.json new file mode 100644 index 00000000..1c23272c --- /dev/null +++ b/src/DistributedLock.Core/packages.lock.json @@ -0,0 +1,252 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "System.ValueTuple": { + "type": "Direct", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + } + }, + ".NETStandard,Version=v2.0": { + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.26, )", + "resolved": "8.0.26", + "contentHash": "o7/yVssM2r9Wyln2s9edBd5ANZXqdSdBI+g7JqXkyJmXrhs2WsJp25K5yPnYrTgdKBCjKB8bg+O2oew4sgzFaA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==" + } + } + } +} \ No newline at end of file diff --git a/DistributedLock.Azure/AssemblyAttributes.cs b/src/DistributedLock.FileSystem/AssemblyAttributes.cs similarity index 78% rename from DistributedLock.Azure/AssemblyAttributes.cs rename to src/DistributedLock.FileSystem/AssemblyAttributes.cs index 398d8ac7..a9e3a34f 100644 --- a/DistributedLock.Azure/AssemblyAttributes.cs +++ b/src/DistributedLock.FileSystem/AssemblyAttributes.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Text; +using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] diff --git a/DistributedLock.Postgres/DistributedLock.Postgres.csproj b/src/DistributedLock.FileSystem/DistributedLock.FileSystem.csproj similarity index 57% rename from DistributedLock.Postgres/DistributedLock.Postgres.csproj rename to src/DistributedLock.FileSystem/DistributedLock.FileSystem.csproj index e7ce454e..8189b435 100644 --- a/DistributedLock.Postgres/DistributedLock.Postgres.csproj +++ b/src/DistributedLock.FileSystem/DistributedLock.FileSystem.csproj @@ -1,22 +1,23 @@ - netstandard2.0;netstandard2.1;net461 - Medallion.Threading.Postgres + net462;netstandard2.0;netstandard2.1 + Medallion.Threading.FileSystem True 4 Latest enable + enable - 1.0.0-alpha01 + 1.0.3 1.0.0.0 Michael Adelson - TODO + Provides a distributed lock implementation based on file locks Copyright © 2020 Michael Adelson MIT - distributed lock async waithandle mutex sql postgres + distributed file lock https://github.com/madelson/DistributedLock https://github.com/madelson/DistributedLock 1.0.0.0 @@ -30,6 +31,11 @@ True True + + embedded + + true + true @@ -39,10 +45,20 @@ - + - + - + + all + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.FileSystem/FileDistributedLock.IDistributedLock.cs b/src/DistributedLock.FileSystem/FileDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..b236d6b1 --- /dev/null +++ b/src/DistributedLock.FileSystem/FileDistributedLock.IDistributedLock.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.FileSystem; + +public partial class FileDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public FileDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public FileDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.FileSystem/FileDistributedLock.cs b/src/DistributedLock.FileSystem/FileDistributedLock.cs new file mode 100644 index 00000000..5ac4c2de --- /dev/null +++ b/src/DistributedLock.FileSystem/FileDistributedLock.cs @@ -0,0 +1,171 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.FileSystem; + +/// +/// A distributed lock based on holding an exclusive handle to a lock file. The file will be deleted when the lock is released. +/// +public sealed partial class FileDistributedLock : IInternalDistributedLock +{ + /// + /// Since can be thrown EITHER transiently or for permissions issues, we retry up to this many times + /// before we assume that the issue is non-transient. Empirically I've found this value to be reliable both locally and on AppVeyor (if there + /// IS a problem there's little risk to trying more times because we'll eventually be failing hard). + /// + private const int MaxUnauthorizedAccessExceptionRetries = 1600; + + // These are not configurable currently because in the future we may want to change the implementation of FileDistributedLock + // to leverage native methods which may allow for actual blocking. The values here reflect the idea that we expect file locks + // to be used in cases where contention is rare + private static readonly TimeoutValue MinBusyWaitSleepTime = TimeSpan.FromMilliseconds(50), + MaxBusyWaitSleepTime = TimeSpan.FromSeconds(1); + + private string? _cachedDirectory; + + /// + /// Constructs a lock which uses the provided as the exact file name. + /// + /// Upon acquiring the lock, the file's directory will be created automatically if it does not already exist. The file + /// will similarly be created if it does not already exist, and will be deleted when the lock is released. + /// + public FileDistributedLock(FileInfo lockFile) + { + this.Name = (lockFile ?? throw new ArgumentNullException(nameof(lockFile))).FullName; + if (lockFile.Name.Length == 0) { throw new FormatException($"{nameof(lockFile)}: may not have an empty file name"); } + } + + /// + /// Constructs a lock which will place a lock file in . The file's name + /// will be based on , but with proper escaping/hashing to ensure that a valid file name is produced. + /// + /// Upon acquiring the lock, the file's directory will be created automatically if it does not already exist. The file + /// will similarly be created if it does not already exist, and will be deleted when the lock is released. + /// + public FileDistributedLock(DirectoryInfo lockFileDirectory, string name) + { + this.Name = FileNameValidationHelper.GetLockFileName(lockFileDirectory, name); + } + + /// + /// Implements + /// + public string Name { get; } + + private string Directory => this._cachedDirectory ??= Path.GetDirectoryName(this.Name); + + ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + BusyWaitHelper.WaitAsync( + state: this, + tryGetValue: (@this, token) => @this.TryAcquire(token).AsValueTask(), + timeout: timeout, + minSleepTime: MinBusyWaitSleepTime, + maxSleepTime: MaxBusyWaitSleepTime, + cancellationToken + ); + + private FileDistributedLockHandle? TryAcquire(CancellationToken cancellationToken) + { + var retryCount = 0; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + this.EnsureDirectoryExists(); + + FileStream lockFileStream; + try + { + // key arguments: + // OpenOrCreate to be robust to the file existing or not + // None to take an exclusive lock + // DeleteOnClose to clean up after ourselves + lockFileStream = new FileStream(this.Name, FileMode.OpenOrCreate, FileAccess.Read, FileShare.None, bufferSize: 1, FileOptions.DeleteOnClose); + } + catch (DirectoryNotFoundException) + { + // this should almost never happen because we just created the directory but in a race condition it could. Just retry + continue; + } + catch (UnauthorizedAccessException) + { + // This can happen in few cases: + + // The path is already directory, so we'll never be able to open a handle of it as a file + if (System.IO.Directory.Exists(this.Name)) + { + throw new InvalidOperationException($"Failed to create lock file '{this.Name}' because it is already the name of a directory"); + } + + // The file exists and is read-only + FileAttributes attributes; + try { attributes = File.GetAttributes(this.Name); } + catch { attributes = FileAttributes.Normal; } // e. g. could fail with FileNotFoundException + if (attributes.HasFlag(FileAttributes.ReadOnly)) + { + // We could support this by eschewing DeleteOnClose once we detect that a file is read-only, + // but absent interest or a use-case we'll just throw for now + throw new NotSupportedException($"Locking on read-only file '{this.Name}' is not supported"); + } + + // Frustratingly, this error can be thrown transiently due to concurrent creation/deletion. Initially assume + // that it is transient and just retry + if (CanRetryTransientFileSystemError(ref retryCount)) + { + continue; + } + + // If we get here, we've exhausted our retries: assume that it is a legitimate permissions issue + throw; + } + // this should never happen because we validate. However if it does (e. g. due to some system configuration change?), throw so that + // this doesn't end up in the IOException block (PathTooLongException is IOException) + catch (PathTooLongException) { throw; } + catch (IOException) + { + // the hope is that if we get here the only failure reason would be that the file is locked + return null; + } + + return new FileDistributedLockHandle(lockFileStream); + } + } + + private void EnsureDirectoryExists() + { + var retryCount = 0; + + while (true) + { + try + { + System.IO.Directory.CreateDirectory(this.Directory); + return; + } + catch (Exception ex) + { + // This can indicate either a transient failure during concurrent creation/deletion or a permissions issue. + // If we encounter it, assume it is transient unless it persists. + // For a long time, I just checked for UnauthorizedAccessException here. However, recent tests on Linux have + // shown that in race conditions we can see IOException as well, presumably because there is some period during + // directory creation where it presents as a file. + if (ex is UnauthorizedAccessException or IOException + && CanRetryTransientFileSystemError(ref retryCount)) + { + continue; + } + + throw new InvalidOperationException($"Failed to ensure that lock file directory {this.Directory} exists", ex); + } + } + } + + private static bool CanRetryTransientFileSystemError(ref int retryCount) + { + if (retryCount >= MaxUnauthorizedAccessExceptionRetries) { return false; } + + ++retryCount; + + return true; + } +} diff --git a/src/DistributedLock.FileSystem/FileDistributedLockHandle.cs b/src/DistributedLock.FileSystem/FileDistributedLockHandle.cs new file mode 100644 index 00000000..a54ca733 --- /dev/null +++ b/src/DistributedLock.FileSystem/FileDistributedLockHandle.cs @@ -0,0 +1,33 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.FileSystem; + +/// +/// Implements +/// +public sealed class FileDistributedLockHandle : IDistributedSynchronizationHandle +{ + private FileStream? _fileStream; + + internal FileDistributedLockHandle(FileStream fileStream) + { + this._fileStream = fileStream; + } + + CancellationToken IDistributedSynchronizationHandle.HandleLostToken => + Volatile.Read(ref this._fileStream) != null ? CancellationToken.None : throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._fileStream, null)?.Dispose(); + + /// + /// Releases the lock + /// + public ValueTask DisposeAsync() + { + this.Dispose(); + return default; + } +} diff --git a/src/DistributedLock.FileSystem/FileDistributedSynchronizationProvider.cs b/src/DistributedLock.FileSystem/FileDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..6d722a7b --- /dev/null +++ b/src/DistributedLock.FileSystem/FileDistributedSynchronizationProvider.cs @@ -0,0 +1,24 @@ +namespace Medallion.Threading.FileSystem; + +/// +/// Implements for +/// +public sealed class FileDistributedSynchronizationProvider : IDistributedLockProvider +{ + private readonly DirectoryInfo _lockFileDirectory; + + /// + /// Constructs a provider that scopes lock files within the provided . + /// + public FileDistributedSynchronizationProvider(DirectoryInfo lockFileDirectory) + { + this._lockFileDirectory = lockFileDirectory ?? throw new ArgumentNullException(nameof(lockFileDirectory)); + } + + /// + /// Constructs a with the given . + /// + public FileDistributedLock CreateLock(string name) => new(this._lockFileDirectory, name); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); +} diff --git a/src/DistributedLock.FileSystem/FileNameValidationHelper.cs b/src/DistributedLock.FileSystem/FileNameValidationHelper.cs new file mode 100644 index 00000000..2a79938d --- /dev/null +++ b/src/DistributedLock.FileSystem/FileNameValidationHelper.cs @@ -0,0 +1,149 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Medallion.Threading.FileSystem; + +/// +/// Helper class for validating file names and converting lock names to valid file names. +/// +internal static class FileNameValidationHelper +{ + // NOTE: our goal here is to ensure consistent behavior across platforms where possible, in case someone is locking a networked file system + // file. + // + // That means we limit ourselves to just letters, digits and underscores in the name, and we always incorporate a hash component + // (which avoids the various Windows "special" file names. + // + // Length is another thing we have to consider; on Windows we are dealing with max file name lengths on 255 chars and max path lengths of + // either 259 chars or 32000 chars, depending on whether long paths are enabled and whether we're on .NET Core or .NET framework. On unix + // we expect limits of 255 bytes for file names and 4096 bytes for paths. + // + // Another difference is case-sensitivity: on Windows file names are case-insensitive but we want lock names to be case sensitive. + // + // For portability, we prefer to use a fixed name length of 64 chars of the form [clean base name prefix][hash].lock. "clean base name prefix" + // is a prefix of the orginal name with non letter/digit/underscore chars replaced with underscores. The prefix is as long as possible without going + // over the overall name limit. "hash" is a Base 32 hash of the UTF8 bytes of the provided name. This gives us case-sensitivity and a minimal chance + // of collision if the name got truncated or mutated during "cleaning". Finally, the ".lock" extension is a helpful indicator; both this and the name + // prefix are intended to help with debugging. + // + // Sometimes, our base directory will be so long that we can't use this full portable name format. In that case, we fall back to JUST the hash component. + // If that is still too long, we fall back to just the first 12 chars of the hash. If that is still too long, we give up. + + // Chosen because below this the risk of collisions just seems too high. If someone is picking a base directory so long + // that names are limited to < 12 chars, it feels like a mistake + internal const int MinFileNameLength = 12; + + // Chosen because this is both less than the 255 chars allowed by Windows and (because we always incorporate a Base 32 hash) less + // than the 255 bytes allowed by Unix. This is hopefully also short enough to rarely overflow MAX_PATH, even on Windows + private const int PortableFileNameLength = 64; + + public static string GetLockFileName(DirectoryInfo lockFileDirectory, string name) + { + if (lockFileDirectory == null) { throw new ArgumentNullException(nameof(lockFileDirectory)); } + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + var directoryPath = lockFileDirectory.FullName; + var directoryPathWithTrailingSeparator = directoryPath[directoryPath.Length - 1] == Path.DirectorySeparatorChar + ? directoryPath + : directoryPath + Path.DirectorySeparatorChar; + + var baseName = ConvertToValidBaseName(name); + var nameHash = ComputeHash(Encoding.UTF8.GetBytes(name)); + const string Extension = ".lock"; + + // first, try the full portable name format + var portableLockFileName = directoryPathWithTrailingSeparator + + baseName.Substring(0, Math.Min(PortableFileNameLength - nameHash.Length - Extension.Length, baseName.Length)) + + nameHash + + Extension; + if (!IsTooLong(portableLockFileName)) + { + return portableLockFileName; + } + + // next, try using just the hash as the name + var hashOnlyFileName = directoryPathWithTrailingSeparator + nameHash; + if (!IsTooLong(hashOnlyFileName)) + { + return hashOnlyFileName; + } + + // finally, try using just a portion of the hash + var minimumLengthFileName = directoryPathWithTrailingSeparator + nameHash.Substring(0, MinFileNameLength); + if (!IsTooLong(minimumLengthFileName)) + { + return minimumLengthFileName; + } + + throw new PathTooLongException($"Unable to construct lock file name because the base directory (length = {directoryPathWithTrailingSeparator.Length}) does not leave enough room for a {MinFileNameLength} lock name"); + } + + private static bool IsTooLong(string name) + { + try + { + Path.GetFullPath(name); + return false; + } + catch (PathTooLongException) + { + return true; + } + } + + private static string ConvertToValidBaseName(string name) + { + const char ReplacementChar = '_'; + + StringBuilder? builder = null; + for (var i = 0; i < name.Length; ++i) + { + var @char = name[i]; + if (!char.IsLetterOrDigit(@char) && @char != ReplacementChar) + { + builder ??= new StringBuilder(name.Length).Append(name, startIndex: 0, count: i); + builder.Append(ReplacementChar); + } + else if (builder != null) + { + builder.Append(@char); + } + } + + return builder?.ToString() ?? name; + } + + // We truncate to 160 bits, which is 32 chars of Base32. This should still give us good collision resistance but allows for a 64-char + // name to include a good portion of the original provided name, which is good for debugging. See + // https://crypto.stackexchange.com/questions/9435/is-truncating-a-sha512-hash-to-the-first-160-bits-as-secure-as-using-sha1#:~:text=Yes.,time%20is%20still%20pretty%20big + private const int Base32CharBits = 5; + internal const int HashLengthInChars = 160 / Base32CharBits; + + private static string ComputeHash(byte[] bytes) + { + using var sha = SHA512.Create(); + var hashBytes = sha.ComputeHash(bytes); + + // we use Base32 because it is case-insensitive (important for windows files) and a bit more compact than Base16 + // RFC 4648 from https://en.wikipedia.org/wiki/Base32 + const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + var chars = new char[HashLengthInChars]; + var byteIndex = 0; + var bitBuffer = 0; + var bitsRemaining = 0; + for (var charIndex = 0; charIndex < chars.Length; ++charIndex) + { + if (bitsRemaining < Base32CharBits) + { + bitBuffer |= hashBytes[byteIndex++] << bitsRemaining; + bitsRemaining += 8; + } + chars[charIndex] = Base32Alphabet[bitBuffer & 31]; + bitBuffer >>= Base32CharBits; + bitsRemaining -= Base32CharBits; + } + + return new string(chars); + } +} diff --git a/src/DistributedLock.FileSystem/PublicAPI.Shipped.txt b/src/DistributedLock.FileSystem/PublicAPI.Shipped.txt new file mode 100644 index 00000000..b3794b0e --- /dev/null +++ b/src/DistributedLock.FileSystem/PublicAPI.Shipped.txt @@ -0,0 +1,15 @@ +#nullable enable +Medallion.Threading.FileSystem.FileDistributedLock +Medallion.Threading.FileSystem.FileDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.FileSystem.FileDistributedLockHandle! +Medallion.Threading.FileSystem.FileDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.FileSystem.FileDistributedLock.FileDistributedLock(System.IO.DirectoryInfo! lockFileDirectory, string! name) -> void +Medallion.Threading.FileSystem.FileDistributedLock.FileDistributedLock(System.IO.FileInfo! lockFile) -> void +Medallion.Threading.FileSystem.FileDistributedLock.Name.get -> string! +Medallion.Threading.FileSystem.FileDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.FileSystem.FileDistributedLockHandle? +Medallion.Threading.FileSystem.FileDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.FileSystem.FileDistributedLockHandle +Medallion.Threading.FileSystem.FileDistributedLockHandle.Dispose() -> void +Medallion.Threading.FileSystem.FileDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.FileSystem.FileDistributedSynchronizationProvider +Medallion.Threading.FileSystem.FileDistributedSynchronizationProvider.CreateLock(string! name) -> Medallion.Threading.FileSystem.FileDistributedLock! +Medallion.Threading.FileSystem.FileDistributedSynchronizationProvider.FileDistributedSynchronizationProvider(System.IO.DirectoryInfo! lockFileDirectory) -> void \ No newline at end of file diff --git a/src/DistributedLock.FileSystem/PublicAPI.Unshipped.txt b/src/DistributedLock.FileSystem/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/DistributedLock.FileSystem/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/DistributedLock.FileSystem/packages.lock.json b/src/DistributedLock.FileSystem/packages.lock.json new file mode 100644 index 00000000..bdf49de5 --- /dev/null +++ b/src/DistributedLock.FileSystem/packages.lock.json @@ -0,0 +1,237 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "Nullable": { + "type": "Direct", + "requested": "[1.3.1, )", + "resolved": "1.3.1", + "contentHash": "Mk4ZVDfAORTjvckQprCSehi1XgOAAlk5ez06Va/acRYEloN9t6d6zpzJRn5MEq7+RnagyFIq9r+kbWzLGd+6QA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.ValueTuple": { + "type": "CentralTransitive", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + } + }, + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Nullable": { + "type": "Direct", + "requested": "[1.3.1, )", + "resolved": "1.3.1", + "contentHash": "Mk4ZVDfAORTjvckQprCSehi1XgOAAlk5ez06Va/acRYEloN9t6d6zpzJRn5MEq7+RnagyFIq9r+kbWzLGd+6QA==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "distributedlock.core": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.MongoDB/AssemblyAttributes.cs b/src/DistributedLock.MongoDB/AssemblyAttributes.cs new file mode 100644 index 00000000..95195306 --- /dev/null +++ b/src/DistributedLock.MongoDB/AssemblyAttributes.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +// Allow mocking internals +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] \ No newline at end of file diff --git a/src/DistributedLock.MongoDB/DistributedLock.MongoDB.csproj b/src/DistributedLock.MongoDB/DistributedLock.MongoDB.csproj new file mode 100644 index 00000000..47e1939d --- /dev/null +++ b/src/DistributedLock.MongoDB/DistributedLock.MongoDB.csproj @@ -0,0 +1,66 @@ + + + + netstandard2.1;net8.0;net472; + Medallion.Threading.MongoDB + True + 4 + Latest + enable + enable + + + + 1.0.2 + 1.0.0.0 + Michael Adelson, joesdu + Provides a distributed lock implementation based on MongoDB + Copyright © 2026 Michael Adelson + MIT + distributed lock async mongodb + https://github.com/madelson/DistributedLock + https://github.com/madelson/DistributedLock + 1.0.0.0 + See https://github.com/madelson/DistributedLock#release-notes + true + ..\DistributedLock.snk + + + + True + True + True + + + embedded + + true + true + + + + False + 1591 + TRACE;DEBUG + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.MongoDB/MongoDistributedLock.IDistributedLock.cs b/src/DistributedLock.MongoDB/MongoDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..33ffa43a --- /dev/null +++ b/src/DistributedLock.MongoDB/MongoDistributedLock.IDistributedLock.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.MongoDB; + +public partial class MongoDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public MongoDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public MongoDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.MongoDB/MongoDistributedLock.cs b/src/DistributedLock.MongoDB/MongoDistributedLock.cs new file mode 100644 index 00000000..154de6f8 --- /dev/null +++ b/src/DistributedLock.MongoDB/MongoDistributedLock.cs @@ -0,0 +1,252 @@ +using Medallion.Threading.Internal; +using MongoDB.Bson; +using MongoDB.Driver; +using System.Diagnostics; + +namespace Medallion.Threading.MongoDB; + +/// +/// Implements a using MongoDB. +/// +public sealed partial class MongoDistributedLock : IInternalDistributedLock +{ + internal const string DefaultCollectionName = "distributed_locks"; + + private static readonly DateTime EpochUtc = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly MongoIndexInitializer IndexInitializer = new(); + + /// + /// ActivitySource for distributed tracing and diagnostics + /// + internal static readonly ActivitySource ActivitySource = new("DistributedLock.MongoDB", "1.0.0"); + + // Safe static caches: BsonDateTime and BsonValue wrappers are immutable, + // so they can be safely shared across threads and driver calls without risk of mutation. + private static readonly BsonDateTime EpochBsonDateTime = new(EpochUtc); + private static readonly BsonValue ExpiresAtFieldRef = "$expiresAt"; + private static readonly BsonValue AcquiredAtFieldRef = "$acquiredAt"; + private static readonly BsonValue FencingTokenFieldRef = "$fencingToken"; + private static readonly BsonValue LockIdFieldRef = "$lockId"; + private static readonly BsonValue NowRef = "$$NOW"; + private static readonly BsonValue MillisecondRef = "millisecond"; + + private readonly string _collectionName; + private readonly MongoDistributedLockOptions _options; + private readonly Lazy> _collection; + + /// + /// The MongoDB key used to implement the lock + /// + public string Key { get; } + + /// + /// Implements + /// + public string Name => this.Key; + + /// + /// Constructs a lock named using the provided and . + /// The locks will be stored in a collection named "distributed_locks" by default. + /// + public MongoDistributedLock(string key, IMongoDatabase database, Action? options = null) + : this(key, database, DefaultCollectionName, options) { } + + /// + /// Constructs a lock named using the provided , , and + /// . + /// + public MongoDistributedLock(string key, IMongoDatabase database, string collectionName, Action? options = null) + : this(key, database, collectionName, MongoDistributedSynchronizationOptionsBuilder.GetOptions(options)) { } + + internal MongoDistributedLock(string key, IMongoDatabase database, string collectionName, MongoDistributedLockOptions options) + { + var validatedDatabase = database ?? throw new ArgumentNullException(nameof(database)); + this._collectionName = collectionName ?? throw new ArgumentNullException(nameof(collectionName)); + // From what I can tell, modern (and all supported) MongoDB versions have no limits on index keys or + // _id lengths other than the 16MB document limit. This is so high that providing "safe name" functionality as a fallback doesn't + // see worth it. + this.Key = key ?? throw new ArgumentNullException(nameof(key)); + this._options = options; + this._collection = new(() => validatedDatabase.GetCollection(this._collectionName)); + } + + ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + BusyWaitHelper.WaitAsync(this, + (@this, ct) => @this.TryAcquireAsync(ct), + timeout, + minSleepTime: this._options.MinBusyWaitSleepTime, + maxSleepTime: this._options.MaxBusyWaitSleepTime, + cancellationToken); + + private async ValueTask TryAcquireAsync(CancellationToken cancellationToken) + { + using var activity = ActivitySource.StartActivity(nameof(MongoDistributedLock) + ".TryAcquire"); + activity?.SetTag("lock.key", this.Key); + activity?.SetTag("lock.collection", this._collectionName); + + // Use a unique token per acquisition attempt (like Redis' value token) + var lockId = Guid.NewGuid().ToString("N"); + + var collection = this._collection.Value; + + // We avoid exception-driven contention (DuplicateKey) by using a single upsert on {_id == Key} + // and an update pipeline that only overwrites fields when the existing lock is expired. + // This is conceptually similar to Redis: SET key value NX PX . + var filter = Builders.Filter.Eq(d => d.Id, this.Key); + var update = this.CreateAcquireUpdate(lockId); + var options = new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }; + + var result = SyncViaAsync.IsSynchronous + ? collection.FindOneAndUpdate(filter, update, options, cancellationToken) + : await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false); + + // Verify we actually got the lock + if (result?.LockId == lockId) + { + // Fire-and-forget TTL index creation only on successful acquire to avoid + // unnecessary DB calls when the lock is contended. + _ = IndexInitializer.InitializeTtlIndex(collection); + activity?.SetTag("lock.acquired", true); + activity?.SetTag("lock.fencing_token", result.FencingToken); + return new(new(this, lockId, collection), result.FencingToken); + } + activity?.SetTag("lock.acquired", false); + return null; + } + + private UpdateDefinition CreateAcquireUpdate(string lockId) + { + Invariant.Require(!this._options.Expiry.IsInfinite); + + // expired := ifNull(expiresAt, epoch) <= $$NOW + var expiredOrMissing = new BsonDocument( + "$lte", + new BsonArray + { + new BsonDocument("$ifNull", new BsonArray { ExpiresAtFieldRef, EpochBsonDateTime }), + NowRef + } + ); + + var newExpiresAt = new BsonDocument( + "$dateAdd", + new BsonDocument + { + { "startDate", NowRef }, + { "unit", MillisecondRef }, + { "amount", this._options.Expiry.InMilliseconds } + } + ); + + // Increment fencing token only when acquiring a new lock + var newFencingToken = new BsonDocument( + "$add", + new BsonArray + { + new BsonDocument("$ifNull", new BsonArray { FencingTokenFieldRef, 0L }), + 1L + } + ); + + var setStage = new BsonDocument( + "$set", + new BsonDocument + { + // Only overwrite lock fields when the previous lock is expired/missing + { nameof(lockId), new BsonDocument("$cond", new BsonArray { expiredOrMissing, lockId, LockIdFieldRef }) }, + { "expiresAt", new BsonDocument("$cond", new BsonArray { expiredOrMissing, newExpiresAt, ExpiresAtFieldRef }) }, + { "acquiredAt", new BsonDocument("$cond", new BsonArray { expiredOrMissing, NowRef, AcquiredAtFieldRef }) }, + { "fencingToken", new BsonDocument("$cond", new BsonArray { expiredOrMissing, newFencingToken, FencingTokenFieldRef }) } + } + ); + + return new PipelineUpdateDefinition(new[] { setStage }); + } + + /// + /// Inner handle that performs actual lock management and release. + /// Separated from the outer handle so it can be registered with ManagedFinalizerQueue. + /// + internal sealed class InnerHandle : IAsyncDisposable, LeaseMonitor.ILeaseHandle + { + private readonly MongoDistributedLock _lock; + private readonly IMongoCollection _collection; + private readonly LeaseMonitor _monitor; + + // Cached filter to avoid repeated allocations during renewal and release + private readonly FilterDefinition _ownerFilter; + // Lazily initialized: most locks are released quickly and never need renewal + private PipelineUpdateDefinition? _renewUpdate; + + public CancellationToken HandleLostToken => this._monitor.HandleLostToken; + + TimeoutValue LeaseMonitor.ILeaseHandle.LeaseDuration => this._lock._options.Expiry; + TimeoutValue LeaseMonitor.ILeaseHandle.MonitoringCadence => this._lock._options.ExtensionCadence; + + public InnerHandle(MongoDistributedLock @lock, string lockId, IMongoCollection collection) + { + this._lock = @lock; + this._collection = collection; + + // Cache the filter that identifies this specific lock ownership + this._ownerFilter = Builders.Filter.Eq(d => d.Id, @lock.Key) + & Builders.Filter.Eq(d => d.LockId, lockId); + + // important to set this last, since the monitor constructor will read other fields of this + this._monitor = new(this); + } + + public async ValueTask DisposeAsync() + { + try + { + await this._monitor.DisposeAsync().ConfigureAwait(false); + } + finally + { + await this.ReleaseLockAsync().ConfigureAwait(false); + } + } + + private async ValueTask ReleaseLockAsync() + { + if (SyncViaAsync.IsSynchronous) + { + // ReSharper disable once MethodHasAsyncOverload + this._collection.DeleteOne(this._ownerFilter); + } + else + { + // Do not use HandleLostToken here: the monitor (and its CancellationTokenSource) is + // already disposed before ReleaseLockAsync is called from DisposeAsync. + await this._collection.DeleteOneAsync(this._ownerFilter, CancellationToken.None).ConfigureAwait(false); + } + } + + async Task LeaseMonitor.ILeaseHandle.RenewOrValidateLeaseAsync(CancellationToken cancellationToken) + { + // Lazily create the renewal update on first use to avoid allocations for short-lived locks + this._renewUpdate ??= new PipelineUpdateDefinition( + new[] + { + new BsonDocument("$set", new BsonDocument("expiresAt", new BsonDocument( + "$dateAdd", + new BsonDocument + { + { "startDate", NowRef }, + { "unit", MillisecondRef }, + { "amount", this._lock._options.Expiry.InMilliseconds } + } + ))) + } + ); + + var result = await this._collection.UpdateOneAsync(this._ownerFilter, this._renewUpdate, cancellationToken: cancellationToken).ConfigureAwait(false); + return result.MatchedCount > 0 ? LeaseMonitor.LeaseState.Renewed : LeaseMonitor.LeaseState.Lost; + } + } +} diff --git a/src/DistributedLock.MongoDB/MongoDistributedLockHandle.cs b/src/DistributedLock.MongoDB/MongoDistributedLockHandle.cs new file mode 100644 index 00000000..0d2b4391 --- /dev/null +++ b/src/DistributedLock.MongoDB/MongoDistributedLockHandle.cs @@ -0,0 +1,45 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.MongoDB; + +/// +/// Implements for +/// +public sealed class MongoDistributedLockHandle : IDistributedSynchronizationHandle +{ + private MongoDistributedLock.InnerHandle? _innerHandle; + private IDisposable? _finalizerRegistration; + + /// + /// Implements + /// + public CancellationToken HandleLostToken => (this._innerHandle ?? throw this.ObjectDisposed()).HandleLostToken; + + /// + /// Gets the fencing token for this lock acquisition. This is a monotonically increasing value + /// that can be used to detect stale operations when working with external resources. + /// + public long FencingToken { get; } + + internal MongoDistributedLockHandle(MongoDistributedLock.InnerHandle innerHandle, long fencingToken) + { + this._innerHandle = innerHandle; + this.FencingToken = fencingToken; + // Register for managed finalization so the lock gets released if the handle is GC'd without being disposed + this._finalizerRegistration = ManagedFinalizerQueue.Instance.Register(this, innerHandle); + } + + /// + /// Releases the lock + /// + public void Dispose() => this.DisposeSyncViaAsync(); + + /// + /// Releases the lock asynchronously + /// + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref this._finalizerRegistration, null)?.Dispose(); + return Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; + } +} \ No newline at end of file diff --git a/src/DistributedLock.MongoDB/MongoDistributedSynchronizationOptionsBuilder.cs b/src/DistributedLock.MongoDB/MongoDistributedSynchronizationOptionsBuilder.cs new file mode 100644 index 00000000..49e88485 --- /dev/null +++ b/src/DistributedLock.MongoDB/MongoDistributedSynchronizationOptionsBuilder.cs @@ -0,0 +1,122 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.MongoDB; + +/// +/// Options for configuring a MongoDB-based distributed synchronization algorithm +/// +public sealed class MongoDistributedSynchronizationOptionsBuilder +{ + private static readonly TimeoutValue DefaultExpiry = TimeSpan.FromSeconds(30); + + /// + /// We don't want to allow expiry to go too low, since then the lock doesn't even work + /// + private static readonly TimeoutValue MinimumExpiry = TimeSpan.FromSeconds(.1); + + private TimeoutValue? _expiry, _extensionCadence, _minBusyWaitSleepTime, _maxBusyWaitSleepTime; + + private MongoDistributedSynchronizationOptionsBuilder() { } + + /// + /// Specifies how long the lock will last, absent auto-extension. Because auto-extension exists, + /// this value generally will have little effect on program behavior. However, making the expiry longer means that + /// auto-extension requests can occur less frequently, saving resources. On the other hand, when a lock is abandoned + /// without explicit release (e.g. if the holding process crashes), the expiry determines how long other processes + /// would need to wait in order to acquire it. + /// Defaults to 30s. + /// + public MongoDistributedSynchronizationOptionsBuilder Expiry(TimeSpan expiry) + { + var expiryTimeoutValue = new TimeoutValue(expiry, nameof(expiry)); + if (expiryTimeoutValue.IsInfinite || expiryTimeoutValue.CompareTo(MinimumExpiry) < 0) + { + throw new ArgumentOutOfRangeException(nameof(expiry), expiry, $"Must be >= {MinimumExpiry.TimeSpan} and < ∞"); + } + _expiry = expiryTimeoutValue; + return this; + } + + /// + /// Determines how frequently the lock will be extended while held. More frequent extension means more unnecessary requests + /// but also a lower chance of losing the lock due to the process hanging or otherwise failing to get its extension request in + /// before the lock expiry elapses. + /// Defaults to 1/3 of the expiry time. + /// + public MongoDistributedSynchronizationOptionsBuilder ExtensionCadence(TimeSpan extensionCadence) + { + _extensionCadence = new TimeoutValue(extensionCadence, nameof(extensionCadence)); + return this; + } + + /// + /// Waiting to acquire a lock requires a busy wait that alternates acquire attempts and sleeps. + /// This determines how much time is spent sleeping between attempts. Lower values will raise the + /// volume of acquire requests under contention but will also raise the responsiveness (how long + /// it takes a waiter to notice that a contended the lock has become available). + /// Specifying a range of values allows the implementation to select an actual value in the range + /// at random for each sleep. This helps avoid the case where two clients become "synchronized" + /// in such a way that results in one client monopolizing the lock. + /// The default is [10ms, 800ms] + /// + public MongoDistributedSynchronizationOptionsBuilder BusyWaitSleepTime(TimeSpan min, TimeSpan max) + { + TimeoutValue minTimeoutValue = new(min, nameof(min)), + maxTimeoutValue = new(max, nameof(max)); + if (minTimeoutValue.IsInfinite) { throw new ArgumentOutOfRangeException(nameof(min), "may not be infinite"); } + if (maxTimeoutValue.IsInfinite || maxTimeoutValue.CompareTo(min) < 0) + { + throw new ArgumentOutOfRangeException(nameof(max), max, "must be non-infinite and greater than " + nameof(min)); + } + _minBusyWaitSleepTime = minTimeoutValue; + _maxBusyWaitSleepTime = maxTimeoutValue; + return this; + } + + internal static MongoDistributedLockOptions GetOptions(Action? optionsBuilder) + { + MongoDistributedSynchronizationOptionsBuilder? options; + if (optionsBuilder != null) + { + options = new(); + optionsBuilder(options); + } + else + { + options = null; + } + var expiry = options?._expiry ?? DefaultExpiry; + TimeoutValue extensionCadence; + if (options?._extensionCadence is { } specifiedExtensionCadence) + { + if (specifiedExtensionCadence.CompareTo(expiry) >= 0) + { + throw new ArgumentOutOfRangeException(nameof(extensionCadence), + specifiedExtensionCadence.TimeSpan, + $"{nameof(extensionCadence)} must be less than {nameof(expiry)} ({expiry.TimeSpan})"); + } + extensionCadence = specifiedExtensionCadence; + } + else + { + extensionCadence = TimeSpan.FromMilliseconds(expiry.InMilliseconds / 3.0); + } + return new( + expiry: expiry, + extensionCadence: extensionCadence, + minBusyWaitSleepTime: options?._minBusyWaitSleepTime ?? TimeSpan.FromMilliseconds(10), + maxBusyWaitSleepTime: options?._maxBusyWaitSleepTime ?? TimeSpan.FromSeconds(0.8)); + } +} + +internal readonly struct MongoDistributedLockOptions( + TimeoutValue expiry, + TimeoutValue extensionCadence, + TimeoutValue minBusyWaitSleepTime, + TimeoutValue maxBusyWaitSleepTime) +{ + public TimeoutValue Expiry => expiry; + public TimeoutValue ExtensionCadence => extensionCadence; + public TimeoutValue MinBusyWaitSleepTime => minBusyWaitSleepTime; + public TimeoutValue MaxBusyWaitSleepTime => maxBusyWaitSleepTime; +} \ No newline at end of file diff --git a/src/DistributedLock.MongoDB/MongoDistributedSynchronizationProvider.cs b/src/DistributedLock.MongoDB/MongoDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..05a00035 --- /dev/null +++ b/src/DistributedLock.MongoDB/MongoDistributedSynchronizationProvider.cs @@ -0,0 +1,38 @@ +using MongoDB.Driver; + +namespace Medallion.Threading.MongoDB; + +/// +/// Implements for . +/// +public sealed class MongoDistributedSynchronizationProvider : IDistributedLockProvider +{ + private readonly string _collectionName; + private readonly IMongoDatabase _database; + private readonly Action? _options; + + /// + /// Constructs a that connects to the provided + /// and uses the provided . Locks will be stored in a collection named "distributed_locks" by default. + /// + public MongoDistributedSynchronizationProvider(IMongoDatabase database, Action? options = null) + : this(database, MongoDistributedLock.DefaultCollectionName, options) { } + + /// + /// Constructs a that connects to the provided , + /// stores locks in the specified , and uses the provided . + /// + public MongoDistributedSynchronizationProvider(IMongoDatabase database, string collectionName, Action? options = null) + { + this._database = database ?? throw new ArgumentNullException(nameof(database)); + this._collectionName = collectionName ?? throw new ArgumentNullException(nameof(collectionName)); + this._options = options; + } + + /// + /// Creates a using the given . + /// + public MongoDistributedLock CreateLock(string name) => new(name, this._database, this._collectionName, this._options); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); +} \ No newline at end of file diff --git a/src/DistributedLock.MongoDB/MongoIndexInitializer.cs b/src/DistributedLock.MongoDB/MongoIndexInitializer.cs new file mode 100644 index 00000000..cf3089e5 --- /dev/null +++ b/src/DistributedLock.MongoDB/MongoIndexInitializer.cs @@ -0,0 +1,134 @@ +using MongoDB.Driver; +using System.Collections.Concurrent; + +namespace Medallion.Threading.MongoDB; + +internal class MongoIndexInitializer +{ + private const string IndexName = "expiresAt_ttl"; + + // We want to ensure indexes are created at most once per process per (database, collection) + private readonly ConcurrentDictionary<(int SettingsHash, CollectionNamespace Namespace), Lazy>> _indexInitializationTasks = []; + + /// + /// Idempotently creates a best-effort TTL index to clean up expired rows over time. + /// Note: TTL monitors run on a schedule; correctness MUST NOT depend on this index existing. + /// + public Task InitializeTtlIndex(IMongoCollection collection) + { + // Include the hash code of the settings to differentiate between different clusters/clients + // that happen to use the same database/collection names. + // While GetHashCode() isn't perfect, it should be sufficient to distinguish between different clients/settings + // in valid use-cases (e.g. diff connection strings). + var clientSettingsHash = collection.Database.Client.Settings.GetHashCode(); + var key = (clientSettingsHash, collection.CollectionNamespace); + + // If we already have a task and it is in-progress or finished conclusively, noop + if (this._indexInitializationTasks.TryGetValue(key, out var existingTask) + && (!existingTask.Value.IsCompleted || existingTask.Value.Result.HasValue)) + { + return existingTask.Value; + } + + var newTask = this._indexInitializationTasks.AddOrUpdate( + key, + addValueFactory: static (k, a) => new(() => a.initializer.CreateIndexIfNotExistsWrapperAsync(a.collection)), + updateValueFactory: static (k, existing, a) => existing == a.existingTask ? new(() => a.initializer.CreateIndexIfNotExistsWrapperAsync(a.collection)) : existing, + factoryArgument: (initializer: this, collection, existingTask)); + return newTask.Value; + } + + private async Task CreateIndexIfNotExistsWrapperAsync(IMongoCollection collection) + { + if (await CreateIndexIfNotExistsAsync(collection).ConfigureAwait(false) is { } result) + { + return result; + } + + // On a retryable failure, avoid resolving the task for a bit so we don't retry immediately and spam the DB + await this.DelayBeforeRetry().ConfigureAwait(false); + + return null; + } + + // exposed for mocking + internal virtual Task DelayBeforeRetry() => Task.Delay(TimeSpan.FromMinutes(1)); + + private static async Task CreateIndexIfNotExistsAsync(IMongoCollection collection) + { + using var activity = MongoDistributedLock.ActivitySource.StartActivity(nameof(MongoIndexInitializer) + ".CreateIndexIfNotExists"); + activity?.AddTag("collection", collection.CollectionNamespace.FullName); + + const string TagKey = "ttl_index"; + try + { + var indexKeys = Builders.IndexKeys.Ascending(d => d.ExpiresAt); + var indexOptions = new CreateIndexOptions + { + // TTL cleanup: remove documents once expiresAt < now + ExpireAfter = TimeSpan.Zero, + Name = IndexName, + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + await collection.Indexes.CreateOneAsync(indexModel, cancellationToken: CancellationToken.None).ConfigureAwait(false); + activity?.SetTag(TagKey, "created"); + return true; + } + catch (MongoCommandException ex) when (ex.CodeName is "IndexOptionsConflict" or "IndexKeySpecsConflict" or "IndexAlreadyExists") + { + // Index already exists with same or different options - this is acceptable. + // The existing index will still handle TTL cleanup. + activity?.SetTag(TagKey, "exists"); + return true; + } + catch (MongoCommandException ex) when (ex.CodeName == "Unauthorized") + { + try + { + // If we don't have permissions to create an index, we may still be able to affirm + // that it exists by querying for it + if (await CheckIfIndexExists(collection).ConfigureAwait(false)) + { + activity?.SetTag(TagKey, "exists"); + return true; + } + } + catch (Exception checkException) + { + activity?.SetTag("exists_check", $"failed: {checkException.GetType()}: {checkException.Message}"); + } + + activity?.SetTag(TagKey, "failed: " + ex.CodeName); + return false; // if we're not authorized, there's no point in retrying + } + catch (Exception ex) + { + activity?.SetTag(TagKey, $"failed: {ex.GetType()}: {ex.Message}"); + activity?.SetTag("will_retry", true); + return null; // retry ephemeral failures + } + } + + // exposed for testing + internal static async Task CheckIfIndexExists(IMongoCollection collection) + { + using var cursor = await collection.Indexes.ListAsync().ConfigureAwait(false); + while (await cursor.MoveNextAsync().ConfigureAwait(false)) + { + foreach (var index in cursor.Current) + { + if (index["name"].AsString == IndexName) { return true; } + + // TTL indexes contain the "expireAfterSeconds" field in their options + if (index.Contains("expireAfterSeconds")) + { + var keyElement = index["key"].AsBsonDocument; + // Check if the first key in the index is "expiresAt" + if (keyElement.Contains("expiresAt")) { return true; } + } + } + } + + return false; + } +} diff --git a/src/DistributedLock.MongoDB/MongoLockDocument.cs b/src/DistributedLock.MongoDB/MongoLockDocument.cs new file mode 100644 index 00000000..f2a37cbb --- /dev/null +++ b/src/DistributedLock.MongoDB/MongoLockDocument.cs @@ -0,0 +1,45 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Medallion.Threading.MongoDB; + +/// +/// Represents a lock document stored in MongoDB +/// +// ReSharper disable once ClassNeverInstantiated.Global +internal sealed class MongoLockDocument +{ + /// + /// The lock name/key (MongoDB document ID) + /// + [BsonId] + [BsonRepresentation(BsonType.String)] + public string Id { get; set; } = null!; + + /// + /// Unique identifier for this lock acquisition + /// + [BsonElement("lockId")] + [BsonRepresentation(BsonType.String)] + public string LockId { get; set; } = null!; + + /// + /// When the lock was acquired (UTC) + /// + [BsonElement("acquiredAt")] + [BsonRepresentation(BsonType.DateTime)] + public DateTime AcquiredAt { get; set; } + + /// + /// When the lock expires (UTC) + /// + [BsonElement("expiresAt")] + [BsonRepresentation(BsonType.DateTime)] + public DateTime ExpiresAt { get; set; } + + /// + /// Monotonically increasing fencing token for safe resource access + /// + [BsonElement("fencingToken")] + public long FencingToken { get; set; } +} \ No newline at end of file diff --git a/src/DistributedLock.MongoDB/PublicAPI.Shipped.txt b/src/DistributedLock.MongoDB/PublicAPI.Shipped.txt new file mode 100644 index 00000000..5e440dc2 --- /dev/null +++ b/src/DistributedLock.MongoDB/PublicAPI.Shipped.txt @@ -0,0 +1,22 @@ +#nullable enable +Medallion.Threading.MongoDB.MongoDistributedLock +Medallion.Threading.MongoDB.MongoDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.MongoDB.MongoDistributedLockHandle! +Medallion.Threading.MongoDB.MongoDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.MongoDB.MongoDistributedLock.Key.get -> string! +Medallion.Threading.MongoDB.MongoDistributedLock.Name.get -> string! +Medallion.Threading.MongoDB.MongoDistributedLock.MongoDistributedLock(string! key, MongoDB.Driver.IMongoDatabase! database, System.Action? options = null) -> void +Medallion.Threading.MongoDB.MongoDistributedLock.MongoDistributedLock(string! key, MongoDB.Driver.IMongoDatabase! database, string! collectionName, System.Action? options = null) -> void +Medallion.Threading.MongoDB.MongoDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.MongoDB.MongoDistributedLockHandle? +Medallion.Threading.MongoDB.MongoDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.MongoDB.MongoDistributedLockHandle +Medallion.Threading.MongoDB.MongoDistributedLockHandle.Dispose() -> void +Medallion.Threading.MongoDB.MongoDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.MongoDB.MongoDistributedLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.MongoDB.MongoDistributedSynchronizationOptionsBuilder +Medallion.Threading.MongoDB.MongoDistributedSynchronizationOptionsBuilder.BusyWaitSleepTime(System.TimeSpan min, System.TimeSpan max) -> Medallion.Threading.MongoDB.MongoDistributedSynchronizationOptionsBuilder! +Medallion.Threading.MongoDB.MongoDistributedSynchronizationOptionsBuilder.Expiry(System.TimeSpan expiry) -> Medallion.Threading.MongoDB.MongoDistributedSynchronizationOptionsBuilder! +Medallion.Threading.MongoDB.MongoDistributedSynchronizationOptionsBuilder.ExtensionCadence(System.TimeSpan extensionCadence) -> Medallion.Threading.MongoDB.MongoDistributedSynchronizationOptionsBuilder! +Medallion.Threading.MongoDB.MongoDistributedSynchronizationProvider +Medallion.Threading.MongoDB.MongoDistributedSynchronizationProvider.CreateLock(string! name) -> Medallion.Threading.MongoDB.MongoDistributedLock! +Medallion.Threading.MongoDB.MongoDistributedSynchronizationProvider.MongoDistributedSynchronizationProvider(MongoDB.Driver.IMongoDatabase! database, System.Action? options = null) -> void +Medallion.Threading.MongoDB.MongoDistributedSynchronizationProvider.MongoDistributedSynchronizationProvider(MongoDB.Driver.IMongoDatabase! database, string! collectionName, System.Action? options = null) -> void diff --git a/src/DistributedLock.MongoDB/PublicAPI.Unshipped.txt b/src/DistributedLock.MongoDB/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..beb583c7 --- /dev/null +++ b/src/DistributedLock.MongoDB/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +#nullable enable +Medallion.Threading.MongoDB.MongoDistributedLockHandle.FencingToken.get -> long \ No newline at end of file diff --git a/src/DistributedLock.MongoDB/packages.lock.json b/src/DistributedLock.MongoDB/packages.lock.json new file mode 100644 index 00000000..0e54b358 --- /dev/null +++ b/src/DistributedLock.MongoDB/packages.lock.json @@ -0,0 +1,572 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.7.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "MongoDB.Driver": { + "type": "Direct", + "requested": "[3.9.0, )", + "resolved": "3.9.0", + "contentHash": "XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Net.Http": "4.3.4", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "ZstdSharp.Port": "0.7.3" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0", + "System.Buffers": "4.5.1" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "6ZCllUYGFukkymSTx3Yr0G/ajRxoNJp7/FqSxSB4fGISST54ifBhgu4Nc0ItGi3i6DqwuNd8SUyObmiC++AO2Q==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.9.0", + "contentHash": "J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Text.Encoding.CodePages": "8.0.0" + } + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==", + "dependencies": { + "System.Memory": "4.6.3" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.4", + "contentHash": "aOa2d51SEbmM+H+Csw7yJOuNZoHkrP2XnAurye5HWYgGVVU54YZDvsLUYRv6h18X3sPnjNCANmN7ZhIPiqMcjA==", + "dependencies": { + "System.Security.Cryptography.X509Certificates": "4.3.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "System.IO": "4.3.0", + "System.Runtime": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==" + }, + "System.Security.Cryptography.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==" + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "System.Memory": "4.5.5" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "MongoDB.Driver": { + "type": "Direct", + "requested": "[3.9.0, )", + "resolved": "3.9.0", + "contentHash": "XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "ZstdSharp.Port": "0.7.3" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "6ZCllUYGFukkymSTx3Yr0G/ajRxoNJp7/FqSxSB4fGISST54ifBhgu4Nc0ItGi3i6DqwuNd8SUyObmiC++AO2Q==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.9.0", + "contentHash": "J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Text.Encoding.CodePages": "8.0.0" + } + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "distributedlock.core": { + "type": "Project" + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.26, )", + "resolved": "8.0.26", + "contentHash": "o7/yVssM2r9Wyln2s9edBd5ANZXqdSdBI+g7JqXkyJmXrhs2WsJp25K5yPnYrTgdKBCjKB8bg+O2oew4sgzFaA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "MongoDB.Driver": { + "type": "Direct", + "requested": "[3.9.0, )", + "resolved": "3.9.0", + "contentHash": "XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "System.Buffers": "4.6.1", + "ZstdSharp.Port": "0.7.3" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "6ZCllUYGFukkymSTx3Yr0G/ajRxoNJp7/FqSxSB4fGISST54ifBhgu4Nc0ItGi3i6DqwuNd8SUyObmiC++AO2Q==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.9.0", + "contentHash": "J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==" + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZD9TMpsmYJLrxbbmdvhwt9YEgG5WntEnZ/d1eH8JBX9LBp+Ju8BSBhUGbZMNVHHomWo2KVImJhTDl2hIgw/6MA==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==" + }, + "distributedlock.core": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.MySql/AssemblyAttributes.cs b/src/DistributedLock.MySql/AssemblyAttributes.cs new file mode 100644 index 00000000..a9e3a34f --- /dev/null +++ b/src/DistributedLock.MySql/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] diff --git a/src/DistributedLock.MySql/DistributedLock.MySql.csproj b/src/DistributedLock.MySql/DistributedLock.MySql.csproj new file mode 100644 index 00000000..ba277a40 --- /dev/null +++ b/src/DistributedLock.MySql/DistributedLock.MySql.csproj @@ -0,0 +1,63 @@ + + + + netstandard2.0;netstandard2.1;net462 + Medallion.Threading.MySql + True + 4 + Latest + enable + enable + + + + 1.0.2 + 1.0.0.0 + Michael Adelson + Provides a distributed lock implementation based on MySql + Copyright © 2021 Michael Adelson + MIT + distributed lock async mutex sql mysql + https://github.com/madelson/DistributedLock + https://github.com/madelson/DistributedLock + 1.0.0.0 + See https://github.com/madelson/DistributedLock#release-notes + true + ..\DistributedLock.snk + + + + True + True + True + + + embedded + + true + true + + + + False + 1591 + TRACE;DEBUG + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.MySql/MySqlConnectionOptionsBuilder.cs b/src/DistributedLock.MySql/MySqlConnectionOptionsBuilder.cs new file mode 100644 index 00000000..54cf5100 --- /dev/null +++ b/src/DistributedLock.MySql/MySqlConnectionOptionsBuilder.cs @@ -0,0 +1,71 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.MySql; + +/// +/// Specifies options for connecting to and locking against a MySQL database +/// +public sealed class MySqlConnectionOptionsBuilder +{ + private TimeoutValue? _keepaliveCadence; + private bool? _useMultiplexing; + + internal MySqlConnectionOptionsBuilder() { } + + /// + /// MySQL's wait_timeout system variable determines how long the server will allow a connection to be idle before killing it. + /// For more information, see https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_wait_timeout. + /// + /// To prevent this, this option sets the cadence at which we run a no-op "keepalive" query on a connection that is holding a lock. + /// + /// Because MySQL's default for this setting is 8 hours, the default is 3.5 hours. + /// + /// Setting a value of disables keepalive. + /// + public MySqlConnectionOptionsBuilder KeepaliveCadence(TimeSpan keepaliveCadence) + { + this._keepaliveCadence = new TimeoutValue(keepaliveCadence, nameof(keepaliveCadence)); + return this; + } + + /// + /// This mode takes advantage of the fact that while "holding" a lock (or other synchronization primitive) + /// a connection is essentially idle. Thus, rather than creating a new connection for each held lock it is + /// often possible to multiplex a shared connection so that that connection can hold multiple locks at the same time. + /// + /// Multiplexing is on by default. + /// + /// This is implemented in such a way that releasing a lock held on such a connection will never be blocked by an + /// Acquire() call that is waiting to acquire a lock on that same connection. For this reason, the multiplexing + /// strategy is "optimistic": if the lock can't be acquired instantaneously on the shared connection, a new (shareable) + /// connection will be allocated. + /// + /// This option can improve performance and avoid connection pool starvation in high-load scenarios. It is also + /// particularly applicable to cases where + /// semantics are used with a zero-length timeout. + /// + public MySqlConnectionOptionsBuilder UseMultiplexing(bool useMultiplexing = true) + { + this._useMultiplexing = useMultiplexing; + return this; + } + + internal static (TimeoutValue keepaliveCadence, bool useMultiplexing) GetOptions(Action? optionsBuilder) + { + MySqlConnectionOptionsBuilder? options; + if (optionsBuilder != null) + { + options = new MySqlConnectionOptionsBuilder(); + optionsBuilder(options); + } + else + { + options = null; + } + + var keepaliveCadence = options?._keepaliveCadence ?? TimeSpan.FromHours(3.5); + var useMultiplexing = options?._useMultiplexing ?? true; + + return (keepaliveCadence, useMultiplexing); + } +} diff --git a/src/DistributedLock.MySql/MySqlDatabaseConnection.cs b/src/DistributedLock.MySql/MySqlDatabaseConnection.cs new file mode 100644 index 00000000..f9f6271c --- /dev/null +++ b/src/DistributedLock.MySql/MySqlDatabaseConnection.cs @@ -0,0 +1,43 @@ +using Medallion.Threading.Internal.Data; +using MySqlConnector; +using System.Data; + +namespace Medallion.Threading.MySql; + +internal class MySqlDatabaseConnection : DatabaseConnection +{ + public MySqlDatabaseConnection(IDbConnection connection) + : base(connection, isExternallyOwned: true) + { + } + + public MySqlDatabaseConnection(IDbTransaction transaction) + : base(transaction, isExternallyOwned: true) + { + } + + public MySqlDatabaseConnection(string connectionString) + : base(new MySqlConnection(connectionString), isExternallyOwned: false) + { + } + + // Seems like this only helps when executing a statement multiple times on the + // same connection (unclear since there's limited documentation) + public override bool ShouldPrepareCommands => false; + + public override bool IsCommandCancellationException(Exception exception) + { + // see https://mysqlconnector.net/overview/command-cancellation/ + return exception is MySqlException ex && ex.ErrorCode == MySqlErrorCode.QueryInterrupted; + } + + public override async Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor) + { + using var sleepCommand = this.CreateCommand(); + sleepCommand.SetCommandText("SELECT SLEEP(@durationSeconds)"); + sleepCommand.AddParameter("durationSeconds", sleepTime.TotalSeconds); + sleepCommand.SetTimeout(sleepTime); + + await executor(sleepCommand, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/DistributedLock.MySql/MySqlDistributedLock.IDistributedLock.cs b/src/DistributedLock.MySql/MySqlDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..f9c1e374 --- /dev/null +++ b/src/DistributedLock.MySql/MySqlDistributedLock.IDistributedLock.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.MySql; + +public partial class MySqlDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public MySqlDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public MySqlDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.MySql/MySqlDistributedLock.cs b/src/DistributedLock.MySql/MySqlDistributedLock.cs new file mode 100644 index 00000000..f57c0e7e --- /dev/null +++ b/src/DistributedLock.MySql/MySqlDistributedLock.cs @@ -0,0 +1,178 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; +using System.Security.Cryptography; +using System.Text; + +namespace Medallion.Threading.MySql; + +/// +/// Implements a distributed lock for MySQL or MariaDB based on the GET_LOCK family of functions +/// +public sealed partial class MySqlDistributedLock : IInternalDistributedLock +{ + /// + /// From https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html + /// + internal const int MaxNameLength = 64; + + private readonly IDbDistributedLock _internalLock; + + /// + /// Constructs a lock with the given that connects using the provided and + /// . + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public MySqlDistributedLock(string name, string connectionString, Action? options = null, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, connectionString, options)) + { + } + + /// + /// Constructs a lock with the given that connects using the provided . + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public MySqlDistributedLock(string name, IDbConnection connection, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, connection)) + { + } + + /// + /// Constructs a lock with the given that connects using the connection from the provided . + /// + /// NOTE that the lock will not be scoped to the and must still be explicitly released before the transaction ends. + /// However, this constructor allows the lock to PARTICIPATE in an ongoing transaction on a connection. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public MySqlDistributedLock(string name, IDbTransaction transaction, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, transaction)) + { + } + + private MySqlDistributedLock(string name, bool exactName, Func internalLockFactory) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + if (exactName) + { + if (name.Length > MaxNameLength) { throw new FormatException($"{nameof(name)}: must be at most {MaxNameLength} characters"); } + if (name.Length == 0) { throw new FormatException($"{nameof(name)}: must not be empty"); } + if (name.ToLowerInvariant() != name) { throw new FormatException($"{nameof(name)}: must not container uppercase letters"); } + this.Name = name; + } + else + { + this.Name = GetSafeName(name); + } + + this._internalLock = internalLockFactory(this.Name); + } + + /// + /// Implements + /// + public string Name { get; } + + ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + this._internalLock.TryAcquireAsync(timeout, new MySqlUserLock(), cancellationToken, contextHandle: null).Wrap(h => new MySqlDistributedLockHandle(h)); + + private static string GetSafeName(string name) => + ToSafeName( + name, + MaxNameLength, + convertToValidName: s => + { + if (s.Length == 0) { return "__empty__"; } + return s.ToLowerInvariant(); + }, + hash: ComputeHash + ); + + private static string ToSafeName(string name, int maxNameLength, Func convertToValidName, Func hash) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + var validBaseLockName = convertToValidName(name); + if (validBaseLockName == name && validBaseLockName.Length <= maxNameLength) + { + return name; + } + + var nameHash = hash(Encoding.UTF8.GetBytes(name)); + + if (nameHash.Length >= maxNameLength) + { + return nameHash.Substring(0, length: maxNameLength); + } + + var prefix = validBaseLockName.Substring(0, Math.Min(validBaseLockName.Length, maxNameLength - nameHash.Length)); + return prefix + nameHash; + } + + private static string ComputeHash(byte[] bytes) + { + using var sha = SHA512.Create(); + var hashBytes = sha.ComputeHash(bytes); + + // We truncate to 160 bits, which is 32 chars of Base32. This should still give us good collision resistance but allows for a 64-char + // name to include a good portion of the original provided name, which is good for debugging. See + // https://crypto.stackexchange.com/questions/9435/is-truncating-a-sha512-hash-to-the-first-160-bits-as-secure-as-using-sha1#:~:text=Yes.,time%20is%20still%20pretty%20big + const int Base32CharBits = 5; + const int HashLengthInChars = 160 / Base32CharBits; + + // we use Base32 because it is case-insensitive (like MySQL) and a bit more compact than Base16 + // RFC 4648 from https://en.wikipedia.org/wiki/Base32 + const string Base32Alphabet = "abcdefghijklmnopqrstuvwxyz234567"; + + var chars = new char[HashLengthInChars]; + var byteIndex = 0; + var bitBuffer = 0; + var bitsRemaining = 0; + for (var charIndex = 0; charIndex < chars.Length; ++charIndex) + { + if (bitsRemaining < Base32CharBits) + { + bitBuffer |= hashBytes[byteIndex++] << bitsRemaining; + bitsRemaining += 8; + } + chars[charIndex] = Base32Alphabet[bitBuffer & 31]; + bitBuffer >>= Base32CharBits; + bitsRemaining -= Base32CharBits; + } + + return new string(chars); + } + + private static IDbDistributedLock CreateInternalLock(string name, string connectionString, Action? options) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + var (keepaliveCadence, useMultiplexing) = MySqlConnectionOptionsBuilder.GetOptions(options); + + if (useMultiplexing) + { + return new OptimisticConnectionMultiplexingDbDistributedLock(name, connectionString, MySqlMultiplexedConnectionLockPool.Instance, keepaliveCadence); + } + + return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new MySqlDatabaseConnection(connectionString), useTransaction: false, keepaliveCadence); + } + + private static IDbDistributedLock CreateInternalLock(string name, IDbConnection connection) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + + return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new MySqlDatabaseConnection(connection)); + } + + private static IDbDistributedLock CreateInternalLock(string name, IDbTransaction transaction) + { + if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); } + + // Note: we pass useTransaction:false here because MYSQL locks are always session-scoped; we only support locking against a transaction + // so that your lock can participate in the connection. + return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new MySqlDatabaseConnection(transaction), useTransaction: false, keepaliveCadence: Timeout.InfiniteTimeSpan); + } +} diff --git a/src/DistributedLock.MySql/MySqlDistributedLockHandle.cs b/src/DistributedLock.MySql/MySqlDistributedLockHandle.cs new file mode 100644 index 00000000..1fee6c5e --- /dev/null +++ b/src/DistributedLock.MySql/MySqlDistributedLockHandle.cs @@ -0,0 +1,31 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.MySql; + +/// +/// Implements +/// +public sealed class MySqlDistributedLockHandle : IDistributedSynchronizationHandle +{ + private IDistributedSynchronizationHandle? _innerHandle; + + internal MySqlDistributedLockHandle(IDistributedSynchronizationHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the lock asynchronously + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} diff --git a/src/DistributedLock.MySql/MySqlDistributedSynchronizationProvider.cs b/src/DistributedLock.MySql/MySqlDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..34b078e5 --- /dev/null +++ b/src/DistributedLock.MySql/MySqlDistributedSynchronizationProvider.cs @@ -0,0 +1,49 @@ +using System.Data; + +namespace Medallion.Threading.MySql; + +/// +/// Implements for +/// +public sealed class MySqlDistributedSynchronizationProvider : IDistributedLockProvider +{ + private readonly Func _lockFactory; + + /// + /// Constructs a provider that connects with and . + /// + public MySqlDistributedSynchronizationProvider(string connectionString, Action? options = null) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + this._lockFactory = (name, exactName) => new MySqlDistributedLock(name, connectionString, options, exactName); + } + + /// + /// Constructs a provider that connects with . + /// + public MySqlDistributedSynchronizationProvider(IDbConnection connection) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + + this._lockFactory = (name, exactName) => new MySqlDistributedLock(name, connection, exactName); + } + + /// + /// Constructs a provider that connects with . + /// + public MySqlDistributedSynchronizationProvider(IDbTransaction transaction) + { + if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); } + + this._lockFactory = (name, exactName) => new MySqlDistributedLock(name, transaction, exactName); + } + + /// + /// Creates a with the provided . Unless + /// is specified, invalid names will be escaped/hashed. + /// + public MySqlDistributedLock CreateLock(string name, bool exactName = false) => this._lockFactory(name, exactName); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); +} diff --git a/src/DistributedLock.MySql/MySqlMultiplexedConnectionLockPool.cs b/src/DistributedLock.MySql/MySqlMultiplexedConnectionLockPool.cs new file mode 100644 index 00000000..6c32adbc --- /dev/null +++ b/src/DistributedLock.MySql/MySqlMultiplexedConnectionLockPool.cs @@ -0,0 +1,8 @@ +using Medallion.Threading.Internal.Data; + +namespace Medallion.Threading.MySql; + +internal static class MySqlMultiplexedConnectionLockPool +{ + public static readonly MultiplexedConnectionLockPool Instance = new(s => new MySqlDatabaseConnection(s)); +} diff --git a/src/DistributedLock.MySql/MySqlUserLock.cs b/src/DistributedLock.MySql/MySqlUserLock.cs new file mode 100644 index 00000000..693da295 --- /dev/null +++ b/src/DistributedLock.MySql/MySqlUserLock.cs @@ -0,0 +1,76 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using MySqlConnector; + +namespace Medallion.Threading.MySql; + +/// +/// Implements using user-level locking functions. See +/// https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html +/// +internal class MySqlUserLock : IDbSynchronizationStrategy +{ + // matches SqlApplicationLock + private const int AlreadyHeldReturnCode = 103; + // see behavior documented at https://mariadb.com/kb/en/get_lock/ for when GET_LOCK returns NULL + private const int GetLockErrorReturnCode = 104; + + private static readonly object Cookie = new(); + + public bool IsUpgradeable => false; + + public async ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, object lockCookie) + { + using var command = connection.CreateCommand(); + command.SetCommandText("DO RELEASE_LOCK(@name)"); + command.AddParameter("name", resourceName); + await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + } + + public async ValueTask TryAcquireAsync(DatabaseConnection connection, string resourceName, TimeoutValue timeout, CancellationToken cancellationToken) + { + try + { + using var command = connection.CreateCommand(); + command.SetCommandText($"SELECT CASE WHEN IS_USED_LOCK(@name) = CONNECTION_ID() THEN {AlreadyHeldReturnCode} ELSE IFNULL(GET_LOCK(@name, @timeoutSeconds), {GetLockErrorReturnCode}) END"); + command.AddParameter("name", resourceName); + // Note: -1 works for MySQL but not for MariaDB (https://stackoverflow.com/questions/49792089/set-infinite-timeout-get-lock-in-mariadb/49809919) + command.AddParameter("timeoutSeconds", timeout.IsInfinite ? 0xFFFFFFFF : timeout.InSeconds); + + // Convert required because the value comes back as int on MariaDB and long on MySQL + var acquireCommandResult = Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false)); + switch (acquireCommandResult) + { + case 0: // timeout + return null; + case 1: // success + return Cookie; + case AlreadyHeldReturnCode: + if (timeout.IsZero) { return null; } + if (timeout.IsInfinite) { throw new DeadlockException("Attempted to acquire a lock that is already held on the same connection"); } + await SyncViaAsync.Delay(timeout, cancellationToken).ConfigureAwait(false); + return null; + case GetLockErrorReturnCode: + cancellationToken.ThrowIfCancellationRequested(); // this error can also indicate cancellation in MariaDB + throw new InvalidOperationException("An error occurred such as running out of memory on the thread or a mysqladmin kill when trying to acquire the lock"); + default: + throw new InvalidOperationException($"Unexpected return code {acquireCommandResult}"); + } + } + catch (MySqlException ex) + // from https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html#error_er_user_lock_deadlock + when ((ex.Number == 3058 && ex.SqlState == "HY000") + // from https://mariadb.com/kb/en/mariadb-error-codes/ + || (ex.Number == 1213 && ex.SqlState == "40001")) + { + throw new DeadlockException($"The request for the distributed lock failed with deadlock exit code {ex.Number}", ex); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // If the command is canceled, I believe there's a slim chance that acquisition just completed before the cancellation went through. + // In that case, I'm pretty sure it won't be rolled back. Therefore, to be safe we issue a release + await this.ReleaseAsync(connection, resourceName, Cookie).ConfigureAwait(false); + throw; + } + } +} diff --git a/src/DistributedLock.MySql/PublicAPI.Shipped.txt b/src/DistributedLock.MySql/PublicAPI.Shipped.txt new file mode 100644 index 00000000..896f6f8b --- /dev/null +++ b/src/DistributedLock.MySql/PublicAPI.Shipped.txt @@ -0,0 +1,22 @@ +#nullable enable +Medallion.Threading.MySql.MySqlConnectionOptionsBuilder +Medallion.Threading.MySql.MySqlConnectionOptionsBuilder.KeepaliveCadence(System.TimeSpan keepaliveCadence) -> Medallion.Threading.MySql.MySqlConnectionOptionsBuilder! +Medallion.Threading.MySql.MySqlConnectionOptionsBuilder.UseMultiplexing(bool useMultiplexing = true) -> Medallion.Threading.MySql.MySqlConnectionOptionsBuilder! +Medallion.Threading.MySql.MySqlDistributedLock +Medallion.Threading.MySql.MySqlDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.MySql.MySqlDistributedLockHandle! +Medallion.Threading.MySql.MySqlDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.MySql.MySqlDistributedLock.MySqlDistributedLock(string! name, string! connectionString, System.Action? options = null, bool exactName = false) -> void +Medallion.Threading.MySql.MySqlDistributedLock.MySqlDistributedLock(string! name, System.Data.IDbConnection! connection, bool exactName = false) -> void +Medallion.Threading.MySql.MySqlDistributedLock.MySqlDistributedLock(string! name, System.Data.IDbTransaction! transaction, bool exactName = false) -> void +Medallion.Threading.MySql.MySqlDistributedLock.Name.get -> string! +Medallion.Threading.MySql.MySqlDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.MySql.MySqlDistributedLockHandle? +Medallion.Threading.MySql.MySqlDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.MySql.MySqlDistributedLockHandle +Medallion.Threading.MySql.MySqlDistributedLockHandle.Dispose() -> void +Medallion.Threading.MySql.MySqlDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.MySql.MySqlDistributedLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.MySql.MySqlDistributedSynchronizationProvider +Medallion.Threading.MySql.MySqlDistributedSynchronizationProvider.CreateLock(string! name, bool exactName = false) -> Medallion.Threading.MySql.MySqlDistributedLock! +Medallion.Threading.MySql.MySqlDistributedSynchronizationProvider.MySqlDistributedSynchronizationProvider(string! connectionString, System.Action? options = null) -> void +Medallion.Threading.MySql.MySqlDistributedSynchronizationProvider.MySqlDistributedSynchronizationProvider(System.Data.IDbConnection! connection) -> void +Medallion.Threading.MySql.MySqlDistributedSynchronizationProvider.MySqlDistributedSynchronizationProvider(System.Data.IDbTransaction! transaction) -> void \ No newline at end of file diff --git a/src/DistributedLock.MySql/PublicAPI.Unshipped.txt b/src/DistributedLock.MySql/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/DistributedLock.MySql/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/DistributedLock.MySql/packages.lock.json b/src/DistributedLock.MySql/packages.lock.json new file mode 100644 index 00000000..35bb9a6b --- /dev/null +++ b/src/DistributedLock.MySql/packages.lock.json @@ -0,0 +1,337 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "MySqlConnector": { + "type": "Direct", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1", + "System.Diagnostics.DiagnosticSource": "7.0.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "7.0.1", + "contentHash": "pkeBFx0vqMW/A3aUVHh7MPu3WkBhaVlezhSZeb1c9XD0vUReYH1TLFSy5MxJgZfmz5LZzYoErMorlYZiwpOoNA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "hYr3I9N9811e0Bjf2WNwAGGyTuAFbbTgX1RPLt/3Wbm68x3IGcX5Cl75CMmgT6WlNwLQ2tCCWfqYPpypjaf2xA==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.ValueTuple": { + "type": "CentralTransitive", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + } + }, + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "MySqlConnector": { + "type": "Direct", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1", + "System.Diagnostics.DiagnosticSource": "7.0.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "7.0.1", + "contentHash": "pkeBFx0vqMW/A3aUVHh7MPu3WkBhaVlezhSZeb1c9XD0vUReYH1TLFSy5MxJgZfmz5LZzYoErMorlYZiwpOoNA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "hYr3I9N9811e0Bjf2WNwAGGyTuAFbbTgX1RPLt/3Wbm68x3IGcX5Cl75CMmgT6WlNwLQ2tCCWfqYPpypjaf2xA==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "MySqlConnector": { + "type": "Direct", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1", + "System.Diagnostics.DiagnosticSource": "7.0.2" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "7.0.1", + "contentHash": "pkeBFx0vqMW/A3aUVHh7MPu3WkBhaVlezhSZeb1c9XD0vUReYH1TLFSy5MxJgZfmz5LZzYoErMorlYZiwpOoNA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "distributedlock.core": { + "type": "Project" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.Oracle/AssemblyAttributes.cs b/src/DistributedLock.Oracle/AssemblyAttributes.cs new file mode 100644 index 00000000..a9e3a34f --- /dev/null +++ b/src/DistributedLock.Oracle/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] diff --git a/src/DistributedLock.Oracle/DistributedLock.Oracle.csproj b/src/DistributedLock.Oracle/DistributedLock.Oracle.csproj new file mode 100644 index 00000000..c203a623 --- /dev/null +++ b/src/DistributedLock.Oracle/DistributedLock.Oracle.csproj @@ -0,0 +1,63 @@ + + + + netstandard2.1;net472 + Medallion.Threading.Oracle + True + 4 + Latest + enable + enable + + + + 1.0.5 + 1.0.0.0 + Michael Adelson + Provides a distributed lock implementation based on Oracle Database + Copyright © 2021 Michael Adelson + MIT + distributed lock async mutex reader writer sql oracle + https://github.com/madelson/DistributedLock + https://github.com/madelson/DistributedLock + 1.0.0.0 + See https://github.com/madelson/DistributedLock#release-notes + true + ..\DistributedLock.snk + + + + True + True + True + + + embedded + + true + true + + + + False + 1591 + TRACE;DEBUG + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.Oracle/OracleConnectionOptionsBuilder.cs b/src/DistributedLock.Oracle/OracleConnectionOptionsBuilder.cs new file mode 100644 index 00000000..d5e3282d --- /dev/null +++ b/src/DistributedLock.Oracle/OracleConnectionOptionsBuilder.cs @@ -0,0 +1,69 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Oracle; + +/// +/// Specifies options for connecting to and locking against an Oracle database +/// +public sealed class OracleConnectionOptionsBuilder +{ + private TimeoutValue? _keepaliveCadence; + private bool? _useMultiplexing; + + internal OracleConnectionOptionsBuilder() { } + + /// + /// Oracle does not kill idle connections by default, so by default keepalive is disabled (set to ). + /// + /// However, if you are using the IDLE_TIME setting in Oracle or if your network is dropping connections that are idle holding locks for + /// a long time, you can set a value for keepalive to prevent this from happening. + /// + /// See https://stackoverflow.com/questions/1966247/idle-timeout-parameter-in-oracle. + /// + public OracleConnectionOptionsBuilder KeepaliveCadence(TimeSpan keepaliveCadence) + { + this._keepaliveCadence = new TimeoutValue(keepaliveCadence, nameof(keepaliveCadence)); + return this; + } + + /// + /// This mode takes advantage of the fact that while "holding" a lock (or other synchronization primitive) + /// a connection is essentially idle. Thus, rather than creating a new connection for each held lock it is + /// often possible to multiplex a shared connection so that that connection can hold multiple locks at the same time. + /// + /// Multiplexing is on by default. + /// + /// This is implemented in such a way that releasing a lock held on such a connection will never be blocked by an + /// Acquire() call that is waiting to acquire a lock on that same connection. For this reason, the multiplexing + /// strategy is "optimistic": if the lock can't be acquired instantaneously on the shared connection, a new (shareable) + /// connection will be allocated. + /// + /// This option can improve performance and avoid connection pool starvation in high-load scenarios. It is also + /// particularly applicable to cases where + /// semantics are used with a zero-length timeout. + /// + public OracleConnectionOptionsBuilder UseMultiplexing(bool useMultiplexing = true) + { + this._useMultiplexing = useMultiplexing; + return this; + } + + internal static (TimeoutValue keepaliveCadence, bool useMultiplexing) GetOptions(Action? optionsBuilder) + { + OracleConnectionOptionsBuilder? options; + if (optionsBuilder != null) + { + options = new OracleConnectionOptionsBuilder(); + optionsBuilder(options); + } + else + { + options = null; + } + + var keepaliveCadence = options?._keepaliveCadence ?? Timeout.InfiniteTimeSpan; + var useMultiplexing = options?._useMultiplexing ?? true; + + return (keepaliveCadence, useMultiplexing); + } +} diff --git a/src/DistributedLock.Oracle/OracleDatabaseConnection.cs b/src/DistributedLock.Oracle/OracleDatabaseConnection.cs new file mode 100644 index 00000000..b901e93f --- /dev/null +++ b/src/DistributedLock.Oracle/OracleDatabaseConnection.cs @@ -0,0 +1,84 @@ +using Medallion.Threading.Internal.Data; +using Oracle.ManagedDataAccess.Client; +using System.Data; + +namespace Medallion.Threading.Oracle; + +internal class OracleDatabaseConnection : DatabaseConnection +{ + public const string ApplicationNameIndicatorPrefix = "__DistributedLock.ApplicationName="; + + // see SleepAsync() for why we need this + private readonly IDbConnection _innerConnection; + + public OracleDatabaseConnection(IDbConnection connection) + : this(connection, isExternallyOwned: true) + { + } + + public OracleDatabaseConnection(IDbTransaction transaction) + : base(transaction, isExternallyOwned: true) + { + this._innerConnection = transaction.Connection; + } + + public OracleDatabaseConnection(string connectionString) + : this(CreateConnection(connectionString), isExternallyOwned: false) + { + } + + private OracleDatabaseConnection(IDbConnection connection, bool isExternallyOwned) + : base(connection, isExternallyOwned) + { + this._innerConnection = connection; + } + + // from https://docs.oracle.com/html/E10927_01/OracleCommandClass.htm "this method is a no-op" wrt "Prepare()" + public override bool ShouldPrepareCommands => false; + + public override bool IsCommandCancellationException(Exception exception) => + exception is OracleException oracleException + // based on https://docs.oracle.com/cd/E85694_01/ODPNT/CommandCancel.htm + && (oracleException.Number == 01013 || oracleException.Number == 00936 || oracleException.Number == 00604); + + public override async Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor) + { + using var sleepCommand = this.CreateCommand(); + sleepCommand.SetCommandText("BEGIN sys.DBMS_SESSION.SLEEP(:seconds); END;"); + sleepCommand.AddParameter("seconds", sleepTime.TotalSeconds); + + try + { + await executor(sleepCommand, cancellationToken).ConfigureAwait(false); + } + catch when (!cancellationToken.IsCancellationRequested) + { + // Oracle doesn't fire StateChange unless the State is observed or the connection is explicitly opened/closed. Therefore, we observe + // the state on seeing any exception in order to for the event to fire. See https://github.com/oracle/dotnet-db-samples/issues/226 + _ = this._innerConnection.State; + throw; + } + } + + public static OracleConnection CreateConnection(string connectionString) + { + if (connectionString == null) { throw new ArgumentNullException(connectionString, nameof(connectionString)); } + + // The .NET Oracle provider does not currently support ApplicationName natively as a connection string property. + // However, that functionality is relied on by many of our tests. As a workaround, we permit the application name + // to be included in the connection string using a custom encoding scheme. This is only intended to work in tests! + // See https://github.com/oracle/dotnet-db-samples/issues/216 for more context. + if (connectionString.StartsWith(ApplicationNameIndicatorPrefix, StringComparison.Ordinal)) + { + var firstSeparatorIndex = connectionString.IndexOf(';'); + var applicationName = connectionString.Substring(startIndex: ApplicationNameIndicatorPrefix.Length, length: firstSeparatorIndex - ApplicationNameIndicatorPrefix.Length); + // After upgrading the Oracle client to 23.6.1, the connection pool sometimes seems to grow beyond what is strictly required. + // This causes issues if we're tracking connections by name. Therefore, we disable pooling on named connections + var connection = new OracleConnection(connectionString.Substring(startIndex: firstSeparatorIndex + 1)); + connection.ConnectionOpen += _ => connection.ClientInfo = applicationName; + return connection; + } + + return new OracleConnection(connectionString); + } +} diff --git a/src/DistributedLock.Oracle/OracleDbmsLock.cs b/src/DistributedLock.Oracle/OracleDbmsLock.cs new file mode 100644 index 00000000..d536bf34 --- /dev/null +++ b/src/DistributedLock.Oracle/OracleDbmsLock.cs @@ -0,0 +1,134 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; + +namespace Medallion.Threading.Oracle; + +/// +/// Implements using Oracle's DBMS_LOCK package +/// +internal class OracleDbmsLock : IDbSynchronizationStrategy +{ + // https://docs.oracle.com/cd/B19306_01/appdev.102/b14258/d_lock.htm#i1002309 + private const int MaxWaitSeconds = 32767; + private const int MaxTimeoutSeconds = MaxWaitSeconds - 1; + + public static readonly OracleDbmsLock SharedLock = new(Mode.Shared), + UpdateLock = new(Mode.Update), + ExclusiveLock = new(Mode.Exclusive), + UpgradeLock = new(Mode.Exclusive, isUpgrade: true); + + private static readonly object Cookie = new(); + + private readonly Mode _mode; + private readonly bool _isUpgrade; + + private OracleDbmsLock(Mode mode, bool isUpgrade = false) + { + Invariant.Require(!isUpgrade || mode == Mode.Exclusive); + + this._mode = mode; + this._isUpgrade = isUpgrade; + } + + public bool IsUpgradeable => this._mode == Mode.Update; + + private string ModeSqlConstant + { + get + { + var modeCode = this._mode switch + { + Mode.Shared => "SS", + Mode.Update => "SSX", + Mode.Exclusive => "X", + _ => throw new InvalidOperationException(), + }; + return $"SYS.DBMS_LOCK.{modeCode}_MODE"; + } + } + + public async ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, object lockCookie) + { + // Since we we don't allow downgrading and therefore "releasing" an upgrade only happens on disposal of the + // original handle, this can safely be a noop. + if (this._isUpgrade) { return; } + + using var command = connection.CreateCommand(); + command.SetCommandText(@" + DECLARE + lockHandle VARCHAR2(128); + BEGIN + SYS.DBMS_LOCK.ALLOCATE_UNIQUE(:lockName, lockHandle); + :returnValue := SYS.DBMS_LOCK.RELEASE(lockHandle); + END;" + ); + // note: parameters bind by position by default! + command.AddParameter("lockName", resourceName); + var returnValueParameter = command.AddParameter("returnValue", type: DbType.Int32, direction: ParameterDirection.Output); + await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + + var returnValue = (int)returnValueParameter.Value; + if (returnValue != 0) + { + // we don't enumerate the error codes here because release shouldn't ever fail unless the user really messes things up + throw new InvalidOperationException($"SYS.DBMS_LOCK.RELEASE returned error code {returnValue}"); + } + } + + public async ValueTask TryAcquireAsync(DatabaseConnection connection, string resourceName, TimeoutValue timeout, CancellationToken cancellationToken) + { + var acquireFunction = this._isUpgrade ? "CONVERT" : "REQUEST"; + using var command = connection.CreateCommand(); + command.SetCommandText($@" + DECLARE + lockHandle VARCHAR2(128); + BEGIN + SYS.DBMS_LOCK.ALLOCATE_UNIQUE(:lockName, lockHandle); + :returnValue := SYS.DBMS_LOCK.{acquireFunction}(lockhandle => lockHandle, lockmode => {this.ModeSqlConstant}, timeout => :timeout); + END;" + ); + // note: parameters bind by position by default! + command.AddParameter("lockName", resourceName); + var returnValueParameter = command.AddParameter("returnValue", type: DbType.Int32, direction: ParameterDirection.Output); + command.AddParameter( + "timeout", + timeout.IsInfinite ? MaxWaitSeconds + // we could support longer timeouts via looping lock requests, but this doesn't feel particularly valuable and isn't a true longer wait + // since by looping you fall out of the wait queue + : timeout.TimeSpan.TotalSeconds > MaxTimeoutSeconds ? throw new ArgumentOutOfRangeException($"Requested non-infinite timeout value '{timeout}' is longer than Oracle's allowed max of '{TimeSpan.FromSeconds(MaxTimeoutSeconds)}'") + : timeout.TimeSpan.TotalSeconds + ); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + var returnValue = (int)returnValueParameter.Value; + return returnValue switch + { + 0 => Cookie, // success + 1 => null, // timeout + 2 => throw new DeadlockException(GetErrorMessage("deadlock")), + 3 => throw new InvalidOperationException(GetErrorMessage("parameter error")), + 4 => timeout.IsZero ? null + : timeout.IsInfinite ? throw new DeadlockException("Attempted to acquire a lock that is already held on the same connection") + : await WaitThenReturnNullAsync().ConfigureAwait(false), + 5 => throw new InvalidOperationException(GetErrorMessage("illegal lock handle")), + _ => throw new InvalidOperationException(GetErrorMessage("unknown error code")), + }; + + string GetErrorMessage(string description) => + $"SYS.DBMS_LOCK.{acquireFunction} returned error code {returnValue} ({description})"; + + async ValueTask WaitThenReturnNullAsync() + { + await SyncViaAsync.Delay(timeout, cancellationToken).ConfigureAwait(false); + return null; + } + } + + private enum Mode + { + Shared, + Update, + Exclusive, + } +} diff --git a/src/DistributedLock.Oracle/OracleDistributedLock.IDistributedLock.cs b/src/DistributedLock.Oracle/OracleDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..f616371b --- /dev/null +++ b/src/DistributedLock.Oracle/OracleDistributedLock.IDistributedLock.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Oracle; + +public partial class OracleDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public OracleDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public OracleDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.Oracle/OracleDistributedLock.cs b/src/DistributedLock.Oracle/OracleDistributedLock.cs new file mode 100644 index 00000000..c93231f3 --- /dev/null +++ b/src/DistributedLock.Oracle/OracleDistributedLock.cs @@ -0,0 +1,86 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; + +namespace Medallion.Threading.Oracle; + +/// +/// Implements a distributed lock for Oracle databse based on the DBMS_LOCK package +/// +public sealed partial class OracleDistributedLock : IInternalDistributedLock +{ + internal const int MaxNameLength = 128; + + private readonly IDbDistributedLock _internalLock; + + /// + /// Constructs a lock with the given that connects using the provided and + /// . + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public OracleDistributedLock(string name, string connectionString, Action? options = null, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, connectionString, options)) + { + } + + /// + /// Constructs a lock with the given that connects using the provided . + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public OracleDistributedLock(string name, IDbConnection connection, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, connection)) + { + } + + private OracleDistributedLock(string name, bool exactName, Func internalLockFactory) + { + this.Name = GetName(name, exactName); + this._internalLock = internalLockFactory(this.Name); + } + + internal static string GetName(string name, bool exactName) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + if (exactName) + { + if (name.Length > MaxNameLength) { throw new FormatException($"{nameof(name)}: must be at most {MaxNameLength} characters"); } + // Oracle treats NULL as the empty string. See https://stackoverflow.com/questions/13278773/null-vs-empty-string-in-oracle + if (name.Length == 0) { throw new FormatException($"{nameof(name)} must not be empty"); } + return name; + } + + return DistributedLockHelpers.ToSafeName(name, MaxNameLength, s => s.Length == 0 ? "EMPTY" : s); + } + + /// + /// Implements + /// + public string Name { get; } + + ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + this._internalLock.TryAcquireAsync(timeout, OracleDbmsLock.ExclusiveLock, cancellationToken, contextHandle: null).Wrap(h => new OracleDistributedLockHandle(h)); + + internal static IDbDistributedLock CreateInternalLock(string name, string connectionString, Action? options) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + var (keepaliveCadence, useMultiplexing) = OracleConnectionOptionsBuilder.GetOptions(options); + + if (useMultiplexing) + { + return new OptimisticConnectionMultiplexingDbDistributedLock(name, connectionString, OracleMultiplexedConnectionLockPool.Instance, keepaliveCadence); + } + + return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new OracleDatabaseConnection(connectionString), useTransaction: false, keepaliveCadence); + } + + internal static IDbDistributedLock CreateInternalLock(string name, IDbConnection connection) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + + return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new OracleDatabaseConnection(connection)); + } +} diff --git a/src/DistributedLock.Oracle/OracleDistributedLockHandle.cs b/src/DistributedLock.Oracle/OracleDistributedLockHandle.cs new file mode 100644 index 00000000..29d6ae4f --- /dev/null +++ b/src/DistributedLock.Oracle/OracleDistributedLockHandle.cs @@ -0,0 +1,31 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Oracle; + +/// +/// Implements +/// +public sealed class OracleDistributedLockHandle : IDistributedSynchronizationHandle +{ + private IDistributedSynchronizationHandle? _innerHandle; + + internal OracleDistributedLockHandle(IDistributedSynchronizationHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the lock asynchronously + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} diff --git a/src/DistributedLock.Oracle/OracleDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs b/src/DistributedLock.Oracle/OracleDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs new file mode 100644 index 00000000..ae396666 --- /dev/null +++ b/src/DistributedLock.Oracle/OracleDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs @@ -0,0 +1,226 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Oracle; + +public partial class OracleDistributedReaderWriterLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireReadLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireReadLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireReadLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + IDistributedLockUpgradeableHandle? IDistributedUpgradeableReaderWriterLock.TryAcquireUpgradeableReadLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireUpgradeableReadLock(timeout, cancellationToken); + IDistributedLockUpgradeableHandle IDistributedUpgradeableReaderWriterLock.AcquireUpgradeableReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireUpgradeableReadLock(timeout, cancellationToken); + ValueTask IDistributedUpgradeableReaderWriterLock.TryAcquireUpgradeableReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedUpgradeableReaderWriterLock.AcquireUpgradeableReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireWriteLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireWriteLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireWriteLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireWriteLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireWriteLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireWriteLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire a READ lock synchronously. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// using (var handle = myLock.TryAcquireReadLock(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public OracleDistributedReaderWriterLockHandle? TryAcquireReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken, isWrite: false); + + /// + /// Acquires a READ lock synchronously, failing with if the attempt times out. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// using (myLock.AcquireReadLock(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public OracleDistributedReaderWriterLockHandle AcquireReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken, isWrite: false); + + /// + /// Attempts to acquire a READ lock asynchronously. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// await using (var handle = await myLock.TryAcquireReadLockAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public ValueTask TryAcquireReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken, isWrite: false); + + /// + /// Acquires a READ lock asynchronously, failing with if the attempt times out. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// await using (await myLock.AcquireReadLockAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public ValueTask AcquireReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken, isWrite: false); + + /// + /// Attempts to acquire an UPGRADE lock synchronously. Not compatible with another UPGRADE lock or a WRITE lock. Usage: + /// + /// using (var handle = myLock.TryAcquireUpgradeableReadLock(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public OracleDistributedReaderWriterLockUpgradeableHandle? TryAcquireUpgradeableReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquireUpgradeableReadLock(this, timeout, cancellationToken); + + /// + /// Acquires an UPGRADE lock synchronously, failing with if the attempt times out. Not compatible with another UPGRADE lock or a WRITE lock. Usage: + /// + /// using (myLock.AcquireUpgradeableReadLock(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public OracleDistributedReaderWriterLockUpgradeableHandle AcquireUpgradeableReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireUpgradeableReadLock(this, timeout, cancellationToken); + + /// + /// Attempts to acquire an UPGRADE lock asynchronously. Not compatible with another UPGRADE lock or a WRITE lock. Usage: + /// + /// await using (var handle = await myLock.TryAcquireUpgradeableReadLockAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public ValueTask TryAcquireUpgradeableReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireUpgradeableReadLockAsync(timeout, cancellationToken); + + /// + /// Acquires an UPGRADE lock asynchronously, failing with if the attempt times out. Not compatible with another UPGRADE lock or a WRITE lock. Usage: + /// + /// await using (await myLock.AcquireUpgradeableReadLockAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public ValueTask AcquireUpgradeableReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireUpgradeableReadLockAsync(this, timeout, cancellationToken); + + /// + /// Attempts to acquire a WRITE lock synchronously. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// using (var handle = myLock.TryAcquireWriteLock(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public OracleDistributedReaderWriterLockHandle? TryAcquireWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken, isWrite: true); + + /// + /// Acquires a WRITE lock synchronously, failing with if the attempt times out. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// using (myLock.AcquireWriteLock(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public OracleDistributedReaderWriterLockHandle AcquireWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken, isWrite: true); + + /// + /// Attempts to acquire a WRITE lock asynchronously. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// await using (var handle = await myLock.TryAcquireWriteLockAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public ValueTask TryAcquireWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken, isWrite: true); + + /// + /// Acquires a WRITE lock asynchronously, failing with if the attempt times out. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// await using (await myLock.AcquireWriteLockAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken, isWrite: true); + +} \ No newline at end of file diff --git a/src/DistributedLock.Oracle/OracleDistributedReaderWriterLock.cs b/src/DistributedLock.Oracle/OracleDistributedReaderWriterLock.cs new file mode 100644 index 00000000..614ea424 --- /dev/null +++ b/src/DistributedLock.Oracle/OracleDistributedReaderWriterLock.cs @@ -0,0 +1,68 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; + +namespace Medallion.Threading.Oracle; + +/// +/// Implements an upgradeable distributed reader-writer lock for the Oracle database using the DBMS_LOCK package. +/// +public sealed partial class OracleDistributedReaderWriterLock : IInternalDistributedUpgradeableReaderWriterLock +{ + private readonly IDbDistributedLock _internalLock; + + /// + /// Constructs a new lock using the provided . + /// + /// The provided will be used to connect to the database. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public OracleDistributedReaderWriterLock(string name, string connectionString, Action? options = null, bool exactName = false) + : this(name, exactName, n => OracleDistributedLock.CreateInternalLock(n, connectionString, options)) + { + } + + /// + /// Constructs a new lock using the provided . + /// + /// The provided will be used to connect to the database and will provide lock scope. It is assumed to be externally managed and + /// will not be opened or closed. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public OracleDistributedReaderWriterLock(string name, IDbConnection connection, bool exactName = false) + : this(name, exactName, n => OracleDistributedLock.CreateInternalLock(n, connection)) + { + } + + private OracleDistributedReaderWriterLock(string name, bool exactName, Func internalLockFactory) + { + this.Name = OracleDistributedLock.GetName(name, exactName); + this._internalLock = internalLockFactory(this.Name); + } + + /// + /// Implements + /// + public string Name { get; } + + async ValueTask IInternalDistributedUpgradeableReaderWriterLock.InternalTryAcquireUpgradeableReadLockAsync( + TimeoutValue timeout, + CancellationToken cancellationToken) + { + var innerHandle = await this._internalLock + .TryAcquireAsync(timeout, OracleDbmsLock.UpdateLock, cancellationToken, contextHandle: null).ConfigureAwait(false); + return innerHandle != null ? new OracleDistributedReaderWriterLockUpgradeableHandle(innerHandle, this._internalLock) : null; + } + + async ValueTask IInternalDistributedReaderWriterLock.InternalTryAcquireAsync( + TimeoutValue timeout, + CancellationToken cancellationToken, + bool isWrite) + { + var innerHandle = await this._internalLock + .TryAcquireAsync(timeout, isWrite ? OracleDbmsLock.ExclusiveLock : OracleDbmsLock.SharedLock, cancellationToken, contextHandle: null).ConfigureAwait(false); + return innerHandle != null ? new OracleDistributedReaderWriterLockNonUpgradeableHandle(innerHandle) : null; + } +} diff --git a/src/DistributedLock.Oracle/OracleDistributedReaderWriterLockHandle.cs b/src/DistributedLock.Oracle/OracleDistributedReaderWriterLockHandle.cs new file mode 100644 index 00000000..e7ca0e44 --- /dev/null +++ b/src/DistributedLock.Oracle/OracleDistributedReaderWriterLockHandle.cs @@ -0,0 +1,123 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; + +namespace Medallion.Threading.Oracle; + +/// +/// Implements +/// +public abstract class OracleDistributedReaderWriterLockHandle : IDistributedSynchronizationHandle +{ + // forbid external inheritors + internal OracleDistributedReaderWriterLockHandle() { } + + /// + /// Implements + /// + public abstract CancellationToken HandleLostToken { get; } + + /// + /// Releases the lock + /// + public void Dispose() => this.DisposeSyncViaAsync(); + + /// + /// Releases the lock asynchronously + /// + public abstract ValueTask DisposeAsync(); +} + +internal sealed class OracleDistributedReaderWriterLockNonUpgradeableHandle : OracleDistributedReaderWriterLockHandle +{ + private IDistributedSynchronizationHandle? _innerHandle; + + internal OracleDistributedReaderWriterLockNonUpgradeableHandle(IDistributedSynchronizationHandle? handle) + { + this._innerHandle = handle; + } + + public override CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); + + public override ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} + +/// +/// Implements +/// +public sealed class OracleDistributedReaderWriterLockUpgradeableHandle : OracleDistributedReaderWriterLockHandle, IInternalDistributedLockUpgradeableHandle +{ + private RefBox<(IDistributedSynchronizationHandle innerHandle, IDbDistributedLock @lock, IDistributedSynchronizationHandle? upgradedHandle)>? _box; + + internal OracleDistributedReaderWriterLockUpgradeableHandle(IDistributedSynchronizationHandle innerHandle, IDbDistributedLock @lock) + { + this._box = RefBox.Create((innerHandle, @lock, default(IDistributedSynchronizationHandle?))); + } + + /// + /// Implements + /// + public override CancellationToken HandleLostToken => (this._box ?? throw this.ObjectDisposed()).Value.innerHandle.HandleLostToken; + + /// + /// Releases the lock asynchronously + /// + public override async ValueTask DisposeAsync() + { + if (RefBox.TryConsume(ref this._box, out var contents)) + { + try { await (contents.upgradedHandle?.DisposeAsync() ?? default).ConfigureAwait(false); } + finally { await contents.innerHandle.DisposeAsync().ConfigureAwait(false); } + } + } + + /// + /// Implements + /// + public bool TryUpgradeToWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryUpgradeToWriteLock(this, timeout, cancellationToken); + + /// + /// Implements + /// + public ValueTask TryUpgradeToWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As().InternalTryUpgradeToWriteLockAsync(timeout, cancellationToken); + + /// + /// Implements + /// + public void UpgradeToWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.UpgradeToWriteLock(this, timeout, cancellationToken); + + /// + /// Implements + /// + public ValueTask UpgradeToWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.UpgradeToWriteLockAsync(this, timeout, cancellationToken); + + ValueTask IInternalDistributedLockUpgradeableHandle.InternalTryUpgradeToWriteLockAsync(TimeoutValue timeout, CancellationToken cancellationToken) + { + var box = this._box ?? throw this.ObjectDisposed(); + var contents = box.Value; + if (contents.upgradedHandle != null) { throw new InvalidOperationException("the lock has already been upgraded"); } + return TryPerformUpgradeAsync(); + + async ValueTask TryPerformUpgradeAsync() + { + var upgradedHandle = + await contents.@lock.TryAcquireAsync(timeout, OracleDbmsLock.UpgradeLock, cancellationToken, contextHandle: contents.innerHandle).ConfigureAwait(false); + if (upgradedHandle == null) + { + return false; + } + + contents.upgradedHandle = upgradedHandle; + var newBox = RefBox.Create(contents); + if (Interlocked.CompareExchange(ref this._box, newBox, comparand: box) != box) + { + await upgradedHandle.DisposeAsync().ConfigureAwait(false); + } + + return true; + } + } +} diff --git a/src/DistributedLock.Oracle/OracleDistributedSynchronizationProvider.cs b/src/DistributedLock.Oracle/OracleDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..a59d3066 --- /dev/null +++ b/src/DistributedLock.Oracle/OracleDistributedSynchronizationProvider.cs @@ -0,0 +1,55 @@ +using System.Data; + +namespace Medallion.Threading.Oracle; + +/// +/// Implements for +/// and for +/// +public sealed class OracleDistributedSynchronizationProvider : IDistributedLockProvider, IDistributedUpgradeableReaderWriterLockProvider +{ + private readonly Func _lockFactory; + private readonly Func _readerWriterLockFactory; + + /// + /// Constructs a provider that connects with and . + /// + public OracleDistributedSynchronizationProvider(string connectionString, Action? options = null) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + this._lockFactory = (name, exactName) => new OracleDistributedLock(name, connectionString, options, exactName); + this._readerWriterLockFactory = (name, exactName) => new OracleDistributedReaderWriterLock(name, connectionString, options, exactName); + } + + /// + /// Constructs a provider that connects with . + /// + public OracleDistributedSynchronizationProvider(IDbConnection connection) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + + this._lockFactory = (name, exactName) => new OracleDistributedLock(name, connection, exactName); + this._readerWriterLockFactory = (name, exactName) => new OracleDistributedReaderWriterLock(name, connection, exactName); + } + + /// + /// Creates a with the provided . Unless + /// is specified, invalid names will be escaped/hashed. + /// + public OracleDistributedLock CreateLock(string name, bool exactName = false) => this._lockFactory(name, exactName); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); + + /// + /// Creates a with the provided . Unless + /// is specified, invalid names will be escaped/hashed. + /// + public OracleDistributedReaderWriterLock CreateReaderWriterLock(string name, bool exactName = false) => this._readerWriterLockFactory(name, exactName); + + IDistributedUpgradeableReaderWriterLock IDistributedUpgradeableReaderWriterLockProvider.CreateUpgradeableReaderWriterLock(string name) => + this.CreateReaderWriterLock(name); + + IDistributedReaderWriterLock IDistributedReaderWriterLockProvider.CreateReaderWriterLock(string name) => + this.CreateReaderWriterLock(name); +} diff --git a/src/DistributedLock.Oracle/OracleMultiplexedConnectionLockPool.cs b/src/DistributedLock.Oracle/OracleMultiplexedConnectionLockPool.cs new file mode 100644 index 00000000..31435863 --- /dev/null +++ b/src/DistributedLock.Oracle/OracleMultiplexedConnectionLockPool.cs @@ -0,0 +1,8 @@ +using Medallion.Threading.Internal.Data; + +namespace Medallion.Threading.Oracle; + +internal static class OracleMultiplexedConnectionLockPool +{ + public static readonly MultiplexedConnectionLockPool Instance = new(s => new OracleDatabaseConnection(s)); +} diff --git a/src/DistributedLock.Oracle/PublicAPI.Shipped.txt b/src/DistributedLock.Oracle/PublicAPI.Shipped.txt new file mode 100644 index 00000000..0e2a3af1 --- /dev/null +++ b/src/DistributedLock.Oracle/PublicAPI.Shipped.txt @@ -0,0 +1,48 @@ +#nullable enable +abstract Medallion.Threading.Oracle.OracleDistributedReaderWriterLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +abstract Medallion.Threading.Oracle.OracleDistributedReaderWriterLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.Oracle.OracleConnectionOptionsBuilder +Medallion.Threading.Oracle.OracleConnectionOptionsBuilder.KeepaliveCadence(System.TimeSpan keepaliveCadence) -> Medallion.Threading.Oracle.OracleConnectionOptionsBuilder! +Medallion.Threading.Oracle.OracleConnectionOptionsBuilder.UseMultiplexing(bool useMultiplexing = true) -> Medallion.Threading.Oracle.OracleConnectionOptionsBuilder! +Medallion.Threading.Oracle.OracleDistributedLock +Medallion.Threading.Oracle.OracleDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Oracle.OracleDistributedLockHandle! +Medallion.Threading.Oracle.OracleDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedLock.Name.get -> string! +Medallion.Threading.Oracle.OracleDistributedLock.OracleDistributedLock(string! name, string! connectionString, System.Action? options = null, bool exactName = false) -> void +Medallion.Threading.Oracle.OracleDistributedLock.OracleDistributedLock(string! name, System.Data.IDbConnection! connection, bool exactName = false) -> void +Medallion.Threading.Oracle.OracleDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Oracle.OracleDistributedLockHandle? +Medallion.Threading.Oracle.OracleDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedLockHandle +Medallion.Threading.Oracle.OracleDistributedLockHandle.Dispose() -> void +Medallion.Threading.Oracle.OracleDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.AcquireReadLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Oracle.OracleDistributedReaderWriterLockHandle! +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.AcquireReadLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.AcquireUpgradeableReadLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Oracle.OracleDistributedReaderWriterLockUpgradeableHandle! +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.AcquireUpgradeableReadLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.AcquireWriteLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Oracle.OracleDistributedReaderWriterLockHandle! +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.AcquireWriteLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.Name.get -> string! +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.OracleDistributedReaderWriterLock(string! name, string! connectionString, System.Action? options = null, bool exactName = false) -> void +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.OracleDistributedReaderWriterLock(string! name, System.Data.IDbConnection! connection, bool exactName = false) -> void +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.TryAcquireReadLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Oracle.OracleDistributedReaderWriterLockHandle? +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.TryAcquireReadLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.TryAcquireUpgradeableReadLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Oracle.OracleDistributedReaderWriterLockUpgradeableHandle? +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.TryAcquireUpgradeableReadLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.TryAcquireWriteLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Oracle.OracleDistributedReaderWriterLockHandle? +Medallion.Threading.Oracle.OracleDistributedReaderWriterLock.TryAcquireWriteLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedReaderWriterLockHandle +Medallion.Threading.Oracle.OracleDistributedReaderWriterLockHandle.Dispose() -> void +Medallion.Threading.Oracle.OracleDistributedReaderWriterLockUpgradeableHandle +Medallion.Threading.Oracle.OracleDistributedReaderWriterLockUpgradeableHandle.TryUpgradeToWriteLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +Medallion.Threading.Oracle.OracleDistributedReaderWriterLockUpgradeableHandle.TryUpgradeToWriteLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedReaderWriterLockUpgradeableHandle.UpgradeToWriteLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +Medallion.Threading.Oracle.OracleDistributedReaderWriterLockUpgradeableHandle.UpgradeToWriteLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Oracle.OracleDistributedSynchronizationProvider +Medallion.Threading.Oracle.OracleDistributedSynchronizationProvider.CreateLock(string! name, bool exactName = false) -> Medallion.Threading.Oracle.OracleDistributedLock! +Medallion.Threading.Oracle.OracleDistributedSynchronizationProvider.CreateReaderWriterLock(string! name, bool exactName = false) -> Medallion.Threading.Oracle.OracleDistributedReaderWriterLock! +Medallion.Threading.Oracle.OracleDistributedSynchronizationProvider.OracleDistributedSynchronizationProvider(string! connectionString, System.Action? options = null) -> void +Medallion.Threading.Oracle.OracleDistributedSynchronizationProvider.OracleDistributedSynchronizationProvider(System.Data.IDbConnection! connection) -> void +override Medallion.Threading.Oracle.OracleDistributedReaderWriterLockUpgradeableHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +override Medallion.Threading.Oracle.OracleDistributedReaderWriterLockUpgradeableHandle.HandleLostToken.get -> System.Threading.CancellationToken \ No newline at end of file diff --git a/src/DistributedLock.Oracle/PublicAPI.Unshipped.txt b/src/DistributedLock.Oracle/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/DistributedLock.Oracle/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/DistributedLock.Oracle/packages.lock.json b/src/DistributedLock.Oracle/packages.lock.json new file mode 100644 index 00000000..d1be40b6 --- /dev/null +++ b/src/DistributedLock.Oracle/packages.lock.json @@ -0,0 +1,321 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.7.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Oracle.ManagedDataAccess": { + "type": "Direct", + "requested": "[23.6.1, )", + "resolved": "23.6.1", + "contentHash": "EZi+mahzUwQFWs9Is8ed94eTzWOlfCLMd+DDWukf/h/brTz1wB9Qk3fsxBrjw9+fEXrxDgx4uXNiPHNPRS3BeQ==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.Text.Json": "8.0.5", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.ValueTuple": "4.5.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4", + "System.ValueTuple": "4.5.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Oracle.ManagedDataAccess.Core": { + "type": "Direct", + "requested": "[23.6.1, )", + "resolved": "23.6.1", + "contentHash": "Oc8AX7xme05xrp4/aCxKBH4+bpWgMCFafXI7LbLO/7OBMJLZRXhMtejDgIb8aYvIVyV7vSdAy3LkCYcJorxn1A==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Diagnostics.PerformanceCounter": "6.0.1", + "System.DirectoryServices.Protocols": "6.0.2", + "System.Formats.Asn1": "6.0.1", + "System.Security.Cryptography.Pkcs": "6.0.4", + "System.Text.Json": "6.0.10" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "dDl7Gx3bmSrM2k2ZIm+ucEJnLloZRyvfQF1DvfvATcGF3jtaUBiPvChma+6ZcZzxWMirN3kCywkW7PILphXyMQ==" + }, + "System.DirectoryServices.Protocols": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "vDDPWwHn3/DNZ+kPkdXHoada+tKPEC9bVqDOr4hK6HBSP7hGCUTA0Zw6WU5qpGaqa5M1/V+axHMIv+DNEbIf6g==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "glgtKqWJpH9GDw0m9I5xFiF6WDIQqi/eZXU6MkMRPzAWEERGGAJh+qztkrlWSDbokQ1jalj5NcBNIvVoSDpSSA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "jIMXsKn94T9JY7PvPq/tMfqa6GAaHpElRDpmG+SuL+D3+sTw2M8VhnibKnN8Tq+4JqbPJ/f+BwtLeDMEnzAvRg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "LGbXi1oUJ9QgCNGXRO9ndzBL/GZgANcsURpMhNR8uO+rca47SZmciS3RSQUvlQRwK3QHZSHNOXzoMUASKA+Anw==", + "dependencies": { + "System.Formats.Asn1": "6.0.0", + "System.Security.Cryptography.Cng": "5.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.10", + "contentHash": "NSB0kDipxn2ychp88NXWfFRFlmi1bst/xynOutbnpEfRCT9JZkZ7KOmF/I/hNKo2dILiMGnqblm+j1sggdLB9g==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.Postgres/AssemblyAttributes.cs b/src/DistributedLock.Postgres/AssemblyAttributes.cs new file mode 100644 index 00000000..a9e3a34f --- /dev/null +++ b/src/DistributedLock.Postgres/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] diff --git a/src/DistributedLock.Postgres/DistributedLock.Postgres.csproj b/src/DistributedLock.Postgres/DistributedLock.Postgres.csproj new file mode 100644 index 00000000..1100aeec --- /dev/null +++ b/src/DistributedLock.Postgres/DistributedLock.Postgres.csproj @@ -0,0 +1,67 @@ + + + + netstandard2.0;netstandard2.1;net462;net8.0 + Medallion.Threading.Postgres + True + 4 + Latest + enable + enable + + + + 1.3.1 + 1.0.0.0 + Michael Adelson + Provides a distributed lock implementation based on Postgresql + Copyright © 2020 Michael Adelson + MIT + distributed lock async mutex sql postgres + https://github.com/madelson/DistributedLock + https://github.com/madelson/DistributedLock + 1.0.0.0 + See https://github.com/madelson/DistributedLock#release-notes + true + ..\DistributedLock.snk + + + + True + True + True + + + embedded + + true + true + + + + False + 1591 + TRACE;DEBUG + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PostgresAdvisoryLock.cs b/src/DistributedLock.Postgres/PostgresAdvisoryLock.cs new file mode 100644 index 00000000..c5093bf6 --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresAdvisoryLock.cs @@ -0,0 +1,339 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using Npgsql; +using System.Data; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +namespace Medallion.Threading.Postgres; + +/// +/// Implements using advisory locking functions +/// (see https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS) +/// +internal class PostgresAdvisoryLock : IDbSynchronizationStrategy +{ + private static readonly object Cookie = new(); + + public static readonly PostgresAdvisoryLock ExclusiveLock = new(isShared: false), + SharedLock = new(isShared: true); + + private readonly bool _isShared; + + private PostgresAdvisoryLock(bool isShared) + { + this._isShared = isShared; + } + + /// + /// Advisory locks don't natively support upgradeable + /// + public bool IsUpgradeable => false; + + public async ValueTask TryAcquireAsync(DatabaseConnection connection, string resourceName, TimeoutValue timeout, CancellationToken cancellationToken) + { + const string SavePointName = "medallion_threading_postgres_advisory_lock_acquire"; + + PostgresAdvisoryLockKey key = new(resourceName); + + if (connection.IsExernallyOwned + && await this.IsHoldingLockAsync(connection, key, cancellationToken).ConfigureAwait(false)) + { + if (timeout.IsZero) { return null; } + if (timeout.IsInfinite) { throw new DeadlockException("Attempted to acquire a lock that is already held on the same connection"); } + await SyncViaAsync.Delay(timeout, cancellationToken).ConfigureAwait(false); + return null; + } + + // Only in the case where we will try to acquire a transaction-scoped lock, we will define a save point, but we won't be able to roll it back + // in case of a successful lock acquisition becuase the lock will be released. Therefore, in such cases, we capture the timeout settings values before + // we set a save point, and then we try to restore the values after the attempt to acquire the lock. + // NOTE: the save point functionality can't be removed in favor of capturing and restoring the values for all cases. + // When an error occurs while attempting to acquire the lock, the transaction is aborted and we can't run any other query, unless either + // the transaction or the save point are rolled back. + var capturedTimeoutSettings = await CaptureTimeoutSettingsIfNeededAsync(connection, cancellationToken).ConfigureAwait(false); + + // Our acquire command will use SET LOCAL to set up statement timeouts. This lasts until the end + // of the current transaction instead of just the current batch if we're in a transaction. To make sure + // we don't leak those settings, in the case of a transaction, we first set up a save point which we can + // later roll back (only in cases where we don't acquire a transaction scoped lock - taking the settings changes with it but NOT the lock). + // Because we can't confidently roll back a save point without knowing that it has been set up, we start the save point in its own + // query before we try-catch. + var needsSavePoint = await ShouldDefineSavePoint(connection).ConfigureAwait(false); + + if (needsSavePoint) + { + using var setSavePointCommand = connection.CreateCommand(); + setSavePointCommand.SetCommandText("SAVEPOINT " + SavePointName); + await setSavePointCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + using var acquireCommand = this.CreateAcquireCommand(connection, key, timeout); + + object? acquireCommandResult; + try + { + acquireCommandResult = await acquireCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await RollBackTransactionTimeoutVariablesIfNeededAsync(acquired: false).ConfigureAwait(false); + + await RestoreTimeoutSettingsIfNeededAsync(capturedTimeoutSettings, connection).ConfigureAwait(false); + + if (ex is PostgresException postgresException) + { + switch (postgresException.SqlState) + { + // lock_timeout error code from https://www.postgresql.org/docs/16/errcodes-appendix.html + case "55P03": + // Even though we hit a lock timeout, an underlying race condition in Postgres means that we might actually + // have acquired the lock right before timing out. To account for this, we simply re-check whether we are + // holding the lock to determine the final return value. See https://github.com/madelson/DistributedLock/issues/147 + // and https://www.postgresql.org/message-id/63573.1668271677%40sss.pgh.pa.us for more details. + // NOTE: we use CancellationToken.None for this check because if we ARE holding the lock it would be invalid to abort. + return await this.IsHoldingLockAsync(connection, key, CancellationToken.None).ConfigureAwait(false) + ? Cookie + : null; + // deadlock_detected error code from https://www.postgresql.org/docs/16/errcodes-appendix.html + case "40P01": + throw new DeadlockException($"The request for the distributed lock failed with exit code '{postgresException.SqlState}' (deadlock_detected)", ex); + } + } + + if (ex is OperationCanceledException + && cancellationToken.IsCancellationRequested + // There's no way to explicitly release transaction-scoped locks other than a rollback; in our case + // RollBackTransactionTimeoutVariablesIfNeededAsync will have already released by rolling back the savepoint. + // Furthermore the caller will proceed to dispose the transaction. + && !UseTransactionScopedLock(connection)) + { + // if we bailed in the middle of an acquire, make sure we didn't leave a lock behind + await this.ReleaseAsync(connection, key, isTry: true).ConfigureAwait(false); + } + + throw; + } + + var acquired = acquireCommandResult switch + { + DBNull _ => true, // indicates we called pg_advisory_lock and not pg_try_advisory_lock + null => true, // Npgsql 8 returns null instead of DBNull + false => false, + true => true, + _ => default(bool?) + }; + + await RollBackTransactionTimeoutVariablesIfNeededAsync(acquired: acquired == true).ConfigureAwait(false); + + await RestoreTimeoutSettingsIfNeededAsync(capturedTimeoutSettings, connection).ConfigureAwait(false); + + return acquired switch + { + false => null, + true => Cookie, + null => throw new InvalidOperationException($"Unexpected value '{acquireCommandResult}' from acquire command") + }; + + async ValueTask RollBackTransactionTimeoutVariablesIfNeededAsync(bool acquired) + { + if (needsSavePoint + // For transaction scoped locks, we can't roll back the save point on success because that will roll back our hold on the lock. + // It's ok to "leak" the savepoint because if it's an internally-owned transaction then the savepoint will be cleaned up with the disposal of the transaction. + // If it's an externally-owned transaction then we must "leak" it or we will lose the lock. Also, we can't avoid using a save point in this case + // because otherwise if an exception had occurred the extrenally-owned transaction will be aborted and become completely unusable. + && !(acquired && UseTransactionScopedLock(connection))) + { + // attempt to clear the timeout variables we set + using var rollBackSavePointCommand = connection.CreateCommand(); + rollBackSavePointCommand.SetCommandText("ROLLBACK TO SAVEPOINT " + SavePointName); + await rollBackSavePointCommand.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + } + } + } + + private async Task IsHoldingLockAsync(DatabaseConnection connection, PostgresAdvisoryLockKey key, CancellationToken cancellationToken) + { + using var command = connection.CreateCommand(); + command.SetCommandText($@" + SELECT COUNT(*) + FROM pg_catalog.pg_locks l + JOIN pg_catalog.pg_database d + ON d.oid = l.database + WHERE l.locktype = 'advisory' + AND {AddPGLocksFilterParametersAndGetFilterExpression(command, key)} + AND l.pid = pg_catalog.pg_backend_pid() + AND d.datname = pg_catalog.current_database()" + ); + return (long)(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))! != 0; + } + + private DatabaseCommand CreateAcquireCommand(DatabaseConnection connection, PostgresAdvisoryLockKey key, TimeoutValue timeout) + { + var command = connection.CreateCommand(); + + var commandText = new StringBuilder(); + + // We set the statement_timeout to 0 (inf) because we want everything to be driven by the lock_timeout. + commandText.AppendLine("SET LOCAL statement_timeout = 0;"); + // We set the lock timeout to our timeout, with the exception that if our timeout is zero we set it to inf because + // we'll be using the pg_try_advisory_lock function which doesn't block in that case. + commandText.AppendLine($"SET LOCAL lock_timeout = {(timeout.IsZero || timeout.IsInfinite ? 0 : timeout.InMilliseconds)};"); + + commandText.Append("SELECT "); + var isTry = timeout.IsZero; + commandText.Append("pg_catalog.pg"); + if (isTry) { commandText.Append("_try"); } + commandText.Append("_advisory"); + if (UseTransactionScopedLock(connection)) { commandText.Append("_xact"); } + commandText.Append("_lock"); + if (this._isShared) { commandText.Append("_shared"); } + commandText.Append('(').Append(AddKeyParametersAndGetKeyArguments(command, key)).Append(')') + .Append(" AS result"); + + command.SetCommandText(commandText.ToString()); + command.SetTimeout(timeout); + + return command; + } + + private static async ValueTask CaptureTimeoutSettingsIfNeededAsync(DatabaseConnection connection, CancellationToken cancellationToken) + { + var shouldCaptureTimeoutSettings = connection.IsExernallyOwned && UseTransactionScopedLock(connection); + + // Return null in case we won't try to acquire an externally-owned transaction-scoped lock. + if (!shouldCaptureTimeoutSettings) { return null; } + + using var getCurrentSettingCommand = connection.CreateCommand(); + getCurrentSettingCommand.SetCommandText("SELECT current_setting('statement_timeout') || '|' || current_setting('lock_timeout') AS timeouts"); + var timeouts = ((string)(await getCurrentSettingCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!) + .Split('|'); + return timeouts.Length == 2 + ? new CapturedTimeoutSettings(statementTimeout: timeouts[0], lockTimeout: timeouts[1]) + : throw new InvalidOperationException($"Unexpected statement_timeout|lock_timeout value '{string.Join("|", timeouts)}'"); + } + + private static async ValueTask ShouldDefineSavePoint(DatabaseConnection connection) + { + // If the connection is internally-owned, we only define a save point if a transaction has been opened. + if (!connection.IsExernallyOwned) { return connection.HasTransaction; } + + // If the connection is externally-owned with an established transaction, + // it means that the connection came through the transactional locking APIs (see PostgresDistributedLock.Transactions.cs). + if (connection.HasTransaction) { return true; } + + // The externally-owned connection might still be part of a transaction that we can't see. + // This can only be the case if the externally-owned connection didn't came through the transactional locking APIs (see PostgresDistributedLock.Transactions.cs). + // In that case, the only real way to detect the transaction is to begin a new one. + try + { + await connection.BeginTransactionAsync().ConfigureAwait(false); + } + catch (InvalidOperationException) + { + // Externally-owned connection with a transaction => we need to define a save point. + return true; + } + + await connection.DisposeTransactionAsync().ConfigureAwait(false); + + // Externally-owned connection with no transaction => no save point + return false; + } + + private static async ValueTask RestoreTimeoutSettingsIfNeededAsync(CapturedTimeoutSettings? settings, DatabaseConnection connection) + { + // Settings is expected to be null in case we didn't try to acquire an externally-owned transaction-scoped lock. + if (settings is null) { return; } + + using var restoreTimeoutSettingsCommand = connection.CreateCommand(); + + StringBuilder commandText = new(); + commandText.AppendLine($"SET LOCAL statement_timeout = '{settings.Value.StatementTimeout}';"); + commandText.AppendLine($"SET LOCAL lock_timeout = '{settings.Value.LockTimeout}';"); + + restoreTimeoutSettingsCommand.SetCommandText(commandText.ToString()); + + await restoreTimeoutSettingsCommand.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + } + + public ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, object lockCookie) => + this.ReleaseAsync(connection, new PostgresAdvisoryLockKey(resourceName), isTry: false); + + private async ValueTask ReleaseAsync(DatabaseConnection connection, PostgresAdvisoryLockKey key, bool isTry) + { + Invariant.Require(!UseTransactionScopedLock(connection)); + + using var command = connection.CreateCommand(); + command.SetCommandText($"SELECT pg_catalog.pg_advisory_unlock{(this._isShared ? "_shared" : string.Empty)}({AddKeyParametersAndGetKeyArguments(command, key)})"); + var result = (bool)(await command.ExecuteScalarAsync(CancellationToken.None).ConfigureAwait(false))!; + if (!isTry && !result) + { + throw new InvalidOperationException("Attempted to release a lock that was not held"); + } + } + + private static string AddKeyParametersAndGetKeyArguments(DatabaseCommand command, PostgresAdvisoryLockKey key) + { + if (key.HasSingleKey) + { + command.AddParameter("key", key.Key, DbType.Int64); + return "@key"; + } + else + { + var (key1, key2) = key.Keys; + command.AddParameter("key1", key1, DbType.Int32); + command.AddParameter("key2", key2, DbType.Int32); + return "@key1, @key2"; + } + } + + private static bool UseTransactionScopedLock(DatabaseConnection connection) => + // Transaction-scoped locking is supported on internally-owned connections and externally-owned connections which explicitly have a transaction + // (meaning that the external connection came through the transactional locking APIs, see PostgresDistributedLock.Transactions.cs). + connection.HasTransaction; + + private static string AddPGLocksFilterParametersAndGetFilterExpression(DatabaseCommand command, PostgresAdvisoryLockKey key) + { + // From https://www.postgresql.org/docs/12/view-pg-locks.html + // Advisory locks can be acquired on keys consisting of either a single bigint value or two integer values. + // A bigint key is displayed with its high-order half in the classid column, its low-order half in the objid column, + // and objsubid equal to 1. The original bigint value can be reassembled with the expression (classid::bigint << 32) | objid::bigint. + // Integer keys are displayed with the first key in the classid column, the second key in the objid column, and objsubid equal to 2. + + string classIdParameter, objIdParameter, objSubId; + if (key.HasSingleKey) + { + // since Postgres seems to lack unchecked int conversions, it is simpler to just generate extra + // parameters to carry the split key info in this case + var (keyUpper32, keyLower32) = key.Keys; + command.AddParameter(classIdParameter = "keyUpper32", keyUpper32, DbType.Int32); + command.AddParameter(objIdParameter = "keyLower32", keyLower32, DbType.Int32); + objSubId = "1"; + } + else + { + AddKeyParametersAndGetKeyArguments(command, key); + classIdParameter = "key1"; + objIdParameter = "key2"; + objSubId = "2"; + } + + return $"(l.classid = @{classIdParameter} AND l.objid = @{objIdParameter} AND l.objsubid = {objSubId})"; + } + + private readonly struct CapturedTimeoutSettings(string statementTimeout, string lockTimeout) + { + public string StatementTimeout { get; } = ValidatePostgresTimeout(statementTimeout); + + public string LockTimeout { get; } = ValidatePostgresTimeout(lockTimeout); + + private static string ValidatePostgresTimeout(string timeout) => + // make sure it's safe to use as a SQL literal + timeout.IndexOfAny(['\'', '\\']) >= 0 + ? throw new FormatException($"Unexpected timeout setting value '{timeout}'") + : timeout; + } +} diff --git a/src/DistributedLock.Postgres/PostgresAdvisoryLockKey.cs b/src/DistributedLock.Postgres/PostgresAdvisoryLockKey.cs new file mode 100644 index 00000000..04e9992d --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresAdvisoryLockKey.cs @@ -0,0 +1,273 @@ +using Medallion.Threading.Internal; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Medallion.Threading.Postgres; + +/// +/// Acts as the "name" of a distributed lock in Postgres. Consists of one 64-bit value or two 32-bit values (the spaces do not overlap). +/// See https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS +/// +public readonly struct PostgresAdvisoryLockKey : IEquatable +{ + private readonly long _key; + private readonly KeyEncoding _keyEncoding; + + /// + /// Constructs a key from a single 64-bit value. This is a separate key space + /// than . + /// + public PostgresAdvisoryLockKey(long key) + { + this._key = key; + this._keyEncoding = KeyEncoding.Int64; + } + + /// + /// Constructs a key from a pair of 32-bit values. This is a separate key space + /// than . + /// + public PostgresAdvisoryLockKey(int key1, int key2) + { + this._key = CombineKeys(key1, key2); + this._keyEncoding = KeyEncoding.Int32Pair; + } + + /// + /// Constructs a key based on a string . + /// + /// If the string is of the form "{16-digit hex}" or "{8-digit hex},{8-digit hex}", this will be parsed into numeric keys. + /// + /// If the string is an ascii string with 9 or fewer characters, it will be mapped to a key that does not collide with + /// any other key based on such a string or based on a 32-bit value. + /// + /// Other string names will be rejected unless is specified, in which case it will be hashed to + /// a 64-bit key value. + /// + public PostgresAdvisoryLockKey(string name, bool allowHashing = false) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + if (TryEncodeAscii(name, out this._key)) + { + this._keyEncoding = KeyEncoding.Ascii; + } + else if (TryEncodeHashString(name, out this._key, out var hasSeparator)) + { + this._keyEncoding = hasSeparator ? KeyEncoding.Int32Pair : KeyEncoding.Int64; + } + else if (allowHashing) + { + this._key = HashString(name); + this._keyEncoding = KeyEncoding.Int64; + } + else + { + throw new FormatException($"Name '{name}' could not be encoded as a {nameof(PostgresAdvisoryLockKey)}. Please specify {nameof(allowHashing)} or use one of the following formats:" + + $" or (1) a 0-{MaxAsciiLength} character string using only ASCII characters" + + $", (2) a {HashStringLength} character hex string, such as the result of Int64.MaxValue.ToString(\"x{HashStringLength}\")" + + $", or (3) a 2-part, {SeparatedHashStringLength} character string of the form XXXXXXXX{HashStringSeparator}XXXXXXXX, where the X's are {HashPartLength} hex strings" + + $" such as the result of Int32.MaxValue.ToString(\"x{HashPartLength}\")." + + " Note that each unique string provided for formats 1 and 2 will map to a unique hash value, with no collisions across formats. Format 3 strings use the same key space as 2."); + } + } + + internal bool HasSingleKey => this._keyEncoding == KeyEncoding.Int64; + + internal long Key + { + get + { + Invariant.Require(this.HasSingleKey); + return this._key; + } + } + + // note: we allow calling this even with a single key, since for + // pg_locks lookups we have to split the key anyway + internal (int key1, int key2) Keys => SplitKeys(this._key); + + /// + /// Implements + /// + public bool Equals(PostgresAdvisoryLockKey that) => this.ToTuple().Equals(that.ToTuple()); + + /// + /// Implements + /// + public override bool Equals(object? obj) => obj is PostgresAdvisoryLockKey that && this.Equals(that); + + /// + /// Implements + /// + public override int GetHashCode() => this.ToTuple().GetHashCode(); + + /// + /// Provides equality based on + /// + public static bool operator ==(PostgresAdvisoryLockKey a, PostgresAdvisoryLockKey b) => a.Equals(b); + /// + /// Provides inequality based on + /// + public static bool operator !=(PostgresAdvisoryLockKey a, PostgresAdvisoryLockKey b) => !(a == b); + + private (long, bool) ToTuple() => (this._key, this.HasSingleKey); + + /// + /// Returns a string representation of the key that can be round-tripped through + /// + /// + public override string ToString() => this._keyEncoding switch + { + KeyEncoding.Int64 => ToHashString(this._key), + KeyEncoding.Int32Pair => ToHashString(SplitKeys(this._key)), + KeyEncoding.Ascii => ToAsciiString(this._key), + _ => throw new InvalidOperationException() + }; + + private static long CombineKeys(int key1, int key2) => unchecked(((long)key1 << (8 * sizeof(int))) | (uint)key2); + private static (int key1, int key2) SplitKeys(long key) => ((int)(key >> (8 * sizeof(int))), unchecked((int)(key & uint.MaxValue))); + + #region ---- Ascii ---- + // The ASCII encoding works as follows: + // Each ASCII char is 7 bits allowing for 9 chars = 63 bits in total. + // In order to differentiate between different-length strings with leading '\0', + // we additionally fill the next bit after the string ends with 0. We then fill any + // remaining bits with 1. Therefore the final 64 bit value is 0-9 7-bit characters followed + // by 0, followed by N=63-(7*length) 1s + + private const int AsciiCharBits = 7; + private const int MaxAsciiValue = (1 << AsciiCharBits) - 1; + internal const int MaxAsciiLength = (8 * sizeof(long)) / AsciiCharBits; + + private static bool TryEncodeAscii(string name, out long key) + { + if (name.Length > MaxAsciiLength) + { + key = default; + return false; + } + + // load the chars into result + var result = 0L; + foreach (var @char in name) + { + if (@char > MaxAsciiValue) + { + key = default; + return false; + } + + result = (result << AsciiCharBits) | @char; + } + + // add padding + result <<= 1; // load zero + for (var i = name.Length; i < MaxAsciiLength; ++i) + { + result = (result << AsciiCharBits) | MaxAsciiValue; // load 1s + } + + key = result; + return true; + } + + private static string ToAsciiString(long key) + { + // use unsigned to avoid signed shifts + var remainingKeyBits = unchecked((ulong)key); + + // unload padding 1s to determine length + var length = MaxAsciiLength; + while ((remainingKeyBits & MaxAsciiValue) == MaxAsciiValue) + { + --length; + remainingKeyBits >>= AsciiCharBits; + } + Invariant.Require((remainingKeyBits & 1) == 0, "last padding bit should be zero"); + remainingKeyBits >>= 1; // unload padding 0 + + var chars = new char[length]; + for (var i = length - 1; i >= 0; --i) + { + chars[i] = (char)(remainingKeyBits & MaxAsciiValue); + remainingKeyBits >>= AsciiCharBits; + } + + return new string(chars, startIndex: 0, length); + } + #endregion + + #region ---- Hashing ---- + private const char HashStringSeparator = ','; + internal const int HashPartLength = 8, // 8-byte hex numbers + HashStringLength = 16, // 2 hashes + SeparatedHashStringLength = HashStringLength + 1; // separated by comma + + private static bool TryEncodeHashString(string name, out long key, out bool hasSeparator) + { + if (name.Length == SeparatedHashStringLength && name[HashPartLength] == HashStringSeparator) + { + hasSeparator = true; + } + else + { + hasSeparator = false; + + if (name.Length != HashStringLength) + { + key = default; + return false; + } + } + + return TryParseHashKeys(name, out key); + + static bool TryParseHashKeys(string text, out long key) + { + if (TryParseHashKey(text.Substring(0, HashPartLength), out var key1) + && TryParseHashKey(text.Substring(text.Length - HashPartLength), out var key2)) + { + key = CombineKeys(key1, key2); + return true; + } + + key = default; + return false; + } + + static bool TryParseHashKey(string text, out int key) => + int.TryParse(text, NumberStyles.AllowHexSpecifier, NumberFormatInfo.InvariantInfo, out key); + } + + private static long HashString(string name) + { + // The hash result from SHA1 is too large, so we have to truncate (recommended practice and does not + // weaken the hash other than due to using fewer bytes) + + using var sha1 = SHA1.Create(); + var hashBytes = sha1.ComputeHash(Encoding.UTF8.GetBytes(name)); + + // We don't use BitConverter here because we want to be endianess-agnostic. + // However, this code replicates that result on little-endian + var result = 0L; + for (var i = sizeof(long) - 1; i >= 0; --i) + { + result = (result << 8) | hashBytes[i]; + } + return result; + } + + private static string ToHashString((int key1, int key2) keys) => FormattableString.Invariant($"{keys.key1:x8}{HashStringSeparator}{keys.key2:x8}"); + + private static string ToHashString(long key) => key.ToString("x16", NumberFormatInfo.InvariantInfo); + #endregion + + private enum KeyEncoding + { + Int64 = 0, + Int32Pair, + Ascii, + } +} diff --git a/src/DistributedLock.Postgres/PostgresConnectionOptionsBuilder.cs b/src/DistributedLock.Postgres/PostgresConnectionOptionsBuilder.cs new file mode 100644 index 00000000..0f831a8a --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresConnectionOptionsBuilder.cs @@ -0,0 +1,94 @@ +using Medallion.Threading.Internal; +using System.Data; + +namespace Medallion.Threading.Postgres; + +/// +/// Specifies options for connecting to and locking against a Postgres database +/// +public sealed class PostgresConnectionOptionsBuilder +{ + private TimeoutValue? _keepaliveCadence; + private bool? _useTransaction, _useMultiplexing; + + internal PostgresConnectionOptionsBuilder() { } + + /// + /// Some Postgres setups have automation in place which aggressively kills idle connections. + /// + /// To prevent this, this option sets the cadence at which we run a no-op "keepalive" query on a connection that is holding a lock. + /// Note that this still does not guarantee protection for the connection from all conditions where the governor might kill it. + /// + /// Defaults to , which disables keepalive. + /// + public PostgresConnectionOptionsBuilder KeepaliveCadence(TimeSpan keepaliveCadence) + { + this._keepaliveCadence = new TimeoutValue(keepaliveCadence, nameof(keepaliveCadence)); + return this; + } + + /// + /// Whether the synchronization should use a transaction scope rather than a session scope. Defaults to false. + /// + /// Synchronizing based on a transaction is necessary to do distributed locking with some pgbouncer configurations + /// (see https://github.com/madelson/DistributedLock/issues/168#issuecomment-1823277173). It may also be marginally less + /// expensive than using a connection for a single lock because releasing requires only disposing the + /// underlying . + /// + /// The disadvantage of this strategy is that it is incompatible with and therefore + /// gives up the advantages of that approach. + /// + public PostgresConnectionOptionsBuilder UseTransaction(bool useTransaction = true) + { + this._useTransaction = useTransaction; + return this; + } + + /// + /// This mode takes advantage of the fact that while "holding" a lock (or other synchronization primitive) + /// a connection is essentially idle. Thus, rather than creating a new connection for each held lock it is + /// often possible to multiplex a shared connection so that that connection can hold multiple locks at the same time. + /// + /// Multiplexing is on by default. + /// + /// This is implemented in such a way that releasing a lock held on such a connection will never be blocked by an + /// Acquire() call that is waiting to acquire a lock on that same connection. For this reason, the multiplexing + /// strategy is "optimistic": if the lock can't be acquired instantaneously on the shared connection, a new (shareable) + /// connection will be allocated. + /// + /// This option can improve performance and avoid connection pool starvation in high-load scenarios. It is also + /// particularly applicable to cases where + /// semantics are used with a zero-length timeout. + /// + public PostgresConnectionOptionsBuilder UseMultiplexing(bool useMultiplexing = true) + { + this._useMultiplexing = useMultiplexing; + return this; + } + + internal static (TimeoutValue keepaliveCadence, bool useTransaction, bool useMultiplexing) GetOptions( + Action? optionsBuilder) + { + PostgresConnectionOptionsBuilder? options; + if (optionsBuilder != null) + { + options = new(); + optionsBuilder(options); + } + else + { + options = null; + } + + var keepaliveCadence = options?._keepaliveCadence ?? Timeout.InfiniteTimeSpan; + var useTransaction = options?._useTransaction ?? false; + var useMultiplexing = options?._useMultiplexing ?? !options?._useTransaction ?? true; + + if (useMultiplexing && useTransaction) + { + throw new ArgumentException(nameof(UseTransaction) + ": is not compatible with " + nameof(UseMultiplexing)); + } + + return (keepaliveCadence, useTransaction, useMultiplexing); + } +} \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PostgresDatabaseConnection.cs b/src/DistributedLock.Postgres/PostgresDatabaseConnection.cs new file mode 100644 index 00000000..1ce3adaa --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresDatabaseConnection.cs @@ -0,0 +1,77 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using Npgsql; +using System.Data; +#if NET7_0_OR_GREATER +using System.Data.Common; +#endif + +namespace Medallion.Threading.Postgres; + +internal sealed class PostgresDatabaseConnection : DatabaseConnection +{ + public PostgresDatabaseConnection(IDbConnection connection) + : base(connection, isExternallyOwned: true) + { + } + + public PostgresDatabaseConnection(IDbTransaction transaction) + : base(transaction, isExternallyOwned: true) + { + } + +#if NET7_0_OR_GREATER + public PostgresDatabaseConnection(DbDataSource dbDataSource) + : base(dbDataSource.CreateConnection(), isExternallyOwned: false) + { + } +#endif + + public PostgresDatabaseConnection(string connectionString) + : base(new NpgsqlConnection(connectionString), isExternallyOwned: false) + { + } + + // see https://www.npgsql.org/doc/prepare.html + public override bool ShouldPrepareCommands => true; + + public override bool IsCommandCancellationException(Exception exception) => + exception is PostgresException postgresException + // cancellation error code from https://www.postgresql.org/docs/10/errcodes-appendix.html + && postgresException.SqlState == "57014"; + + public override async Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor) + { + Invariant.Require(sleepTime >= TimeSpan.Zero); + + // if we're in a transaction, we need to establish a savepoint so that we can roll back if we + // get canceled without the whole transaction being aborted + const string SavePointName = "medallion_threading_postgres_database_connection_sleep"; + + var hasTransaction = this.HasTransaction; + if (hasTransaction) + { + using var setSavePointCommand = this.CreateCommand(); + setSavePointCommand.SetCommandText("SAVEPOINT " + SavePointName); + await executor(setSavePointCommand, CancellationToken.None).ConfigureAwait(false); + } + + try + { + using var sleepCommand = this.CreateCommand(); + sleepCommand.SetCommandText("SELECT pg_catalog.pg_sleep(@sleepTimeSeconds)"); + sleepCommand.AddParameter("sleepTimeSeconds", sleepTime.TotalSeconds, DbType.Double); + sleepCommand.SetTimeout(sleepTime); + await executor(sleepCommand, cancellationToken).ConfigureAwait(false); + } + finally + { + if (hasTransaction) + { + using var rollBackSavePointCommand = this.CreateCommand(); + rollBackSavePointCommand.SetCommandText("ROLLBACK TO SAVEPOINT " + SavePointName); + await executor(rollBackSavePointCommand, CancellationToken.None).ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PostgresDistributedLock.IDistributedLock.cs b/src/DistributedLock.Postgres/PostgresDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..d0051d42 --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresDistributedLock.IDistributedLock.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Postgres; + +public partial class PostgresDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public PostgresDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public PostgresDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PostgresDistributedLock.Transactions.cs b/src/DistributedLock.Postgres/PostgresDistributedLock.Transactions.cs new file mode 100644 index 00000000..4580f23a --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresDistributedLock.Transactions.cs @@ -0,0 +1,136 @@ +using Medallion.Threading.Internal; +using System.Data; + +namespace Medallion.Threading.Postgres; + +public partial class PostgresDistributedLock +{ + /// + /// Attempts to acquire a transaction-scoped advisory lock synchronously with an externally owned transaction. Usage: + /// + /// var transaction = /* create a DB transaction */ + /// + /// var isLockAcquired = myLock.TryAcquireWithTransaction(..., transaction, ...) + /// + /// if (isLockAcquired != null) + /// { + /// /* we have the lock! */ + /// + /// // Commit or Rollback the transaction, which in turn will release the lock + /// } + /// + /// + /// NOTE: The owner of the transaction is the responsible party for it - the owner must commit or rollback the transaction in order to release the acquired lock. + /// + /// The postgres advisory lock key which will be used to acquire the lock. + /// The externally owned transaction which will be used to acquire the lock. The owner of the transaction must commit or rollback it for the lock to be released. + /// How long to wait before giving up on the acquisition attempt. Defaults to 0. + /// Specifies a token by which the wait can be canceled + /// Whether the lock has been acquired + public static bool TryAcquireWithTransaction(PostgresAdvisoryLockKey key, IDbTransaction transaction, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(state => TryAcquireWithTransactionAsyncInternal(state.key, state.transaction, state.timeout, state.cancellationToken), (key, transaction, timeout, cancellationToken)); + + /// + /// Acquires a transaction-scoped advisory lock synchronously, failing with if the attempt times out. Usage: + /// + /// var transaction = /* create a DB transaction */ + /// + /// myLock.AcquireWithTransaction(..., transaction, ...) + /// + /// /* we have the lock! */ + /// + /// // Commit or Rollback the transaction, which in turn will release the lock + /// + /// + /// NOTE: The owner of the transaction is the responsible party for it - the owner must commit or rollback the transaction in order to release the acquired lock. + /// + /// The postgres advisory lock key which will be used to acquire the lock. + /// The externally owned transaction which will be used to acquire the lock. The owner of the transaction must commit or rollback it for the lock to be released. + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + public static void AcquireWithTransaction(PostgresAdvisoryLockKey key, IDbTransaction transaction, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + SyncViaAsync.Run(state => AcquireWithTransactionAsyncInternal(state.key, state.transaction, state.timeout, state.cancellationToken), (key, transaction, timeout, cancellationToken)); + + /// + /// Attempts to acquire a transaction-scoped advisory lock asynchronously with an externally owned transaction. Usage: + /// + /// var transaction = /* create a DB transaction */ + /// + /// var isLockAcquired = await myLock.TryAcquireWithTransactionAsync(..., transaction, ...) + /// + /// if (isLockAcquired != null) + /// { + /// /* we have the lock! */ + /// + /// // Commit or Rollback the transaction, which in turn will release the lock + /// } + /// + /// + /// NOTE: The owner of the transaction is the responsible party for it - the owner must commit or rollback the transaction in order to release the acquired lock. + /// + /// The postgres advisory lock key which will be used to acquire the lock. + /// The externally owned transaction which will be used to acquire the lock. The owner of the transaction must commit or rollback it for the lock to be released. + /// How long to wait before giving up on the acquisition attempt. Defaults to 0. + /// Specifies a token by which the wait can be canceled + /// Whether the lock has been acquired + public static ValueTask TryAcquireWithTransactionAsync(PostgresAdvisoryLockKey key, IDbTransaction transaction, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + TryAcquireWithTransactionAsyncInternal(key, transaction, timeout, cancellationToken); + + /// + /// Acquires a transaction-scoped advisory lock asynchronously, failing with if the attempt times out. Usage: + /// + /// var transaction = /* create a DB transaction */ + /// + /// await myLock.AcquireWithTransaction(..., transaction, ...) + /// + /// /* we have the lock! */ + /// + /// // Commit or Rollback the transaction, which in turn will release the lock + /// + /// + /// NOTE: The owner of the transaction is the responsible party for it - the owner must commit or rollback the transaction in order to release the acquired lock. + /// + /// The postgres advisory lock key which will be used to acquire the lock. + /// The externally owned transaction which will be used to acquire the lock. The owner of the transaction must commit or rollback it for the lock to be released. + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + public static ValueTask AcquireWithTransactionAsync(PostgresAdvisoryLockKey key, IDbTransaction transaction, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + AcquireWithTransactionAsyncInternal(key, transaction, timeout, cancellationToken); + + internal static ValueTask TryAcquireWithTransactionAsyncInternal(PostgresAdvisoryLockKey key, IDbTransaction transaction, TimeSpan timeout, CancellationToken cancellationToken) + { + if (key == null) { throw new ArgumentNullException(nameof(key)); } + if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); } + + return TryAcquireAsync(); + + async ValueTask TryAcquireAsync() + { + var connection = new PostgresDatabaseConnection(transaction); + + await using (connection.ConfigureAwait(false)) + { + var lockAcquiredCookie = await PostgresAdvisoryLock.ExclusiveLock.TryAcquireAsync(connection, key.ToString(), timeout, cancellationToken).ConfigureAwait(false); + + return lockAcquiredCookie != null; + } + } + } + + internal static ValueTask AcquireWithTransactionAsyncInternal(PostgresAdvisoryLockKey key, IDbTransaction transaction, TimeSpan? timeout, CancellationToken cancellationToken) + { + if (key == null) { throw new ArgumentNullException(nameof(key)); } + if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); } + + return AcquireAsync(); + + async ValueTask AcquireAsync() + { + var connection = new PostgresDatabaseConnection(transaction); + await using (connection.ConfigureAwait(false)) + { + await PostgresAdvisoryLock.ExclusiveLock.TryAcquireAsync(connection, key.ToString(), timeout, cancellationToken).ThrowTimeoutIfNull().ConfigureAwait(false); + } + } + } +} diff --git a/src/DistributedLock.Postgres/PostgresDistributedLock.cs b/src/DistributedLock.Postgres/PostgresDistributedLock.cs new file mode 100644 index 00000000..640ed16c --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresDistributedLock.cs @@ -0,0 +1,97 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; +#if NET7_0_OR_GREATER +using System.Data.Common; +#endif + +namespace Medallion.Threading.Postgres; + +/// +/// Implements a distributed lock using Postgres advisory locks +/// (see https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS) +/// +public sealed partial class PostgresDistributedLock : IInternalDistributedLock +{ + private readonly IDbDistributedLock _internalLock; + + /// + /// Constructs a lock with the given (effectively the lock name), , + /// and + /// + public PostgresDistributedLock(PostgresAdvisoryLockKey key, string connectionString, Action? options = null) + : this(key, CreateInternalLock(key, connectionString, options)) + { + } + + /// + /// Constructs a lock with the given (effectively the lock name) and . + /// + public PostgresDistributedLock(PostgresAdvisoryLockKey key, IDbConnection connection) + : this(key, CreateInternalLock(key, connection)) + { + } + +#if NET7_0_OR_GREATER + /// + /// Constructs a lock with the given (effectively the lock name) and , + /// and . + /// + /// Not compatible with connection multiplexing. + /// + public PostgresDistributedLock(PostgresAdvisoryLockKey key, DbDataSource dbDataSource, Action? options = null) + : this(key, CreateInternalLock(key, dbDataSource, options)) + { + } +#endif + + private PostgresDistributedLock(PostgresAdvisoryLockKey key, IDbDistributedLock internalLock) + { + this.Key = key; + this._internalLock = internalLock; + } + + /// + /// The that uniquely identifies the lock on the database + /// + public PostgresAdvisoryLockKey Key { get; } + + string IDistributedLock.Name => this.Key.ToString(); + + ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + this._internalLock.TryAcquireAsync(timeout, PostgresAdvisoryLock.ExclusiveLock, cancellationToken, contextHandle: null).Wrap(h => new PostgresDistributedLockHandle(h)); + + internal static IDbDistributedLock CreateInternalLock(PostgresAdvisoryLockKey key, string connectionString, Action? options) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + var (keepaliveCadence, useTransaction, useMultiplexing) = PostgresConnectionOptionsBuilder.GetOptions(options); + + return useMultiplexing + ? new OptimisticConnectionMultiplexingDbDistributedLock(key.ToString(), connectionString, PostgresMultiplexedConnectionLockPool.Instance, keepaliveCadence) + : new DedicatedConnectionOrTransactionDbDistributedLock(key.ToString(), () => new PostgresDatabaseConnection(connectionString), useTransaction: useTransaction, keepaliveCadence); + } + + internal static IDbDistributedLock CreateInternalLock(PostgresAdvisoryLockKey key, IDbConnection connection) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + return new DedicatedConnectionOrTransactionDbDistributedLock(key.ToString(), () => new PostgresDatabaseConnection(connection)); + } + +#if NET7_0_OR_GREATER + internal static IDbDistributedLock CreateInternalLock(PostgresAdvisoryLockKey key, DbDataSource dbDataSource, Action? options) + { + if (dbDataSource == null) { throw new ArgumentNullException(nameof(dbDataSource)); } + + // Multiplexing is currently incompatible with DbDataSource (see #238), so default it to false + var originalOptions = options; + options = o => { o.UseMultiplexing(false); originalOptions?.Invoke(o); }; + + var (keepaliveCadence, useTransaction, useMultiplexing) = PostgresConnectionOptionsBuilder.GetOptions(options); + + return useMultiplexing + ? throw new NotSupportedException("Multiplexing is current incompatible with DbDataSource.") + : new DedicatedConnectionOrTransactionDbDistributedLock(key.ToString(), () => new PostgresDatabaseConnection(dbDataSource), useTransaction: useTransaction, keepaliveCadence); + } +#endif +} \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PostgresDistributedLockHandle.cs b/src/DistributedLock.Postgres/PostgresDistributedLockHandle.cs new file mode 100644 index 00000000..4c0cfcea --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresDistributedLockHandle.cs @@ -0,0 +1,31 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Postgres; + +/// +/// Implements +/// +public sealed class PostgresDistributedLockHandle : IDistributedSynchronizationHandle +{ + private IDistributedSynchronizationHandle? _innerHandle; + + internal PostgresDistributedLockHandle(IDistributedSynchronizationHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the lock asynchronously + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} diff --git a/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.IDistributedReaderWriterLock.cs b/src/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.IDistributedReaderWriterLock.cs similarity index 81% rename from DistributedLock.Postgres/PostgresDistributedReaderWriterLock.IDistributedReaderWriterLock.cs rename to src/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.IDistributedReaderWriterLock.cs index ee5dc2ca..abd694a5 100644 --- a/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.IDistributedReaderWriterLock.cs +++ b/src/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.IDistributedReaderWriterLock.cs @@ -1,30 +1,27 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Medallion.Threading.Internal; -namespace Medallion.Threading.Postgres +namespace Medallion.Threading.Postgres; + +public partial class PostgresDistributedReaderWriterLock { - public partial class PostgresDistributedReaderWriterLock - { - // AUTO-GENERATED + // AUTO-GENERATED - IDistributedLockHandle? IDistributedReaderWriterLock.TryAcquireReadLock(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireReadLock(timeout, cancellationToken); - IDistributedLockHandle IDistributedReaderWriterLock.AcquireReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireReadLock(timeout, cancellationToken); - ValueTask IDistributedReaderWriterLock.TryAcquireReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); - ValueTask IDistributedReaderWriterLock.AcquireReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); - IDistributedLockHandle? IDistributedReaderWriterLock.TryAcquireWriteLock(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireWriteLock(timeout, cancellationToken); - IDistributedLockHandle IDistributedReaderWriterLock.AcquireWriteLock(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireWriteLock(timeout, cancellationToken); - ValueTask IDistributedReaderWriterLock.TryAcquireWriteLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); - ValueTask IDistributedReaderWriterLock.AcquireWriteLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireReadLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireReadLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireReadLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireWriteLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireWriteLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireWriteLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireWriteLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireWriteLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireWriteLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); /// /// Attempts to acquire a READ lock synchronously. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: @@ -154,5 +151,4 @@ public PostgresDistributedReaderWriterLockHandle AcquireWriteLock(TimeSpan? time public ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken, isWrite: true); - } } \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.cs b/src/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.cs new file mode 100644 index 00000000..8a713cc1 --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresDistributedReaderWriterLock.cs @@ -0,0 +1,67 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; +#if NET7_0_OR_GREATER +using System.Data.Common; +#endif + +namespace Medallion.Threading.Postgres; + +/// +/// Implements a distributed lock using Postgres advisory locks +/// (see https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS) +/// +public sealed partial class PostgresDistributedReaderWriterLock : IInternalDistributedReaderWriterLock +{ + private readonly IDbDistributedLock _internalLock; + + /// + /// Constructs a lock with the given (effectively the lock name), , + /// and + /// + public PostgresDistributedReaderWriterLock(PostgresAdvisoryLockKey key, string connectionString, Action? options = null) + : this(key, PostgresDistributedLock.CreateInternalLock(key, connectionString, options)) + { + } + + /// + /// Constructs a lock with the given (effectively the lock name) and . + /// + public PostgresDistributedReaderWriterLock(PostgresAdvisoryLockKey key, IDbConnection connection) + : this(key, PostgresDistributedLock.CreateInternalLock(key, connection)) + { + } + +#if NET7_0_OR_GREATER + /// + /// Constructs a lock with the given (effectively the lock name) and , + /// and . + /// + /// Not compatible with connection multiplexing. + /// + public PostgresDistributedReaderWriterLock(PostgresAdvisoryLockKey key, DbDataSource dbDataSource, Action? options = null) + : this(key, PostgresDistributedLock.CreateInternalLock(key, dbDataSource, options)) + { + } +#endif + + private PostgresDistributedReaderWriterLock(PostgresAdvisoryLockKey key, IDbDistributedLock internalLock) + { + this.Key = key; + this._internalLock = internalLock; + } + + /// + /// The that uniquely identifies the lock on the database + /// + public PostgresAdvisoryLockKey Key { get; } + + string IDistributedReaderWriterLock.Name => this.Key.ToString(); + + ValueTask IInternalDistributedReaderWriterLock.InternalTryAcquireAsync( + TimeoutValue timeout, + CancellationToken cancellationToken, + bool isWrite) => + this._internalLock.TryAcquireAsync(timeout, isWrite ? PostgresAdvisoryLock.ExclusiveLock : PostgresAdvisoryLock.SharedLock, cancellationToken, contextHandle: null) + .Wrap(h => new PostgresDistributedReaderWriterLockHandle(h)); +} \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PostgresDistributedReaderWriterLockHandle.cs b/src/DistributedLock.Postgres/PostgresDistributedReaderWriterLockHandle.cs new file mode 100644 index 00000000..eb8bcf24 --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresDistributedReaderWriterLockHandle.cs @@ -0,0 +1,31 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Postgres; + +/// +/// Implements +/// +public sealed class PostgresDistributedReaderWriterLockHandle : IDistributedSynchronizationHandle +{ + private IDistributedSynchronizationHandle? _innerHandle; + + internal PostgresDistributedReaderWriterLockHandle(IDistributedSynchronizationHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the lock asynchronously + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} diff --git a/src/DistributedLock.Postgres/PostgresDistributedSynchronizationProvider.cs b/src/DistributedLock.Postgres/PostgresDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..bcb9f600 --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresDistributedSynchronizationProvider.cs @@ -0,0 +1,69 @@ +using System.Data; +#if NET7_0_OR_GREATER +using System.Data.Common; +#endif + +namespace Medallion.Threading.Postgres; + +/// +/// Implements for and +/// for . +/// +public sealed class PostgresDistributedSynchronizationProvider : IDistributedLockProvider, IDistributedReaderWriterLockProvider +{ + private readonly Func _lockFactory; + private readonly Func _readerWriterLockFactory; + + /// + /// Constructs a provider which connects to Postgres using the provided and . + /// + public PostgresDistributedSynchronizationProvider(string connectionString, Action? options = null) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + this._lockFactory = key => new PostgresDistributedLock(key, connectionString, options); + this._readerWriterLockFactory = key => new PostgresDistributedReaderWriterLock(key, connectionString, options); + } + + /// + /// Constructs a provider which connects to Postgres using the provided . + /// + public PostgresDistributedSynchronizationProvider(IDbConnection connection) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + + this._lockFactory = key => new PostgresDistributedLock(key, connection); + this._readerWriterLockFactory = key => new PostgresDistributedReaderWriterLock(key, connection); + } + +#if NET7_0_OR_GREATER + /// + /// Constructs a provider which connects to Postgres using the provided and . + /// + /// Not compatible with connection multiplexing. + /// + public PostgresDistributedSynchronizationProvider(DbDataSource dbDataSource, Action? options = null) + { + if (dbDataSource == null) { throw new ArgumentNullException(nameof(dbDataSource)); } + + this._lockFactory = key => new PostgresDistributedLock(key, dbDataSource, options); + this._readerWriterLockFactory = key => new PostgresDistributedReaderWriterLock(key, dbDataSource, options); + } +#endif + + /// + /// Creates a with the provided . + /// + public PostgresDistributedLock CreateLock(PostgresAdvisoryLockKey key) => this._lockFactory(key); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => + this.CreateLock(new PostgresAdvisoryLockKey(name, allowHashing: true)); + + /// + /// Creates a with the provided . + /// + public PostgresDistributedReaderWriterLock CreateReaderWriterLock(PostgresAdvisoryLockKey key) => this._readerWriterLockFactory(key); + + IDistributedReaderWriterLock IDistributedReaderWriterLockProvider.CreateReaderWriterLock(string name) => + this.CreateReaderWriterLock(new PostgresAdvisoryLockKey(name, allowHashing: true)); +} \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs b/src/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs new file mode 100644 index 00000000..7f576747 --- /dev/null +++ b/src/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs @@ -0,0 +1,8 @@ +using Medallion.Threading.Internal.Data; + +namespace Medallion.Threading.Postgres; + +internal static class PostgresMultiplexedConnectionLockPool +{ + public static readonly MultiplexedConnectionLockPool Instance = new(s => new PostgresDatabaseConnection(s)); +} diff --git a/src/DistributedLock.Postgres/PublicAPI.Shipped.txt b/src/DistributedLock.Postgres/PublicAPI.Shipped.txt new file mode 100644 index 00000000..705df616 --- /dev/null +++ b/src/DistributedLock.Postgres/PublicAPI.Shipped.txt @@ -0,0 +1,53 @@ +#nullable enable +Medallion.Threading.Postgres.PostgresAdvisoryLockKey +Medallion.Threading.Postgres.PostgresAdvisoryLockKey.Equals(Medallion.Threading.Postgres.PostgresAdvisoryLockKey that) -> bool +Medallion.Threading.Postgres.PostgresAdvisoryLockKey.PostgresAdvisoryLockKey() -> void +Medallion.Threading.Postgres.PostgresAdvisoryLockKey.PostgresAdvisoryLockKey(int key1, int key2) -> void +Medallion.Threading.Postgres.PostgresAdvisoryLockKey.PostgresAdvisoryLockKey(long key) -> void +Medallion.Threading.Postgres.PostgresAdvisoryLockKey.PostgresAdvisoryLockKey(string! name, bool allowHashing = false) -> void +Medallion.Threading.Postgres.PostgresConnectionOptionsBuilder +Medallion.Threading.Postgres.PostgresConnectionOptionsBuilder.KeepaliveCadence(System.TimeSpan keepaliveCadence) -> Medallion.Threading.Postgres.PostgresConnectionOptionsBuilder! +Medallion.Threading.Postgres.PostgresConnectionOptionsBuilder.UseMultiplexing(bool useMultiplexing = true) -> Medallion.Threading.Postgres.PostgresConnectionOptionsBuilder! +Medallion.Threading.Postgres.PostgresConnectionOptionsBuilder.UseTransaction(bool useTransaction = true) -> Medallion.Threading.Postgres.PostgresConnectionOptionsBuilder! +Medallion.Threading.Postgres.PostgresDistributedLock +Medallion.Threading.Postgres.PostgresDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Postgres.PostgresDistributedLockHandle! +Medallion.Threading.Postgres.PostgresDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Postgres.PostgresDistributedLock.Key.get -> Medallion.Threading.Postgres.PostgresAdvisoryLockKey +Medallion.Threading.Postgres.PostgresDistributedLock.PostgresDistributedLock(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.Postgres.PostgresDistributedLock.PostgresDistributedLock(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, System.Data.IDbConnection! connection) -> void +Medallion.Threading.Postgres.PostgresDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Postgres.PostgresDistributedLockHandle? +Medallion.Threading.Postgres.PostgresDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Postgres.PostgresDistributedLockHandle +Medallion.Threading.Postgres.PostgresDistributedLockHandle.Dispose() -> void +Medallion.Threading.Postgres.PostgresDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.Postgres.PostgresDistributedLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.AcquireReadLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Postgres.PostgresDistributedReaderWriterLockHandle! +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.AcquireReadLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.AcquireWriteLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Postgres.PostgresDistributedReaderWriterLockHandle! +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.AcquireWriteLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.Key.get -> Medallion.Threading.Postgres.PostgresAdvisoryLockKey +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.PostgresDistributedReaderWriterLock(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.PostgresDistributedReaderWriterLock(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, System.Data.IDbConnection! connection) -> void +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.TryAcquireReadLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Postgres.PostgresDistributedReaderWriterLockHandle? +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.TryAcquireReadLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.TryAcquireWriteLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Postgres.PostgresDistributedReaderWriterLockHandle? +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.TryAcquireWriteLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLockHandle +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLockHandle.Dispose() -> void +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.Postgres.PostgresDistributedSynchronizationProvider +Medallion.Threading.Postgres.PostgresDistributedSynchronizationProvider.CreateLock(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key) -> Medallion.Threading.Postgres.PostgresDistributedLock! +Medallion.Threading.Postgres.PostgresDistributedSynchronizationProvider.CreateReaderWriterLock(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key) -> Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock! +Medallion.Threading.Postgres.PostgresDistributedSynchronizationProvider.PostgresDistributedSynchronizationProvider(string! connectionString, System.Action? options = null) -> void +Medallion.Threading.Postgres.PostgresDistributedSynchronizationProvider.PostgresDistributedSynchronizationProvider(System.Data.IDbConnection! connection) -> void +override Medallion.Threading.Postgres.PostgresAdvisoryLockKey.Equals(object? obj) -> bool +override Medallion.Threading.Postgres.PostgresAdvisoryLockKey.GetHashCode() -> int +override Medallion.Threading.Postgres.PostgresAdvisoryLockKey.ToString() -> string! +static Medallion.Threading.Postgres.PostgresAdvisoryLockKey.operator !=(Medallion.Threading.Postgres.PostgresAdvisoryLockKey a, Medallion.Threading.Postgres.PostgresAdvisoryLockKey b) -> bool +static Medallion.Threading.Postgres.PostgresAdvisoryLockKey.operator ==(Medallion.Threading.Postgres.PostgresAdvisoryLockKey a, Medallion.Threading.Postgres.PostgresAdvisoryLockKey b) -> bool +static Medallion.Threading.Postgres.PostgresDistributedLock.AcquireWithTransaction(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, System.Data.IDbTransaction! transaction, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +static Medallion.Threading.Postgres.PostgresDistributedLock.AcquireWithTransactionAsync(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, System.Data.IDbTransaction! transaction, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Medallion.Threading.Postgres.PostgresDistributedLock.TryAcquireWithTransaction(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, System.Data.IDbTransaction! transaction, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +static Medallion.Threading.Postgres.PostgresDistributedLock.TryAcquireWithTransactionAsync(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, System.Data.IDbTransaction! transaction, System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PublicAPI.Unshipped.txt b/src/DistributedLock.Postgres/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/DistributedLock.Postgres/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/DistributedLock.Postgres/PublicAPI/net8.0/PublicAPI.Shipped.txt new file mode 100644 index 00000000..2cd97d79 --- /dev/null +++ b/src/DistributedLock.Postgres/PublicAPI/net8.0/PublicAPI.Shipped.txt @@ -0,0 +1,4 @@ +#nullable enable +Medallion.Threading.Postgres.PostgresDistributedLock.PostgresDistributedLock(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, System.Data.Common.DbDataSource! dbDataSource, System.Action? options = null) -> void +Medallion.Threading.Postgres.PostgresDistributedReaderWriterLock.PostgresDistributedReaderWriterLock(Medallion.Threading.Postgres.PostgresAdvisoryLockKey key, System.Data.Common.DbDataSource! dbDataSource, System.Action? options = null) -> void +Medallion.Threading.Postgres.PostgresDistributedSynchronizationProvider.PostgresDistributedSynchronizationProvider(System.Data.Common.DbDataSource! dbDataSource, System.Action? options = null) -> void \ No newline at end of file diff --git a/src/DistributedLock.Postgres/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/DistributedLock.Postgres/PublicAPI/net8.0/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/DistributedLock.Postgres/packages.lock.json b/src/DistributedLock.Postgres/packages.lock.json new file mode 100644 index 00000000..336f058d --- /dev/null +++ b/src/DistributedLock.Postgres/packages.lock.json @@ -0,0 +1,584 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "Npgsql": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "System.Collections.Immutable": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Json": "8.0.5", + "System.Threading.Channels": "8.0.0" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4", + "System.ValueTuple": "4.5.0" + } + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.ValueTuple": { + "type": "CentralTransitive", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + } + }, + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "System.Collections.Immutable": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Json": "8.0.5", + "System.Threading.Channels": "8.0.0" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "System.Collections.Immutable": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Json": "8.0.5", + "System.Threading.Channels": "8.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.26, )", + "resolved": "8.0.26", + "contentHash": "o7/yVssM2r9Wyln2s9edBd5ANZXqdSdBI+g7JqXkyJmXrhs2WsJp25K5yPnYrTgdKBCjKB8bg+O2oew4sgzFaA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==" + }, + "distributedlock.core": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.Redis/AssemblyAttributes.cs b/src/DistributedLock.Redis/AssemblyAttributes.cs new file mode 100644 index 00000000..a9e3a34f --- /dev/null +++ b/src/DistributedLock.Redis/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] diff --git a/src/DistributedLock.Redis/DistributedLock.Redis.csproj b/src/DistributedLock.Redis/DistributedLock.Redis.csproj new file mode 100644 index 00000000..660f8705 --- /dev/null +++ b/src/DistributedLock.Redis/DistributedLock.Redis.csproj @@ -0,0 +1,65 @@ + + + + net462;netstandard2.0;netstandard2.1 + Medallion.Threading.Redis + True + 4 + Latest + enable + enable + + + + 1.1.1 + 1.0.0.0 + Michael Adelson + Provides distributed locking primitives based on Redis + Copyright © 2020 Michael Adelson + MIT + distributed redis lock redlock + https://github.com/madelson/DistributedLock + https://github.com/madelson/DistributedLock + 1.0.0.0 + See https://github.com/madelson/DistributedLock#release-notes + true + ..\DistributedLock.snk + + + + True + True + True + + + embedded + + true + true + + + + False + 1591 + TRACE;DEBUG + + + + + + + + + + all + + + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.Redis/Primitives/RedisMutexPrimitive.cs b/src/DistributedLock.Redis/Primitives/RedisMutexPrimitive.cs new file mode 100644 index 00000000..1ef46d61 --- /dev/null +++ b/src/DistributedLock.Redis/Primitives/RedisMutexPrimitive.cs @@ -0,0 +1,54 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.RedLock; +using StackExchange.Redis; + +namespace Medallion.Threading.Redis.Primitives; + +internal class RedisMutexPrimitive : IRedLockAcquirableSynchronizationPrimitive, IRedLockExtensibleSynchronizationPrimitive +{ + private static readonly RedisScript TryExtendScript, ReleaseScript; + + static RedisMutexPrimitive() + { + Func key = p => p._key; + Func lockId = p => p._lockId; + Func expiryMillis = p => p._timeouts.Expiry.InMilliseconds; + + TryExtendScript = new($@" + if redis.call('get', {key}) == {lockId} then + return redis.call('pexpire', {key}, {expiryMillis}) + end + return 0"); + + ReleaseScript = new($@" + if redis.call('get', {key}) == {lockId} then + return redis.call('del', {key}) + end + return 0"); + } + + private readonly RedisKey _key; + private readonly RedisValue _lockId; + private readonly RedLockTimeouts _timeouts; + + public RedisMutexPrimitive(RedisKey key, RedisValue lockId, RedLockTimeouts timeouts) + { + this._key = key; + this._lockId = lockId; + this._timeouts = timeouts; + } + + public TimeoutValue AcquireTimeout => this._timeouts.AcquireTimeout; + + public void Release(IDatabase database, bool fireAndForget) => ReleaseScript.Execute(database, this, fireAndForget); + public Task ReleaseAsync(IDatabaseAsync database, bool fireAndForget) => ReleaseScript.ExecuteAsync(database, this, fireAndForget); + + public bool TryAcquire(IDatabase database) => + database.StringSet(this._key, this._lockId, this._timeouts.Expiry.TimeSpan, When.NotExists, CommandFlags.DemandMaster); + public Task TryAcquireAsync(IDatabaseAsync database) => + database.StringSetAsync(this._key, this._lockId, this._timeouts.Expiry.TimeSpan, When.NotExists, CommandFlags.DemandMaster); + + public Task TryExtendAsync(IDatabaseAsync database) => TryExtendScript.ExecuteAsync(database, this).AsBooleanTask(); + + public bool IsConnected(IDatabase database) => database.IsConnected(this._key, CommandFlags.DemandMaster); +} diff --git a/src/DistributedLock.Redis/Primitives/RedisReaderWriterLockPrimitives.cs b/src/DistributedLock.Redis/Primitives/RedisReaderWriterLockPrimitives.cs new file mode 100644 index 00000000..375e30ff --- /dev/null +++ b/src/DistributedLock.Redis/Primitives/RedisReaderWriterLockPrimitives.cs @@ -0,0 +1,151 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.RedLock; +using StackExchange.Redis; + +namespace Medallion.Threading.Redis.Primitives; + +internal class RedisReadLockPrimitive : IRedLockAcquirableSynchronizationPrimitive, IRedLockExtensibleSynchronizationPrimitive +{ + private static readonly RedisScript TryAcquireReadScript, TryExtendReadScript, ReleaseReadScript; + + static RedisReadLockPrimitive() + { + Func readerKey = p => p._readerKey, + writerKey = p => p._writerKey; + Func lockId = p => p._lockId; + Func expiryMillis = p => p._timeouts.Expiry.InMilliseconds; + + // TRY ACQUIRE READ + // + // First, check the writer lock value: if it exists then we fail. + // + // Then, add our ID to the reader set, creating it if it does not exist. Then, extend the TTL + // of the reader set to be at least our expiry. Return success. + TryAcquireReadScript = new($@" + if redis.call('exists', {writerKey}) == 1 then + return 0 + end + redis.call('sadd', {readerKey}, {lockId}) + local readerTtl = redis.call('pttl', {readerKey}) + if readerTtl < tonumber({expiryMillis}) then + redis.call('pexpire', {readerKey}, {expiryMillis}) + end + return 1"); + + // TRY EXTEND READ + // + // First, check if the reader set exists and our ID is still a member. If not, we fail. + // + // Then, extend the reader set TTL to be at least our expiry (at least because other readers might be operating with a longer expiry) + TryExtendReadScript = new($@" + if redis.call('sismember', {readerKey}, {lockId}) == 0 then + return 0 + end + if redis.call('pttl', {readerKey}) < tonumber({expiryMillis}) then + redis.call('pexpire', {readerKey}, {expiryMillis}) + end + return 1"); + + // RELEASE READ + // + // Just remove our ID from the reader set (noop if it wasn't there or the set DNE) + ReleaseReadScript = new($@"redis.call('srem', {readerKey}, {lockId})"); + } + + private readonly RedisValue _lockId = RedLockHelper.CreateLockId(); + private readonly RedisKey _readerKey, _writerKey; + private readonly RedLockTimeouts _timeouts; + + public RedisReadLockPrimitive(RedisKey readerKey, RedisKey writerKey, RedLockTimeouts timeouts) + { + this._readerKey = readerKey; + this._writerKey = writerKey; + this._timeouts = timeouts; + } + + public TimeoutValue AcquireTimeout => this._timeouts.AcquireTimeout; + + public void Release(IDatabase database, bool fireAndForget) => ReleaseReadScript.Execute(database, this, fireAndForget); + public Task ReleaseAsync(IDatabaseAsync database, bool fireAndForget) => ReleaseReadScript.ExecuteAsync(database, this, fireAndForget); + + public Task TryExtendAsync(IDatabaseAsync database) => TryExtendReadScript.ExecuteAsync(database, this).AsBooleanTask(); + + public Task TryAcquireAsync(IDatabaseAsync database) => TryAcquireReadScript.ExecuteAsync(database, this).AsBooleanTask(); + public bool TryAcquire(IDatabase database) => (bool)TryAcquireReadScript.Execute(database, this); + + public bool IsConnected(IDatabase database) => database.IsConnected(this._readerKey, CommandFlags.DemandMaster); +} + +internal class RedisWriterWaitingPrimitive : RedisMutexPrimitive +{ + public const string LockIdSuffix = "_WRITERWAITING"; + + public RedisWriterWaitingPrimitive(RedisKey writerKey, RedisValue baseLockId, RedLockTimeouts timeouts) + : base(writerKey, baseLockId + LockIdSuffix, timeouts) + { + } +} + +internal class RedisWriteLockPrimitive : IRedLockAcquirableSynchronizationPrimitive, IRedLockExtensibleSynchronizationPrimitive +{ + private static readonly RedisScript TryAcquireWriteScript; + + static RedisWriteLockPrimitive() + { + Func readerKey = p => p._readerKey, + writerKey = p => p._writerKey; + Func lockId = p => p._lockId; + Func expiryMillis = p => p._timeouts.Expiry.InMilliseconds; + + // TRY ACQUIRE WRITE + // + // First, check if writerValue exists. If so, fail unless it's our waiting ID. + // + // Then, check if there are no readers. If so, then set writerValue to our ID and return success. If not, then if the lock + // has our waiting ID re-up the expiry (avoids the need to extend the writer waiting lock). + // + // Finally, return failure. + TryAcquireWriteScript = new($@" + local writerValue = redis.call('get', {writerKey}) + if writerValue == false or writerValue == {lockId} .. '{RedisWriterWaitingPrimitive.LockIdSuffix:r}' then + if redis.call('scard', {readerKey}) == 0 then + redis.call('set', {writerKey}, {lockId}, 'px', {expiryMillis}) + return 1 + end + if writerValue ~= false then + redis.call('pexpire', {writerKey}, {expiryMillis}) + end + end + return 0"); + } + + private readonly RedisKey _readerKey, _writerKey; + private readonly RedisValue _lockId; + private readonly RedLockTimeouts _timeouts; + private readonly RedisMutexPrimitive _mutexPrimitive; + + public RedisWriteLockPrimitive( + RedisKey readerKey, + RedisKey writerKey, + RedisValue lockId, + RedLockTimeouts timeouts) + { + this._readerKey = readerKey; + this._writerKey = writerKey; + this._lockId = lockId; + this._timeouts = timeouts; + this._mutexPrimitive = new RedisMutexPrimitive(this._writerKey, this._lockId, this._timeouts); + } + + public TimeoutValue AcquireTimeout => this._timeouts.AcquireTimeout; + + public void Release(IDatabase database, bool fireAndForget) => this._mutexPrimitive.Release(database, fireAndForget); + public Task ReleaseAsync(IDatabaseAsync database, bool fireAndForget) => this._mutexPrimitive.ReleaseAsync(database, fireAndForget); + + public bool TryAcquire(IDatabase database) => (bool)TryAcquireWriteScript.Execute(database, this); + public Task TryAcquireAsync(IDatabaseAsync database) => TryAcquireWriteScript.ExecuteAsync(database, this).AsBooleanTask(); + + public Task TryExtendAsync(IDatabaseAsync database) => this._mutexPrimitive.TryExtendAsync(database); + + public bool IsConnected(IDatabase database) => database.IsConnected(this._writerKey, CommandFlags.DemandMaster); +} diff --git a/src/DistributedLock.Redis/Primitives/RedisScript.cs b/src/DistributedLock.Redis/Primitives/RedisScript.cs new file mode 100644 index 00000000..983166f6 --- /dev/null +++ b/src/DistributedLock.Redis/Primitives/RedisScript.cs @@ -0,0 +1,107 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.RedLock; +using StackExchange.Redis; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace Medallion.Threading.Redis.Primitives; + +/// +/// We use this class over Redis's because that class's parameters all get mapped to ARGV[..] and we want +/// to appropriately map key parameters to KEYS[..] for compatibility with some cloud scenarios. See #254 +/// +internal readonly struct RedisScript +{ + private readonly string _script; + private readonly Func _getKeys; + private readonly Func _getValues; + + public RedisScript(RedisScriptInterpolatedString scriptTemplate) + { + (this._script, this._getKeys, this._getValues) = scriptTemplate.ToScript(); + } + + public RedisResult Execute(IDatabase database, TArg arg, bool fireAndForget = false) => + database.ScriptEvaluate(this._script, this._getKeys(arg), this._getValues(arg), flags: RedLockHelper.GetCommandFlags(fireAndForget)); + + public Task ExecuteAsync(IDatabaseAsync database, TArg arg, bool fireAndForget = false) => + database.ScriptEvaluateAsync(this._script, this._getKeys(arg), this._getValues(arg), flags: RedLockHelper.GetCommandFlags(fireAndForget)); + + public static RedisScriptInterpolatedString Fragment(RedisScriptInterpolatedString fragment) => fragment; + + [InterpolatedStringHandler] + internal readonly ref struct RedisScriptInterpolatedString(int literalLength, int formattedCount) + { + // 2 because for N holes we can have at most N + 1 literals (ignoring fragments) + private readonly List<(string Text, Delegate? Getter)> _parts = new(capacity: (2 * formattedCount) + 1); + + public void AppendLiteral(string text) => this._parts.Add((text, null)); + + public void AppendFormatted(Func key, [CallerArgumentExpression(nameof(key))] string keyString = "") => + this._parts.Add((keyString, key)); + + public void AppendFormatted(Func value, [CallerArgumentExpression(nameof(value))] string valueString = "") => + this._parts.Add((valueString, value)); + + public void AppendFormatted(Func value, [CallerArgumentExpression(nameof(value))] string valueString = "") => + this.AppendFormatted(a => (RedisValue)value(a), valueString); + + public void AppendFormatted(RedisScriptInterpolatedString fragment) => + this._parts.AddRange(fragment._parts); + + public void AppendFormatted(string text, string format) + { + Invariant.Require(format == "r"); + this.AppendLiteral(text); + } + + public (string, Func, Func) ToScript() + { + // 8 for KEYS[..] or ARGV[..] + StringBuilder builder = new(capacity: literalLength + (8 * formattedCount)); + Dictionary, int)> keys = new(capacity: formattedCount); + Dictionary, int)> values = new(capacity: formattedCount); + + foreach (var (text, getter) in this._parts) + { + if (getter is null) + { + builder.Append(text); + } + else if (getter is Func key) + { + builder.Append("KEYS[").Append(GetOneBasedIndex(keys, key, text)).Append(']'); + } + else + { + builder.Append("ARGV[").Append(GetOneBasedIndex(values, (Func)getter, text)).Append(']'); + } + } + + return ( + RemoveExtraneousWhitespace(builder.ToString()), + CreateGetter(keys), + CreateGetter(values) + ); + } + + private static int GetOneBasedIndex(Dictionary dictionary, T key, string keyString) + { + if (dictionary.TryGetValue(keyString, out var value)) { return value.Index + 1; } + + dictionary.Add(keyString, (key, dictionary.Count)); + return dictionary.Count; // implicitly index + 1 + } + + private static Func CreateGetter(Dictionary Value, int Index)> dictionary) => arg => + { + var result = new T[dictionary.Count]; + foreach (var pair in dictionary) { result[pair.Value.Index] = pair.Value.Value(arg); } + return result; + }; + + // send the smallest possible script to the server + private static string RemoveExtraneousWhitespace(string script) => Regex.Replace(script.Trim(), @"\s+", " "); + } +} diff --git a/src/DistributedLock.Redis/Primitives/RedisSemaphorePrimitive.cs b/src/DistributedLock.Redis/Primitives/RedisSemaphorePrimitive.cs new file mode 100644 index 00000000..74d5540a --- /dev/null +++ b/src/DistributedLock.Redis/Primitives/RedisSemaphorePrimitive.cs @@ -0,0 +1,108 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.RedLock; +using StackExchange.Redis; + +namespace Medallion.Threading.Redis.Primitives; + +/// +/// The semaphore algorithm looks similar to the mutex implementation except that the value stored at the key is a +/// sorted set (sorted by timeout). Because elements aren't automatically removed from the set when they time out, +/// would-be acquirers must first purge the set of any expired values before they check whether the set has space +/// for them. +/// +internal class RedisSemaphorePrimitive : IRedLockAcquirableSynchronizationPrimitive, IRedLockExtensibleSynchronizationPrimitive +{ + private static readonly RedisScript AcquireScript, ExtendScript; + + static RedisSemaphorePrimitive() + { + Func key = p => p._key; + Func lockId = p => p._lockId; + Func expiryMillis = p => p._timeouts.Expiry.InMilliseconds, + setExpiryMillis = p => p.SetExpiry.InMilliseconds, + maxCount = p => p._maxCount; + + // replicate_commands is necessary to call before calling non-deterministic functions + var getNowMillisScriptFragment = RedisScript.Fragment($@" + redis.replicate_commands() + local nowResult = redis.call('time') + local nowMillis = (tonumber(nowResult[1]) * 1000.0) + (tonumber(nowResult[2]) / 1000.0)"); + var renewSetScriptFragment = RedisScript.Fragment($@" + local keyTtl = redis.call('pttl', {key}) + if keyTtl < tonumber({setExpiryMillis}) then + redis.call('pexpire', {key}, {setExpiryMillis}) + end"); + + AcquireScript = new($@" + {getNowMillisScriptFragment} + redis.call('zremrangebyscore', {key}, '-inf', nowMillis) + if redis.call('zcard', {key}) < tonumber({maxCount}) then + redis.call('zadd', {key}, nowMillis + tonumber({expiryMillis}), {lockId}) + {renewSetScriptFragment} + return 1 + end + return 0"); + + ExtendScript = new($@" + {getNowMillisScriptFragment} + local result = redis.call('zadd', {key}, 'XX', 'CH', nowMillis + tonumber({expiryMillis}), {lockId}) + {renewSetScriptFragment} + return result"); + } + + private readonly RedisValue _lockId = RedLockHelper.CreateLockId(); + private readonly RedisKey _key; + private readonly int _maxCount; + private readonly RedLockTimeouts _timeouts; + + public RedisSemaphorePrimitive(RedisKey key, int maxCount, RedLockTimeouts timeouts) + { + this._key = key; + this._maxCount = maxCount; + this._timeouts = timeouts; + } + + public TimeoutValue AcquireTimeout => this._timeouts.AcquireTimeout; + + /// + /// The actual expiry is determined by the entry in the timeouts set. However, we also don't want to pollute the db by leaving + /// the sets around forever. Therefore, we give the sets an expiry of 3x the individual entry expiry. The reason to be extra + /// conservative with sets is that there is more disruption from losing them then from having one key time out. + /// + private TimeoutValue SetExpiry => TimeSpan.FromMilliseconds((int)Math.Min(int.MaxValue, 3L * this._timeouts.Expiry.InMilliseconds)); + + public void Release(IDatabase database, bool fireAndForget) => + database.SortedSetRemove(this._key, this._lockId, RedLockHelper.GetCommandFlags(fireAndForget)); + + public Task ReleaseAsync(IDatabaseAsync database, bool fireAndForget) => + database.SortedSetRemoveAsync(this._key, this._lockId, RedLockHelper.GetCommandFlags(fireAndForget)); + + public bool TryAcquire(IDatabase database) => (bool)AcquireScript.Execute(database, this); + + public Task TryAcquireAsync(IDatabaseAsync database) => AcquireScript.ExecuteAsync(database, this).AsBooleanTask(); + + public Task TryExtendAsync(IDatabaseAsync database) => ExtendScript.ExecuteAsync(database, this).AsBooleanTask(); + + public bool IsConnected(IDatabase database) => database.IsConnected(this._key, CommandFlags.DemandMaster); + + public async ValueTask GetAcquiredCountAsync(IDatabase database) + { + long held; + if (SyncViaAsync.IsSynchronous) + { + held = database.SortedSetLength( + this._key, + flags: CommandFlags.DemandMaster + ); + } + else + { + held = await database.SortedSetLengthAsync( + this._key, + flags: CommandFlags.DemandMaster + ).ConfigureAwait(false); + } + + return held; + } +} diff --git a/src/DistributedLock.Redis/PublicAPI.Shipped.txt b/src/DistributedLock.Redis/PublicAPI.Shipped.txt new file mode 100644 index 00000000..111506ee --- /dev/null +++ b/src/DistributedLock.Redis/PublicAPI.Shipped.txt @@ -0,0 +1,55 @@ +#nullable enable +Medallion.Threading.Redis.RedisDistributedLock +Medallion.Threading.Redis.RedisDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Redis.RedisDistributedLockHandle! +Medallion.Threading.Redis.RedisDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedLock.Key.get -> StackExchange.Redis.RedisKey +Medallion.Threading.Redis.RedisDistributedLock.Name.get -> string! +Medallion.Threading.Redis.RedisDistributedLock.RedisDistributedLock(StackExchange.Redis.RedisKey key, StackExchange.Redis.IDatabase! database, System.Action? options = null) -> void +Medallion.Threading.Redis.RedisDistributedLock.RedisDistributedLock(StackExchange.Redis.RedisKey key, System.Collections.Generic.IEnumerable! databases, System.Action? options = null) -> void +Medallion.Threading.Redis.RedisDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Redis.RedisDistributedLockHandle? +Medallion.Threading.Redis.RedisDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedLockHandle +Medallion.Threading.Redis.RedisDistributedLockHandle.Dispose() -> void +Medallion.Threading.Redis.RedisDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.Redis.RedisDistributedReaderWriterLock +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.AcquireReadLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Redis.RedisDistributedReaderWriterLockHandle! +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.AcquireReadLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.AcquireWriteLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Redis.RedisDistributedReaderWriterLockHandle! +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.AcquireWriteLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.Name.get -> string! +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.RedisDistributedReaderWriterLock(string! name, StackExchange.Redis.IDatabase! database, System.Action? options = null) -> void +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.RedisDistributedReaderWriterLock(string! name, System.Collections.Generic.IEnumerable! databases, System.Action? options = null) -> void +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.TryAcquireReadLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Redis.RedisDistributedReaderWriterLockHandle? +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.TryAcquireReadLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.TryAcquireWriteLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Redis.RedisDistributedReaderWriterLockHandle? +Medallion.Threading.Redis.RedisDistributedReaderWriterLock.TryAcquireWriteLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedReaderWriterLockHandle +Medallion.Threading.Redis.RedisDistributedReaderWriterLockHandle.Dispose() -> void +Medallion.Threading.Redis.RedisDistributedReaderWriterLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedReaderWriterLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.Redis.RedisDistributedSemaphore +Medallion.Threading.Redis.RedisDistributedSemaphore.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Redis.RedisDistributedSemaphoreHandle! +Medallion.Threading.Redis.RedisDistributedSemaphore.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedSemaphore.MaxCount.get -> int +Medallion.Threading.Redis.RedisDistributedSemaphore.Name.get -> string! +Medallion.Threading.Redis.RedisDistributedSemaphore.GetCurrentCount() -> int +Medallion.Threading.Redis.RedisDistributedSemaphore.GetCurrentCountAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedSemaphore.RedisDistributedSemaphore(StackExchange.Redis.RedisKey key, int maxCount, StackExchange.Redis.IDatabase! database, System.Action? options = null) -> void +Medallion.Threading.Redis.RedisDistributedSemaphore.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.Redis.RedisDistributedSemaphoreHandle? +Medallion.Threading.Redis.RedisDistributedSemaphore.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedSemaphoreHandle +Medallion.Threading.Redis.RedisDistributedSemaphoreHandle.Dispose() -> void +Medallion.Threading.Redis.RedisDistributedSemaphoreHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.Redis.RedisDistributedSemaphoreHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.Redis.RedisDistributedSynchronizationOptionsBuilder +Medallion.Threading.Redis.RedisDistributedSynchronizationOptionsBuilder.BusyWaitSleepTime(System.TimeSpan min, System.TimeSpan max) -> Medallion.Threading.Redis.RedisDistributedSynchronizationOptionsBuilder! +Medallion.Threading.Redis.RedisDistributedSynchronizationOptionsBuilder.Expiry(System.TimeSpan expiry) -> Medallion.Threading.Redis.RedisDistributedSynchronizationOptionsBuilder! +Medallion.Threading.Redis.RedisDistributedSynchronizationOptionsBuilder.ExtensionCadence(System.TimeSpan extensionCadence) -> Medallion.Threading.Redis.RedisDistributedSynchronizationOptionsBuilder! +Medallion.Threading.Redis.RedisDistributedSynchronizationOptionsBuilder.MinValidityTime(System.TimeSpan minValidityTime) -> Medallion.Threading.Redis.RedisDistributedSynchronizationOptionsBuilder! +Medallion.Threading.Redis.RedisDistributedSynchronizationProvider +Medallion.Threading.Redis.RedisDistributedSynchronizationProvider.CreateLock(StackExchange.Redis.RedisKey key) -> Medallion.Threading.Redis.RedisDistributedLock! +Medallion.Threading.Redis.RedisDistributedSynchronizationProvider.CreateReaderWriterLock(string! name) -> Medallion.Threading.Redis.RedisDistributedReaderWriterLock! +Medallion.Threading.Redis.RedisDistributedSynchronizationProvider.CreateSemaphore(StackExchange.Redis.RedisKey key, int maxCount) -> Medallion.Threading.Redis.RedisDistributedSemaphore! +Medallion.Threading.Redis.RedisDistributedSynchronizationProvider.RedisDistributedSynchronizationProvider(StackExchange.Redis.IDatabase! database, System.Action? options = null) -> void +Medallion.Threading.Redis.RedisDistributedSynchronizationProvider.RedisDistributedSynchronizationProvider(System.Collections.Generic.IEnumerable! databases, System.Action? options = null) -> void diff --git a/src/DistributedLock.Redis/PublicAPI.Unshipped.txt b/src/DistributedLock.Redis/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/DistributedLock.Redis/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/DistributedLock.Redis/RedLock/RedLockAcquire.cs b/src/DistributedLock.Redis/RedLock/RedLockAcquire.cs new file mode 100644 index 00000000..9357570a --- /dev/null +++ b/src/DistributedLock.Redis/RedLock/RedLockAcquire.cs @@ -0,0 +1,252 @@ +using Medallion.Threading.Internal; +using StackExchange.Redis; +using System.Diagnostics; + +namespace Medallion.Threading.Redis.RedLock; + +internal interface IRedLockAcquirableSynchronizationPrimitive : IRedLockReleasableSynchronizationPrimitive +{ + TimeoutValue AcquireTimeout { get; } + Task TryAcquireAsync(IDatabaseAsync database); + bool TryAcquire(IDatabase database); + bool IsConnected(IDatabase database); +} + +/// +/// Implements the acquire operation in the RedLock algorithm. See https://redis.io/topics/distlock +/// +internal readonly struct RedLockAcquire +{ + private readonly IRedLockAcquirableSynchronizationPrimitive _primitive; + private readonly IReadOnlyList _databases; + private readonly CancellationToken _cancellationToken; + + public RedLockAcquire( + IRedLockAcquirableSynchronizationPrimitive primitive, + IReadOnlyList databases, + CancellationToken cancellationToken) + { + this._primitive = primitive; + this._databases = databases; + this._cancellationToken = cancellationToken; + } + + public async ValueTask>?> TryAcquireAsync() + { + this._cancellationToken.ThrowIfCancellationRequested(); + + var isSynchronous = SyncViaAsync.IsSynchronous; + if (isSynchronous && this._databases.Count == 1) + { + return this.TrySingleFullySynchronousAcquire(); + } + + var primitive = this._primitive; + var tryAcquireTasks = this._databases.ToDictionary( + db => db, + db => Helpers.SafeCreateTask(state => state.primitive.TryAcquireAsync(state.db), (primitive, db)) + ); + + var waitForAcquireTask = this.WaitForAcquireAsync(tryAcquireTasks); + + var succeeded = false; + try + { + succeeded = await waitForAcquireTask.AwaitSyncOverAsync().ConfigureAwait(false); + } + finally + { + // clean up + if (!succeeded) + { + List? releaseTasks = null; + foreach (var kvp in tryAcquireTasks) + { + // if the task hasn't finished yet, we don't want to do any releasing now; just + // queue a release command to run when the task eventually completes + if (!kvp.Value.IsCompleted) + { + RedLockHelper.FireAndForgetReleaseUponCompletion(primitive, kvp.Key, kvp.Value); + } + // otherwise, unless we know we failed to acquire, do a release + else if (!RedLockHelper.ReturnedFalse(kvp.Value)) + { + if (isSynchronous) + { + try { primitive.Release(kvp.Key, fireAndForget: true); } + catch { } + } + else + { + (releaseTasks ??= []) + .Add(Helpers.SafeCreateTask(state => state.primitive.ReleaseAsync(state.Key, fireAndForget: true), (primitive, kvp.Key))); + } + } + } + + if (releaseTasks != null) + { + await Task.WhenAll(releaseTasks).ConfigureAwait(false); + } + } + } + + return succeeded ? tryAcquireTasks : null; + } + + private async Task WaitForAcquireAsync(IReadOnlyDictionary> tryAcquireTasks) + { + using var timeout = new TimeoutTask(this._primitive.AcquireTimeout, this._cancellationToken); + var incompleteTasks = new HashSet(tryAcquireTasks.Values) { timeout.Task }; + + var successCount = 0; + var failCount = 0; + var faultCount = 0; + while (true) + { + var completed = TryResolveDisconnectedDatabaseAsFaulted(this) + ?? await Task.WhenAny(incompleteTasks).ConfigureAwait(false); + + if (completed == timeout.Task) + { + await completed.ConfigureAwait(false); // propagates cancellation + return false; // true timeout + } + + if (completed.Status == TaskStatus.RanToCompletion) + { + var result = await ((Task)completed).ConfigureAwait(false); + if (result) + { + ++successCount; + if (RedLockHelper.HasSufficientSuccesses(successCount, this._databases.Count)) { return true; } + } + else + { + ++failCount; + if (RedLockHelper.HasTooManyFailuresOrFaults(failCount, this._databases.Count)) { return false; } + } + } + else // faulted or canceled + { + // if we get too many faults, the lock is not possible to acquire, so we should throw + ++faultCount; + if (RedLockHelper.HasTooManyFailuresOrFaults(faultCount, this._databases.Count)) + { + var faultingTasks = tryAcquireTasks.Values.Where(t => t.IsCanceled || t.IsFaulted) + .ToArray(); + if (faultingTasks.Length == 1) + { + await faultingTasks[0].ConfigureAwait(false); // propagate the error + } + + throw new AggregateException(faultingTasks.Select(t => t.Exception ?? new TaskCanceledException(t).As())) + .Flatten(); + } + + ++failCount; + if (RedLockHelper.HasTooManyFailuresOrFaults(failCount, this._databases.Count)) { return false; } + } + + incompleteTasks.Remove(completed); + Invariant.Require(incompleteTasks.Count > 1, "should be more than just timeout left"); + } + + // MA: this behavior is non-trivial and worth a detailed explanation. Basically, StackExchange.Redis 2.5.27 switched + // the behavior when firing commands against a disconnected database so that those commands would backlog for a period waiting for + // a reconnect rather than failing fast (fail fast is still a non-default option on ConnectionMultiplexer, but we don't want to + // require that). + // + // While this behavior generally makes sense, it creates long delays for the RedLock algorithm. For example, imagine + // a case where processes 1 and 2 are locking against servers A, B, and C when C is down. If process 1 acquires on A and process 2 acquires + // on B, then C is left casting the winning vote and for that to happen both threads must wait for the full connection timeout to observe + // the issue and declare a failed acquire. + // + // The delays caused by this are quite evident in our TestParallelism() case in the 2x1 scenario emulating a downed server: they are significant + // enough to fail the test! + // + // The fix I've implemented is to bypass the backlog via a connectivity check when it comes to the server casting the "deciding vote" in a multi-server + // scenario. That way, the case above is resolved quickly because both proceses will see that C is disconnected and fail the current acquire without + // waiting. Note that this is always a no-op in the single-server scenario. + // + // The current approach DOES NOT handle the case where the deciding vote is down to multiple servers all of which are down. We could implement this + // at the cost of additional complexity, but currently I don't see that as worthwhile since the scenario should be much less common and more problematic + // anyway. + // + // Finally, note that while this behavior could be extended to all RedLock operations (extend, release), I don't see that as valuable since only acquire is + // subject to contention in normal scenarios, so the optimization isn't worth anything elsewhere. + // + // RELEVANT LINKS: + // https://github.com/madelson/DistributedLock/pull/173#discussion_r1342236087 + // https://github.com/StackExchange/StackExchange.Redis/issues/2645 + Task? TryResolveDisconnectedDatabaseAsFaulted(RedLockAcquire @this) + { + // First, check to see if (a) we have at least 1 success/failure and (b) one more would be decisive. If not, bail. + if (!((successCount > 0 && RedLockHelper.HasSufficientSuccesses(successCount + 1, @this._databases.Count)) + || (failCount > 0 && RedLockHelper.HasTooManyFailuresOrFaults(failCount + 1, @this._databases.Count)))) + { + return null; + } + + // Iterate over the tasks to see if all outstanding tasks are disconnected. If so, pick one to resolve + Task? toResolve = null; + foreach (var kvp in tryAcquireTasks) + { + if (incompleteTasks.Contains(kvp.Value)) + { + if (kvp.Value.IsCompleted) + { + return null; + } + if (toResolve is null && !@this._primitive.IsConnected(kvp.Key)) + { + toResolve = kvp.Value; + // don't return here because if another task is completed we want that to take precedence + } + } + } + + if (toResolve is null) { return null; } + + // Remove this here because we'll be replacing it with a resolved task so the later call to + // incompleteTasks.Remove() will noop + incompleteTasks.Remove(toResolve); + return Task.FromException(new RedisException("Database is disconnected")); + } + } + + /// + /// We only allow synchronous acquire for a single db because StackExchange.Redis does not currently allow for + /// single-operation timeouts/cancellations. Therefore, one slow response would jeopardize our ability to claim the + /// lock in time. With a single db, the one operation is all that matters so it is fine if we need to wait for it. + /// + private Dictionary>? TrySingleFullySynchronousAcquire() + { + var database = this._databases.Single(); + + bool success; + var stopwatch = Stopwatch.StartNew(); + try { success = this._primitive.TryAcquire(database); } + catch + { + // on failure, still attempt a release just in case + try { this._primitive.Release(database, fireAndForget: true); } + catch { } // do nothing; we're going to throw anyway and the cause of failure is probably the same + + throw; + } + + if (success) + { + // make sure we didn't time out + if (this._primitive.AcquireTimeout.CompareTo(stopwatch.Elapsed) >= 0) + { + return new Dictionary> { [database] = Task.FromResult(success) }; + } + + this._primitive.Release(database, fireAndForget: true); // timed out, so release + } + + return null; + } +} diff --git a/src/DistributedLock.Redis/RedLock/RedLockExtend.cs b/src/DistributedLock.Redis/RedLock/RedLockExtend.cs new file mode 100644 index 00000000..f159dc47 --- /dev/null +++ b/src/DistributedLock.Redis/RedLock/RedLockExtend.cs @@ -0,0 +1,89 @@ +using Medallion.Threading.Internal; +using StackExchange.Redis; + +namespace Medallion.Threading.Redis.RedLock; + +internal interface IRedLockExtensibleSynchronizationPrimitive : IRedLockReleasableSynchronizationPrimitive +{ + TimeoutValue AcquireTimeout { get; } + Task TryExtendAsync(IDatabaseAsync database); +} + +/// +/// Implements the extend operation in the RedLock algorithm. See https://redis.io/topics/distlock +/// +internal readonly struct RedLockExtend +{ + private readonly IRedLockExtensibleSynchronizationPrimitive _primitive; + private readonly Dictionary> _tryAcquireOrRenewTasks; + private readonly CancellationToken _cancellationToken; + + public RedLockExtend( + IRedLockExtensibleSynchronizationPrimitive primitive, + Dictionary> tryAcquireOrRenewTasks, + CancellationToken cancellationToken) + { + this._primitive = primitive; + this._tryAcquireOrRenewTasks = tryAcquireOrRenewTasks; + this._cancellationToken = cancellationToken; + } + + public async Task TryExtendAsync() + { + Invariant.Require(!SyncViaAsync.IsSynchronous, "should only be called from a background renewal thread which is async"); + + var incompleteTasks = new HashSet(); + foreach (var kvp in this._tryAcquireOrRenewTasks.ToArray()) + { + if (kvp.Value.IsCompleted) + { + incompleteTasks.Add( + this._tryAcquireOrRenewTasks[kvp.Key] = Helpers.SafeCreateTask( + state => state.primitive.TryExtendAsync(state.database), + (primitive: this._primitive, database: kvp.Key) + ) + ); + } + else + { + // if the previous acquire/renew is still going, just keep waiting for that + incompleteTasks.Add(kvp.Value); + } + } + + // For extension we use the same timeout as acquire. This ensures the same min validity time which should be + // sufficient to keep extending + using var timeout = new TimeoutTask(this._primitive.AcquireTimeout, this._cancellationToken); + incompleteTasks.Add(timeout.Task); + + var databaseCount = this._tryAcquireOrRenewTasks.Count; + var successCount = 0; + var failCount = 0; + while (true) + { + var completed = await Task.WhenAny(incompleteTasks).ConfigureAwait(false); + + if (completed == timeout.Task) + { + await completed.ConfigureAwait(false); // propagate cancellation + return null; // inconclusive + } + + if (completed.Status == TaskStatus.RanToCompletion && ((Task)completed).Result) + { + ++successCount; + if (RedLockHelper.HasSufficientSuccesses(successCount, databaseCount)) { return true; } + } + else + { + // note that we treat faulted and failed the same in extend. There's no reason to throw, since + // this is just called by the extend loop. While in theory a fault could indicate some kind of post-success + // failure, most likely it means the db is unreachable and so it is safest to consider it a failure + ++failCount; + if (RedLockHelper.HasTooManyFailuresOrFaults(failCount, databaseCount)) { return false; } + } + + incompleteTasks.Remove(completed); + } + } +} diff --git a/src/DistributedLock.Redis/RedLock/RedLockHandle.cs b/src/DistributedLock.Redis/RedLock/RedLockHandle.cs new file mode 100644 index 00000000..43a99422 --- /dev/null +++ b/src/DistributedLock.Redis/RedLock/RedLockHandle.cs @@ -0,0 +1,64 @@ +using Medallion.Threading.Internal; +using StackExchange.Redis; + +namespace Medallion.Threading.Redis.RedLock; + +internal sealed class RedLockHandle : IDistributedSynchronizationHandle, LeaseMonitor.ILeaseHandle +{ + private readonly IRedLockExtensibleSynchronizationPrimitive _primitive; + private Dictionary>? _tryAcquireTasks; + private readonly TimeoutValue _extensionCadence, _expiry; + private readonly LeaseMonitor _monitor; + + public RedLockHandle( + IRedLockExtensibleSynchronizationPrimitive primitive, + Dictionary> tryAcquireTasks, + TimeoutValue extensionCadence, + TimeoutValue expiry) + { + this._primitive = primitive; + this._tryAcquireTasks = tryAcquireTasks; + this._extensionCadence = extensionCadence; + this._expiry = expiry; + // important to set this last, since the monitor constructor will read other fields of this + this._monitor = new LeaseMonitor(this); + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => this._monitor.HandleLostToken; + + /// + /// Releases the lock + /// + public void Dispose() => this.DisposeSyncViaAsync(); + + /// + /// Releases the lock asynchronously + /// + public async ValueTask DisposeAsync() + { + await this._monitor.DisposeAsync().ConfigureAwait(false); + var tryAcquireTasks = Interlocked.Exchange(ref this._tryAcquireTasks, null); + if (tryAcquireTasks != null) + { + await new RedLockRelease(this._primitive, tryAcquireTasks).ReleaseAsync().ConfigureAwait(false); + } + } + + TimeoutValue LeaseMonitor.ILeaseHandle.LeaseDuration => this._expiry; + + TimeoutValue LeaseMonitor.ILeaseHandle.MonitoringCadence => this._extensionCadence; + + async Task LeaseMonitor.ILeaseHandle.RenewOrValidateLeaseAsync(CancellationToken cancellationToken) + { + var extendResult = await new RedLockExtend(this._primitive, this._tryAcquireTasks!, cancellationToken).TryExtendAsync().ConfigureAwait(false); + return extendResult switch + { + null => LeaseMonitor.LeaseState.Unknown, + false => LeaseMonitor.LeaseState.Lost, + true => LeaseMonitor.LeaseState.Renewed, + }; + } +} diff --git a/src/DistributedLock.Redis/RedLock/RedLockHelper.cs b/src/DistributedLock.Redis/RedLock/RedLockHelper.cs new file mode 100644 index 00000000..5b819e58 --- /dev/null +++ b/src/DistributedLock.Redis/RedLock/RedLockHelper.cs @@ -0,0 +1,64 @@ +using Medallion.Threading.Internal; +using StackExchange.Redis; +using System.Diagnostics; + +namespace Medallion.Threading.Redis.RedLock; + +internal static class RedLockHelper +{ + private static readonly string LockIdPrefix; + + static RedLockHelper() + { + using var currentProcess = Process.GetCurrentProcess(); + LockIdPrefix = $"{Environment.MachineName}_{currentProcess.Id}_"; + } + + public static bool HasSufficientSuccesses(int successCount, int databaseCount) + { + // a majority is required + var threshold = (databaseCount / 2) + 1; + // While in theory this should return true if we have more than enough, we never expect this to be + // called except with just enough or not enough due to how we've implemented our approaches. + Invariant.Require(successCount <= threshold); + return successCount >= threshold; + } + + public static bool HasTooManyFailuresOrFaults(int failureOrFaultCount, int databaseCount) + { + // For an odd number of databases, we need a majority to make success impossible. For an + // even number, however, getting to 50% failures/faults is sufficient to rule out getting + // a majority of successes. + var threshold = (databaseCount / 2) + (databaseCount % 2); + // While in theory this should return true if we have more than enough, we never expect this to be + // called except with just enough or not enough due to how we've implemented our approaches. + Invariant.Require(failureOrFaultCount <= threshold); + return failureOrFaultCount >= threshold; + } + + public static RedisValue CreateLockId() => LockIdPrefix + Guid.NewGuid().ToString("n"); + + public static bool ReturnedFalse(Task task) => task.Status == TaskStatus.RanToCompletion && !task.Result; + + public static void FireAndForgetReleaseUponCompletion(IRedLockReleasableSynchronizationPrimitive primitive, IDatabase database, Task acquireOrRenewTask) + { + if (ReturnedFalse(acquireOrRenewTask)) { return; } + + acquireOrRenewTask.ContinueWith(static async (t, state) => + { + // don't clean up if we know we failed + if (!ReturnedFalse(t)) + { + var (primitive, database) = (Tuple)state; + await primitive.ReleaseAsync(database, fireAndForget: true).ConfigureAwait(false); + } + }, + state: Tuple.Create(primitive, database) + ); + } + + public static CommandFlags GetCommandFlags(bool fireAndForget) => + CommandFlags.DemandMaster | (fireAndForget ? CommandFlags.FireAndForget : CommandFlags.None); + + public static async Task AsBooleanTask(this Task redisResultTask) => (bool)await redisResultTask.ConfigureAwait(false); +} diff --git a/src/DistributedLock.Redis/RedLock/RedLockRelease.cs b/src/DistributedLock.Redis/RedLock/RedLockRelease.cs new file mode 100644 index 00000000..ce3e383e --- /dev/null +++ b/src/DistributedLock.Redis/RedLock/RedLockRelease.cs @@ -0,0 +1,90 @@ +using Medallion.Threading.Internal; +using StackExchange.Redis; + +namespace Medallion.Threading.Redis.RedLock; + +internal interface IRedLockReleasableSynchronizationPrimitive +{ + Task ReleaseAsync(IDatabaseAsync database, bool fireAndForget); + void Release(IDatabase database, bool fireAndForget); +} + +/// +/// Implements the release operation in the RedLock algorithm. See https://redis.io/topics/distlock +/// +internal readonly struct RedLockRelease( + IRedLockReleasableSynchronizationPrimitive primitive, + IReadOnlyDictionary> tryAcquireOrRenewTasks) +{ + public async ValueTask ReleaseAsync() + { + var isSynchronous = SyncViaAsync.IsSynchronous; + var unreleasedTryAcquireOrRenewTasks = tryAcquireOrRenewTasks.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + List? releaseExceptions = null; + var successCount = 0; + var faultCount = 0; + var databaseCount = unreleasedTryAcquireOrRenewTasks.Count; + + try + { + while (true) + { + var releaseableDatabases = unreleasedTryAcquireOrRenewTasks.Where(kvp => kvp.Value.IsCompleted) + // work through completed tasks first + .OrderByDescending(kvp => kvp.Value.IsCompleted) + // among those prioritize successful completions since faults are likely to be slow to process + .ThenByDescending(kvp => kvp.Value.Status == TaskStatus.RanToCompletion) + // among those prioritize failed (not faulted) acquisitions since those require no work to release + .ThenByDescending(kvp => RedLockHelper.ReturnedFalse(kvp.Value)) + .Select(kvp => kvp.Key) + .ToArray(); + foreach (var db in releaseableDatabases) + { + var tryAcquireOrRenewTask = unreleasedTryAcquireOrRenewTasks[db]; + unreleasedTryAcquireOrRenewTasks.Remove(db); + + if (RedLockHelper.ReturnedFalse(tryAcquireOrRenewTask)) + { + // if we failed to acquire, we don't need to release + ++successCount; + } + else + { + try + { + if (isSynchronous) { primitive.Release(db, fireAndForget: false); } + else { await primitive.ReleaseAsync(db, fireAndForget: false).ConfigureAwait(false); } + ++successCount; + } + catch (Exception ex) + { + (releaseExceptions ??= []).Add(ex); + ++faultCount; + if (RedLockHelper.HasTooManyFailuresOrFaults(faultCount, databaseCount)) + { + throw new AggregateException(releaseExceptions!).Flatten(); + } + } + } + + if (RedLockHelper.HasSufficientSuccesses(successCount, databaseCount)) + { + return; + } + } + + // if we haven't released enough yet to be done or certain of success or failure, wait for another to finish + if (isSynchronous) { Task.WaitAny(unreleasedTryAcquireOrRenewTasks.Values.ToArray()); } + else { await Task.WhenAny(unreleasedTryAcquireOrRenewTasks.Values).ConfigureAwait(false); } + } + } + finally // fire and forget the rest + { + foreach (var kvp in unreleasedTryAcquireOrRenewTasks) + { + RedLockHelper.FireAndForgetReleaseUponCompletion(primitive, kvp.Key, kvp.Value); + } + } + } +} diff --git a/src/DistributedLock.Redis/RedLock/RedLockTimeouts.cs b/src/DistributedLock.Redis/RedLock/RedLockTimeouts.cs new file mode 100644 index 00000000..fa33f75f --- /dev/null +++ b/src/DistributedLock.Redis/RedLock/RedLockTimeouts.cs @@ -0,0 +1,18 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Redis.RedLock; + +internal readonly struct RedLockTimeouts +{ + public RedLockTimeouts( + TimeoutValue expiry, + TimeoutValue minValidityTime) + { + this.Expiry = expiry; + this.MinValidityTime = minValidityTime; + } + + public TimeoutValue Expiry { get; } + public TimeoutValue MinValidityTime { get; } + public TimeoutValue AcquireTimeout => this.Expiry.TimeSpan - this.MinValidityTime.TimeSpan; +} diff --git a/src/DistributedLock.Redis/RedisDistributedLock.IDistributedLock.cs b/src/DistributedLock.Redis/RedisDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..e0e1465f --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedLock.IDistributedLock.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Redis; + +public partial class RedisDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public RedisDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public RedisDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.Redis/RedisDistributedLock.cs b/src/DistributedLock.Redis/RedisDistributedLock.cs new file mode 100644 index 00000000..5a9cea84 --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedLock.cs @@ -0,0 +1,72 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.Primitives; +using Medallion.Threading.Redis.RedLock; +using StackExchange.Redis; + +namespace Medallion.Threading.Redis; + +/// +/// Implements a using Redis. Can leverage multiple servers via the RedLock algorithm. +/// +public sealed partial class RedisDistributedLock : IInternalDistributedLock +{ + private readonly IReadOnlyList _databases; + private readonly RedisDistributedLockOptions _options; + + /// + /// Constructs a lock named using the provided and . + /// + public RedisDistributedLock(RedisKey key, IDatabase database, Action? options = null) + : this(key, new[] { database ?? throw new ArgumentNullException(nameof(database)) }, options) + { + } + + /// + /// Constructs a lock named using the provided and . + /// + public RedisDistributedLock(RedisKey key, IEnumerable databases, Action? options = null) + { + if (key == default(RedisKey)) { throw new ArgumentNullException(nameof(key)); } + this._databases = ValidateDatabases(databases); + + this.Key = key; + this._options = RedisDistributedSynchronizationOptionsBuilder.GetOptions(options); + } + + internal static IReadOnlyList ValidateDatabases(IEnumerable databases) + { + var databasesArray = databases?.ToArray() ?? throw new ArgumentNullException(nameof(databases)); + if (databasesArray.Length == 0) { throw new ArgumentException("may not be empty", nameof(databases)); } + if (databasesArray.Contains(null!)) { throw new ArgumentNullException(nameof(databases), "may not contain null"); } + return databasesArray; + } + + /// + /// The Redis key used to implement the lock + /// + public RedisKey Key { get; } + + /// + /// Implements + /// + public string Name => this.Key.ToString(); + + ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + BusyWaitHelper.WaitAsync( + state: this, + tryGetValue: (@this, cancellationToken) => @this.TryAcquireAsync(cancellationToken), + timeout: timeout, + minSleepTime: this._options.MinBusyWaitSleepTime, + maxSleepTime: this._options.MaxBusyWaitSleepTime, + cancellationToken: cancellationToken + ); + + private async ValueTask TryAcquireAsync(CancellationToken cancellationToken) + { + var primitive = new RedisMutexPrimitive(this.Key, RedLockHelper.CreateLockId(), this._options.RedLockTimeouts); + var tryAcquireTasks = await new RedLockAcquire(primitive, this._databases, cancellationToken).TryAcquireAsync().ConfigureAwait(false); + return tryAcquireTasks != null + ? new RedisDistributedLockHandle(new RedLockHandle(primitive, tryAcquireTasks, extensionCadence: this._options.ExtensionCadence, expiry: this._options.RedLockTimeouts.Expiry)) + : null; + } +} diff --git a/src/DistributedLock.Redis/RedisDistributedLockHandle.cs b/src/DistributedLock.Redis/RedisDistributedLockHandle.cs new file mode 100644 index 00000000..e299e66a --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedLockHandle.cs @@ -0,0 +1,33 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.RedLock; + +namespace Medallion.Threading.Redis; + +/// +/// Implements for +/// +public sealed class RedisDistributedLockHandle : IDistributedSynchronizationHandle +{ + private RedLockHandle? _innerHandle; + + internal RedisDistributedLockHandle(RedLockHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => Volatile.Read(ref this._innerHandle)?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the lock asynchronously + /// + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} diff --git a/DistributedLock.Core/IDistributedReaderWriterLock.cs b/src/DistributedLock.Redis/RedisDistributedReaderWriterLock.IDistributedReaderWriterLock.cs similarity index 51% rename from DistributedLock.Core/IDistributedReaderWriterLock.cs rename to src/DistributedLock.Redis/RedisDistributedReaderWriterLock.IDistributedReaderWriterLock.cs index 06e2309a..82699e5e 100644 --- a/DistributedLock.Core/IDistributedReaderWriterLock.cs +++ b/src/DistributedLock.Redis/RedisDistributedReaderWriterLock.IDistributedReaderWriterLock.cs @@ -1,25 +1,27 @@ -using System; -using System.Threading; -using System.Threading.Tasks; +using Medallion.Threading.Internal; -namespace Medallion.Threading +namespace Medallion.Threading.Redis; + +public partial class RedisDistributedReaderWriterLock { - /// - /// Provides distributed locking functionality comparable to - /// - public interface IDistributedReaderWriterLock - { - /// - /// A name that uniquely identifies the lock - /// - string Name { get; } + // AUTO-GENERATED - // todo remove - /// - /// Whether the lock can be acquired multiple times by the same user. - /// Equivalent to - /// - bool IsReentrant { get; } + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireReadLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireReadLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireReadLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireWriteLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireWriteLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireWriteLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireWriteLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireWriteLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireWriteLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); /// /// Attempts to acquire a READ lock synchronously. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: @@ -33,8 +35,9 @@ public interface IDistributedReaderWriterLock /// /// How long to wait before giving up on the acquisition attempt. Defaults to 0 /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - IDistributedLockHandle? TryAcquireReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); + /// A which can be used to release the lock or null on failure + public RedisDistributedReaderWriterLockHandle? TryAcquireReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken, isWrite: false); /// /// Acquires a READ lock synchronously, failing with if the attempt times out. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: @@ -48,8 +51,9 @@ public interface IDistributedReaderWriterLock /// /// How long to wait before giving up on the acquisition attempt. Defaults to /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - IDistributedLockHandle AcquireReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + /// A which can be used to release the lock + public RedisDistributedReaderWriterLockHandle AcquireReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken, isWrite: false); /// /// Attempts to acquire a READ lock asynchronously. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: @@ -63,8 +67,9 @@ public interface IDistributedReaderWriterLock /// /// How long to wait before giving up on the acquisition attempt. Defaults to 0 /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - ValueTask TryAcquireReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken, isWrite: false); /// /// Acquires a READ lock asynchronously, failing with if the attempt times out. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: @@ -78,8 +83,9 @@ public interface IDistributedReaderWriterLock /// /// How long to wait before giving up on the acquisition attempt. Defaults to /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - ValueTask AcquireReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + /// A which can be used to release the lock + public ValueTask AcquireReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken, isWrite: false); /// /// Attempts to acquire a WRITE lock synchronously. Not compatible with another WRITE lock or an UPGRADE lock. Usage: @@ -93,8 +99,9 @@ public interface IDistributedReaderWriterLock /// /// How long to wait before giving up on the acquisition attempt. Defaults to 0 /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - IDistributedLockHandle? TryAcquireWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default); + /// A which can be used to release the lock or null on failure + public RedisDistributedReaderWriterLockHandle? TryAcquireWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken, isWrite: true); /// /// Acquires a WRITE lock synchronously, failing with if the attempt times out. Not compatible with another WRITE lock or an UPGRADE lock. Usage: @@ -108,8 +115,9 @@ public interface IDistributedReaderWriterLock /// /// How long to wait before giving up on the acquisition attempt. Defaults to /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - IDistributedLockHandle AcquireWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + /// A which can be used to release the lock + public RedisDistributedReaderWriterLockHandle AcquireWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken, isWrite: true); /// /// Attempts to acquire a WRITE lock asynchronously. Not compatible with another WRITE lock or an UPGRADE lock. Usage: @@ -123,8 +131,9 @@ public interface IDistributedReaderWriterLock /// /// How long to wait before giving up on the acquisition attempt. Defaults to 0 /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock or null on failure - ValueTask TryAcquireWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default); + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken, isWrite: true); /// /// Acquires a WRITE lock asynchronously, failing with if the attempt times out. Not compatible with another WRITE lock or an UPGRADE lock. Usage: @@ -138,7 +147,8 @@ public interface IDistributedReaderWriterLock /// /// How long to wait before giving up on the acquisition attempt. Defaults to /// Specifies a token by which the wait can be canceled - /// An which can be used to release the lock - ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); - } -} + /// A which can be used to release the lock + public ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken, isWrite: true); + +} \ No newline at end of file diff --git a/src/DistributedLock.Redis/RedisDistributedReaderWriterLock.cs b/src/DistributedLock.Redis/RedisDistributedReaderWriterLock.cs new file mode 100644 index 00000000..1c0b5577 --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedReaderWriterLock.cs @@ -0,0 +1,152 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.Primitives; +using Medallion.Threading.Redis.RedLock; +using StackExchange.Redis; + +namespace Medallion.Threading.Redis; + +/// +/// Implements a using Redis. Can leverage multiple servers via the RedLock algorithm. +/// +public sealed partial class RedisDistributedReaderWriterLock : IInternalDistributedReaderWriterLock +{ + private readonly IReadOnlyList _databases; + private readonly RedisDistributedLockOptions _options; + + /// + /// Constructs a lock named using the provided and . + /// + public RedisDistributedReaderWriterLock(string name, IDatabase database, Action? options = null) + : this(name, new[] { database ?? throw new ArgumentNullException(nameof(database)) }, options) + { + } + + /// + /// Constructs a lock named using the provided and . + /// + public RedisDistributedReaderWriterLock(string name, IEnumerable databases, Action? options = null) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + this._databases = RedisDistributedLock.ValidateDatabases(databases); + + this.ReaderKey = name + ".readers"; + this.WriterKey = name + ".writer"; + this.Name = name; + this._options = RedisDistributedSynchronizationOptionsBuilder.GetOptions(options); + + // We insist on this rule to ensure that when we take the writer waiting lock it won't expire between attempts + // to upgrade it to the write lock. This avoids the need to extend the writer waiting lock + if (this._options.RedLockTimeouts.MinValidityTime.CompareTo(this._options.MaxBusyWaitSleepTime) <= 0) + { + throw new ArgumentException($"{nameof(RedisDistributedSynchronizationOptionsBuilder.BusyWaitSleepTime)} must be <= {nameof(RedisDistributedSynchronizationOptionsBuilder.MinValidityTime)}", nameof(options)); + } + } + + internal RedisKey ReaderKey { get; } + internal RedisKey WriterKey { get; } + + /// + /// Implements + /// + public string Name { get; } + + ValueTask IInternalDistributedReaderWriterLock.InternalTryAcquireAsync( + TimeoutValue timeout, + CancellationToken cancellationToken, + bool isWrite) + { + return isWrite + ? this.TryAcquireWriteLockAsync(timeout, cancellationToken) + : BusyWaitHelper.WaitAsync( + this, + (@lock, cancellationToken) => @lock.TryAcquireAsync(new RedisReadLockPrimitive(@lock.ReaderKey, @lock.WriterKey, @lock._options.RedLockTimeouts), cancellationToken), + timeout: timeout, + minSleepTime: this._options.MinBusyWaitSleepTime, + maxSleepTime: this._options.MaxBusyWaitSleepTime, + cancellationToken + ); + } + + private async ValueTask TryAcquireWriteLockAsync(TimeoutValue timeout, CancellationToken cancellationToken) + { + var acquireWriteLockState = new AcquireWriteLockState(canRetry: !timeout.IsZero); + RedisDistributedReaderWriterLockHandle? handle = null; + try + { + return handle = await BusyWaitHelper.WaitAsync( + (Lock: this, State: acquireWriteLockState), + (state, cancellationToken) => state.Lock.TryAcquireWriteLockAsync(state.State, cancellationToken), + timeout: timeout, + minSleepTime: this._options.MinBusyWaitSleepTime, + maxSleepTime: this._options.MaxBusyWaitSleepTime, + cancellationToken + ).ConfigureAwait(false); + } + finally + { + // If we failed to take the write lock but we took the writer waiting lock, release + // the writer waiting lock on our way out. + if (handle == null && acquireWriteLockState.WriterWaiting is { } writerWaiting) + { + await new RedLockRelease(writerWaiting.Primitive, writerWaiting.TryAcquireTasks).ReleaseAsync().ConfigureAwait(false); + } + } + } + + private async ValueTask TryAcquireWriteLockAsync(AcquireWriteLockState state, CancellationToken cancellationToken) + { + // The first time, through, just try to acquire the write lock. This covers the TryAcquire(0) case and ensures that we + // don't bother with taking the writer waiting lock if we don't need to. + if (state.IsFirstTry) + { + state.IsFirstTry = false; + var firstTryResult = await TryAcquireWriteLockAsync(RedLockHelper.CreateLockId()).ConfigureAwait(false); + if (firstTryResult != null) { return firstTryResult; } + // if we're not going to retry the acquire, don't bother attempting the writer waiting lock + if (!state.CanRetry) { return null; } + } + + Invariant.Require(state.CanRetry); + + // Otherwise, if we don't have the writer waiting lock yet, try to take that + if (!state.WriterWaiting.HasValue) + { + var lockId = RedLockHelper.CreateLockId(); + var primitive = new RedisWriterWaitingPrimitive(this.WriterKey, lockId, this._options.RedLockTimeouts); + var tryAcquireTasks = await new RedLockAcquire(primitive, this._databases, cancellationToken).TryAcquireAsync().ConfigureAwait(false); + if (tryAcquireTasks == null) { return null; } + + // if we took writer waiting, save off the info and just keep going + state.WriterWaiting = (primitive, tryAcquireTasks, lockId); + } + + // If we get here, we have the writer waiting lock. Try to "upgrade" that to an actual writer lock + return await TryAcquireWriteLockAsync(state.WriterWaiting.Value.LockId).ConfigureAwait(false); + + ValueTask TryAcquireWriteLockAsync(RedisValue lockId) => + this.TryAcquireAsync(new RedisWriteLockPrimitive(this.ReaderKey, this.WriterKey, lockId, this._options.RedLockTimeouts), cancellationToken); + } + + private async ValueTask TryAcquireAsync(TPrimitive primitive, CancellationToken cancellationToken) + where TPrimitive : IRedLockAcquirableSynchronizationPrimitive, IRedLockExtensibleSynchronizationPrimitive + { + var tryAcquireTasks = await new RedLockAcquire(primitive, this._databases, cancellationToken).TryAcquireAsync().ConfigureAwait(false); + return tryAcquireTasks != null + ? new RedisDistributedReaderWriterLockHandle(new RedLockHandle(primitive, tryAcquireTasks, extensionCadence: this._options.ExtensionCadence, expiry: this._options.RedLockTimeouts.Expiry)) + : null; + } + + private class AcquireWriteLockState + { + public AcquireWriteLockState(bool canRetry) + { + this.CanRetry = canRetry; + } + + public bool CanRetry { get; } + + public bool IsFirstTry { get; set; } = true; + + public (RedisWriterWaitingPrimitive Primitive, IReadOnlyDictionary> TryAcquireTasks, RedisValue LockId)? WriterWaiting { get; set; } + } +} diff --git a/src/DistributedLock.Redis/RedisDistributedReaderWriterLockHandle.cs b/src/DistributedLock.Redis/RedisDistributedReaderWriterLockHandle.cs new file mode 100644 index 00000000..3ac6effb --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedReaderWriterLockHandle.cs @@ -0,0 +1,33 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.RedLock; + +namespace Medallion.Threading.Redis; + +/// +/// Implements for +/// +public sealed class RedisDistributedReaderWriterLockHandle : IDistributedSynchronizationHandle +{ + private RedLockHandle? _innerHandle; + + internal RedisDistributedReaderWriterLockHandle(RedLockHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => Volatile.Read(ref this._innerHandle)?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the lock asynchronously + /// + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} diff --git a/src/DistributedLock.Redis/RedisDistributedSemaphore.IDistributedSemaphore.cs b/src/DistributedLock.Redis/RedisDistributedSemaphore.IDistributedSemaphore.cs new file mode 100644 index 00000000..533d7769 --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedSemaphore.IDistributedSemaphore.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Redis; + +public partial class RedisDistributedSemaphore +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedSemaphore.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedSemaphore.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedSemaphore.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedSemaphore.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire a semaphore ticket synchronously. Usage: + /// + /// using (var handle = mySemaphore.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the ticket! */ } + /// } + /// // dispose releases the ticket if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket or null on failure + public RedisDistributedSemaphoreHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires a semaphore ticket synchronously, failing with if the attempt times out. Usage: + /// + /// using (mySemaphore.Acquire(...)) + /// { + /// /* we have the ticket! */ + /// } + /// // dispose releases the ticket + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket + public RedisDistributedSemaphoreHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire a semaphore ticket asynchronously. Usage: + /// + /// await using (var handle = await mySemaphore.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the ticket! */ } + /// } + /// // dispose releases the ticket if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires a semaphore ticket asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await mySemaphore.AcquireAsync(...)) + /// { + /// /* we have the ticket! */ + /// } + /// // dispose releases the ticket + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.Redis/RedisDistributedSemaphore.cs b/src/DistributedLock.Redis/RedisDistributedSemaphore.cs new file mode 100644 index 00000000..5596dbb4 --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedSemaphore.cs @@ -0,0 +1,83 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.Primitives; +using Medallion.Threading.Redis.RedLock; +using StackExchange.Redis; + +namespace Medallion.Threading.Redis; + +/// +/// Implements a using Redis. +/// +public sealed partial class RedisDistributedSemaphore : IInternalDistributedSemaphore +{ + /// + /// Note: while we store this as a list to simplify the interactions with the RedLock components, in fact the semaphore + /// algorithm only works with a single database. With multiple databases, we risk violating our . + /// For example, with 3 dbs and 2 tickets, we can have 3 users acquiring AB, BC, and AC. Each database sees 2 tickets taken! + /// + private readonly IReadOnlyList _databases; + private readonly RedisDistributedLockOptions _options; + + /// + /// Constructs a semaphore named using the provided , , and . + /// + public RedisDistributedSemaphore(RedisKey key, int maxCount, IDatabase database, Action? options = null) + { + if (key == default(RedisKey)) { throw new ArgumentNullException(nameof(key)); } + if (maxCount < 1) { throw new ArgumentOutOfRangeException(nameof(maxCount), maxCount, "must be positive"); } + this._databases = new[] { database ?? throw new ArgumentNullException(nameof(database)) }; + + this.Key = key; + this.MaxCount = maxCount; + this._options = RedisDistributedSynchronizationOptionsBuilder.GetOptions(options); + } + + internal RedisKey Key { get; } + + /// + /// Implements + /// + public string Name => this.Key.ToString(); + + /// + /// Gets the current available count. Comparable to + /// + public int GetCurrentCount() => SyncViaAsync.Run(s => s.GetCurrentCountAsync(), state: this); + + /// + /// Asynchronously gets the current available count. Comparable to + /// + public async ValueTask GetCurrentCountAsync() + { + var database = this._databases[0]; + var primitive = new RedisSemaphorePrimitive(this.Key, this.MaxCount, this._options.RedLockTimeouts); + var acquiredCount = await primitive.GetAcquiredCountAsync(database).ConfigureAwait(false); + + var available = this.MaxCount - acquiredCount; + return (int)Math.Max(0, available); + } + + /// + /// Implements + /// + public int MaxCount { get; } + + ValueTask IInternalDistributedSemaphore.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + BusyWaitHelper.WaitAsync( + state: this, + tryGetValue: (@this, cancellationToken) => @this.TryAcquireAsync(cancellationToken), + timeout: timeout, + minSleepTime: this._options.MinBusyWaitSleepTime, + maxSleepTime: this._options.MaxBusyWaitSleepTime, + cancellationToken: cancellationToken + ); + + private async ValueTask TryAcquireAsync(CancellationToken cancellationToken) + { + var primitive = new RedisSemaphorePrimitive(this.Key, this.MaxCount, this._options.RedLockTimeouts); + var tryAcquireTasks = await new RedLockAcquire(primitive, this._databases, cancellationToken).TryAcquireAsync().ConfigureAwait(false); + return tryAcquireTasks != null + ? new RedisDistributedSemaphoreHandle(new RedLockHandle(primitive, tryAcquireTasks, extensionCadence: this._options.ExtensionCadence, expiry: this._options.RedLockTimeouts.Expiry)) + : null; + } +} diff --git a/src/DistributedLock.Redis/RedisDistributedSemaphoreHandle.cs b/src/DistributedLock.Redis/RedisDistributedSemaphoreHandle.cs new file mode 100644 index 00000000..727cfcf0 --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedSemaphoreHandle.cs @@ -0,0 +1,33 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.RedLock; + +namespace Medallion.Threading.Redis; + +/// +/// Implements for a +/// +public sealed class RedisDistributedSemaphoreHandle : IDistributedSynchronizationHandle +{ + private RedLockHandle? _innerHandle; + + internal RedisDistributedSemaphoreHandle(RedLockHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => Volatile.Read(ref this._innerHandle)?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the lock asynchronously + /// + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} diff --git a/src/DistributedLock.Redis/RedisDistributedSynchronizationOptionsBuilder.cs b/src/DistributedLock.Redis/RedisDistributedSynchronizationOptionsBuilder.cs new file mode 100644 index 00000000..77e4be22 --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedSynchronizationOptionsBuilder.cs @@ -0,0 +1,187 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis.RedLock; + +namespace Medallion.Threading.Redis; + +/// +/// Options for configuring a redis-based distributed synchronization algorithm +/// +public sealed class RedisDistributedSynchronizationOptionsBuilder +{ + internal static readonly TimeoutValue DefaultExpiry = TimeSpan.FromSeconds(30); + /// + /// We don't want to allow expiry to go too low, since then the lock doesn't even work (and the default + /// min observed expiry will end up greater than the default expiry) + /// + internal static readonly TimeoutValue MinimumExpiry = TimeSpan.FromSeconds(.1); + + private TimeoutValue? _expiry, + _extensionCadence, + _minValidityTime, + _minBusyWaitSleepTime, + _maxBusyWaitSleepTime; + + internal RedisDistributedSynchronizationOptionsBuilder() { } + + /// + /// Specifies how long the lock will last, absent auto-extension. Because auto-extension exists, + /// this value generally will have little effect on program behavior. However, making the expiry longer means that + /// auto-extension requests can occur less frequently, saving resources. On the other hand, when a lock is abandoned + /// without explicit release (e. g. if the holding process crashes), the expiry determines how long other processes + /// would need to wait in order to acquire it. + /// + /// Defaults to 30s. + /// + public RedisDistributedSynchronizationOptionsBuilder Expiry(TimeSpan expiry) + { + var expiryTimeoutValue = new TimeoutValue(expiry, nameof(expiry)); + if (expiryTimeoutValue.IsInfinite || expiryTimeoutValue.CompareTo(MinimumExpiry) < 0) + { + throw new ArgumentOutOfRangeException(nameof(expiry), expiry, $"Must be >= {MinimumExpiry.TimeSpan} and < ∞"); + } + this._expiry = expiryTimeoutValue; + return this; + } + + /// + /// Determines how frequently the lock will be extended while held. More frequent extension means more unnecessary requests + /// but also a lower chance of losing the lock due to the process hanging or otherwise failing to get its extension request in + /// before the lock expiry elapses. + /// + /// Defaults to 1/3 of the specified . + /// + public RedisDistributedSynchronizationOptionsBuilder ExtensionCadence(TimeSpan extensionCadence) + { + this._extensionCadence = new TimeoutValue(extensionCadence, nameof(extensionCadence)); + return this; + } + + /// + /// The lock expiry determines how long the lock will be held without being extended. However, since it takes some amount + /// of time to acquire the lock, we will not have all of expiry available upon acquisition. + /// + /// This value sets a minimum amount which we'll be guaranteed to have left once acquisition completes. + /// + /// Defaults to 90% of the specified lock expiry. + /// + public RedisDistributedSynchronizationOptionsBuilder MinValidityTime(TimeSpan minValidityTime) + { + var minValidityTimeoutValue = new TimeoutValue(minValidityTime, nameof(minValidityTime)); + if (minValidityTimeoutValue.IsZero) + { + throw new ArgumentOutOfRangeException(nameof(minValidityTime), minValidityTime, "may not be zero"); + } + this._minValidityTime = minValidityTimeoutValue; + return this; + } + + /// + /// Waiting to acquire a lock requires a busy wait that alternates acquire attempts and sleeps. + /// This determines how much time is spent sleeping between attempts. Lower values will raise the + /// volume of acquire requests under contention but will also raise the responsiveness (how long + /// it takes a waiter to notice that a contended the lock has become available). + /// + /// Specifying a range of values allows the implementation to select an actual value in the range + /// at random for each sleep. This helps avoid the case where two clients become "synchronized" + /// in such a way that results in one client monopolizing the lock. + /// + /// The default is [10ms, 800ms] + /// + public RedisDistributedSynchronizationOptionsBuilder BusyWaitSleepTime(TimeSpan min, TimeSpan max) + { + var minTimeoutValue = new TimeoutValue(min, nameof(min)); + var maxTimeoutValue = new TimeoutValue(max, nameof(max)); + + if (minTimeoutValue.IsInfinite) { throw new ArgumentOutOfRangeException(nameof(min), "may not be infinite"); } + if (maxTimeoutValue.IsInfinite || maxTimeoutValue.CompareTo(min) < 0) + { + throw new ArgumentOutOfRangeException(nameof(max), max, "must be non-infinite and greater than " + nameof(min)); + } + + this._minBusyWaitSleepTime = minTimeoutValue; + this._maxBusyWaitSleepTime = maxTimeoutValue; + return this; + } + + internal static RedisDistributedLockOptions GetOptions(Action? optionsBuilder) + { + RedisDistributedSynchronizationOptionsBuilder? options; + if (optionsBuilder != null) + { + options = new RedisDistributedSynchronizationOptionsBuilder(); + optionsBuilder(options); + } + else + { + options = null; + } + + var expiry = options?._expiry ?? DefaultExpiry; + + TimeoutValue minValidityTime; + if (options?._minValidityTime is { } specifiedMinValidityTime) + { + if (specifiedMinValidityTime.CompareTo(expiry) >= 0) + { + throw new ArgumentOutOfRangeException( + nameof(minValidityTime), + specifiedMinValidityTime.TimeSpan, + $"{nameof(minValidityTime)} must be less than {nameof(expiry)} ({expiry.TimeSpan})" + ); + } + minValidityTime = specifiedMinValidityTime; + } + else + { + minValidityTime = TimeSpan.FromMilliseconds(Math.Max(0.9 * expiry.InMilliseconds, 1)); + } + + TimeoutValue extensionCadence; + if (options?._extensionCadence is { } specifiedExtensionCadence) + { + // Note: we do not allow for disabling auto-extension here because it leads to traps + // where people might abandon the handle and then have it be closed due to GC. + // See discussion here: https://github.com/madelson/DistributedLock/issues/130. + if (specifiedExtensionCadence.CompareTo(minValidityTime) >= 0) + { + throw new ArgumentOutOfRangeException( + nameof(extensionCadence), + specifiedExtensionCadence.TimeSpan, + $"{nameof(extensionCadence)} must be less than {nameof(expiry)} ({expiry.TimeSpan})" + ); + } + extensionCadence = specifiedExtensionCadence; + } + else + { + extensionCadence = TimeSpan.FromMilliseconds(minValidityTime.InMilliseconds / 3.0); + } + + return new RedisDistributedLockOptions( + redLockTimeouts: new RedLockTimeouts(expiry: expiry, minValidityTime: minValidityTime), + extensionCadence: extensionCadence, + minBusyWaitSleepTime: options?._minBusyWaitSleepTime ?? TimeSpan.FromMilliseconds(10), + maxBusyWaitSleepTime: options?._maxBusyWaitSleepTime ?? TimeSpan.FromSeconds(0.8) + ); + } +} + +internal readonly struct RedisDistributedLockOptions +{ + public RedisDistributedLockOptions( + RedLockTimeouts redLockTimeouts, + TimeoutValue extensionCadence, + TimeoutValue minBusyWaitSleepTime, + TimeoutValue maxBusyWaitSleepTime) + { + this.RedLockTimeouts = redLockTimeouts; + this.ExtensionCadence = extensionCadence; + this.MinBusyWaitSleepTime = minBusyWaitSleepTime; + this.MaxBusyWaitSleepTime = maxBusyWaitSleepTime; + } + + public RedLockTimeouts RedLockTimeouts { get; } + public TimeoutValue ExtensionCadence { get; } + public TimeoutValue MinBusyWaitSleepTime { get; } + public TimeoutValue MaxBusyWaitSleepTime { get; } +} diff --git a/src/DistributedLock.Redis/RedisDistributedSynchronizationProvider.cs b/src/DistributedLock.Redis/RedisDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..af740768 --- /dev/null +++ b/src/DistributedLock.Redis/RedisDistributedSynchronizationProvider.cs @@ -0,0 +1,60 @@ +using StackExchange.Redis; + +namespace Medallion.Threading.Redis; + +/// +/// Implements for , +/// for , +/// and for . +/// +public sealed class RedisDistributedSynchronizationProvider : IDistributedLockProvider, IDistributedReaderWriterLockProvider, IDistributedSemaphoreProvider +{ + private readonly IReadOnlyList _databases; + private readonly Action? _options; + + /// + /// Constructs a that connects to the provided + /// and uses the provided . + /// + public RedisDistributedSynchronizationProvider(IDatabase database, Action? options = null) + : this(new[] { database ?? throw new ArgumentNullException(nameof(database)) }, options) + { + } + + /// + /// Constructs a that connects to the provided + /// and uses the provided . + /// + /// Note that if multiple s are provided, will use only the first + /// . + /// + public RedisDistributedSynchronizationProvider(IEnumerable databases, Action? options = null) + { + this._databases = RedisDistributedLock.ValidateDatabases(databases); + this._options = options; + } + + /// + /// Creates a using the given . + /// + public RedisDistributedLock CreateLock(RedisKey key) => new(key, this._databases, this._options); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); + + /// + /// Creates a using the given . + /// + public RedisDistributedReaderWriterLock CreateReaderWriterLock(string name) => + new(name, this._databases, this._options); + + IDistributedReaderWriterLock IDistributedReaderWriterLockProvider.CreateReaderWriterLock(string name) => + this.CreateReaderWriterLock(name); + + /// + /// Creates a using the provided and . + /// + public RedisDistributedSemaphore CreateSemaphore(RedisKey key, int maxCount) => new(key, maxCount, this._databases[0], this._options); + + IDistributedSemaphore IDistributedSemaphoreProvider.CreateSemaphore(string name, int maxCount) => + this.CreateSemaphore(name, maxCount); +} diff --git a/src/DistributedLock.Redis/Shims.cs b/src/DistributedLock.Redis/Shims.cs new file mode 100644 index 00000000..8b93dd54 --- /dev/null +++ b/src/DistributedLock.Redis/Shims.cs @@ -0,0 +1,18 @@ +#if !NET6_OR_GREATER +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class)] + internal sealed class InterpolatedStringHandlerAttribute : Attribute { } +} +#endif + +#if !NETCOREAPP3_0_OR_GREATER +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)] + internal sealed class CallerArgumentExpressionAttribute(string parameterName) : Attribute + { + public string ParameterName { get; } = parameterName; + } +} +#endif \ No newline at end of file diff --git a/src/DistributedLock.Redis/TimeoutTask.cs b/src/DistributedLock.Redis/TimeoutTask.cs new file mode 100644 index 00000000..7ebd27fa --- /dev/null +++ b/src/DistributedLock.Redis/TimeoutTask.cs @@ -0,0 +1,34 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Redis; + +/// +/// Acts as a which is cleaned up when +/// the gets disposed +/// +internal readonly struct TimeoutTask : IDisposable +{ + private readonly CancellationTokenSource _cleanupTokenSource; + private readonly CancellationTokenSource? _linkedTokenSource; + + public TimeoutTask(TimeoutValue timeout, CancellationToken cancellationToken) + { + this._cleanupTokenSource = new CancellationTokenSource(); + this._linkedTokenSource = cancellationToken.CanBeCanceled + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, this._cleanupTokenSource.Token) + : null; + this.Task = Task.Delay(timeout.TimeSpan, this._linkedTokenSource?.Token ?? this._cleanupTokenSource.Token); + } + + public Task Task { get; } + + public void Dispose() + { + try { this._cleanupTokenSource.Cancel(); } + finally + { + this._linkedTokenSource?.Dispose(); + this._cleanupTokenSource.Dispose(); + } + } +} diff --git a/src/DistributedLock.Redis/packages.lock.json b/src/DistributedLock.Redis/packages.lock.json new file mode 100644 index 00000000..0765be0f --- /dev/null +++ b/src/DistributedLock.Redis/packages.lock.json @@ -0,0 +1,425 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "Nullable": { + "type": "Direct", + "requested": "[1.3.1, )", + "resolved": "1.3.1", + "contentHash": "Mk4ZVDfAORTjvckQprCSehi1XgOAAlk5ez06Va/acRYEloN9t6d6zpzJRn5MEq7+RnagyFIq9r+kbWzLGd+6QA==" + }, + "StackExchange.Redis": { + "type": "Direct", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8", + "System.IO.Compression": "4.3.0", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "System.Threading.Channels": "5.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/HggWBbTwy8TgebGSX5DBZ24ndhzi93sHUBDvP1IxbZD7FDokYzdAr6+vbWGjw2XAfR2EJ1sfKUotpjHnFWPxA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw==" + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "RLBIxntLaG9pRmmuVDwY1kc8Bvp/FQzSxPU+19VekkScKkWtVP9r8bLhm28ama3usc816UBrmkg3vv3jUea/hw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.ValueTuple": { + "type": "CentralTransitive", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + } + }, + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Nullable": { + "type": "Direct", + "requested": "[1.3.1, )", + "resolved": "1.3.1", + "contentHash": "Mk4ZVDfAORTjvckQprCSehi1XgOAAlk5ez06Va/acRYEloN9t6d6zpzJRn5MEq7+RnagyFIq9r+kbWzLGd+6QA==" + }, + "StackExchange.Redis": { + "type": "Direct", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8", + "System.Threading.Channels": "5.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/HggWBbTwy8TgebGSX5DBZ24ndhzi93sHUBDvP1IxbZD7FDokYzdAr6+vbWGjw2XAfR2EJ1sfKUotpjHnFWPxA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "RLBIxntLaG9pRmmuVDwY1kc8Bvp/FQzSxPU+19VekkScKkWtVP9r8bLhm28ama3usc816UBrmkg3vv3jUea/hw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "StackExchange.Redis": { + "type": "Direct", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8", + "System.Threading.Channels": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/HggWBbTwy8TgebGSX5DBZ24ndhzi93sHUBDvP1IxbZD7FDokYzdAr6+vbWGjw2XAfR2EJ1sfKUotpjHnFWPxA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "RLBIxntLaG9pRmmuVDwY1kc8Bvp/FQzSxPU+19VekkScKkWtVP9r8bLhm28ama3usc816UBrmkg3vv3jUea/hw==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/DistributedLock.SqlServer/AssemblyAttributes.cs b/src/DistributedLock.SqlServer/AssemblyAttributes.cs similarity index 100% rename from DistributedLock.SqlServer/AssemblyAttributes.cs rename to src/DistributedLock.SqlServer/AssemblyAttributes.cs diff --git a/DistributedLock.SqlServer/DistributedLock.SqlServer.csproj b/src/DistributedLock.SqlServer/DistributedLock.SqlServer.csproj similarity index 61% rename from DistributedLock.SqlServer/DistributedLock.SqlServer.csproj rename to src/DistributedLock.SqlServer/DistributedLock.SqlServer.csproj index c15ab771..c7ea2101 100644 --- a/DistributedLock.SqlServer/DistributedLock.SqlServer.csproj +++ b/src/DistributedLock.SqlServer/DistributedLock.SqlServer.csproj @@ -1,22 +1,23 @@ - netstandard2.0;netstandard2.1;net461 + netstandard2.0;netstandard2.1;net462 Medallion.Threading.SqlServer True 4 Latest enable + enable - 1.0.0-alpha01 + 1.0.7 1.0.0.0 Michael Adelson - TODO + Provides a distributed lock implementation based on SQL Server Copyright © 2020 Michael Adelson MIT - TODO + distributed lock async mutex sql sqlserver https://github.com/madelson/DistributedLock https://github.com/madelson/DistributedLock 1.0.0.0 @@ -30,6 +31,11 @@ True True + + embedded + + true + true @@ -39,10 +45,18 @@ - + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.SqlServer/PublicAPI.Shipped.txt b/src/DistributedLock.SqlServer/PublicAPI.Shipped.txt new file mode 100644 index 00000000..14482c62 --- /dev/null +++ b/src/DistributedLock.SqlServer/PublicAPI.Shipped.txt @@ -0,0 +1,67 @@ +#nullable enable +abstract Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +abstract Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.SqlServer.SqlConnectionOptionsBuilder +Medallion.Threading.SqlServer.SqlConnectionOptionsBuilder.KeepaliveCadence(System.TimeSpan keepaliveCadence) -> Medallion.Threading.SqlServer.SqlConnectionOptionsBuilder! +Medallion.Threading.SqlServer.SqlConnectionOptionsBuilder.UseMultiplexing(bool useMultiplexing = true) -> Medallion.Threading.SqlServer.SqlConnectionOptionsBuilder! +Medallion.Threading.SqlServer.SqlConnectionOptionsBuilder.UseTransaction(bool useTransaction = true) -> Medallion.Threading.SqlServer.SqlConnectionOptionsBuilder! +Medallion.Threading.SqlServer.SqlDistributedLock +Medallion.Threading.SqlServer.SqlDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedLockHandle! +Medallion.Threading.SqlServer.SqlDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedLock.Name.get -> string! +Medallion.Threading.SqlServer.SqlDistributedLock.SqlDistributedLock(string! name, string! connectionString, System.Action? options = null, bool exactName = false) -> void +Medallion.Threading.SqlServer.SqlDistributedLock.SqlDistributedLock(string! name, System.Data.IDbConnection! connection, bool exactName = false) -> void +Medallion.Threading.SqlServer.SqlDistributedLock.SqlDistributedLock(string! name, System.Data.IDbTransaction! transaction, bool exactName = false) -> void +Medallion.Threading.SqlServer.SqlDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedLockHandle? +Medallion.Threading.SqlServer.SqlDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedLockHandle +Medallion.Threading.SqlServer.SqlDistributedLockHandle.Dispose() -> void +Medallion.Threading.SqlServer.SqlDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.AcquireReadLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockHandle! +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.AcquireReadLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.AcquireUpgradeableReadLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockUpgradeableHandle! +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.AcquireUpgradeableReadLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.AcquireWriteLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockHandle! +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.AcquireWriteLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.Name.get -> string! +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.SqlDistributedReaderWriterLock(string! name, string! connectionString, System.Action? options = null, bool exactName = false) -> void +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.SqlDistributedReaderWriterLock(string! name, System.Data.IDbConnection! connection, bool exactName = false) -> void +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.SqlDistributedReaderWriterLock(string! name, System.Data.IDbTransaction! transaction, bool exactName = false) -> void +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.TryAcquireReadLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockHandle? +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.TryAcquireReadLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.TryAcquireUpgradeableReadLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockUpgradeableHandle? +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.TryAcquireUpgradeableReadLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.TryAcquireWriteLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockHandle? +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock.TryAcquireWriteLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockHandle +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockHandle.Dispose() -> void +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockUpgradeableHandle +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockUpgradeableHandle.TryUpgradeToWriteLock(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockUpgradeableHandle.TryUpgradeToWriteLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockUpgradeableHandle.UpgradeToWriteLock(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockUpgradeableHandle.UpgradeToWriteLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedSemaphore +Medallion.Threading.SqlServer.SqlDistributedSemaphore.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedSemaphoreHandle! +Medallion.Threading.SqlServer.SqlDistributedSemaphore.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedSemaphore.MaxCount.get -> int +Medallion.Threading.SqlServer.SqlDistributedSemaphore.Name.get -> string! +Medallion.Threading.SqlServer.SqlDistributedSemaphore.SqlDistributedSemaphore(string! name, int maxCount, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.SqlServer.SqlDistributedSemaphore.SqlDistributedSemaphore(string! name, int maxCount, System.Data.IDbConnection! connection) -> void +Medallion.Threading.SqlServer.SqlDistributedSemaphore.SqlDistributedSemaphore(string! name, int maxCount, System.Data.IDbTransaction! transaction) -> void +Medallion.Threading.SqlServer.SqlDistributedSemaphore.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.SqlServer.SqlDistributedSemaphoreHandle? +Medallion.Threading.SqlServer.SqlDistributedSemaphore.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedSemaphoreHandle +Medallion.Threading.SqlServer.SqlDistributedSemaphoreHandle.Dispose() -> void +Medallion.Threading.SqlServer.SqlDistributedSemaphoreHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.SqlServer.SqlDistributedSemaphoreHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.SqlServer.SqlDistributedSynchronizationProvider +Medallion.Threading.SqlServer.SqlDistributedSynchronizationProvider.CreateLock(string! name, bool exactName = false) -> Medallion.Threading.SqlServer.SqlDistributedLock! +Medallion.Threading.SqlServer.SqlDistributedSynchronizationProvider.CreateReaderWriterLock(string! name, bool exactName = false) -> Medallion.Threading.SqlServer.SqlDistributedReaderWriterLock! +Medallion.Threading.SqlServer.SqlDistributedSynchronizationProvider.CreateSemaphore(string! name, int maxCount) -> Medallion.Threading.SqlServer.SqlDistributedSemaphore! +Medallion.Threading.SqlServer.SqlDistributedSynchronizationProvider.SqlDistributedSynchronizationProvider(string! connectionString, System.Action? options = null) -> void +Medallion.Threading.SqlServer.SqlDistributedSynchronizationProvider.SqlDistributedSynchronizationProvider(System.Data.IDbConnection! connection) -> void +Medallion.Threading.SqlServer.SqlDistributedSynchronizationProvider.SqlDistributedSynchronizationProvider(System.Data.IDbTransaction! transaction) -> void +override Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockUpgradeableHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +override Medallion.Threading.SqlServer.SqlDistributedReaderWriterLockUpgradeableHandle.HandleLostToken.get -> System.Threading.CancellationToken \ No newline at end of file diff --git a/src/DistributedLock.SqlServer/PublicAPI.Unshipped.txt b/src/DistributedLock.SqlServer/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/DistributedLock.SqlServer/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/DistributedLock.SqlServer/SqlApplicationLock.cs b/src/DistributedLock.SqlServer/SqlApplicationLock.cs new file mode 100644 index 00000000..d9037d88 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlApplicationLock.cs @@ -0,0 +1,217 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; + +namespace Medallion.Threading.SqlServer; + +/// +/// Implements using sp_getapplock +/// +internal sealed class SqlApplicationLock : IDbSynchronizationStrategy +{ + public const int TimeoutExitCode = -1, + AlreadyHeldExitCode = 103, + InvalidUpgradeExitCode = 104; + + public static readonly SqlApplicationLock SharedLock = new(Mode.Shared), + UpdateLock = new(Mode.Update), + ExclusiveLock = new(Mode.Exclusive), + UpgradeLock = new(Mode.Exclusive, isUpgrade: true); + + private static readonly object Cookie = new(); + private readonly Mode _mode; + private readonly bool _isUpgrade; + + private SqlApplicationLock(Mode mode, bool isUpgrade = false) + { + Invariant.Require(!isUpgrade || mode == Mode.Exclusive); + + this._mode = mode; + this._isUpgrade = isUpgrade; + } + + bool IDbSynchronizationStrategy.IsUpgradeable => this._mode == Mode.Update; + + async ValueTask IDbSynchronizationStrategy.TryAcquireAsync( + DatabaseConnection connection, + string resourceName, + TimeoutValue timeout, + CancellationToken cancellationToken) + { + try + { + return await this.ExecuteAcquireCommandAsync(connection, resourceName, timeout, cancellationToken).ConfigureAwait(false) ? Cookie : null; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // If the command is canceled, I believe there's a slim chance that acquisition just completed before the cancellation went through. + // In that case, I'm pretty sure it won't be rolled back. Therefore, to be safe we issue a try-release + await ExecuteReleaseCommandAsync(connection, resourceName, isTry: true).ConfigureAwait(false); + throw; + } + } + + ValueTask IDbSynchronizationStrategy.ReleaseAsync(DatabaseConnection connection, string resourceName, object lockCookie) => + ExecuteReleaseCommandAsync(connection, resourceName, isTry: false); + + private async Task ExecuteAcquireCommandAsync(DatabaseConnection connection, string lockName, TimeoutValue timeout, CancellationToken cancellationToken) + { + using var command = this.CreateAcquireCommand(connection, lockName, timeout, out var returnValue); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return await ParseExitCodeAsync((int)returnValue.Value, timeout, cancellationToken).ConfigureAwait(false); + } + + private static async ValueTask ExecuteReleaseCommandAsync(DatabaseConnection connection, string lockName, bool isTry) + { + using var command = CreateReleaseCommand(connection, lockName, isTry, out var returnValue); + await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + await ParseExitCodeAsync((int)returnValue.Value, TimeSpan.Zero, CancellationToken.None).ConfigureAwait(false); + } + + private DatabaseCommand CreateAcquireCommand( + DatabaseConnection connection, + string lockName, + TimeoutValue timeout, + out IDbDataParameter returnValue) + { + var command = connection.CreateCommand(); + + if (connection.IsExernallyOwned || this._isUpgrade) + { + returnValue = command.AddParameter("Result", type: DbType.Int32, direction: ParameterDirection.Output); + + const string CurrentOwnerMode = "APPLOCK_MODE('public', @Resource, @LockOwner)", + GetAppLock = "EXEC @Result = dbo.sp_getapplock @Resource=@Resource, @LockMode=@LockMode, @LockOwner=@LockOwner, @LockTimeout=@LockTimeout, @DbPrincipal='public'"; + var alternateOwnerHasLockCheck = connection.IsExernallyOwned && connection.HasTransaction + ? " OR APPLOCK_MODE('public', @Resource, 'Session') != 'NoLock'" + : string.Empty; + + if (this._isUpgrade) + { + command.SetCommandText( + $@"DECLARE @Mode NVARCHAR(32) = {CurrentOwnerMode} + IF @Mode = 'NoLock' + SET @Result = {InvalidUpgradeExitCode} + ELSE IF @Mode != '{GetModeString(Mode.Update)}'{alternateOwnerHasLockCheck} + SET @Result = {AlreadyHeldExitCode} + ELSE + {GetAppLock}" + ); + } + else + { + command.SetCommandText( + $@"IF {CurrentOwnerMode} != 'NoLock'{alternateOwnerHasLockCheck} + SET @Result = {AlreadyHeldExitCode} + ELSE + {GetAppLock}" + ); + } + } + else + { + returnValue = command.AddParameter(type: DbType.Int32, direction: ParameterDirection.ReturnValue); + command.SetCommandText("dbo.sp_getapplock"); + command.SetCommandType(CommandType.StoredProcedure); + } + command.SetTimeout(timeout); + + command.AddParameter("Resource", lockName); + command.AddParameter("LockMode", GetModeString(this._mode)); + command.AddParameter("LockOwner", connection.HasTransaction ? "Transaction" : "Session"); + command.AddParameter("LockTimeout", timeout.InMilliseconds); + + return command; + } + + private static DatabaseCommand CreateReleaseCommand(DatabaseConnection connection, string lockName, bool isTry, out IDbDataParameter returnValue) + { + var command = connection.CreateCommand(); + if (isTry) + { + command.SetCommandText( + @"IF APPLOCK_MODE('public', @Resource, @LockOwner) != 'NoLock' + EXEC @Result = dbo.sp_releaseapplock @Resource, @LockOwner + ELSE + SET @Result = 0" + ); + } + else + { + command.SetCommandText("dbo.sp_releaseapplock"); + command.SetCommandType(CommandType.StoredProcedure); + } + + command.AddParameter("Resource", lockName); + command.AddParameter("LockOwner", connection.HasTransaction ? "Transaction" : "Session"); + + if (isTry) + { + returnValue = command.AddParameter("Result", type: DbType.Int32, direction: ParameterDirection.Output); + } + else + { + returnValue = command.AddParameter(type: DbType.Int32, direction: ParameterDirection.ReturnValue); + } + + return command; + } + + public static async ValueTask ParseExitCodeAsync(int exitCode, TimeoutValue timeout, CancellationToken cancellationToken) + { + // sp_getapplock exit codes documented at + // https://msdn.microsoft.com/en-us/library/ms189823.aspx + + switch (exitCode) + { + case 0: + case 1: + return true; + + case TimeoutExitCode: + return false; + + case -2: // canceled + throw new OperationCanceledException(GetErrorMessage(exitCode, "canceled")); + case -3: // deadlock + throw new DeadlockException(GetErrorMessage(exitCode, "deadlock")); + case -999: // parameter / unknown + throw new ArgumentException(GetErrorMessage(exitCode, "parameter validation or other error")); + + case InvalidUpgradeExitCode: + // should never happen unless something goes wrong (e. g. user manually releases the lock on an externally-owned connection) + throw new InvalidOperationException("Cannot upgrade to an exclusive lock because the update lock is not held"); + case AlreadyHeldExitCode: + return timeout.IsZero ? false + : timeout.IsInfinite ? throw new DeadlockException("Attempted to acquire a lock that is already held on the same connection") + : await WaitThenReturnFalseAsync().ConfigureAwait(false); + + default: + if (exitCode <= 0) { throw new InvalidOperationException(GetErrorMessage(exitCode, "unknown")); } + return true; // unknown success code + } + + async ValueTask WaitThenReturnFalseAsync() + { + await SyncViaAsync.Delay(timeout, cancellationToken).ConfigureAwait(false); + return false; + } + } + + private static string GetErrorMessage(int exitCode, string type) => $"The request for the distributed lock failed with exit code {exitCode} ({type})"; + + private static string GetModeString(Mode mode) => mode switch + { + Mode.Shared => "Shared", + Mode.Update => "Update", + Mode.Exclusive => "Exclusive", + _ => throw new ArgumentException(nameof(mode)), + }; + + private enum Mode + { + Shared, + Update, + Exclusive, + } +} diff --git a/src/DistributedLock.SqlServer/SqlConnectionOptionsBuilder.cs b/src/DistributedLock.SqlServer/SqlConnectionOptionsBuilder.cs new file mode 100644 index 00000000..7bfcf54e --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlConnectionOptionsBuilder.cs @@ -0,0 +1,98 @@ +using Medallion.Threading.Internal; +using System.Data; + +namespace Medallion.Threading.SqlServer; + +/// +/// Specifies options for connecting to and locking against a SQL database +/// +public sealed class SqlConnectionOptionsBuilder +{ + private TimeoutValue? _keepaliveCadence; + private bool? _useTransaction, _useMultiplexing; + + internal SqlConnectionOptionsBuilder() { } + + /// + /// Using SQL Azure as a distributed synchronization provider can be challenging due to Azure's aggressive connection governor + /// which proactively kills idle connections. + /// + /// To prevent this, this option sets the cadence at which we run a no-op "keepalive" query on a connection that is holding a lock. + /// Note that this still does not guarantee protection for the connection from all conditions where the governor might kill it. + /// + /// To disable keepalive, set to . + /// + /// Defaults to 10 minutes based on Azure's 30 minute default behavior. + /// + /// For more information, see the dicussion on https://github.com/madelson/DistributedLock/issues/5 + /// + public SqlConnectionOptionsBuilder KeepaliveCadence(TimeSpan keepaliveCadence) + { + this._keepaliveCadence = new TimeoutValue(keepaliveCadence, nameof(keepaliveCadence)); + return this; + } + + /// + /// Whether the synchronization should use a transaction scope rather than a session scope. Defaults to false. + /// + /// Synchronizing based on a transaction is marginally less expensive than using a connection + /// because releasing requires only disposing the underlying . + /// + /// The disadvantage is that using this strategy may lead to long-running transactions, which can be + /// problematic for databases using the full recovery model. Furthermore, this strategy prevents us from + /// taking advantage of and its performance advantages. + /// + public SqlConnectionOptionsBuilder UseTransaction(bool useTransaction = true) + { + this._useTransaction = useTransaction; + return this; + } + + /// + /// This mode takes advantage of the fact that while "holding" a lock (or other synchronization primitive) + /// a connection is essentially idle. Thus, rather than creating a new connection for each held lock it is + /// often possible to multiplex a shared connection so that that connection can hold multiple locks at the same time. + /// + /// Multiplexing is on by default, unless is set to TRUE in which case multiplexing is disabled + /// because it is not compatible with . + /// + /// This is implemented in such a way that releasing a lock held on such a connection will never be blocked by an + /// Acquire() call that is waiting to acquire a lock on that same connection. For this reason, the multiplexing + /// strategy is "optimistic": if the lock can't be acquired instantaneously on the shared connection, a new (shareable) + /// connection will be allocated. + /// + /// This option can improve performance and avoid connection pool starvation in high-load scenarios. It is also + /// particularly applicable to cases where + /// semantics are used with a zero-length timeout. + /// + public SqlConnectionOptionsBuilder UseMultiplexing(bool useMultiplexing = true) + { + this._useMultiplexing = useMultiplexing; + return this; + } + + internal static (TimeoutValue keepaliveCadence, bool useTransaction, bool useMultiplexing) GetOptions(Action? optionsBuilder) + { + SqlConnectionOptionsBuilder? options; + if (optionsBuilder != null) + { + options = new SqlConnectionOptionsBuilder(); + optionsBuilder(options); + } + else + { + options = null; + } + + var keepaliveCadence = options?._keepaliveCadence ?? TimeSpan.FromMinutes(10); + var useTransaction = options?._useTransaction ?? false; + var useMultiplexing = options?._useMultiplexing ?? !options?._useTransaction ?? true; + + if (useMultiplexing && useTransaction) + { + throw new ArgumentException(nameof(UseTransaction) + ": is not compatible with " + nameof(UseMultiplexing)); + } + + return (keepaliveCadence, useTransaction, useMultiplexing); + } +} diff --git a/src/DistributedLock.SqlServer/SqlDatabaseConnection.cs b/src/DistributedLock.SqlServer/SqlDatabaseConnection.cs new file mode 100644 index 00000000..03a5b18e --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDatabaseConnection.cs @@ -0,0 +1,67 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using Microsoft.Data.SqlClient; +using System.Data; +using System.Reflection; + +namespace Medallion.Threading.SqlServer; + +internal sealed class SqlDatabaseConnection : DatabaseConnection +{ + public SqlDatabaseConnection(IDbConnection connection, bool isExternallyOwned = true) + : base(connection, isExternallyOwned: isExternallyOwned) + { + } + + public SqlDatabaseConnection(IDbTransaction transaction) + : base(transaction, isExternallyOwned: true) + { + } + + public SqlDatabaseConnection(string connectionString) + : this(new SqlConnection(connectionString), isExternallyOwned: false) + { + } + + // SQLServer gets no benefit from this + public override bool ShouldPrepareCommands => false; + + public override bool IsCommandCancellationException(Exception exception) + { + const int CanceledNumber = 0; + + // fast path using default SqlClient + if (exception is SqlException sqlException && sqlException.Number == CanceledNumber) + { + return true; + } + + var exceptionType = exception.GetType(); + // since SqlException is sealed (as of 2020-01-26) + if (exceptionType.ToString() == "System.Data.SqlClient.SqlException") + { + var numberProperty = exceptionType + .GetProperty(nameof(SqlException.Number), BindingFlags.Public | BindingFlags.Instance); + Invariant.Require(numberProperty != null); + if (numberProperty != null) + { + return Equals(numberProperty.GetValue(exception), CanceledNumber); + } + } + + // this shows up when you call DbCommand.Cancel() + return exception is InvalidOperationException; + } + + public override async Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor) + { + Invariant.Require(sleepTime >= TimeSpan.Zero && sleepTime < TimeSpan.FromDays(1)); + + using var command = this.CreateCommand(); + command.SetCommandText(@"WAITFOR DELAY @delay"); + command.AddParameter("delay", sleepTime.ToString(@"hh\:mm\:ss\.fff"), DbType.AnsiStringFixedLength); + command.SetTimeout(sleepTime); + + await executor(command, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/DistributedLock.SqlServer/SqlDistributedLock.IDistributedLock.cs b/src/DistributedLock.SqlServer/SqlDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..9d73b848 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDistributedLock.IDistributedLock.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.SqlServer; + +public partial class SqlDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public SqlDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public SqlDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.SqlServer/SqlDistributedLock.cs b/src/DistributedLock.SqlServer/SqlDistributedLock.cs new file mode 100644 index 00000000..e66fbc36 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDistributedLock.cs @@ -0,0 +1,109 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; + +namespace Medallion.Threading.SqlServer; + +/// +/// Implements a distributed lock using a SQL server application lock +/// (see https://msdn.microsoft.com/en-us/library/ms189823.aspx) +/// +public sealed partial class SqlDistributedLock : IInternalDistributedLock +{ + private readonly IDbDistributedLock _internalLock; + + /// + /// Constructs a new lock using the provided . + /// + /// The provided will be used to connect to the database. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public SqlDistributedLock(string name, string connectionString, Action? options = null, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, connectionString, options)) + { + } + + /// + /// Constructs a new lock using the provided . + /// + /// The provided will be used to connect to the database and will provide lock scope. It is assumed to be externally managed and + /// will not be opened or closed. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public SqlDistributedLock(string name, IDbConnection connection, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, connection)) + { + } + + /// + /// Constructs a new lock using the provided . + /// + /// The provided will be used to connect to the database and will provide lock scope. It is assumed to be externally managed and + /// will not be committed or rolled back. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public SqlDistributedLock(string name, IDbTransaction transaction, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, transaction)) + { + } + + private SqlDistributedLock(string name, bool exactName, Func internalLockFactory) + { + if (exactName) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + if (name.Length > MaxNameLength) { throw new FormatException($"{nameof(name)}: must be at most {MaxNameLength} characters"); } + this.Name = name; + } + else + { + this.Name = GetSafeName(name); + } + + this._internalLock = internalLockFactory(this.Name); + } + + /// + /// The maximum allowed length for lock names. See https://msdn.microsoft.com/en-us/library/ms189823.aspx + /// + internal static int MaxNameLength => 255; + + /// + /// Implements + /// + public string Name { get; } + + internal static string GetSafeName(string name) => + DistributedLockHelpers.ToSafeName(name, MaxNameLength, s => s); + + ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + this._internalLock.TryAcquireAsync(timeout, SqlApplicationLock.ExclusiveLock, cancellationToken, contextHandle: null).Wrap(h => new SqlDistributedLockHandle(h)); + + internal static IDbDistributedLock CreateInternalLock(string name, string connectionString, Action? optionsBuilder) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + var (keepaliveCadence, useTransaction, useMultiplexing) = SqlConnectionOptionsBuilder.GetOptions(optionsBuilder); + + return useMultiplexing + ? new OptimisticConnectionMultiplexingDbDistributedLock(name, connectionString, SqlMultiplexedConnectionLockPool.Instance, keepaliveCadence) + : new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new SqlDatabaseConnection(connectionString), useTransaction: useTransaction, keepaliveCadence); + } + + internal static IDbDistributedLock CreateInternalLock(string name, IDbConnection connection) + { + return connection == null + ? throw new ArgumentNullException(nameof(connection)) + : new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new SqlDatabaseConnection(connection)); + } + + internal static IDbDistributedLock CreateInternalLock(string name, IDbTransaction transaction) + { + return transaction == null + ? throw new ArgumentNullException(nameof(transaction)) + : new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new SqlDatabaseConnection(transaction)); + } +} diff --git a/src/DistributedLock.SqlServer/SqlDistributedLockHandle.cs b/src/DistributedLock.SqlServer/SqlDistributedLockHandle.cs new file mode 100644 index 00000000..0b47c16d --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDistributedLockHandle.cs @@ -0,0 +1,31 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.SqlServer; + +/// +/// Implements +/// +public sealed class SqlDistributedLockHandle : IDistributedSynchronizationHandle +{ + private IDistributedSynchronizationHandle? _innerHandle; + + internal SqlDistributedLockHandle(IDistributedSynchronizationHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the lock asynchronously + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} diff --git a/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs b/src/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs similarity index 80% rename from DistributedLock.SqlServer/SqlDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs rename to src/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs index ed36e0e8..3026efa1 100644 --- a/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs +++ b/src/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs @@ -1,38 +1,35 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Medallion.Threading.Internal; -namespace Medallion.Threading.SqlServer +namespace Medallion.Threading.SqlServer; + +public partial class SqlDistributedReaderWriterLock { - public partial class SqlDistributedReaderWriterLock - { - // AUTO-GENERATED - - IDistributedLockHandle? IDistributedReaderWriterLock.TryAcquireReadLock(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireReadLock(timeout, cancellationToken); - IDistributedLockHandle IDistributedReaderWriterLock.AcquireReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireReadLock(timeout, cancellationToken); - ValueTask IDistributedReaderWriterLock.TryAcquireReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); - ValueTask IDistributedReaderWriterLock.AcquireReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); - IDistributedLockUpgradeableHandle? IDistributedUpgradeableReaderWriterLock.TryAcquireUpgradeableReadLock(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireUpgradeableReadLock(timeout, cancellationToken); - IDistributedLockUpgradeableHandle IDistributedUpgradeableReaderWriterLock.AcquireUpgradeableReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireUpgradeableReadLock(timeout, cancellationToken); - ValueTask IDistributedUpgradeableReaderWriterLock.TryAcquireUpgradeableReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); - ValueTask IDistributedUpgradeableReaderWriterLock.AcquireUpgradeableReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); - IDistributedLockHandle? IDistributedReaderWriterLock.TryAcquireWriteLock(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireWriteLock(timeout, cancellationToken); - IDistributedLockHandle IDistributedReaderWriterLock.AcquireWriteLock(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireWriteLock(timeout, cancellationToken); - ValueTask IDistributedReaderWriterLock.TryAcquireWriteLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => - this.TryAcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); - ValueTask IDistributedReaderWriterLock.AcquireWriteLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => - this.AcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireReadLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireReadLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireReadLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + IDistributedLockUpgradeableHandle? IDistributedUpgradeableReaderWriterLock.TryAcquireUpgradeableReadLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireUpgradeableReadLock(timeout, cancellationToken); + IDistributedLockUpgradeableHandle IDistributedUpgradeableReaderWriterLock.AcquireUpgradeableReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireUpgradeableReadLock(timeout, cancellationToken); + ValueTask IDistributedUpgradeableReaderWriterLock.TryAcquireUpgradeableReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedUpgradeableReaderWriterLock.AcquireUpgradeableReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireWriteLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireWriteLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireWriteLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireWriteLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireWriteLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireWriteLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); /// /// Attempts to acquire a READ lock synchronously. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: @@ -226,5 +223,4 @@ public SqlDistributedReaderWriterLockHandle AcquireWriteLock(TimeSpan? timeout = public ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken, isWrite: true); - } } \ No newline at end of file diff --git a/src/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.cs b/src/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.cs new file mode 100644 index 00000000..c71f0ed4 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDistributedReaderWriterLock.cs @@ -0,0 +1,106 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; + +namespace Medallion.Threading.SqlServer; + +/// +/// Implements reader-writer lock semantics using a SQL server application lock +/// (see https://msdn.microsoft.com/en-us/library/ms189823.aspx). +/// +/// This class supports the following patterns: +/// * Multiple readers AND single writer (using and ) +/// * Multiple readers OR single writer (using and ) +/// * Upgradeable read locks similar to (using and ) +/// +public sealed partial class SqlDistributedReaderWriterLock : IInternalDistributedUpgradeableReaderWriterLock +{ + private readonly IDbDistributedLock _internalLock; + + #region ---- Constructors ---- + /// + /// Constructs a new lock using the provided . + /// + /// The provided will be used to connect to the database. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public SqlDistributedReaderWriterLock(string name, string connectionString, Action? options = null, bool exactName = false) + : this(name, exactName, n => SqlDistributedLock.CreateInternalLock(n, connectionString, options)) + { + } + + /// + /// Constructs a new lock using the provided . + /// + /// The provided will be used to connect to the database and will provide lock scope. It is assumed to be externally managed and + /// will not be opened or closed. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public SqlDistributedReaderWriterLock(string name, IDbConnection connection, bool exactName = false) + : this(name, exactName, n => SqlDistributedLock.CreateInternalLock(n, connection)) + { + } + + /// + /// Constructs a new lock using the provided . + /// + /// The provided will be used to connect to the database and will provide lock scope. It is assumed to be externally managed and + /// will not be committed or rolled back. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public SqlDistributedReaderWriterLock(string name, IDbTransaction transaction, bool exactName = false) + : this(name, exactName, n => SqlDistributedLock.CreateInternalLock(n, transaction)) + { + } + + private SqlDistributedReaderWriterLock(string name, bool exactName, Func internalLockFactory) + { + if (exactName) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + if (name.Length > MaxNameLength) { throw new FormatException($"{nameof(name)}: must be at most {MaxNameLength} characters"); } + this.Name = name; + } + else + { + this.Name = GetSafeName(name); + } + + this._internalLock = internalLockFactory(this.Name); + } + #endregion + + /// + /// Implements + /// + public string Name { get; } + + /// + /// The maximum allowed length for lock names. See https://msdn.microsoft.com/en-us/library/ms189823.aspx + /// + internal static int MaxNameLength => SqlDistributedLock.MaxNameLength; + + internal static string GetSafeName(string name) => SqlDistributedLock.GetSafeName(name); + + async ValueTask IInternalDistributedUpgradeableReaderWriterLock.InternalTryAcquireUpgradeableReadLockAsync( + TimeoutValue timeout, + CancellationToken cancellationToken) + { + var innerHandle = await this._internalLock + .TryAcquireAsync(timeout, SqlApplicationLock.UpdateLock, cancellationToken, contextHandle: null).ConfigureAwait(false); + return innerHandle != null ? new SqlDistributedReaderWriterLockUpgradeableHandle(innerHandle, this._internalLock) : null; + } + + async ValueTask IInternalDistributedReaderWriterLock.InternalTryAcquireAsync( + TimeoutValue timeout, + CancellationToken cancellationToken, + bool isWrite) + { + var innerHandle = await this._internalLock + .TryAcquireAsync(timeout, isWrite ? SqlApplicationLock.ExclusiveLock : SqlApplicationLock.SharedLock, cancellationToken, contextHandle: null).ConfigureAwait(false); + return innerHandle != null ? new SqlDistributedReaderWriterLockNonUpgradeableHandle(innerHandle) : null; + } +} diff --git a/src/DistributedLock.SqlServer/SqlDistributedReaderWriterLockHandle.cs b/src/DistributedLock.SqlServer/SqlDistributedReaderWriterLockHandle.cs new file mode 100644 index 00000000..50627703 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDistributedReaderWriterLockHandle.cs @@ -0,0 +1,123 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; + +namespace Medallion.Threading.SqlServer; + +/// +/// Implements +/// +public abstract class SqlDistributedReaderWriterLockHandle : IDistributedSynchronizationHandle +{ + // forbid external inheritors + internal SqlDistributedReaderWriterLockHandle() { } + + /// + /// Implements + /// + public abstract CancellationToken HandleLostToken { get; } + + /// + /// Releases the lock + /// + public void Dispose() => this.DisposeSyncViaAsync(); + + /// + /// Releases the lock asynchronously + /// + public abstract ValueTask DisposeAsync(); +} + +internal sealed class SqlDistributedReaderWriterLockNonUpgradeableHandle : SqlDistributedReaderWriterLockHandle +{ + private IDistributedSynchronizationHandle? _innerHandle; + + internal SqlDistributedReaderWriterLockNonUpgradeableHandle(IDistributedSynchronizationHandle? handle) + { + this._innerHandle = handle; + } + + public override CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); + + public override ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} + +/// +/// Implements +/// +public sealed class SqlDistributedReaderWriterLockUpgradeableHandle : SqlDistributedReaderWriterLockHandle, IInternalDistributedLockUpgradeableHandle +{ + private RefBox<(IDistributedSynchronizationHandle innerHandle, IDbDistributedLock @lock, IDistributedSynchronizationHandle? upgradedHandle)>? _box; + + internal SqlDistributedReaderWriterLockUpgradeableHandle(IDistributedSynchronizationHandle innerHandle, IDbDistributedLock @lock) + { + this._box = RefBox.Create((innerHandle, @lock, default(IDistributedSynchronizationHandle?))); + } + + /// + /// Implements + /// + public override CancellationToken HandleLostToken => (this._box ?? throw this.ObjectDisposed()).Value.innerHandle.HandleLostToken; + + /// + /// Releases the lock asynchronously + /// + public override async ValueTask DisposeAsync() + { + if (RefBox.TryConsume(ref this._box, out var contents)) + { + try { await (contents.upgradedHandle?.DisposeAsync() ?? default).ConfigureAwait(false); } + finally { await contents.innerHandle.DisposeAsync().ConfigureAwait(false); } + } + } + + /// + /// Implements + /// + public bool TryUpgradeToWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryUpgradeToWriteLock(this, timeout, cancellationToken); + + /// + /// Implements + /// + public ValueTask TryUpgradeToWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As().InternalTryUpgradeToWriteLockAsync(timeout, cancellationToken); + + /// + /// Implements + /// + public void UpgradeToWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.UpgradeToWriteLock(this, timeout, cancellationToken); + + /// + /// Implements + /// + public ValueTask UpgradeToWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.UpgradeToWriteLockAsync(this, timeout, cancellationToken); + + ValueTask IInternalDistributedLockUpgradeableHandle.InternalTryUpgradeToWriteLockAsync(TimeoutValue timeout, CancellationToken cancellationToken) + { + var box = this._box ?? throw this.ObjectDisposed(); + var contents = box.Value; + if (contents.upgradedHandle != null) { throw new InvalidOperationException("the lock has already been upgraded"); } + return TryPerformUpgradeAsync(); + + async ValueTask TryPerformUpgradeAsync() + { + var upgradedHandle = + await contents.@lock.TryAcquireAsync(timeout, SqlApplicationLock.UpgradeLock, cancellationToken, contextHandle: contents.innerHandle).ConfigureAwait(false); + if (upgradedHandle == null) + { + return false; + } + + contents.upgradedHandle = upgradedHandle; + var newBox = RefBox.Create(contents); + if (Interlocked.CompareExchange(ref this._box, newBox, comparand: box) != box) + { + await upgradedHandle.DisposeAsync().ConfigureAwait(false); + } + + return true; + } + } +} diff --git a/src/DistributedLock.SqlServer/SqlDistributedSemaphore.IDistributedSemaphore.cs b/src/DistributedLock.SqlServer/SqlDistributedSemaphore.IDistributedSemaphore.cs new file mode 100644 index 00000000..59e0ac75 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDistributedSemaphore.IDistributedSemaphore.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.SqlServer; + +public partial class SqlDistributedSemaphore +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedSemaphore.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedSemaphore.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedSemaphore.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedSemaphore.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire a semaphore ticket synchronously. Usage: + /// + /// using (var handle = mySemaphore.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the ticket! */ } + /// } + /// // dispose releases the ticket if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket or null on failure + public SqlDistributedSemaphoreHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires a semaphore ticket synchronously, failing with if the attempt times out. Usage: + /// + /// using (mySemaphore.Acquire(...)) + /// { + /// /* we have the ticket! */ + /// } + /// // dispose releases the ticket + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket + public SqlDistributedSemaphoreHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire a semaphore ticket asynchronously. Usage: + /// + /// await using (var handle = await mySemaphore.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the ticket! */ } + /// } + /// // dispose releases the ticket if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires a semaphore ticket asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await mySemaphore.AcquireAsync(...)) + /// { + /// /* we have the ticket! */ + /// } + /// // dispose releases the ticket + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.SqlServer/SqlDistributedSemaphore.cs b/src/DistributedLock.SqlServer/SqlDistributedSemaphore.cs new file mode 100644 index 00000000..5f03e635 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDistributedSemaphore.cs @@ -0,0 +1,72 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; + +namespace Medallion.Threading.SqlServer; + +/// +/// Implements a distributed semaphore using SQL Server constructs. +/// +public sealed partial class SqlDistributedSemaphore : IInternalDistributedSemaphore +{ + private readonly IDbDistributedLock _internalLock; + private readonly SqlSemaphore _strategy; + + #region ---- Constructors ---- + /// + /// Creates a semaphore with name that can be acquired up to + /// times concurrently. The provided will be used to connect to the database. + /// + public SqlDistributedSemaphore(string name, int maxCount, string connectionString, Action? options = null) + : this(name, maxCount, n => SqlDistributedLock.CreateInternalLock(n, connectionString, options)) + { + } + + /// + /// Creates a semaphore with name that can be acquired up to + /// times concurrently. When acquired, the semaphore will be scoped to the given . + /// The is assumed to be externally managed: the will + /// not attempt to open, close, or dispose it + /// + public SqlDistributedSemaphore(string name, int maxCount, IDbConnection connection) + : this(name, maxCount, n => SqlDistributedLock.CreateInternalLock(n, connection)) + { + } + + /// + /// Creates a semaphore with name that can be acquired up to + /// times concurrently. When acquired, the semaphore will be scoped to the given . + /// The and its are assumed to be externally managed: + /// the will not attempt to open, close, commit, roll back, or dispose them + /// + public SqlDistributedSemaphore(string name, int maxCount, IDbTransaction transaction) + : this(name, maxCount, n => SqlDistributedLock.CreateInternalLock(n, transaction)) + { + } + + private SqlDistributedSemaphore(string name, int maxCount, Func createInternalLockFromName) + { + if (maxCount < 1) { throw new ArgumentOutOfRangeException(nameof(maxCount), maxCount, "must be positive"); } + + this.Name = name ?? throw new ArgumentNullException(nameof(name)); + this._strategy = new SqlSemaphore(maxCount); + this._internalLock = createInternalLockFromName(SqlSemaphore.ToSafeName(name)); + } + #endregion + + /// + /// Implements + /// + public string Name { get; } + + /// + /// Implements + /// + public int MaxCount => this._strategy.MaxCount; + + async ValueTask IInternalDistributedSemaphore.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) + { + var handle = await this._internalLock.TryAcquireAsync(timeout, this._strategy, cancellationToken, contextHandle: null).ConfigureAwait(false); + return handle != null ? new SqlDistributedSemaphoreHandle(handle) : null; + } +} diff --git a/src/DistributedLock.SqlServer/SqlDistributedSemaphoreHandle.cs b/src/DistributedLock.SqlServer/SqlDistributedSemaphoreHandle.cs new file mode 100644 index 00000000..03d54b04 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDistributedSemaphoreHandle.cs @@ -0,0 +1,31 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.SqlServer; + +/// +/// Implements +/// +public sealed class SqlDistributedSemaphoreHandle : IDistributedSynchronizationHandle +{ + private IDistributedSynchronizationHandle? _innerHandle; + + internal SqlDistributedSemaphoreHandle(IDistributedSynchronizationHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the semaphore + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the semaphore asynchronously + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; +} diff --git a/src/DistributedLock.SqlServer/SqlDistributedSynchronizationProvider.cs b/src/DistributedLock.SqlServer/SqlDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..b952d9c4 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlDistributedSynchronizationProvider.cs @@ -0,0 +1,78 @@ +using System.Data; + +namespace Medallion.Threading.SqlServer; + +/// +/// Implements for , +/// for , +/// and for . +/// +public sealed class SqlDistributedSynchronizationProvider : IDistributedLockProvider, IDistributedUpgradeableReaderWriterLockProvider, IDistributedSemaphoreProvider +{ + private readonly Func _lockFactory; + private readonly Func _readerWriterLockFactory; + private readonly Func _semaphoreFactory; + + /// + /// Constructs a provider that connects with and . + /// + public SqlDistributedSynchronizationProvider(string connectionString, Action? options = null) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + this._lockFactory = (name, exactName) => new SqlDistributedLock(name, connectionString, options, exactName); + this._readerWriterLockFactory = (name, exactName) => new SqlDistributedReaderWriterLock(name, connectionString, options, exactName); + this._semaphoreFactory = (name, maxCount) => new SqlDistributedSemaphore(name, maxCount, connectionString, options); + } + + /// + /// Constructs a provider that connects with . + /// + public SqlDistributedSynchronizationProvider(IDbConnection connection) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + + this._lockFactory = (name, exactName) => new SqlDistributedLock(name, connection, exactName); + this._readerWriterLockFactory = (name, exactName) => new SqlDistributedReaderWriterLock(name, connection, exactName); + this._semaphoreFactory = (name, maxCount) => new SqlDistributedSemaphore(name, maxCount, connection); + } + + /// + /// Constructs a provider that connects with . + /// + public SqlDistributedSynchronizationProvider(IDbTransaction transaction) + { + if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); } + + this._lockFactory = (name, exactName) => new SqlDistributedLock(name, transaction, exactName); + this._readerWriterLockFactory = (name, exactName) => new SqlDistributedReaderWriterLock(name, transaction, exactName); + this._semaphoreFactory = (name, maxCount) => new SqlDistributedSemaphore(name, maxCount, transaction); + } + + /// + /// Constructs an instance of with the provided . Unless + /// is specified, invalid applock names will be escaped/hashed. + /// + public SqlDistributedLock CreateLock(string name, bool exactName = false) => this._lockFactory(name, exactName); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); + + /// + /// Constructs an instance of with the provided . Unless + /// is specified, invalid applock names will be escaped/hashed. + /// + public SqlDistributedReaderWriterLock CreateReaderWriterLock(string name, bool exactName = false) => this._readerWriterLockFactory(name, exactName); + + IDistributedUpgradeableReaderWriterLock IDistributedUpgradeableReaderWriterLockProvider.CreateUpgradeableReaderWriterLock(string name) => + this.CreateReaderWriterLock(name); + + IDistributedReaderWriterLock IDistributedReaderWriterLockProvider.CreateReaderWriterLock(string name) => + this.CreateReaderWriterLock(name); + + /// + /// Constructs an instance of with the provided and . + /// + public SqlDistributedSemaphore CreateSemaphore(string name, int maxCount) => this._semaphoreFactory(name, maxCount); + + IDistributedSemaphore IDistributedSemaphoreProvider.CreateSemaphore(string name, int maxCount) => this.CreateSemaphore(name, maxCount); +} diff --git a/src/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs b/src/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs new file mode 100644 index 00000000..b280c793 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs @@ -0,0 +1,8 @@ +using Medallion.Threading.Internal.Data; + +namespace Medallion.Threading.SqlServer; + +internal static class SqlMultiplexedConnectionLockPool +{ + public static readonly MultiplexedConnectionLockPool Instance = new(s => new SqlDatabaseConnection(s)); +} diff --git a/src/DistributedLock.SqlServer/SqlSemaphore.cs b/src/DistributedLock.SqlServer/SqlSemaphore.cs new file mode 100644 index 00000000..7e1d60e5 --- /dev/null +++ b/src/DistributedLock.SqlServer/SqlSemaphore.cs @@ -0,0 +1,522 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System.Data; +using System.Security.Cryptography; +using System.Text; + +namespace Medallion.Threading.SqlServer; + +internal sealed class SqlSemaphore(int maxCount) : IDbSynchronizationStrategy +{ + public int MaxCount { get; } = maxCount; + + #region ---- Execution ---- + public async ValueTask TryAcquireAsync(DatabaseConnection connection, string resourceName, TimeoutValue timeout, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string? markerTableName; + + // when we aren't supporting cancellation, we can use a simplified one-step algorithm. We treat a timeout of + // zero in the same way: since there is no blocking, we don't need to bother with explicit cancellation support + if (!cancellationToken.CanBeCanceled || timeout.IsZero) + { + using var command = CreateTextCommand(connection, operationTimeout: timeout); + command.SetCommandText(AcquireNonCancelableQuery.Value); + this.AddCommonParameters(command, resourceName, timeout: timeout); + await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + return await ProcessAcquireResultAsync(command.Parameters, timeout, cancellationToken, out markerTableName, out var ticketLockName).ConfigureAwait(false) + ? new Cookie(ticket: ticketLockName!, markerTable: markerTableName!) + : null; + } + + // cancelable case + + using (var command = CreateTextCommand(connection, operationTimeout: timeout)) + { + command.SetCommandText(AcquireCancelablePreambleQuery.Value); + this.AddCommonParameters(command, resourceName); + // preamble is non-cancelable + await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + if (await ProcessAcquireResultAsync(command.Parameters, timeout, cancellationToken, out markerTableName, out var ticketLockName).ConfigureAwait(false)) + { + return new Cookie(ticket: ticketLockName!, markerTable: markerTableName!); + } + } + + using (var command = CreateTextCommand(connection, operationTimeout: timeout)) + { + command.SetCommandText(AcquireCancelableQuery.Value); + this.AddCommonParameters(command, resourceName, timeout: timeout, markerTableName: markerTableName); + try + { + // see comments around disallowAsyncCancellation for why we pass this flag + await command.ExecuteNonQueryAsync(cancellationToken, disallowAsyncCancellation: true).ConfigureAwait(false); + } + catch when (cancellationToken.IsCancellationRequested) + { + // if we canceled the query, we need to perform cleanup to make sure we don't leave marker tables or held locks + + using (var cleanupCommand = CreateTextCommand(connection, operationTimeout: TimeSpan.Zero)) + { + cleanupCommand.SetCommandText(CancellationCleanupQuery.Value); + this.AddCommonParameters(cleanupCommand, resourceName, markerTableName: markerTableName); + await cleanupCommand.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + } + + throw; + } + + return await ProcessAcquireResultAsync(command.Parameters, timeout, cancellationToken, out markerTableName, out var ticketLockName).ConfigureAwait(false) + ? new Cookie(ticket: ticketLockName!, markerTable: markerTableName!) + : null; + } + } + + public async ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, Cookie lockCookie) + { + using var command = CreateTextCommand(connection, operationTimeout: Timeout.InfiniteTimeSpan); + command.SetCommandText(ReleaseQuery.Value); + this.AddCommonParameters(command, resourceName, markerTableName: lockCookie.MarkerTable, ticketLockName: lockCookie.Ticket); + await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + } + + bool IDbSynchronizationStrategy.IsUpgradeable => false; + + public sealed class Cookie(string ticket, string markerTable) + { + public string Ticket { get; } = ticket ?? throw new ArgumentNullException(nameof(ticket)); + public string MarkerTable { get; } = markerTable ?? throw new ArgumentNullException(nameof(markerTable)); + } + #endregion + + #region ---- Command Execution ---- + private static ValueTask ProcessAcquireResultAsync( + IDataParameterCollection parameters, + TimeoutValue timeout, + CancellationToken cancellationToken, + out string? markerTableName, + out string? ticketLockName) + { + var resultCode = (int)((IDbDataParameter)parameters[ResultCodeParameter]).Value; + switch (resultCode) + { + case SuccessCode: + ticketLockName = (string)((IDbDataParameter)parameters[TicketLockNameParameter]).Value; + markerTableName = (string)((IDbDataParameter)parameters[MarkerTableNameParameter]).Value; + return true.AsValueTask(); + case FinishedPreambleWithoutAcquiringCode: + ticketLockName = null; + markerTableName = (string)((IDbDataParameter)parameters[MarkerTableNameParameter]).Value; + return false.AsValueTask(); + case FailedToAcquireWithSpaceRemainingCode: + throw new InvalidOperationException($"An internal semaphore algorithm error ({resultCode}) occurred: failed to acquire a ticket despite indication that tickets are available"); + case BusyWaitTimeoutCode: + ticketLockName = markerTableName = null; + return false.AsValueTask(); + case AllTicketsHeldByCurrentSessionCode: + // whenever we hit this case, it's a deadlock. If the user asked us to wait forever, we just throw. However, + // if the user asked us to wait a specified amount of time we will wait in C#. There are other justifiable policies + // but this one seems relatively safe and likely to do what you want. It seems reasonable that no one intends to hang + // forever but also reasonable that someone should be able to test for lock acquisition without getting a throw + if (timeout.IsInfinite) + { + throw new DeadlockException("Deadlock detected: attempt to acquire the semaphore cannot succeed because all tickets are held by the current connection"); + } + + ticketLockName = markerTableName = null; + + async ValueTask DelayFalseAsync() + { + await SyncViaAsync.Delay(timeout, cancellationToken).ConfigureAwait(false); + return false; + } + return DelayFalseAsync(); + case SqlApplicationLock.TimeoutExitCode: + ticketLockName = markerTableName = null; + return false.AsValueTask(); + default: + ticketLockName = markerTableName = null; + return FailAsync(); + + async ValueTask FailAsync() + { + if (resultCode < 0) + { + await SqlApplicationLock.ParseExitCodeAsync(resultCode, timeout, cancellationToken).ConfigureAwait(false); + } + throw new InvalidOperationException($"Unexpected semaphore algorithm result code {resultCode}"); + } + } + } + + private static DatabaseCommand CreateTextCommand(DatabaseConnection connection, TimeoutValue operationTimeout) + { + var command = connection.CreateCommand(); + command.SetTimeout(operationTimeout); + return command; + } + + private void AddCommonParameters(DatabaseCommand command, string semaphoreName, TimeoutValue? timeout = null, string? markerTableName = null, string? ticketLockName = null) + { + command.AddParameter(SemaphoreNameParameter, semaphoreName); + command.AddParameter(MaxCountParameter, this.MaxCount); + if (timeout is { } timeoutValue) + { + command.AddParameter(TimeoutMillisParameter, timeoutValue.InMilliseconds); + } + + command.AddParameter(ResultCodeParameter, type: DbType.Int32, direction: ParameterDirection.Output); + + var ticket = command.AddParameter(TicketLockNameParameter, ticketLockName, type: DbType.String); + if (ticketLockName == null) + { + ticket.Direction = ParameterDirection.Output; + } + const int MaxOutputStringLength = 8000; // plenty long enough + ticket.Size = MaxOutputStringLength; + + var markerTable = command.AddParameter(MarkerTableNameParameter, markerTableName, type: DbType.String); + if (markerTableName == null) + { + markerTable.Direction = ParameterDirection.Output; + } + markerTable.Size = MaxOutputStringLength; + } + #endregion + + #region ---- Naming ---- + public static string ToSafeName(string semaphoreName) + { + // the max table name length is 128 for global and 116 for local temp tables. While we don't use local temp tables + // currently, to be conservative we use the lower cap. We're using 115 not 116 to reflect the missing '#' which counts + // towards the limit + const int MaxTableNameLength = 115; + const string Suffix = "semaphore"; + // this accounts for various other things we pad onto the name: + // * Marker table adds SPID + "s" + WAITERNUMBER (10 + 1 + 10 = 21) + // * Ticket lock name adds TICKETNUMBER (10) + // * Intent table name adds "intent_" + SPID + "_" + TICKETNUMBER (7 + 10 + 1 + 10 = 28) + // We will use 30 as a safe number + const int AdditionalSuffixMaxLength = 30; + + var nameWithoutInvalidCharacters = ReplaceInvalidCharacters(semaphoreName); + // note that we hash the original name, not the replaced name. This makes us even more robust to collisions + var nameHash = HashName(semaphoreName); + var maxBaseNameLength = MaxTableNameLength - (nameHash.Length + Suffix.Length + AdditionalSuffixMaxLength); + var baseName = nameWithoutInvalidCharacters.Length <= maxBaseNameLength + ? nameWithoutInvalidCharacters + : nameWithoutInvalidCharacters.Substring(0, maxBaseNameLength); + return $"{baseName}{nameHash}{Suffix}"; + } + + private static string ReplaceInvalidCharacters(string semaphoreName) + { + StringBuilder? modifiedName = null; + for (var i = 0; i < semaphoreName.Length; ++i) + { + var @char = semaphoreName[i]; + if (!IsAsciiLetterOrDigit(@char)) + { + if (modifiedName == null) + { + modifiedName = new StringBuilder(); + for (var j = 0; j < i; ++j) { modifiedName.Append(semaphoreName[j]); } + } + + modifiedName.Append(((int)@char).ToString("x")); + } + else if (modifiedName != null) + { + modifiedName.Append(@char); + } + } + + return modifiedName?.ToString() ?? semaphoreName; + } + + private static bool IsAsciiLetterOrDigit(char @char) => ('a' <= @char && @char <= 'z') + || ('A' <= @char && @char <= 'Z') + || ('0' <= @char && @char <= '9'); + + private static string HashName(string name) + { + using var hashAlgorithm = SHA256.Create(); + var hashBytes = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(name)); + return BitConverter.ToString(hashBytes) + .Replace("-", string.Empty) + .ToLowerInvariant(); + } + #endregion + + #region ---- Query Generation ---- + private const string SemaphoreNameParameter = "semaphoreName", + MaxCountParameter = "maxCount", + ResultCodeParameter = "resultCode", + TimeoutMillisParameter = "timeoutMillis", + MarkerTableNameParameter = "markerTableName", + TicketLockNameParameter = "ticketLockName", + LockResultVariable = "lockResult", + LockScopeVariable = "lockScope", + PreambleLockNameVariable = "preambleLock", + BusyWaitLockNameVariable = "busyWaitLock"; + + private const int SuccessCode = 0, + FinishedPreambleWithoutAcquiringCode = 100, + FailedToAcquireWithSpaceRemainingCode = 101, + BusyWaitTimeoutCode = 102, + AllTicketsHeldByCurrentSessionCode = SqlApplicationLock.AlreadyHeldExitCode; + + // when we don't have to deal with cancellation, we can put everything in one big query to save on round trips + private static readonly Lazy AcquireNonCancelableQuery = new(() => Merge( + CreateCommonVariableDeclarationsSql(includePreambleLock: true, includeBusyWaitLock: true, includeTryAcquireOnceVariables: true), + CreateAcquirePreambleSql(willRetryInSeparateQueryAfterPreamble: null), + CreateAcquireSql(cancelable: false), + CreateCodaSql(includePreambleLockRelease: true, includeBusyWaitLockRelease: true) + )), + // for cancellation, we run the preamble first as non-cancellable followed by a cancelable busy wait. This + // ensures that we avoid the case where we create a marker table in the preamble and then cancel before returning it + AcquireCancelablePreambleQuery = new(() => Merge( + CreateCommonVariableDeclarationsSql(includePreambleLock: true, includeBusyWaitLock: false, includeTryAcquireOnceVariables: true), + CreateAcquirePreambleSql(willRetryInSeparateQueryAfterPreamble: true), + CreateCodaSql(includePreambleLockRelease: true, includeBusyWaitLockRelease: false) + )), + AcquireCancelableQuery = new(() => Merge( + CreateCommonVariableDeclarationsSql(includePreambleLock: false, includeBusyWaitLock: true, includeTryAcquireOnceVariables: true), + CreateAcquireSql(cancelable: true), + CreateCodaSql(includePreambleLockRelease: false, includeBusyWaitLockRelease: true) + )), + CancellationCleanupQuery = new(() => Merge( + CreateCommonVariableDeclarationsSql(includePreambleLock: false, includeBusyWaitLock: true, includeTryAcquireOnceVariables: false), + CreateCancellationCleanupSql(), + CreateCodaSql(includePreambleLockRelease: false, includeBusyWaitLockRelease: true) + )), + ReleaseQuery = new(() => Merge( + CreateCommonVariableDeclarationsSql(includePreambleLock: false, includeBusyWaitLock: false, includeTryAcquireOnceVariables: false), + CreateReleaseSql() + )); + + private const string IntentMarkerTablePrefix = "intent"; + + /// + /// Used for making comments in format strings + /// + private static readonly object? C = null; + + /// + /// The preamble is the first part of the acquire algorithm. It is not cancellation-safe + /// + private static string CreateAcquirePreambleSql(bool? willRetryInSeparateQueryAfterPreamble) + { + const string SpidCountSeparator = "s"; + // if everything is going smoothly then the preamble lock should never even come close to timing out since + // nothing blocking happens inside the preamble. However, to be safe we do eventually give up + var preambleLockTimeoutMillis = (int)TimeSpan.FromMinutes(1).TotalMilliseconds; + + return $@" + {C/* The preamble body executes inside a special lock. Since the preamble is designed to be + non-blocking we can wait for a long time on this lock without worrying about respecting + our timeout. We avoid waiting forever in case there are unexpected problems (e. g. a lock on sys.tables) */} + EXEC @{LockResultVariable} = sys.sp_getapplock @{PreambleLockNameVariable}, 'Exclusive', @{LockScopeVariable}, {preambleLockTimeoutMillis} + IF @{LockResultVariable} < 0 GOTO CODA + + {C/* First, we determine the number of existing waiters/holders so we know whether we will have to block or not. + At the same time, we determine a value for our marker table which has not been chosen yet. This value is 1 greater + than the greatest value that exists so far, so it can be > the count. The expression for determining this value is + somewhat complex. We are looking at table names like ##[sem name][spid][separator][value] and parsing out value. */} + DECLARE @waiterNumber INT, @waiterCount INT + SELECT TOP 1 @waiterNumber = ISNULL(MAX(CAST(SUBSTRING(name, CHARINDEX('{SpidCountSeparator}', name, LEN(@{SemaphoreNameParameter})) + 1, LEN(name)) AS INT) + 1), 0), + @waiterCount = COUNT(*) + {C/* The NOLOCK here is important: otherwise we'll be blocked by trying to read entries for marker tables created in transactions that aren't committed */} + FROM tempdb.sys.tables WITH(NOLOCK) + {C/* Prefix search here is important since it uses an index. We don't need escaping because we bound the name to use a fixed character set */} + WHERE name LIKE '##' + @{SemaphoreNameParameter} + '%' + + {C/* Create the marker table. This table exists to give others a count of the number of waiting/holding processes. + we name our marker table using the form ##[sem name][spid][separator][value]. We use SPID over a random value since SPID values are typically small integers + that recycle over time; this means that we may be able to take advantage of SQL temp table caching. The reason we need SPID here at all is because if another + transaction creates and destroys a table of name X, we will be blocked if we try to create table X before the transaction ends. */} + SET @{MarkerTableNameParameter} = '##' + @{SemaphoreNameParameter} + CAST(@@SPID AS NVARCHAR(MAX)) + '{SpidCountSeparator}' + CAST(@waiterNumber AS NVARCHAR(MAX)) + DECLARE @createMarkerTableSql NVARCHAR(MAX) = 'CREATE TABLE ' + @{MarkerTableNameParameter} + ' (_ BIT)' + EXEC sp_executeSql @createMarkerTableSql + + {C/* If the number of waiters indicates that a space is free, we should be able to immediately acquire without blocking. */} + IF @waiterCount < @{MaxCountParameter} + BEGIN + {C/* may GOTO CODA; the CODA will release preamble lock */} + {CreateTryAcquireOnceSql(allowOneWait: false, cancelable: false)} + + SET @{ResultCodeParameter} = {FailedToAcquireWithSpaceRemainingCode} + GOTO CODA {C/* the CODA will release preamble lock */} + END + + {C/* If we get here, it means we finished the preamble without acquiring a ticket */} + {( + // if this is the end of the query, we have to set an exit code. If we are going to retry we indicate the special code that will trigger that and otherwise we indicate + // timeout. If this is not the end of the query, we just release the preamble lock and keep going + willRetryInSeparateQueryAfterPreamble is { } willRetryInSeparateQueryAfterPreambleValue + ? $@"SET @{ResultCodeParameter} = {(willRetryInSeparateQueryAfterPreambleValue ? FinishedPreambleWithoutAcquiringCode : SqlApplicationLock.TimeoutExitCode)}" + : $"EXEC sys.sp_releaseapplock @{PreambleLockNameVariable}, @{LockScopeVariable}" + )}"; + } + + private static string CreateAcquireSql(bool cancelable) + { + return $@" + {C/* The next step is to do a busy wait on all ticket locks. For fairness and to reduce resource usage, + use a "busy wait lock" to permit only one thread to busy wait at a time. */} + EXEC @{LockResultVariable} = sys.sp_getapplock @{BusyWaitLockNameVariable}, 'Exclusive', @{LockScopeVariable}, @{TimeoutMillisParameter} + IF @{LockResultVariable} < 0 GOTO CODA + + DECLARE @expiry DATETIME2 = CASE WHEN @{TimeoutMillisParameter} < 0 THEN NULL ELSE DATEADD(ms, @{TimeoutMillisParameter}, SYSUTCDATETIME()) END + WHILE 1 = 1 + BEGIN + {C/* may GOTO CODA; the CODA will release busy wait lock */} + {CreateTryAcquireOnceSql(allowOneWait: true, cancelable: cancelable)} + + IF SYSUTCDATETIME() > @expiry + BEGIN + SET @{ResultCodeParameter} = {BusyWaitTimeoutCode} + GOTO CODA + END + END"; + } + + private static string CreateCancellationCleanupSql() + { + // we check for the existence of an intent marker table since this indicates that we may have + // acquired a lock before being canceled and not have released it + + return $@" + DECLARE @intentMarkerTableName NVARCHAR(MAX) + SELECT TOP 1 @intentMarkerTableName = name + FROM tempdb.sys.tables WITH(NOLOCK) + WHERE name LIKE '##{IntentMarkerTablePrefix}\_' + CAST(@@SPID AS NVARCHAR(MAX)) + '\_%' ESCAPE '\' + + IF @intentMarkerTableName IS NOT NULL + BEGIN + SET @{TicketLockNameParameter} = RIGHT(@intentMarkerTableName, LEN(@intentMarkerTableName) - LEN('##{IntentMarkerTablePrefix}_' + CAST(@@SPID AS NVARCHAR(MAX)) + '_')) + IF APPLOCK_MODE('public', @{TicketLockNameParameter}, @{LockScopeVariable}) != 'NoLock' + EXEC sys.sp_releaseapplock @{TicketLockNameParameter}, @{LockScopeVariable} + + DECLARE @dropIntentMarkerTableSql NVARCHAR(MAX) = 'DROP TABLE ' + @intentMarkerTableName + EXEC sp_executeSql @dropIntentMarkerTableSql + END + "; + } + + private static string CreateReleaseSql() + { + return $@" + EXEC sys.sp_releaseAppLock @{TicketLockNameParameter}, @{LockScopeVariable} + DECLARE @dropMarkerTableSql NVARCHAR(MAX) = 'DROP TABLE ' + @{MarkerTableNameParameter} + EXEC sp_executeSql @dropMarkerTableSql"; + } + + private static string CreateCommonVariableDeclarationsSql( + bool includePreambleLock, + bool includeBusyWaitLock, + bool includeTryAcquireOnceVariables) + { + return $@" + DECLARE @{LockResultVariable} INT + , @{LockScopeVariable} NVARCHAR(32) = CASE @@TRANCOUNT WHEN 0 THEN 'Session' ELSE 'Transaction' END + {(includePreambleLock ? $", @{PreambleLockNameVariable} NVARCHAR(MAX) = 'preamble_' + @semaphoreName" : null)} + {(includeBusyWaitLock ? $", @{BusyWaitLockNameVariable} NVARCHAR(MAX) = 'busyWait_' + @semaphoreName" : null)} + {(includeTryAcquireOnceVariables ? @", @i INT, @baseTicketIndex INT, @anyNotHeld BIT" : null)}"; + } + + private static string CreateTryAcquireOnceSql(bool allowOneWait, bool cancelable) + { + return $@" + SET @i = 0 + {C/* Rather than always looping through tickets 0 .. N-1, we start at a random ticket. This should reduce looping in the average case and means + that if we are allowing a wait then the one ticket we wait on is randomized. */} + SET @baseTicketIndex = CAST(RAND() * @{MaxCountParameter} AS INT) + SET @anyNotHeld = 0 + WHILE @i < @{MaxCountParameter} + BEGIN + SET @{TicketLockNameParameter} = @{SemaphoreNameParameter} + CAST((@baseTicketIndex + @i) % @{MaxCountParameter} AS NVARCHAR(MAX)) + + {C/* Since app locks are reentrant on the same connection, we must do an explicit check to avoid taking the same ticket twice. + Additionally, if we are transaction-scoped we must check whether we hold the lock on EITHER the transaction or the session. */} + IF APPLOCK_MODE('public', @{TicketLockNameParameter}, @{LockScopeVariable}) = 'NoLock' + AND (@{LockScopeVariable} = 'Session' OR APPLOCK_MODE('public', @{TicketLockNameParameter}, 'Session') = 'NoLock') + BEGIN + {C/* "allowOneWait" will be specified when we are in a busy wait loop. To avoid burning CPU we pick the first unheld ticket we come + across and allow that wait to be > 0. This is preferable to doing WAITFOR since the wait will be broken if that ticket + becomes available. Note that we used to wait just 1ms here. However, in testing that proved flaky in detecting + deadlocks; empirically, 64ms seems to be sufficient to work reliably. The longer wait should also reduce the + CPU load without meaningfully adding delay overhead (SQL_SEMAPHORE_ONE_WAIT) */} + {(allowOneWait ? "DECLARE @lockTimeoutMillis INT = CASE @anyNotHeld WHEN 0 THEN 64 ELSE 0 END" : null)} + SET @anyNotHeld = 1 + + {( + cancelable + // The intent marker supports robust cancellation by ensuring that we never leave a lingering lock on a connection. By creating a marker before + // any lock acquisition, we have a way of determining the case where the query is canceled right after acquiring a lock + ? $@"DECLARE @intentMarkerTableName NVARCHAR(MAX) = '##{IntentMarkerTablePrefix}_' + CAST(@@SPID AS NVARCHAR(MAX)) + '_' + @{TicketLockNameParameter} + DECLARE @createIntentMarkerTableSql NVARCHAR(MAX) = 'CREATE TABLE ' + @intentMarkerTableName + ' (_ BIT)' + EXEC sp_executeSql @createIntentMarkerTableSql" + : null + )} + + EXEC @{LockResultVariable} = sys.sp_getapplock @{TicketLockNameParameter}, 'Exclusive', @{LockScopeVariable}, {(allowOneWait ? "@lockTimeoutMillis" : "0")} + IF @{LockResultVariable} >= 0 + BEGIN + SET @{ResultCodeParameter} = {SuccessCode} + GOTO CODA + END + + {( + cancelable + // on any failed acquisition, drop the intent marker + ? $@"DECLARE @dropIntentMarkerTableSql NVARCHAR(MAX) = 'DROP TABLE ' + @intentMarkerTableName + EXEC sp_executeSql @dropIntentMarkerTableSql" + : null + )} + + {C/* on any unexpected lock failure, quit */} + IF @{LockResultVariable} < -1 GOTO CODA + END + SET @i = @i + 1 + END + {C/* detect this as a special case since it means we'll never succeed. We can handle in C# */} + IF @anyNotHeld = 0 + BEGIN + SET @{ResultCodeParameter} = {AllTicketsHeldByCurrentSessionCode} + GOTO CODA + END + "; + } + + private static string CreateCodaSql(bool includePreambleLockRelease, bool includeBusyWaitLockRelease) + { + return $@" + CODA: + {( + includePreambleLockRelease + ? $@"IF APPLOCK_MODE('public', @{PreambleLockNameVariable}, @{LockScopeVariable}) != 'NoLock' + EXEC sys.sp_releaseapplock @{PreambleLockNameVariable}, @{LockScopeVariable}" + : null + )} + {( + includeBusyWaitLockRelease + ? $@"IF APPLOCK_MODE('public', @{BusyWaitLockNameVariable}, @{LockScopeVariable}) != 'NoLock' + EXEC sys.sp_releaseapplock @{BusyWaitLockNameVariable}, @{LockScopeVariable}" + : null + )} + IF @{ResultCodeParameter} IS NULL AND @{LockResultVariable} < 0 + SET @{ResultCodeParameter} = @{LockResultVariable} + IF @{ResultCodeParameter} NOT IN ({SuccessCode}, {FinishedPreambleWithoutAcquiringCode}) + BEGIN + IF OBJECT_ID('tempdb..' + @{MarkerTableNameParameter}) IS NOT NULL + BEGIN + DECLARE @dropMarkerTableSql NVARCHAR(MAX) = 'DROP TABLE ' + @{MarkerTableNameParameter} + EXEC sp_executeSql @dropMarkerTableSql + END + END"; + } + + private static string Merge(params string[] parts) => string.Join(Environment.NewLine, parts); + #endregion +} diff --git a/src/DistributedLock.SqlServer/packages.lock.json b/src/DistributedLock.SqlServer/packages.lock.json new file mode 100644 index 00000000..9ef3b215 --- /dev/null +++ b/src/DistributedLock.SqlServer/packages.lock.json @@ -0,0 +1,1216 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Data.SqlClient": { + "type": "Direct", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "System.Buffers": "4.6.1", + "System.Data.Common": "4.3.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Memory": "4.6.3", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6", + "System.Text.RegularExpressions": "4.3.1" + } + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.6", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0", + "System.Memory": "4.6.3" + } + }, + "Microsoft.Data.SqlClient.SNI": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "p3Pm/+7oPSn4At6vKrttRpUOVdrcer3oZln0XeYZ94DTTQirUVzQy5QmHjdMmbyIaTaYb6BYf+8N7ob5t1ctQA==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1", + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "System.Data.Common": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "lm6E3T5u7BOuEH0u18JpbJHxBfOJPuCyl4Kg1RH10ktYLp5uEEE1xKrHW56/We4SnZpGAuCc9N0MJpSDhTHZGQ==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "vaoWjvkG1aenR2XdjaVivlCV9fADfgyhW5bZtXT23qaEea0lWiUljdQuze4E31vKM7ZWJaSUsbYIKE3rnzfZUg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.ValueTuple": "4.5.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.5" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4", + "System.ValueTuple": "4.5.0" + } + }, + "System.Text.RegularExpressions": { + "type": "Transitive", + "resolved": "4.3.1", + "contentHash": "N0kNRrWe4+nXOWlpLT4LAY5brb8caNFlUuIRpraCVMDLYutKkol1aV079rQjLuSxKMJT2SpBQsYX9xbcTMmzwg==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.ValueTuple": { + "type": "CentralTransitive", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + } + }, + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Data.SqlClient": { + "type": "Direct", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "8.0.1", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.6", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0", + "System.Memory": "4.6.3" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.CSharp": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "kaj6Wb4qoMuH3HySFJhxwQfe8R/sJsNJnANrvv8WdFPMoNbKY5htfNscv+LHCu5ipz+49m2e+WQXpLXr9XYemQ==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ComponentModel.Annotations": "5.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.Security.Cryptography.Cng": "5.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.CSharp": "4.5.0", + "Microsoft.IdentityModel.Logging": "7.7.1", + "System.Security.Cryptography.Cng": "4.5.0", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "System.ComponentModel.Annotations": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg==" + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "vaoWjvkG1aenR2XdjaVivlCV9fADfgyhW5bZtXT23qaEea0lWiUljdQuze4E31vKM7ZWJaSUsbYIKE3rnzfZUg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.5" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "jIMXsKn94T9JY7PvPq/tMfqa6GAaHpElRDpmG+SuL+D3+sTw2M8VhnibKnN8Tq+4JqbPJ/f+BwtLeDMEnzAvRg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Formats.Asn1": "8.0.1", + "System.Memory": "4.5.5", + "System.Security.Cryptography.Cng": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==", + "dependencies": { + "System.Memory": "4.5.5" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Data.SqlClient": { + "type": "Direct", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "8.0.1", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.6", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0", + "System.Memory": "4.6.3" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.CSharp": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "kaj6Wb4qoMuH3HySFJhxwQfe8R/sJsNJnANrvv8WdFPMoNbKY5htfNscv+LHCu5ipz+49m2e+WQXpLXr9XYemQ==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ComponentModel.Annotations": "5.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.Security.Cryptography.Cng": "5.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.CSharp": "4.5.0", + "Microsoft.IdentityModel.Logging": "7.7.1", + "System.Security.Cryptography.Cng": "4.5.0", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "System.ComponentModel.Annotations": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg==" + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.5" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "jIMXsKn94T9JY7PvPq/tMfqa6GAaHpElRDpmG+SuL+D3+sTw2M8VhnibKnN8Tq+4JqbPJ/f+BwtLeDMEnzAvRg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==", + "dependencies": { + "System.Formats.Asn1": "8.0.1", + "System.Security.Cryptography.Cng": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==", + "dependencies": { + "System.Memory": "4.5.5" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "distributedlock.core": { + "type": "Project" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs new file mode 100644 index 00000000..e9cc4ce5 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs @@ -0,0 +1,119 @@ +using NUnit.Framework; +using System.Diagnostics; + +namespace Medallion.Threading.Tests.Data; + +public abstract class ConnectionStringStrategyTestCases + where TLockProvider : TestingLockProvider, new() + where TStrategy : TestingConnectionStringSynchronizationStrategy, new() + where TDb : TestingPrimaryClientDb, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + /// + /// Tests that internally-owned connections are properly cleaned up by disposing the lock handle + /// + [Test] + public void TestConnectionDoesNotLeak() + { + // If the lock is based on a multi-ticket semaphore, then the first creation will claim N-1 connections. To avoid this messing with + // our count, we create a throwaway lock instance here to hold those connections using the default application name + this._lockProvider.CreateLock(nameof(TestConnectionDoesNotLeak)); + + // set a distinctive application name so that we can count how many connections are used + var applicationName = this._lockProvider.Strategy.Db.SetUniqueApplicationName(); + + var @lock = this._lockProvider.CreateLock(nameof(TestConnectionDoesNotLeak)); + for (var i = 0; i < 30; ++i) + { + using (@lock.Acquire()) + { + this._lockProvider.Strategy.Db.CountActiveSessions(applicationName).ShouldEqual(1, this.GetType().Name); + } + // still alive due to pooling, except in Oracle where the application name (client info) is not part of the pool key + Assert.That(this._lockProvider.Strategy.Db.CountActiveSessions(applicationName), Is.LessThanOrEqualTo(1), this.GetType().Name); + } + + using (var connection = this._lockProvider.Strategy.Db.CreateConnection()) + { + this._lockProvider.Strategy.Db.ClearPool(connection); + } + + // checking immediately seems flaky; likely clear pool finishing + // doesn't guarantee that SQL will immediately reflect the clear + var maxWaitForPoolsToClear = TimeSpan.FromSeconds(5); + var stopwatch = Stopwatch.StartNew(); + do + { + var activeCount = this._lockProvider.Strategy.Db.CountActiveSessions(applicationName); + if (activeCount == 0) { return; } + Thread.Sleep(10); + } + while (stopwatch.Elapsed < maxWaitForPoolsToClear); + + Assert.Fail("Connection was not released"); + } + + [Test] + [NonParallelizable, Retry(5)] // timing-sensitive + public void TestKeepaliveProtectsFromIdleSessionKiller() + { + var applicationName = this._lockProvider.Strategy.Db.SetUniqueApplicationName(); + + this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromSeconds(.05); + var @lock = this._lockProvider.CreateLock(Guid.NewGuid().ToString()); // use unique name due to retry + + var handle = @lock.Acquire(); + using var idleSessionKiller = new IdleSessionKiller(this._lockProvider.Strategy.Db, applicationName, idleTimeout: TimeSpan.FromSeconds(.5)); + Thread.Sleep(TimeSpan.FromSeconds(2)); + Assert.DoesNotThrow(handle.Dispose); + } + + /// + /// Demonstrates that we don't multi-thread the connection despite running keepalive queries + /// + [Test] + public void TestKeepaliveDoesNotCreateRaceCondition() + { + this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromMilliseconds(1); + + Assert.DoesNotThrow(() => + { + var @lock = this._lockProvider.CreateLock(nameof(TestKeepaliveDoesNotCreateRaceCondition)); + for (var i = 0; i < 25; ++i) + { + using (@lock.Acquire()) + { + Thread.Sleep(1); + } + } + }); + } + + // replicates issue from https://github.com/madelson/DistributedLock/issues/85 + [Test] + public async Task TestAccessingHandleLostTokenWhileKeepaliveActiveDoesNotBlock() + { + this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromMinutes(5); + + var @lock = this._lockProvider.CreateLock(string.Empty); + var handle = await @lock.TryAcquireAsync(); + if (handle != null) + { + var accessHandleLostTokenTask = Task.Run(() => + { + if (handle.HandleLostToken.CanBeCanceled) + { + handle.HandleLostToken.Register(() => { }); + } + }); + Assert.That(await accessHandleLostTokenTask.TryWaitAsync(TimeSpan.FromSeconds(5)), Is.True); + + // do this only on success; on failure we're likely deadlocked and dispose will hang + await handle.DisposeAsync(); + } + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Data/DbSemaphoreTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Data/DbSemaphoreTestCases.cs new file mode 100644 index 00000000..91240ac4 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Data/DbSemaphoreTestCases.cs @@ -0,0 +1,101 @@ +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Data; + +public abstract class DbSemaphoreTestCases + where TSemaphoreProvider : TestingSemaphoreProvider, new() + where TStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy, new() + where TDb : TestingDb, new() +{ + private TSemaphoreProvider _semaphoreProvider = default!; + + [SetUp] public void SetUp() => this._semaphoreProvider = new TSemaphoreProvider(); + [TearDown] public void TearDown() => this._semaphoreProvider.Dispose(); + + /// + /// This case and several that follow test "self-deadlock", where a semaphore acquire cannot possibly succeed because + /// the current connection owns all tickets. Since this can only happen when a connection/transaction is re-used, we require + /// on our providers. + /// + [Test] + public void TestSelfDeadlockThrowsOnInfiniteWait() + { + var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestSelfDeadlockThrowsOnInfiniteWait), maxCount: 2); + semaphore.Acquire(); + semaphore.Acquire(); + var ex = Assert.Catch(() => semaphore.Acquire()); + ex!.Message.Contains("Deadlock").ShouldEqual(true, ex.Message); + } + + [Test] + public void TestMultipleConnectionsCannotTriggerSelfDeadlock() + { + var semaphore1 = this._semaphoreProvider.CreateSemaphore(nameof(TestMultipleConnectionsCannotTriggerSelfDeadlock), maxCount: 2); + var semaphore2 = this._semaphoreProvider.CreateSemaphore(nameof(TestMultipleConnectionsCannotTriggerSelfDeadlock), maxCount: 2); + semaphore1.Acquire(); + semaphore2.Acquire(); + + var source = new CancellationTokenSource(); + var acquireTask = semaphore1.AcquireAsync(cancellationToken: source.Token).AsTask(); + acquireTask.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false); + source.Cancel(); + acquireTask.ContinueWith(t => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true); + acquireTask.Status.ShouldEqual(TaskStatus.Canceled); + } + + [Test] + public void TestSelfDeadlockWaitsOnSpecifiedTime() + { + var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestSelfDeadlockWaitsOnSpecifiedTime), maxCount: 1); + semaphore.Acquire(); + + var acquireTask = Task.Run(() => semaphore.TryAcquire(TimeSpan.FromSeconds(.2))); + acquireTask.Wait(TimeSpan.FromSeconds(.05)).ShouldEqual(false); + acquireTask.Wait(TimeSpan.FromSeconds(.3)).ShouldEqual(true); + acquireTask.Result.ShouldEqual(null); + } + + [Test] + public void TestSelfDeadlockWaitRespectsCancellation() + { + var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestSelfDeadlockWaitsOnSpecifiedTime), maxCount: 1); + semaphore.Acquire(); + + var source = new CancellationTokenSource(); + var acquireTask = semaphore.AcquireAsync(TimeSpan.FromSeconds(20), source.Token).AsTask(); + acquireTask.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false); + source.Cancel(); + acquireTask.ContinueWith(t => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true); + acquireTask.Status.ShouldEqual(TaskStatus.Canceled); + } + + [Test] + public void TestSameNameDifferentCounts() + { + var longTimeout = TimeSpan.FromSeconds(5); + + // if 2 semaphores have different views of what the max count is, things still kind of + // work. The semaphore with the higher count behaves normally. The semaphore with the lower + // count behaves normally when the number of contenders is below it's count. After that, it + // behaves unpredictably. For example, if we have counts 2 and 3 and the 3-semaphore holds 2 tickets, + // then the 2-semaphore might or might not be able to acquire a ticket depending on whether the + // 3-semaphore holds tickets 1&2 (no), 1&3 (yes), or 2&3 (yes). This test serves to document + // the behavior that is more well-defined + + var semaphore2 = this._semaphoreProvider.CreateSemaphore(nameof(TestSameNameDifferentCounts), 2); + var semaphore3 = this._semaphoreProvider.CreateSemaphore(nameof(TestSameNameDifferentCounts), 3); + + var handle1 = semaphore2.Acquire(longTimeout); + var handle2 = semaphore3.Acquire(longTimeout); + var handle3 = semaphore3.Acquire(longTimeout); + semaphore2.TryAcquire().ShouldEqual(null); + semaphore3.TryAcquire().ShouldEqual(null); + + handle1.Dispose(); + handle1 = semaphore3.Acquire(longTimeout); + + handle1.Dispose(); + handle2.Dispose(); + handle3.Dispose(); + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs new file mode 100644 index 00000000..12467d3c --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs @@ -0,0 +1,101 @@ +using NUnit.Framework; +using System.Data; +using System.Data.Common; +using System.Reflection; + +namespace Medallion.Threading.Tests.Data; + +public abstract class ExternalConnectionOrTransactionStrategyTestCases + where TLockProvider : TestingLockProvider, new() + where TStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy, new() + where TDb : TestingDb, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + [Test] + [NonParallelizable, Retry(tryCount: 3)] // timing sensitive for SqlSemaphore (see SQL_SEMAPHORE_ONE_WAIT) + public async Task TestDeadlockDetection() + { + var timeout = TimeSpan.FromSeconds(20); + + using var barrier = new Barrier(participantCount: 2); + const string LockName1 = nameof(TestDeadlockDetection) + "_1", + LockName2 = nameof(TestDeadlockDetection) + "_2"; + + Task RunDeadlockAsync(bool isFirst) + { + this._lockProvider.Strategy.StartAmbient(); + var lock1 = this._lockProvider.CreateLock(isFirst ? LockName1 : LockName2); + var lock2 = this._lockProvider.CreateLock(isFirst ? LockName2 : LockName1); + return Task.Run(async () => + { + using (await lock1.AcquireAsync(timeout)) + { + barrier.SignalAndWait(); + (await lock2.AcquireAsync(timeout)).Dispose(); + } + }); + } + + var tasks = new[] { RunDeadlockAsync(isFirst: true), RunDeadlockAsync(isFirst: false) }; + + (await Task.WhenAll(tasks).ContinueWith(_ => { }).TryWaitAsync(TimeSpan.FromSeconds(15))).ShouldEqual(true, this.GetType().Name); + + // MariaDB fails both tasks due to deadlock instead of just picking a single victim + Assert.That(tasks.Count(t => t.IsFaulted), Is.GreaterThanOrEqualTo(1)); + Assert.That(tasks.Count(t => t.Status == TaskStatus.RanToCompletion), Is.LessThanOrEqualTo(1)); + Assert.That(tasks.Where(t => t.IsCanceled), Is.Empty); + + foreach (var deadlockVictim in tasks.Where(t => t.IsFaulted)) + { + Assert.That(deadlockVictim.Exception!.GetBaseException(), Is.InstanceOf()); // backwards compat check + Assert.That(deadlockVictim.Exception.GetBaseException(), Is.InstanceOf()); + } + } + + [Test] + public async Task TestReAcquireLockOnSameConnection() + { + var @lock = this._lockProvider.CreateLock("lock"); + await using var handle = await @lock.AcquireAsync(); + Assert.ThrowsAsync(() => @lock.AcquireAsync().AsTask()); + Assert.ThrowsAsync(() => @lock.AcquireAsync(TimeSpan.FromSeconds(.01)).AsTask()); + } + + /// + /// Currently, we leverage to track handle loss. This test + /// validates that the handler is properly removed when the lock handle is disposed + /// + [Test] + public void TestStateChangeHandlerIsNotLeaked() + { + this._lockProvider.Strategy.StartAmbient(); + + // creating this first assures that the Semaphore5 provider's handlers get included in initial + var @lock = this._lockProvider.CreateLock(nameof(TestStateChangeHandlerIsNotLeaked)); + + var initialHandler = GetStateChanged(this._lockProvider.Strategy.AmbientConnection!); + + using (@lock.Acquire()) + { + Assert.That(GetStateChanged(this._lockProvider.Strategy.AmbientConnection!), Is.Not.Null); + } + + GetStateChanged(this._lockProvider.Strategy.AmbientConnection!).ShouldEqual(initialHandler); + + static StateChangeEventHandler? GetStateChanged(DbConnection connection) => + // We check both the connection type and the base type because OracleConnection overrides the storage for + // the StateChange event handler + (StateChangeEventHandler?)new[] { connection.GetType(), typeof(DbConnection) } + .Select( + t => t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) + .Where(f => f.FieldType == typeof(StateChangeEventHandler)) + .SingleOrDefault() + ) + .First(f => f != null)! + .GetValue(connection); + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs new file mode 100644 index 00000000..cd42e918 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs @@ -0,0 +1,62 @@ +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Data; + +public abstract class ExternalConnectionStrategyTestCases + where TLockProvider : TestingLockProvider>, new() + where TDb : TestingDb, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + [Test] + public void TestCloseLockOnClosedConnection() + { + var nonAmbientConnectionLock = this._lockProvider.CreateLock(nameof(TestCloseLockOnClosedConnection)); + + // Disable pooling for the ambient connection. This is important because we want to show that the lock + // will get released; in reality for a pooled connection in this scenario the lock-holding connection will + // return to the pool and would get released the next time that connection was fetched from the pool + this._lockProvider.Strategy.Db.ConnectionStringBuilder["Pooling"] = false; + this._lockProvider.Strategy.StartAmbient(); + var ambientConnectionLock = this._lockProvider.CreateLock(nameof(TestCloseLockOnClosedConnection)); + + this._lockProvider.Strategy.AmbientConnection!.Close(); + + Assert.Catch(() => ambientConnectionLock.Acquire()); + + this._lockProvider.Strategy.AmbientConnection!.Open(); + + var handle = ambientConnectionLock.Acquire(); + + nonAmbientConnectionLock.IsHeld().ShouldEqual(true, this.GetType().Name); + + this._lockProvider.Strategy.AmbientConnection!.Close(); + + // Note: in version 1.0 we'd avoid throwing in this scenario. However, that approach could hide bugs because + // merely closing the connection doesn't release the lock: it just returns the connection to the pool where + // it will continue to hold the lock until it is used again. + Assert.Throws(() => handle.Dispose()); + + // lock can be re-acquired + nonAmbientConnectionLock.IsHeld().ShouldEqual(false); + } + + [Test] + public void TestIsNotScopedToTransaction() + { + var nonAmbientConnectionLock = this._lockProvider.CreateLock(nameof(TestIsNotScopedToTransaction)); + + this._lockProvider.Strategy.StartAmbient(); + + using var handle = this._lockProvider.CreateLock(nameof(TestIsNotScopedToTransaction)).Acquire(); + using (var transaction = this._lockProvider.Strategy.AmbientConnection!.BeginTransaction()) + { + transaction.Rollback(); + } + + nonAmbientConnectionLock.IsHeld().ShouldEqual(true, this.GetType().Name); + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs new file mode 100644 index 00000000..ea4595a3 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs @@ -0,0 +1,141 @@ +using NUnit.Framework; +using System.Data.Common; +using System.Runtime.CompilerServices; + +namespace Medallion.Threading.Tests.Data; + +public abstract class ExternalTransactionStrategyTestCases + where TLockProvider : TestingLockProvider>, new() + where TDb : TestingDb, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + [Test] + public void TestScopedToTransactionOnly() + { + this._lockProvider.Strategy.StartAmbient(); + + var ambientTransactionLock = this._lockProvider.CreateLock(nameof(TestScopedToTransactionOnly)); + using (ambientTransactionLock.Acquire()) + { + Assert.That(this._lockProvider.CreateLock(nameof(TestScopedToTransactionOnly)).IsHeld(), Is.True); + + // create a lock of the same type on the underlying connection of the ambient transaction + using dynamic specificConnectionProvider = Activator.CreateInstance( + ReplaceGenericParameter(typeof(TLockProvider), this._lockProvider.Strategy.GetType(), typeof(SpecificConnectionStrategy)) + )!; + specificConnectionProvider.Strategy.Test = this; + Assert.Catch(() => ((IDistributedLock)specificConnectionProvider.CreateLock(nameof(TestScopedToTransactionOnly))).Acquire()); + } + + static Type ReplaceGenericParameter(Type type, Type old, Type @new) + { + if (type == old) { return @new; } + if (!type.IsConstructedGenericType) { return type; } + + var newGenericArguments = type.GetGenericArguments() + .Select(a => ReplaceGenericParameter(a, old, @new)) + .ToArray(); + return type.GetGenericTypeDefinition() + .MakeGenericType(newGenericArguments); + } + } + + /// + /// Special strategy designed to allow us to make connection-scoped locks using the same connection as + /// the ambient transaction from our own + /// + private class SpecificConnectionStrategy : TestingDbSynchronizationStrategy + { + public ExternalTransactionStrategyTestCases? Test { get; set; } + + public override TestingDbConnectionOptions GetConnectionOptions() => + new() { Connection = this.Test!._lockProvider.Strategy.AmbientTransaction!.Connection }; + } + + public void TestCloseTransactionLockOnClosedConnectionOrTransaction([Values] bool closeConnection) + { + var lockName = closeConnection ? "Connection" : "Transaction"; + + var nonAmbientTransactionLock = this._lockProvider.CreateLock(lockName); + + // Disable pooling for the ambient connection. This is important because we want to show that the lock + // will get released; in reality for a pooled connection in this scenario the lock-holding connection will + // return to the pool and would get released the next time that connection was fetched from the pool + this._lockProvider.Strategy.Db.ConnectionStringBuilder["Pooling"] = false; + this._lockProvider.Strategy.StartAmbient(); + var ambientTransactionLock = this._lockProvider.CreateLock(lockName); + + using var handle = ambientTransactionLock.Acquire(); + Assert.That(nonAmbientTransactionLock.IsHeld(), Is.True); + + if (closeConnection) + { + this._lockProvider.Strategy.AmbientTransaction!.Connection!.Dispose(); + } + else + { + this._lockProvider.Strategy.AmbientTransaction!.Dispose(); + } + Assert.DoesNotThrow(handle.Dispose); + + // now lock can be re-acquired + Assert.That(nonAmbientTransactionLock.IsHeld(), Is.False); + } + + [Test] + public void TestLockOnRolledBackTransaction() => this.TestLockOnCompletedTransactionHelper(t => t.Rollback()); + + [Test] + public void TestLockOnCommittedTransaction() => this.TestLockOnCompletedTransactionHelper(t => t.Commit()); + + [Test] + public void TestLockOnDisposedTransaction() => this.TestLockOnCompletedTransactionHelper(t => t.Dispose()); + + private void TestLockOnCompletedTransactionHelper(Action complete, [CallerMemberName] string lockName = "") + { + var nonAmbientTransactionLock = this._lockProvider.CreateLock(lockName); + + // Disable pooling for the ambient connection. This is important because we want to show that the lock + // will get released; in reality for a pooled connection in this scenario the lock-holding connection will + // return to the pool and would get released the next time that connection was fetched from the pool + this._lockProvider.Strategy.Db.ConnectionStringBuilder["Pooling"] = false; + this._lockProvider.Strategy.StartAmbient(); + var ambientTransactionLock = this._lockProvider.CreateLock(lockName); + + using var handle = ambientTransactionLock.Acquire(); + Assert.That(nonAmbientTransactionLock.IsHeld(), Is.True); + + complete(this._lockProvider.Strategy.AmbientTransaction!); + + var transactionSupport = this._lockProvider.Strategy.Db.TransactionSupport; + if (transactionSupport == TransactionSupport.ExplicitParticipation) + { + // this will throw because the lock will still be trying to use the transaction and we've ended it + Assert.Throws(handle.Dispose); + } + else + { + Assert.DoesNotThrow(handle.Dispose); + } + + nonAmbientTransactionLock.IsHeld() + // explicit participation will fail to release above, so it is still held + .ShouldEqual(transactionSupport == TransactionSupport.ExplicitParticipation ? true : false); + + if (transactionSupport == TransactionSupport.ImplicitParticipation) + { + // If we use transactions implicitly then we can keep using our lock without issue + // because we're just using the underlyign connection which is still good. + Assert.DoesNotThrow(() => ambientTransactionLock.Acquire().Dispose()); + } + else + { + // Otherwise we'll fail to use a transaction that has been ended + Assert.Catch(() => ambientTransactionLock.Acquire()); + } + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs new file mode 100644 index 00000000..6f9f8d26 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs @@ -0,0 +1,147 @@ +using Medallion.Threading.Internal; +using NUnit.Framework; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Medallion.Threading.Tests.Data; + +public abstract class MultiplexingConnectionStrategyTestCases + where TLockProvider : TestingLockProvider>, new() + where TDb : TestingPrimaryClientDb, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + /// + /// Similar to but demonstrates + /// the time-based cleanup loop rather than forcing a cleanup + /// + [Test, NonParallelizable] // timing sensitive + public void TestLockAbandonmentWithTimeBasedCleanupRun() + { + var lock1 = this._lockProvider.CreateLock(nameof(this.TestLockAbandonmentWithTimeBasedCleanupRun)); + var lock2 = this._lockProvider.CreateLock(nameof(this.TestLockAbandonmentWithTimeBasedCleanupRun)); + var handleReference = this.TestCleanupHelper(lock1, lock2); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + handleReference.IsAlive.ShouldEqual(false); + + // We might get lucky and wait for less than the cadence based on how the timing works out. However, + // due to system load we might also need to wait longer than the cadence. To be safe, we wait for up + // to 2x the cadence but check in frequently to see if we can finish early. + var maxWait = TimeSpan.FromSeconds(2 * ManagedFinalizerQueue.FinalizerCadence.TotalSeconds); + var stopwatch = Stopwatch.StartNew(); + while (lock2.IsHeld()) + { + if (stopwatch.Elapsed > maxWait) + { + Assert.Fail(this.GetType().ToString()); + } + Thread.Sleep(TimeSpan.FromSeconds(.25)); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] // need to isolate for GC + private WeakReference TestCleanupHelper(IDistributedLock lock1, IDistributedLock lock2) + { + var handle = lock1.Acquire(); + + Assert.That(lock2.TryAcquireAsync().AsTask().Result, Is.Null); + + return new WeakReference(handle); + } + + /// + /// This method demonstrates how multiplexing can be used to hold many locks concurrently on one underlying connection. + /// + /// Note: I would like this test to actually leverage multiple threads, but this runs into issues because the current + /// implementation of optimistic multiplexing only makes one attempt to use a shared lock before opening a new connection. + /// This runs into problems because the attempt to use a shared lock can fail if, for example, a lock is being released on + /// that connection which means that the mutex for the connection can't be acquired without waiting. Once something like + /// this happens, we try to open a new connection which times out due to pool size limits + /// + [Test] + public void TestHighConcurrencyWithSmallPool() + { + const int LockNameCount = 20; + + // Pre-generate all locks we will use. This is necessary for our Semaphore5 strategy, where the first lock created + // takes 4 of the 5 tickets (and thus may need more connections than a single-connection pool can support). For other + // lock types this does nothing since creating a lock might open a connection but otherwise won't run any commands + for (var i = 0; i < LockNameCount; ++i) + { + this._lockProvider.CreateLock(MakeLockName(i)); + } + + // Multiplexing is not allowed for upgrade locks since the upgrade operation could block. Therefore + // we don't allow a lock provider based on a RW lock to use its upgrade lock as an exclusive lock + if (this._lockProvider is ITestingReaderWriterLockAsMutexProvider readerWriterAsMutexProvider) + { + readerWriterAsMutexProvider.DisableUpgradeLock = true; + } + + // assign a unique app name to make sure we'll own the entire pool + this._lockProvider.Strategy.Db.SetUniqueApplicationName(); + this._lockProvider.Strategy.Db.MaxPoolSize = 1; + + async Task Test() + { + var random = new Random(12345); + + var heldLocks = new Dictionary(); + for (var i = 0; i < 1000; ++i) + { + var lockName = MakeLockName(random.Next(20)); + if (heldLocks.TryGetValue(lockName, out var existingHandle)) + { + existingHandle.Dispose(); + heldLocks.Remove(lockName); + } + else + { + var @lock = this._lockProvider.CreateLock(lockName); + var handle = await @lock.TryAcquireAsync(); + if (handle != null) { heldLocks.Add(lockName, handle); } + } + } + + foreach (var remainingHandle in heldLocks.Values) + { + remainingHandle.Dispose(); + } + }; + + Assert.That(Task.Run(Test).Wait(Debugger.IsAttached ? TimeSpan.FromMinutes(10) : TimeSpan.FromSeconds(10)), Is.True); + + string MakeLockName(int i) => $"{nameof(TestHighConcurrencyWithSmallPool)}_{i}"; + } + + [Test] + public async Task TestBrokenConnectionDoesNotCorruptPool() + { + // This makes sure that for the Semaphore5 lock initial 4 tickets are taken with the default + // application name and therefore won't be killed + this._lockProvider.CreateLock("1"); + this._lockProvider.CreateLock("2"); + var applicationName = this._lockProvider.Strategy.Db.SetUniqueApplicationName(); + + var lock1 = this._lockProvider.CreateLock("1"); + await using var handle1 = await lock1.AcquireAsync(); + + // kill the session + await this._lockProvider.Strategy.Db.KillSessionsAsync(applicationName); + + var lock2 = this._lockProvider.CreateLock("2"); + Assert.DoesNotThrowAsync(async () => await (await lock2.AcquireAsync()).DisposeAsync()); + + await using var handle2 = await lock2.AcquireAsync(); + Assert.DoesNotThrow(() => lock2.TryAcquire()?.Dispose()); + + Assert.Catch(handle1.Dispose); + + Assert.DoesNotThrowAsync(async () => await (await lock1.AcquireAsync()).DisposeAsync()); + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs new file mode 100644 index 00000000..1e25fb3c --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs @@ -0,0 +1,58 @@ +using NUnit.Framework; +using System.Data.Common; +using System.Diagnostics; + +namespace Medallion.Threading.Tests.Data; + +public abstract class OwnedConnectionStrategyTestCases + where TLockProvider : TestingLockProvider>, new() + where TDb : TestingPrimaryClientDb, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + /// + /// Tests that our idle session killer works, therefore validating our other tests that use it. + /// + /// We test this here rather than in + /// because (a) we don't need to repeat the test for both regular and multiplexed and (2) for owned-transaction the test won't + /// pass because you can safely Dispose a transaction on a killed SQL connection + /// + [Test] + public void TestIdleSessionKiller() + { + // This makes sure that for the Semaphore5 lock initial 4 tickets are taken with the default + // application name and therefore won't be counted or killed + this._lockProvider.CreateLock(nameof(TestIdleSessionKiller)); + + var applicationName = this._lockProvider.Strategy.Db.SetUniqueApplicationName(); + var @lock = this._lockProvider.CreateLock(nameof(TestIdleSessionKiller)); + + // go through one acquire/dispose cycle to ensure all commands are prepared. Due to + // https://github.com/npgsql/npgsql/issues/2912 in Postgres, we get NRE on the post-kill Dispose() + // call rather than the DbException we expected. + @lock.Acquire().Dispose(); + + using var handle = @lock.Acquire(); + this._lockProvider.Strategy.Db.CountActiveSessions(applicationName).ShouldEqual(1); + + using var idleSessionKiller = new IdleSessionKiller(this._lockProvider.Strategy.Db, applicationName, idleTimeout: TimeSpan.FromSeconds(.1)); + var stopwatch = Stopwatch.StartNew(); + while (true) + { + Thread.Sleep(TimeSpan.FromSeconds(.02)); + if (this._lockProvider.Strategy.Db.CountActiveSessions(applicationName) == 0) + { + break; + } + if (stopwatch.Elapsed > TimeSpan.FromSeconds(5)) + { + Assert.Fail("Timed out waiting for idle session to be killed"); + } + } + + Assert.Catch(handle.Dispose); + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs new file mode 100644 index 00000000..31fc9126 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs @@ -0,0 +1,76 @@ +using NUnit.Framework; +using System.Data; + +namespace Medallion.Threading.Tests.Data; + +public abstract class OwnedTransactionStrategyTestCases + where TLockProvider : TestingLockProvider>, new() + where TDb : TestingPrimaryClientDb, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + /// + /// Validates that we use the default isolation level to avoid the problem described + /// here: https://msdn.microsoft.com/en-us/library/5ha4240h(v=vs.110).aspx + /// + /// From MSDN: + /// After a transaction is committed or rolled back, the isolation level of the transaction + /// persists for all subsequent commands that are in autocommit mode (the SQL Server default). + /// This can produce unexpected results, such as an isolation level of REPEATABLE READ persisting + /// and locking other users out of a row. To reset the isolation level to the default (READ COMMITTED), + /// execute the Transact-SQL SET TRANSACTION ISOLATION LEVEL READ COMMITTED statement, or call + /// SqlConnection.BeginTransaction followed immediately by SqlTransaction.Commit. For more + /// information on SQL Server isolation levels, see "Isolation Levels in the Database Engine" in SQL + /// Server Books Online. + /// + /// This obviously only applies to SQLServer currently. However, we might as well run this test against + /// other providers in case they have the same issue. + /// + [Test] + public void TestIsolationLevelLeakage() + { + // Needed because MySQL has RepeatableRead while SqlServer and Postgres have ReadCommitted + IsolationLevel defaultIsolationLevel; + using (var connection = this._lockProvider.Strategy.Db.CreateConnection()) + { + connection.Open(); + try + { + defaultIsolationLevel = this._lockProvider.Strategy.Db.GetIsolationLevel(connection); + } + catch (NotSupportedException) + { + Assert.Pass("Getting isolation level not supported"); + throw; + } + } + + // Pre-generate the lock we will use. This is necessary for our Semaphore5 strategy, where the first lock created + // takes 4 of the 5 tickets (and thus may need more connections than a single-connection pool can support). For other + // lock types this does nothing since creating a lock might open a connection but otherwise won't run any commands + this._lockProvider.CreateLock(nameof(TestIsolationLevelLeakage)); + + // use a unique pool of size 1 so we can reclaim the connection after we use it and test for leaks + this._lockProvider.Strategy.Db.SetUniqueApplicationName(); + this._lockProvider.Strategy.Db.MaxPoolSize = 1; + + AssertHasDefaultIsolationLevel(); + + var @lock = this._lockProvider.CreateLock(nameof(TestIsolationLevelLeakage)); + @lock.Acquire().Dispose(); + AssertHasDefaultIsolationLevel(); + + @lock.AcquireAsync().Result.Dispose(); + AssertHasDefaultIsolationLevel(); + + void AssertHasDefaultIsolationLevel() + { + using var connection = this._lockProvider.Strategy.Db.CreateConnection(); + connection.Open(); + this._lockProvider.Strategy.Db.GetIsolationLevel(connection).ShouldEqual(defaultIsolationLevel); + } + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs new file mode 100644 index 00000000..c66540e3 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs @@ -0,0 +1,62 @@ +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Data; + +public abstract class UpgradeableReaderWriterLockConnectionStringStrategyTestCases + where TLockProvider : TestingUpgradeableReaderWriterLockProvider, new() + where TStrategy : TestingConnectionStringSynchronizationStrategy, new() + where TDb : TestingPrimaryClientDb, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + /// + /// Tests the logic where upgrading a connection stops and restarts the keepalive + /// + [Test] + [NonParallelizable, Retry(tryCount: 5)] // this test is somewhat timing sensitive + public void TestKeepaliveProtectsFromIdleSessionKillerAfterFailedUpgrade() + { + var applicationName = this._lockProvider.Strategy.Db.SetUniqueApplicationName(); + + this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromSeconds(.1); + var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(Guid.NewGuid().ToString()); + + using var idleSessionKiller = new IdleSessionKiller(this._lockProvider.Strategy.Db, applicationName, idleTimeout: TimeSpan.FromSeconds(2)); + + using (@lock.AcquireReadLock()) + { + var handle = @lock.AcquireUpgradeableReadLock(); + handle.TryUpgradeToWriteLock().ShouldEqual(false); + handle.TryUpgradeToWriteLockAsync().Result.ShouldEqual(false); + Thread.Sleep(TimeSpan.FromSeconds(4)); + Assert.DoesNotThrow(() => handle.Dispose()); + } + } + + /// + /// Demonstrates that we don't multi-thread the connection despite running keepalive queries + /// + /// This test is similar to , + /// but in this case we additionally test lock upgrading which must pause and restart the keepalive process. + /// + [Test] + public void TestKeepaliveDoesNotCreateRaceCondition() + { + this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromMilliseconds(1); + + Assert.DoesNotThrow(() => + { + var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestKeepaliveDoesNotCreateRaceCondition)); + for (var i = 0; i < 30; ++i) + { + using var handle = @lock.AcquireUpgradeableReadLockAsync().Result; + Thread.Sleep(1); + handle.UpgradeToWriteLock(); + Thread.Sleep(1); + } + }); + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs new file mode 100644 index 00000000..77d791a4 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs @@ -0,0 +1,489 @@ +using Medallion.Shell; +using Medallion.Threading.Internal; +using NUnit.Framework; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +namespace Medallion.Threading.Tests; + +public abstract class DistributedLockCoreTestCases + where TLockProvider : TestingLockProvider, new() + where TStrategy : TestingSynchronizationStrategy, new() +{ + private TLockProvider _lockProvider = default!; + private readonly List _cleanupActions = []; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + + [TearDown] + public void TearDown() + { + this._cleanupActions.ForEach(a => a()); + this._cleanupActions.Clear(); + this._lockProvider.Dispose(); + } + + [Test] + public void BasicTest() + { + var @lock = this._lockProvider.CreateLock(nameof(BasicTest)); + var lock2 = this._lockProvider.CreateLock(nameof(BasicTest) + "2"); + + using (var handle = @lock.TryAcquire()) + { + Assert.That(handle, Is.Not.Null, this.GetType() + ": should be able to acquire new lock"); + + using (var nestedHandle = @lock.TryAcquire()) + { + Assert.That(nestedHandle, Is.Null, "should not be reentrant"); + } + + using var nestedHandle2 = lock2.TryAcquire(); + Assert.That(nestedHandle2, Is.Not.Null, this.GetType() + ": should be able to acquire a different lock"); + } + + using (var handle = @lock.TryAcquire()) + { + Assert.That(handle, Is.Not.Null, this.GetType() + ": should be able to re-acquire after releasing"); + } + } + + [Test] + public async Task BasicAsyncTest() + { + // note: we intentionally have a mix of await using vs using and await + // vs .Result here to excercise various code paths + + var @lock = this._lockProvider.CreateLock(nameof(BasicAsyncTest)); + var lock2 = this._lockProvider.CreateLock(nameof(BasicAsyncTest) + "2"); + + await using (var handle = await @lock.TryAcquireAsync()) + { + Assert.That(handle, Is.Not.Null, this.GetType().Name); + + using (var nestedHandle = await @lock.TryAcquireAsync()) + { + Assert.That(nestedHandle, Is.Null, this.GetType().Name); + } + + await using var nestedHandle2 = lock2.TryAcquireAsync().AsTask().Result; + Assert.That(nestedHandle2, Is.Not.Null, this.GetType().Name); + } + + await using (var handle = await @lock.TryAcquireAsync()) + { + Assert.That(handle, Is.Not.Null, this.GetType().Name); + } + } + + [Test] + public void TestBadArguments() + { + var @lock = this._lockProvider.CreateLock(nameof(TestBadArguments)); + Assert.Catch(() => @lock.Acquire(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.AcquireAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.TryAcquire(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.TryAcquireAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.Acquire(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.AcquireAsync(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.TryAcquire(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.TryAcquireAsync(TimeSpan.FromSeconds(int.MaxValue))); + } + + [Test] + public void TestDisposeHandleIsIdempotent() + { + var @lock = this._lockProvider.CreateLock(nameof(TestDisposeHandleIsIdempotent)); + var handle = @lock.Acquire(TimeSpan.FromSeconds(30)); + Assert.That(handle, Is.Not.Null); + handle.Dispose(); + var handle2 = @lock.Acquire(TimeSpan.FromSeconds(30)); + Assert.DoesNotThrow(handle.Dispose); + Assert.DoesNotThrow(handle2.Dispose); + } + + [Test] + [NonParallelizable, Retry(tryCount: 3)] // timing-sensitive + public async Task TestTimeouts() + { + // use a randomized name in case we end up retrying + var lockName = Guid.NewGuid().ToString(); + + var @lock = this._lockProvider.CreateLock(lockName); + // acquire with a different lock instance to avoid reentrancy mattering + await using (await this._lockProvider.CreateLock(lockName).AcquireAsync()) + { + var timeout = TimeSpan.FromSeconds(.2); + var waitTime = TimeSpan.FromSeconds(.5); + + var syncAcquireTask = Task.Run(() => @lock.Acquire(timeout)); + (await syncAcquireTask.ContinueWith(_ => { }).TryWaitAsync(waitTime)).ShouldEqual(true, "sync acquire " + this.GetType().Name); + Assert.That(syncAcquireTask.Exception?.InnerException, Is.InstanceOf(), "sync acquire " + this.GetType().Name); + + var asyncAcquireTask = @lock.AcquireAsync(timeout).AsTask(); + (await asyncAcquireTask.ContinueWith(_ => { }).TryWaitAsync(waitTime)).ShouldEqual(true, "async acquire " + this.GetType().Name); + Assert.That(asyncAcquireTask.Exception!.InnerException, Is.InstanceOf(), "async acquire " + this.GetType().Name); + + var syncTryAcquireTask = Task.Run(() => @lock.TryAcquire(timeout)); + (await syncTryAcquireTask.TryWaitAsync(waitTime)).ShouldEqual(true, "sync tryAcquire " + this.GetType().Name); + syncTryAcquireTask.Result.ShouldEqual(null, "sync tryAcquire " + this.GetType().Name); + + var asyncTryAcquireTask = @lock.TryAcquireAsync(timeout).AsTask(); + (await asyncTryAcquireTask.TryWaitAsync(waitTime)).ShouldEqual(true, "async tryAcquire " + this.GetType().Name); + asyncTryAcquireTask.Result.ShouldEqual(null, "async tryAcquire " + this.GetType().Name); + } + } + + [Test] + public void CancellationTest() + { + var lockName = nameof(CancellationTest); + var @lock = this._lockProvider.CreateLock(lockName); + + var source = new CancellationTokenSource(); + using (var handle = this._lockProvider.CreateLock(lockName).Acquire()) + { + // Task.Run() forces true asynchrony even for locks that don't support it + var blocked = Task.Run(() => @lock.AcquireAsync(cancellationToken: source.Token).AsTask()); + blocked.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false); + source.Cancel(); + blocked.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true, this.GetType().Name); + blocked.Status.ShouldEqual(TaskStatus.Canceled, (blocked.Exception ?? (object)"no exception").ToString()); + } + + // already canceled + source = new CancellationTokenSource(); + source.Cancel(); + Assert.Catch(() => @lock.Acquire(cancellationToken: source.Token)); + } + + [Test] + public async Task TestParallelism() + { + var taskCount = 100; + this._lockProvider.Strategy.PrepareForHighContention(ref taskCount); + + // NOTE: if this test fails for Postgres, we may need to raise the default connection limit. This can + // be done by setting max_connections in C:\Program Files\PostgreSQL\\data\postgresql.conf or + // /var/lib/pgsql//data/postgresql.conf and then restarting Postgres. I set max_connections = 10000. + // See https://docs.alfresco.com/5.0/tasks/postgresql-config.html + + var locks = Enumerable.Range(0, taskCount) + .Select(_ => this._lockProvider.CreateLock("parallel_test")) + .ToArray(); + var counter = 0; + // Task.Run() ensures true parallelism even for locks that don't support it + var tasks = Enumerable.Range(0, taskCount).Select(i => Task.Run(async () => + { + await using (await locks[i].AcquireAsync()) + { + // increment going in + if (Interlocked.Increment(ref counter) == 2) + { + Assert.Fail($"Concurrent lock acquisitions ({this.GetType()}"); + } + + // hang out for a bit to ensure concurrency + await Task.Delay(TimeSpan.FromMilliseconds(10)); + + // decrement and return on the way out (returns # inside the lock when this left ... should be 0) + return Interlocked.Decrement(ref counter); + } + })) + .ToList(); + + var failure = new TaskCompletionSource(); + foreach (var task in tasks) + { + _ = task.ContinueWith(t => failure.TrySetException(t.Exception!), TaskContinuationOptions.OnlyOnFaulted); + } + + var timeout = Task.Delay(TimeSpan.FromSeconds(30)); + + var completed = await Task.WhenAny(Task.WhenAll(tasks), failure.Task, timeout); + Assert.That(completed, Is.Not.SameAs(failure.Task), $"Failed with {(failure.Task.IsFaulted ? failure.Task.Exception!.ToString() : null)}"); + Assert.That(completed, Is.Not.SameAs(timeout), $"Timed out! (only {tasks.Count(t => t.IsCompleted)}/{taskCount} completed)"); + + tasks.ForEach(t => t.Result.ShouldEqual(0)); + } + + [Test] + [NonParallelizable] // takes locks with known names + public void TestGetSafeName() + { + Assert.Catch(() => this._lockProvider.GetSafeName(null!)); + + foreach (var name in new[] { string.Empty, new string('a', 1000), @"\\\\\", new string('\\', 1000) }) + { + var safeName = this._lockProvider.GetSafeName(name); + Assert.DoesNotThrow(() => this._lockProvider.CreateLockWithExactName(safeName).Acquire(TimeSpan.FromSeconds(10)).Dispose(), $"{this.GetType().Name}: could not acquire '{name}'"); + } + } + + [Test] + public void TestGetSafeLockNameIsCaseSensitive() + { + var longName1 = new string('a', 1000); + var longName2 = new string('a', longName1.Length - 1) + "A"; + StringComparer.OrdinalIgnoreCase.Equals(longName1, longName2).ShouldEqual(true, "sanity check"); + + Assert.That(this._lockProvider.GetSafeName(longName2), Is.Not.EqualTo(this._lockProvider.GetSafeName(longName1))); + } + + [Test] + public async Task TestLockNamesAreCaseSensitive() + { + // the goal here is to construct 2 valid lock names that differ only by case. We start by generating a hash name + // that is unique to this test yet stable across runs. Then we truncate it to avoid need for further hashing in Postgres + // (which only supports very short ASCII string names). Finally, we re-run through GetSafeName to pick up any special prefix + // that is needed (e. g. for wait handles) + using var sha1 = SHA1.Create(); + var uniqueHashName = BitConverter.ToString(sha1.ComputeHash(Encoding.UTF8.GetBytes(this._lockProvider.GetUniqueSafeName()))) + .Replace("-", string.Empty) + // normalize to upper case per https://docs.microsoft.com/en-us/visualstudio/code-quality/ca1308?view=vs-2019 + .ToUpperInvariant(); + var lowerBaseName = $"{uniqueHashName.Substring(0, 6)}_a"; + var lowerName = this._lockProvider.GetSafeName(lowerBaseName); + var upperBaseName = $"{uniqueHashName.Substring(0, 6)}_A"; + var upperName = this._lockProvider.GetSafeName(upperBaseName); + // make sure we succeeded in generating what we set out to generate + Assert.That(upperName, Is.Not.EqualTo(lowerName)); + if (StringComparer.OrdinalIgnoreCase.Equals(lowerName, upperName)) + { + // if the names vary only by case, test that they are different locks + await using (await this._lockProvider.CreateLockWithExactName(lowerName).AcquireAsync()) + await using (var handle = await this._lockProvider.CreateLockWithExactName(upperName).TryAcquireAsync()) + { + Assert.That(handle, Is.Not.Null); + } + } + else + { + // otherwise, check that the names still contain the suffixes we added + Assert.That(lowerName.IndexOf(lowerBaseName, StringComparison.OrdinalIgnoreCase) >= 0, Is.True); + Assert.That(upperName.IndexOf(upperBaseName, StringComparison.OrdinalIgnoreCase) >= 0, Is.True); + } + } + + [Test] + public void TestCanceledAlreadyThrowsForSyncAndDoesNotThrowForAsync() + { + using var source = new CancellationTokenSource(); + source.Cancel(); + + var @lock = this._lockProvider.CreateLock("already-canceled"); + + Assert.Catch(() => @lock.Acquire(cancellationToken: source.Token)); + Assert.Catch(() => @lock.TryAcquire(cancellationToken: source.Token)); + + var acquireTask = @lock.AcquireAsync(cancellationToken: source.Token).AsTask(); + acquireTask.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true); + acquireTask.IsCanceled.ShouldEqual(true, "acquire"); + + var tryAcquireTask = @lock.TryAcquireAsync(cancellationToken: source.Token).AsTask(); + tryAcquireTask.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true); + tryAcquireTask.IsCanceled.ShouldEqual(true, "tryAcquire"); + } + + [Test] + public async Task TestHandleLostTriggersCorrectly() + { + // pre-create the lock so that semaphore5 tickets don't get created on the connection + // we're going to kill + this._lockProvider.CreateLock(nameof(TestHandleLostTriggersCorrectly)); + + var handleLostHelper = this._lockProvider.Strategy.PrepareForHandleLost(); + + var @lock = this._lockProvider.CreateLock(nameof(TestHandleLostTriggersCorrectly)); + + CancellationToken handleLostToken; + await using (var notLostHandle = await @lock.AcquireAsync()) + { + handleLostToken = notLostHandle.HandleLostToken; + Assert.IsFalse(handleLostToken.IsCancellationRequested); + } + Assert.IsFalse(handleLostToken.IsCancellationRequested, "Token should not be canceled by manual release"); + + var handle = await @lock.AcquireAsync(); + try + { + handle.HandleLostToken.CanBeCanceled.ShouldEqual(handleLostHelper != null); + Assert.That(handle.HandleLostToken.IsCancellationRequested, Is.False); + + if (handleLostHelper != null) + { + using var canceledEvent = new ManualResetEventSlim(initialState: false); + using var registration = handle.HandleLostToken.Register(canceledEvent.Set); + + Assert.That(canceledEvent.Wait(TimeSpan.FromSeconds(.05)), Is.False); + + handleLostHelper.Dispose(); + + Assert.That(canceledEvent.Wait(TimeSpan.FromSeconds(10)), Is.True); + Assert.That(handle.HandleLostToken.IsCancellationRequested, Is.True); + } + } + finally + { + // when the handle is lost, Dispose() may throw + try { await handle.DisposeAsync(); } + catch { } + } + + Assert.Throws(() => handle.HandleLostToken.GetType()); + } + + [Test] + public async Task TestHandleLostReturnsAlreadyCanceledIfHandleAlreadyLost() + { + // pre-create the lock so that semaphore5 tickets don't get created on the connection + // we're going to kill + this._lockProvider.CreateLock(nameof(TestHandleLostReturnsAlreadyCanceledIfHandleAlreadyLost)); + + var handleLostHelper = this._lockProvider.Strategy.PrepareForHandleLost(); + if (handleLostHelper == null) { Assert.Pass(); } + + var @lock = this._lockProvider.CreateLock(nameof(TestHandleLostReturnsAlreadyCanceledIfHandleAlreadyLost)); + + using var handle = await @lock.AcquireAsync(); + + handleLostHelper!.Dispose(); + + using var canceledEvent = new ManualResetEventSlim(initialState: false); + handle.HandleLostToken.Register(canceledEvent.Set); + Assert.That(canceledEvent.Wait(TimeSpan.FromSeconds(5)), Is.True); + + // when the handle is lost, Dispose() may throw + try { handle.Dispose(); } + catch { } + } + + [Test] + public void TestCanSafelyDisposeWhileMonitoring() + { + var @lock = this._lockProvider.CreateLock(nameof(TestCanSafelyDisposeWhileMonitoring)); + + using var handle = @lock.Acquire(); + + // force monitoring to happen + using var canceledEvent = new ManualResetEventSlim(initialState: false); + using var registration = handle.HandleLostToken.Register(canceledEvent.Set); + Assert.That(canceledEvent.Wait(TimeSpan.FromSeconds(.05)), Is.False); + + Assert.DoesNotThrow(handle.Dispose); + } + + [Test] + public async Task TestLockAbandonment() + { + const string LockName = nameof(TestLockAbandonment); + + // pre-create the lock so that the semaphore5 provider will allocate the extra tickets + // against a connection that won't get cleand up when we force additional cleanup + this._lockProvider.CreateLock(LockName); + + this._lockProvider.Strategy.PrepareForHandleAbandonment(); + new Action(name => this._lockProvider.CreateLock(name).Acquire())(LockName); + GC.Collect(); + GC.WaitForPendingFinalizers(); + await ManagedFinalizerQueue.Instance.FinalizeAsync(); + this._lockProvider.Strategy.PerformAdditionalCleanupForHandleAbandonment(); + + using var handle = this._lockProvider.CreateLock(LockName).TryAcquire(); + Assert.That(handle, Is.Not.Null, this.GetType().Name); + } + + [Test] + public void TestCrossProcess() + { + var lockName = this._lockProvider.GetUniqueSafeName(); + var command = this.RunLockTaker(this._lockProvider, this._lockProvider.GetCrossProcessLockType(), lockName); + Assert.That(command.StandardOutput.ReadLineAsync().Wait(TimeSpan.FromSeconds(10)), Is.True); + Assert.That(command.Task.Wait(TimeSpan.FromSeconds(.1)), Is.False); + + var @lock = this._lockProvider.CreateLockWithExactName(lockName); + @lock.TryAcquire().ShouldEqual(null, this.GetType().Name); + + command.StandardInput.WriteLine("done"); + command.StandardInput.Flush(); + + using var handle = @lock.TryAcquire(TimeSpan.FromSeconds(10)); + Assert.That(handle, Is.Not.Null, this.GetType().Name); + + Assert.That(command.Task.Wait(TimeSpan.FromSeconds(10)), Is.True); + } + + [Test] + public void TestCrossProcessAbandonment() + { + this.CrossProcessAbandonmentHelper(asyncWait: false, kill: false); + } + + [Test] + public void TestCrossProcessAbandonmentWithKill() + { + this.CrossProcessAbandonmentHelper(asyncWait: true, kill: true); + } + + private void CrossProcessAbandonmentHelper(bool asyncWait, bool kill) + { + var name = this._lockProvider.GetUniqueSafeName($"cpl-{asyncWait}-{kill}"); + var command = this.RunLockTaker(this._lockProvider, this._lockProvider.GetCrossProcessLockType(), name); + Assert.That(command.StandardOutput.ReadLineAsync().Wait(TimeSpan.FromSeconds(10)), Is.True); + Assert.That(command.Task.IsCompleted, Is.False); + + var @lock = this._lockProvider.CreateLockWithExactName(name); + + var acquireTask = asyncWait + // always use Task.Run() to force asynchrony even for locks that don't truly support it + ? Task.Run(() => @lock.TryAcquireAsync(TimeSpan.FromSeconds(20)).AsTask()) + : Task.Run(() => @lock.TryAcquire(TimeSpan.FromSeconds(20))); + acquireTask.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false, this.GetType().Name); + + if (kill) + { + command.Kill(); + } + else + { + command.StandardInput.WriteLine("abandon"); + command.StandardInput.Flush(); + } + // make sure it actually exits + Assert.That(command.Task.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(5)), Is.True, "lock taker should exit"); + + if (this._lockProvider.SupportsCrossProcessAbandonment) + { + using var handle = acquireTask.Result; + Assert.That(handle, Is.Not.Null, this.GetType().Name); + } + else + { + Assert.That(acquireTask.Wait(TimeSpan.FromSeconds(1)), Is.False); + } + } + + private Command RunLockTaker(TLockProvider engine, params string[] args) + { + const string Configuration = +#if DEBUG + "Debug"; +#else + "Release"; +#endif + var exeExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty; + var exePath = Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "DistributedLockTaker", "bin", Configuration, TargetFramework.Current, "DistributedLockTaker" + exeExtension); + + var command = Command.Run(exePath, args, o => o.WorkingDirectory(TestContext.CurrentContext.TestDirectory).ThrowOnError(true)) + .RedirectStandardErrorTo(Console.Error); + this._cleanupActions.Add(() => + { + if (!command.Task.IsCompleted) + { + command.Kill(); + } + }); + return command; + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs new file mode 100644 index 00000000..fcfb8686 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs @@ -0,0 +1,90 @@ +using NUnit.Framework; + +namespace Medallion.Threading.Tests; + +public abstract class DistributedReaderWriterLockCoreTestCases + where TLockProvider : TestingReaderWriterLockProvider, new() + where TStrategy : TestingSynchronizationStrategy, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + [Test] + public async Task TestMultipleReadersSingleWriter() + { + IDistributedReaderWriterLock Lock() => + this._lockProvider.CreateReaderWriterLock(nameof(TestMultipleReadersSingleWriter)); + + using var readHandle1 = await Lock().TryAcquireReadLockAsync(); + Assert.That(readHandle1, Is.Not.Null, this.GetType().ToString()); + using var readHandle2 = Lock().TryAcquireReadLock(); + Assert.That(readHandle2, Is.Not.Null, this.GetType().ToString()); + + using var writeHandle1 = Lock().TryAcquireWriteLock(); + Assert.That(writeHandle1, Is.Null); + + var writeHandleTask = Task.Run(() => Lock().AcquireWriteLockAsync().AsTask()); + Assert.That(writeHandleTask.Wait(TimeSpan.FromSeconds(.05)), Is.False); + + readHandle1!.Dispose(); + Assert.That(writeHandleTask.Wait(TimeSpan.FromSeconds(.05)), Is.False); + + readHandle2!.Dispose(); + Assert.That(writeHandleTask.Wait(TimeSpan.FromSeconds(10)), Is.True); + using var writeHandle2 = writeHandleTask.Result; + + using var writeHandle3 = Lock().TryAcquireWriteLock(); + Assert.That(writeHandle3, Is.Null); + + writeHandle2.Dispose(); + + using var writeHandle4 = Lock().TryAcquireWriteLock(); + Assert.That(writeHandle4, Is.Not.Null); + } + + [Test] + public async Task TestWriterTrumpsReader() + { + IDistributedReaderWriterLock Lock() => + this._lockProvider.CreateReaderWriterLock(nameof(this.TestWriterTrumpsReader)); + + await using var readerHandle = await Lock().AcquireReadLockAsync(); + + var writerHandleTask = Task.Run(() => Lock().AcquireWriteLockAsync().AsTask()); + Assert.That(await writerHandleTask.TryWaitAsync(TimeSpan.FromSeconds(0.2)), Is.False); + + // trying to take a read lock here fails because there is a writer waiting + await using var readerHandle2 = await Lock().TryAcquireReadLockAsync(); + Assert.That(readerHandle2, Is.Null); + + await readerHandle.DisposeAsync(); + + Assert.That(await writerHandleTask.TryWaitAsync(TimeSpan.FromSeconds(5)), Is.True); + await writerHandleTask.Result.DisposeAsync(); + } + + [Test] + public void TestReaderWriterLockBadArguments() + { + var @lock = this._lockProvider.CreateReaderWriterLock(nameof(TestReaderWriterLockBadArguments)); + Assert.Catch(() => @lock.AcquireReadLock(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.AcquireReadLockAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.TryAcquireReadLock(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.TryAcquireReadLockAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.AcquireReadLock(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.AcquireReadLockAsync(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.TryAcquireReadLock(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.TryAcquireReadLockAsync(TimeSpan.FromSeconds(int.MaxValue))); + + Assert.Catch(() => @lock.AcquireWriteLock(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.AcquireWriteLockAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.TryAcquireWriteLock(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.TryAcquireWriteLockAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.AcquireWriteLock(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.AcquireWriteLockAsync(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.TryAcquireWriteLock(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.TryAcquireWriteLockAsync(TimeSpan.FromSeconds(int.MaxValue))); + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/DistributedSemaphoreCoreTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/DistributedSemaphoreCoreTestCases.cs new file mode 100644 index 00000000..c0c934a1 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/DistributedSemaphoreCoreTestCases.cs @@ -0,0 +1,132 @@ +using NUnit.Framework; + +namespace Medallion.Threading.Tests; + +public abstract class DistributedSemaphoreCoreTestCases + where TSemaphoreProvider : TestingSemaphoreProvider, new() + where TStrategy : TestingSynchronizationStrategy, new() +{ + private static readonly TimeSpan LongTimeout = TimeSpan.FromSeconds(5); + + private TSemaphoreProvider _semaphoreProvider = default!; + + [SetUp] public void SetUp() => this._semaphoreProvider = new TSemaphoreProvider(); + [TearDown] public void TearDown() => this._semaphoreProvider.Dispose(); + + [Test] + public void TestMaxCount() + { + this._semaphoreProvider.CreateSemaphore(string.Empty, 5).MaxCount.ShouldEqual(5); + this._semaphoreProvider.CreateSemaphore(string.Empty, 23).MaxCount.ShouldEqual(23); + } + + [Test] + [NonParallelizable] // timing-sensitive + public void TestConcurrencyHandling() + { + const int MaxCount = 3; + + var counter = 0; + var seenCounterValues = new HashSet(); + + const int Threads = 10; + const int Trials = 25; + var barrier = new Barrier(Threads); + var threads = Enumerable.Range(0, Threads) + .Select(_ => Task.Factory.StartNew(() => + { + var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestConcurrencyHandling), MaxCount); + + barrier.SignalAndWait(); + for (var i = 0; i < Trials; ++i) + { + using var _ = semaphore.Acquire(LongTimeout); + var newCounterValue = Interlocked.Increment(ref counter); + lock (seenCounterValues) { seenCounterValues.Add(newCounterValue); } + Thread.Sleep(10); + Interlocked.Decrement(ref counter); + } + }, + TaskCreationOptions.LongRunning // dedicated thread + )) + .ToArray(); + Task.WaitAll(threads); + + Assert.That(seenCounterValues.ToArray(), Is.EquivalentTo(new[] { 1, 2, 3 })); + } + + [Test] + public void TestDrain() + { + var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestDrain), maxCount: 4); + var semaphore2 = this._semaphoreProvider.CreateSemaphore(nameof(TestDrain), maxCount: 4); + + var handles = new List { semaphore.Acquire(LongTimeout) }; + Assert.DoesNotThrow(() => semaphore2.Acquire().Dispose()); + while (handles.Count < 4) { handles.Add(semaphore.Acquire(LongTimeout)); } + + semaphore2.TryAcquire().ShouldEqual(null); + semaphore.TryAcquire().ShouldEqual(null); + + handles[0].Dispose(); + Assert.DoesNotThrow(() => semaphore2.Acquire().Dispose()); + + handles.ForEach(h => h.Dispose()); + } + + [Test] + public void TestHighTicketCount() + { + var semaphore = this._semaphoreProvider.CreateSemaphore($"s{new string('o', 1000)} many tickets!", int.MaxValue); + var handles = Enumerable.Range(0, 100) + .Select(_ => semaphore.Acquire(LongTimeout)) + .ToList(); + handles.ForEach(h => h.Dispose()); + } + + [Test] + [NonParallelizable, Retry(tryCount: 3)] // somewhat perf-sensitive + public void TestSemaphoreParallelism() + { + const int MaxCount = 10; + + var counter = 0; + var maxCounterValue = 0; + var maxCounterValueLock = new object(); + var tasks = Enumerable.Range(1, 100).Select(async _ => + { + var semaphore = this._semaphoreProvider.CreateSemaphore(nameof(TestSemaphoreParallelism), MaxCount); + using (await semaphore.AcquireAsync()) + { + // increment going in + var currentCounterValue = Interlocked.Increment(ref counter); + + lock (maxCounterValueLock) + { + maxCounterValue = Math.Max(maxCounterValue, currentCounterValue); + } + + // hang out for a bit to ensure concurrency + await Task.Delay(TimeSpan.FromMilliseconds(30)); + + // decrement and return on the way out (returns # inside the lock when this left ... should be 0) + return Interlocked.Decrement(ref counter); + } + }) + .ToList(); + + Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(30)).ShouldEqual(true, this.GetType().Name); + + tasks.ForEach(t => + { + (t.Result >= 0).ShouldEqual(true); + (t.Result <= MaxCount).ShouldEqual(true); + }); + Volatile.Read(ref counter).ShouldEqual(0); + + lock (maxCounterValueLock) + { + maxCounterValue.ShouldEqual(MaxCount, this.GetType().Name + ": should reach the maximum level of allowed concurrency"); + } + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs new file mode 100644 index 00000000..dd316422 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs @@ -0,0 +1,146 @@ +using NUnit.Framework; + +namespace Medallion.Threading.Tests; + +public abstract class DistributedUpgradeableReaderWriterLockCoreTestCases + where TLockProvider : TestingUpgradeableReaderWriterLockProvider, new() + where TStrategy : TestingSynchronizationStrategy, new() +{ + private TLockProvider _lockProvider = default!; + + [SetUp] public void SetUp() => this._lockProvider = new TLockProvider(); + [TearDown] public void TearDown() => this._lockProvider.Dispose(); + + [Test] + public void TestMultipleReadersSingleWriter() + { + IDistributedUpgradeableReaderWriterLock Lock() => + this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestMultipleReadersSingleWriter)); + + using var readHandle1 = Lock().TryAcquireReadLockAsync().AsTask().Result; + Assert.That(readHandle1, Is.Not.Null, this.GetType().ToString()); + using var readHandle2 = Lock().TryAcquireReadLock(); + Assert.That(readHandle2, Is.Not.Null, this.GetType().ToString()); + + using (var handle = Lock().TryAcquireUpgradeableReadLock()) + { + Assert.That(handle, Is.Not.Null); + + using var readHandle3 = Lock().TryAcquireReadLock(); + Assert.That(readHandle3, Is.Not.Null); + + Lock().TryAcquireUpgradeableReadLock().ShouldEqual(null); + Lock().TryAcquireWriteLock().ShouldEqual(null); + + readHandle3!.Dispose(); + } + + readHandle1!.Dispose(); + readHandle2!.Dispose(); + + using var upgradeHandle = Lock().TryAcquireUpgradeableReadLock(); + Assert.That(upgradeHandle, Is.Not.Null); + } + + [Test] + public void TestUpgradeToWriteLock() + { + IDistributedUpgradeableReaderWriterLock Lock() => + this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestUpgradeToWriteLock)); + + var readHandle = Lock().AcquireReadLock(); + + Task readTask; + using (var upgradeableHandle = Lock().AcquireUpgradeableReadLockAsync().AsTask().Result) + { + upgradeableHandle.TryUpgradeToWriteLock().ShouldEqual(false); // read lock still held + + readHandle.Dispose(); + + upgradeableHandle.TryUpgradeToWriteLock().ShouldEqual(true); + + readTask = Task.Run(() => Lock().AcquireReadLockAsync().AsTask()); + readTask.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false, "write lock held"); + } + + readTask.Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true, "write lock released"); + readTask.Result.Dispose(); + } + + [Test] + public void TestReaderWriterLockBadArguments() + { + var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestReaderWriterLockBadArguments)); + Assert.Catch(() => @lock.AcquireUpgradeableReadLock(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.AcquireUpgradeableReadLockAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.TryAcquireUpgradeableReadLock(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.TryAcquireUpgradeableReadLockAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => @lock.AcquireUpgradeableReadLock(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.AcquireUpgradeableReadLockAsync(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.TryAcquireUpgradeableReadLock(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => @lock.TryAcquireUpgradeableReadLockAsync(TimeSpan.FromSeconds(int.MaxValue))); + + using var upgradeableHandle = @lock.AcquireUpgradeableReadLock(); + Assert.Catch(() => upgradeableHandle.UpgradeToWriteLock(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => upgradeableHandle.UpgradeToWriteLockAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => upgradeableHandle.TryUpgradeToWriteLock(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => upgradeableHandle.TryUpgradeToWriteLockAsync(TimeSpan.FromSeconds(-2))); + Assert.Catch(() => upgradeableHandle.UpgradeToWriteLock(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => upgradeableHandle.UpgradeToWriteLockAsync(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => upgradeableHandle.TryUpgradeToWriteLock(TimeSpan.FromSeconds(int.MaxValue))); + Assert.Catch(() => upgradeableHandle.TryUpgradeToWriteLockAsync(TimeSpan.FromSeconds(int.MaxValue))); + } + + [Test] + public void TestUpgradeableHandleDisposal() + { + var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestUpgradeableHandleDisposal)); + + var handle = @lock.AcquireUpgradeableReadLock(); + handle.Dispose(); + Assert.DoesNotThrow(() => handle.Dispose()); + Assert.Catch(() => handle.TryUpgradeToWriteLock()); + Assert.Catch(() => handle.TryUpgradeToWriteLockAsync()); + Assert.Catch(() => handle.UpgradeToWriteLock()); + Assert.Catch(() => handle.UpgradeToWriteLockAsync()); + } + + [Test] + public void TestUpgradeableHandleMultipleUpgrades() + { + var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestUpgradeableHandleMultipleUpgrades)); + + using var upgradeHandle = @lock.AcquireUpgradeableReadLock(); + upgradeHandle.UpgradeToWriteLock(); + Assert.Catch(() => upgradeHandle.TryUpgradeToWriteLock()); + } + + [Test] + public async Task TestCanUpgradeHandleWhileMonitoring() + { + var handleLostHelper = this._lockProvider.Strategy.PrepareForHandleLost(); + + var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(nameof(TestCanUpgradeHandleWhileMonitoring)); + + using var handle = await @lock.AcquireUpgradeableReadLockAsync(); + + // start monitoring + using var canceledEvent = new ManualResetEventSlim(initialState: false); + using var registration = handle.HandleLostToken.Register(canceledEvent.Set); + Assert.That(canceledEvent.Wait(TimeSpan.FromSeconds(.05)), Is.False); + + Assert.DoesNotThrowAsync(() => handle.UpgradeToWriteLockAsync().AsTask()); + + Assert.That(canceledEvent.Wait(TimeSpan.FromSeconds(.05)), Is.False); + + if (handleLostHelper != null) + { + handleLostHelper.Dispose(); + Assert.That(canceledEvent.Wait(TimeSpan.FromSeconds(10)), Is.True); + } + + // when the handle is lost, Dispose() may throw + try { await handle.DisposeAsync(); } + catch { } + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Redis/RedisExtensionTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Redis/RedisExtensionTestCases.cs new file mode 100644 index 00000000..913fb918 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Redis/RedisExtensionTestCases.cs @@ -0,0 +1,34 @@ +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Redis; + +public abstract class RedisExtensionTestCases + where TLockProvider : TestingLockProvider>, new() + where TDatabaseProvider : TestingRedisDatabaseProvider, new() +{ + private TLockProvider _provider = default!; + + [SetUp] + public void SetUp() => this._provider = new TLockProvider(); + + [TearDown] + public void TearDown() => this._provider.Dispose(); + + [Test] + [NonParallelizable, Retry(tryCount: 3)] // timing-sensitive + public async Task TestCanExtendLock() + { + this._provider.Strategy.SetOptions(o => o.Expiry(TimeSpan.FromSeconds(1)).BusyWaitSleepTime(TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(50))); + var @lock = this._provider.CreateLock(Guid.NewGuid().ToString()); + + await using var handle = await @lock.AcquireAsync(); + + var secondHandleTask = @lock.AcquireAsync().AsTask(); + _ = secondHandleTask.ContinueWith(t => t.Result.Dispose()); // ensure cleanup + Assert.That(await secondHandleTask.TryWaitAsync(TimeSpan.FromSeconds(2)), Is.False); + + await handle.DisposeAsync(); + + Assert.That(await secondHandleTask.TryWaitAsync(TimeSpan.FromSeconds(5)), Is.True); + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/Redis/RedisSynchronizationCoreTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/Redis/RedisSynchronizationCoreTestCases.cs new file mode 100644 index 00000000..945ea9c4 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/Redis/RedisSynchronizationCoreTestCases.cs @@ -0,0 +1,175 @@ +using Medallion.Threading.Redis; +using Moq; +using NUnit.Framework; +using StackExchange.Redis; +using StackExchange.Redis.KeyspaceIsolation; + +namespace Medallion.Threading.Tests.Redis; + +public abstract class RedisSynchronizationCoreTestCases + // note: we arbitrarily use the single db provider because we will be overriding the set of dbs and so we don't + // want to see cases for each possible db provider type + where TLockProvider : TestingLockProvider>, new() +{ + private TLockProvider _provider = default!; + + [SetUp] + public void SetUp() => this._provider = new TLockProvider(); + + [TearDown] + public void TearDown() => this._provider.Dispose(); + + [Test] + public void TestMajorityFaultingDatabasesCauseAcquireToThrow() + { + var databases = Enumerable.Range(0, 3).Select(_ => CreateDatabaseMock()).ToArray(); + MockDatabase(databases[0], () => throw new TimeZoneNotFoundException()); + MockDatabase(databases[2], () => throw new ArrayTypeMismatchException()); + + this._provider.Strategy.DatabaseProvider.Databases = databases.Select(d => d.Object).ToArray(); + var @lock = this._provider.CreateLock("multi"); + + // we only get the one exception + Assert.That( + Assert.CatchAsync(() => @lock.TryAcquireAsync().AsTask()), + Is.InstanceOf().Or.InstanceOf()); + + // single sync acquire flow is different + this._provider.Strategy.DatabaseProvider.Databases = new[] { databases[2].Object }; + var singleDatabaseLock = this._provider.CreateLock("single"); + Assert.Throws(() => singleDatabaseLock.Acquire()); + } + + [Test] + [NonParallelizable] // timing-sensitive + public async Task TestMajorityHangingDatabasesCauseAcquireToFail() + { + using var @event = new ManualResetEventSlim(initialState: false); + var databases = Enumerable.Range(0, 3).Select(_ => CreateDatabaseMock()).ToArray(); + MockDatabase(databases[1], () => { @event.Wait(); return true; }); + MockDatabase(databases[2], () => { @event.Wait(); return false; }); + + this._provider.Strategy.DatabaseProvider.Databases = databases.Select(d => d.Object).ToArray(); + // use a high min validity time so that TryAcquireAsync() can return very quickly despite the hang + this._provider.Strategy.SetOptions(o => o.MinValidityTime(RedisDistributedSynchronizationOptionsBuilder.DefaultExpiry.TimeSpan - TimeSpan.FromSeconds(.2))); + var @lock = this._provider.CreateLock("lock"); + + Assert.That(await @lock.TryAcquireAsync(), Is.Null); + + @event.Set(); // just to free the waiting threads + } + + [Test] + public void TestMajorityFaultingDatabasesCauseReleaseToThrow() + { + var databases = Enumerable.Range(0, 5).Select(_ => CreateDatabaseMock()).ToArray(); + this._provider.Strategy.DatabaseProvider.Databases = databases.Select(d => d.Object).ToArray(); + var @lock = this._provider.CreateLock("lock"); + using var handle = @lock.Acquire(); + + new List { 1, 2, 4 }.ForEach(i => MockDatabase(databases[i], () => throw new DataMisalignedException())); + var aggregateException = Assert.Throws(() => handle.Dispose())!; + Assert.That(aggregateException.InnerException, Is.InstanceOf()); + } + + [Test] + public void TestHalfFaultingDatabasesCauseAcquireToThrow() + { + var databases = Enumerable.Range(0, 2).Select(_ => CreateDatabaseMock()).ToArray(); + MockDatabase(databases[0], () => throw new TimeZoneNotFoundException()); + this._provider.Strategy.DatabaseProvider.Databases = databases.Select(d => d.Object).ToArray(); + + var @lock = this._provider.CreateLock("lock"); + Assert.Throws(() => @lock.Acquire(TimeSpan.FromSeconds(10))); + } + + [Test] + [NonParallelizable, Retry(tryCount: 3)] // timing-sensitive + public async Task TestAcquireFailsIfItTakesTooLong([Values] bool synchronous) + { + var database = CreateDatabaseMock(); + MockDatabase(database, () => { Thread.Sleep(50); return true; }); + + this._provider.Strategy.DatabaseProvider.Databases = new[] { database.Object }; + this._provider.Strategy.SetOptions(o => o.MinValidityTime(RedisDistributedSynchronizationOptionsBuilder.DefaultExpiry.TimeSpan - TimeSpan.FromMilliseconds(10))); + var @lock = this._provider.CreateLock("lock"); + + // single sync acquire has different timeout logic, so we test it separately + Assert.That(synchronous ? @lock.TryAcquire() : await @lock.TryAcquireAsync(), Is.Null); + } + + [Test] + [NonParallelizable] // timing-sensitive + public async Task TestFailedAcquireReleasesWhatHasAlreadyBeenAcquired() + { + using var @event = new ManualResetEventSlim(); + var failDatabase = CreateDatabaseMock(); + MockDatabase(failDatabase, () => { @event.Wait(); return false; }); + + this._provider.Strategy.DatabaseProvider.Databases = new[] { RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase(), failDatabase.Object }; + var @lock = this._provider.CreateLock("lock"); + + var acquireTask = @lock.TryAcquireAsync().AsTask(); + Assert.That(acquireTask.Wait(TimeSpan.FromMilliseconds(50)), Is.False); + @event.Set(); + Assert.That(await acquireTask, Is.Null); + + this._provider.Strategy.DatabaseProvider.Databases = new[] { RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase() }; + var singleDatabaseLock = this._provider.CreateLock("lock"); + using var handle = await singleDatabaseLock.TryAcquireAsync(); + Assert.That(handle, Is.Not.Null); + } + + [Test] + public void TestAcquireWithLockPrefix() + { + this._provider.Strategy.DatabaseProvider.Databases = new[] { CreateDatabase(keyPrefix: "P") }; + var implicitPrefixLock = this._provider.CreateLock("N"); + + this._provider.Strategy.DatabaseProvider.Databases = new[] { CreateDatabase() }; + var noPrefixLock = this._provider.CreateLock("N"); + var explicitPrefixLock = this._provider.CreateLock("PN"); + + using var implicitPrefixHandle = implicitPrefixLock.TryAcquire(); + Assert.That(implicitPrefixHandle, Is.Not.Null); + using var noPrefixHandle = noPrefixLock.TryAcquire(); + Assert.That(noPrefixHandle, Is.Not.Null); + using var explicitPrefixHandle = explicitPrefixLock.TryAcquire(); + Assert.That(explicitPrefixHandle, Is.Null); + + static IDatabase CreateDatabase(string? keyPrefix = null) + { + var database = RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase(); + return keyPrefix is null ? database : database.WithKeyPrefix(keyPrefix); + } + } + + private static Mock CreateDatabaseMock() + { + var mock = new Mock(MockBehavior.Strict); + MockDatabase(mock, () => true); + return mock; + } + + private static void MockDatabase(Mock mockDatabase, Func returns) + { + mockDatabase.Setup(d => d.StringSet(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(returns); + mockDatabase.Setup(d => d.StringSetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => Task.Run(returns)); + mockDatabase.Setup(d => d.ScriptEvaluate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => RedisResult.Create(returns())); + mockDatabase.Setup(d => d.ScriptEvaluateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => Task.Run(() => RedisResult.Create(returns()))); + mockDatabase.Setup(d => d.ScriptEvaluate(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => RedisResult.Create(returns())); + mockDatabase.Setup(d => d.ScriptEvaluateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => Task.Run(() => RedisResult.Create(returns()))); + mockDatabase.Setup(d => d.SortedSetRemove(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => (bool)RedisResult.Create(returns())); + mockDatabase.Setup(d => d.SortedSetRemoveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => Task.Run(() => (bool)RedisResult.Create(returns()))); + mockDatabase.Setup(d => d.IsConnected(It.IsAny(), It.IsAny())) + .Returns(true); + } +} diff --git a/src/DistributedLock.Tests/AbstractTestCases/ZooKeeper/ZooKeeperSynchronizationCoreTestCases.cs b/src/DistributedLock.Tests/AbstractTestCases/ZooKeeper/ZooKeeperSynchronizationCoreTestCases.cs new file mode 100644 index 00000000..b6aa6426 --- /dev/null +++ b/src/DistributedLock.Tests/AbstractTestCases/ZooKeeper/ZooKeeperSynchronizationCoreTestCases.cs @@ -0,0 +1,214 @@ +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; +using org.apache.zookeeper; +using org.apache.zookeeper.data; +using System.Security.Cryptography; +using System.Text; + +namespace Medallion.Threading.Tests.ZooKeeper; + +public abstract class ZooKeeperSynchronizationCoreTestCases + where TLockProvider : TestingLockProvider, new() +{ + private TLockProvider _provider = default!; + + [SetUp] + public void SetUp() => this._provider = new TLockProvider(); + + [TearDown] + public void TearDown() => this._provider.Dispose(); + + [Test] + public async Task TestDoesNotAttemptToCreateOrDeleteExistingNode() + { + // This doesn't work because creating the lock attempts to acquire which will then fail initially. We could work around this by testing + // for a different set of conditions in the multi-ticket case, but the extra coverage doesn't seem valuable (we still have coverage of single-ticket) + if (IsMultiTicketSemaphoreProvider) { Assert.Pass("not supported"); } + + var path = new ZooKeeperPath($"/{this.GetType()}.{nameof(this.TestDoesNotAttemptToCreateOrDeleteExistingNode)} ({TargetFramework.Current})"); + using var connection = await ZooKeeperConnection.DefaultPool.ConnectAsync( + new ZooKeeperConnectionInfo(ZooKeeperPorts.DefaultConnectionString, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), new EquatableReadOnlyList(Array.Empty())), + CancellationToken.None + ); + + // pre-clean up just in case + try { await connection.ZooKeeper.deleteAsync(path.ToString()); } + catch (KeeperException.NoNodeException) { } + + this._provider.Strategy.AssumeNodeExists = true; + var @lock = this._provider.CreateLockWithExactName(path.ToString()); + + Assert.That( + Assert.ThrowsAsync(() => @lock.TryAcquireAsync().AsTask())!.Message, + Does.Contain("does not exist") + ); + + await connection.ZooKeeper.createAsync(path.ToString(), Array.Empty(), new List { ZooKeeperNodeCreator.PublicAcl }, CreateMode.PERSISTENT); + try + { + await using (var handle = await @lock.TryAcquireAsync()) + { + Assert.That(handle, Is.Not.Null); + } + + Assert.That(await connection.ZooKeeper.existsAsync(path.ToString()), Is.Not.Null); + } + finally + { + await connection.ZooKeeper.deleteAsync(path.ToString()); + } + } + + [TestCase("/")] + [TestCase(".")] + [TestCase("..")] + [TestCase("zookeeper")] + [TestCase("abc\0")] + public void TestGetSafeName(string name) => + Assert.DoesNotThrowAsync(async () => await (await this._provider.CreateLockWithExactName(this._provider.GetSafeName(name)).AcquireAsync()).DisposeAsync()); + + [Test] + public void TestGetSafeNameWithControlCharacters() => this.TestGetSafeName("\u001f\u009F\uf8ff\ufff1"); + + [Test] + public async Task TestCustomAclAndAuth() + { + // This doesn't work because creating the lock causes the node to be created (from taking the other tickets) + // and releasing the lock doesn't cause the node to be deleted (due to those other tickets). + if (IsMultiTicketSemaphoreProvider) { Assert.Pass("not supported"); } + + const string Username = "username"; + const string Password = "secretPassword"; + + var unauthenticatedLock = this._provider.CreateLock(string.Empty); + + this._provider.Strategy.Options = o => o.AddAccessControl("digest", GenerateDigestAclId(Username, Password), 0x1f) + .AddAuthInfo("digest", Encoding.UTF8.GetBytes($"{Username}:{Password}")); + var @lock = this._provider.CreateLock(string.Empty); + + await using (await @lock.AcquireAsync()) + { + Assert.ThrowsAsync(() => unauthenticatedLock.TryAcquireAsync().AsTask()); + } + + Assert.DoesNotThrowAsync(async () => await (await unauthenticatedLock.AcquireAsync()).DisposeAsync()); + + // Based on + // https://github.com/apache/zookeeper/blob/d8561f620fa8611e9a6819d9879b0f18e5a404a9/zookeeper-server/src/main/java/org/apache/zookeeper/server/auth/DigestAuthenticationProvider.java + static string GenerateDigestAclId(string username, string password) + { + using var sha = SHA1.Create(); + var digest = sha.ComputeHash(Encoding.UTF8.GetBytes($"{username}:{password}")); + return $"{username}:{Convert.ToBase64String(digest)}"; + } + } + + [Test] + public async Task TestInvalidAclDoesNotCorruptStore() + { + // This doesn't work because creating the lock causes the node to be created (from taking the other tickets) + // and releasing the lock doesn't cause the node to be deleted (due to those other tickets). + if (IsMultiTicketSemaphoreProvider) { Assert.Pass("not supported"); } + + const string Username = "username"; + const string Password = "xyz"; + + // ACL is the right format but the wrong password (this can easily happen if you get the encoding wrong) + this._provider.Strategy.Options = o => o.AddAccessControl("digest", $"{Username}:1eYGPn6j9+P9osACW8ob4HhZT+s=", 0x1f) + .AddAuthInfo("digest", Encoding.UTF8.GetBytes($"{Username}:{Password}")); + var invalidAclLock = this._provider.CreateLock(string.Empty); + + // pre-cleanup to make sure we will actually create the path + using var connection = await ZooKeeperConnection.DefaultPool.ConnectAsync( + new ZooKeeperConnectionInfo(ZooKeeperPorts.DefaultConnectionString, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), new EquatableReadOnlyList(Array.Empty())), + CancellationToken.None + ); + try { await connection.ZooKeeper.deleteAsync(invalidAclLock.Name); } + catch (KeeperException.NoNodeException) { } + + Assert.ThrowsAsync(() => invalidAclLock.AcquireAsync().AsTask()); + + Assert.That(await connection.ZooKeeper.existsAsync(invalidAclLock.Name), Is.Null); + + this._provider.Strategy.Options = null; + var validLock = this._provider.CreateLock(string.Empty); + Assert.DoesNotThrowAsync(async () => await (await validLock.AcquireAsync()).DisposeAsync()); + } + + [Test] + public async Task TestDeepDirectoryCreation() + { + var directory = new ZooKeeperPath($"/{TestHelper.UniqueName}/foo/bar/baz"); + + // pre-cleanup to make sure we will actually create the directory + using var connection = await ZooKeeperConnection.DefaultPool.ConnectAsync( + new ZooKeeperConnectionInfo(ZooKeeperPorts.DefaultConnectionString, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), new EquatableReadOnlyList(Array.Empty())), + CancellationToken.None + ); + for (var toDelete = directory; toDelete != ZooKeeperPath.Root; toDelete = toDelete.GetDirectory()!.Value) + { + try { await connection.ZooKeeper.deleteAsync(toDelete.ToString()); } + catch (KeeperException.NoNodeException) { } + } + + var @lock = this._provider.CreateLockWithExactName(directory.GetChildNodePathWithSafeName("qux").ToString()); + + await using (await @lock.AcquireAsync()) + { + Assert.That(await connection.ZooKeeper.existsAsync(directory.ToString()), Is.Not.Null); + } + + Assert.That(await connection.ZooKeeper.existsAsync(directory.ToString()), Is.Not.Null, "directory still exists"); + } + + [Test] + public async Task TestThrowsIfPathDeletedWhileWaiting() + { + var @lock = this._provider.CreateLock(string.Empty); + + // hold the lock + await using var handle = await @lock.AcquireAsync(); + + using var connection = await ZooKeeperConnection.DefaultPool.ConnectAsync( + new ZooKeeperConnectionInfo(ZooKeeperPorts.DefaultConnectionString, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), new EquatableReadOnlyList(Array.Empty())), + CancellationToken.None + ); + var initialChildren = await connection.ZooKeeper.getChildrenAsync(@lock.Name); + + // start waiting + var blockedAcquireTask = @lock.AcquireAsync(TimeSpan.FromSeconds(30)).AsTask(); + // once the wait has started... + var newChild = await WaitForNewChildAsync(); + // ... start another waiter... + var blockedAcquireTask2 = @lock.AcquireAsync(TimeSpan.FromSeconds(30)).AsTask(); + // ... and delete the first waiter's node + await connection.ZooKeeper.deleteAsync(newChild); + + // release the lock + await handle.DisposeAsync(); + + // the first waiter should throw + Assert.ThrowsAsync(() => blockedAcquireTask); + + // the second waiter should complete + Assert.DoesNotThrowAsync(async () => await (await blockedAcquireTask2).DisposeAsync()); + + async Task WaitForNewChildAsync() + { + var start = DateTime.UtcNow; + while (true) + { + var children = await connection.ZooKeeper.getChildrenAsync(@lock.Name); + var newChild = children.Children.Except(initialChildren.Children).SingleOrDefault(); + if (newChild != null) { return $"{@lock.Name}/{newChild}"; } + + if (DateTime.UtcNow - start >= TimeSpan.FromSeconds(10)) { Assert.Fail("Timed out"); } + + await Task.Delay(5); + } + } + } + + private static bool IsMultiTicketSemaphoreProvider => + typeof(TLockProvider) == typeof(TestingSemaphore5AsMutexProvider); +} diff --git a/src/DistributedLock.Tests/DistributedLock.Tests.csproj b/src/DistributedLock.Tests/DistributedLock.Tests.csproj new file mode 100644 index 00000000..be82219e --- /dev/null +++ b/src/DistributedLock.Tests/DistributedLock.Tests.csproj @@ -0,0 +1,36 @@ + + + + + net8.0 + net472;net8.0 + Latest + enable + enable + Medallion.Threading.Tests + true + ..\DistributedLock.snk + true + 1591 + + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.Tests/Infrastructure/Azure/AzureSetUpFixture.cs b/src/DistributedLock.Tests/Infrastructure/Azure/AzureSetUpFixture.cs new file mode 100644 index 00000000..9e410d46 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Azure/AzureSetUpFixture.cs @@ -0,0 +1,67 @@ +using Azure.Storage.Blobs; +using Medallion.Shell; +using NUnit.Framework; +using System.Diagnostics; + +namespace Medallion.Threading.Tests.Azure; + +[SetUpFixture] +public class AzureSetUpFixture +{ + // https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite?tabs=visual-studio%2Cblob-storage + private const string EmulatorProcessName = "azurite"; + + private bool _startedEmulator; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + var existingProcesses = Process.GetProcessesByName(EmulatorProcessName); + if (existingProcesses.Any()) + { + Console.WriteLine($"Emulator already running (PID={existingProcesses[0].Id})"); + foreach (var process in existingProcesses) { process.Dispose(); } + } + else + { + var emulatorExePaths = Directory.GetFiles(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Microsoft Visual Studio"), $"{EmulatorProcessName}.exe", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .ToArray(); + if (!emulatorExePaths.Any()) + { + throw new FileNotFoundException($"Could not locate {EmulatorProcessName}. This is required to run Azure tests. See https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite"); + } + + // Note: we used to hang on to this command to kill it later; we no longer do that because this process seems to naturally exit + // by the end of the test and instead the emulator is running in another process. Therefore, we do a name-based lookup in teardown instead. + var command = Command.Run( + emulatorExePaths[0], + // After updating to Azure.Storage.Blobs 12.19.1 I started getting an error saying that I had to update Azurite or pass this flag. + // AFAIK the only way to update Azurite is to update VS, which did not fix the issue. Therefore, I am passing this flag instead. + ["start", "--skipApiVersionCheck"], + o => o.StartInfo(i => i.RedirectStandardInput = false) + .WorkingDirectory(Path.GetDirectoryName(this.GetType().Assembly.Location)!)) + .RedirectTo(Console.Out) + .RedirectStandardErrorTo(Console.Error); + Console.WriteLine($"Launched {EmulatorProcessName}"); + this._startedEmulator = true; + } + + new BlobContainerClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName).CreateIfNotExists(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + new BlobContainerClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName).DeleteIfExists(); + + if (this._startedEmulator) + { + foreach (var process in Process.GetProcessesByName(EmulatorProcessName)) + { + process.Kill(); + process.WaitForExit(); + } + } + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseDistributedLockProvider.cs b/src/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseDistributedLockProvider.cs new file mode 100644 index 00000000..6dfc26f2 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseDistributedLockProvider.cs @@ -0,0 +1,29 @@ +using Azure.Storage.Blobs; +using Medallion.Threading.Azure; + +namespace Medallion.Threading.Tests.Azure; + +public sealed class TestingAzureBlobLeaseDistributedLockProvider : TestingLockProvider +{ + private readonly HashSet _createdBlobs = []; + + public override IDistributedLock CreateLockWithExactName(string name) + { + var client = new BlobClient(AzureCredentials.ConnectionString, this.Strategy.ContainerName, name); + if (this.Strategy.CreateBlobBeforeLockIsCreated) + { + lock (this._createdBlobs) + { + if (this._createdBlobs.Add(client.Uri)) + { + // Azurite blobs persist across runs, so we need overwrite: true + client.Upload(Stream.Null, overwrite: true); + } + } + } + return new AzureBlobLeaseDistributedLock(client, this.Strategy.Options); + } + + public override string GetSafeName(string name) => + AzureBlobLeaseDistributedLock.GetSafeName(name, new BlobContainerClient(AzureCredentials.ConnectionString, this.Strategy.ContainerName)); +} diff --git a/src/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs b/src/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs new file mode 100644 index 00000000..eaedd1a6 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs @@ -0,0 +1,71 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Specialized; +using Medallion.Threading.Azure; +using NUnit.Framework; +using System.Numerics; +using System.Security.Cryptography; +using System.Text; + +namespace Medallion.Threading.Tests.Azure; + +public sealed class TestingAzureBlobLeaseSynchronizationStrategy : TestingSynchronizationStrategy +{ + private readonly DisposableCollection _disposables = new(); + + private static readonly Action DefaultTestingOptions = o => + // for test speed + o.BusyWaitSleepTime(TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(25)); + + public string ContainerName { get; set; } = AzureCredentials.DefaultBlobContainerName; + + public Action? Options { get; set; } = DefaultTestingOptions; + public bool CreateBlobBeforeLockIsCreated { get; set; } + + public override IDisposable? PrepareForHandleLost() + { + this.Options = o => + { + DefaultTestingOptions(o); + o.RenewalCadence(TimeSpan.FromMilliseconds(10)); + }; + + using var md5 = MD5.Create(); + this.ContainerName = $"distributed-lock-handle-lost-{new BigInteger(md5.ComputeHash(Encoding.UTF8.GetBytes(TargetFramework.Current + TestContext.CurrentContext.Test.FullName))):x}"; + var containerClient = new BlobContainerClient(AzureCredentials.ConnectionString, this.ContainerName); + containerClient.CreateIfNotExists(); + this._disposables.Add(() => containerClient.DeleteIfExists()); + return new HandleLostScope(this.ContainerName); + } + + public override void PrepareForHighContention(ref int maxConcurrentAcquires) + { + this.Options = null; // reduces # of requests under high contention + this.CreateBlobBeforeLockIsCreated = true; + } + + public override void Dispose() + { + try { this._disposables.Dispose(); } + finally { base.Dispose(); } + } + + private class HandleLostScope : IDisposable + { + private readonly string _containerName; + + public HandleLostScope(string containerName) + { + this._containerName = containerName; + } + + public void Dispose() + { + var containerClient = new BlobContainerClient(AzureCredentials.ConnectionString, this._containerName); + foreach (var blob in containerClient.GetBlobs()) + { + var leaseClient = containerClient.GetBlobClient(blob.Name).GetBlobLeaseClient(); + leaseClient.Break(breakPeriod: TimeSpan.Zero); + } + } + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Composites/TestingCompositesProviders.cs b/src/DistributedLock.Tests/Infrastructure/Composites/TestingCompositesProviders.cs new file mode 100644 index 00000000..3a1287ae --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Composites/TestingCompositesProviders.cs @@ -0,0 +1,38 @@ +using Medallion.Threading.Tests.Data; +using Medallion.Threading.Tests.FileSystem; +using Medallion.Threading.Tests.Postgres; +using Medallion.Threading.Tests.WaitHandles; + +namespace Medallion.Threading.Tests; + +[SupportsContinuousIntegration] +public sealed class TestingCompositeDistributedLockProvider : TestingLockProvider +{ + public override IDistributedLock CreateLockWithExactName(string name) => new TestingCompositeFileDistributedLock(name); + + public override string GetSafeName(string name) => name ?? throw new ArgumentNullException(nameof(name)); +} + +[SupportsContinuousIntegration(WindowsOnly = true)] +public sealed class TestingCompositeDistributedSemaphoreProvider : TestingSemaphoreProvider +{ + public override IDistributedSemaphore CreateSemaphoreWithExactName(string name, int maxCount) => + new TestingCompositeWaitHandleDistributedSemaphore(name, maxCount); + + public override string GetSafeName(string name) => name ?? throw new ArgumentNullException(nameof(name)); +} + +public sealed class TestingCompositeReaderWriterLockProvider : TestingReaderWriterLockProvider> +{ + public override IDistributedReaderWriterLock CreateReaderWriterLockWithExactName(string name) => + this.Strategy.GetConnectionOptions() + .Create( + fromConnectionString: (connectionString, options) => new TestingCompositePostgresReaderWriterLock( + name, + connectionString, + TestingPostgresDistributedLockProvider>.ToPostgresOptions(options)), + fromConnection: _ => throw new Exception(), + fromTransaction: _ => throw new Exception()); + + public override string GetSafeName(string name) => name ?? throw new ArgumentNullException(nameof(name)); +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Infrastructure/Data/ConnectionOptions.cs b/src/DistributedLock.Tests/Infrastructure/Data/ConnectionOptions.cs new file mode 100644 index 00000000..b85fc4e3 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Data/ConnectionOptions.cs @@ -0,0 +1,40 @@ +using System.Data.Common; + +namespace Medallion.Threading.Tests.Data; + +/// +/// Determines how an ADO.NET-based distributed lock should manage its connection to the database +/// and its locking strategy +/// +public sealed class TestingDbConnectionOptions +{ + public string? ConnectionString { get; set; } + public bool ConnectionStringUseMultiplexing { get; set; } + public bool ConnectionStringUseTransaction { get; set; } + public TimeSpan? ConnectionStringKeepaliveCadence { get; set; } + public DbConnection? Connection { get; set; } + public DbTransaction? Transaction { get; set; } + + public T Create( + Func fromConnectionString, + Func fromConnection, + Func fromTransaction) + { + if (this.ConnectionString != null) + { + return fromConnectionString(this.ConnectionString, (this.ConnectionStringUseMultiplexing, this.ConnectionStringUseTransaction, this.ConnectionStringKeepaliveCadence)); + } + + if (this.Connection != null) + { + return fromConnection(this.Connection); + } + + if (this.Transaction != null) + { + return fromTransaction(this.Transaction); + } + + throw new InvalidOperationException("should never get here"); + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Data/IdleSessionKiller.cs b/src/DistributedLock.Tests/Infrastructure/Data/IdleSessionKiller.cs new file mode 100644 index 00000000..268f2689 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Data/IdleSessionKiller.cs @@ -0,0 +1,33 @@ +namespace Medallion.Threading.Tests.Data; + +internal class IdleSessionKiller : IDisposable +{ + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly Task _task; + + public IdleSessionKiller(TestingPrimaryClientDb db, string applicationName, TimeSpan idleTimeout) + { + this._cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = this._cancellationTokenSource.Token; + this._task = Task.Run(async () => + { + while (!cancellationToken.IsCancellationRequested) + { + var expirationDate = DateTimeOffset.Now - idleTimeout; + await db.KillSessionsAsync(applicationName, expirationDate); + await Task.Delay(TimeSpan.FromTicks(idleTimeout.Ticks / 2), cancellationToken); + } + }); + } + + public void Dispose() + { + this._cancellationTokenSource.Cancel(); + + // wait and swallow any OCE + try { this._task.Wait(); } + catch when (this._task.IsCanceled) { } + + this._cancellationTokenSource.Dispose(); + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Data/TestingDb.cs b/src/DistributedLock.Tests/Infrastructure/Data/TestingDb.cs new file mode 100644 index 00000000..2cbe9e64 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Data/TestingDb.cs @@ -0,0 +1,88 @@ +using Medallion.Threading.Internal; +using NUnit.Framework; +using System.Data; +using System.Data.Common; +using System.Reflection; + +namespace Medallion.Threading.Tests.Data; + +/// +/// Abstraction over an ADO.NET client for a database technology +/// +public abstract class TestingDb +{ + public abstract DbConnectionStringBuilder ConnectionStringBuilder { get; } + + public virtual string ApplicationName + { + get => (string)this.ConnectionStringBuilder["Application Name"]; + set => this.ConnectionStringBuilder["Application Name"] = value; + } + + public string SetUniqueApplicationName(string baseName = "") + { + return this.ApplicationName = DistributedLockHelpers.ToSafeName( + // note: due to retries, we incorporate a GUID here to ensure that we have a fresh connection pool + $"{(baseName.Length > 0 ? baseName + "_" : string.Empty)}{TestContext.CurrentContext.Test.FullName}_{TargetFramework.Current}_{Guid.NewGuid()}", + maxNameLength: this.MaxApplicationNameLength, + s => s + ); + } + + public virtual string ConnectionString => this.ConnectionStringBuilder.ConnectionString; + + // needed since different providers have different names for this key + public virtual int MaxPoolSize + { + get => (int)this.GetMaxPoolSizeProperty().GetValue(this.ConnectionStringBuilder)!; + set => this.GetMaxPoolSizeProperty().SetValue(this.ConnectionStringBuilder, value); + } + + private PropertyInfo GetMaxPoolSizeProperty() => this.ConnectionStringBuilder.GetType() + .GetProperty("MaxPoolSize", BindingFlags.Public | BindingFlags.Instance)!; + + public abstract int MaxApplicationNameLength { get; } + + public abstract TransactionSupport TransactionSupport { get; } + + public abstract DbConnection CreateConnection(); + + public void ClearPool(DbConnection connection) + { + var clearPoolMethod = connection.GetType().GetMethod("ClearPool", BindingFlags.Public | BindingFlags.Static); + clearPoolMethod!.Invoke(null, new[] { connection }); + } + + public abstract int CountActiveSessions(string applicationName); + + public abstract IsolationLevel GetIsolationLevel(DbConnection connection); + + public virtual void PrepareForHighContention(ref int maxConcurrentAcquires) { } +} + +public enum TransactionSupport +{ + /// + /// The lifetime of the lock is tied to the transaction + /// + TransactionScoped, + + /// + /// Connection-scoped lifetime, but locking requests will automatically participate in a transaction if the connection has one + /// + ImplicitParticipation, + + /// + /// Connection-scoped lifetime, but locking requests will participate in a transaction if one is explicitly provided + /// + ExplicitParticipation, +} + +/// +/// Interface for the "primary" ADO.NET client for a particular DB backend. For now +/// this is just used to designate Microsoft.Data.SqlClient vs. System.Data.SqlClient +/// +public abstract class TestingPrimaryClientDb : TestingDb +{ + public abstract Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince = null); +} diff --git a/src/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs b/src/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs new file mode 100644 index 00000000..91e8bc0e --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs @@ -0,0 +1,219 @@ +using System.Data.Common; + +namespace Medallion.Threading.Tests.Data; + +/// +/// Determines how an ADO.NET-based synchronization primitive should function +/// +public abstract class TestingDbSynchronizationStrategy : TestingSynchronizationStrategy +{ + protected TestingDbSynchronizationStrategy(TestingDb db) + { + this.Db = db; + } + + public TestingDb Db { get; } + + public abstract TestingDbConnectionOptions GetConnectionOptions(); + + public override void PrepareForHighContention(ref int maxConcurrentAcquires) => + this.Db.PrepareForHighContention(ref maxConcurrentAcquires); +} + +public abstract class TestingDbSynchronizationStrategy : TestingDbSynchronizationStrategy + where TDb : TestingDb, new() +{ + protected TestingDbSynchronizationStrategy() : base(new TDb()) { } + + public new TDb Db => (TDb)base.Db; + + public override void Dispose() + { + // if we have a uniquely-named connection, clear it's pool to avoid "leaking" connections into pools we'll never + // use again + if (!Equals(this.Db.ApplicationName, new TDb().ApplicationName)) + { + using var connection = this.Db.CreateConnection(); + this.Db.ClearPool(connection); + } + + base.Dispose(); + } +} + +public abstract class TestingConnectionStringSynchronizationStrategy : TestingDbSynchronizationStrategy + // since we're just going to be generating from connection strings, we only care about + // the primary ADO client for the database + where TDb : TestingPrimaryClientDb, new() +{ + protected abstract bool? UseMultiplexingNotTransaction { get; } + public TimeSpan? KeepaliveCadence { get; set; } + + public sealed override TestingDbConnectionOptions GetConnectionOptions() => + new() + { + ConnectionString = this.Db.ConnectionString, + ConnectionStringUseMultiplexing = this.UseMultiplexingNotTransaction == true, + ConnectionStringUseTransaction = this.UseMultiplexingNotTransaction == false, + ConnectionStringKeepaliveCadence = this.KeepaliveCadence, + }; + + public sealed override IDisposable? PrepareForHandleLost() => + new HandleLostScope(this.Db.SetUniqueApplicationName(nameof(this.PrepareForHandleLost)), this.Db); + + private class HandleLostScope : IDisposable + { + private string? _applicationName; + private readonly TDb _db; + + public HandleLostScope(string applicationName, TDb testingDb) + { + this._applicationName = applicationName; + this._db = testingDb; + } + + public void Dispose() + { + var applicationName = Interlocked.Exchange(ref this._applicationName, null); + if (applicationName != null) + { + this._db.KillSessionsAsync(applicationName).Wait(); + } + } + } +} + +public sealed class TestingConnectionMultiplexingSynchronizationStrategy : TestingConnectionStringSynchronizationStrategy + where TDb : TestingPrimaryClientDb, new() +{ + protected override bool? UseMultiplexingNotTransaction => true; +} + +public sealed class TestingOwnedConnectionSynchronizationStrategy : TestingConnectionStringSynchronizationStrategy + where TDb : TestingPrimaryClientDb, new() +{ + protected override bool? UseMultiplexingNotTransaction => null; +} + +public sealed class TestingOwnedTransactionSynchronizationStrategy : TestingConnectionStringSynchronizationStrategy + where TDb : TestingPrimaryClientDb, new() +{ + protected override bool? UseMultiplexingNotTransaction => false; +} + +public abstract class TestingExternalConnectionOrTransactionSynchronizationStrategy : TestingDbSynchronizationStrategy + where TDb : TestingDb, new() +{ + /// + /// Starts a new "ambient" connection or transaction that future locks will be created with + /// + public abstract void StartAmbient(); + + protected abstract void EndAmbient(); + + /// + /// If has been called, returns the current ambient connection + /// + public abstract DbConnection? AmbientConnection { get; } + + public sealed override IDisposable? PrepareForHandleLost() + { + this.StartAmbient(); + return this.AmbientConnection; + } + + public sealed override void PrepareForHandleAbandonment() => this.StartAmbient(); + + public sealed override void PerformAdditionalCleanupForHandleAbandonment() + { + this.AmbientConnection!.Dispose(); + using var connection = this.Db.CreateConnection(); + this.Db.ClearPool(connection); + this.EndAmbient(); + } +} + +public sealed class TestingExternalConnectionSynchronizationStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy + where TDb : TestingDb, new() +{ + private readonly DisposableCollection _disposables = new(); + private DbConnection? _ambientConnection; + + public override DbConnection? AmbientConnection => this._ambientConnection; + + public override void StartAmbient() + { + // clear first so GetConnectionOptions will make a new connection + this._ambientConnection = null; + + this._ambientConnection = this.GetConnectionOptions().Connection; + } + + protected override void EndAmbient() => this._ambientConnection = null; + + public override TestingDbConnectionOptions GetConnectionOptions() + { + DbConnection connection; + if (this.AmbientConnection != null) + { + connection = this.AmbientConnection; + } + else + { + connection = this.Db.CreateConnection(); + this._disposables.Add(connection); + connection.Open(); + } + return new TestingDbConnectionOptions { Connection = connection }; + } + + public override void Dispose() + { + this._disposables.Dispose(); + base.Dispose(); + } +} + +public sealed class TestingExternalTransactionSynchronizationStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy + where TDb : TestingDb, new() +{ + private readonly DisposableCollection _disposables = new(); + + public DbTransaction? AmbientTransaction { get; private set; } + public override DbConnection? AmbientConnection => this.AmbientTransaction?.Connection; + + public override void StartAmbient() + { + // clear first so GetConnectionOptions will make a new transaction + this.AmbientTransaction = null; + + this.AmbientTransaction = this.GetConnectionOptions().Transaction; + } + + protected override void EndAmbient() => this.AmbientTransaction = null; + + public override TestingDbConnectionOptions GetConnectionOptions() + { + DbTransaction transaction; + if (this.AmbientTransaction != null) + { + transaction = this.AmbientTransaction; + } + else + { + var connection = this.Db.CreateConnection(); + this._disposables.Add(connection); + connection.Open(); + transaction = connection.BeginTransaction(); + this._disposables.Add(transaction); + } + + return new TestingDbConnectionOptions { Transaction = transaction }; + } + + public override void Dispose() + { + this._disposables.Dispose(); + base.Dispose(); + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/DisposableCollection.cs b/src/DistributedLock.Tests/Infrastructure/DisposableCollection.cs new file mode 100644 index 00000000..97c95d2a --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/DisposableCollection.cs @@ -0,0 +1,60 @@ +namespace Medallion.Threading.Tests; + +internal sealed class DisposableCollection : IDisposable +{ + private readonly object _lock = new(); + private Stack? _resources = new(); + + public void Add(IDisposable resource) + { + lock (this._lock) + { + (this._resources ?? throw new ObjectDisposedException(this.GetType().ToString())) + .Push(resource); + } + } + + public void Add(Action cleanupAction) => this.Add(new ReleaseAction(cleanupAction)); + + public void ClearAndDisposeAll() => this.InternalClearAndDisposeAll(isDispose: false); + + public void Dispose() => this.InternalClearAndDisposeAll(isDispose: true); + + private void InternalClearAndDisposeAll(bool isDispose) + { + lock (this._lock) + { + if (this._resources == null) + { + if (isDispose) { return; } + throw new ObjectDisposedException(this.GetType().ToString()); + } + + var exceptions = new List(); + while (this._resources.Count > 0) + { + try { this._resources.Pop().Dispose(); } + catch (Exception ex) { exceptions.Add(ex); } + } + + if (isDispose) + { + this._resources = null; + } + + if (exceptions.Any()) + { + throw new AggregateException(exceptions).Flatten(); + } + } + } +} + +internal class ReleaseAction : IDisposable +{ + private Action? _action; + + public ReleaseAction(Action action) { this._action = action; } + + public void Dispose() => Interlocked.Exchange(ref this._action, null)?.Invoke(); +} diff --git a/src/DistributedLock.Tests/Infrastructure/FileSystem/TestingFileSystemProviders.cs b/src/DistributedLock.Tests/Infrastructure/FileSystem/TestingFileSystemProviders.cs new file mode 100644 index 00000000..9d12d806 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/FileSystem/TestingFileSystemProviders.cs @@ -0,0 +1,13 @@ +using Medallion.Threading.FileSystem; + +namespace Medallion.Threading.Tests.FileSystem; + +[SupportsContinuousIntegration] +public sealed class TestingFileDistributedLockProvider : TestingLockProvider +{ + public override IDistributedLock CreateLockWithExactName(string name) => + new FileDistributedLock(new FileInfo(name)); + + public override string GetSafeName(string name) => + new FileDistributedLock(new DirectoryInfo(Path.Combine(Path.GetTempPath(), this.GetType().Name)), name).Name; +} diff --git a/src/DistributedLock.Tests/Infrastructure/FileSystem/TestingLockFileSynchronizationStrategy.cs b/src/DistributedLock.Tests/Infrastructure/FileSystem/TestingLockFileSynchronizationStrategy.cs new file mode 100644 index 00000000..5df35d13 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/FileSystem/TestingLockFileSynchronizationStrategy.cs @@ -0,0 +1,6 @@ +namespace Medallion.Threading.Tests.FileSystem; + +[SupportsContinuousIntegration] +public sealed class TestingLockFileSynchronizationStrategy : TestingSynchronizationStrategy +{ +} diff --git a/src/DistributedLock.Tests/Infrastructure/ITestingNameProvider.cs b/src/DistributedLock.Tests/Infrastructure/ITestingNameProvider.cs new file mode 100644 index 00000000..5702b4de --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/ITestingNameProvider.cs @@ -0,0 +1,16 @@ +namespace Medallion.Threading.Tests; + +public interface ITestingNameProvider +{ + string GetSafeName(string name); +} + +internal static class TestingNameProviderExtensions +{ + /// + /// Returns a name based on which is "namespaced" by the current + /// test and framework name, thus avoiding potential collisions between test cases + /// + public static string GetUniqueSafeName(this ITestingNameProvider provider, string baseName = "") => + provider.GetSafeName($"{baseName}_{TestHelper.UniqueName}"); +} diff --git a/src/DistributedLock.Tests/Infrastructure/MongoDB/MongoDBSetUpFixture.cs b/src/DistributedLock.Tests/Infrastructure/MongoDB/MongoDBSetUpFixture.cs new file mode 100644 index 00000000..fc04f961 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/MongoDB/MongoDBSetUpFixture.cs @@ -0,0 +1,60 @@ +using MongoDB.Driver; +using NUnit.Framework; +using MongoDB.Bson; +using Medallion.Shell; + +namespace Medallion.Threading.Tests.MongoDB; + +[SetUpFixture] +internal class MongoDBSetUpFixture +{ + [OneTimeSetUp] + public void OneTimeSetUp() + { + if (TestHelper.IsCi) { return; } // currently we don't attempt integration tests on CI + + var settings = MongoClientSettings.FromConnectionString(MongoDBCredentials.GetConnectionString(Environment.CurrentDirectory)); + if (IsMongoReady(settings, TimeSpan.FromSeconds(3))) { return; } + + // start mongo via docker + const string ContainerName = "distributed-lock-mongo"; + DockerCommand(["stop", ContainerName]); + DockerCommand(["rm", ContainerName]); + var port = settings.Server.Port; + DockerCommand(["run", "-d", "-p", $"{port}:{port}", "--name", ContainerName, "mongo:latest"]); + + settings.ServerSelectionTimeout = TimeSpan.FromSeconds(15); + for (var i = 0; i < 4; ++i) + { + if (IsMongoReady(settings, TimeSpan.FromSeconds(15))) { return; } + } + + throw new Exception("Failed to start Mongo! Make sure Docker is started!"); + + static bool DockerCommand(string[] args, bool throwOnError = false) => + Command.Run("docker", args, o => o.ThrowOnError(throwOnError)) + .RedirectTo(Console.Out) + .RedirectStandardErrorTo(Console.Error) + .Result.Success; + } + + private static bool IsMongoReady(MongoClientSettings settings, TimeSpan timeout) + { + settings.ServerSelectionTimeout = settings.ConnectTimeout = settings.SocketTimeout = timeout; + try + { + var client = new MongoClient(settings); + var adminDb = client.GetDatabase("admin"); + + adminDb.RunCommand(new BsonDocument("ping", 1)); + return true; + } + catch + { + return false; + } + } + + [OneTimeTearDown] + public void OneTimeTearDown() { } +} diff --git a/src/DistributedLock.Tests/Infrastructure/MongoDB/TestingMongoDbProviders.cs b/src/DistributedLock.Tests/Infrastructure/MongoDB/TestingMongoDbProviders.cs new file mode 100644 index 00000000..a98302ae --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/MongoDB/TestingMongoDbProviders.cs @@ -0,0 +1,32 @@ +using Medallion.Threading.MongoDB; +using MongoDB.Driver; + +namespace Medallion.Threading.Tests.MongoDB; + +public sealed class TestingMongoDistributedLockProvider : TestingLockProvider +{ + private readonly IMongoDatabase _database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + + public override IDistributedLock CreateLockWithExactName(string name) + { + // Use a short expiry to make tests like TestHandleLostTriggersCorrectly run faster + var @lock = new MongoDistributedLock(name, this._database, options => options.Expiry(TimeSpan.FromSeconds(5)).ExtensionCadence(TimeSpan.FromSeconds(.5))); + this.Strategy.KillHandleAction = () => + { + var collection = this._database.GetCollection(MongoDistributedLock.DefaultCollectionName); + collection.DeleteOne(Builders.Filter.Eq(d => d.Id, name)); + }; + return @lock; + } + + public override string GetSafeName(string name) => + new MongoDistributedLock(name, this._database, MongoDistributedLock.DefaultCollectionName).Name; + + public override string GetCrossProcessLockType() => nameof(MongoDistributedLock); + + public override void Dispose() + { + this._database.DropCollection(MongoDistributedLock.DefaultCollectionName); + base.Dispose(); + } +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Infrastructure/MongoDB/TestingMongoDbSynchronizationStrategy.cs b/src/DistributedLock.Tests/Infrastructure/MongoDB/TestingMongoDbSynchronizationStrategy.cs new file mode 100644 index 00000000..fc0b53cf --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/MongoDB/TestingMongoDbSynchronizationStrategy.cs @@ -0,0 +1,17 @@ +using Medallion.Threading.Tests; +using Medallion.Threading.Internal; +using Medallion.Threading.MongoDB; +using MongoDB.Driver; + +namespace Medallion.Threading.Tests.MongoDB; + +public sealed class TestingMongoDbSynchronizationStrategy : TestingSynchronizationStrategy +{ + public Action? KillHandleAction { get; set; } + + public override void PrepareForHandleAbandonment() => this.KillHandleAction?.Invoke(); + + public override void PerformAdditionalCleanupForHandleAbandonment() => this.KillHandleAction?.Invoke(); + + public override IDisposable? PrepareForHandleLost() => new ReleaseAction(() => this.KillHandleAction?.Invoke()); +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs b/src/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs new file mode 100644 index 00000000..827e2937 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs @@ -0,0 +1,94 @@ +using Medallion.Threading.Tests.Data; +using MySqlConnector; +using NUnit.Framework; +using System.Data; +using System.Data.Common; + +namespace Medallion.Threading.Tests.MySql; + +public class TestingMySqlDb : TestingPrimaryClientDb +{ + private readonly string _defaultConnectionString; + private readonly MySqlConnectionStringBuilder _connectionStringBuilder; + + public TestingMySqlDb() + : this(MySqlCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory)) + { + } + + protected TestingMySqlDb(string defaultConnectionString) + { + this._defaultConnectionString = defaultConnectionString; + this._connectionStringBuilder = new MySqlConnectionStringBuilder(this._defaultConnectionString); + } + + public override DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; + + public override int MaxPoolSize { get => (int)this._connectionStringBuilder.MaximumPoolSize; set => this._connectionStringBuilder.MaximumPoolSize = (uint)value; } + + public override int MaxApplicationNameLength => 65390; // based on empirical testing + + public override TransactionSupport TransactionSupport => TransactionSupport.ExplicitParticipation; + + protected virtual string IsolationLevelVariableName => "transaction_isolation"; + + public override int CountActiveSessions(string applicationName) + { + using var connection = new MySqlConnection(this._defaultConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM performance_schema.session_connect_attrs WHERE ATTR_NAME = 'program_name' AND ATTR_VALUE = @applicationName"; + command.Parameters.AddWithValue(nameof(applicationName), applicationName); + return (int)(long)command.ExecuteScalar()!; + } + + public override DbConnection CreateConnection() => new MySqlConnection(this.ConnectionStringBuilder.ConnectionString); + public override IsolationLevel GetIsolationLevel(DbConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT @@" + this.IsolationLevelVariableName; + var rawIsolationLevel = (string)command.ExecuteScalar()!; + return (IsolationLevel)Enum.Parse(typeof(IsolationLevel), rawIsolationLevel.Replace("-", string.Empty), ignoreCase: true); + } + + public override async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince = null) + { + var minTimeSeconds = idleSince.HasValue + ? (int?)(DateTimeOffset.UtcNow - idleSince.Value).TotalSeconds + : null; + + using var connection = new MySqlConnection(this._defaultConnectionString); + await connection.OpenAsync(); + using var idleSessionsCommand = connection.CreateCommand(); + idleSessionsCommand.CommandText = @" + SELECT a.PROCESSLIST_ID + FROM performance_schema.session_connect_attrs a + JOIN information_schema.processlist p + ON p.ID = a.PROCESSLIST_ID + WHERE a.ATTR_NAME = 'program_name' + AND a.ATTR_VALUE = @applicationName + AND (@minTimeSeconds IS NULL OR p.TIME > @minTimeSeconds)"; + idleSessionsCommand.Parameters.AddWithValue(nameof(applicationName), applicationName); + idleSessionsCommand.Parameters.AddWithValue(nameof(minTimeSeconds), minTimeSeconds); + + var idsToKill = new List(); + await using (var reader = await idleSessionsCommand.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) { idsToKill.Add(reader.GetInt32(0)); } + } + + foreach (var idToKill in idsToKill) + { + using var killCommand = connection.CreateCommand(); + killCommand.CommandText = $"KILL {idToKill}"; + await killCommand.ExecuteNonQueryAsync(); + } + } +} + +public sealed class TestingMariaDbDb : TestingMySqlDb +{ + public TestingMariaDbDb() : base(MariaDbCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory)) { } + + protected override string IsolationLevelVariableName => "tx_isolation"; +} diff --git a/src/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlProviders.cs b/src/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlProviders.cs new file mode 100644 index 00000000..a8c91c86 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlProviders.cs @@ -0,0 +1,27 @@ +using Medallion.Threading.MySql; +using Medallion.Threading.Tests.Data; + +namespace Medallion.Threading.Tests.MySql; + +public sealed class TestingMySqlDistributedLockProvider : TestingLockProvider + where TStrategy : TestingDbSynchronizationStrategy, new() + where TDb : TestingMySqlDb, new() +{ + public override IDistributedLock CreateLockWithExactName(string name) => + this.Strategy.GetConnectionOptions() + .Create( + (connectionString, options) => new MySqlDistributedLock(name, connectionString, options: ToMySqlOptions(options)), + connection => new MySqlDistributedLock(name, connection, exactName: true), + transaction => new MySqlDistributedLock(name, transaction, exactName: true) + ); + + public override string GetSafeName(string name) => new MySqlDistributedLock(name, new TDb().ConnectionStringBuilder.ConnectionString).Name; + + public override string GetCrossProcessLockType() => (typeof(TDb) == typeof(TestingMariaDbDb) ? "MariaDB" : string.Empty) + base.GetCrossProcessLockType(); + + internal static Action ToMySqlOptions((bool useMultiplexing, bool useTransaction, TimeSpan? keepaliveCadence) options) => o => + { + o.UseMultiplexing(options.useMultiplexing); + if (options.keepaliveCadence is { } keepaliveCadence) { o.KeepaliveCadence(keepaliveCadence); } + }; +} diff --git a/src/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleDb.cs b/src/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleDb.cs new file mode 100644 index 00000000..3ff1714d --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleDb.cs @@ -0,0 +1,95 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Oracle; +using Medallion.Threading.Tests.Data; +using NUnit.Framework; +using Oracle.ManagedDataAccess.Client; +using System.Data; +using System.Data.Common; + +namespace Medallion.Threading.Tests.Oracle; + +public sealed class TestingOracleDb : TestingPrimaryClientDb +{ + internal static readonly string DefaultConnectionString = OracleCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory); + + private readonly OracleConnectionStringBuilder _connectionStringBuilder = new(DefaultConnectionString); + + public override DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; + + public override string ApplicationName { get; set; } = string.Empty; + + public override string ConnectionString => + (this.ApplicationName.Length > 0 ? $"{OracleDatabaseConnection.ApplicationNameIndicatorPrefix}{this.ApplicationName};" : string.Empty) + + this.ConnectionStringBuilder.ConnectionString; + + // see https://docs.oracle.com/database/121/ARPLS/d_appinf.htm#ARPLS65237 + public override int MaxApplicationNameLength => 64; + + public override TransactionSupport TransactionSupport => TransactionSupport.ImplicitParticipation; + + public override int CountActiveSessions(string applicationName) + { + Invariant.Require(applicationName.Length <= this.MaxApplicationNameLength); + + // Oracle's client seems to allow the connection pool to grow beyond what is strictly required; + // Clear the pool to get an accurate count. See https://github.com/oracle/dotnet-db-samples/issues/425 + using (var clearConnection = this.CreateConnection()) + { + this.ClearPool(clearConnection); + } + + using var connection = new OracleConnection(DefaultConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM v$session WHERE client_info = :applicationName AND status != 'KILLED'"; + command.Parameters.Add("applicationName", applicationName); + return (int)(decimal)command.ExecuteScalar()!; + } + + public override DbConnection CreateConnection() => OracleDatabaseConnection.CreateConnection(this.As().ConnectionString); + + public override IsolationLevel GetIsolationLevel(DbConnection connection) + { + // After briefly trying the various approaches mentioned on https://stackoverflow.com/questions/10711204/how-to-check-isoloation-level + // I could not get them to work. Given that the tests using this are checking something relatively minor and SQLServer specific, not + // supporting this seems fine. + throw new NotSupportedException(); + } + + public override void PrepareForHighContention(ref int maxConcurrentAcquires) + { + // Oracle XE has a default max session limit of 20. When concurrency approaches that, parellel + // execution slows down greatly because often releases become queued behind competing aquires. When concurrency surpasses + // that level we risk total deadlock where all active sessions are in use by acquires and as such no release can ever get + // through. It's possible that we could configure a higher limit but I'm not sure that's necessary. + maxConcurrentAcquires = Math.Min(maxConcurrentAcquires, 15); + } + + public override async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince = null) + { + using var connection = new OracleConnection(DefaultConnectionString); + await connection.OpenAsync(); + + using var getIdleSessionsCommand = connection.CreateCommand(); + var idleTimeSeconds = idleSince.HasValue ? (DateTimeOffset.Now - idleSince.Value).TotalSeconds : default(double?); + getIdleSessionsCommand.CommandText = $@" + SELECT sid, serial# + FROM v$session + WHERE client_info = :applicationName + {(idleTimeSeconds.HasValue ? $"AND last_call_et >= {idleTimeSeconds}" : string.Empty)}"; + getIdleSessionsCommand.Parameters.Add("applicationName", applicationName); + using var reader = await getIdleSessionsCommand.ExecuteReaderAsync(); + var sessionsToKill = new List<(int Sid, int SerialNumber)>(); + while (await reader.ReadAsync()) + { + sessionsToKill.Add((Sid: (int)reader.GetDecimal(0), SerialNumber: (int)reader.GetDecimal(1))); + } + + foreach (var (sid, serialNumber) in sessionsToKill) + { + using var killCommand = connection.CreateCommand(); + killCommand.CommandText = $"ALTER SYSTEM KILL SESSION '{sid},{serialNumber}'"; + await killCommand.ExecuteNonQueryAsync(); + } + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleProviders.cs b/src/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleProviders.cs new file mode 100644 index 00000000..90729c4e --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleProviders.cs @@ -0,0 +1,38 @@ +using Medallion.Threading.Oracle; +using Medallion.Threading.Tests.Data; + +namespace Medallion.Threading.Tests.Oracle; + +public sealed class TestingOracleDistributedLockProvider : TestingLockProvider + where TStrategy : TestingDbSynchronizationStrategy, new() +{ + public override IDistributedLock CreateLockWithExactName(string name) => + this.Strategy.GetConnectionOptions() + .Create( + (connectionString, options) => new OracleDistributedLock(name, connectionString, options: ToOracleOptions(options)), + connection => new OracleDistributedLock(name, connection), + transaction => new OracleDistributedLock(name, transaction.Connection!) + ); + + public override string GetSafeName(string name) => new OracleDistributedLock(name, TestingOracleDb.DefaultConnectionString).Name; + + internal static Action ToOracleOptions((bool useMultiplexing, bool useTransaction, TimeSpan? keepaliveCadence) options) => o => + { + o.UseMultiplexing(options.useMultiplexing); + if (options.keepaliveCadence is { } keepaliveCadence) { o.KeepaliveCadence(keepaliveCadence); } + }; +} + +public sealed class TestingOracleDistributedReaderWriterLockProvider : TestingUpgradeableReaderWriterLockProvider + where TStrategy : TestingDbSynchronizationStrategy, new() +{ + public override IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderWriterLockWithExactName(string name) => + this.Strategy.GetConnectionOptions() + .Create( + (connectionString, options) => + new OracleDistributedReaderWriterLock(name, connectionString, TestingOracleDistributedLockProvider.ToOracleOptions(options), exactName: true), + connection => new OracleDistributedReaderWriterLock(name, connection, exactName: true), + transaction => new OracleDistributedReaderWriterLock(name, transaction.Connection!, exactName: true)); + + public override string GetSafeName(string name) => new OracleDistributedReaderWriterLock(name, TestingOracleDb.DefaultConnectionString).Name; +} diff --git a/src/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs b/src/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs new file mode 100644 index 00000000..46a137aa --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs @@ -0,0 +1,70 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Tests.Data; +using Npgsql; +using NpgsqlTypes; +using NUnit.Framework; +using System.Data; +using System.Data.Common; + +namespace Medallion.Threading.Tests.Postgres; + +public sealed class TestingPostgresDb : TestingPrimaryClientDb +{ + internal static readonly string DefaultConnectionString = PostgresCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory); + + private readonly NpgsqlConnectionStringBuilder _connectionStringBuilder = new(DefaultConnectionString); + + public override DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; + + // https://til.hashrocket.com/posts/8f87c65a0a-postgresqls-max-identifier-length-is-63-bytes + public override int MaxApplicationNameLength => 63; + + /// + /// Technically Postgres does support this through xact advisory lock methods, but it is very unwieldy to use due to the transaction + /// abort semantics and largely unnecessary for our purposes since, unlike SQLServer, a connection-scoped Postgres lock can still + /// participate in an ongoing transaction. + /// + public override TransactionSupport TransactionSupport => TransactionSupport.ImplicitParticipation; + + public override int CountActiveSessions(string applicationName) + { + Invariant.Require(applicationName.Length <= this.MaxApplicationNameLength); + + using var connection = new NpgsqlConnection(DefaultConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*)::int FROM pg_stat_activity WHERE application_name = @applicationName"; + command.Parameters.AddWithValue("applicationName", applicationName); + return (int)command.ExecuteScalar()!; + } + + public override IsolationLevel GetIsolationLevel(DbConnection connection) + { + using var command = connection.CreateCommand(); + // values based on https://www.postgresql.org/docs/12/transaction-iso.html + command.CommandText = "SELECT REPLACE(current_setting('transaction_isolation'), ' ', '')"; + return (IsolationLevel)Enum.Parse(typeof(IsolationLevel), (string)command.ExecuteScalar()!, ignoreCase: true); + } + + public override DbConnection CreateConnection() => new NpgsqlConnection(this.ConnectionStringBuilder.ConnectionString); + + public override async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince) + { + using var connection = new NpgsqlConnection(DefaultConnectionString); + await connection.OpenAsync(); + using var command = connection.CreateCommand(); + // based on https://stackoverflow.com/questions/13236160/is-there-a-timeout-for-idle-postgresql-connections + command.CommandText = @" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE application_name = @applicationName + AND ( + @idleSince IS NULL + OR (state = 'idle' AND state_change < @idleSince) + )"; + command.Parameters.AddWithValue("applicationName", applicationName); + command.Parameters.Add(new NpgsqlParameter("idleSince", idleSince?.ToUniversalTime() ?? DBNull.Value.As()) { NpgsqlDbType = NpgsqlDbType.TimestampTz }); + + await command.ExecuteNonQueryAsync(); + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresProviders.cs b/src/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresProviders.cs new file mode 100644 index 00000000..f77fab22 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresProviders.cs @@ -0,0 +1,48 @@ +using Medallion.Threading.Postgres; +using Medallion.Threading.Tests.Data; + +namespace Medallion.Threading.Tests.Postgres; + +public sealed class TestingPostgresDistributedLockProvider : TestingLockProvider + where TStrategy : TestingDbSynchronizationStrategy, new() +{ + public override IDistributedLock CreateLockWithExactName(string name) => + this.Strategy.GetConnectionOptions() + .Create( + (connectionString, options) => new PostgresDistributedLock( + new PostgresAdvisoryLockKey(name, allowHashing: false), + connectionString, + ToPostgresOptions(options) + ), + connection => new PostgresDistributedLock(new PostgresAdvisoryLockKey(name, allowHashing: false), connection), + transaction => new PostgresDistributedLock(new PostgresAdvisoryLockKey(name, allowHashing: false), transaction.Connection!) + ); + + public override string GetSafeName(string name) => new PostgresAdvisoryLockKey(name, allowHashing: true).ToString(); + + internal static Action ToPostgresOptions((bool useMultiplexing, bool useTransaction, TimeSpan? keepaliveCadence) options) => o => + { + o.UseMultiplexing(options.useMultiplexing); + o.UseTransaction(options.useTransaction); + if (options.keepaliveCadence is { } keepaliveCadence) { o.KeepaliveCadence(keepaliveCadence); } + }; +} + +public sealed class TestingPostgresDistributedReaderWriterLockProvider : TestingReaderWriterLockProvider + where TStrategy : TestingDbSynchronizationStrategy, new() +{ + public override IDistributedReaderWriterLock CreateReaderWriterLockWithExactName(string name) => + this.Strategy.GetConnectionOptions() + .Create( + (connectionString, options) => + new PostgresDistributedReaderWriterLock( + new PostgresAdvisoryLockKey(name, allowHashing: false), + connectionString, + TestingPostgresDistributedLockProvider.ToPostgresOptions(options) + ), + connection => new PostgresDistributedReaderWriterLock(new PostgresAdvisoryLockKey(name, allowHashing: false), connection), + transaction => new PostgresDistributedReaderWriterLock(new PostgresAdvisoryLockKey(name, allowHashing: false), transaction.Connection!) + ); + + public override string GetSafeName(string name) => new PostgresAdvisoryLockKey(name, allowHashing: true).ToString(); +} diff --git a/src/DistributedLock.Tests/Infrastructure/Redis/RedisServer.cs b/src/DistributedLock.Tests/Infrastructure/Redis/RedisServer.cs new file mode 100644 index 00000000..d524205a --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Redis/RedisServer.cs @@ -0,0 +1,91 @@ +using Medallion.Shell; +using StackExchange.Redis; + +namespace Medallion.Threading.Tests.Redis; + +internal class RedisServer +{ + // redis default is 6379, so go one above that + private static readonly int MinDynamicPort = RedisPorts.DefaultPorts.Max() + 1, MaxDynamicPort = MinDynamicPort + 100; + + // it's important for this to be lazy because it doesn't work when running on Linux + private static readonly Lazy WslPath = new( + () => Directory.GetDirectories(@"C:\Windows\WinSxS") + .Select(d => Path.Combine(d, "wsl.exe")) + .Where(File.Exists) + .OrderByDescending(File.GetCreationTimeUtc) + .First() + ); + + private static readonly Dictionary ActiveServersByPort = []; + private static readonly RedisServer[] DefaultServers = new RedisServer[RedisPorts.DefaultPorts.Count]; + + private readonly Command _command; + + public RedisServer(bool allowAdmin = false) : this(null, allowAdmin) { } + + private RedisServer(int? port, bool allowAdmin) + { + lock (ActiveServersByPort) + { + this.Port = port ?? Enumerable.Range(MinDynamicPort, count: MaxDynamicPort - MinDynamicPort + 1) + .First(p => !ActiveServersByPort.ContainsKey(p)); + this._command = Command.Run(WslPath.Value, ["redis-server", "--port", this.Port], options: o => o.StartInfo(si => si.RedirectStandardInput = false)) + .RedirectTo(Console.Out) + .RedirectStandardErrorTo(Console.Error); + ActiveServersByPort.Add(this.Port, this); + } + this.Multiplexer = ConnectionMultiplexer.Connect($"localhost:{this.Port},abortConnect=false{(allowAdmin ? ",allowAdmin=true" : string.Empty)}"); + // Clean the db to ensure it is empty. Running an arbitrary command also ensures that + // the db successfully spun up before we proceed (Connect seemingly can complete before that happens). + // This is particularly important for cross-process locking where the lock taker process + // assumes we've already started a server on certain ports. + this.Multiplexer.GetDatabase().Execute("flushall", Array.Empty(), CommandFlags.DemandMaster); + } + + public int ProcessId => this._command.ProcessId; + public int Port { get; } + public ConnectionMultiplexer Multiplexer { get; } + + public static RedisServer GetDefaultServer(int index) + { + lock (DefaultServers) + { + return DefaultServers[index] ??= new RedisServer(RedisPorts.DefaultPorts[index], allowAdmin: false); + } + } + + public static void DisposeAll() + { + lock (ActiveServersByPort) + { + var shutdownTasks = ActiveServersByPort.Values + .Select(async server => + { + // When testing the case of a server outage, we'll have manually shut down some servers. + // In that case, we shouldn't attempt to connect to them since that will fail. + var isConnected = server.Multiplexer.GetServers().Any(s => s.IsConnected); + server.Multiplexer.Dispose(); + try + { + if (isConnected) + { + using var adminMultiplexer = await ConnectionMultiplexer.ConnectAsync($"localhost:{server.Port},allowAdmin=true"); + adminMultiplexer.GetServer("localhost", server.Port).Shutdown(ShutdownMode.Never); + } + } + finally + { + if (!await server._command.Task.TryWaitAsync(TimeSpan.FromSeconds(5))) + { + server._command.Kill(); + throw new InvalidOperationException("Forced to kill Redis server"); + } + } + }) + .ToArray(); + ActiveServersByPort.Clear(); + Task.WaitAll(shutdownTasks); + } + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Redis/RedisSetUpFixture.cs b/src/DistributedLock.Tests/Infrastructure/Redis/RedisSetUpFixture.cs new file mode 100644 index 00000000..0226708a --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Redis/RedisSetUpFixture.cs @@ -0,0 +1,13 @@ +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Redis; + +[SetUpFixture] +public class RedisSetUpFixture +{ + [OneTimeSetUp] + public void OneTimeSetUp() { } + + [OneTimeTearDown] + public void OneTimeTearDown() => RedisServer.DisposeAll(); +} diff --git a/src/DistributedLock.Tests/Infrastructure/Redis/TestingRedisDatabaseProvider.cs b/src/DistributedLock.Tests/Infrastructure/Redis/TestingRedisDatabaseProvider.cs new file mode 100644 index 00000000..875ec6c5 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Redis/TestingRedisDatabaseProvider.cs @@ -0,0 +1,63 @@ +using NUnit.Framework; +using StackExchange.Redis; +using StackExchange.Redis.KeyspaceIsolation; +using System.Diagnostics; + +namespace Medallion.Threading.Tests.Redis; + +public abstract class TestingRedisDatabaseProvider +{ + protected TestingRedisDatabaseProvider(IEnumerable databases) + { + this.Databases = databases.ToArray(); + } + + protected TestingRedisDatabaseProvider(int count) + : this(Enumerable.Range(0, count).Select(i => RedisServer.GetDefaultServer(i).Multiplexer.GetDatabase())) + { + } + + // publicly settable so that callers can alter the dbs in use + public IReadOnlyList Databases { get; set; } + + public virtual string CrossProcessLockTypeSuffix => this.Databases.Count.ToString(); +} + +public sealed class TestingRedisSingleDatabaseProvider : TestingRedisDatabaseProvider +{ + public TestingRedisSingleDatabaseProvider() : base(count: 1) { } +} + +public sealed class TestingRedisWithKeyPrefixSingleDatabaseProvider : TestingRedisDatabaseProvider +{ + public TestingRedisWithKeyPrefixSingleDatabaseProvider() + : base(new[] { RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase().WithKeyPrefix("distributed_locks:") }) { } + + public override string CrossProcessLockTypeSuffix => "1WithPrefix"; +} + +public sealed class TestingRedis3DatabaseProvider : TestingRedisDatabaseProvider +{ + public TestingRedis3DatabaseProvider() : base(count: 3) { } +} + +public sealed class TestingRedis2x1DatabaseProvider : TestingRedisDatabaseProvider +{ + private static readonly IDatabase DeadDatabase; + + static TestingRedis2x1DatabaseProvider() + { + var server = new RedisServer(allowAdmin: true); + DeadDatabase = server.Multiplexer.GetDatabase(); + using var process = Process.GetProcessById(server.ProcessId); + server.Multiplexer.GetServer($"localhost:{server.Port}").Shutdown(ShutdownMode.Never); + Assert.That(process.WaitForExit(5000), Is.True); + } + + public TestingRedis2x1DatabaseProvider() + : base(Enumerable.Range(0, 2).Select(i => RedisServer.GetDefaultServer(i).Multiplexer.GetDatabase()).Append(DeadDatabase)) + { + } + + public override string CrossProcessLockTypeSuffix => "2x1"; +} diff --git a/src/DistributedLock.Tests/Infrastructure/Redis/TestingRedisProviders.cs b/src/DistributedLock.Tests/Infrastructure/Redis/TestingRedisProviders.cs new file mode 100644 index 00000000..10b5f144 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Redis/TestingRedisProviders.cs @@ -0,0 +1,61 @@ +using Medallion.Threading.Redis; + +namespace Medallion.Threading.Tests.Redis; + +public sealed class TestingRedisDistributedLockProvider : TestingLockProvider> + where TDatabaseProvider : TestingRedisDatabaseProvider, new() +{ + public override IDistributedLock CreateLockWithExactName(string name) + { + var @lock = new RedisDistributedLock(name, this.Strategy.DatabaseProvider.Databases, this.Strategy.Options); + this.Strategy.RegisterKillHandleAction( + () => this.Strategy.DatabaseProvider.Databases.Take((this.Strategy.DatabaseProvider.Databases.Count / 2) + 1) + .ToList() + .ForEach(db => db.KeyDelete(@lock.Key)) + ); + return @lock; + } + + public override string GetSafeName(string name) => new RedisDistributedLock(name, this.Strategy.DatabaseProvider.Databases).Name; + + public override string GetCrossProcessLockType() => $"{nameof(RedisDistributedLock)}{this.Strategy.DatabaseProvider.CrossProcessLockTypeSuffix}"; +} + +public sealed class TestingRedisDistributedReaderWriterLockProvider : TestingReaderWriterLockProvider> + where TDatabaseProvider : TestingRedisDatabaseProvider, new() +{ + public override IDistributedReaderWriterLock CreateReaderWriterLockWithExactName(string name) + { + var @lock = new RedisDistributedReaderWriterLock(name, this.Strategy.DatabaseProvider.Databases, this.Strategy.Options); + this.Strategy.RegisterKillHandleAction( + () => this.Strategy.DatabaseProvider.Databases.Take((this.Strategy.DatabaseProvider.Databases.Count / 2) + 1) + .ToList() + .ForEach(db => + { + db.KeyDelete(@lock.ReaderKey); + db.KeyDelete(@lock.WriterKey); + }) + ); + return @lock; + } + + public override string GetSafeName(string name) => new RedisDistributedReaderWriterLock(name, this.Strategy.DatabaseProvider.Databases).Name; + + public override string GetCrossProcessLockType(ReaderWriterLockType type) => $"{type}{nameof(RedisDistributedReaderWriterLock)}{this.Strategy.DatabaseProvider.CrossProcessLockTypeSuffix}"; +} + +public sealed class TestingRedisDistributedSemaphoreProvider : TestingSemaphoreProvider> +{ + public override IDistributedSemaphore CreateSemaphoreWithExactName(string name, int maxCount) + { + var semaphore = new RedisDistributedSemaphore(name, maxCount, this.Strategy.DatabaseProvider.Databases.Single(), this.Strategy.Options); + this.Strategy.RegisterKillHandleAction( + () => this.Strategy.DatabaseProvider.Databases.Take((this.Strategy.DatabaseProvider.Databases.Count / 2) + 1) + .ToList() + .ForEach(db => db.KeyDelete(semaphore.Key)) + ); + return semaphore; + } + + public override string GetSafeName(string name) => new RedisDistributedSemaphore(name, 1, this.Strategy.DatabaseProvider.Databases.Single()).Name; +} diff --git a/src/DistributedLock.Tests/Infrastructure/Redis/TestingRedisSynchronizationStrategy.cs b/src/DistributedLock.Tests/Infrastructure/Redis/TestingRedisSynchronizationStrategy.cs new file mode 100644 index 00000000..ebf8ded4 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Redis/TestingRedisSynchronizationStrategy.cs @@ -0,0 +1,68 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis; + +namespace Medallion.Threading.Tests.Redis; + +public sealed class TestingRedisSynchronizationStrategy : TestingSynchronizationStrategy + where TDatabaseProvider : TestingRedisDatabaseProvider, new() +{ + private bool _preparedForHandleLost, _preparedForHandleAbandonment; + private Action? _killHandleAction; + private Action? _options; + + public TDatabaseProvider DatabaseProvider { get; } = new TDatabaseProvider(); + + public void SetOptions(Action? options) + { + this._options = options; + } + + public void Options(RedisDistributedSynchronizationOptionsBuilder options) + { + if (this._preparedForHandleLost) + { + options.ExtensionCadence(TimeSpan.FromMilliseconds(30)); + } + if (this._preparedForHandleAbandonment) + { + options.Expiry(TimeSpan.FromSeconds(.2)) + // the reader writer lock requires that the busy wait sleep time is shorter + // than the expiry, so adjust for that + .BusyWaitSleepTime(TimeSpan.FromSeconds(.01), TimeSpan.FromSeconds(.1)); + } + + this._options?.Invoke(options); + } + + public override IDisposable? PrepareForHandleLost() + { + Invariant.Require(!this._preparedForHandleLost); + this._preparedForHandleLost = true; + return new ReleaseAction(() => + { + Invariant.Require(this._preparedForHandleLost); + try { this._killHandleAction?.Invoke(); } + finally + { + this._killHandleAction = null; + this._preparedForHandleLost = false; + } + }); + } + + public override void PrepareForHandleAbandonment() => this._preparedForHandleAbandonment = true; + + public override void PerformAdditionalCleanupForHandleAbandonment() + { + Invariant.Require(this._preparedForHandleAbandonment); + Thread.Sleep(TimeSpan.FromSeconds(.5)); + } + + public void RegisterKillHandleAction(Action action) + { + if (this._preparedForHandleLost) + { + this._killHandleAction += action; + } + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/AzureCredentials.cs b/src/DistributedLock.Tests/Infrastructure/Shared/AzureCredentials.cs new file mode 100644 index 00000000..b8847cd3 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/AzureCredentials.cs @@ -0,0 +1,9 @@ +namespace Medallion.Threading.Tests; + +public static class AzureCredentials +{ + // based on https://docs.microsoft.com/en-us/azure/storage/common/storage-use-emulator#connect-to-the-emulator-account-using-a-shortcut + public const string ConnectionString = "UseDevelopmentStorage=true"; + + public static string DefaultBlobContainerName { get; } = "distributed-lock-" + TargetFramework.Current.Replace('.', '-'); +} diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/Composites.cs b/src/DistributedLock.Tests/Infrastructure/Shared/Composites.cs new file mode 100644 index 00000000..d11fce04 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/Composites.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Medallion.Threading.FileSystem; +using Medallion.Threading.Postgres; +using Medallion.Threading.WaitHandles; + +namespace Medallion.Threading.Tests; + +public class TestingCompositeFileDistributedLock(string name) : IDistributedLock +{ + private readonly FileDistributedSynchronizationProvider _provider = new( + new DirectoryInfo(Path.Combine(Path.GetTempPath(), typeof(TestingCompositeFileDistributedLock).Name))); + private readonly string[] _names = [name + "_1", name + "_2"]; + + public string Name => name; + + public IDistributedSynchronizationHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + this._provider.AcquireAllLocks(this._names, timeout, cancellationToken); + + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + this._provider.AcquireAllLocksAsync(this._names, timeout, cancellationToken); + + public IDistributedSynchronizationHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this._provider.TryAcquireAllLocks(this._names, timeout, cancellationToken); + + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this._provider.TryAcquireAllLocksAsync(this._names, timeout, cancellationToken); +} + +public class TestingCompositeWaitHandleDistributedSemaphore(string name, int maxCount) : IDistributedSemaphore +{ + private readonly WaitHandleDistributedSynchronizationProvider _provider = new(); + private readonly string[] _names = [name + "_1", name + "_2"]; + + public string Name => name; + + public int MaxCount => maxCount; + + public IDistributedSynchronizationHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + this._provider.AcquireAllSemaphores(this._names, maxCount, timeout, cancellationToken); + + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + this._provider.AcquireAllSemaphoresAsync(this._names, maxCount, timeout, cancellationToken); + + public IDistributedSynchronizationHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this._provider.TryAcquireAllSemaphores(this._names, maxCount, timeout, cancellationToken); + + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this._provider.TryAcquireAllSemaphoresAsync(this._names, maxCount, timeout, cancellationToken); +} + +public class TestingCompositePostgresReaderWriterLock(string name, string connectionString, Action? options = null) : IDistributedReaderWriterLock +{ + private readonly PostgresDistributedSynchronizationProvider _provider = new(connectionString, options); + private readonly string[] _names = [name + "_1", name + "_2"]; + + public string Name => name; + + public IDistributedSynchronizationHandle AcquireReadLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + this._provider.AcquireAllReadLocks(this._names, timeout, cancellationToken); + + public ValueTask AcquireReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + this._provider.AcquireAllReadLocksAsync(this._names, timeout, cancellationToken); + + public IDistributedSynchronizationHandle AcquireWriteLock(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + this._provider.AcquireAllWriteLocks(this._names, timeout, cancellationToken); + + public ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + this._provider.AcquireAllWriteLocksAsync(this._names, timeout, cancellationToken); + + public IDistributedSynchronizationHandle? TryAcquireReadLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this._provider.TryAcquireAllReadLocks(this._names, timeout, cancellationToken); + + public ValueTask TryAcquireReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this._provider.TryAcquireAllReadLocksAsync(this._names, timeout, cancellationToken); + + public IDistributedSynchronizationHandle? TryAcquireWriteLock(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this._provider.TryAcquireAllWriteLocks(this._names, timeout, cancellationToken); + + public ValueTask TryAcquireWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this._provider.TryAcquireAllWriteLocksAsync(this._names, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs b/src/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs new file mode 100644 index 00000000..045d1fc5 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs @@ -0,0 +1,42 @@ +using MySqlConnector; +using System; +using System.IO; + +namespace Medallion.Threading.Tests; + +internal static class MariaDbCredentials +{ + // MARIADB SETUP NOTE + // + // In order to enable application name tracking, we must enable the performance schema. Add the following to + // C:\Program Files\MariaDB 10.6\data\my.ini in the [mysqld] section + // + // ;from https://mariadb.com/kb/en/performance-schema-overview/#activating-the-performance-schema + // performance_schema=ON + + private static (string username, string password) GetCredentials(string baseDirectory) + { + var file = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "credentials", "mariadb.txt")); + if (!File.Exists(file)) { throw new InvalidOperationException($"Unable to find MariaDB credentials file {file}"); } + var lines = File.ReadAllLines(file); + if (lines.Length != 2) { throw new FormatException($"{file} must contain exactly 2 lines of text"); } + return (lines[0], lines[1]); + } + + public static string GetConnectionString(string baseDirectory) + { + var (username, password) = GetCredentials(baseDirectory); + + return new MySqlConnectionStringBuilder + { + Port = 3306, + Server = "localhost", + Database = "mysql", + UserID = username, + Password = password, + PersistSecurityInfo = true, + // set a high pool size so that we don't empty the pool through things like lock abandonment tests + MaximumPoolSize = 500, + }.ConnectionString; + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/MongoDbCredentials.cs b/src/DistributedLock.Tests/Infrastructure/Shared/MongoDbCredentials.cs new file mode 100644 index 00000000..cf87621f --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/MongoDbCredentials.cs @@ -0,0 +1,26 @@ +using MongoDB.Driver; +using System.Collections.Concurrent; +using System.IO; + +namespace Medallion.Threading.Tests.MongoDB; + +internal static class MongoDBCredentials +{ + private static readonly ConcurrentDictionary ConnectionStringsByBaseDirectory = []; + + public static string GetConnectionString(string baseDirectory) => + ConnectionStringsByBaseDirectory.GetOrAdd(baseDirectory, static d => + { + var file = Path.GetFullPath(Path.Combine(d, "..", "..", "..", "credentials", "mongodb.txt")); + return File.Exists(file) + ? File.ReadAllText(file).Trim() + // Default local MongoDB connection + : "mongodb://localhost:27017"; + }); + + public static IMongoDatabase GetDefaultDatabase(string baseDirectory) + { + var client = new MongoClient(GetConnectionString(baseDirectory)); + return client.GetDatabase("distributedLockTests"); + } +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/MySqlCredentials.cs b/src/DistributedLock.Tests/Infrastructure/Shared/MySqlCredentials.cs new file mode 100644 index 00000000..d2b9aa0d --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/MySqlCredentials.cs @@ -0,0 +1,36 @@ +using MySqlConnector; +using System; +using System.IO; + +namespace Medallion.Threading.Tests; + +internal static class MySqlCredentials +{ + private static (string username, string password) GetCredentials(string baseDirectory) + { + var file = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "credentials", "mysql.txt")); + if (!File.Exists(file)) { throw new InvalidOperationException($"Unable to find mysql credentials file {file}"); } + var lines = File.ReadAllLines(file); + if (lines.Length != 2) { throw new FormatException($"{file} must contain exactly 2 lines of text"); } + return (lines[0], lines[1]); + } + + public static string GetConnectionString(string baseDirectory) + { + var (username, password) = GetCredentials(baseDirectory); + + return new MySqlConnectionStringBuilder + { + Port = 3307, + Server = "localhost", + Database = "mysql", + UserID = username, + Password = password, + PersistSecurityInfo = true, + // set a high pool size so that we don't empty the pool through things like lock abandonment tests + MaximumPoolSize = 500, + // workaround for https://github.com/mysql-net/MySqlConnector/issues/1448 + TlsVersion = "Tls12" + }.ConnectionString; + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/OracleCredentials.cs b/src/DistributedLock.Tests/Infrastructure/Shared/OracleCredentials.cs new file mode 100644 index 00000000..517f078e --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/OracleCredentials.cs @@ -0,0 +1,41 @@ +using Oracle.ManagedDataAccess.Client; +using System; +using System.IO; +using System.Linq; + +namespace Medallion.Threading.Tests; + +/// +/// For Oracle, we need both a password and a "wallet" directory. +/// +/// See https://www.oracle.com/topics/technologies/dotnet/tech-info-autonomousdatabase.html for setup instructions. +/// See also https://github.com/oracle/dotnet-db-samples/issues/225 +/// +/// If the tests haven't been run for some time, it might be necessary to start the autonomous database at https://cloud.oracle.com/, +/// since it will stop after being idle for some time. +/// +internal static class OracleCredentials +{ + public static string GetConnectionString(string baseDirectory) + { + var credentialDirectory = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "credentials")); + var (username, password) = GetCredentials(credentialDirectory); + + return new OracleConnectionStringBuilder + { + DataSource = "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=XE)))", + UserID = username, + Password = password, + PersistSecurityInfo = true, + }.ConnectionString; + } + + private static (string Username, string Password) GetCredentials(string credentialDirectory) + { + var file = Path.Combine(credentialDirectory, "oracle.txt"); + if (!File.Exists(file)) { throw new InvalidOperationException($"Unable to find Oracle credentials file {file}"); } + var lines = File.ReadAllLines(file); + if (lines.Length != 2) { throw new FormatException($"{file} must contain exactly 2 lines of text"); } + return (lines[0], lines[1]); + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/PostgresCredentials.cs b/src/DistributedLock.Tests/Infrastructure/Shared/PostgresCredentials.cs new file mode 100644 index 00000000..3222829a --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/PostgresCredentials.cs @@ -0,0 +1,35 @@ +using Npgsql; +using System; +using System.IO; + +namespace Medallion.Threading.Tests; + +internal static class PostgresCredentials +{ + private static (string username, string password) GetCredentials(string baseDirectory) + { + var file = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "credentials", "postgres.txt")); + if (!File.Exists(file)) { throw new InvalidOperationException($"Unable to find postgres credentials file {file}"); } + var lines = File.ReadAllLines(file); + if (lines.Length != 2) { throw new FormatException($"{file} must contain exactly 2 lines of text"); } + return (lines[0], lines[1]); + } + + public static string GetConnectionString(string baseDirectory) + { + var (username, password) = GetCredentials(baseDirectory); + + return new NpgsqlConnectionStringBuilder + { + Port = 5432, + Host = "localhost", + Database = "postgres", + Username = username, + Password = password, + PersistSecurityInfo = true, + ApplicationName = SqlServerCredentials.ApplicationName, + // set a high pool size so that we don't empty the pool through things like lock abandonment tests + MaxPoolSize = 500, + }.ConnectionString; + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/RedisPorts.cs b/src/DistributedLock.Tests/Infrastructure/Shared/RedisPorts.cs new file mode 100644 index 00000000..e970f9d8 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/RedisPorts.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Medallion.Threading.Tests; + +internal static class RedisPorts +{ + // 6379 is the redis default, so don't use that + public static readonly IReadOnlyList DefaultPorts = Enumerable.Range(6380, count: 10).ToArray(); +} diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/SqlServerCredentials.cs b/src/DistributedLock.Tests/Infrastructure/Shared/SqlServerCredentials.cs new file mode 100644 index 00000000..d9598e32 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/SqlServerCredentials.cs @@ -0,0 +1,21 @@ +namespace Medallion.Threading.Tests; + +internal static class SqlServerCredentials +{ + public static readonly string ApplicationName = $"{typeof(SqlServerCredentials).Assembly.GetName().Name} ({TargetFramework.Current})"; + + public static readonly string ConnectionString = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder + { + DataSource = @"localhost", // localhost for SQL Developer, .\SQLEXPRESS for express + InitialCatalog = "master", + IntegratedSecurity = true, + ApplicationName = ApplicationName, + // set a high pool size so that we don't empty the pool through things like lock abandonment tests + MaxPoolSize = 10000, + // Allows us to connect to SQLExpress with Microsoft.Data.SqlClient while still being compatible + // with System.Data.SqlClient (alternative would be building the connection string with System.Data.SqlClient + // and doing TrustServerCertificate = true). + Encrypt = false, + } + .ConnectionString; +} diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/TargetFramework.cs b/src/DistributedLock.Tests/Infrastructure/Shared/TargetFramework.cs new file mode 100644 index 00000000..a4044956 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/TargetFramework.cs @@ -0,0 +1,11 @@ +namespace Medallion.Threading.Tests; + +internal static class TargetFramework +{ + public const string Current = +#if NET472 + "net472"; +#elif NET8_0 + "net8.0"; +#endif +} diff --git a/src/DistributedLock.Tests/Infrastructure/Shared/ZooKeeperPorts.cs b/src/DistributedLock.Tests/Infrastructure/Shared/ZooKeeperPorts.cs new file mode 100644 index 00000000..c8df041d --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/Shared/ZooKeeperPorts.cs @@ -0,0 +1,8 @@ +namespace Medallion.Threading.Tests; + +public static class ZooKeeperPorts +{ + public const int DefaultPort = 2181; + + public static readonly string DefaultConnectionString = "127.0.0.1:" + DefaultPort; +} diff --git a/src/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs b/src/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs new file mode 100644 index 00000000..a7f8327a --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs @@ -0,0 +1,110 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Tests.Data; +using System.Data; +using System.Data.Common; + +namespace Medallion.Threading.Tests.SqlServer; + +public interface ITestingSqlServerDb { } + +public sealed class TestingSqlServerDb : TestingPrimaryClientDb, ITestingSqlServerDb +{ + internal static readonly string DefaultConnectionString = SqlServerCredentials.ConnectionString; + + private readonly Microsoft.Data.SqlClient.SqlConnectionStringBuilder _connectionStringBuilder = + new(DefaultConnectionString); + + public override DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; + + // https://stackoverflow.com/questions/5808332/sql-server-maximum-character-length-of-object-names/41502228 + public override int MaxApplicationNameLength => 128; + + public override TransactionSupport TransactionSupport => TransactionSupport.TransactionScoped; + + public override int CountActiveSessions(string applicationName) + { + Invariant.Require(applicationName.Length <= this.MaxApplicationNameLength); + + using var connection = new Microsoft.Data.SqlClient.SqlConnection(DefaultConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = $@"SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE program_name = @applicationName"; + command.Parameters.AddWithValue("applicationName", applicationName); + return (int)command.ExecuteScalar(); + } + + public override IsolationLevel GetIsolationLevel(DbConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT CASE transaction_isolation_level + WHEN 0 THEN 'Unspecified' + WHEN 1 THEN 'ReadUncommitted' + WHEN 2 THEN 'ReadCommitted' + WHEN 3 THEN 'RepeatableRead' + WHEN 4 THEN 'Serializable' + WHEN 5 THEN 'Snapshot' + ELSE 'Unknown' END AS isolationLevel + FROM sys.dm_exec_sessions + WHERE session_id = @@SPID"; + return (IsolationLevel)Enum.Parse(typeof(IsolationLevel), (string)command.ExecuteScalar()!); + } + + public override DbConnection CreateConnection() => new Microsoft.Data.SqlClient.SqlConnection(this.ConnectionStringBuilder.ConnectionString); + + public override async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince) + { + using var connection = new Microsoft.Data.SqlClient.SqlConnection(DefaultConnectionString); + await connection.OpenAsync(); + + var findIdleSessionsCommand = connection.CreateCommand(); + findIdleSessionsCommand.CommandText = @" + SELECT session_id FROM sys.dm_exec_sessions + WHERE session_id != @@SPID + AND program_name = @applicationName + AND ( + @idleSince IS NULL + OR ( + (last_request_start_time IS NULL OR last_request_start_time <= @idleSince) + AND (last_request_end_time IS NULL OR last_request_end_time <= @idleSince) + ) + )"; + findIdleSessionsCommand.Parameters.AddWithValue("applicationName", applicationName); + findIdleSessionsCommand.Parameters.AddWithValue("idleSince", idleSince?.DateTime ?? DBNull.Value.As()).SqlDbType = SqlDbType.DateTime; + + var spidsToKill = new List(); + using (var idleSessionsReader = await findIdleSessionsCommand.ExecuteReaderAsync()) + { + while (await idleSessionsReader.ReadAsync()) + { + spidsToKill.Add(idleSessionsReader.GetInt16(0)); + } + } + + foreach (var spid in spidsToKill) + { + using var killCommand = connection.CreateCommand(); + killCommand.CommandText = "KILL " + spid; + try { await killCommand.ExecuteNonQueryAsync(); } + catch (Exception ex) { Console.WriteLine($"Failed to kill {spid}: {ex}"); } + } + } +} + +public sealed class TestingSystemDataSqlServerDb : TestingDb, ITestingSqlServerDb +{ + private readonly System.Data.SqlClient.SqlConnectionStringBuilder _connectionStringBuilder = + new(TestingSqlServerDb.DefaultConnectionString); + + public override DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; + + public override int MaxApplicationNameLength => new TestingSqlServerDb().MaxApplicationNameLength; + + public override TransactionSupport TransactionSupport => TransactionSupport.TransactionScoped; + + public override int CountActiveSessions(string applicationName) => new TestingSqlServerDb().CountActiveSessions(applicationName); + + public override IsolationLevel GetIsolationLevel(DbConnection connection) => new TestingSqlServerDb().GetIsolationLevel(connection); + + public override DbConnection CreateConnection() => new System.Data.SqlClient.SqlConnection(this.ConnectionStringBuilder.ConnectionString); +} diff --git a/src/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerProviders.cs b/src/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerProviders.cs new file mode 100644 index 00000000..ddb4e411 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerProviders.cs @@ -0,0 +1,54 @@ +using Medallion.Threading.SqlServer; +using Medallion.Threading.Tests.Data; + +namespace Medallion.Threading.Tests.SqlServer; + +public sealed class TestingSqlDistributedLockProvider : TestingLockProvider + where TStrategy : TestingDbSynchronizationStrategy, new() + where TDb : TestingDb, ITestingSqlServerDb, new() +{ + public override IDistributedLock CreateLockWithExactName(string name) => + this.Strategy.GetConnectionOptions() + .Create( + (connectionString, options) => new SqlDistributedLock(name, connectionString, ToSqlOptions(options), exactName: true), + connection => new SqlDistributedLock(name, connection, exactName: true), + transaction => new SqlDistributedLock(name, transaction, exactName: true)); + + public override string GetSafeName(string name) => SqlDistributedLock.GetSafeName(name); + + internal static Action ToSqlOptions((bool useMultiplexing, bool useTransaction, TimeSpan? keepaliveCadence) options) => o => + { + o.UseMultiplexing(options.useMultiplexing).UseTransaction(options.useTransaction); + if (options.keepaliveCadence is { } keepaliveCadence) { o.KeepaliveCadence(keepaliveCadence); } + }; +} + +public sealed class TestingSqlDistributedReaderWriterLockProvider : TestingUpgradeableReaderWriterLockProvider + where TStrategy : TestingDbSynchronizationStrategy, new() + where TDb : TestingDb, ITestingSqlServerDb, new() +{ + public override IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderWriterLockWithExactName(string name) => + this.Strategy.GetConnectionOptions() + .Create( + (connectionString, options) => + new SqlDistributedReaderWriterLock(name, connectionString, TestingSqlDistributedLockProvider.ToSqlOptions(options), exactName: true), + connection => new SqlDistributedReaderWriterLock(name, connection, exactName: true), + transaction => new SqlDistributedReaderWriterLock(name, transaction, exactName: true)); + + public override string GetSafeName(string name) => SqlDistributedReaderWriterLock.GetSafeName(name); +} + +public sealed class TestingSqlDistributedSemaphoreProvider : TestingSemaphoreProvider + where TStrategy : TestingDbSynchronizationStrategy, new() + where TDb : TestingDb, ITestingSqlServerDb, new() +{ + public override IDistributedSemaphore CreateSemaphoreWithExactName(string name, int maxCount) => + this.Strategy.GetConnectionOptions() + .Create( + (connectionString, options) => + new SqlDistributedSemaphore(name, maxCount, connectionString, TestingSqlDistributedLockProvider.ToSqlOptions(options)), + connection => new SqlDistributedSemaphore(name, maxCount, connection), + transaction => new SqlDistributedSemaphore(name, maxCount, transaction)); + + public override string GetSafeName(string name) => name ?? throw new ArgumentNullException(nameof(name)); +} diff --git a/src/DistributedLock.Tests/Infrastructure/SupportsContinuousIntegrationAttribute.cs b/src/DistributedLock.Tests/Infrastructure/SupportsContinuousIntegrationAttribute.cs new file mode 100644 index 00000000..609826e5 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/SupportsContinuousIntegrationAttribute.cs @@ -0,0 +1,10 @@ +namespace Medallion.Threading.Tests; + +/// +/// Indicates that a test infrastructure component supports being run in a remote continuous integration environment +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +internal class SupportsContinuousIntegrationAttribute : Attribute +{ + public bool WindowsOnly { get; set; } +} diff --git a/src/DistributedLock.Tests/Infrastructure/TestHelper.cs b/src/DistributedLock.Tests/Infrastructure/TestHelper.cs new file mode 100644 index 00000000..c3e71259 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/TestHelper.cs @@ -0,0 +1,71 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Redis; +using NUnit.Framework; + +namespace Medallion.Threading.Tests; + +internal static class TestHelper +{ + /// + /// Returns a name that is unique to the current test and target framework but stable otherwise. + /// + public static string UniqueName => $"{TestContext.CurrentContext.Test.FullName}_{TargetFramework.Current}"; + + public static bool IsCi { get; } = Environment.GetEnvironmentVariable("CI")?.ToLowerInvariant() == "true"; + + public static T ShouldEqual(this T @this, T that, string? message = null) + { + Assert.That(@this, Is.EqualTo(that), message: message); + return @this; + } + + public static bool IsHeld(this IDistributedLock @lock) + { + using var handle = @lock.TryAcquire(); + return handle == null; + } + + public static async Task TryWaitAsync(this Task task, TimeoutValue timeout) + { + if (!task.IsCompleted) + { + using var timeoutTask = new TimeoutTask(timeout, CancellationToken.None); + if (await Task.WhenAny(task, timeoutTask.Task) != task) + { + return false; + } + } + + await task; + return true; + } + + /// + /// Waits up to for to return true. Checks every + /// . + /// + public static async Task WaitForAsync(Func> predicate, TimeoutValue timeout, TimeoutValue? checkCadence = null) + { + using var cancellationSource = new CancellationTokenSource(); + var waitForPredicateTask = WaitForPredicateAsync(); + + if (!await waitForPredicateTask.TryWaitAsync(timeout)) + { + cancellationSource.Cancel(); + await waitForPredicateTask; + return false; + } + + return true; + + async Task WaitForPredicateAsync() + { + var cancellationToken = cancellationSource.Token; + while (!cancellationToken.IsCancellationRequested) + { + if (await predicate()) { return; } + await Task.Delay(checkCadence?.TimeSpan ?? TimeSpan.FromMilliseconds(5)); + } + } + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/TestingLockProvider.cs b/src/DistributedLock.Tests/Infrastructure/TestingLockProvider.cs new file mode 100644 index 00000000..407e04c2 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/TestingLockProvider.cs @@ -0,0 +1,23 @@ +namespace Medallion.Threading.Tests; + +public abstract class TestingLockProvider : ITestingNameProvider, IDisposable + where TStrategy : TestingSynchronizationStrategy, new() +{ + private readonly Lazy _lazyStrategy = new(() => new TStrategy()); + + public virtual TStrategy Strategy => this._lazyStrategy.Value; + + public virtual bool SupportsCrossProcessAbandonment => true; + + public abstract IDistributedLock CreateLockWithExactName(string name); + public abstract string GetSafeName(string name); + + public virtual string GetCrossProcessLockType() => this.CreateLock(string.Empty).GetType().Name; + public virtual void Dispose() => this.Strategy.Dispose(); + + /// + /// Returns a lock whose name is based on + /// + public IDistributedLock CreateLock(string baseName) => + this.CreateLockWithExactName(this.GetUniqueSafeName(baseName)); +} diff --git a/src/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockAsMutexProvider.cs b/src/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockAsMutexProvider.cs new file mode 100644 index 00000000..87a542b0 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockAsMutexProvider.cs @@ -0,0 +1,87 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Tests; + +public interface ITestingReaderWriterLockAsMutexProvider +{ + public bool DisableUpgradeLock { get; set; } +} + +public sealed class TestingReaderWriterLockAsMutexProvider : TestingLockProvider, ITestingReaderWriterLockAsMutexProvider + where TReaderWriterLockProvider : TestingReaderWriterLockProvider, new() + where TStrategy : TestingSynchronizationStrategy, new() +{ + private readonly TReaderWriterLockProvider _readerWriterLockProvider = new(); + + public override TStrategy Strategy => this._readerWriterLockProvider.Strategy; + + public bool DisableUpgradeLock { get; set; } + + public override IDistributedLock CreateLockWithExactName(string name) => + new ReaderWriterLockAsMutex(this._readerWriterLockProvider.CreateReaderWriterLockWithExactName(name), this); + + public override string GetSafeName(string name) => this._readerWriterLockProvider.GetSafeName(name); + + public override string GetCrossProcessLockType() => + this._readerWriterLockProvider.GetCrossProcessLockType(ReaderWriterLockType.Write); + + public override void Dispose() + { + this._readerWriterLockProvider.Dispose(); + base.Dispose(); + } + + private bool GetShouldUseUpgradeLock() + { + return !this.DisableUpgradeLock + // intended to be random yet consistent across runs (assuming no changes) + && (Environment.StackTrace.Length % 2) == 1; + } + + private class ReaderWriterLockAsMutex : IDistributedLock + { + private readonly TestingReaderWriterLockAsMutexProvider _provider; + private readonly IDistributedReaderWriterLock _readerWriterLock; + + public ReaderWriterLockAsMutex(IDistributedReaderWriterLock readerWriterLock, TestingReaderWriterLockAsMutexProvider provider) + { + this._readerWriterLock = readerWriterLock; + this._provider = provider; + } + + string IDistributedLock.Name => this._readerWriterLock.Name; + + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.ShouldUseUpgrade(out var upgradeable) + ? upgradeable.AcquireUpgradeableReadLock(timeout, cancellationToken) + : this._readerWriterLock.AcquireWriteLock(timeout, cancellationToken); + + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.ShouldUseUpgrade(out var upgradeable) + ? upgradeable.AcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask) + : this._readerWriterLock.AcquireWriteLockAsync(timeout, cancellationToken); + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.ShouldUseUpgrade(out var upgradeable) + ? upgradeable.TryAcquireUpgradeableReadLock(timeout, cancellationToken) + : this._readerWriterLock.TryAcquireWriteLock(timeout, cancellationToken); + + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.ShouldUseUpgrade(out var upgradeable) + ? upgradeable.TryAcquireUpgradeableReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask) + : this._readerWriterLock.TryAcquireWriteLockAsync(timeout, cancellationToken); + + private bool ShouldUseUpgrade(out IDistributedUpgradeableReaderWriterLock upgradeable) + { + if (this._readerWriterLock is IDistributedUpgradeableReaderWriterLock upgradeableLock + && this._provider.GetShouldUseUpgradeLock()) + { + upgradeable = upgradeableLock; + return true; + } + + upgradeable = null!; + return false; + } + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockProvider.cs b/src/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockProvider.cs new file mode 100644 index 00000000..8e137c74 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/TestingReaderWriterLockProvider.cs @@ -0,0 +1,43 @@ +namespace Medallion.Threading.Tests; + +public abstract class TestingReaderWriterLockProvider : ITestingNameProvider, IDisposable + where TStrategy : TestingSynchronizationStrategy, new() +{ + public TStrategy Strategy { get; } = new TStrategy(); + + public abstract IDistributedReaderWriterLock CreateReaderWriterLockWithExactName(string name); + public abstract string GetSafeName(string name); + + public virtual string GetCrossProcessLockType(ReaderWriterLockType type) => + type + this.CreateReaderWriterLock(string.Empty).GetType().Name; + + /// + /// Returns a lock whose name is based on + /// + public IDistributedReaderWriterLock CreateReaderWriterLock(string baseName) => + this.CreateReaderWriterLockWithExactName(this.GetUniqueSafeName(baseName)); + + public void Dispose() => this.Strategy.Dispose(); +} + +public abstract class TestingUpgradeableReaderWriterLockProvider : TestingReaderWriterLockProvider + where TStrategy : TestingSynchronizationStrategy, new() +{ + public abstract IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderWriterLockWithExactName(string name); + + public sealed override IDistributedReaderWriterLock CreateReaderWriterLockWithExactName(string name) => + this.CreateUpgradeableReaderWriterLockWithExactName(name); + + /// + /// Returns a lock whose name is based on + /// + public IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderWriterLock(string baseName) => + this.CreateUpgradeableReaderWriterLockWithExactName(this.GetUniqueSafeName(baseName)); +} + +public enum ReaderWriterLockType +{ + Read, + Write, + Upgrade, +} diff --git a/src/DistributedLock.Tests/Infrastructure/TestingSemaphoreAsMutexProvider.cs b/src/DistributedLock.Tests/Infrastructure/TestingSemaphoreAsMutexProvider.cs new file mode 100644 index 00000000..0bb03996 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/TestingSemaphoreAsMutexProvider.cs @@ -0,0 +1,97 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Tests; + +public abstract class TestingSemaphoreAsMutexProvider : TestingLockProvider + where TSemaphoreProvider : TestingSemaphoreProvider, new() + where TStrategy : TestingSynchronizationStrategy, new() +{ + private readonly TSemaphoreProvider _semaphoreProvider = new(); + private readonly DisposableCollection _disposables = new(); + private readonly HashSet _mostlyDrainedSemaphoreNames = new(); + private readonly int _maxCount; + + protected TestingSemaphoreAsMutexProvider(int maxCount) + { + this._maxCount = maxCount; + this._disposables.Add(this._semaphoreProvider); + } + + public override TStrategy Strategy => this._semaphoreProvider.Strategy; + + public override string GetCrossProcessLockType() => $"{this._semaphoreProvider.GetCrossProcessLockType()}{this._maxCount}AsMutex"; + + public override IDistributedLock CreateLockWithExactName(string name) + { + var semaphore = this._semaphoreProvider.CreateSemaphoreWithExactName(name, this._maxCount); + lock (this._mostlyDrainedSemaphoreNames) + { + if (!this._mostlyDrainedSemaphoreNames.Contains(name)) + { + this._mostlyDrainedSemaphoreNames.Add(name); + + // If our max count is > 1, we'll acquire the extra tickets such that any resolved semaphore + // functions as a mutex + for (var i = 0; i < this._maxCount - 1; ++i) + { + this._disposables.Add( + semaphore.TryAcquire() + ?? throw new InvalidOperationException($"Failed to take ticket {i} of {semaphore.GetType()} {name}") + ); + } + } + } + + return new SemaphoreAsMutex(semaphore); + } + + public override string GetSafeName(string name) => this._semaphoreProvider.GetSafeName(name); + + public override void Dispose() + { + this._disposables.Dispose(); + base.Dispose(); + } + + private class SemaphoreAsMutex : IDistributedLock + { + private readonly IDistributedSemaphore _semaphore; + + public SemaphoreAsMutex(IDistributedSemaphore semaphore) + { + this._semaphore = semaphore; + } + + string IDistributedLock.Name => this._semaphore.Name; + + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this._semaphore.Acquire(timeout, cancellationToken); + + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this._semaphore.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this._semaphore.TryAcquire(timeout, cancellationToken); + + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this._semaphore.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + } +} + +[SupportsContinuousIntegration] +public sealed class TestingSemaphore1AsMutexProvider : TestingSemaphoreAsMutexProvider + where TSemaphoreProvider : TestingSemaphoreProvider, new() + where TStrategy : TestingSynchronizationStrategy, new() +{ + public TestingSemaphore1AsMutexProvider() : base(maxCount: 1) { } +} + +[SupportsContinuousIntegration] +public sealed class TestingSemaphore5AsMutexProvider : TestingSemaphoreAsMutexProvider + where TSemaphoreProvider : TestingSemaphoreProvider, new() + where TStrategy : TestingSynchronizationStrategy, new() +{ + public TestingSemaphore5AsMutexProvider() : base(maxCount: 5) { } + + public override bool SupportsCrossProcessAbandonment => this.Strategy.SupportsCrossProcessSingleSemaphoreTicketAbandonment; +} diff --git a/src/DistributedLock.Tests/Infrastructure/TestingSemaphoreProvider.cs b/src/DistributedLock.Tests/Infrastructure/TestingSemaphoreProvider.cs new file mode 100644 index 00000000..febd92e1 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/TestingSemaphoreProvider.cs @@ -0,0 +1,21 @@ +namespace Medallion.Threading.Tests; + +public abstract class TestingSemaphoreProvider : ITestingNameProvider, IDisposable + where TStrategy : TestingSynchronizationStrategy, new() +{ + public TStrategy Strategy { get; } = new TStrategy(); + + public abstract IDistributedSemaphore CreateSemaphoreWithExactName(string name, int maxCount); + public abstract string GetSafeName(string name); + + public virtual string GetCrossProcessLockType() => + this.CreateSemaphore(string.Empty, maxCount: 1).GetType().Name; + + /// + /// Returns a semaphore whose name is based on + /// + public IDistributedSemaphore CreateSemaphore(string baseName, int maxCount) => + this.CreateSemaphoreWithExactName(this.GetUniqueSafeName(baseName), maxCount); + + public void Dispose() => this.Strategy.Dispose(); +} diff --git a/src/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs b/src/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs new file mode 100644 index 00000000..8a58ea08 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs @@ -0,0 +1,20 @@ +namespace Medallion.Threading.Tests; + +/// +/// Manages the underlying approach to synchronization. Having this class allows us to parameterize tests by +/// synchronization strategy (e. g. only connection string-based strategies) +/// +public abstract class TestingSynchronizationStrategy : IDisposable +{ + /// + /// Whether or not abandoning a ticket held in another process will cause that ticket + /// to be released if tickets are still held elsewhere + /// + public virtual bool SupportsCrossProcessSingleSemaphoreTicketAbandonment => true; + + public virtual void PrepareForHandleAbandonment() { } + public virtual void PerformAdditionalCleanupForHandleAbandonment() { } + public virtual IDisposable? PrepareForHandleLost() => null; + public virtual void PrepareForHighContention(ref int maxConcurrentAcquires) { } + public virtual void Dispose() { } +} diff --git a/src/DistributedLock.Tests/Infrastructure/WaitHandles/TestingWaitHandleProviders.cs b/src/DistributedLock.Tests/Infrastructure/WaitHandles/TestingWaitHandleProviders.cs new file mode 100644 index 00000000..6f511114 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/WaitHandles/TestingWaitHandleProviders.cs @@ -0,0 +1,19 @@ +using Medallion.Threading.WaitHandles; + +namespace Medallion.Threading.Tests.WaitHandles; + +[SupportsContinuousIntegration(WindowsOnly = true)] +public sealed class TestingEventWaitHandleDistributedLockProvider : TestingLockProvider +{ + public override IDistributedLock CreateLockWithExactName(string name) => new EventWaitHandleDistributedLock(name, exactName: true); + + public override string GetSafeName(string name) => DistributedWaitHandleHelpers.GetSafeName(name); +} + +[SupportsContinuousIntegration(WindowsOnly = true)] +public sealed class TestingWaitHandleDistributedSemaphoreProvider : TestingSemaphoreProvider +{ + public override IDistributedSemaphore CreateSemaphoreWithExactName(string name, int maxCount) => new WaitHandleDistributedSemaphore(name, maxCount, exactName: true); + + public override string GetSafeName(string name) => DistributedWaitHandleHelpers.GetSafeName(name); +} diff --git a/src/DistributedLock.Tests/Infrastructure/WaitHandles/TestingWaitHandleSynchronizationStrategy.cs b/src/DistributedLock.Tests/Infrastructure/WaitHandles/TestingWaitHandleSynchronizationStrategy.cs new file mode 100644 index 00000000..17e6687a --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/WaitHandles/TestingWaitHandleSynchronizationStrategy.cs @@ -0,0 +1,9 @@ +namespace Medallion.Threading.Tests.WaitHandles; + +[SupportsContinuousIntegration] +public sealed class TestingWaitHandleSynchronizationStrategy : TestingSynchronizationStrategy +{ + // since the wait handle won't be collected by the system until all instances of it are closed, + // we won't see abandoned handles release their tickets until the semaphore is fully abandoned + public override bool SupportsCrossProcessSingleSemaphoreTicketAbandonment => false; +} diff --git a/src/DistributedLock.Tests/Infrastructure/ZooKeeper/TestingZooKeeperProviders.cs b/src/DistributedLock.Tests/Infrastructure/ZooKeeper/TestingZooKeeperProviders.cs new file mode 100644 index 00000000..c1470a2c --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/ZooKeeper/TestingZooKeeperProviders.cs @@ -0,0 +1,39 @@ +using Medallion.Threading.ZooKeeper; + +namespace Medallion.Threading.Tests.ZooKeeper; + +public sealed class TestingZooKeeperDistributedLockProvider : TestingLockProvider +{ + public override IDistributedLock CreateLockWithExactName(string name) + { + var @lock = new ZooKeeperDistributedLock(new ZooKeeperPath(name), ZooKeeperPorts.DefaultConnectionString, this.Strategy.AssumeNodeExists, this.Strategy.Options); + this.Strategy.TrackPath(name); + return @lock; + } + + public override string GetSafeName(string name) => new ZooKeeperDistributedLock(name, ZooKeeperPorts.DefaultConnectionString).Path.ToString(); +} + +public sealed class TestingZooKeeperDistributedReaderWriterLockProvider : TestingReaderWriterLockProvider +{ + public override IDistributedReaderWriterLock CreateReaderWriterLockWithExactName(string name) + { + var @lock = new ZooKeeperDistributedReaderWriterLock(new ZooKeeperPath(name), ZooKeeperPorts.DefaultConnectionString, this.Strategy.AssumeNodeExists, this.Strategy.Options); + this.Strategy.TrackPath(name); + return @lock; + } + + public override string GetSafeName(string name) => new ZooKeeperDistributedReaderWriterLock(name, ZooKeeperPorts.DefaultConnectionString).Path.ToString(); +} + +public sealed class TestingZooKeeperDistributedSemaphoreProvider : TestingSemaphoreProvider +{ + public override IDistributedSemaphore CreateSemaphoreWithExactName(string name, int maxCount) + { + var semaphore = new ZooKeeperDistributedSemaphore(new ZooKeeperPath(name), maxCount, ZooKeeperPorts.DefaultConnectionString, this.Strategy.AssumeNodeExists, this.Strategy.Options); + this.Strategy.TrackPath(name); + return semaphore; + } + + public override string GetSafeName(string name) => new ZooKeeperDistributedSemaphore(name, maxCount: 1, ZooKeeperPorts.DefaultConnectionString).Path.ToString(); +} diff --git a/src/DistributedLock.Tests/Infrastructure/ZooKeeper/TestingZooKeeperSynchronizationStrategy.cs b/src/DistributedLock.Tests/Infrastructure/ZooKeeper/TestingZooKeeperSynchronizationStrategy.cs new file mode 100644 index 00000000..db4572bb --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/ZooKeeper/TestingZooKeeperSynchronizationStrategy.cs @@ -0,0 +1,55 @@ +using Medallion.Threading.ZooKeeper; + +namespace Medallion.Threading.Tests.ZooKeeper; + +public sealed class TestingZooKeeperSynchronizationStrategy : TestingSynchronizationStrategy +{ + private List? _trackedPaths; + + public bool AssumeNodeExists { get; set; } + + public Action? Options { get; set; } + + public void TrackPath(string path) => this._trackedPaths?.Add(path); + + public override IDisposable? PrepareForHandleLost() + { + if (this._trackedPaths != null) { throw new InvalidOperationException("Already in handle lost mode"); } + + this._trackedPaths = new List(); + return new HandleLostScope(this); + } + + private class HandleLostScope : IDisposable + { + private readonly TestingZooKeeperSynchronizationStrategy _strategy; + + public HandleLostScope(TestingZooKeeperSynchronizationStrategy strategy) + { + this._strategy = strategy; + } + + public void Dispose() + { + var trackedPaths = Interlocked.Exchange(ref this._strategy._trackedPaths, null); + if (trackedPaths == null) { return; } // already disposed + + using var connection = ZooKeeperConnection.DefaultPool.ConnectAsync( + new ZooKeeperConnectionInfo(ZooKeeperPorts.DefaultConnectionString, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), new EquatableReadOnlyList(Array.Empty())), + CancellationToken.None + ) + .Result; + + // delete the newest child of the node (other children may be extra semaphore ticket holders) + foreach (var trackedPath in trackedPaths) + { + var childrenResult = connection.ZooKeeper.getChildrenAsync(trackedPath).Result; + var toDelete = childrenResult.Children.Select(ch => $"{trackedPath.TrimEnd(ZooKeeperPath.Separator)}{ZooKeeperPath.Separator}{ch}") + .Select(p => (Path: p, CreationTime: connection.ZooKeeper.existsAsync(p).Result?.getCtime() ?? -1)) + .OrderByDescending(t => t.CreationTime) + .First(); + connection.ZooKeeper.deleteAsync(toDelete.Path).Wait(); + } + } + } +} diff --git a/src/DistributedLock.Tests/Infrastructure/ZooKeeper/ZooKeeperSetUpFixture.cs b/src/DistributedLock.Tests/Infrastructure/ZooKeeper/ZooKeeperSetUpFixture.cs new file mode 100644 index 00000000..13fb8312 --- /dev/null +++ b/src/DistributedLock.Tests/Infrastructure/ZooKeeper/ZooKeeperSetUpFixture.cs @@ -0,0 +1,92 @@ +using Medallion.Shell; +using NUnit.Framework; +using System.Net.Sockets; +using System.Text; + +namespace Medallion.Threading.Tests.ZooKeeper; + +[SetUpFixture] +public class ZooKeeperSetUpFixture +{ + private Command? _zooKeeperCommand; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + if (Environment.GetEnvironmentVariable("APPVEYOR") != null) + { + Console.WriteLine("Running on AppVeyor; will not attempt to launch ZooKeeper"); + } + else if (IsZooKeeperRunning()) + { + Console.WriteLine("ZooKeeper already running"); + } + else + { + var zooKeeperHome = File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "credentials", "zookeeper.txt")).Trim(); + if (!Directory.Exists(zooKeeperHome)) { throw new DirectoryNotFoundException(zooKeeperHome); } + // On Windows, zkServer.cmd calls zkEnv.cmd, which checks for the environment variable JAVA_HOME. + if (Environment.GetEnvironmentVariable("JAVA_HOME") is not { } javaHome || !Directory.Exists(javaHome)) + { + throw new DirectoryNotFoundException("To run ZooKeeper, you should install Java Development Kit (JDK) and set the environment variable 'JAVA_HOME' based on that."); + } + var zooKeeperPath = Path.Combine(zooKeeperHome, "bin", "zkServer.cmd"); + + var command = Command.Run(zooKeeperPath, options: o => o.StartInfo(i => i.RedirectStandardInput = false)) + .RedirectTo(Console.Out) + .RedirectStandardErrorTo(Console.Error); + Console.WriteLine($"Launched ZooKeeper ({zooKeeperPath}; PID={command.ProcessId})"); + this._zooKeeperCommand = command; + } + } + + private static bool IsZooKeeperRunning() + { + // based loosely on https://stackoverflow.com/questions/29106546/how-to-check-if-zookeeper-is-running-or-up-from-command-prompt + + try + { + using var tcpClient = new TcpClient("localhost", ZooKeeperPorts.DefaultPort); + using var tcpStream = tcpClient.GetStream(); + var message = Encoding.UTF8.GetBytes("ruok"); + tcpStream.Write(message, 0, message.Length); + + tcpStream.ReadTimeout = 500; // ms + var readBuffer = new byte[1024]; + var bytesRead = tcpStream.Read(readBuffer, 0, readBuffer.Length); + var response = Encoding.UTF8.GetString(readBuffer, 0, bytesRead).Trim(); + if (response == "imok" || response == "ruok is not executed because it is not in the whitelist.") + { + return true; + } + + throw new InvalidOperationException($"Received unexpected response '{response}' from application running at port {ZooKeeperPorts.DefaultPort}"); + } + catch (SocketException) { } + catch (IOException) { } + + return false; + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + if (this._zooKeeperCommand != null) + { + if (this._zooKeeperCommand.Task.IsCompleted) + { + throw new InvalidOperationException($"ZooKeeper exited unexpectedly with error code {this._zooKeeperCommand.Result.ExitCode}"); + } + + if (this._zooKeeperCommand.TrySignalAsync(CommandSignal.ControlC).Result + && this._zooKeeperCommand.Task.Wait(TimeSpan.FromSeconds(2))) + { + return; // graceful shutdown + } + + Console.WriteLine("ZooKeeper graceful shutdown failed: killing"); + this._zooKeeperCommand.Kill(); + this._zooKeeperCommand.Wait(); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/ApiTest.cs b/src/DistributedLock.Tests/Tests/ApiTest.cs new file mode 100644 index 00000000..464aeb58 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ApiTest.cs @@ -0,0 +1,180 @@ +using System.Reflection; +using NUnit.Framework; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Tests; + +[Category("CI")] +public class ApiTest +{ + private static object[] DistributedLockAssemblies => typeof(ApiTest).Assembly + .GetReferencedAssemblies() + .Where(a => a.Name!.StartsWith("DistributedLock.")) + .ToArray(); + + [TestCaseSource(nameof(DistributedLockAssemblies))] + public void TestPublicNamespaces(AssemblyName assemblyName) + { + var expectedNamespace = assemblyName.Name!.Replace("DistributedLock", "Medallion.Threading") + .Replace(".Core", string.Empty); + foreach (var type in GetPublicTypes(Assembly.Load(assemblyName))) + { + type.Namespace.ShouldEqual(expectedNamespace, $"{type} in {assemblyName}"); + } + } + + [TestCaseSource(nameof(DistributedLockAssemblies))] + public void TestPublicApisAreSealed(AssemblyName assemblyName) + { + foreach (var type in GetPublicTypes(Assembly.Load(assemblyName)).Where(t => t.IsClass)) + { + if (!type.IsAbstract) + { + Assert.That(type.IsSealed, Is.True, $"{type} should be sealed"); + } + else + { + Assert.That( + type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(c => c.IsPublic || c.Attributes.HasFlag(MethodAttributes.Family)), + Is.Empty); + } + } + } + + [TestCaseSource(nameof(DistributedLockAssemblies))] + public void TestProviderApisAreAvailable(AssemblyName assemblyName) + { + var providerTypesToProvidedTypes = typeof(IDistributedLockProvider).Assembly + .GetTypes() + .Where(t => t.IsInterface && t.IsPublic && t.Name.EndsWith("Provider")) + .ToDictionary(t => t, t => t.GetMethods().Single(m => m.Name.StartsWith("Create")).ReturnType); + + var types = GetPublicTypes(Assembly.Load(assemblyName)); + + foreach (var kvp in providerTypesToProvidedTypes) + { + var providers = types.Where(t => !t.IsInterface && kvp.Key.IsAssignableFrom(t)).ToArray(); + var provided = types.Where(t => !t.IsInterface && kvp.Value.IsAssignableFrom(t)).ToArray(); + Assert.That( + providers.Select(t => t.GetMethods().Single(m => m.Name.StartsWith("Create") && kvp.Value.IsAssignableFrom(m.ReturnType)).ReturnType), + Is.EquivalentTo(provided)); + + foreach (var provider in providers) + { + Assert.That(provider.Name, Does.EndWith("DistributedSynchronizationProvider")); + } + } + } + + [TestCaseSource(nameof(DistributedLockAssemblies))] + public void TestPublicHandleTypesDoNotHaveVisibleConstructors(AssemblyName assemblyName) + { + var publicHandleTypes = GetPublicTypes(Assembly.Load(assemblyName)) + .Where(t => typeof(IDistributedSynchronizationHandle).IsAssignableFrom(t)) + .ToArray(); + Assert.That(publicHandleTypes, Is.Not.Empty); // sanity check + + foreach (var publicHandleType in publicHandleTypes) + { + Assert.That( + publicHandleType.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(c => c.IsPublic || c.IsFamily || c.IsFamilyOrAssembly), + Is.Empty); + } + } + + [TestCaseSource(nameof(DistributedLockAssemblies))] + public void TestLibrariesUseConfigureAwaitFalse(AssemblyName assemblyName) + { + var projectDirectory = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(CurrentFilePath())!, "..", "..", assemblyName.Name!)); + var codeFiles = Directory.GetFiles(projectDirectory, "*.cs", SearchOption.AllDirectories); + Assert.That(codeFiles, Is.Not.Empty); + + var awaitRegex = new Regex(@"//.*|(?\bawait\s)"); + var configureAwaitRegex = new Regex(@"\.ConfigureAwait\(false\)|\.TryAwait\(\)"); + foreach (var codeFile in codeFiles) + { + var code = File.ReadAllText(codeFile); + var awaitCount = awaitRegex.Matches(code).Cast().Count(m => m.Groups["await"].Success); + var configureAwaitCount = configureAwaitRegex.Matches(code).Count; + Assert.That(configureAwaitCount >= awaitCount, Is.True, $"ConfigureAwait(false) count ({configureAwaitCount}) < await count ({awaitCount}) in {codeFile}"); + } + } + + [Test] + public void TestLibraryFilesDoNotWriteToConsole() + { + var projectDirectory = Path.GetDirectoryName(Path.GetDirectoryName(CurrentFilePath())); + var solutionDirectory = Path.GetDirectoryName(projectDirectory!); + var libraryCsFiles = Directory.GetFiles(solutionDirectory!, "*.cs", SearchOption.AllDirectories) + .Where(f => new[] { ".Tests", "CodeGen", "DistributedLockTaker" }.All(s => f.IndexOf(s, StringComparison.OrdinalIgnoreCase) < 0)); + Assert.That( + libraryCsFiles.Where(f => File.ReadAllText(f).Contains("Console.")) + .Select(Path.GetFileName), + Is.Empty); + } + + [TestCaseSource(nameof(DistributedLockAssemblies))] + public void TestInternalNamedMembersAreInternal(AssemblyName assemblyName) + { + var assembly = Assembly.Load(assemblyName); + var publicTypes = GetPublicTypes(assembly); + foreach (var publicType in publicTypes) + { + Assert.That( + publicType.GetMembers(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance) + .Where(m => m.Name.Contains("Internal")), + Is.Empty); + } + } + + [TestCaseSource(nameof(DistributedLockAssemblies))] + public void TestLegacyGetSafeNameApisAreRemoved(AssemblyName assemblyName) + { + var assembly = Assembly.Load(assemblyName); + var publicTypes = GetPublicTypes(assembly); + foreach (var publicType in publicTypes) + { + Assert.That(publicType.GetMethod("GetSafeName", BindingFlags.Public | BindingFlags.Static), Is.Null); + Assert.That(publicType.GetProperty("MaxNameLength", BindingFlags.Public | BindingFlags.Static), Is.Null); + } + } + + [TestCaseSource(nameof(DistributedLockAssemblies))] + public void TestAssemblyVersioning(AssemblyName assemblyName) + { + var assembly = Assembly.Load(assemblyName); + Assert.That(assembly.GetName().GetPublicKeyToken(), Is.Not.Null, "Should be signed"); + + // scheme based on https://codingforsmarties.wordpress.com/2016/01/21/how-to-version-assemblies-destined-for-nuget/ + var version = assembly.GetName().Version; + version!.Minor.ShouldEqual(0); + version.Revision.ShouldEqual(0); + version.Build.ShouldEqual(0); + var informationalVersion = assembly.GetCustomAttribute(); + Assert.That(informationalVersion?.InformationalVersion, Does.StartWith($"{version.Major}.")); + } + + [Test] + public void RememberToRemoveObsoleteMembers() + { + Assert.That( + typeof(Helpers).Assembly.GetName().Version < new Version(1, 1) + || typeof(Helpers).GetMethod("TryGetValue") is null); + } + + private static IEnumerable GetPublicTypes(Assembly assembly) => assembly.GetTypes() + .Where(IsInPublicApi) +#if DEBUG + .Where(t => !(t.Namespace!.Contains(".Internal") && assembly.GetName().Name == "DistributedLock.Core")) +#endif + ; + + private static string CurrentFilePath([CallerFilePath] string filePath = "") => filePath; + + private static bool IsInPublicApi(Type type) => type.IsPublic + || ((type.IsNestedPublic || type.IsNestedFamily || type.IsNestedFamORAssem) && IsInPublicApi(type.DeclaringType!)); +} diff --git a/src/DistributedLock.Tests/Tests/Azure/AzureBehaviorTest.cs b/src/DistributedLock.Tests/Tests/Azure/AzureBehaviorTest.cs new file mode 100644 index 00000000..398ac9d6 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Azure/AzureBehaviorTest.cs @@ -0,0 +1,52 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Specialized; +using Medallion.Threading.Azure; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Azure; + +/// +/// Demonstrates various behaviors of Azure blob storage that our implementation relies upon or takes into account +/// +public class AzureBehaviorTest +{ + [Test] + public void TestAttemptToLeaseBlobIfDoesNotExist() + { + var blobClient = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, Guid.NewGuid().ToString()); + + Assert.Throws(() => blobClient.GetBlobLeaseClient().Acquire(TimeSpan.FromMinutes(1)))! + .ErrorCode.ShouldEqual(AzureErrors.BlobNotFound); + + blobClient = new BlobClient(AzureCredentials.ConnectionString, "dne-container", Guid.NewGuid().ToString()); + Assert.Throws(() => blobClient.GetBlobLeaseClient().Acquire(TimeSpan.FromMinutes(1)))! + .ErrorCode.ShouldEqual("ContainerNotFound"); + } + + [Test] + public void TestSlashEquivalence() + { + var name = Guid.NewGuid() + "/a"; + + var blobClient1 = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name); + Assert.That((bool)blobClient1.Exists(), Is.False); + blobClient1.Upload(Stream.Null); + Assert.That((bool)blobClient1.Exists(), Is.True); + + var blobClient2 = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name.Replace('/', '\\')); + Assert.That((bool)blobClient2.Exists(), Is.True); + } + + [Test] + public void TestThrowsIfLeaseAlreadyHeld() + { + var name = nameof(TestThrowsIfLeaseAlreadyHeld) + Guid.NewGuid(); + var client1 = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name); + client1.Upload(Stream.Null); + var client2 = new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name); + Assert.DoesNotThrow(() => client1.GetBlobLeaseClient().Acquire(TimeSpan.FromSeconds(15))); + Assert.Throws(() => client2.GetBlobLeaseClient().Acquire(TimeSpan.FromSeconds(15)))! + .ErrorCode.ShouldEqual("LeaseAlreadyPresent"); + } +} diff --git a/src/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseDistributedLockTest.cs new file mode 100644 index 00000000..409e475d --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseDistributedLockTest.cs @@ -0,0 +1,224 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Blobs.Specialized; +using Medallion.Threading.Azure; +using Medallion.Threading.Internal; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Azure; + +public class AzureBlobLeaseDistributedLockTest +{ + [Test] + public void TestSafeNaming() + { + var names = new[] + { + string.Empty, + new string('a', 2000), + "a/", + "a\\", + string.Join("/", Enumerable.Repeat("a", 254)), + string.Join(@"\", Enumerable.Repeat("b", 254)), + new string('/', 254), + new string('\\', 254) + }; + + var containerClient = new BlobContainerClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName); + foreach (var name in names) + { + var @lock = new AzureBlobLeaseDistributedLock(containerClient, name); + Assert.DoesNotThrow(() => @lock.Acquire()); + } + } + + [Test] + public async Task TestLockOnDifferentBlobClientTypes( + [Values] BlobClientType type, + [Values] bool isAsync) + { + if (isAsync) + { + await TestAsync(); + } + else + { + SyncViaAsync.Run(_ => TestAsync(), default(object)); + } + + async ValueTask TestAsync() + { + using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); + var name = provider.GetUniqueSafeName(); + var client = CreateClient(type, name); + + if (client.GetType() == typeof(BlobBaseClient)) + { + // work around inability to do CreateIfNotExists for the base client + await new BlobClientWrapper(new BlobClient(AzureCredentials.ConnectionString, client.BlobContainerName, client.Name)) + .CreateIfNotExistsAsync(new Dictionary(), CancellationToken.None); + } + + var @lock = new AzureBlobLeaseDistributedLock(client); + await using var handle = await @lock.TryAcquireAsync(); + Assert.That(handle, Is.Not.Null); + await using var nestedHandle = await @lock.TryAcquireAsync(); + Assert.That(nestedHandle, Is.Null); + } + } + + [Test] + public async Task TestWrapperCreateIfNotExists([Values] BlobClientType type) + { + using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); + var name = provider.GetUniqueSafeName(); + var client = CreateClient(type, name); + var wrapper = new BlobClientWrapper(client); + + var metadata = new Dictionary { ["abc"] = "123" }; + + if (client.GetType() == typeof(BlobBaseClient)) + { + Assert.That( + Assert.ThrowsAsync(async () => await wrapper.CreateIfNotExistsAsync(metadata, CancellationToken.None))!.ToString(), + Does.Contain("Either ensure that the blob exists or use a non-base client type") + ); + return; + } + + await wrapper.CreateIfNotExistsAsync(metadata, CancellationToken.None); + Assert.That((await client.ExistsAsync()).Value, Is.True); + Assert.That((await client.GetPropertiesAsync()).Value.Metadata, Is.EqualTo(metadata).AsCollection); + + Assert.DoesNotThrowAsync(async () => await wrapper.CreateIfNotExistsAsync(metadata, CancellationToken.None)); + Assert.That((await client.ExistsAsync()).Value, Is.True); + } + + [Test] + public void TestCanUseLeaseIdForBlobOperations() + { + using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); + var name = provider.GetUniqueSafeName(); + var client = new PageBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name); + const int BlobSize = 512; + client.Create(size: BlobSize); + var @lock = new AzureBlobLeaseDistributedLock(client); + + using var handle = @lock.Acquire(); + Assert.Throws(() => client.UploadPages(new MemoryStream(new byte[BlobSize]), offset: 0))! + .ErrorCode.ShouldEqual(AzureErrors.LeaseIdMissing); + + Assert.DoesNotThrow( + () => client.UploadPages(new MemoryStream(new byte[BlobSize]), offset: 0, options: new() + { + Conditions = new PageBlobRequestConditions { LeaseId = handle.LeaseId } + }) + ); + + handle.Dispose(); + Assert.Throws(() => handle.LeaseId.ToString()); + } + + [Test] + public void TestThrowsIfContainerDoesNotExist() + { + using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); + provider.Strategy.ContainerName = "does-not-exist"; + var @lock = provider.CreateLock(nameof(TestThrowsIfContainerDoesNotExist)); + + Assert.Throws(() => @lock.TryAcquire()?.Dispose())! + .ErrorCode.ShouldEqual("ContainerNotFound"); + } + + [Test] + public void TestCanAcquireIfContainerLeased() + { + using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); + provider.Strategy.ContainerName = "leased-container" + TargetFramework.Current.Replace('.', '-'); + + var containerClient = new BlobContainerClient(AzureCredentials.ConnectionString, provider.Strategy.ContainerName); + var containerLeaseClient = new BlobLeaseClient(containerClient); + try + { + containerClient.CreateIfNotExists(); + containerLeaseClient.Acquire(TimeSpan.FromSeconds(60)); + + var @lock = provider.CreateLock(nameof(TestCanAcquireIfContainerLeased)); + + using var handle = @lock.TryAcquire(); + Assert.That(handle, Is.Not.Null); + } + finally + { + try { containerLeaseClient.Release(); } + finally { containerClient.DeleteIfExists(); } + } + } + + [Test] + public async Task TestSuccessfulRenewal() + { + using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); + provider.Strategy.Options = o => o.RenewalCadence(TimeSpan.FromSeconds(.05)); + var @lock = provider.CreateLock(nameof(TestSuccessfulRenewal)); + + using var handle = @lock.Acquire(); + await Task.Delay(TimeSpan.FromSeconds(.2)); // long enough for renewal to run + Assert.DoesNotThrow(handle.Dispose); // observes the result of the renewal task + } + + [Test] + [NonParallelizable, Retry(tryCount: 3)] // timing-sensitive + public void TestTriggersHandleLostIfLeaseExpiresNaturally() + { + using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); + provider.Strategy.Options = o => o.RenewalCadence(Timeout.InfiniteTimeSpan).Duration(TimeSpan.FromSeconds(15)); + var @lock = provider.CreateLock(nameof(TestTriggersHandleLostIfLeaseExpiresNaturally)); + + using var handle = @lock.Acquire(); + using var @event = new ManualResetEventSlim(initialState: false); + using var registration = handle.HandleLostToken.Register(@event.Set); + using var faultingRegistration = handle.HandleLostToken.Register(() => throw new TimeZoneNotFoundException()); + + Assert.That(@event.Wait(TimeSpan.FromSeconds(15.1)), Is.True); + + Assert.Throws(handle.Dispose)! + .ErrorCode.ShouldEqual("LeaseNotPresentWithBlobOperation"); + } + + [Test] + public void TestExitsDespiteLongSleepTime() + { + using var provider = new TestingAzureBlobLeaseDistributedLockProvider(); + provider.Strategy.Options = o => o.BusyWaitSleepTime(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(1)); + var @lock = provider.CreateLock(nameof(TestExitsDespiteLongSleepTime)); + + using var handle1 = @lock.Acquire(); + + var handle2Task = @lock.TryAcquireAsync(TimeSpan.FromSeconds(2)).AsTask(); + Assert.That(handle2Task.Wait(TimeSpan.FromSeconds(.05)), Is.False); + + handle1.Dispose(); + Assert.That(handle2Task.Wait(TimeSpan.FromSeconds(5)), Is.True); + } + + private static BlobBaseClient CreateClient([Values] BlobClientType type, string name) => type switch + { + BlobClientType.Basic => new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), + BlobClientType.Block => new BlockBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), + BlobClientType.Page => new PageBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), + BlobClientType.Append => new AppendBlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), + BlobClientType.Base => new BlobBaseClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), + _ => throw new ArgumentException("Bad type", nameof(type)), + }; + + public enum BlobClientType + { + Basic, + Block, + Page, + Append, + Base + } +} diff --git a/src/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..9a13cbd7 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseDistributedSynchronizationProviderTest.cs @@ -0,0 +1,28 @@ +using Azure.Storage.Blobs; +using Medallion.Threading.Azure; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Azure; + +public class AzureBlobLeaseDistributedSynchronizationProviderTest +{ + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => new AzureBlobLeaseDistributedSynchronizationProvider(null!)); + } + + [Test] + public async Task BasicTest() + { + const string LockName = TargetFramework.Current + "ProviderBasicTest"; + + var container = new BlobContainerClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName); + var provider = new AzureBlobLeaseDistributedSynchronizationProvider(container); + await using (await provider.AcquireLockAsync(LockName)) + { + await using var handle = await provider.TryAcquireLockAsync(LockName); + Assert.That(handle, Is.Null); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseOptionsBuilderTest.cs b/src/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseOptionsBuilderTest.cs new file mode 100644 index 00000000..e062ad3f --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Azure/AzureBlobLeaseOptionsBuilderTest.cs @@ -0,0 +1,60 @@ +using Medallion.Threading.Azure; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Azure; + +public class AzureBlobLeaseOptionsBuilderTest +{ + [Test] + public void TestValidatesDuration() + { + var builder = new AzureBlobLeaseOptionsBuilder(); + + Assert.DoesNotThrow(() => builder.Duration(TimeSpan.FromSeconds(15))); + Assert.DoesNotThrow(() => builder.Duration(TimeSpan.FromSeconds(60))); + Assert.DoesNotThrow(() => builder.Duration(Timeout.InfiniteTimeSpan)); + Assert.Throws(() => builder.Duration(TimeSpan.FromSeconds(14))); + Assert.Throws(() => builder.Duration(TimeSpan.FromSeconds(61))); + } + + [Test] + public void TestValidatesRenewalCadence() + { + Assert.Throws(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(TimeSpan.FromSeconds(-1)))); + Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(TimeSpan.Zero))); + + Assert.Throws(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(TimeSpan.FromSeconds(30)))); + Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(TimeSpan.FromSeconds(3)))); + Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.RenewalCadence(Timeout.InfiniteTimeSpan))); + + Assert.Throws(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.Duration(TimeSpan.FromSeconds(60)).RenewalCadence(TimeSpan.FromSeconds(60.1)))); + Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.Duration(TimeSpan.FromSeconds(60)).RenewalCadence(TimeSpan.FromSeconds(59.9)))); + + Assert.DoesNotThrow(() => AzureBlobLeaseOptionsBuilder.GetOptions(o => o.Duration(Timeout.InfiniteTimeSpan).RenewalCadence(Timeout.InfiniteTimeSpan))); + } + + [Test] + public void TestValidatesBusyWaitSleepTime() + { + var builder = new AzureBlobLeaseOptionsBuilder(); + + Assert.Throws(() => builder.BusyWaitSleepTime(Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(1))); + Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(-1), TimeSpan.FromSeconds(1))); + Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.MaxValue, TimeSpan.FromSeconds(1))); + Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan)); + Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(-1))); + Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(1), TimeSpan.MaxValue)); + + Assert.Throws(() => builder.BusyWaitSleepTime(TimeSpan.FromSeconds(1.1), TimeSpan.FromSeconds(1))); + + Assert.DoesNotThrow(() => builder.BusyWaitSleepTime(TimeSpan.Zero, TimeSpan.Zero)); + Assert.DoesNotThrow(() => builder.BusyWaitSleepTime(TimeSpan.FromMinutes(3), TimeSpan.FromMinutes(4))); + } + + [Test] + public void TestDisablesAutoRenewalIfDurationIsInfinite() + { + var options = AzureBlobLeaseOptionsBuilder.GetOptions(b => b.Duration(Timeout.InfiniteTimeSpan)); + Assert.That(options.renewalCadence.IsInfinite, Is.True); + } +} diff --git a/DistributedLock.Tests/Tests/CombinatorialTests.cs b/src/DistributedLock.Tests/Tests/CombinatorialTests.cs similarity index 60% rename from DistributedLock.Tests/Tests/CombinatorialTests.cs rename to src/DistributedLock.Tests/Tests/CombinatorialTests.cs index e7ad960e..fdff8c42 100644 --- a/DistributedLock.Tests/Tests/CombinatorialTests.cs +++ b/src/DistributedLock.Tests/Tests/CombinatorialTests.cs @@ -1,21 +1,110 @@ +// AUTO-GENERATED using Medallion.Threading.Tests.Data; using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Medallion.Threading.Tests.Azure { public class Core_AzureBlobLease_AzureBlobLeaseSynchronizationStrategyTest : DistributedLockCoreTestCases { } } +namespace Medallion.Threading.Tests.FileSystem +{ + [Category("CI")] public class Core_Composite_FileSynchronizationStrategyTest : DistributedLockCoreTestCases { } + [Category("CI")] public class Core_File_FileSynchronizationStrategyTest : DistributedLockCoreTestCases { } +} + +namespace Medallion.Threading.Tests.MongoDB +{ + public class Core_Mongo_MongoDbSynchronizationStrategyTest : DistributedLockCoreTestCases { } +} + +namespace Medallion.Threading.Tests.MySql +{ + public class ConnectionStringStrategy_MySql_ConnectionMultiplexingSynchronizationStrategy_MariaDbDb_MariaDbDb_ConnectionMultiplexingSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ConnectionStringStrategyTestCases, TestingMariaDbDb>, TestingConnectionMultiplexingSynchronizationStrategy, TestingMariaDbDb> { } + public class ConnectionStringStrategy_MySql_ConnectionMultiplexingSynchronizationStrategy_MySqlDb_MySqlDb_ConnectionMultiplexingSynchronizationStrategy_MySqlDb_MySqlDbTest : ConnectionStringStrategyTestCases, TestingMySqlDb>, TestingConnectionMultiplexingSynchronizationStrategy, TestingMySqlDb> { } + public class ConnectionStringStrategy_MySql_OwnedConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_OwnedConnectionSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ConnectionStringStrategyTestCases, TestingMariaDbDb>, TestingOwnedConnectionSynchronizationStrategy, TestingMariaDbDb> { } + public class ConnectionStringStrategy_MySql_OwnedConnectionSynchronizationStrategy_MySqlDb_MySqlDb_OwnedConnectionSynchronizationStrategy_MySqlDb_MySqlDbTest : ConnectionStringStrategyTestCases, TestingMySqlDb>, TestingOwnedConnectionSynchronizationStrategy, TestingMySqlDb> { } + public class ConnectionStringStrategy_MySql_OwnedTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_OwnedTransactionSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ConnectionStringStrategyTestCases, TestingMariaDbDb>, TestingOwnedTransactionSynchronizationStrategy, TestingMariaDbDb> { } + public class ConnectionStringStrategy_MySql_OwnedTransactionSynchronizationStrategy_MySqlDb_MySqlDb_OwnedTransactionSynchronizationStrategy_MySqlDb_MySqlDbTest : ConnectionStringStrategyTestCases, TestingMySqlDb>, TestingOwnedTransactionSynchronizationStrategy, TestingMySqlDb> { } + public class Core_MySql_ConnectionMultiplexingSynchronizationStrategy_MariaDbDb_MariaDbDb_ConnectionMultiplexingSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingConnectionMultiplexingSynchronizationStrategy> { } + public class Core_MySql_ConnectionMultiplexingSynchronizationStrategy_MySqlDb_MySqlDb_ConnectionMultiplexingSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingConnectionMultiplexingSynchronizationStrategy> { } + public class Core_MySql_ExternalConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_ExternalConnectionSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingExternalConnectionSynchronizationStrategy> { } + public class Core_MySql_ExternalConnectionSynchronizationStrategy_MySqlDb_MySqlDb_ExternalConnectionSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingExternalConnectionSynchronizationStrategy> { } + public class Core_MySql_ExternalTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_ExternalTransactionSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingExternalTransactionSynchronizationStrategy> { } + public class Core_MySql_ExternalTransactionSynchronizationStrategy_MySqlDb_MySqlDb_ExternalTransactionSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingExternalTransactionSynchronizationStrategy> { } + public class Core_MySql_OwnedConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_OwnedConnectionSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingOwnedConnectionSynchronizationStrategy> { } + public class Core_MySql_OwnedConnectionSynchronizationStrategy_MySqlDb_MySqlDb_OwnedConnectionSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingOwnedConnectionSynchronizationStrategy> { } + public class Core_MySql_OwnedTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_OwnedTransactionSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingOwnedTransactionSynchronizationStrategy> { } + public class Core_MySql_OwnedTransactionSynchronizationStrategy_MySqlDb_MySqlDb_OwnedTransactionSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingOwnedTransactionSynchronizationStrategy> { } + public class ExternalConnectionOrTransactionStrategy_MySql_ExternalConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_ExternalConnectionSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingMariaDbDb>, TestingExternalConnectionSynchronizationStrategy, TestingMariaDbDb> { } + public class ExternalConnectionOrTransactionStrategy_MySql_ExternalConnectionSynchronizationStrategy_MySqlDb_MySqlDb_ExternalConnectionSynchronizationStrategy_MySqlDb_MySqlDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingMySqlDb>, TestingExternalConnectionSynchronizationStrategy, TestingMySqlDb> { } + public class ExternalConnectionOrTransactionStrategy_MySql_ExternalTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_ExternalTransactionSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingMariaDbDb>, TestingExternalTransactionSynchronizationStrategy, TestingMariaDbDb> { } + public class ExternalConnectionOrTransactionStrategy_MySql_ExternalTransactionSynchronizationStrategy_MySqlDb_MySqlDb_ExternalTransactionSynchronizationStrategy_MySqlDb_MySqlDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingMySqlDb>, TestingExternalTransactionSynchronizationStrategy, TestingMySqlDb> { } + public class ExternalConnectionStrategy_MySql_ExternalConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : ExternalConnectionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class ExternalConnectionStrategy_MySql_ExternalConnectionSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : ExternalConnectionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } + public class ExternalTransactionStrategy_MySql_ExternalTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : ExternalTransactionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class ExternalTransactionStrategy_MySql_ExternalTransactionSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : ExternalTransactionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } + public class MultiplexingConnectionStrategy_MySql_ConnectionMultiplexingSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : MultiplexingConnectionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class MultiplexingConnectionStrategy_MySql_ConnectionMultiplexingSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : MultiplexingConnectionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } + public class OwnedConnectionStrategy_MySql_OwnedConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : OwnedConnectionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class OwnedConnectionStrategy_MySql_OwnedConnectionSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : OwnedConnectionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } + public class OwnedTransactionStrategy_MySql_OwnedTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : OwnedTransactionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class OwnedTransactionStrategy_MySql_OwnedTransactionSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : OwnedTransactionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } +} + +namespace Medallion.Threading.Tests.Oracle +{ + public class ConnectionStringStrategy_Oracle_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDb_OracleDbTest : ConnectionStringStrategyTestCases>, TestingConnectionMultiplexingSynchronizationStrategy, TestingOracleDb> { } + public class ConnectionStringStrategy_Oracle_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDb_OracleDbTest : ConnectionStringStrategyTestCases>, TestingOwnedConnectionSynchronizationStrategy, TestingOracleDb> { } + public class ConnectionStringStrategy_Oracle_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDb_OracleDbTest : ConnectionStringStrategyTestCases>, TestingOwnedTransactionSynchronizationStrategy, TestingOracleDb> { } + public class ConnectionStringStrategy_ReaderWriterAsMutex_OracleReaderWriter_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDb_OracleDbTest : ConnectionStringStrategyTestCases>, TestingConnectionMultiplexingSynchronizationStrategy>, TestingConnectionMultiplexingSynchronizationStrategy, TestingOracleDb> { } + public class ConnectionStringStrategy_ReaderWriterAsMutex_OracleReaderWriter_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDb_OracleDbTest : ConnectionStringStrategyTestCases>, TestingOwnedConnectionSynchronizationStrategy>, TestingOwnedConnectionSynchronizationStrategy, TestingOracleDb> { } + public class ConnectionStringStrategy_ReaderWriterAsMutex_OracleReaderWriter_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDb_OracleDbTest : ConnectionStringStrategyTestCases>, TestingOwnedTransactionSynchronizationStrategy>, TestingOwnedTransactionSynchronizationStrategy, TestingOracleDb> { } + public class Core_Oracle_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingConnectionMultiplexingSynchronizationStrategy> { } + public class Core_Oracle_ExternalConnectionSynchronizationStrategy_OracleDb_ExternalConnectionSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingExternalConnectionSynchronizationStrategy> { } + public class Core_Oracle_ExternalTransactionSynchronizationStrategy_OracleDb_ExternalTransactionSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingExternalTransactionSynchronizationStrategy> { } + public class Core_Oracle_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingOwnedConnectionSynchronizationStrategy> { } + public class Core_Oracle_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingOwnedTransactionSynchronizationStrategy> { } + public class Core_ReaderWriterAsMutex_OracleReaderWriter_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingConnectionMultiplexingSynchronizationStrategy>, TestingConnectionMultiplexingSynchronizationStrategy> { } + public class Core_ReaderWriterAsMutex_OracleReaderWriter_ExternalConnectionSynchronizationStrategy_OracleDb_ExternalConnectionSynchronizationStrategy_OracleDb_ExternalConnectionSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingExternalConnectionSynchronizationStrategy>, TestingExternalConnectionSynchronizationStrategy> { } + public class Core_ReaderWriterAsMutex_OracleReaderWriter_ExternalTransactionSynchronizationStrategy_OracleDb_ExternalTransactionSynchronizationStrategy_OracleDb_ExternalTransactionSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingExternalTransactionSynchronizationStrategy>, TestingExternalTransactionSynchronizationStrategy> { } + public class Core_ReaderWriterAsMutex_OracleReaderWriter_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingOwnedConnectionSynchronizationStrategy>, TestingOwnedConnectionSynchronizationStrategy> { } + public class Core_ReaderWriterAsMutex_OracleReaderWriter_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDbTest : DistributedLockCoreTestCases>, TestingOwnedTransactionSynchronizationStrategy>, TestingOwnedTransactionSynchronizationStrategy> { } + public class ExternalConnectionOrTransactionStrategy_Oracle_ExternalConnectionSynchronizationStrategy_OracleDb_ExternalConnectionSynchronizationStrategy_OracleDb_OracleDbTest : ExternalConnectionOrTransactionStrategyTestCases>, TestingExternalConnectionSynchronizationStrategy, TestingOracleDb> { } + public class ExternalConnectionOrTransactionStrategy_Oracle_ExternalTransactionSynchronizationStrategy_OracleDb_ExternalTransactionSynchronizationStrategy_OracleDb_OracleDbTest : ExternalConnectionOrTransactionStrategyTestCases>, TestingExternalTransactionSynchronizationStrategy, TestingOracleDb> { } + public class ExternalConnectionOrTransactionStrategy_ReaderWriterAsMutex_OracleReaderWriter_ExternalConnectionSynchronizationStrategy_OracleDb_ExternalConnectionSynchronizationStrategy_OracleDb_ExternalConnectionSynchronizationStrategy_OracleDb_OracleDbTest : ExternalConnectionOrTransactionStrategyTestCases>, TestingExternalConnectionSynchronizationStrategy>, TestingExternalConnectionSynchronizationStrategy, TestingOracleDb> { } + public class ExternalConnectionOrTransactionStrategy_ReaderWriterAsMutex_OracleReaderWriter_ExternalTransactionSynchronizationStrategy_OracleDb_ExternalTransactionSynchronizationStrategy_OracleDb_ExternalTransactionSynchronizationStrategy_OracleDb_OracleDbTest : ExternalConnectionOrTransactionStrategyTestCases>, TestingExternalTransactionSynchronizationStrategy>, TestingExternalTransactionSynchronizationStrategy, TestingOracleDb> { } + public class ExternalConnectionStrategy_Oracle_ExternalConnectionSynchronizationStrategy_OracleDb_OracleDbTest : ExternalConnectionStrategyTestCases>, TestingOracleDb> { } + public class ExternalConnectionStrategy_ReaderWriterAsMutex_OracleReaderWriter_ExternalConnectionSynchronizationStrategy_OracleDb_ExternalConnectionSynchronizationStrategy_OracleDb_OracleDbTest : ExternalConnectionStrategyTestCases>, TestingExternalConnectionSynchronizationStrategy>, TestingOracleDb> { } + public class ExternalTransactionStrategy_Oracle_ExternalTransactionSynchronizationStrategy_OracleDb_OracleDbTest : ExternalTransactionStrategyTestCases>, TestingOracleDb> { } + public class ExternalTransactionStrategy_ReaderWriterAsMutex_OracleReaderWriter_ExternalTransactionSynchronizationStrategy_OracleDb_ExternalTransactionSynchronizationStrategy_OracleDb_OracleDbTest : ExternalTransactionStrategyTestCases>, TestingExternalTransactionSynchronizationStrategy>, TestingOracleDb> { } + public class MultiplexingConnectionStrategy_Oracle_ConnectionMultiplexingSynchronizationStrategy_OracleDb_OracleDbTest : MultiplexingConnectionStrategyTestCases>, TestingOracleDb> { } + public class MultiplexingConnectionStrategy_ReaderWriterAsMutex_OracleReaderWriter_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDb_OracleDbTest : MultiplexingConnectionStrategyTestCases>, TestingConnectionMultiplexingSynchronizationStrategy>, TestingOracleDb> { } + public class OwnedConnectionStrategy_Oracle_OwnedConnectionSynchronizationStrategy_OracleDb_OracleDbTest : OwnedConnectionStrategyTestCases>, TestingOracleDb> { } + public class OwnedConnectionStrategy_ReaderWriterAsMutex_OracleReaderWriter_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDb_OracleDbTest : OwnedConnectionStrategyTestCases>, TestingOwnedConnectionSynchronizationStrategy>, TestingOracleDb> { } + public class OwnedTransactionStrategy_Oracle_OwnedTransactionSynchronizationStrategy_OracleDb_OracleDbTest : OwnedTransactionStrategyTestCases>, TestingOracleDb> { } + public class OwnedTransactionStrategy_ReaderWriterAsMutex_OracleReaderWriter_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDb_OracleDbTest : OwnedTransactionStrategyTestCases>, TestingOwnedTransactionSynchronizationStrategy>, TestingOracleDb> { } + public class ReaderWriterCore_OracleReaderWriter_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDbTest : DistributedReaderWriterLockCoreTestCases>, TestingConnectionMultiplexingSynchronizationStrategy> { } + public class ReaderWriterCore_OracleReaderWriter_ExternalConnectionSynchronizationStrategy_OracleDb_ExternalConnectionSynchronizationStrategy_OracleDbTest : DistributedReaderWriterLockCoreTestCases>, TestingExternalConnectionSynchronizationStrategy> { } + public class ReaderWriterCore_OracleReaderWriter_ExternalTransactionSynchronizationStrategy_OracleDb_ExternalTransactionSynchronizationStrategy_OracleDbTest : DistributedReaderWriterLockCoreTestCases>, TestingExternalTransactionSynchronizationStrategy> { } + public class ReaderWriterCore_OracleReaderWriter_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDbTest : DistributedReaderWriterLockCoreTestCases>, TestingOwnedConnectionSynchronizationStrategy> { } + public class ReaderWriterCore_OracleReaderWriter_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDbTest : DistributedReaderWriterLockCoreTestCases>, TestingOwnedTransactionSynchronizationStrategy> { } + public class UpgradeableReaderWriterConnectionStringStrategy_OracleReaderWriter_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDb_OracleDbTest : UpgradeableReaderWriterLockConnectionStringStrategyTestCases>, TestingConnectionMultiplexingSynchronizationStrategy, TestingOracleDb> { } + public class UpgradeableReaderWriterConnectionStringStrategy_OracleReaderWriter_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDb_OracleDbTest : UpgradeableReaderWriterLockConnectionStringStrategyTestCases>, TestingOwnedConnectionSynchronizationStrategy, TestingOracleDb> { } + public class UpgradeableReaderWriterConnectionStringStrategy_OracleReaderWriter_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDb_OracleDbTest : UpgradeableReaderWriterLockConnectionStringStrategyTestCases>, TestingOwnedTransactionSynchronizationStrategy, TestingOracleDb> { } + public class UpgradeableReaderWriterCore_OracleReaderWriter_ConnectionMultiplexingSynchronizationStrategy_OracleDb_ConnectionMultiplexingSynchronizationStrategy_OracleDbTest : DistributedUpgradeableReaderWriterLockCoreTestCases>, TestingConnectionMultiplexingSynchronizationStrategy> { } + public class UpgradeableReaderWriterCore_OracleReaderWriter_ExternalConnectionSynchronizationStrategy_OracleDb_ExternalConnectionSynchronizationStrategy_OracleDbTest : DistributedUpgradeableReaderWriterLockCoreTestCases>, TestingExternalConnectionSynchronizationStrategy> { } + public class UpgradeableReaderWriterCore_OracleReaderWriter_ExternalTransactionSynchronizationStrategy_OracleDb_ExternalTransactionSynchronizationStrategy_OracleDbTest : DistributedUpgradeableReaderWriterLockCoreTestCases>, TestingExternalTransactionSynchronizationStrategy> { } + public class UpgradeableReaderWriterCore_OracleReaderWriter_OwnedConnectionSynchronizationStrategy_OracleDb_OwnedConnectionSynchronizationStrategy_OracleDbTest : DistributedUpgradeableReaderWriterLockCoreTestCases>, TestingOwnedConnectionSynchronizationStrategy> { } + public class UpgradeableReaderWriterCore_OracleReaderWriter_OwnedTransactionSynchronizationStrategy_OracleDb_OwnedTransactionSynchronizationStrategy_OracleDbTest : DistributedUpgradeableReaderWriterLockCoreTestCases>, TestingOwnedTransactionSynchronizationStrategy> { } +} + namespace Medallion.Threading.Tests.Postgres { public class ConnectionStringStrategy_Postgres_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_PostgresDbTest : ConnectionStringStrategyTestCases>, TestingConnectionMultiplexingSynchronizationStrategy, TestingPostgresDb> { } public class ConnectionStringStrategy_Postgres_OwnedConnectionSynchronizationStrategy_PostgresDb_OwnedConnectionSynchronizationStrategy_PostgresDb_PostgresDbTest : ConnectionStringStrategyTestCases>, TestingOwnedConnectionSynchronizationStrategy, TestingPostgresDb> { } public class ConnectionStringStrategy_Postgres_OwnedTransactionSynchronizationStrategy_PostgresDb_OwnedTransactionSynchronizationStrategy_PostgresDb_PostgresDbTest : ConnectionStringStrategyTestCases>, TestingOwnedTransactionSynchronizationStrategy, TestingPostgresDb> { } + public class ConnectionStringStrategy_ReaderWriterAsMutex_CompositeReaderWriter_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_PostgresDbTest : ConnectionStringStrategyTestCases>, TestingConnectionMultiplexingSynchronizationStrategy, TestingPostgresDb> { } public class ConnectionStringStrategy_ReaderWriterAsMutex_PostgresReaderWriter_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_PostgresDbTest : ConnectionStringStrategyTestCases>, TestingConnectionMultiplexingSynchronizationStrategy>, TestingConnectionMultiplexingSynchronizationStrategy, TestingPostgresDb> { } public class ConnectionStringStrategy_ReaderWriterAsMutex_PostgresReaderWriter_OwnedConnectionSynchronizationStrategy_PostgresDb_OwnedConnectionSynchronizationStrategy_PostgresDb_OwnedConnectionSynchronizationStrategy_PostgresDb_PostgresDbTest : ConnectionStringStrategyTestCases>, TestingOwnedConnectionSynchronizationStrategy>, TestingOwnedConnectionSynchronizationStrategy, TestingPostgresDb> { } public class ConnectionStringStrategy_ReaderWriterAsMutex_PostgresReaderWriter_OwnedTransactionSynchronizationStrategy_PostgresDb_OwnedTransactionSynchronizationStrategy_PostgresDb_OwnedTransactionSynchronizationStrategy_PostgresDb_PostgresDbTest : ConnectionStringStrategyTestCases>, TestingOwnedTransactionSynchronizationStrategy>, TestingOwnedTransactionSynchronizationStrategy, TestingPostgresDb> { } @@ -24,6 +113,7 @@ public class Core_Postgres_ExternalConnectionSynchronizationStrategy_PostgresDb_ public class Core_Postgres_ExternalTransactionSynchronizationStrategy_PostgresDb_ExternalTransactionSynchronizationStrategy_PostgresDbTest : DistributedLockCoreTestCases>, TestingExternalTransactionSynchronizationStrategy> { } public class Core_Postgres_OwnedConnectionSynchronizationStrategy_PostgresDb_OwnedConnectionSynchronizationStrategy_PostgresDbTest : DistributedLockCoreTestCases>, TestingOwnedConnectionSynchronizationStrategy> { } public class Core_Postgres_OwnedTransactionSynchronizationStrategy_PostgresDb_OwnedTransactionSynchronizationStrategy_PostgresDbTest : DistributedLockCoreTestCases>, TestingOwnedTransactionSynchronizationStrategy> { } + public class Core_ReaderWriterAsMutex_CompositeReaderWriter_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDbTest : DistributedLockCoreTestCases>, TestingConnectionMultiplexingSynchronizationStrategy> { } public class Core_ReaderWriterAsMutex_PostgresReaderWriter_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDbTest : DistributedLockCoreTestCases>, TestingConnectionMultiplexingSynchronizationStrategy>, TestingConnectionMultiplexingSynchronizationStrategy> { } public class Core_ReaderWriterAsMutex_PostgresReaderWriter_ExternalConnectionSynchronizationStrategy_PostgresDb_ExternalConnectionSynchronizationStrategy_PostgresDb_ExternalConnectionSynchronizationStrategy_PostgresDbTest : DistributedLockCoreTestCases>, TestingExternalConnectionSynchronizationStrategy>, TestingExternalConnectionSynchronizationStrategy> { } public class Core_ReaderWriterAsMutex_PostgresReaderWriter_ExternalTransactionSynchronizationStrategy_PostgresDb_ExternalTransactionSynchronizationStrategy_PostgresDb_ExternalTransactionSynchronizationStrategy_PostgresDbTest : DistributedLockCoreTestCases>, TestingExternalTransactionSynchronizationStrategy>, TestingExternalTransactionSynchronizationStrategy> { } @@ -38,11 +128,13 @@ public class ExternalConnectionStrategy_ReaderWriterAsMutex_PostgresReaderWriter public class ExternalTransactionStrategy_Postgres_ExternalTransactionSynchronizationStrategy_PostgresDb_PostgresDbTest : ExternalTransactionStrategyTestCases>, TestingPostgresDb> { } public class ExternalTransactionStrategy_ReaderWriterAsMutex_PostgresReaderWriter_ExternalTransactionSynchronizationStrategy_PostgresDb_ExternalTransactionSynchronizationStrategy_PostgresDb_PostgresDbTest : ExternalTransactionStrategyTestCases>, TestingExternalTransactionSynchronizationStrategy>, TestingPostgresDb> { } public class MultiplexingConnectionStrategy_Postgres_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_PostgresDbTest : MultiplexingConnectionStrategyTestCases>, TestingPostgresDb> { } + public class MultiplexingConnectionStrategy_ReaderWriterAsMutex_CompositeReaderWriter_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_PostgresDbTest : MultiplexingConnectionStrategyTestCases>, TestingPostgresDb> { } public class MultiplexingConnectionStrategy_ReaderWriterAsMutex_PostgresReaderWriter_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_PostgresDbTest : MultiplexingConnectionStrategyTestCases>, TestingConnectionMultiplexingSynchronizationStrategy>, TestingPostgresDb> { } public class OwnedConnectionStrategy_Postgres_OwnedConnectionSynchronizationStrategy_PostgresDb_PostgresDbTest : OwnedConnectionStrategyTestCases>, TestingPostgresDb> { } public class OwnedConnectionStrategy_ReaderWriterAsMutex_PostgresReaderWriter_OwnedConnectionSynchronizationStrategy_PostgresDb_OwnedConnectionSynchronizationStrategy_PostgresDb_PostgresDbTest : OwnedConnectionStrategyTestCases>, TestingOwnedConnectionSynchronizationStrategy>, TestingPostgresDb> { } public class OwnedTransactionStrategy_Postgres_OwnedTransactionSynchronizationStrategy_PostgresDb_PostgresDbTest : OwnedTransactionStrategyTestCases>, TestingPostgresDb> { } public class OwnedTransactionStrategy_ReaderWriterAsMutex_PostgresReaderWriter_OwnedTransactionSynchronizationStrategy_PostgresDb_OwnedTransactionSynchronizationStrategy_PostgresDb_PostgresDbTest : OwnedTransactionStrategyTestCases>, TestingOwnedTransactionSynchronizationStrategy>, TestingPostgresDb> { } + public class ReaderWriterCore_CompositeReaderWriter_ConnectionMultiplexingSynchronizationStrategy_PostgresDbTest : DistributedReaderWriterLockCoreTestCases> { } public class ReaderWriterCore_PostgresReaderWriter_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDbTest : DistributedReaderWriterLockCoreTestCases>, TestingConnectionMultiplexingSynchronizationStrategy> { } public class ReaderWriterCore_PostgresReaderWriter_ExternalConnectionSynchronizationStrategy_PostgresDb_ExternalConnectionSynchronizationStrategy_PostgresDbTest : DistributedReaderWriterLockCoreTestCases>, TestingExternalConnectionSynchronizationStrategy> { } public class ReaderWriterCore_PostgresReaderWriter_ExternalTransactionSynchronizationStrategy_PostgresDb_ExternalTransactionSynchronizationStrategy_PostgresDbTest : DistributedReaderWriterLockCoreTestCases>, TestingExternalTransactionSynchronizationStrategy> { } @@ -50,6 +142,37 @@ public class ReaderWriterCore_PostgresReaderWriter_OwnedConnectionSynchronizatio public class ReaderWriterCore_PostgresReaderWriter_OwnedTransactionSynchronizationStrategy_PostgresDb_OwnedTransactionSynchronizationStrategy_PostgresDbTest : DistributedReaderWriterLockCoreTestCases>, TestingOwnedTransactionSynchronizationStrategy> { } } +namespace Medallion.Threading.Tests.Redis +{ + public class Core_ReaderWriterAsMutex_RedisReaderWriter_Redis2x1Database_RedisSynchronizationStrategy_Redis2x1Database_RedisSynchronizationStrategy_Redis2x1DatabaseTest : DistributedLockCoreTestCases, TestingRedisSynchronizationStrategy>, TestingRedisSynchronizationStrategy> { } + public class Core_ReaderWriterAsMutex_RedisReaderWriter_Redis3Database_RedisSynchronizationStrategy_Redis3Database_RedisSynchronizationStrategy_Redis3DatabaseTest : DistributedLockCoreTestCases, TestingRedisSynchronizationStrategy>, TestingRedisSynchronizationStrategy> { } + public class Core_ReaderWriterAsMutex_RedisReaderWriter_RedisSingleDatabase_RedisSynchronizationStrategy_RedisSingleDatabase_RedisSynchronizationStrategy_RedisSingleDatabaseTest : DistributedLockCoreTestCases, TestingRedisSynchronizationStrategy>, TestingRedisSynchronizationStrategy> { } + public class Core_ReaderWriterAsMutex_RedisReaderWriter_RedisWithKeyPrefixSingleDatabase_RedisSynchronizationStrategy_RedisWithKeyPrefixSingleDatabase_RedisSynchronizationStrategy_RedisWithKeyPrefixSingleDatabaseTest : DistributedLockCoreTestCases, TestingRedisSynchronizationStrategy>, TestingRedisSynchronizationStrategy> { } + public class Core_Redis_Redis2x1Database_RedisSynchronizationStrategy_Redis2x1DatabaseTest : DistributedLockCoreTestCases, TestingRedisSynchronizationStrategy> { } + public class Core_Redis_Redis3Database_RedisSynchronizationStrategy_Redis3DatabaseTest : DistributedLockCoreTestCases, TestingRedisSynchronizationStrategy> { } + public class Core_Redis_RedisSingleDatabase_RedisSynchronizationStrategy_RedisSingleDatabaseTest : DistributedLockCoreTestCases, TestingRedisSynchronizationStrategy> { } + public class Core_Redis_RedisWithKeyPrefixSingleDatabase_RedisSynchronizationStrategy_RedisWithKeyPrefixSingleDatabaseTest : DistributedLockCoreTestCases, TestingRedisSynchronizationStrategy> { } + public class Core_Semaphore1AsMutex_RedisSemaphore_RedisSynchronizationStrategy_RedisSingleDatabase_RedisSynchronizationStrategy_RedisSingleDatabaseTest : DistributedLockCoreTestCases>, TestingRedisSynchronizationStrategy> { } + public class Core_Semaphore5AsMutex_RedisSemaphore_RedisSynchronizationStrategy_RedisSingleDatabase_RedisSynchronizationStrategy_RedisSingleDatabaseTest : DistributedLockCoreTestCases>, TestingRedisSynchronizationStrategy> { } + public class ReaderWriterCore_RedisReaderWriter_Redis2x1Database_RedisSynchronizationStrategy_Redis2x1DatabaseTest : DistributedReaderWriterLockCoreTestCases, TestingRedisSynchronizationStrategy> { } + public class ReaderWriterCore_RedisReaderWriter_Redis3Database_RedisSynchronizationStrategy_Redis3DatabaseTest : DistributedReaderWriterLockCoreTestCases, TestingRedisSynchronizationStrategy> { } + public class ReaderWriterCore_RedisReaderWriter_RedisSingleDatabase_RedisSynchronizationStrategy_RedisSingleDatabaseTest : DistributedReaderWriterLockCoreTestCases, TestingRedisSynchronizationStrategy> { } + public class ReaderWriterCore_RedisReaderWriter_RedisWithKeyPrefixSingleDatabase_RedisSynchronizationStrategy_RedisWithKeyPrefixSingleDatabaseTest : DistributedReaderWriterLockCoreTestCases, TestingRedisSynchronizationStrategy> { } + public class RedisExtension_ReaderWriterAsMutex_RedisReaderWriter_Redis2x1Database_RedisSynchronizationStrategy_Redis2x1Database_Redis2x1DatabaseTest : RedisExtensionTestCases, TestingRedisSynchronizationStrategy>, TestingRedis2x1DatabaseProvider> { } + public class RedisExtension_ReaderWriterAsMutex_RedisReaderWriter_Redis3Database_RedisSynchronizationStrategy_Redis3Database_Redis3DatabaseTest : RedisExtensionTestCases, TestingRedisSynchronizationStrategy>, TestingRedis3DatabaseProvider> { } + public class RedisExtension_ReaderWriterAsMutex_RedisReaderWriter_RedisSingleDatabase_RedisSynchronizationStrategy_RedisSingleDatabase_RedisSingleDatabaseTest : RedisExtensionTestCases, TestingRedisSynchronizationStrategy>, TestingRedisSingleDatabaseProvider> { } + public class RedisExtension_ReaderWriterAsMutex_RedisReaderWriter_RedisWithKeyPrefixSingleDatabase_RedisSynchronizationStrategy_RedisWithKeyPrefixSingleDatabase_RedisWithKeyPrefixSingleDatabaseTest : RedisExtensionTestCases, TestingRedisSynchronizationStrategy>, TestingRedisWithKeyPrefixSingleDatabaseProvider> { } + public class RedisExtension_Redis_Redis2x1Database_Redis2x1DatabaseTest : RedisExtensionTestCases, TestingRedis2x1DatabaseProvider> { } + public class RedisExtension_Redis_Redis3Database_Redis3DatabaseTest : RedisExtensionTestCases, TestingRedis3DatabaseProvider> { } + public class RedisExtension_Redis_RedisSingleDatabase_RedisSingleDatabaseTest : RedisExtensionTestCases, TestingRedisSingleDatabaseProvider> { } + public class RedisExtension_Redis_RedisWithKeyPrefixSingleDatabase_RedisWithKeyPrefixSingleDatabaseTest : RedisExtensionTestCases, TestingRedisWithKeyPrefixSingleDatabaseProvider> { } + public class RedisExtension_Semaphore1AsMutex_RedisSemaphore_RedisSynchronizationStrategy_RedisSingleDatabase_RedisSingleDatabaseTest : RedisExtensionTestCases>, TestingRedisSingleDatabaseProvider> { } + public class RedisExtension_Semaphore5AsMutex_RedisSemaphore_RedisSynchronizationStrategy_RedisSingleDatabase_RedisSingleDatabaseTest : RedisExtensionTestCases>, TestingRedisSingleDatabaseProvider> { } + public class RedisSynchronizationCore_ReaderWriterAsMutex_RedisReaderWriter_Redis3Database_RedisSynchronizationStrategy_Redis3DatabaseTest : RedisSynchronizationCoreTestCases, TestingRedisSynchronizationStrategy>> { } + public class RedisSynchronizationCore_Redis_Redis3DatabaseTest : RedisSynchronizationCoreTestCases> { } + public class SemaphoreCore_RedisSemaphore_RedisSynchronizationStrategy_RedisSingleDatabaseTest : DistributedSemaphoreCoreTestCases> { } +} + namespace Medallion.Threading.Tests.SqlServer { public class ConnectionStringStrategy_ReaderWriterAsMutex_SqlReaderWriter_ConnectionMultiplexingSynchronizationStrategy_SqlServerDb_SqlServerDb_ConnectionMultiplexingSynchronizationStrategy_SqlServerDb_ConnectionMultiplexingSynchronizationStrategy_SqlServerDb_SqlServerDbTest : ConnectionStringStrategyTestCases, TestingSqlServerDb>, TestingConnectionMultiplexingSynchronizationStrategy>, TestingConnectionMultiplexingSynchronizationStrategy, TestingSqlServerDb> { } @@ -92,6 +215,10 @@ public class Core_Sql_ExternalTransactionSynchronizationStrategy_SqlServerDb_Sql public class Core_Sql_ExternalTransactionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDb_ExternalTransactionSynchronizationStrategy_SystemDataSqlServerDbTest : DistributedLockCoreTestCases, TestingSystemDataSqlServerDb>, TestingExternalTransactionSynchronizationStrategy> { } public class Core_Sql_OwnedConnectionSynchronizationStrategy_SqlServerDb_SqlServerDb_OwnedConnectionSynchronizationStrategy_SqlServerDbTest : DistributedLockCoreTestCases, TestingSqlServerDb>, TestingOwnedConnectionSynchronizationStrategy> { } public class Core_Sql_OwnedTransactionSynchronizationStrategy_SqlServerDb_SqlServerDb_OwnedTransactionSynchronizationStrategy_SqlServerDbTest : DistributedLockCoreTestCases, TestingSqlServerDb>, TestingOwnedTransactionSynchronizationStrategy> { } + public class DbSemaphore_SqlSemaphore_ExternalConnectionSynchronizationStrategy_SqlServerDb_SqlServerDb_ExternalConnectionSynchronizationStrategy_SqlServerDb_SqlServerDbTest : DbSemaphoreTestCases, TestingSqlServerDb>, TestingExternalConnectionSynchronizationStrategy, TestingSqlServerDb> { } + public class DbSemaphore_SqlSemaphore_ExternalConnectionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDb_ExternalConnectionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDbTest : DbSemaphoreTestCases, TestingSystemDataSqlServerDb>, TestingExternalConnectionSynchronizationStrategy, TestingSystemDataSqlServerDb> { } + public class DbSemaphore_SqlSemaphore_ExternalTransactionSynchronizationStrategy_SqlServerDb_SqlServerDb_ExternalTransactionSynchronizationStrategy_SqlServerDb_SqlServerDbTest : DbSemaphoreTestCases, TestingSqlServerDb>, TestingExternalTransactionSynchronizationStrategy, TestingSqlServerDb> { } + public class DbSemaphore_SqlSemaphore_ExternalTransactionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDb_ExternalTransactionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDbTest : DbSemaphoreTestCases, TestingSystemDataSqlServerDb>, TestingExternalTransactionSynchronizationStrategy, TestingSystemDataSqlServerDb> { } public class ExternalConnectionOrTransactionStrategy_ReaderWriterAsMutex_SqlReaderWriter_ExternalConnectionSynchronizationStrategy_SqlServerDb_SqlServerDb_ExternalConnectionSynchronizationStrategy_SqlServerDb_ExternalConnectionSynchronizationStrategy_SqlServerDb_SqlServerDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingSqlServerDb>, TestingExternalConnectionSynchronizationStrategy>, TestingExternalConnectionSynchronizationStrategy, TestingSqlServerDb> { } public class ExternalConnectionOrTransactionStrategy_ReaderWriterAsMutex_SqlReaderWriter_ExternalConnectionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDb_ExternalConnectionSynchronizationStrategy_SystemDataSqlServerDb_ExternalConnectionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingSystemDataSqlServerDb>, TestingExternalConnectionSynchronizationStrategy>, TestingExternalConnectionSynchronizationStrategy, TestingSystemDataSqlServerDb> { } public class ExternalConnectionOrTransactionStrategy_ReaderWriterAsMutex_SqlReaderWriter_ExternalTransactionSynchronizationStrategy_SqlServerDb_SqlServerDb_ExternalTransactionSynchronizationStrategy_SqlServerDb_ExternalTransactionSynchronizationStrategy_SqlServerDb_SqlServerDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingSqlServerDb>, TestingExternalTransactionSynchronizationStrategy>, TestingExternalTransactionSynchronizationStrategy, TestingSqlServerDb> { } @@ -150,10 +277,6 @@ public class SemaphoreCore_SqlSemaphore_ExternalTransactionSynchronizationStrate public class SemaphoreCore_SqlSemaphore_ExternalTransactionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDb_ExternalTransactionSynchronizationStrategy_SystemDataSqlServerDbTest : DistributedSemaphoreCoreTestCases, TestingSystemDataSqlServerDb>, TestingExternalTransactionSynchronizationStrategy> { } public class SemaphoreCore_SqlSemaphore_OwnedConnectionSynchronizationStrategy_SqlServerDb_SqlServerDb_OwnedConnectionSynchronizationStrategy_SqlServerDbTest : DistributedSemaphoreCoreTestCases, TestingSqlServerDb>, TestingOwnedConnectionSynchronizationStrategy> { } public class SemaphoreCore_SqlSemaphore_OwnedTransactionSynchronizationStrategy_SqlServerDb_SqlServerDb_OwnedTransactionSynchronizationStrategy_SqlServerDbTest : DistributedSemaphoreCoreTestCases, TestingSqlServerDb>, TestingOwnedTransactionSynchronizationStrategy> { } - public class SemaphoreSelfDeadlock_SqlSemaphore_ExternalConnectionSynchronizationStrategy_SqlServerDb_SqlServerDb_ExternalConnectionSynchronizationStrategy_SqlServerDb_SqlServerDbTest : SemaphoreSelfDeadlockTestCases, TestingSqlServerDb>, TestingExternalConnectionSynchronizationStrategy, TestingSqlServerDb> { } - public class SemaphoreSelfDeadlock_SqlSemaphore_ExternalConnectionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDb_ExternalConnectionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDbTest : SemaphoreSelfDeadlockTestCases, TestingSystemDataSqlServerDb>, TestingExternalConnectionSynchronizationStrategy, TestingSystemDataSqlServerDb> { } - public class SemaphoreSelfDeadlock_SqlSemaphore_ExternalTransactionSynchronizationStrategy_SqlServerDb_SqlServerDb_ExternalTransactionSynchronizationStrategy_SqlServerDb_SqlServerDbTest : SemaphoreSelfDeadlockTestCases, TestingSqlServerDb>, TestingExternalTransactionSynchronizationStrategy, TestingSqlServerDb> { } - public class SemaphoreSelfDeadlock_SqlSemaphore_ExternalTransactionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDb_ExternalTransactionSynchronizationStrategy_SystemDataSqlServerDb_SystemDataSqlServerDbTest : SemaphoreSelfDeadlockTestCases, TestingSystemDataSqlServerDb>, TestingExternalTransactionSynchronizationStrategy, TestingSystemDataSqlServerDb> { } public class UpgradeableReaderWriterConnectionStringStrategy_SqlReaderWriter_ConnectionMultiplexingSynchronizationStrategy_SqlServerDb_SqlServerDb_ConnectionMultiplexingSynchronizationStrategy_SqlServerDb_SqlServerDbTest : UpgradeableReaderWriterLockConnectionStringStrategyTestCases, TestingSqlServerDb>, TestingConnectionMultiplexingSynchronizationStrategy, TestingSqlServerDb> { } public class UpgradeableReaderWriterConnectionStringStrategy_SqlReaderWriter_OwnedConnectionSynchronizationStrategy_SqlServerDb_SqlServerDb_OwnedConnectionSynchronizationStrategy_SqlServerDb_SqlServerDbTest : UpgradeableReaderWriterLockConnectionStringStrategyTestCases, TestingSqlServerDb>, TestingOwnedConnectionSynchronizationStrategy, TestingSqlServerDb> { } public class UpgradeableReaderWriterConnectionStringStrategy_SqlReaderWriter_OwnedTransactionSynchronizationStrategy_SqlServerDb_SqlServerDb_OwnedTransactionSynchronizationStrategy_SqlServerDb_SqlServerDbTest : UpgradeableReaderWriterLockConnectionStringStrategyTestCases, TestingSqlServerDb>, TestingOwnedTransactionSynchronizationStrategy, TestingSqlServerDb> { } @@ -168,5 +291,25 @@ public class UpgradeableReaderWriterCore_SqlReaderWriter_OwnedTransactionSynchro namespace Medallion.Threading.Tests.WaitHandles { - [Category("CI")] public class Core_EventWaitHandle_WaitHandlesSynchronizationStrategyTest : DistributedLockCoreTestCases { } + [Category("CIWindows")] public class Core_EventWaitHandle_WaitHandleSynchronizationStrategyTest : DistributedLockCoreTestCases { } + [Category("CIWindows")] public class Core_Semaphore1AsMutex_CompositeSemaphore_WaitHandleSynchronizationStrategy_WaitHandleSynchronizationStrategyTest : DistributedLockCoreTestCases, TestingWaitHandleSynchronizationStrategy> { } + [Category("CIWindows")] public class Core_Semaphore1AsMutex_WaitHandleSemaphore_WaitHandleSynchronizationStrategy_WaitHandleSynchronizationStrategyTest : DistributedLockCoreTestCases, TestingWaitHandleSynchronizationStrategy> { } + [Category("CIWindows")] public class Core_Semaphore5AsMutex_CompositeSemaphore_WaitHandleSynchronizationStrategy_WaitHandleSynchronizationStrategyTest : DistributedLockCoreTestCases, TestingWaitHandleSynchronizationStrategy> { } + [Category("CIWindows")] public class Core_Semaphore5AsMutex_WaitHandleSemaphore_WaitHandleSynchronizationStrategy_WaitHandleSynchronizationStrategyTest : DistributedLockCoreTestCases, TestingWaitHandleSynchronizationStrategy> { } + [Category("CIWindows")] public class SemaphoreCore_CompositeSemaphore_WaitHandleSynchronizationStrategyTest : DistributedSemaphoreCoreTestCases { } + [Category("CIWindows")] public class SemaphoreCore_WaitHandleSemaphore_WaitHandleSynchronizationStrategyTest : DistributedSemaphoreCoreTestCases { } +} + +namespace Medallion.Threading.Tests.ZooKeeper +{ + public class Core_ReaderWriterAsMutex_ZooKeeperReaderWriter_ZooKeeperSynchronizationStrategy_ZooKeeperSynchronizationStrategyTest : DistributedLockCoreTestCases, TestingZooKeeperSynchronizationStrategy> { } + public class Core_Semaphore1AsMutex_ZooKeeperSemaphore_ZooKeeperSynchronizationStrategy_ZooKeeperSynchronizationStrategyTest : DistributedLockCoreTestCases, TestingZooKeeperSynchronizationStrategy> { } + public class Core_Semaphore5AsMutex_ZooKeeperSemaphore_ZooKeeperSynchronizationStrategy_ZooKeeperSynchronizationStrategyTest : DistributedLockCoreTestCases, TestingZooKeeperSynchronizationStrategy> { } + public class Core_ZooKeeper_ZooKeeperSynchronizationStrategyTest : DistributedLockCoreTestCases { } + public class ReaderWriterCore_ZooKeeperReaderWriter_ZooKeeperSynchronizationStrategyTest : DistributedReaderWriterLockCoreTestCases { } + public class SemaphoreCore_ZooKeeperSemaphore_ZooKeeperSynchronizationStrategyTest : DistributedSemaphoreCoreTestCases { } + public class ZooKeeperSynchronizationCore_ReaderWriterAsMutex_ZooKeeperReaderWriter_ZooKeeperSynchronizationStrategyTest : ZooKeeperSynchronizationCoreTestCases> { } + public class ZooKeeperSynchronizationCore_Semaphore1AsMutex_ZooKeeperSemaphore_ZooKeeperSynchronizationStrategyTest : ZooKeeperSynchronizationCoreTestCases> { } + public class ZooKeeperSynchronizationCore_Semaphore5AsMutex_ZooKeeperSemaphore_ZooKeeperSynchronizationStrategyTest : ZooKeeperSynchronizationCoreTestCases> { } + public class ZooKeeperSynchronizationCore_ZooKeeperTest : ZooKeeperSynchronizationCoreTestCases { } } \ No newline at end of file diff --git a/src/DistributedLock.Tests/Tests/Core/Data/DatabaseConnectionTest.cs b/src/DistributedLock.Tests/Tests/Core/Data/DatabaseConnectionTest.cs new file mode 100644 index 00000000..d07f29aa --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Core/Data/DatabaseConnectionTest.cs @@ -0,0 +1,29 @@ +using Medallion.Threading.SqlServer; +using Medallion.Threading.Tests.SqlServer; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Core.Data; + +public class DatabaseConnectionTest +{ + /// + /// Reproduces the root cause of https://github.com/madelson/DistributedLock/issues/133 + /// + [Test] + public async Task TestConnectionMonitorStaysSubscribedAfterClose() + { + var db = new TestingSqlServerDb { ApplicationName = TestHelper.UniqueName }; + + await using var connection = new SqlDatabaseConnection(db.ConnectionString); + + await connection.OpenAsync(CancellationToken.None); + connection.ConnectionMonitor.GetMonitoringHandle().Dispose(); // initialize monitoring + await connection.CloseAsync(); + + await connection.OpenAsync(CancellationToken.None); + using var handle = connection.ConnectionMonitor.GetMonitoringHandle(); + Assert.That(handle.ConnectionLostToken.IsCancellationRequested, Is.False); + await db.KillSessionsAsync(db.ApplicationName, idleSince: null); + Assert.That(await TestHelper.WaitForAsync(() => new(handle.ConnectionLostToken.IsCancellationRequested), timeout: TimeSpan.FromSeconds(5)), Is.True); + } +} diff --git a/src/DistributedLock.Tests/Tests/Core/DeadlockExceptionTest.cs b/src/DistributedLock.Tests/Tests/Core/DeadlockExceptionTest.cs new file mode 100644 index 00000000..5e26d4a6 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Core/DeadlockExceptionTest.cs @@ -0,0 +1,27 @@ +using NUnit.Framework; +using System.Runtime.Serialization.Formatters.Binary; + +namespace Medallion.Threading.Tests.Core; + +[Category("CI")] +public class DeadlockExceptionTest +{ +#if NETFRAMEWORK // binary serialization has been removed from newer versions of .NET + [Test] + public void TestDeadlockExceptionSerialization() + { + void ThrowDeadlockException() => throw new DeadlockException(nameof(TestDeadlockExceptionSerialization), new InvalidOperationException("foo")); + var deadlockException = Assert.Throws(ThrowDeadlockException)!; + + var formatter = new BinaryFormatter(); + var stream = new MemoryStream(); + formatter.Serialize(stream, deadlockException); + + stream.Position = 0; + var deserialized = (DeadlockException)formatter.Deserialize(stream); + deserialized.Message.ShouldEqual(deadlockException.Message); + deserialized.StackTrace.ShouldEqual(deadlockException.StackTrace); + (deserialized.InnerException?.Message).ShouldEqual(deadlockException.InnerException?.Message); + } +#endif +} diff --git a/src/DistributedLock.Tests/Tests/Core/DistributedLockProviderExtensionsTest.cs b/src/DistributedLock.Tests/Tests/Core/DistributedLockProviderExtensionsTest.cs new file mode 100644 index 00000000..71799e7c --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Core/DistributedLockProviderExtensionsTest.cs @@ -0,0 +1,331 @@ +using Moq; +using NUnit.Framework; +using System.Linq.Expressions; + +namespace Medallion.Threading.Tests.Core; + +// Note: we don't bother testing the extensions for the other providers since they are all auto-generated by the same component. +[Category("CI")] +public class DistributedLockProviderExtensionsTest +{ + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => DistributedLockProviderExtensions.TryAcquireLockAsync(null!, "name")); + Assert.Throws(() => DistributedLockProviderExtensions.TryAcquireLock(null!, "name")); + Assert.Throws(() => DistributedLockProviderExtensions.AcquireLockAsync(null!, "name")); + Assert.Throws(() => DistributedLockProviderExtensions.AcquireLock(null!, "name")); + } + + [Test, Combinatorial] + public void TestCallThrough([Values] bool isTry, [Values] bool isAsync) + { + var mockLock = new Mock(); + var mockProvider = new Mock(); + mockProvider.Setup(p => p.CreateLock("name")) + .Returns(mockLock.Object) + .Verifiable(); + + if (isTry) + { + if (isAsync) + { + Test(p => p.TryAcquireLockAsync("name", default, default), l => l.TryAcquireAsync(default, default)); + } + else + { + Test(p => p.TryAcquireLock("name", default, default), l => l.TryAcquire(default, default)); + } + } + else + { + if (isAsync) + { + Test(p => p.AcquireLockAsync("name", default, default), l => l.AcquireAsync(default, default)); + } + else + { + Test(p => p.AcquireLock("name", default, default), l => l.Acquire(default, default)); + } + } + + void Test( + Expression> providerFunction, + Expression> lockFunction) + { + providerFunction.Compile()(mockProvider.Object); + + mockProvider.Verify(p => p.CreateLock("name"), Times.Once); + mockLock.Verify(lockFunction, Times.Once()); + } + } + + [Test, Combinatorial] + public void TestCompositeArgumentValidation([Values] bool isAsync) + { + var mockProvider = new Mock(); + var validNames = new List { "lock1", "lock2" }; + + if (isAsync) + { + Assert.ThrowsAsync(() => DistributedLockProviderExtensions.TryAcquireAllLocksAsync(null!, validNames).AsTask()); + Assert.ThrowsAsync(() => DistributedLockProviderExtensions.TryAcquireAllLocksAsync(mockProvider.Object, null!).AsTask()); + Assert.ThrowsAsync(() => DistributedLockProviderExtensions.TryAcquireAllLocksAsync(mockProvider.Object, new List()).AsTask()); + Assert.ThrowsAsync(() => DistributedLockProviderExtensions.TryAcquireAllLocksAsync(mockProvider.Object, new List { "lock1", null! }).AsTask()); + + Assert.ThrowsAsync(() => DistributedLockProviderExtensions.AcquireAllLocksAsync(null!, validNames).AsTask()); + Assert.ThrowsAsync(() => DistributedLockProviderExtensions.AcquireAllLocksAsync(mockProvider.Object, null!).AsTask()); + Assert.ThrowsAsync(() => DistributedLockProviderExtensions.AcquireAllLocksAsync(mockProvider.Object, new List()).AsTask()); + Assert.ThrowsAsync(() => DistributedLockProviderExtensions.AcquireAllLocksAsync(mockProvider.Object, new List { "lock1", null! }).AsTask()); + } + else + { + Assert.Throws(() => DistributedLockProviderExtensions.TryAcquireAllLocks(null!, validNames)); + Assert.Throws(() => DistributedLockProviderExtensions.TryAcquireAllLocks(mockProvider.Object, null!)); + Assert.Throws(() => DistributedLockProviderExtensions.TryAcquireAllLocks(mockProvider.Object, new List())); + Assert.Throws(() => DistributedLockProviderExtensions.TryAcquireAllLocks(mockProvider.Object, new List { "lock1", null! })); + + Assert.Throws(() => DistributedLockProviderExtensions.AcquireAllLocks(null!, validNames)); + Assert.Throws(() => DistributedLockProviderExtensions.AcquireAllLocks(mockProvider.Object, null!)); + Assert.Throws(() => DistributedLockProviderExtensions.AcquireAllLocks(mockProvider.Object, new List())); + Assert.Throws(() => DistributedLockProviderExtensions.AcquireAllLocks(mockProvider.Object, new List { "lock1", null! })); + } + } + + [Test, Combinatorial] + public async Task TestCompositePartialAcquisitionFailure([Values] bool isAsync) + { + var mockProvider = new Mock(); + var mockLockA = new Mock(); + var mockLockB = new Mock(); + var mockHandleA = new Mock(); + + mockProvider.Setup(p => p.CreateLock("A")).Returns(mockLockA.Object); + mockProvider.Setup(p => p.CreateLock("B")).Returns(mockLockB.Object); + + if (isAsync) + { + mockLockA.Setup(l => l.TryAcquireAsync(TimeSpan.Zero, default)) + .ReturnsAsync(mockHandleA.Object); + mockLockB.Setup(l => l.TryAcquireAsync(It.IsAny(), default)) + .ReturnsAsync((IDistributedSynchronizationHandle?)null); + } + else + { + mockLockA.Setup(l => l.TryAcquire(TimeSpan.Zero, default)) + .Returns(mockHandleA.Object); + mockLockB.Setup(l => l.TryAcquire(It.IsAny(), default)) + .Returns((IDistributedSynchronizationHandle?)null); + } + + var names = new List { "A", "B" }; + IDistributedSynchronizationHandle? result; + + if (isAsync) + { + result = await mockProvider.Object.TryAcquireAllLocksAsync(names, TimeSpan.Zero, default); + } + else + { + result = mockProvider.Object.TryAcquireAllLocks(names, TimeSpan.Zero, default); + } + + Assert.That(result, Is.Null); + + if (isAsync) { mockHandleA.Verify(h => h.DisposeAsync(), Times.Once); } + else { mockHandleA.Verify(h => h.Dispose(), Times.Once); } + } + + [Test, Combinatorial] + public async Task TestCompositeSuccessfulAcquisition([Values] bool isTry, [Values] bool isAsync) + { + var mockProvider = new Mock(); + var mockLockA = new Mock(); + var mockLockB = new Mock(); + var mockHandleA = new Mock(); + var mockHandleB = new Mock(); + + mockProvider.Setup(p => p.CreateLock("A")).Returns(mockLockA.Object); + mockProvider.Setup(p => p.CreateLock("B")).Returns(mockLockB.Object); + + if (isAsync) + { + mockLockA.Setup(l => l.TryAcquireAsync(It.IsAny(), default)) + .ReturnsAsync(mockHandleA.Object); + mockLockB.Setup(l => l.TryAcquireAsync(It.IsAny(), default)) + .ReturnsAsync(mockHandleB.Object); + mockLockA.Setup(l => l.AcquireAsync(It.IsAny(), default)) + .ReturnsAsync(mockHandleA.Object); + mockLockB.Setup(l => l.AcquireAsync(It.IsAny(), default)) + .ReturnsAsync(mockHandleB.Object); + } + else + { + mockLockA.Setup(l => l.TryAcquire(It.IsAny(), default)) + .Returns(mockHandleA.Object); + mockLockB.Setup(l => l.TryAcquire(It.IsAny(), default)) + .Returns(mockHandleB.Object); + mockLockA.Setup(l => l.Acquire(It.IsAny(), default)) + .Returns(mockHandleA.Object); + mockLockB.Setup(l => l.Acquire(It.IsAny(), default)) + .Returns(mockHandleB.Object); + } + + var names = new List { "A", "B" }; + IDistributedSynchronizationHandle? result; + + if (isAsync) + { + if (isTry) + { + result = await mockProvider.Object.TryAcquireAllLocksAsync(names, TimeSpan.FromSeconds(10), default); + } + else + { + result = await mockProvider.Object.AcquireAllLocksAsync(names, TimeSpan.FromSeconds(10), default); + } + } + else + { + if (isTry) + { + result = mockProvider.Object.TryAcquireAllLocks(names, TimeSpan.FromSeconds(10), default); + } + else + { + result = mockProvider.Object.AcquireAllLocks(names, TimeSpan.FromSeconds(10), default); + } + } + + Assert.That(result, Is.Not.Null); + + mockProvider.Verify(p => p.CreateLock("A"), Times.Once); + mockProvider.Verify(p => p.CreateLock("B"), Times.Once); + + if (isAsync) + { + await result!.DisposeAsync(); + mockHandleA.Verify(h => h.DisposeAsync(), Times.Once); + mockHandleB.Verify(h => h.DisposeAsync(), Times.Once); + } + else + { + result!.Dispose(); + mockHandleA.Verify(h => h.Dispose(), Times.Once); + mockHandleB.Verify(h => h.Dispose(), Times.Once); + } + } + + [Test, Combinatorial] + public void TestCompositeAcquireThrowsOnTimeout([Values] bool isAsync) + { + var mockProvider = new Mock(); + var mockLockA = new Mock(); + + mockProvider.Setup(p => p.CreateLock("A")).Returns(mockLockA.Object); + + if (isAsync) + { + mockLockA.Setup(l => l.TryAcquireAsync(It.IsAny(), default)) + .ReturnsAsync((IDistributedSynchronizationHandle?)null); + } + else + { + mockLockA.Setup(l => l.TryAcquire(It.IsAny(), default)) + .Returns((IDistributedSynchronizationHandle?)null); + } + + var names = new List { "A" }; + + if (isAsync) + { + Assert.ThrowsAsync(() => mockProvider.Object.AcquireAllLocksAsync(names, TimeSpan.Zero, default).AsTask()); + } + else + { + Assert.Throws(() => mockProvider.Object.AcquireAllLocks(names, TimeSpan.Zero, default)); + } + } + + [Test, Retry(3)] // timing-sensitive + public async Task TestCompositeRemainingTimeDistribution() + { + var mockProvider = new Mock(); + var mockLockA = new Mock(); + var mockLockB = new Mock(); + var mockHandleA = new Mock(); + var mockHandleB = new Mock(); + + mockProvider.Setup(p => p.CreateLock("A")).Returns(mockLockA.Object); + mockProvider.Setup(p => p.CreateLock("B")).Returns(mockLockB.Object); + + List capturedTimeouts = []; + + mockLockA.Setup(l => l.TryAcquireAsync(It.IsAny(), default)) + .Returns(async (TimeSpan timeout, CancellationToken _) => + { + capturedTimeouts.Add(timeout); + await Task.Delay(TimeSpan.FromMilliseconds(100)); + return mockHandleA.Object; + }); + + mockLockB.Setup(l => l.TryAcquireAsync(It.IsAny(), default)) + .ReturnsAsync((TimeSpan timeout, CancellationToken _) => + { + capturedTimeouts.Add(timeout); + return mockHandleB.Object; + }); + + var names = new List { "A", "B" }; + var totalTimeout = TimeSpan.FromSeconds(5); + + await using var result = await mockProvider.Object.TryAcquireAllLocksAsync(names, totalTimeout, default); + + Assert.That(result, Is.Not.Null); + Assert.That(capturedTimeouts.Count, Is.EqualTo(2)); + + Assert.That(capturedTimeouts[0].TotalMilliseconds, Is.EqualTo(totalTimeout.TotalMilliseconds).Within(TestHelper.IsCi ? 50 : 2)); + + Assert.That(capturedTimeouts[1], Is.LessThan(totalTimeout)); + Assert.That(capturedTimeouts[1], Is.GreaterThanOrEqualTo(TimeSpan.Zero)); + } + + [Test] + public async Task TestCompositeHandleLostToken() + { + var mockProvider = new Mock(); + var mockLockA = new Mock(); + var mockLockB = new Mock(); + + var ctsA = new CancellationTokenSource(); + var ctsB = new CancellationTokenSource(); + + var mockHandleA = new Mock(); + var mockHandleB = new Mock(); + + mockHandleA.Setup(h => h.HandleLostToken).Returns(ctsA.Token); + mockHandleB.Setup(h => h.HandleLostToken).Returns(ctsB.Token); + + mockProvider.Setup(p => p.CreateLock("A")).Returns(mockLockA.Object); + mockProvider.Setup(p => p.CreateLock("B")).Returns(mockLockB.Object); + + mockLockA.Setup(l => l.TryAcquireAsync(It.IsAny(), default)) + .ReturnsAsync(mockHandleA.Object); + mockLockB.Setup(l => l.TryAcquireAsync(It.IsAny(), default)) + .ReturnsAsync(mockHandleB.Object); + + var names = new List { "A", "B" }; + var result = await mockProvider.Object.TryAcquireAllLocksAsync(names, TimeSpan.FromSeconds(10), default); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.HandleLostToken.CanBeCanceled, Is.True); + + ctsA.Cancel(); + + Assert.That(result.HandleLostToken.IsCancellationRequested, Is.True); + + await result.DisposeAsync(); + ctsA.Dispose(); + ctsB.Dispose(); + } +} diff --git a/src/DistributedLock.Tests/Tests/Core/HelpersTest.cs b/src/DistributedLock.Tests/Tests/Core/HelpersTest.cs new file mode 100644 index 00000000..abd79610 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Core/HelpersTest.cs @@ -0,0 +1,80 @@ +using Medallion.Threading.Internal; +using NUnit.Framework; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; + +namespace Medallion.Threading.Tests.Core; + +[Category("CI")] +public class HelpersTest +{ + [Test] + public void TestSafeCreateTaskPassesThroughSafeTasks() + { + var tasks = new[] { Task.FromResult(14), Task.FromResult(24) }; + var safeTask = Helpers.SafeCreateTask(state => tasks[state], 1); + Assert.That(safeTask, Is.SameAs(tasks[1])); + + var safeNonGenericTask = Helpers.SafeCreateTask(state => tasks[state], 1); + Assert.That(tasks[1], Is.SameAs(safeNonGenericTask)); + } + + [Test] + public void TestSafeCreateTaskReturnsCaughtExceptionAsFaultedTask() + { + var safeTask = Helpers.SafeCreateTask(state => GetTask(state), "m1"); + Assert.That(safeTask.Exception!.InnerException, Is.InstanceOf()); + safeTask.Exception.InnerException!.Message.ShouldEqual("m1"); + + var safeNonGenericTask = Helpers.SafeCreateTask(state => GetTask(state), "m2"); + Assert.That(safeNonGenericTask.Exception!.InnerException, Is.InstanceOf()); + safeNonGenericTask.Exception.InnerException!.Message.ShouldEqual("m2"); + + static Task GetTask(string message) => throw new TimeZoneNotFoundException(message); + } + + /// + /// Based on https://github.com/madelson/DistributedLock/issues/192 + /// + [Test] + public async Task TestTryAwaitShouldNotResultInUnobservedTaskException([Values] bool faulted) + { + AsyncLocal scope = new() { Value = true }; + ConcurrentBag unobservedTaskExceptions = []; + EventHandler handler = (_, e) => unobservedTaskExceptions.Add(e.Exception); + + TaskScheduler.UnobservedTaskException += handler; + try + { + await TryAwaitFailedTask(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + finally { TaskScheduler.UnobservedTaskException -= handler; } + + foreach (var exception in unobservedTaskExceptions) + { + Assert.That(exception.ToString(), Does.Not.Contain(nameof(TimeZoneNotFoundException))); + Assert.That(exception.ToString(), Does.Not.Contain(nameof(OperationCanceledException))); + Assert.That(exception.ToString(), Does.Not.Contain(nameof(TaskCanceledException))); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + async Task TryAwaitFailedTask() + { + Task task; + if (faulted) + { + task = Task.Run(() => throw new TimeZoneNotFoundException()); + } + else + { + CancellationTokenSource cancellationSource = new(); + task = Task.Delay(TimeSpan.FromSeconds(30), cancellationSource.Token); + cancellationSource.Cancel(); + } + + await task.TryAwait(); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/Core/InternalVisibilityTest.cs b/src/DistributedLock.Tests/Tests/Core/InternalVisibilityTest.cs new file mode 100644 index 00000000..d068e000 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Core/InternalVisibilityTest.cs @@ -0,0 +1,20 @@ +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Core; + +[Category("CI")] +public class InternalVisibilityTest +{ + [Test] + public void TestInternalNamespaceMethodsHaveCorrectVisibility() + { + var internalNamespaceTypes = typeof(IDistributedLock).Assembly.GetTypes() + .Where(t => t.Namespace?.Contains(".Internal") ?? false) + .ToList(); + Assert.That(internalNamespaceTypes, Is.Not.Empty); + +#if !DEBUG + Assert.That(internalNamespaceTypes.Where(t => t.IsPublic), Is.Empty); +#endif + } +} diff --git a/src/DistributedLock.Tests/Tests/Core/SyncViaAsyncTest.cs b/src/DistributedLock.Tests/Tests/Core/SyncViaAsyncTest.cs new file mode 100644 index 00000000..0d458710 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Core/SyncViaAsyncTest.cs @@ -0,0 +1,46 @@ +using Medallion.Threading.Internal; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Core; + +[Category("CI")] +public class SyncViaAsyncTest +{ + [Test] + public void TestSyncOverAsyncVoid() + { + var currentThread = Thread.CurrentThread; + SyncViaAsync.Run<(int a, int b, Thread startingThread, bool expectAsync)>( + async state => await AddAsync(state.a, state.b, state.startingThread, state.expectAsync), + (1, 2, currentThread, false) + ); + } + + [Test] + public void TestSyncOverAsyncWithResult() + { + var currentThread = Thread.CurrentThread; + var result = SyncViaAsync.Run( + async ((int a, int b, Thread startingThread, bool expectAsync) state) => await AddAsync(state.a, state.b, state.startingThread, state.expectAsync), + (1, 2, currentThread, false) + ); + Assert.That(result, Is.EqualTo(3)); + } + + private static async ValueTask AddAsync(int a, int b, Thread startingThread, bool expectAsync) + { + var result = await AddHelperAsync(a, expectAsync) + await AddHelperAsync(b, expectAsync); + Assert.That(Thread.CurrentThread != startingThread, Is.EqualTo(expectAsync)); + return result; + } + + private static async ValueTask AddHelperAsync(int a, bool expectAsync) + { + Assert.That(SyncViaAsync.IsSynchronous, Is.Not.EqualTo(expectAsync)); + + if (expectAsync) { await Task.Delay(1); } + else { Thread.Sleep(1); } + + return a; + } +} diff --git a/src/DistributedLock.Tests/Tests/Core/TimeoutValueTest.cs b/src/DistributedLock.Tests/Tests/Core/TimeoutValueTest.cs new file mode 100644 index 00000000..f48ce01f --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Core/TimeoutValueTest.cs @@ -0,0 +1,96 @@ +using Medallion.Threading.Internal; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Core; + +[Category("CI")] +public class TimeoutValueTest +{ + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => new TimeoutValue(TimeSpan.FromMilliseconds(-2))); + Assert.Throws(() => new TimeoutValue(TimeSpan.FromMilliseconds((long)int.MaxValue + 1))); + } + + [Test] + public void TestProperties() + { + Assert.That(default(TimeoutValue).IsZero, Is.True); + Assert.That(default(TimeoutValue).IsInfinite, Is.False); + Assert.That(default(TimeoutValue).InMilliseconds, Is.EqualTo(0)); + Assert.That(default(TimeoutValue).InSeconds, Is.EqualTo(0)); + + TimeoutValue infinite = Timeout.InfiniteTimeSpan; + Assert.That(infinite.IsZero, Is.False); + Assert.That(infinite.IsInfinite, Is.True); + Assert.That(infinite.InMilliseconds, Is.EqualTo(-1)); + Assert.Throws(() => infinite.InSeconds.ToString()); + + TimeoutValue normal = TimeSpan.FromSeconds(10.4); + Assert.That(normal.IsZero, Is.False); + Assert.That(normal.IsInfinite, Is.False); + Assert.That(normal.InMilliseconds, Is.EqualTo(10400)); + Assert.That(normal.InSeconds, Is.EqualTo(10)); + } + + [Test] + public void TestConversion() + { + Assert.That(new TimeoutValue(Timeout.InfiniteTimeSpan), Is.EqualTo((TimeoutValue)default(TimeSpan?))); + + CheckEquality(Timeout.InfiniteTimeSpan); + CheckEquality(TimeSpan.FromSeconds(101.3)); + CheckEquality(TimeSpan.FromTicks(1)); + CheckEquality(TimeSpan.Zero); + + static void CheckEquality(TimeSpan value) => Assert.That(((TimeoutValue)value).InMilliseconds, Is.EqualTo((int)value.TotalMilliseconds)); + } + + [Test] + public void TestEquality() + { + var timeSpans = new double[] { Timeout.Infinite, 0, 1, 1000, 10101 }.Select(TimeSpan.FromMilliseconds) + .ToArray(); + + foreach (var a in timeSpans) + { + foreach (var b in timeSpans) + { + TimeoutValue aValue = a, bValue = b; + + if (a == b) + { + Assert.That(aValue == bValue, Is.True); + Assert.That(aValue != bValue, Is.False); + Assert.That(aValue.Equals(bValue), Is.True); + Assert.That(aValue.Equals((object)bValue), Is.True); + Assert.That(Equals(aValue, bValue), Is.True); + Assert.That(bValue.GetHashCode(), Is.EqualTo(aValue.GetHashCode())); + } + else + { + Assert.That(aValue == bValue, Is.False); + Assert.That(aValue != bValue, Is.True); + Assert.That(aValue.Equals(bValue), Is.False); + Assert.That(aValue.Equals((object)bValue), Is.False); + Assert.That(Equals(aValue, bValue), Is.False); + Assert.That(bValue.GetHashCode(), Is.Not.EqualTo(aValue.GetHashCode())); + } + } + } + } + + [Test] + public void TestComparison() + { + new TimeoutValue(Timeout.InfiniteTimeSpan).CompareTo(Timeout.InfiniteTimeSpan).ShouldEqual(0); + new TimeoutValue(TimeSpan.FromSeconds(1)).CompareTo(TimeSpan.FromSeconds(1)).ShouldEqual(0); + + new TimeoutValue(Timeout.InfiniteTimeSpan).CompareTo(TimeSpan.FromMilliseconds(int.MaxValue)).ShouldEqual(1); + new TimeoutValue(TimeSpan.FromMilliseconds(int.MaxValue)).CompareTo(Timeout.InfiniteTimeSpan).ShouldEqual(-1); + + new TimeoutValue(TimeSpan.Zero).CompareTo(TimeSpan.FromSeconds(1)).ShouldEqual(-1); + new TimeoutValue(TimeSpan.FromSeconds(1)).CompareTo(TimeSpan.Zero).ShouldEqual(1); + } +} diff --git a/src/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockTest.cs new file mode 100644 index 00000000..8cfd4b74 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockTest.cs @@ -0,0 +1,519 @@ +using Medallion.Threading.FileSystem; +using NUnit.Framework; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +namespace Medallion.Threading.Tests.FileSystem; + +[Category("CI")] +public class FileDistributedLockTest +{ + private static readonly string LockFileDirectory = Path.Combine(Path.GetTempPath(), nameof(FileDistributedLockTest), TargetFramework.Current); + private static DirectoryInfo LockFileDirectoryInfo => new(LockFileDirectory); + + [OneTimeSetUp] + public void OneTimeSetUp() + { + if (Directory.Exists(LockFileDirectory)) + { + Directory.Delete(LockFileDirectory, recursive: true); + } + } + + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Throws(() => new FileDistributedLock(default!)); + Assert.Throws(() => new FileDistributedLock(new FileInfo(LockFileDirectory + Path.DirectorySeparatorChar))); + + Assert.Throws(() => new FileDistributedLock(default!, "name")); + Assert.Throws(() => new FileDistributedLock(LockFileDirectoryInfo, default!)); + } + + [Test, Combinatorial] + public void TestDirectoryIsCreatedIfNeededAndFileIsCreatedIfNeededAndAlwaysDeleted( + [Values("nothing", "directory", "file")] string alreadyExists, + [Values] bool constructFromFileInfo) + { + var directoryName = Path.Combine(LockFileDirectory, Hash("directory")); + var fileName = Path.Combine(directoryName, Hash("file")); + + switch (alreadyExists) + { + case "nothing": + if (Directory.Exists(directoryName)) + { + Directory.Delete(directoryName, recursive: true); + } + break; + case "directory": + Directory.CreateDirectory(directoryName); + File.Delete(fileName); + break; + case "file": + Directory.CreateDirectory(directoryName); + File.WriteAllText(fileName, "text"); + break; + default: + throw new InvalidOperationException("should never get here"); + } + + var @lock = constructFromFileInfo + ? new FileDistributedLock(new FileInfo(fileName)) + : new FileDistributedLock(new DirectoryInfo(directoryName), Path.GetFileName(fileName)); + if (constructFromFileInfo) + { + @lock.Name.ShouldEqual(fileName); + } + + Directory.Exists(directoryName).ShouldEqual(alreadyExists != "nothing"); + File.Exists(fileName).ShouldEqual(alreadyExists == "file"); + + using (@lock.Acquire()) + { + Assert.That(Directory.Exists(directoryName), Is.True); + Assert.That(File.Exists(@lock.Name), Is.True); + } + + Assert.That(Directory.Exists(directoryName), Is.True); + Assert.That(File.Exists(@lock.Name), Is.False); + + static string Hash(string text) + { + using var md5 = MD5.Create(); + var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes($"{text}_{TestContext.CurrentContext.Test.FullName}_{TargetFramework.Current}")); + return BitConverter.ToString(hashBytes).Replace("-", string.Empty); + } + } + + [Test] + public void TestFileCannotBeModifiedOrDeletedWhileHeld() + { + var @lock = new FileDistributedLock(LockFileDirectoryInfo, nameof(TestFileCannotBeModifiedOrDeletedWhileHeld)); + using (@lock.Acquire()) + { + Assert.Throws(() => File.WriteAllText(@lock.Name, "contents"), "write"); + Assert.Throws(() => File.ReadAllText(@lock.Name), "read"); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Throws(() => File.Delete(@lock.Name), "delete"); + } + else + { + // on unix, locking a file doesn't prevent unliking, so deletion effectively unlocks + // https://stackoverflow.com/questions/2028874/what-happens-to-an-open-file-handle-on-linux-if-the-pointed-file-gets-moved-or-d + Assert.DoesNotThrow(() => File.Delete(@lock.Name)); + using var reaquireHandle = @lock.TryAcquire(); + Assert.That(reaquireHandle, Is.Not.Null); + } + } + } + + [Test] + public void TestThrowsIfProvidedFileNameIsAlreadyADirectory() + { + var @lock = new FileDistributedLock(LockFileDirectoryInfo, nameof(TestThrowsIfProvidedFileNameIsAlreadyADirectory)); + Directory.CreateDirectory(@lock.Name); + + var exception = Assert.Throws(() => @lock.Acquire().Dispose())!; + Assert.That(exception.Message, Does.Contain("because it is already the name of a directory")); + } + + [Test] + public void TestThrowsIfProvidedDirectoryIsAlreadyFile() + { + var tempFile = Path.GetTempFileName(); + + var @lock = new FileDistributedLock(lockFileDirectory: new(tempFile), "some name"); + var exception = Assert.Throws(() => @lock.Acquire().Dispose())!; + Assert.That(exception.InnerException!.Message, Does.Match("file .* already exists")); + } + + [Test] + public void TestEmptyNameIsAllowed() => AssertCanUseName(string.Empty); + + [Test] + public void TestLongNamesAreAllowed() => AssertCanUseName(new string('a', ushort.MaxValue)); + + [Test] + public void TestHandlesLongDirectoryNames() + { + DirectoryInfo tooLongDirectory; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + tooLongDirectory = AppContext.TryGetSwitch("Switch.System.IO.UseLegacyPathHandling", out var useLegacyPathHandling) && useLegacyPathHandling + ? BuildLongDirectory(259 - (FileNameValidationHelper.MinFileNameLength - 1)) + : BuildLongDirectory(short.MaxValue - (FileNameValidationHelper.MinFileNameLength - 1)); + + // we only check this on Windows currently since AppVeyor linux does not see to have a length restriction + Assert.Throws(() => new FileDistributedLock(tooLongDirectory, new string('a', FileNameValidationHelper.MinFileNameLength))); + } + else + { + tooLongDirectory = BuildLongDirectory(4096 - (FileNameValidationHelper.MinFileNameLength - 1)); + } + + var almostTooLongDirectory = new DirectoryInfo(tooLongDirectory.FullName.Substring(0, tooLongDirectory.FullName.Length - 1)); + AssertCanUseName(new string('a', FileNameValidationHelper.MinFileNameLength)); + AssertCanUseName(new string('a', 100 * FileNameValidationHelper.MinFileNameLength)); + + var almostTooLongBytesDirectory = new DirectoryInfo(almostTooLongDirectory.FullName.Replace("aaaa", "🦉")); + Encoding.UTF8.GetByteCount(almostTooLongBytesDirectory.FullName).ShouldEqual(Encoding.UTF8.GetByteCount(almostTooLongDirectory.FullName)); + AssertCanUseName(new string('a', FileNameValidationHelper.MinFileNameLength)); + AssertCanUseName(new string('a', FileNameValidationHelper.MinFileNameLength + 1)); + + static DirectoryInfo BuildLongDirectory(int length) + { + var name = new StringBuilder(LockFileDirectory); + while (name.Length < length) + { + name.Append(Path.DirectorySeparatorChar) + .Append('a', 100); // shorter than 255 max name lengths + } + name.Length = length; + // make sure we don't have separators near the end that block trimming + name[name.Length - 1] = 'b'; + name[name.Length - 2] = 'b'; + return new DirectoryInfo(name.ToString()); + } + } + + [TestCase(".")] + [TestCase("..")] + [TestCase("...")] + [TestCase("....")] + [TestCase("A.")] + [TestCase("A..")] + [TestCase(".A")] + [TestCase("..A")] + [TestCase(" ")] + [TestCase(" ")] + [TestCase(" ")] + [TestCase("A ")] + [TestCase(" A")] + [TestCase(" .")] + [TestCase(". ")] + [TestCase(". .")] + [TestCase(" .. ")] + [TestCase("\t.")] + [TestCase(" \t")] + public void TestStrangePaths(string name) => AssertCanUseName(name); + + [TestCase("a.")] + [TestCase("a ")] + public void TestTrailingWhitespaceOrDotDoesNotCauseCollision(string name) + { + var @lock = new FileDistributedLock(LockFileDirectoryInfo, name); + using (@lock.Acquire()) + { + using var handle = new FileDistributedLock(LockFileDirectoryInfo, name.Trim('.').Trim(' ')).TryAcquire(); + Assert.That(handle, Is.Not.Null); + } + } + + [TestCase("CON")] + [TestCase("PRN")] + [TestCase("AUX")] + [TestCase("NUL")] + [TestCase("COM1")] + [TestCase("COM2")] + [TestCase("COM3")] + [TestCase("COM4")] + [TestCase("COM5")] + [TestCase("COM6")] + [TestCase("COM7")] + [TestCase("COM8")] + [TestCase("COM9")] + [TestCase("CONIN$")] + [TestCase("CONOUT$")] + [TestCase("LPT1")] + [TestCase("LPT2")] + [TestCase("LPT3")] + [TestCase("LPT4")] + [TestCase("LPT5")] + [TestCase("LPT6")] + [TestCase("LPT7")] + [TestCase("LPT8")] + [TestCase("LPT9")] + public void TestReservedWindowsNamesAreAllowed(string name) + { + var variants = new[] + { + name, + name.ToLowerInvariant() + ".txt", + name[0] + name.Substring(1).ToLowerInvariant(), + }; + foreach (var variant in variants) + { + AssertCanUseName(variant); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + // Restrictions were alleviated in Win11: https://superuser.com/a/1742520/281669 + // Win <= 10 detection: see https://stackoverflow.com/a/69038652/1142970 + && Environment.OSVersion.Version.Build < 22000) + { + Assert.That(CanCreateFileWithName(variant), Is.False, variant); + Assert.That(Path.GetFileName(new FileDistributedLock(LockFileDirectoryInfo, name).Name), Is.Not.EqualTo(name), variant); + } + } + } + + [Test] + public void TestEscapesBadCharactersInName() + { + Check("A", shouldEscape: false); + Check("A_B", shouldEscape: false); + Check(string.Empty, shouldEscape: false); + Check(".", shouldEscape: true); + Check("..", shouldEscape: true); + Check("/A/", shouldEscape: true); + Check(@"\A\", shouldEscape: true); + Check("<", shouldEscape: true); + Check(">", shouldEscape: true); + Check("\0", shouldEscape: true); + + foreach (var invalidChar in Path.GetInvalidFileNameChars()) + { + Check("_" + invalidChar, shouldEscape: true); + } + + static void Check(string name, bool shouldEscape) + { + var @lock = new FileDistributedLock(LockFileDirectoryInfo, name); + // ordinal required for null char comparison (see https://github.com/dotnet/runtime/issues/4673) + @lock.Name.StartsWith(LockFileDirectory + Path.DirectorySeparatorChar + name, StringComparison.Ordinal) + .ShouldEqual(!shouldEscape, $"'{name}', {@lock.Name}"); + } + } + + [Test] + public void TestForcesCaseSensitivity() + { + Path.GetFileName(new FileDistributedLock(LockFileDirectoryInfo, "lower").Name) + .ShouldEqual("lowerPRE5SHQAMC324P4C6UKD4R4VMGGJMF6T.lock"); + } + + [TestCase("", ExpectedResult = "P6AD62YPPHO33YTKIBUM5WBQHQVBCSXA.lock")] + [TestCase(".", ExpectedResult = "_LIYISOQPXAPX3NYVHPC2EK4WEOU6OW7Y.lock")] + [TestCase("..", ExpectedResult = "__G2H2MVGLQK7QVO2MAWVKSVSKM26KX5S6.lock")] + [TestCase("...", ExpectedResult = "___2ZDJLYOT376KUA5OQFR2OERKUXIH4A7R.lock")] + [TestCase("LPT1", ExpectedResult = "LPT1VUGMI6NJVPIYUGXMY4K6ETA3232OR2B5.lock")] + [TestCase(" ", ExpectedResult = "_ZPD253R4AYXN6RS7UP6A3FYDCXHBXKUY.lock")] + [TestCase("_", ExpectedResult = "_3VP7XSELHOF3DU4D257KSSAQD3WR4SSL.lock")] + [TestCase(@"cool<>!/:x\zzz", ExpectedResult = "cool_____x_zzzATJSHZSADXN7WTL4DBU6JULFTEDVFF3L.lock")] + [TestCase( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ExpectedResult = "aaaaaaaaaaaaaaaaaaaaaaaaaaaXTSAJQWFKOYRBNXC6DV3HRZWQJANRKCX.lock" + )] + [TestCase("ABC", ExpectedResult = "ABCZJ4QR6TVN4A3KMGQ4BUKHOWQYFRLXSB3.lock")] + [TestCase("abc", ExpectedResult = "abc56LLTQOSBT6ULGHITLS4KQEIRREMO53J.lock")] + // 255 UTF8 bytes + [TestCase( + "🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉123", + ExpectedResult = "___________________________VA7XJG3JUQKTL6AU2KWPHVQFK43N4DVW.lock" + )] + // 256 UTF8 bytes + [TestCase( + "🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉🦉1234", + ExpectedResult = "___________________________NOLT44QRLWYINT53TY2I4H34X4AU4O46.lock" + )] + public string TestSafeNameCompatibility(string name) + { + // meant to be consistent length across platforms + var consistentDirectory = Path.Combine(LockFileDirectory, new string('b', 100)).Substring(0, 100); + var @lock = new FileDistributedLock(new DirectoryInfo(consistentDirectory), name); + Assert.That(@lock.Name, Does.StartWith(consistentDirectory + Path.DirectorySeparatorChar)); + return @lock.Name.Substring(consistentDirectory.Length + 1); + } + + [Test] + public void TestBase32Hashing() + { + const int Iterations = 100000; + + var charCounts = new int[128]; + + var nameChars = new char[] { default, default, Path.GetInvalidFileNameChars()[0] }; + var directoryInfo = LockFileDirectoryInfo; + for (var i = 0; i < Iterations; ++i) + { + if (++nameChars[0] == 0) + { + ++nameChars[1]; + } + + var @lock = new FileDistributedLock(directoryInfo, new string(nameChars)); + var lockFileNameWithoutExtension = Path.GetFileNameWithoutExtension(@lock.Name); + var hashStart = lockFileNameWithoutExtension.Length - FileNameValidationHelper.HashLengthInChars; + + for (var j = hashStart; j < lockFileNameWithoutExtension.Length; ++j) + { + ++charCounts[lockFileNameWithoutExtension[j]]; + } + } + + // for debugging + //for (var i = 0; i < charCounts.Length; ++i) + //{ + // Console.WriteLine($"{(char.IsLetterOrDigit((char)i) ? ((char)i).ToString() : i.ToString())}: {charCounts[i]}"); + //} + + var expectedCount = (Iterations * FileNameValidationHelper.HashLengthInChars) / 32; + for (var @char = default(char); @char < charCounts.Length; ++@char) + { + if ((@char >= '2' && @char <= '7') || (@char >= 'A' && @char <= 'Z')) + { + Assert.That(charCounts[@char], Is.EqualTo(expectedCount).Within(.1 * expectedCount)); + } + else + { + charCounts[@char].ShouldEqual(0); + } + } + } + + /// + /// Reproduces https://github.com/madelson/DistributedLock/issues/109 + /// + /// Basically, there is a small window where concurrent file creation/deletion throws + /// despite there being no access permission errors. + /// See also https://github.com/dotnet/runtime/issues/61395. + /// + /// This test shows that we are not vulnerable to this. + /// + [Test] + public void TestDoesNotFailDueToUnauthorizedAccessExceptionOnFileCreation() + { + Directory.CreateDirectory(LockFileDirectory); + var @lock = new FileDistributedLock(LockFileDirectoryInfo, Guid.NewGuid().ToString()); + + const int TaskCount = 20; + + using var barrier = new Barrier(TaskCount); + + var tasks = Enumerable.Range(0, TaskCount) + .Select(_ => Task.Factory.StartNew(() => + { + barrier.SignalAndWait(); + + for (var i = 0; i < 500; ++i) + { + @lock.TryAcquire()?.Dispose(); + } + }, TaskCreationOptions.LongRunning)) + .ToArray(); + + Assert.DoesNotThrowAsync(() => Task.WhenAll(tasks)); + } + + /// + /// Reproduces https://github.com/madelson/DistributedLock/issues/106 + /// + /// Basically, there is a small window where concurrent creation/deletion of directories + /// throws even though there are no access permission errors. + /// + /// This test confirms that we recover from such errors. + /// + [Test] + public void TestDoesNotFailDueToUnauthorizedAccessExceptionOnDirectoryCreation() + { + var @lock = new FileDistributedLock(LockFileDirectoryInfo, Guid.NewGuid().ToString()); + + const int TaskCount = 20; + + using var barrier = new Barrier(TaskCount); + using var cancelationTokenSource = new CancellationTokenSource(); + + var tasks = Enumerable.Range(0, TaskCount) + .Select(task => Task.Factory.StartNew(() => + { + for (var i = 0; i < 1000; ++i) + { + // line up all the threads + try { barrier.SignalAndWait(cancelationTokenSource.Token); } + catch when (cancelationTokenSource.Token.IsCancellationRequested) { return; } + + // have one thread clear the directory + if (task == 0 && Directory.Exists(LockFileDirectory)) { Directory.Delete(LockFileDirectory, recursive: true); } + + // line up all the threads + if (!barrier.SignalAndWait(TimeSpan.FromSeconds(3))) { throw new TimeoutException("should never get here"); } + + // have half the threads just create and delete the directory, catching any errors + if (task % 2 == 0) + { + try + { + Directory.CreateDirectory(LockFileDirectory); + Directory.Delete(LockFileDirectory); + } + catch { } + } + // the other half will attempt to acquire the lock + else + { + try { @lock.TryAcquire()?.Dispose(); } + catch + { + cancelationTokenSource.Cancel(); // exception found: exit + throw; + } + } + } + }, TaskCreationOptions.LongRunning)) + .ToArray(); + + Assert.DoesNotThrowAsync(() => Task.WhenAll(tasks)); + } + + /// + /// Documents a limitation we've imposed for now to keep the code simpler + /// + [Test] + public void TestLockingReadOnlyFileIsNotSupportedOnWindows() + { + // File.SetAttributes is failing on Ubuntu with FileNotFoundException even though File.Exists + // returns true. Likely some platform compat issue with that method + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return; } + + Directory.CreateDirectory(LockFileDirectory); + var @lock = new FileDistributedLock(LockFileDirectoryInfo, Guid.NewGuid().ToString()); + File.Create(@lock.Name).Dispose(); + + try + { + File.SetAttributes(@lock.Name, FileAttributes.ReadOnly); + + Assert.Throws(() => @lock.TryAcquire()?.Dispose()); + } + finally + { + File.SetAttributes(@lock.Name, FileAttributes.Normal); + } + } + + private static void AssertCanUseName(string name, DirectoryInfo? directory = null) + { + var @lock = new FileDistributedLock(directory ?? LockFileDirectoryInfo, name); + IDistributedSynchronizationHandle? handle = null; + Assert.DoesNotThrow(() => handle = @lock.TryAcquire(), name); + Assert.That(handle, Is.Not.Null, name); + handle!.Dispose(); + } + + private static bool CanCreateFileWithName(string name) + { + try + { + var path = Path.Combine(LockFileDirectory, name); + File.OpenWrite(path).Dispose(); + File.Delete(path); + return true; + } + catch + { + return false; + } + } +} diff --git a/src/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockWindowsTest.cs b/src/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockWindowsTest.cs new file mode 100644 index 00000000..8910c96c --- /dev/null +++ b/src/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockWindowsTest.cs @@ -0,0 +1,31 @@ +using Medallion.Threading.FileSystem; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.FileSystem; + +public class FileDistributedLockWindowsTest +{ + /// + /// Example of where always ignoring during file creation + /// would be problematic. + /// + [Test] + public void TestThrowsUnauthorizedAccessExceptionInCaseOfFilePermissionViolation() + { + var @lock = new FileDistributedLock(new DirectoryInfo(@"C:\Windows"), Guid.NewGuid().ToString()); + Assert.Throws(() => @lock.TryAcquire()?.Dispose()); + } + + /// + /// Example of where always ignoring during directory creation + /// would be problematic. + /// + [Test] + public void TestThrowsUnauthorizedAccessExceptionInCaseOfDirectoryPermissionViolation() + { + var @lock = new FileDistributedLock(new DirectoryInfo(@"C:\Windows\MedallionDistributedLock"), Guid.NewGuid().ToString()); + var exception = Assert.Throws(() => @lock.TryAcquire()?.Dispose())!; + Assert.That(exception.InnerException, Is.InstanceOf()); + Assert.That(Directory.Exists(Path.GetDirectoryName(@lock.Name)), Is.False); + } +} diff --git a/src/DistributedLock.Tests/Tests/FileSystem/FileDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/FileSystem/FileDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..f6764f48 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/FileSystem/FileDistributedSynchronizationProviderTest.cs @@ -0,0 +1,36 @@ +using Medallion.Threading.FileSystem; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.FileSystem; + +public class FileDistributedSynchronizationProviderTest +{ + private static readonly string LockFileDirectory = Path.Combine(Path.GetTempPath(), nameof(FileDistributedSynchronizationProviderTest), TargetFramework.Current); + private static DirectoryInfo LockFileDirectoryInfo => new(LockFileDirectory); + + [OneTimeSetUp] + public void OneTimeSetUp() + { + if (Directory.Exists(LockFileDirectory)) + { + Directory.Delete(LockFileDirectory, recursive: true); + } + } + + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => new FileDistributedSynchronizationProvider(null!)); + } + + [Test] + public async Task BasicTest() + { + var provider = new FileDistributedSynchronizationProvider(LockFileDirectoryInfo); + await using (await provider.AcquireLockAsync("ProviderBasicTest")) + { + await using var handle = await provider.TryAcquireLockAsync("ProviderBasicTest"); + Assert.That(handle, Is.Null); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/MongoDB/MongoDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/MongoDB/MongoDistributedLockTest.cs new file mode 100644 index 00000000..992b50e1 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/MongoDB/MongoDistributedLockTest.cs @@ -0,0 +1,385 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.MongoDB; +using MongoDB.Driver; +using Moq; +using NUnit.Framework; +using System.Text; + +namespace Medallion.Threading.Tests.MongoDB; + +public class MongoDistributedLockTest +{ + [Test] + public async Task TestBasicLockFunctionality() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + var lockName = TestHelper.UniqueName; + var @lock = new MongoDistributedLock(lockName, database); + await using (var handle = await @lock.AcquireAsync()) + { + Assert.That(handle, Is.Not.Null); + // Use async TryAcquireAsync instead of synchronous IsHeld() + await using var handle2 = await @lock.TryAcquireAsync(TimeSpan.Zero); + Assert.That(handle2, Is.Null, "Lock should be held"); + } + // Verify lock is released + await using (var handle = await @lock.TryAcquireAsync(TimeSpan.FromSeconds(1))) + { + Assert.That(handle, Is.Not.Null, "Lock should be released"); + } + + // Make sure index was created + var collection = database.GetCollection(MongoDistributedLock.DefaultCollectionName); + await TestHelper.WaitForAsync(() => MongoIndexInitializer.CheckIfIndexExists(collection).AsValueTask(), TimeSpan.FromSeconds(15)); + } + + [Test] + public async Task TestCustomCollectionName() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + var lockName = TestHelper.UniqueName; + var customCollectionName = TestHelper.UniqueName + "-locks"; + var @lock = new MongoDistributedLock(lockName, database, customCollectionName); + await using (var handle = await @lock.AcquireAsync()) + { + Assert.That(handle, Is.Not.Null); + } + + // Verify the collection was created + var collectionExists = (await database.ListCollectionNamesAsync()).ToList().Contains(customCollectionName); + Assert.That(collectionExists, Is.True); + + // Make sure index was created + var collection = database.GetCollection(customCollectionName); + await TestHelper.WaitForAsync(() => MongoIndexInitializer.CheckIfIndexExists(collection).AsValueTask(), TimeSpan.FromSeconds(15)); + + // Cleanup + await database.DropCollectionAsync(customCollectionName); + } + + [Test] + public async Task TestLockNameSupport() + { + using var provider = new TestingMongoDistributedLockProvider(); + + var randomBytes = new byte[100_000]; + new Random(12345).NextBytes(randomBytes); + var utf8 = Encoding.GetEncoding( + "utf-8", + new EncoderReplacementFallback("?"), + new DecoderReplacementFallback("?")); + var name = TestHelper.UniqueName + utf8.GetString(randomBytes); + + var @lock = provider.CreateLockWithExactName(name); + await using var handle = await @lock.TryAcquireAsync(); + Assert.IsNotNull(handle); + + @lock = provider.CreateLockWithExactName(TestHelper.UniqueName + new string('z', 16_000_000)); + await using var handle2 = await @lock.TryAcquireAsync(); + Assert.IsNotNull(handle2); + + @lock = provider.CreateLockWithExactName(""); + await using var handle3 = await @lock.TryAcquireAsync(); + Assert.IsNotNull(handle3); + + @lock = provider.CreateLockWithExactName(" "); + await using var handle4 = await @lock.TryAcquireAsync(); + Assert.IsNotNull(handle4); + + @lock = provider.CreateLockWithExactName("\0"); + await using var handle5 = await @lock.TryAcquireAsync(); + Assert.IsNotNull(handle5); + } + + [Test] + public async Task TestHandleLostToken() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + // Configure a short extension cadence so the test doesn't have to wait too long + var @lock = new MongoDistributedLock(TestHelper.UniqueName, database, options: o => o.ExtensionCadence(TimeSpan.FromMilliseconds(500))); + await using var handle = await @lock.AcquireAsync(); + Assert.That(handle, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(handle.HandleLostToken.CanBeCanceled, Is.True); + Assert.That(handle.HandleLostToken.IsCancellationRequested, Is.False); + }); + + // Manually delete the lock document to simulate lock loss + var collection = database.GetCollection(MongoDistributedLock.DefaultCollectionName); + Assert.That((await collection.DeleteOneAsync(Builders.Filter.Eq(d => d.Id, @lock.Key))).DeletedCount, Is.EqualTo(1)); + + // Wait a bit for the extension task to detect the loss + var timeout = Task.Delay(TimeSpan.FromSeconds(4)); + while (!handle.HandleLostToken.IsCancellationRequested && !timeout.IsCompleted) { } + Assert.That(handle.HandleLostToken.IsCancellationRequested, Is.True, "HandleLostToken should be signaled when lock is lost"); + } + + [Test] + public async Task TestLockContentionAsync() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + var lockName = TestHelper.UniqueName; + var lock1 = new MongoDistributedLock(lockName, database); + var lock2 = new MongoDistributedLock(lockName, database); + await using (var handle1 = await lock1.AcquireAsync()) + { + Assert.That(handle1, Is.Not.Null); + await using var handle2 = await lock2.TryAcquireAsync(TimeSpan.FromMilliseconds(100)); + Assert.That(handle2, Is.Null, "Should not acquire lock while held by another instance"); + } + + // After release, lock2 should be able to acquire + await using (var handle2 = await lock2.AcquireAsync(TimeSpan.FromSeconds(5))) + { + Assert.That(handle2, Is.Not.Null); + } + } + + [Test] + [Category("CI")] + public void TestName() + { + const string Name = "\0🐉汉字\b\r\n\\"; + var database = new Mock(MockBehavior.Strict).Object; + var @lock = new MongoDistributedLock(Name, database); + // With safe name conversion, the key should be the same as name since it's within byte limits + @lock.Name.ShouldEqual(@lock.Key); + } + + [Test] + [Category("CI")] + public void TestReturnsUnmodifiedKey() + { + const string Key = "my-exact-key"; + var database = new Mock(MockBehavior.Strict).Object; + var @lock = new MongoDistributedLock(Key, database); + @lock.Key.ShouldEqual(Key); + @lock.Name.ShouldEqual(Key); + } + + [Test] + [Category("CI")] + public void TestValidatesConstructorParameters() + { + var database = new Mock(MockBehavior.Strict).Object; + Assert.Throws(() => new MongoDistributedLock(null!, database)); + Assert.Throws(() => new MongoDistributedLock("key", null!)); + Assert.Throws(() => new MongoDistributedLock("key", database, (string)null!)); + } + + [Test] + [Category("CI")] + public void TestActivitySourceNaming() + { + var assemblyName = typeof(MongoDistributedLock).Assembly.GetName()!; + Assert.That(MongoDistributedLock.ActivitySource.Name, Is.EqualTo(assemblyName.Name)); + Assert.That(MongoDistributedLock.ActivitySource.Version, Is.EqualTo(assemblyName.Version!.ToString(3))); + } + + [Test] + public async Task TestIndexExistence() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + var collectionName = "TestIndex" + Guid.NewGuid().ToString("N"); + + var @lock = new MongoDistributedLock("lock", database, collectionName); + await using (await @lock.AcquireAsync()) + { + } + + var collection = database.GetCollection(collectionName); + await TestHelper.WaitForAsync(async () => + { + using var cursor = await collection.Indexes.ListAsync(); + var indexes = await cursor.ToListAsync(); + var ttlIndex = indexes.FirstOrDefault(i => i["name"] == "expiresAt_ttl"); + if (ttlIndex is null) { return false; } + Assert.That(ttlIndex!["expireAfterSeconds"].AsInt32, Is.EqualTo(0)); // check functionality + return true; + }, TimeSpan.FromSeconds(15)); + } + + [Test] + [Category("CI")] + public async Task TestIndexCreationIsScopedToCluster() + { + // Simulate two distinct databases with SAME name/collection but treated as different + var db1 = new Mock(MockBehavior.Strict); + var db2 = new Mock(MockBehavior.Strict); + + var collection1 = new Mock>(MockBehavior.Strict); + var collection2 = new Mock>(MockBehavior.Strict); + + // We can't easily mock ClusterId equality without deeper mocking, + // We need a unique db/coll name to avoid interference from other tests but we want + // it to be the same otherwise. + var uniqueName = "db_" + Guid.NewGuid().ToString("N"); + + // Setup index creation mocks + var index1 = new Mock>(MockBehavior.Strict); + var index2 = new Mock>(MockBehavior.Strict); + + foreach (var (index, collection, db) in new[] { (index1, collection1, db1), (index2, collection2, db2) }) + { + SetDb(db, collection, uniqueName, "locks"); + + collection.Setup(c => c.Indexes).Returns(index.Object); + + // Expect CreateOneAsync + index.Setup(i => i.CreateOneAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync("idx"); + + var @lock = new MongoDistributedLock("k", db.Object, "locks"); + + // First set it up so that acquire will fail + collection.Setup(c => c.FindOneAndUpdateAsync(It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((MongoLockDocument)null!); + await using var handle = await @lock.TryAcquireAsync(); + Assert.IsNull(handle); + index.Verify(i => i.CreateOneAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never, "Failed acquire does not trigger index creation"); + + // Allow FindOneAndUpdate + collection.Setup(c => c.FindOneAndUpdateAsync(It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((FilterDefinition filter, UpdateDefinition update, FindOneAndUpdateOptions findAndUpdate, CancellationToken _) => + { + // this is reversing the construction in MongoDistributedLock.CreateAcquireUpdate() + var pipeline = (BsonDocumentStagePipelineDefinition)((PipelineUpdateDefinition)update).Pipeline; + var lockId = pipeline.Documents[0]["$set"]["lockId"]["$cond"][1].AsString; + return new MongoLockDocument { Id = Guid.NewGuid().ToString(), LockId = lockId }; + }); + collection.Setup(c => c.DeleteOneAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new DeleteResult.Acknowledged(1)); + + await (await @lock.AcquireAsync()).DisposeAsync(); + } + + // We want to verify ConfigureIndexes is called on BOTH. + index1.Verify(i => i.CreateOneAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once, "First DB should create index"); + index2.Verify(i => i.CreateOneAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once, "Second DB should create index too because it's a different instance"); + } + + private static void SetDb(Mock db, Mock> coll, string dbName, string collName) + { + db.Setup(d => d.GetCollection(collName, null)).Returns(coll.Object); + var dbNs = new DatabaseNamespace(dbName); + var collNs = new CollectionNamespace(dbNs, collName); + + coll.Setup(c => c.CollectionNamespace).Returns(collNs); + coll.Setup(c => c.Database).Returns(db.Object); + db.Setup(d => d.DatabaseNamespace).Returns(dbNs); + + // Mock Client and Settings + var client = new Mock(MockBehavior.Strict); + // Ensure settings are distinct by adding a random server address + var settings = new MongoClientSettings { Servers = [new("host" + Guid.NewGuid().ToString("N"))] }; + client.Setup(c => c.Settings).Returns(settings); + db.Setup(d => d.Client).Returns(client.Object); + } + + [Test] + [Category("CI")] + public async Task TestIndexCreationFailureIsCached() + { + var db = new Mock(MockBehavior.Strict); + var collection = new Mock>(MockBehavior.Strict); + SetDb(db, collection, "db_" + Guid.NewGuid().ToString("N"), "locks"); + + var index = new Mock>(MockBehavior.Strict); + collection.Setup(c => c.Indexes).Returns(index.Object); + + // Fail first time + index.SetupSequence(i => i.CreateOneAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new MongoException("Test failure")) + .ReturnsAsync("idx"); + + // Allow FindOneAndUpdate + collection.Setup(c => c.FindOneAndUpdateAsync(It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((FilterDefinition filter, UpdateDefinition update, FindOneAndUpdateOptions findAndUpdate, CancellationToken _) => + { + // this is reversing the construction in MongoDistributedLock.CreateAcquireUpdate() + var pipeline = (BsonDocumentStagePipelineDefinition)((PipelineUpdateDefinition)update).Pipeline; + var lockId = pipeline.Documents[0]["$set"]["lockId"]["$cond"][1].AsString; + return new MongoLockDocument { Id = Guid.NewGuid().ToString(), LockId = lockId }; + }); + collection.Setup(c => c.DeleteOneAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new DeleteResult.Acknowledged(1)); + + var @lock = new MongoDistributedLock("k", db.Object, "locks"); + + // First acquire: fails to create index (swallowed), acquires lock + await (await @lock.AcquireAsync()).DisposeAsync(); + + // Second acquire: caches the failed task, so it won't retry. + await (await @lock.AcquireAsync()).DisposeAsync(); + + // Verify CreateOneAsync was called ONCE (proving caching). + index.Verify(i => i.CreateOneAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once(), "Should retry index creation after failure"); + } + + [Test, Category("CI")] + public async Task TestFailedIndexCreationEventuallyRetries() + { + var db = new Mock(MockBehavior.Strict); + var collection = new Mock>(MockBehavior.Strict); + SetDb(db, collection, "db_" + Guid.NewGuid().ToString("N"), "locks"); + + var index = new Mock>(MockBehavior.Strict); + collection.Setup(c => c.Indexes).Returns(index.Object); + + // Fail first time + index.SetupSequence(i => i.CreateOneAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new MongoException("Test failure")) + .ReturnsAsync("idx"); + + // Allow FindOneAndUpdate + collection.Setup(c => c.FindOneAndUpdateAsync(It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((FilterDefinition filter, UpdateDefinition update, FindOneAndUpdateOptions findAndUpdate, CancellationToken _) => + { + // this is reversing the construction in MongoDistributedLock.CreateAcquireUpdate() + var pipeline = (BsonDocumentStagePipelineDefinition)((PipelineUpdateDefinition)update).Pipeline; + var lockId = pipeline.Documents[0]["$set"]["lockId"]["$cond"][1].AsString; + return new MongoLockDocument { Id = Guid.NewGuid().ToString(), LockId = lockId }; + }); + + Mock initializer = new(); + // set cache time to 0 + initializer.Setup(i => i.DelayBeforeRetry()).Returns(Task.CompletedTask); + + // First acquire: fails to create index (swallowed), acquires lock + await initializer.Object.InitializeTtlIndex(collection.Object); + + // Second acquire: should retry index creation if we fix it. + // Currently it caches the failed task, so it won't retry. + await initializer.Object.InitializeTtlIndex(collection.Object); + + // Verify CreateOneAsync was called TWICE (proving retry). + index.Verify(i => i.CreateOneAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Exactly(2), "Should retry index creation after failure"); + } + + [Test] + public async Task TestLockDocumentStructure() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + var lockName = TestHelper.UniqueName; + var collectionName = "locks_" + Guid.NewGuid().ToString("N"); + + var @lock = new MongoDistributedLock(lockName, database, collectionName, o => o.Expiry(TimeSpan.FromSeconds(10))); + await using var handle = await @lock.AcquireAsync(); + var collection = database.GetCollection(collectionName); + var doc = await collection.Find(Builders.Filter.Eq(d => d.Id, lockName)).FirstOrDefaultAsync(); + + Assert.That(doc, Is.Not.Null); + Assert.That(doc.LockId, Is.Not.Null); + Assert.That(doc.FencingToken, Is.GreaterThan(0)); + + // Allow for some clock skew/processing time. + // Mongo and BsonDateTime usually assume UTC; check implicit assumption. + + // Depending on Mongo version and driver, dates are UTC. + // The lock sets expiresAt = $$NOW + 10s. + // Check that it is roughly in the future. + Assert.That(doc.ExpiresAt.ToUniversalTime(), Is.GreaterThan(DateTime.UtcNow.AddSeconds(5))); + Assert.That(doc.ExpiresAt.ToUniversalTime(), Is.LessThan(DateTime.UtcNow.AddSeconds(15))); + } +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Tests/MongoDB/MongoDistributedSynchronizationOptionsBuilderTest.cs b/src/DistributedLock.Tests/Tests/MongoDB/MongoDistributedSynchronizationOptionsBuilderTest.cs new file mode 100644 index 00000000..cbdf4cc6 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/MongoDB/MongoDistributedSynchronizationOptionsBuilderTest.cs @@ -0,0 +1,63 @@ +using Medallion.Threading.MongoDB; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.MongoDB; + +public class MongoDistributedSynchronizationOptionsBuilderTest +{ + [Test] + public void TestBusyWaitSleepTimeValidation() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + Assert.Throws(() => + new MongoDistributedLock("test", database, options => options + .BusyWaitSleepTime(TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(500)))); + Assert.Throws(() => + new MongoDistributedLock("test", database, options => options + .BusyWaitSleepTime(Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(1)))); + Assert.DoesNotThrow(() => + new MongoDistributedLock("test", database, options => options + .BusyWaitSleepTime(TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(1)))); + } + + [Test] + public void TestExpiryValidation() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + Assert.Throws(() => + new MongoDistributedLock("test", database, options => options.Expiry(TimeSpan.FromMilliseconds(50)))); + Assert.Throws(() => + new MongoDistributedLock("test", database, options => options.Expiry(Timeout.InfiniteTimeSpan))); + Assert.DoesNotThrow(() => + new MongoDistributedLock("test", database, options => options.Expiry(TimeSpan.FromSeconds(1)))); + } + + [Test] + public void TestExtensionCadenceValidation() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + Assert.Throws(() => + new MongoDistributedLock("test", database, options => options + .Expiry(TimeSpan.FromSeconds(5)) + .ExtensionCadence(TimeSpan.FromSeconds(10)))); + Assert.DoesNotThrow(() => + new MongoDistributedLock("test", database, options => options + .Expiry(TimeSpan.FromSeconds(10)) + .ExtensionCadence(TimeSpan.FromSeconds(3)))); + } + + [Test] + public async Task TestOptionsAreApplied() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + var lockName = TestHelper.UniqueName; + var @lock = new MongoDistributedLock(lockName, database, options => options + .Expiry(TimeSpan.FromSeconds(60)) + .ExtensionCadence(TimeSpan.FromSeconds(20)) + .BusyWaitSleepTime(TimeSpan.FromMilliseconds(5), TimeSpan.FromMilliseconds(100))); + await using (var handle = await @lock.AcquireAsync()) + { + Assert.That(handle, Is.Not.Null); + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Tests/MongoDB/MongoDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/MongoDB/MongoDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..efd865ab --- /dev/null +++ b/src/DistributedLock.Tests/Tests/MongoDB/MongoDistributedSynchronizationProviderTest.cs @@ -0,0 +1,70 @@ +using Medallion.Threading.MongoDB; +using MongoDB.Driver; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.MongoDB; + +public class MongoDistributedSynchronizationProviderTest +{ + [Test] + public void TestArgumentValidation() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + Assert.Throws(() => new MongoDistributedSynchronizationProvider(null!)); + Assert.Throws(() => new MongoDistributedSynchronizationProvider(database, (string)null!)); + Assert.DoesNotThrow(() => new MongoDistributedSynchronizationProvider(database)); + Assert.DoesNotThrow(() => new MongoDistributedSynchronizationProvider(database, "CustomCollection")); + } + + [Test] + public void TestIDistributedLockProviderInterface() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + IDistributedLockProvider provider = new MongoDistributedSynchronizationProvider(database); + var @lock = provider.CreateLock("interfaceTest"); + Assert.That(@lock, Is.Not.Null); + Assert.That(@lock, Is.InstanceOf()); + Assert.That(@lock.Name, Is.EqualTo("interfaceTest")); + } + + [Test] + public async Task TestProviderCreateLock() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + var provider = new MongoDistributedSynchronizationProvider(database); + var lock1 = provider.CreateLock("testLock1"); + var lock2 = provider.CreateLock("testLock2"); + Assert.That(lock1, Is.Not.Null); + Assert.That(lock2, Is.Not.Null); + Assert.That(lock1.Name, Is.EqualTo("testLock1")); + Assert.That(lock2.Name, Is.EqualTo("testLock2")); + + // Test that locks work + await using (var handle1 = await lock1.AcquireAsync()) + await using (var handle2 = await lock2.AcquireAsync()) + { + Assert.That(handle1, Is.Not.Null); + Assert.That(handle2, Is.Not.Null); + } + } + + [Test] + public async Task TestProviderWithCustomCollection() + { + var database = MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory); + const string CustomCollection = "TestProviderLocks"; + var provider = new MongoDistributedSynchronizationProvider(database, CustomCollection); + var @lock = provider.CreateLock("testLock"); + await using (var handle = await @lock.AcquireAsync()) + { + Assert.That(handle, Is.Not.Null); + } + + // Verify the custom collection was used + var collectionExists = (await database.ListCollectionNamesAsync()).ToList().Contains(CustomCollection); + Assert.That(collectionExists, Is.True); + + // Cleanup + await database.DropCollectionAsync(CustomCollection); + } +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Tests/MySql/MySqlConnectionOptionsBuilderTest.cs b/src/DistributedLock.Tests/Tests/MySql/MySqlConnectionOptionsBuilderTest.cs new file mode 100644 index 00000000..da0e3a45 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/MySql/MySqlConnectionOptionsBuilderTest.cs @@ -0,0 +1,25 @@ +using Medallion.Threading.MySql; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.MySql; + +[Category("CI")] +public class MySqlConnectionOptionsBuilderTest +{ + [Test] + public void TestValidatesArguments() + { + var builder = new MySqlConnectionOptionsBuilder(); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.FromMilliseconds(-2))); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.MaxValue)); + } + + [Test] + public void TestDefaults() + { + var options = MySqlConnectionOptionsBuilder.GetOptions(null); + options.keepaliveCadence.ShouldEqual(TimeSpan.FromHours(3.5)); + Assert.That(options.useMultiplexing, Is.True); + options.ShouldEqual(MySqlConnectionOptionsBuilder.GetOptions(o => { })); + } +} diff --git a/src/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs new file mode 100644 index 00000000..a706f1f6 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs @@ -0,0 +1,72 @@ +using Medallion.Threading.MySql; +using Medallion.Threading.Tests.Data; +using MySqlConnector; +using NUnit.Framework; +using System.Data; + +namespace Medallion.Threading.Tests.MySql; + +public class MySqlDistributedLockTest +{ + private static readonly string ConnectionString = new TestingMySqlDb().ConnectionStringBuilder.ConnectionString; + + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Catch(() => new MySqlDistributedLock(null!, ConnectionString)); + Assert.Catch(() => new MySqlDistributedLock(null!, ConnectionString, exactName: true)); + Assert.Catch(() => new MySqlDistributedLock("a", default(string)!)); + Assert.Catch(() => new MySqlDistributedLock("a", default(IDbTransaction)!)); + Assert.Catch(() => new MySqlDistributedLock("a", default(IDbConnection)!)); + Assert.Catch(() => new MySqlDistributedLock(new string('a', MySqlDistributedLock.MaxNameLength + 1), ConnectionString, exactName: true)); + Assert.DoesNotThrow(() => new MySqlDistributedLock(new string('a', MySqlDistributedLock.MaxNameLength), ConnectionString, exactName: true)); + } + + [Test] + public void TestGetSafeLockNameCompat() + { + GetSafeName(string.Empty).ShouldEqual("__empty__p6ad62yppho33ytkibum5wbqhqvbcsxa"); + GetSafeName("abc").ShouldEqual("abc"); + GetSafeName("ABC").ShouldEqual("abczj4qr6tvn4a3kmgq4bukhowqyfrlxsb3"); + GetSafeName("\\").ShouldEqual("\\"); + GetSafeName(new string('a', MySqlDistributedLock.MaxNameLength)).ShouldEqual(new string('a', MySqlDistributedLock.MaxNameLength)); + GetSafeName(new string('\\', MySqlDistributedLock.MaxNameLength)).ShouldEqual(@"\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"); + GetSafeName(new string('x', MySqlDistributedLock.MaxNameLength + 1)).ShouldEqual("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxgkd2zq6c6ey6mhs45clqg7vij6ycgo43"); + + static string GetSafeName(string name) => new MySqlDistributedLock(name, ConnectionString).Name; + } + + /// + /// This test justifies why we have constructors for MySQL locks that take in a . + /// Otherwise, you can't have a lock use the same connection as a transaction you're working on. Compare to + /// + /// + [TestCase(typeof(TestingMySqlDb))] + [TestCase(typeof(TestingMariaDbDb))] + public async Task TestMySqlCommandMustExplicitlyParticipateInTransaction(Type testingDbType) + { + var db = (TestingDb)Activator.CreateInstance(testingDbType)!; + + using var connection = new MySqlConnection(db.ConnectionStringBuilder.ConnectionString); + await connection.OpenAsync(); + + using var createTableCommand = connection.CreateCommand(); + createTableCommand.CommandText = "CREATE TEMPORARY TABLE distributed_lock.temp (id INT)"; + await createTableCommand.ExecuteNonQueryAsync(); + + using var transaction = connection.BeginTransaction(); + + using var commandInTransaction = connection.CreateCommand(); + commandInTransaction.Transaction = transaction; + commandInTransaction.CommandText = @"INSERT INTO distributed_lock.temp (id) VALUES (1), (2)"; + await commandInTransaction.ExecuteNonQueryAsync(); + + using var commandOutsideTransaction = connection.CreateCommand(); + commandOutsideTransaction.CommandText = "SELECT COUNT(*) FROM distributed_lock.temp"; + var exception = Assert.ThrowsAsync(commandOutsideTransaction.ExecuteScalarAsync)!; + Assert.That(exception.Message, Does.Contain("The transaction associated with this command is not the connection's active transaction")); + + commandInTransaction.CommandText = "SELECT COUNT(*) FROM distributed_lock.temp"; + (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual(2); + } +} diff --git a/src/DistributedLock.Tests/Tests/MySql/MySqlDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/MySql/MySqlDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..ae918f57 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/MySql/MySqlDistributedSynchronizationProviderTest.cs @@ -0,0 +1,31 @@ +using Medallion.Threading.MySql; +using Medallion.Threading.Tests.Data; +using NUnit.Framework; +using System.Data; + +namespace Medallion.Threading.Tests.MySql; + +public class MySqlDistributedSynchronizationProviderTest +{ + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => new MySqlDistributedSynchronizationProvider(default(string)!)); + Assert.Throws(() => new MySqlDistributedSynchronizationProvider(default(IDbConnection)!)); + Assert.Throws(() => new MySqlDistributedSynchronizationProvider(default(IDbTransaction)!)); + } + + [Test] + public async Task BasicTest([Values(typeof(TestingMySqlDb), typeof(TestingMariaDbDb))] Type dbType) + { + var db = (TestingDb)Activator.CreateInstance(dbType)!; + var provider = new MySqlDistributedSynchronizationProvider(db.ConnectionString); + + const string LockName = TargetFramework.Current + "ProviderBasicTest"; + await using (await provider.AcquireLockAsync(LockName)) + { + await using var handle = await provider.TryAcquireLockAsync(LockName); + Assert.That(handle, Is.Null, db.GetType().Name); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/Oracle/OracleBehaviorTest.cs b/src/DistributedLock.Tests/Tests/Oracle/OracleBehaviorTest.cs new file mode 100644 index 00000000..f56cc2d3 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Oracle/OracleBehaviorTest.cs @@ -0,0 +1,27 @@ +using NUnit.Framework; +using Oracle.ManagedDataAccess.Client; + +namespace Medallion.Threading.Tests.Oracle; + +public class OracleBehaviorTest +{ + [Test] + public async Task BasicConnectivityTest() + { + using var connection = new OracleConnection(OracleCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory)); + await connection.OpenAsync(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 10 FROM DUAL"; + (await command.ExecuteScalarAsync()).ShouldEqual(10); + } + + [Test] + public async Task TestCommandImplicitlyParticipatesInTransaction() + { + using var connection = new OracleConnection(OracleCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory)); + await connection.OpenAsync(); + using var transaction = connection.BeginTransaction(); + using var command = connection.CreateCommand(); + command.Transaction.ShouldEqual(transaction); + } +} diff --git a/src/DistributedLock.Tests/Tests/Oracle/OracleConnectionOptionsBuilderTest.cs b/src/DistributedLock.Tests/Tests/Oracle/OracleConnectionOptionsBuilderTest.cs new file mode 100644 index 00000000..efb19d99 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Oracle/OracleConnectionOptionsBuilderTest.cs @@ -0,0 +1,25 @@ +using Medallion.Threading.Oracle; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Oracle; + +[Category("CI")] +public class OracleConnectionOptionsBuilderTest +{ + [Test] + public void TestValidatesArguments() + { + var builder = new OracleConnectionOptionsBuilder(); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.FromMilliseconds(-2))); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.MaxValue)); + } + + [Test] + public void TestDefaults() + { + var options = OracleConnectionOptionsBuilder.GetOptions(null); + options.keepaliveCadence.ShouldEqual(Timeout.InfiniteTimeSpan); + Assert.That(options.useMultiplexing, Is.True); + options.ShouldEqual(OracleConnectionOptionsBuilder.GetOptions(o => { })); + } +} diff --git a/src/DistributedLock.Tests/Tests/Oracle/OracleDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/Oracle/OracleDistributedLockTest.cs new file mode 100644 index 00000000..ce8eb5e0 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Oracle/OracleDistributedLockTest.cs @@ -0,0 +1,33 @@ +using Medallion.Threading.Oracle; +using NUnit.Framework; +using System.Data; + +namespace Medallion.Threading.Tests.Oracle; + +public class OracleDistributedLockTest +{ + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Catch(() => new OracleDistributedLock(null!, TestingOracleDb.DefaultConnectionString)); + Assert.Catch(() => new OracleDistributedLock(null!, TestingOracleDb.DefaultConnectionString, exactName: true)); + Assert.Catch(() => new OracleDistributedLock("a", default(string)!)); + Assert.Catch(() => new OracleDistributedLock("a", default(IDbConnection)!)); + Assert.Catch(() => new OracleDistributedLock(new string('a', OracleDistributedLock.MaxNameLength + 1), TestingOracleDb.DefaultConnectionString, exactName: true)); + Assert.DoesNotThrow(() => new OracleDistributedLock(new string('a', OracleDistributedLock.MaxNameLength), TestingOracleDb.DefaultConnectionString, exactName: true)); + } + + [Test] + public void TestGetSafeLockNameCompat() + { + GetSafeName(string.Empty).ShouldEqual("EMPTYz4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg=="); + GetSafeName("abc").ShouldEqual("abc"); + GetSafeName("ABC").ShouldEqual("ABC"); + GetSafeName("\\").ShouldEqual("\\"); + GetSafeName(new string('a', OracleDistributedLock.MaxNameLength)).ShouldEqual(new string('a', OracleDistributedLock.MaxNameLength)); + GetSafeName(new string('\\', OracleDistributedLock.MaxNameLength)).ShouldEqual(new string('\\', OracleDistributedLock.MaxNameLength)); + GetSafeName(new string('x', OracleDistributedLock.MaxNameLength + 1)).ShouldEqual("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxGQFUg+qZ+nRyj9exOtumtynPpKt8OIVz76JkHSrwV38k3VGsuu7EGnoR0Q9sTmijuQ57I0jGeEhqQ2XJ2RAc3Q=="); + + static string GetSafeName(string name) => new OracleDistributedLock(name, TestingOracleDb.DefaultConnectionString).Name; + } +} diff --git a/src/DistributedLock.Tests/Tests/Oracle/OracleDistributedReaderWriterLockTest.cs b/src/DistributedLock.Tests/Tests/Oracle/OracleDistributedReaderWriterLockTest.cs new file mode 100644 index 00000000..3f036db3 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Oracle/OracleDistributedReaderWriterLockTest.cs @@ -0,0 +1,40 @@ +using Medallion.Threading.Oracle; +using NUnit.Framework; +using System.Data; + +namespace Medallion.Threading.Tests.Oracle; + +public class OracleDistributedReaderWriterLockTest +{ + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Catch(() => new OracleDistributedReaderWriterLock(null!, TestingOracleDb.DefaultConnectionString)); + Assert.Catch(() => new OracleDistributedReaderWriterLock(null!, TestingOracleDb.DefaultConnectionString, exactName: true)); + Assert.Catch(() => new OracleDistributedReaderWriterLock("a", default(string)!)); + Assert.Catch(() => new OracleDistributedReaderWriterLock("a", default(IDbConnection)!)); + Assert.Catch(() => new OracleDistributedReaderWriterLock(new string('a', OracleDistributedLock.MaxNameLength + 1), TestingOracleDb.DefaultConnectionString, exactName: true)); + Assert.DoesNotThrow(() => new OracleDistributedReaderWriterLock(new string('a', OracleDistributedLock.MaxNameLength), TestingOracleDb.DefaultConnectionString, exactName: true)); + } + + [Test] + public void TestGetSafeLockNameCompat() + { + var cases = new[] + { + string.Empty, + "abc", + "\\", + new string('a', OracleDistributedLock.MaxNameLength), + new string('\\', OracleDistributedLock.MaxNameLength), + new string('x', OracleDistributedLock.MaxNameLength + 1) + }; + + foreach (var lockName in cases) + { + // should be compatible with OracleDistributedLock + new OracleDistributedReaderWriterLock(lockName, TestingOracleDb.DefaultConnectionString).Name + .ShouldEqual(new OracleDistributedLock(lockName, TestingOracleDb.DefaultConnectionString).Name); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/Oracle/OracleDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/Oracle/OracleDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..9d93271c --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Oracle/OracleDistributedSynchronizationProviderTest.cs @@ -0,0 +1,44 @@ +using Medallion.Threading.Oracle; +using NUnit.Framework; +using System.Data; + +namespace Medallion.Threading.Tests.Oracle; + +public class OracleDistributedSynchronizationProviderTest +{ + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => new OracleDistributedSynchronizationProvider(default(string)!)); + Assert.Throws(() => new OracleDistributedSynchronizationProvider(default(IDbConnection)!)); + } + + [Test] + public async Task BasicTest() + { + var provider = new OracleDistributedSynchronizationProvider(TestingOracleDb.DefaultConnectionString); + + const string LockName = TargetFramework.Current + "ProviderBasicTest"; + + await using (await provider.AcquireLockAsync(LockName)) + { + await using var handle = await provider.TryAcquireLockAsync(LockName); + Assert.That(handle, Is.Null); + } + + await using (await provider.AcquireReadLockAsync(LockName)) + { + await using var readHandle = await provider.TryAcquireReadLockAsync(LockName); + Assert.That(readHandle, Is.Not.Null); + + await using (var upgradeHandle = await provider.TryAcquireUpgradeableReadLockAsync(LockName)) + { + Assert.That(upgradeHandle, Is.Not.Null); + Assert.That(await upgradeHandle!.TryUpgradeToWriteLockAsync(), Is.False); + } + + await using var writeHandle = await provider.TryAcquireWriteLockAsync(LockName); + Assert.That(writeHandle, Is.Null); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/Postgres/PostgresAdvisoryLockKeyTest.cs b/src/DistributedLock.Tests/Tests/Postgres/PostgresAdvisoryLockKeyTest.cs new file mode 100644 index 00000000..dddc63e1 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Postgres/PostgresAdvisoryLockKeyTest.cs @@ -0,0 +1,149 @@ +using Medallion.Threading.Postgres; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Postgres; + +public class PostgresAdvisoryLockKeyTest +{ + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => new PostgresAdvisoryLockKey(null!)); + Assert.Throws(() => new PostgresAdvisoryLockKey(new string('A', PostgresAdvisoryLockKey.MaxAsciiLength + 1))); + Assert.Throws(() => new PostgresAdvisoryLockKey("漢字")); + } + + [Test] + public void TestDefault() + { + Assert.That(default(PostgresAdvisoryLockKey).ToString(), Is.EqualTo(new string('0', 16))); + Assert.That(default(PostgresAdvisoryLockKey).HasSingleKey, Is.True); + Assert.That(default(PostgresAdvisoryLockKey).Key, Is.EqualTo(0)); + AssertEquality(new PostgresAdvisoryLockKey(0), default); + } + + [Test] + public void TestAscii() + { + var emptyKey = AssertRoundTrips(string.Empty); + Assert.That(emptyKey.HasSingleKey, Is.False); + + var keys = new HashSet<(int, int)> { emptyKey.Keys }; + for (var i = (char)1; i < 128; ++i) + { + for (var j = 1; j <= PostgresAdvisoryLockKey.MaxAsciiLength; ++j) + { + var key = AssertRoundTrips(new string(i, j)); + Assert.That(key.HasSingleKey, Is.False); + Assert.That(keys.Add(key.Keys), Is.True); + } + } + } + + [Test] + public void TestInt64Construction() + { + var key = new PostgresAdvisoryLockKey(1); + Assert.That(key.HasSingleKey, Is.True); + Assert.That(key.Key, Is.EqualTo(1L)); + Assert.That(key.ToString(), Is.EqualTo("0000000000000001")); + AssertEquality(key, new PostgresAdvisoryLockKey(key.ToString())); + } + + [Test] + public void TestInt32PairConstruction() + { + var key = new PostgresAdvisoryLockKey(3, -1); + Assert.That(key.HasSingleKey, Is.False); + Assert.That(key.Keys, Is.EqualTo((3, -1))); + Assert.That(key.ToString(), Is.EqualTo("00000003,ffffffff")); + AssertEquality(key, new PostgresAdvisoryLockKey(key.ToString())); + } + + [Test] + public void TestNameHashing() + { + var key = new PostgresAdvisoryLockKey(new string('漢', 2 * PostgresAdvisoryLockKey.MaxAsciiLength), allowHashing: true); + Assert.That(key.HasSingleKey, Is.True); + Assert.That(key.Key, Is.EqualTo(-5707277204051710361)); + AssertEquality(key, new PostgresAdvisoryLockKey(key.ToString())); + } + + [Test] + public void TestEquality() + { + AssertEquality(new PostgresAdvisoryLockKey(long.MinValue), new PostgresAdvisoryLockKey(long.MinValue)); + AssertInequality(new PostgresAdvisoryLockKey(long.MinValue), new PostgresAdvisoryLockKey(long.MinValue + 1)); + + AssertEquality(new PostgresAdvisoryLockKey(int.MinValue, int.MaxValue), new PostgresAdvisoryLockKey(int.MinValue, int.MaxValue)); + AssertInequality(new PostgresAdvisoryLockKey(int.MinValue, int.MaxValue), new PostgresAdvisoryLockKey(int.MinValue, int.MaxValue - 1)); + + AssertEquality(new PostgresAdvisoryLockKey("base38"), new PostgresAdvisoryLockKey("base38")); + AssertInequality(new PostgresAdvisoryLockKey("base38"), new PostgresAdvisoryLockKey("base37")); + + AssertEquality(new PostgresAdvisoryLockKey("ASCII"), new PostgresAdvisoryLockKey("ASCII")); + AssertInequality(new PostgresAdvisoryLockKey("ASCII"), new PostgresAdvisoryLockKey("ASCIi")); + + AssertInequality(new PostgresAdvisoryLockKey(string.Empty), new PostgresAdvisoryLockKey("\0")); + AssertInequality(new PostgresAdvisoryLockKey("\0"), new PostgresAdvisoryLockKey("a")); + + AssertEquality(new PostgresAdvisoryLockKey("some very long name", allowHashing: true), new PostgresAdvisoryLockKey("some very long name", allowHashing: true)); + AssertInequality(new PostgresAdvisoryLockKey("some very long name", allowHashing: true), new PostgresAdvisoryLockKey("same very long name", allowHashing: true)); + + var names = new[] { "base38", "base37", "ASCII", "ASCIi", "some very long name", "same very long name" }; + foreach (var name1 in names) + foreach (var name2 in names.Where(n => n != name1)) + { + AssertInequality(new PostgresAdvisoryLockKey(name1, allowHashing: true), new PostgresAdvisoryLockKey(name2, allowHashing: true)); + } + + AssertEquality(new PostgresAdvisoryLockKey(new string('0', 16)), new PostgresAdvisoryLockKey(0)); + AssertEquality(new PostgresAdvisoryLockKey("00000000,00000000"), new PostgresAdvisoryLockKey(0, 0)); + AssertEquality(new PostgresAdvisoryLockKey(new string('\0', PostgresAdvisoryLockKey.MaxAsciiLength)), new PostgresAdvisoryLockKey(0, 0)); + AssertInequality(new PostgresAdvisoryLockKey(0), new PostgresAdvisoryLockKey(0, 0)); + } + + private static void AssertInequality(PostgresAdvisoryLockKey a, PostgresAdvisoryLockKey b) + { + Assert.That(b, Is.Not.EqualTo(a)); + Assert.That(a == b, Is.False); + Assert.That(a != b, Is.True); + Assert.That(b.GetHashCode(), Is.Not.EqualTo(a.GetHashCode())); + if (a.HasSingleKey && b.HasSingleKey) + { + Assert.That(b.Key, Is.Not.EqualTo(a.Key)); + } + else if (!a.HasSingleKey && !b.HasSingleKey) + { + Assert.That(b.Keys, Is.Not.EqualTo(a.Keys)); + } + } + + private static void AssertEquality(PostgresAdvisoryLockKey a, PostgresAdvisoryLockKey b) + { + Assert.That(b, Is.EqualTo(a)); + Assert.That(a == b, Is.True); + Assert.That(a != b, Is.False); + Assert.That(b.GetHashCode(), Is.EqualTo(a.GetHashCode())); + if (a.HasSingleKey) + { + Assert.That(b.Key, Is.EqualTo(a.Key)); + } + else + { + Assert.That(b.Keys, Is.EqualTo(a.Keys)); + } + } + + private static PostgresAdvisoryLockKey AssertRoundTrips(string name) + { + var key1 = new PostgresAdvisoryLockKey(name); + var key2 = new PostgresAdvisoryLockKey(key1.ToString()); + var key3 = new PostgresAdvisoryLockKey(name, allowHashing: true); + AssertEquality(key1, key2); + AssertEquality(key1, key3); + Assert.That(key2.ToString(), Is.EqualTo(key1.ToString())); + Assert.That(key3.ToString(), Is.EqualTo(key1.ToString())); + return key1; + } +} diff --git a/src/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs b/src/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs new file mode 100644 index 00000000..74ff8e6b --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs @@ -0,0 +1,170 @@ +using Npgsql; +using NUnit.Framework; +using System.Data; + +namespace Medallion.Threading.Tests.Postgres; + +/// +/// This class contains tests which demonstrate specific Postgres/Npgsql behaviors which our implementations +/// rely on or account for. These should be tested through the normal set of test cases, but having this here +/// is convenient as a demonstration / documentation +/// +public class PostgresBehaviorTest +{ + /// + /// This test justifies why we do not need to have Postgres locks that take in a . + /// Compare this behavior to + /// + [Test] + public async Task TestPostgresCommandAutomaticallyParticipatesInTransaction() + { + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + + using var transaction = +#if NETCOREAPP + await connection.BeginTransactionAsync(); +#elif NETFRAMEWORK + connection.BeginTransaction(); +#endif + + using var commandInTransaction = connection.CreateCommand(); + commandInTransaction.Transaction = transaction; + commandInTransaction.CommandText = @"SHOW statement_timeout; CREATE TABLE foo (id INT); SET LOCAL statement_timeout = 2020;"; + (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual("0"); + + using var commandOutsideTransaction = connection.CreateCommand(); + Assert.That(commandOutsideTransaction.Transaction, Is.Null); + commandOutsideTransaction.CommandText = "SELECT COUNT(*) FROM foo"; + (await commandOutsideTransaction.ExecuteScalarAsync()).ShouldEqual(0); + + commandOutsideTransaction.CommandText = "SHOW statement_timeout"; + (await commandOutsideTransaction.ExecuteScalarAsync()).ShouldEqual("2020ms"); + + commandInTransaction.CommandText = "SELECT COUNT(*) FROM foo"; + (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual(0); + + commandInTransaction.CommandText = "SHOW statement_timeout"; + (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual("2020ms"); + } + + [Test] + public Task TestTransactionCancellationRecovery() => + this.TestTransactionCancellationOrTimeoutRecovery(useTimeout: false); + + [Test] + public Task TestTransactionTimeoutRecovery() => + this.TestTransactionCancellationOrTimeoutRecovery(useTimeout: true); + + /// + /// Demonstrates how we can leverage save points to recover from otherwise destroyed transactions + /// + private async Task TestTransactionCancellationOrTimeoutRecovery(bool useTimeout) + { + Assert.ThrowsAsync(() => RunTransactionWithAbortAsync(useSavePoint: false)); + await RunTransactionWithAbortAsync(useSavePoint: true); + + async Task RunTransactionWithAbortAsync(bool useSavePoint) + { + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + + using (connection.BeginTransaction()) + { + var command = connection.CreateCommand(); + + if (useSavePoint) + { + command.CommandText = "SAVEPOINT cancellationRecovery"; + await command.ExecuteNonQueryAsync(); + } + + command.CommandText = "SELECT pg_sleep(10)"; + using var cancellationTokenSource = new CancellationTokenSource(); + if (useTimeout) { command.CommandText = "SET LOCAL statement_timeout = 100; " + command.CommandText; } + else { cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(.5)); } + + var exception = Assert.CatchAsync(() => command.ExecuteNonQueryAsync(cancellationTokenSource.Token)); + Assert.That(exception, Is.InstanceOf(useTimeout ? typeof(PostgresException) : typeof(OperationCanceledException))); + + if (useSavePoint) + { + command.CommandText = "ROLLBACK TO SAVEPOINT cancellationRecovery"; + await command.ExecuteNonQueryAsync(); + } + + command.CommandText = "SHOW statement_timeout"; + (await command.ExecuteScalarAsync()).ShouldEqual("0"); + } + } + } + + [Test] + public async Task TestCanDetectTransactionWithBeginTransactionException() + { + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + + Assert.DoesNotThrow(() => connection.BeginTransaction().Dispose()); + + using var transaction = connection.BeginTransaction(); + + var ex = Assert.Throws(() => connection.BeginTransaction().Dispose())!; + Assert.That(ex.Message, Does.Contain("A transaction is already in progress")); + } + + [Test] + public async Task TestDoesNotDetectConnectionBreakViaState() + { + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + + using var getPidCommand = connection.CreateCommand(); + getPidCommand.CommandText = "SELECT pg_backend_pid()"; + var pid = (int)(await getPidCommand.ExecuteScalarAsync())!; + + var stateChangedEvent = new ManualResetEventSlim(initialState: false); + connection.StateChange += (_, _2) => stateChangedEvent.Set(); + + // kill the connection from the back end + using var killingConnection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await killingConnection.OpenAsync(); + using var killCommand = killingConnection.CreateCommand(); + killCommand.CommandText = $"SELECT pg_terminate_backend({pid})"; + await killCommand.ExecuteNonQueryAsync(); + + Assert.That(stateChangedEvent.Wait(TimeSpan.FromSeconds(.1)), Is.False); + + Assert.Throws(() => getPidCommand.ExecuteScalar()); + Assert.That(stateChangedEvent.Wait(TimeSpan.FromSeconds(5)), Is.True); + } + + // Effective test for https://github.com/npgsql/npgsql/issues/3442, which broke monitoring + [Test] + public async Task TestExecutingQueryOnKilledConnectionFiresStateChanged() + { + using var stateChangedEvent = new ManualResetEventSlim(initialState: false); + + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + connection.StateChange += (o, e) => stateChangedEvent.Set(); + + using var getPidCommand = connection.CreateCommand(); + getPidCommand.CommandText = "SELECT pg_backend_pid()"; + var pid = (int)(await getPidCommand.ExecuteScalarAsync())!; + + Assert.That(connection.State, Is.EqualTo(ConnectionState.Open)); + + // kill the connection from the back end + using var killingConnection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await killingConnection.OpenAsync(); + using var killCommand = killingConnection.CreateCommand(); + killCommand.CommandText = $"SELECT pg_terminate_backend({pid})"; + await killCommand.ExecuteNonQueryAsync(); + + Assert.ThrowsAsync(getPidCommand.ExecuteScalarAsync); + Assert.That(connection.State, Is.Not.EqualTo(ConnectionState.Open)); + + Assert.That(stateChangedEvent.Wait(TimeSpan.FromSeconds(5)), Is.True); + } +} diff --git a/src/DistributedLock.Tests/Tests/Postgres/PostgresConnectionOptionsBuilderTest.cs b/src/DistributedLock.Tests/Tests/Postgres/PostgresConnectionOptionsBuilderTest.cs new file mode 100644 index 00000000..9782aa29 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Postgres/PostgresConnectionOptionsBuilderTest.cs @@ -0,0 +1,36 @@ +using Medallion.Threading.Postgres; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Postgres; + +[Category("CI")] +public class PostgresConnectionOptionsBuilderTest +{ + [Test] + public void TestValidatesArguments() + { + var builder = new PostgresConnectionOptionsBuilder(); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.FromMilliseconds(-2))); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.MaxValue)); + + Assert.Throws(() => PostgresConnectionOptionsBuilder.GetOptions(o => o.UseMultiplexing().UseTransaction())); + } + + [Test] + public void TestDefaults() + { + var options = PostgresConnectionOptionsBuilder.GetOptions(null); + Assert.That(options.keepaliveCadence.IsInfinite, Is.True); + Assert.That(options.useMultiplexing, Is.True); + Assert.That(options.useTransaction, Is.False); + options.ShouldEqual(PostgresConnectionOptionsBuilder.GetOptions(o => { })); + } + + [Test] + public void TestUseTransactionDoesNotRequireDisablingMultiplexing() + { + var options = PostgresConnectionOptionsBuilder.GetOptions(o => o.UseTransaction()); + Assert.That(options.useTransaction, Is.True); + Assert.That(options.useMultiplexing, Is.False); + } +} diff --git a/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockExtensionsTest.cs b/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockExtensionsTest.cs new file mode 100644 index 00000000..a612b401 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockExtensionsTest.cs @@ -0,0 +1,142 @@ +using Medallion.Threading.Postgres; +using Npgsql; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Postgres; + +internal class PostgresDistributedLockExtensionsTest +{ + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Throws(() => PostgresDistributedLock.TryAcquireWithTransaction(default, null!)); + Assert.ThrowsAsync(async () => await PostgresDistributedLock.TryAcquireWithTransactionAsync(default, null!).ConfigureAwait(false)); + Assert.Throws(() => PostgresDistributedLock.AcquireWithTransaction(default, null!)); + Assert.ThrowsAsync(async () => await PostgresDistributedLock.AcquireWithTransactionAsync(default, null!).ConfigureAwait(false)); + } + + [Test] + public async Task TestWorksWithExternalTransaction() + { + bool isLockAcquired; + + var key = new PostgresAdvisoryLockKey(0); + + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + + using (var transaction = connection.BeginTransaction()) + { + PostgresDistributedLock.AcquireWithTransaction(key, transaction); + + isLockAcquired = PostgresDistributedLock.TryAcquireWithTransaction(key, transaction); + Assert.That(isLockAcquired, Is.False); + + transaction.Rollback(); + } + + using (var transaction = connection.BeginTransaction()) + { + isLockAcquired = await PostgresDistributedLock.TryAcquireWithTransactionAsync(key, transaction).ConfigureAwait(false); + Assert.That(isLockAcquired, Is.True); + + Assert.ThrowsAsync(async () => await PostgresDistributedLock.AcquireWithTransactionAsync(key, transaction, TimeSpan.FromMilliseconds(10)).ConfigureAwait(false)); + + transaction.Commit(); + } + } + + [Test] + public async Task TestTimeoutSettingsRestoredWithExternalTransaction() + { + bool isLockAcquired; + + var key = new PostgresAdvisoryLockKey(0); + + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + + using (var transaction = connection.BeginTransaction()) + { + using var transactionCommand = connection.CreateCommand(); + transactionCommand.Transaction = transaction; + + transactionCommand.CommandText = "SET LOCAL statement_timeout = 1010;SET LOCAL lock_timeout = 510;"; + await transactionCommand.ExecuteNonQueryAsync(); + + isLockAcquired = await PostgresDistributedLock.TryAcquireWithTransactionAsync(key, transaction).ConfigureAwait(false); + Assert.That(isLockAcquired, Is.True); + + (await GetTimeoutAsync("statement_timeout", transactionCommand)).ShouldEqual("1010ms"); + (await GetTimeoutAsync("lock_timeout", transactionCommand)).ShouldEqual("510ms"); + + isLockAcquired = PostgresDistributedLock.TryAcquireWithTransaction(key, transaction, TimeSpan.FromMilliseconds(10)); + Assert.That(isLockAcquired, Is.False); + + (await GetTimeoutAsync("statement_timeout", transactionCommand)).ShouldEqual("1010ms"); + (await GetTimeoutAsync("lock_timeout", transactionCommand)).ShouldEqual("510ms"); + + transaction.Rollback(); + + (await GetTimeoutAsync("statement_timeout", transactionCommand)).ShouldEqual("0"); + (await GetTimeoutAsync("lock_timeout", transactionCommand)).ShouldEqual("0"); + } + + using (var transaction = connection.BeginTransaction()) + { + using var transactionCommand = connection.CreateCommand(); + transactionCommand.Transaction = transaction; + + transactionCommand.CommandText = "SET LOCAL statement_timeout = 1010;SET LOCAL lock_timeout = 510;"; + await transactionCommand.ExecuteNonQueryAsync(); + + await PostgresDistributedLock.AcquireWithTransactionAsync(key, transaction).ConfigureAwait(false); + + (await GetTimeoutAsync("statement_timeout", transactionCommand)).ShouldEqual("1010ms"); + (await GetTimeoutAsync("lock_timeout", transactionCommand)).ShouldEqual("510ms"); + + Assert.Throws(() => PostgresDistributedLock.AcquireWithTransaction(key, transaction, TimeSpan.FromMilliseconds(10))); + + (await GetTimeoutAsync("statement_timeout", transactionCommand)).ShouldEqual("1010ms"); + (await GetTimeoutAsync("lock_timeout", transactionCommand)).ShouldEqual("510ms"); + + transaction.Commit(); + + (await GetTimeoutAsync("statement_timeout", transactionCommand)).ShouldEqual("0"); + (await GetTimeoutAsync("lock_timeout", transactionCommand)).ShouldEqual("0"); + } + } + + + [Test] + // Each lock acquisition creates the same named savepoint; this seems like it would create a conflict + // but it actually works fine in Postgres (see https://www.postgresql.org/docs/current/sql-savepoint.html) + public async Task TestWorksForMultipleLocksUnderTheSameConnectionWithExternalTransaction() + { + var key1 = new PostgresAdvisoryLockKey(1); + var key2 = new PostgresAdvisoryLockKey(2); + + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + + using (var transaction = connection.BeginTransaction()) + { + var isFirstLockAcquired = await PostgresDistributedLock.TryAcquireWithTransactionAsync(key1, transaction).ConfigureAwait(false); + Assert.That(isFirstLockAcquired, Is.True); + + var isSecondLockAcquired = await PostgresDistributedLock.TryAcquireWithTransactionAsync(key2, transaction).ConfigureAwait(false); + Assert.That(isSecondLockAcquired, Is.True); + + isSecondLockAcquired = await PostgresDistributedLock.TryAcquireWithTransactionAsync(key2, transaction).ConfigureAwait(false); + Assert.That(isSecondLockAcquired, Is.False); + + transaction.Rollback(); + } + } + + private static Task GetTimeoutAsync(string timeoutName, NpgsqlCommand command) + { + command.CommandText = $"SHOW {timeoutName}"; + return command.ExecuteScalarAsync()!; + } +} diff --git a/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs new file mode 100644 index 00000000..e8802b8b --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs @@ -0,0 +1,125 @@ +using Medallion.Threading.Postgres; +using Npgsql; +using NUnit.Framework; +using System.Data; +#if NET7_0_OR_GREATER +using System.Data.Common; +#endif + +namespace Medallion.Threading.Tests.Postgres; + +public class PostgresDistributedLockTest +{ + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Throws(() => new PostgresDistributedLock(new(0), default(string)!)); + Assert.Throws(() => new PostgresDistributedLock(new(0), default(IDbConnection)!)); +#if NET7_0_OR_GREATER + Assert.Throws(() => new PostgresDistributedLock(new(0), default(DbDataSource)!)); +#endif + } + +#if NET7_0_OR_GREATER + [Test] + public void TestMultiplexingWithDbDataSourceThrowNotSupportedException() + { + using var dataSource = new NpgsqlDataSourceBuilder(TestingPostgresDb.DefaultConnectionString).Build(); + Assert.Throws(() => new PostgresDistributedLock(new(0), dataSource, opt => opt.UseMultiplexing())); + } + + // DbDataSource just calls through to the IDbConnection flow so we don't need exhaustive testing, but we want to + // see it at least work once + [Test] + public async Task TestDbDataSourceConstructorWorks() + { + using var dataSource = new NpgsqlDataSourceBuilder(TestingPostgresDb.DefaultConnectionString).Build(); + PostgresDistributedLock @lock = new(new(5, 5), dataSource); + await using (await @lock.AcquireAsync()) + { + await using var handle = await @lock.TryAcquireAsync(); + Assert.IsNull(handle); + } + } +#endif + + [Test] + public async Task TestInt64AndInt32PairKeyNamespacesAreDifferent() + { + var connectionString = TestingPostgresDb.DefaultConnectionString; + var key1 = new PostgresAdvisoryLockKey(0); + var key2 = new PostgresAdvisoryLockKey(0, 0); + var @lock1 = new PostgresDistributedLock(key1, connectionString); + var @lock2 = new PostgresDistributedLock(key2, connectionString); + + using var handle1 = await lock1.TryAcquireAsync(); + Assert.That(handle1, Is.Not.Null); + + using var handle2 = await lock2.TryAcquireAsync(); + Assert.That(handle2, Is.Not.Null); + } + + [Test] + public async Task TestWorksWithInternalTransaction() + { + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + + using var command = connection.CreateCommand(); + + var transactionLock = new PostgresDistributedLock(new PostgresAdvisoryLockKey("InternTrans", true), TestingPostgresDb.DefaultConnectionString, o => o.UseTransaction()); + + using (var transactionLockHandle = await transactionLock.TryAcquireAsync(TimeSpan.FromSeconds(.3))) + { + (await GetTimeoutAsync("lock_timeout", command)).ShouldEqual("0"); + } + + (await GetTimeoutAsync("lock_timeout", command)).ShouldEqual("0"); + } + + [Test] + public async Task TestWorksWithAmbientTransaction( + [Values("1010ms", "1d", "5min", "20h", "3s")] string timeout) + { + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); + await connection.OpenAsync(); + + var connectionLock = new PostgresDistributedLock(new PostgresAdvisoryLockKey("AmbTrans"), connection); + var otherLock = new PostgresDistributedLock(connectionLock.Key, TestingPostgresDb.DefaultConnectionString); + using var otherLockHandle = await otherLock.AcquireAsync(); + + using (var transaction = connection.BeginTransaction()) + { + using var transactionCommand = connection.CreateCommand(); + transactionCommand.Transaction = transaction; + + transactionCommand.CommandText = $"SET LOCAL statement_timeout = '{timeout}'"; + await transactionCommand.ExecuteNonQueryAsync(); + + using (var timedOutHandle = await connectionLock.TryAcquireAsync(TimeSpan.FromSeconds(.2))) + { + (await GetTimeoutAsync("statement_timeout", transactionCommand)).ShouldEqual(timeout); + + Assert.That(timedOutHandle, Is.Null); + } + + (await GetTimeoutAsync("statement_timeout", transactionCommand)).ShouldEqual(timeout); + + var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(.3)); + var task = connectionLock.AcquireAsync(cancellationToken: cancellationTokenSource.Token).AsTask(); + task.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(5)).ShouldEqual(true); + task.Status.ShouldEqual(TaskStatus.Canceled); + + (await GetTimeoutAsync("statement_timeout", transactionCommand)).ShouldEqual(timeout); + } + + using var connectionCommand = connection.CreateCommand(); + (await GetTimeoutAsync("statement_timeout", connectionCommand)).ShouldEqual("0"); + } + + private static Task GetTimeoutAsync(string timeoutName, NpgsqlCommand command) + { + command.CommandText = $"SHOW {timeoutName}"; + return command.ExecuteScalarAsync()!; + } +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedReaderWriterLockTest.cs b/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedReaderWriterLockTest.cs new file mode 100644 index 00000000..822f7fed --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedReaderWriterLockTest.cs @@ -0,0 +1,21 @@ +using Medallion.Threading.Postgres; +using NUnit.Framework; +using System.Data; +#if NET7_0_OR_GREATER +using System.Data.Common; +#endif + +namespace Medallion.Threading.Tests.Postgres; + +public class PostgresDistributedReaderWriterLockTest +{ + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Throws(() => new PostgresDistributedReaderWriterLock(new(0), default(string)!)); + Assert.Throws(() => new PostgresDistributedReaderWriterLock(new(0), default(IDbConnection)!)); +#if NET7_0_OR_GREATER + Assert.Throws(() => new PostgresDistributedReaderWriterLock(new(0), default(DbDataSource)!)); +#endif + } +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..8b1deb65 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Postgres/PostgresDistributedSynchronizationProviderTest.cs @@ -0,0 +1,41 @@ +using Medallion.Threading.Postgres; +using NUnit.Framework; +using System.Data; +#if NET7_0_OR_GREATER +using System.Data.Common; +#endif + +namespace Medallion.Threading.Tests.Postgres; + +public class PostgresDistributedSynchronizationProviderTest +{ + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => new PostgresDistributedSynchronizationProvider(default(string)!)); + Assert.Throws(() => new PostgresDistributedSynchronizationProvider(default(IDbConnection)!)); +#if NET7_0_OR_GREATER + Assert.Throws(() => new PostgresDistributedSynchronizationProvider(default(DbDataSource)!)); +#endif + } + + [Test] + public async Task BasicTest() + { + var provider = new PostgresDistributedSynchronizationProvider(TestingPostgresDb.DefaultConnectionString); + + const string LockName = TargetFramework.Current + "ProviderBasicTest"; + await using (await provider.AcquireLockAsync(LockName)) + { + await using var handle = await provider.TryAcquireLockAsync(LockName); + Assert.That(handle, Is.Null); + } + + const string ReaderWriterLockName = TargetFramework.Current + "ProviderBasicTest_ReaderWriter"; + await using (await provider.AcquireReadLockAsync(ReaderWriterLockName)) + { + await using var handle = await provider.TryAcquireWriteLockAsync(ReaderWriterLockName); + Assert.That(handle, Is.Null); + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.Tests/Tests/Redis/RedisDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedLockTest.cs new file mode 100644 index 00000000..6ea2d757 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedLockTest.cs @@ -0,0 +1,59 @@ +using Medallion.Threading.Redis; +using Moq; +using NUnit.Framework; +using StackExchange.Redis; +using System.Globalization; + +namespace Medallion.Threading.Tests.Redis; + +public class RedisDistributedLockTest +{ + [Test, Category("CI")] + public void TestName() + { + const string Name = "\0🐉汉字\b\r\n\\"; + var @lock = new RedisDistributedLock(Name, new Mock(MockBehavior.Strict).Object); + @lock.Name.ShouldEqual(Name); + @lock.Key.ShouldEqual(new RedisKey(Name)); + } + + [Test, Category("CI")] + public void TestValidatesConstructorParameters() + { + var database = new Mock(MockBehavior.Strict).Object; + Assert.Throws(() => new RedisDistributedLock(default, database)); + Assert.Throws(() => new RedisDistributedLock(default, new[] { database })); + Assert.Throws(() => new RedisDistributedLock("key", default(IDatabase)!)); + Assert.Throws(() => new RedisDistributedLock("key", default(IEnumerable)!)); + Assert.Throws(() => new RedisDistributedLock("key", new[] { database, null! })); + Assert.Throws(() => new RedisDistributedLock("key", Enumerable.Empty())); + } + + /// + /// Reproduces the bug in https://github.com/madelson/DistributedLock/issues/162 + /// where a Redis lock couldn't be acquired if the current CultureInfo was tr-TR, + /// due to a bug in the underlying StackExchange.Redis package. + /// + /// This is because there are both "dotted i" and "dotless i" in some Turkic languages: + /// https://en.wikipedia.org/wiki/Dotted_and_dotless_I_in_computing + /// + [Test] + public async Task TestCanAcquireLockWhenCurrentCultureIsTurkishTurkey() + { + var originalCultureInfo = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR"); + var @lock = new RedisDistributedLock( + TestHelper.UniqueName, + RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase() + ); + await (await @lock.AcquireAsync()).DisposeAsync(); + } + finally + { + CultureInfo.CurrentCulture = originalCultureInfo; + } + } +} diff --git a/src/DistributedLock.Tests/Tests/Redis/RedisDistributedReaderWriterLockTest.cs b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedReaderWriterLockTest.cs new file mode 100644 index 00000000..0a8ea930 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedReaderWriterLockTest.cs @@ -0,0 +1,76 @@ +using Medallion.Threading.Redis; +using Moq; +using NUnit.Framework; +using StackExchange.Redis; + +namespace Medallion.Threading.Tests.Redis; + +public class RedisDistributedReaderWriterLockTest +{ + [Test] + [Category("CI")] + public void TestName() + { + const string Name = "\0🐉汉字\b\r\n\\"; + var @lock = new RedisDistributedReaderWriterLock(Name, new Mock(MockBehavior.Strict).Object); + @lock.Name.ShouldEqual(Name); + } + + [Test] + [Category("CI")] + public void TestValidatesConstructorParameters() + { + var database = new Mock(MockBehavior.Strict).Object; + Assert.Throws(() => new RedisDistributedReaderWriterLock(default!, database)); + Assert.Throws(() => new RedisDistributedReaderWriterLock(default!, new[] { database })); + Assert.Throws(() => new RedisDistributedReaderWriterLock("key", default(IDatabase)!)); + Assert.Throws(() => new RedisDistributedReaderWriterLock("key", default(IEnumerable)!)); + Assert.Throws(() => new RedisDistributedReaderWriterLock("key", new[] { database, null! })); + Assert.Throws(() => new RedisDistributedReaderWriterLock("key", Enumerable.Empty())); + Assert.Throws(() => new RedisDistributedReaderWriterLock("key", new[] { database }, o => o.Expiry(TimeSpan.FromSeconds(0.2)))); + } + + [Test] + [NonParallelizable] // timing-sensitive + public async Task TestCanExtendReadLock() + { + var @lock = new RedisDistributedReaderWriterLock( + TestHelper.UniqueName, + RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase(), + o => o.Expiry(TimeSpan.FromSeconds(0.3)).BusyWaitSleepTime(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(5)) + ); + + await using var readHandle = await @lock.AcquireReadLockAsync(); + + var writeHandleTask = @lock.AcquireWriteLockAsync().AsTask(); + _ = writeHandleTask.ContinueWith(t => t.Result.Dispose()); // ensure cleanup + Assert.That(await writeHandleTask.TryWaitAsync(TimeSpan.FromSeconds(.5)), Is.False); + + await readHandle.DisposeAsync(); + + Assert.That(await writeHandleTask.TryWaitAsync(TimeSpan.FromSeconds(5)), Is.True); + } + + [Test] + [NonParallelizable] // timing-sensitive + public async Task TestReadLockAbandonment() + { + var @lock = new RedisDistributedReaderWriterLock( + TestHelper.UniqueName, + RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase(), + o => o.Expiry(TimeSpan.FromSeconds(1)) + .ExtensionCadence(TimeSpan.FromSeconds(0.1)) + .BusyWaitSleepTime(TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50)) + ); + + await AcquireReadLockAsync(); + await Task.Delay(20); // seems to help ensure that the GC works + GC.Collect(); + GC.WaitForPendingFinalizers(); + + await using var writeHandle = await @lock.TryAcquireWriteLockAsync(TimeSpan.FromSeconds(10)); + Assert.That(writeHandle, Is.Not.Null); // indicates read lock was released + + async Task AcquireReadLockAsync() => await @lock.AcquireReadLockAsync(); + } +} diff --git a/src/DistributedLock.Tests/Tests/Redis/RedisDistributedSemaphoreTest.cs b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedSemaphoreTest.cs new file mode 100644 index 00000000..c34e2ca6 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedSemaphoreTest.cs @@ -0,0 +1,142 @@ +using Medallion.Threading.Redis; +using Moq; +using NUnit.Framework; +using StackExchange.Redis; + +namespace Medallion.Threading.Tests.Redis; + +public class RedisDistributedSemaphoreTest +{ + [Test] + [Category("CI")] + public void TestName() + { + const string Name = "\0🐉汉字\b\r\n\\"; + var @lock = new RedisDistributedSemaphore(Name, 1, new Mock(MockBehavior.Strict).Object); + @lock.Name.ShouldEqual(Name); + } + + [Test] + [Category("CI")] + public void TestValidatesConstructorParameters() + { + var database = new Mock(MockBehavior.Strict).Object; + Assert.Throws(() => new RedisDistributedSemaphore(default!, 2, database)); + Assert.Throws(() => new RedisDistributedSemaphore("key", 0, database)); + Assert.Throws(() => new RedisDistributedSemaphore("key", -1, database)); + Assert.Throws(() => new RedisDistributedSemaphore("key", 2, default(IDatabase)!)); + } + + [Test] + [Category("CI")] + public async Task TestGetCurrentCountAsync() + { + const int maxCount = 5; + const int heldCount = 2; + var expectedAvailable = maxCount - heldCount; + + var databaseMock = new Mock(MockBehavior.Strict); + databaseMock + .Setup(db => db.SortedSetLengthAsync( + It.Is(k => k == "test-key"), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CommandFlags.DemandMaster)) + .ReturnsAsync(heldCount); + + var semaphore = new RedisDistributedSemaphore("test-key", 5, databaseMock.Object); + var available = await semaphore.GetCurrentCountAsync(); + + available.ShouldEqual(expectedAvailable); + + databaseMock.VerifyAll(); + } + + [Test] + [Category("CI")] + public void TestGetCurrentCountSync() + { + const int maxCount = 4; + const int heldCount = 3; + var expectedAvailable = maxCount - heldCount; + + var databaseMock = new Mock(MockBehavior.Strict); + databaseMock + .Setup(db => db.SortedSetLength( + It.Is(k => k == "test-key"), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CommandFlags.DemandMaster)) + .Returns(heldCount); + + var semaphore = new RedisDistributedSemaphore("test-key", maxCount, databaseMock.Object); + var available = semaphore.GetCurrentCount(); + + expectedAvailable.ShouldEqual(available); + databaseMock.VerifyAll(); + } + + [Test] + public async Task TestGetCurrentCountReflectsAcquisitionsAndReleases() + { + var db = RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase(); + + const int maxCount = 3; + var key = TestHelper.UniqueName + ":sem"; + var semaphore = new RedisDistributedSemaphore(key, maxCount, db); + + maxCount.ShouldEqual(semaphore.GetCurrentCount()); + maxCount.ShouldEqual(await semaphore.GetCurrentCountAsync()); + + // Acquire one + var handle1 = await semaphore.AcquireAsync(); + + Assert.IsNotNull(handle1); + Assert.That(semaphore.GetCurrentCount(), Is.EqualTo(maxCount - 1)); + Assert.That(await semaphore.GetCurrentCountAsync(), Is.EqualTo(maxCount - 1)); + + // Acquire second + var handle2 = await semaphore.AcquireAsync(); + Assert.IsNotNull(handle2); + Assert.That(semaphore.GetCurrentCount(), Is.EqualTo(maxCount - 2)); + Assert.That(await semaphore.GetCurrentCountAsync(), Is.EqualTo(maxCount - 2)); + + // Release first + await handle1.DisposeAsync(); + Assert.That(semaphore.GetCurrentCount(), Is.EqualTo(maxCount - 1)); + Assert.That(await semaphore.GetCurrentCountAsync(), Is.EqualTo(maxCount - 1)); + + // Release second + await handle2.DisposeAsync(); + Assert.That(semaphore.GetCurrentCount(), Is.EqualTo(maxCount)); + Assert.That(await semaphore.GetCurrentCountAsync(), Is.EqualTo(maxCount)); + } + + [Test] + public async Task TestGetCurrentCountNeverNegativeWhenOverReleased() + { + var db = RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase(); + + const int maxCount = 2; + var key = TestHelper.UniqueName + ":sem2"; + var semaphore = new RedisDistributedSemaphore(key, maxCount, db); + + // Acquire more than maxCount (simulate drift) + var h1 = await semaphore.AcquireAsync(); + var h2 = await semaphore.AcquireAsync(); + // manually add a phantom entry to exceed maxCount + await db.SortedSetAddAsync(key, "phantom", 0); + + // Now phantom + two real => acquiredCount == 3 > maxCount + // GetCurrentCount should floor at 0 + Assert.That(semaphore.GetCurrentCount(), Is.EqualTo(0)); + Assert.That(await semaphore.GetCurrentCountAsync(), Is.EqualTo(0)); + + // Cleanup + await h1.DisposeAsync(); + await h2.DisposeAsync(); + await db.KeyDeleteAsync(key); + } +} diff --git a/src/DistributedLock.Tests/Tests/Redis/RedisDistributedSynchronizationOptionsBuilderTest.cs b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedSynchronizationOptionsBuilderTest.cs new file mode 100644 index 00000000..63f1f5ba --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedSynchronizationOptionsBuilderTest.cs @@ -0,0 +1,66 @@ +using Medallion.Threading.Redis; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.Redis; + +public class RedisDistributedSynchronizationOptionsBuilderTest +{ + [Test] + public void TestValidatesExpiry() + { + Assert.Throws(() => GetOptions(o => o.Expiry(TimeSpan.FromSeconds(-2)))); + Assert.Throws(() => GetOptions(o => o.Expiry(Timeout.InfiniteTimeSpan))); + Assert.Throws(() => GetOptions(o => o.Expiry(RedisDistributedSynchronizationOptionsBuilder.MinimumExpiry.TimeSpan - TimeSpan.FromTicks(1)))); + Assert.DoesNotThrow(() => GetOptions(o => o.Expiry(RedisDistributedSynchronizationOptionsBuilder.MinimumExpiry.TimeSpan))); + } + + [Test] + public void TestValidatesMinValidityTime() + { + Assert.Throws(() => GetOptions(o => o.MinValidityTime(TimeSpan.FromSeconds(-2)))); + Assert.Throws(() => GetOptions(o => o.MinValidityTime(TimeSpan.Zero))); + Assert.Throws(() => GetOptions(o => o.MinValidityTime(Timeout.InfiniteTimeSpan))); + Assert.Throws(() => GetOptions(o => o.MinValidityTime(RedisDistributedSynchronizationOptionsBuilder.DefaultExpiry.TimeSpan))); + Assert.DoesNotThrow(() => GetOptions( + o => o.MinValidityTime(RedisDistributedSynchronizationOptionsBuilder.DefaultExpiry.TimeSpan).Expiry(RedisDistributedSynchronizationOptionsBuilder.DefaultExpiry.TimeSpan + TimeSpan.FromMilliseconds(1)) + )); + } + + [Test] + public void TestValidatesExtensionCadence() + { + Assert.Throws(() => GetOptions(o => o.ExtensionCadence(TimeSpan.FromSeconds(-2)))); + Assert.Throws(() => GetOptions(o => o.ExtensionCadence(Timeout.InfiniteTimeSpan))); + Assert.Throws(() => GetOptions(o => o.MinValidityTime(TimeSpan.FromSeconds(1)).ExtensionCadence(TimeSpan.FromSeconds(1)))); + } + + [Test] + public void TestValidatesBusyWaitSleepTime() + { + Assert.Throws(() => GetOptions(o => o.BusyWaitSleepTime(Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(1)))); + Assert.Throws(() => GetOptions(o => o.BusyWaitSleepTime(TimeSpan.FromSeconds(-1), TimeSpan.FromSeconds(1)))); + Assert.Throws(() => GetOptions(o => o.BusyWaitSleepTime(TimeSpan.MaxValue, TimeSpan.FromSeconds(1)))); + Assert.Throws(() => GetOptions(o => o.BusyWaitSleepTime(TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan))); + Assert.Throws(() => GetOptions(o => o.BusyWaitSleepTime(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(-1)))); + Assert.Throws(() => GetOptions(o => o.BusyWaitSleepTime(TimeSpan.FromSeconds(1), TimeSpan.MaxValue))); + + Assert.Throws(() => GetOptions(o => o.BusyWaitSleepTime(TimeSpan.FromSeconds(1.1), TimeSpan.FromSeconds(1)))); + + Assert.DoesNotThrow(() => GetOptions(o => o.BusyWaitSleepTime(TimeSpan.Zero, TimeSpan.Zero))); + Assert.DoesNotThrow(() => GetOptions(o => o.BusyWaitSleepTime(TimeSpan.FromMinutes(3), TimeSpan.FromMinutes(4)))); + } + + [Test] + public void TestDefaults() + { + var defaultOptions = RedisDistributedSynchronizationOptionsBuilder.GetOptions(null); + defaultOptions.RedLockTimeouts.Expiry.ShouldEqual(RedisDistributedSynchronizationOptionsBuilder.DefaultExpiry); + defaultOptions.RedLockTimeouts.MinValidityTime.ShouldEqual(TimeSpan.FromSeconds(27)); + defaultOptions.ExtensionCadence.ShouldEqual(TimeSpan.FromSeconds(9)); + defaultOptions.MinBusyWaitSleepTime.ShouldEqual(TimeSpan.FromMilliseconds(10)); + defaultOptions.MaxBusyWaitSleepTime.ShouldEqual(TimeSpan.FromMilliseconds(800)); + } + + private static void GetOptions(Action options) => + RedisDistributedSynchronizationOptionsBuilder.GetOptions(options); +} diff --git a/src/DistributedLock.Tests/Tests/Redis/RedisDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..4cdd3f6d --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Redis/RedisDistributedSynchronizationProviderTest.cs @@ -0,0 +1,47 @@ +using Medallion.Threading.Redis; +using NUnit.Framework; +using StackExchange.Redis; + +namespace Medallion.Threading.Tests.Redis; + +public class RedisDistributedSynchronizationProviderTest +{ + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => new RedisDistributedSynchronizationProvider(default(IDatabase)!)); + Assert.Throws(() => new RedisDistributedSynchronizationProvider(default(IEnumerable)!)); + Assert.Throws(() => new RedisDistributedSynchronizationProvider(new[] { default(IDatabase)! })); + Assert.Throws(() => new RedisDistributedSynchronizationProvider(Array.Empty())); + } + + [Test] + public async Task BasicTest() + { + var provider = new RedisDistributedSynchronizationProvider(RedisServer.GetDefaultServer(0).Multiplexer.GetDatabase()); + + const string LockName = TargetFramework.Current + "ProviderBasicTest"; + await using (await provider.AcquireLockAsync(LockName)) + { + await using var handle = await provider.TryAcquireLockAsync(LockName); + Assert.That(handle, Is.Null); + } + + const string ReaderWriterLockName = TargetFramework.Current + "ProviderBasicTest_ReaderWriter"; + await using (await provider.AcquireReadLockAsync(ReaderWriterLockName)) + { + await using var handle = await provider.TryAcquireWriteLockAsync(ReaderWriterLockName); + Assert.That(handle, Is.Null); + } + + const string SemaphoreName = TargetFramework.Current + "ProviderBasicTest_Semaphore"; + await using (await provider.AcquireSemaphoreAsync(SemaphoreName, 2)) + { + await using var handle = await provider.TryAcquireSemaphoreAsync(SemaphoreName, 2); + Assert.That(handle, Is.Not.Null); + + await using var failedHandle = await provider.TryAcquireSemaphoreAsync(SemaphoreName, 2); + Assert.That(failedHandle, Is.Null); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/Redis/RedisLibraryTest.cs b/src/DistributedLock.Tests/Tests/Redis/RedisLibraryTest.cs new file mode 100644 index 00000000..05158932 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/Redis/RedisLibraryTest.cs @@ -0,0 +1,25 @@ +using Medallion.Threading.Redis.Primitives; +using NUnit.Framework; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Medallion.Threading.Tests.Redis; + +[Category("CI")] +public class RedisLibraryTest +{ + // ensures that we are caching the preparation of these + [Test] + public void TestAllRedisScriptFieldsAreStatic() + { + var redisScriptFields = typeof(RedisScript<>).Assembly + .GetTypes() + .Where(t => t.GetCustomAttribute() == null) + .SelectMany(t => t.GetFields(BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)) + .Where(f => f.FieldType.IsGenericType && f.FieldType.GetGenericTypeDefinition() == typeof(RedisScript<>)); + foreach (var field in redisScriptFields) + { + Assert.That(field.IsStatic, Is.True, field.ToString()); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/SqlServer/SqlConnectionOptionsBuilderTest.cs b/src/DistributedLock.Tests/Tests/SqlServer/SqlConnectionOptionsBuilderTest.cs new file mode 100644 index 00000000..aad31393 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/SqlServer/SqlConnectionOptionsBuilderTest.cs @@ -0,0 +1,36 @@ +using Medallion.Threading.SqlServer; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.SqlServer; + +[Category("CI")] +public class SqlConnectionOptionsBuilderTest +{ + [Test] + public void TestValidatesArguments() + { + var builder = new SqlConnectionOptionsBuilder(); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.FromMilliseconds(-2))); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.MaxValue)); + + Assert.Throws(() => SqlConnectionOptionsBuilder.GetOptions(o => o.UseMultiplexing().UseTransaction())); + } + + [Test] + public void TestDefaults() + { + var options = SqlConnectionOptionsBuilder.GetOptions(null); + options.keepaliveCadence.ShouldEqual(TimeSpan.FromMinutes(10)); + Assert.That(options.useMultiplexing, Is.True); + Assert.That(options.useTransaction, Is.False); + options.ShouldEqual(SqlConnectionOptionsBuilder.GetOptions(o => { })); + } + + [Test] + public void TestUseTransactionDoesNotRequireDisablingMultiplexing() + { + var options = SqlConnectionOptionsBuilder.GetOptions(o => o.UseTransaction()); + Assert.That(options.useTransaction, Is.True); + Assert.That(options.useMultiplexing, Is.False); + } +} diff --git a/src/DistributedLock.Tests/Tests/SqlServer/SqlDatabaseConnectionTest.cs b/src/DistributedLock.Tests/Tests/SqlServer/SqlDatabaseConnectionTest.cs new file mode 100644 index 00000000..6ec43b83 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/SqlServer/SqlDatabaseConnectionTest.cs @@ -0,0 +1,75 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.SqlServer; +using NUnit.Framework; +using System.Data.Common; + +namespace Medallion.Threading.Tests.SqlServer; + +public class SqlDatabaseConnectionTest +{ + [Test, Combinatorial] + public async Task TestExecuteNonQueryAlreadyCanceled( + [Values] bool isAsync, + [Values] bool isSystemDataSqlClient, + [Values] bool isFastQuery) + { + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await using var connection = CreateConnection(isSystemDataSqlClient); + await connection.OpenAsync(CancellationToken.None); + using var command = connection.CreateCommand(); + command.SetCommandText( + isFastQuery + ? "SELECT 1" + : @"WHILE 1 = 1 + BEGIN + DECLARE @x INT = 1 + END" + ); + + if (isAsync) + { + Assert.CatchAsync(() => command.ExecuteNonQueryAsync(cancellationTokenSource.Token).AsTask()); + } + else + { + Assert.Catch(() => SyncViaAsync.Run(_ => command.ExecuteNonQueryAsync(cancellationTokenSource.Token), 0)); + } + } + + [Test, Combinatorial] + public async Task TestExecuteNonQueryCanCancel([Values] bool isAsync, [Values] bool isSystemDataSqlClient) + { + using var cancellationTokenSource = new CancellationTokenSource(); + + await using var connection = CreateConnection(isSystemDataSqlClient); + await connection.OpenAsync(CancellationToken.None); + using var command = connection.CreateCommand(); + command.SetCommandText(@" + WHILE 1 = 1 + BEGIN + DECLARE @x INT = 1 + END" + ); + + var task = Task.Run(async () => + { + if (isAsync) { await command.ExecuteNonQueryAsync(cancellationTokenSource.Token, disallowAsyncCancellation: true); } + else { SyncViaAsync.Run(_ => command.ExecuteNonQueryAsync(cancellationTokenSource.Token), 0); } + }); + Assert.That(task.Wait(TimeSpan.FromSeconds(.1)), Is.False); + + cancellationTokenSource.Cancel(); + Assert.That(task.ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(5)), Is.True); + task.Status.ShouldEqual(TaskStatus.Canceled); + } + + private static SqlDatabaseConnection CreateConnection(bool isSystemDataSqlClient) => + new( + isSystemDataSqlClient + ? new System.Data.SqlClient.SqlConnection(TestingSqlServerDb.DefaultConnectionString).As() + : new Microsoft.Data.SqlClient.SqlConnection(TestingSqlServerDb.DefaultConnectionString), + isExternallyOwned: false + ); +} diff --git a/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs new file mode 100644 index 00000000..b3f3cf29 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs @@ -0,0 +1,59 @@ +using Medallion.Threading.SqlServer; +using Microsoft.Data.SqlClient; +using NUnit.Framework; +using System.Data; + +namespace Medallion.Threading.Tests.SqlServer; + +public class SqlDistributedLockTest +{ + [Test] + public void TestBadConstructorArguments() + { + Assert.Catch(() => new SqlDistributedLock(null!, TestingSqlServerDb.DefaultConnectionString)); + Assert.Catch(() => new SqlDistributedLock(null!, TestingSqlServerDb.DefaultConnectionString, exactName: true)); + Assert.Catch(() => new SqlDistributedLock("a", default(string)!)); + Assert.Catch(() => new SqlDistributedLock("a", default(IDbTransaction)!)); + Assert.Catch(() => new SqlDistributedLock("a", default(IDbConnection)!)); + Assert.Catch(() => new SqlDistributedLock(new string('a', SqlDistributedLock.MaxNameLength + 1), TestingSqlServerDb.DefaultConnectionString, exactName: true)); + Assert.DoesNotThrow(() => new SqlDistributedLock(new string('a', SqlDistributedLock.MaxNameLength), TestingSqlServerDb.DefaultConnectionString, exactName: true)); + } + + [Test] + public void TestGetSafeLockNameCompat() + { + SqlDistributedLock.GetSafeName("").ShouldEqual(""); + SqlDistributedLock.GetSafeName("abc").ShouldEqual("abc"); + SqlDistributedLock.GetSafeName("\\").ShouldEqual("\\"); + SqlDistributedLock.GetSafeName(new string('a', SqlDistributedLock.MaxNameLength)).ShouldEqual(new string('a', SqlDistributedLock.MaxNameLength)); + SqlDistributedLock.GetSafeName(new string('\\', SqlDistributedLock.MaxNameLength)).ShouldEqual(@"\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"); + SqlDistributedLock.GetSafeName(new string('x', SqlDistributedLock.MaxNameLength + 1)).ShouldEqual("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxA3SOHbN+Zq/qt/fpO9dxauQ3kVj8wfeEbknAYembWJG1Xuf4CL0Dmx3u+dAWHzkFMdjQhlRnlAXtiH7ZMFjjsg=="); + } + + /// + /// This test justifies why we have constructors for SQL Server locks that take in a . + /// Otherwise, you can't have a lock use the same connection as a transaction you're working on. Compare to + /// + /// + [Test] + public async Task TestSqlCommandMustParticipateInTransaction() + { + using var connection = new SqlConnection(TestingSqlServerDb.DefaultConnectionString); + await connection.OpenAsync(); + + using var transaction = connection.BeginTransaction(); + + using var commandInTransaction = connection.CreateCommand(); + commandInTransaction.Transaction = transaction; + commandInTransaction.CommandText = @"CREATE TABLE foo (id INT); SELECT 1"; + (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual(1); + + using var commandOutsideTransaction = connection.CreateCommand(); + commandOutsideTransaction.CommandText = "SELECT 2"; + var exception = Assert.ThrowsAsync(() => commandOutsideTransaction.ExecuteScalarAsync())!; + Assert.That(exception.Message, Does.Contain("requires the command to have a transaction when the connection assigned to the command is in a pending local transaction")); + + commandInTransaction.CommandText = "SELECT COUNT(*) FROM foo"; + (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual(0); + } +} diff --git a/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs b/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs new file mode 100644 index 00000000..461b4352 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs @@ -0,0 +1,42 @@ +using NUnit.Framework; +using System.Data.Common; +using Medallion.Threading.SqlServer; + +namespace Medallion.Threading.Tests.SqlServer; + +public sealed class SqlDistributedReaderWriterLockTest +{ + [Test] + public void TestBadConstructorArguments() + { + Assert.Catch(() => new SqlDistributedReaderWriterLock(null!, TestingSqlServerDb.DefaultConnectionString)); + Assert.Catch(() => new SqlDistributedReaderWriterLock(null!, TestingSqlServerDb.DefaultConnectionString, exactName: true)); + Assert.Catch(() => new SqlDistributedReaderWriterLock("a", default(string)!)); + Assert.Catch(() => new SqlDistributedReaderWriterLock("a", default(DbTransaction)!)); + Assert.Catch(() => new SqlDistributedReaderWriterLock("a", default(DbConnection)!)); + Assert.Catch(() => new SqlDistributedReaderWriterLock(new string('a', SqlDistributedReaderWriterLock.MaxNameLength + 1), TestingSqlServerDb.DefaultConnectionString, exactName: true)); + Assert.DoesNotThrow(() => new SqlDistributedReaderWriterLock(new string('a', SqlDistributedReaderWriterLock.MaxNameLength), TestingSqlServerDb.DefaultConnectionString, exactName: true)); + } + + [Test] + public void TestGetSafeLockNameCompat() + { + SqlDistributedReaderWriterLock.MaxNameLength.ShouldEqual(SqlDistributedLock.MaxNameLength); + + var cases = new[] + { + string.Empty, + "abc", + "\\", + new string('a', SqlDistributedLock.MaxNameLength), + new string('\\', SqlDistributedLock.MaxNameLength), + new string('x', SqlDistributedLock.MaxNameLength + 1) + }; + + foreach (var lockName in cases) + { + // should be compatible with SqlDistributedLock + SqlDistributedReaderWriterLock.GetSafeName(lockName).ShouldEqual(SqlDistributedLock.GetSafeName(lockName)); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs b/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs new file mode 100644 index 00000000..f13a3155 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs @@ -0,0 +1,96 @@ +using Medallion.Threading.SqlServer; +using Microsoft.Data.SqlClient; +using NUnit.Framework; +using System.Data; +using System.Text; +using System.Text.RegularExpressions; + +namespace Medallion.Threading.Tests.SqlServer; + +public sealed class SqlDistributedSemaphoreTest +{ + [Test] + public void TestBadConstructorArguments() + { + Assert.Catch(() => new SqlDistributedSemaphore(null!, 1, TestingSqlServerDb.DefaultConnectionString)); + Assert.Catch(() => new SqlDistributedSemaphore("a", -1, TestingSqlServerDb.DefaultConnectionString)); + Assert.Catch(() => new SqlDistributedSemaphore("a", 0, TestingSqlServerDb.DefaultConnectionString)); + Assert.Catch(() => new SqlDistributedSemaphore("a", 1, default(string)!)); + Assert.Catch(() => new SqlDistributedSemaphore("a", 1, default(IDbConnection)!)); + Assert.Catch(() => new SqlDistributedSemaphore("a", 1, default(IDbTransaction)!)); + + var random = new Random(1234); + var bytes = new byte[10000]; + random.NextBytes(bytes); + Assert.DoesNotThrow(() => new SqlDistributedSemaphore(Encoding.UTF8.GetString(bytes), int.MaxValue, TestingSqlServerDb.DefaultConnectionString)); + } + + [Test] + public void TestNameMangling() + { + static string ToSafeNameChecked(string name) + { + var safeName = SqlSemaphore.ToSafeName(name); + (safeName.Length > 0).ShouldEqual(true, "was: " + safeName); + // max name length here based on constants in SqlSemaphore.cs + (safeName.Length <= (115 - 19)).ShouldEqual(true, "was: " + safeName); + Regex.IsMatch(safeName, @"^[a-zA-Z0-9]+$").ShouldEqual(true, "was: " + safeName); + return safeName; + } + + ToSafeNameChecked(string.Empty); + ToSafeNameChecked("a b"); + ToSafeNameChecked(new string('a', 1000)); + ToSafeNameChecked(string.Join(string.Empty, Enumerable.Range(0, byte.MaxValue).Select(i => (char)i))); + + Assert.That(ToSafeNameChecked(new string('b', 499) + "B"), Is.Not.EqualTo(ToSafeNameChecked(new string('b', 500)))); + + ToSafeNameChecked(new string('x', 200)).Length.ShouldEqual(115 - 30); + + Enumerable.Range(0, 1000) + .Select(i => ToSafeNameChecked(i.ToString())) + .Distinct() + .Count() + .ShouldEqual(1000); + } + + [Test] + public void TestNameManglingCompatibility() + { + SqlSemaphore.ToSafeName(string.Empty).ShouldEqual("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855semaphore"); + SqlSemaphore.ToSafeName("a_simple_name").ShouldEqual("a5fsimple5fn5becacaa1afce7173bf71d20caf31364c2b10c21f7490c942fdc45467aba2d2asemaphore"); + SqlSemaphore.ToSafeName("a").ShouldEqual("aca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bbsemaphore"); + SqlSemaphore.ToSafeName("A").ShouldEqual("A559aead08264d5795d3909718cdd05abd49572e84fe55590eef31a88a08fdffdsemaphore"); + SqlSemaphore.ToSafeName("0").ShouldEqual("05feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9semaphore"); + SqlSemaphore.ToSafeName("!?#").ShouldEqual("213f231be5b6313c68d3c674c3b17246eaaa3222fe5bc23d9173ac6f58319c6004d6bfsemaphore"); + SqlSemaphore.ToSafeName(string.Join(string.Empty, Enumerable.Range(0, byte.MaxValue).Select(i => (char)i))) + .ShouldEqual("0123456789ab7fb98786c16c175d232ab161b5e604c5792e6befd4e1e8d4ecac9d568a6db524semaphore"); + } + + [Test] + public void TestTicketsTakenOnBothConnectionAndTransactionForThatConnection() + { + using var connection = new SqlConnection(TestingSqlServerDb.DefaultConnectionString); + connection.Open(); + + var semaphore1 = new SqlDistributedSemaphore( + UniqueSemaphoreName(nameof(TestTicketsTakenOnBothConnectionAndTransactionForThatConnection)), + 2, + connection + ); + var handle1 = semaphore1.Acquire(); + + using var transaction = connection.BeginTransaction(); + var semaphore2 = new SqlDistributedSemaphore( + UniqueSemaphoreName(nameof(TestTicketsTakenOnBothConnectionAndTransactionForThatConnection)), + 2, + transaction + ); + var handle2 = semaphore2.Acquire(); + semaphore2.TryAcquire().ShouldEqual(null); + var ex = Assert.Catch(() => semaphore2.Acquire())!; + ex.Message.Contains("Deadlock").ShouldEqual(true, ex.ToString()); + } + + private static string UniqueSemaphoreName(string baseName) => $"{baseName}_{TargetFramework.Current}"; +} diff --git a/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..473e7a44 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSynchronizationProviderTest.cs @@ -0,0 +1,46 @@ +using Medallion.Threading.SqlServer; +using NUnit.Framework; +using System.Data; + +namespace Medallion.Threading.Tests.SqlServer; + +public class SqlDistributedSynchronizationProviderTest +{ + [Test] + public void TestArgumentValidation() + { + Assert.Throws(() => new SqlDistributedSynchronizationProvider(default(string)!)); + Assert.Throws(() => new SqlDistributedSynchronizationProvider(default(IDbConnection)!)); + Assert.Throws(() => new SqlDistributedSynchronizationProvider(default(IDbTransaction)!)); + } + + [Test] + public async Task BasicTest() + { + var provider = new SqlDistributedSynchronizationProvider(TestingSqlServerDb.DefaultConnectionString); + + const string LockName = TargetFramework.Current + "ProviderBasicTest"; + await using (await provider.AcquireLockAsync(LockName)) + { + await using var handle = await provider.TryAcquireLockAsync(LockName); + Assert.That(handle, Is.Null); + } + + const string ReaderWriterLockName = TargetFramework.Current + "ProviderBasicTest_ReaderWriter"; + await using (await provider.AcquireReadLockAsync(ReaderWriterLockName)) + { + await using var handle = await provider.TryAcquireWriteLockAsync(ReaderWriterLockName); + Assert.That(handle, Is.Null); + } + + const string SemaphoreName = TargetFramework.Current + "ProviderBasicTest_Semaphore"; + await using (await provider.AcquireSemaphoreAsync(SemaphoreName, 2)) + { + await using var handle = await provider.TryAcquireSemaphoreAsync(SemaphoreName, 2); + Assert.That(handle, Is.Not.Null); + + await using var failedHandle = await provider.TryAcquireSemaphoreAsync(SemaphoreName, 2); + Assert.That(failedHandle, Is.Null); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/TestSetupTest.cs b/src/DistributedLock.Tests/Tests/TestSetupTest.cs new file mode 100644 index 00000000..e471f8ed --- /dev/null +++ b/src/DistributedLock.Tests/Tests/TestSetupTest.cs @@ -0,0 +1,222 @@ +#if NETCOREAPP // no need to run this on multiple frameworks +using NUnit.Framework; +using System.Collections.Concurrent; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace Medallion.Threading.Tests; + +[Category("CI")] +public class TestSetupTest +{ + [Test] + public void VerifyAllTestsAreCreated() + { + var testCaseClasses = this.GetType().Assembly + .GetTypes() + .Where( + t => t.IsAbstract + && t.IsClass + && t.IsGenericTypeDefinition + && t.GetMethods().Any(m => m.GetCustomAttributes(inherit: false).Any(a => a is TestAttribute)) + ) + .ToArray(); + + var expectedTestTypes = testCaseClasses.AsParallel() + .SelectMany(this.GetPossibleGenericInstantiations) + .ToArray(); + + var combinatorialTestsFile = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "Tests", "CombinatorialTests.cs")); + + var expectedTestContents = +$@"// AUTO-GENERATED +using Medallion.Threading.Tests.Data; +using NUnit.Framework; + +{string.Join( +Environment.NewLine + Environment.NewLine, +expectedTestTypes.Select(GetTestClassDeclaration) + .GroupBy(t => t.@namespace, t => t.declaration) + .OrderBy(g => g.Key) + .Select(g => +$@"namespace {g.Key} +{{ +{string.Join(Environment.NewLine, g.OrderBy(s => s).Select(s => " " + s))} +}}") +)}"; + + var existingContents = File.Exists(combinatorialTestsFile) ? File.ReadAllText(combinatorialTestsFile) : null; + if (NormalizeWhitespace(expectedTestContents) != NormalizeWhitespace(existingContents)) + { + File.WriteAllText(combinatorialTestsFile, expectedTestContents); + Assert.Fail("Updated " + combinatorialTestsFile + + $"**** EXPECTED **** \r\n{expectedTestContents}\r\n **** FOUND **** {existingContents ?? "NULL"}"); + } + + static string? NormalizeWhitespace(string? code) => code?.Trim().Replace("\r\n", "\n"); + } + + [Test] + public void TestTestNamespaces() + { + // these can be auto-added by VS but they can mess up our SetUpFixtures + var badNamespaceSegments = new[] { "Tests.Tests", "AbstractTestCases", "Infrastructure" }; + Assert.That(this.GetType().Assembly.GetTypes().Where(t => t.Namespace != null && badNamespaceSegments.Any(s => t.Namespace.Contains(s))), Is.Empty); + } + + private static (string declaration, string @namespace) GetTestClassDeclaration(Type testClassType) + { + static string GetTestClassName(Type type) + { + return type.IsGenericType + ? $"{RemoveProviderSuffix(RemoveGenericMarkers(type.Name))}_{string.Join("_", type.GetGenericArguments().Select(GetTestClassName))}" + : RemoveProviderSuffix(type.Name); + + static string RemoveProviderSuffix(string name) + { + var ProviderSuffix = "Provider"; + return name.EndsWith(ProviderSuffix) ? name.Substring(0, name.Length - ProviderSuffix.Length) : name; + } + } + + static string GetCSharpName(Type type) + { + return type.IsGenericType + ? $"{RemoveGenericMarkers(type.Name)}<{string.Join(", ", type.GetGenericArguments().Select(GetCSharpName))}>" + : type.Name; + } + + // remove words that are very common and therefore don't add much to the name + var testClassName = Regex.Replace(GetTestClassName(testClassType), "Distributed|Lock|Testing|TestCases", string.Empty) + "Test"; + + var supportsContinuousIntegrationAttributes = TraverseDepthFirst(testClassType, t => t.GetGenericArguments()) + .Where(t => t != testClassType) + .Select(a => a.GetCustomAttribute()) + .ToArray(); + var categoryAttribute = supportsContinuousIntegrationAttributes.Any(a => a == null) ? string.Empty + : supportsContinuousIntegrationAttributes.Any(a => a!.WindowsOnly) ? "[Category(\"CIWindows\")] " + : "[Category(\"CI\")] "; + + var declaration = $@"{categoryAttribute}public class {testClassName} : {GetCSharpName(testClassType)} {{ }}"; + + var namespaces = TraverseDepthFirst(testClassType, t => t.GetGenericArguments()) + .Select(t => t.Namespace ?? string.Empty) + .Distinct() + .Where(ns => ns.StartsWith(typeof(TestSetupTest).Namespace!)) + .ToList(); + if (namespaces.Count > 1) { namespaces.RemoveAll(ns => ns == typeof(TestSetupTest).Namespace); } + if (namespaces.Count > 1) { namespaces.RemoveAll(ns => ns.EndsWith(".Data") || ns.EndsWith(".Composites")); } + if (namespaces.Count > 1) { Assert.Fail(string.Join(", ", namespaces)); } + return (declaration, namespaces.Single()); + } + + private static string RemoveGenericMarkers(string name) => Regex.Replace(name, @"`\d+", string.Empty); + + private static readonly ConcurrentDictionary PossibleGenericInstantiationsCache = new(); + + private Type[] GetPossibleGenericInstantiations(Type genericTypeDefinition) + { + if (PossibleGenericInstantiationsCache.TryGetValue(genericTypeDefinition, out var cached)) { return cached; } + + var genericParameterTypes = genericTypeDefinition.GetGenericArguments() + .Select(this.GetTypesForGenericParameter) + .ToArray(); + // Re-check the cache since we may have computed the result in the call to GetTypesForGenericParameter + if (PossibleGenericInstantiationsCache.TryGetValue(genericTypeDefinition, out cached)) { return cached; } + + var allCombinations = TraverseDepthFirst( + root: (index: 0, value: Enumerable.Empty()), + children: t => t.index == genericParameterTypes.Length + ? Enumerable.Empty<(int index, IEnumerable value)>() + : genericParameterTypes[t.index].Select(type => (index: t.index + 1, value: t.value.Append(type))) + ) + .Where(t => t.index == genericParameterTypes.Length) + .Select(t => MakeGenericTypeOrDefault(genericTypeDefinition, t.value.ToArray())) + .Where(t => t != null).Select(t => t!) + .ToArray(); + PossibleGenericInstantiationsCache.TryAdd(genericTypeDefinition, allCombinations); + return allCombinations; + } + + private static readonly IReadOnlyList GenericParameterTypeCandidates = typeof(TestSetupTest).Assembly + .GetTypes() + .Where(t => !t.IsNestedPrivate && !t.IsAbstract) + .ToArray(); + + private Type[] GetTypesForGenericParameter(Type genericParameter) + { + var constraints = genericParameter.GetGenericParameterConstraints(); + return GenericParameterTypeCandidates + // This doesn't support all fancy constraints like class or new() + // see https://stackoverflow.com/questions/4864496/checking-if-an-object-meets-a-generic-parameter-constraint. + // It also does attempt to enforce cross-constraint rules (e. g. T : Foo[V]). The idea is to identify cases + // that might match + .Where(t => constraints.All(c => IsDerivedFromOrDerivedFromGenericOf(derived: t, @base: c))) + .SelectMany(t => t.IsGenericTypeDefinition ? this.GetPossibleGenericInstantiations(t) : new[] { t }) + .ToArray(); + } + + /// + /// Attempts to construct a generic type. While we do filter down the types we try based on the generic constraints, + /// we currently make no attempt to do cross-generic-parameter optimization such as when one generic constraint is + /// dependent on another generic parameter (e. g. T : Foo[V]). In these cases, we fall back to the native validation + /// + private static Type? MakeGenericTypeOrDefault(Type genericTypeDefininition, Type[] genericArguments) + { + try { return genericTypeDefininition.MakeGenericType(genericArguments); } + catch (ArgumentException) { return null; } + } + + private static bool IsDerivedFromOrDerivedFromGenericOf(Type derived, Type @base) + { + if (@base.IsAssignableFrom(derived)) { return true; } + if (!@base.IsGenericType) { return false; } + + var baseDefinition = @base.GetGenericTypeDefinition(); + return TraverseAlong(derived, t => t.BaseType) + .Concat(derived.GetInterfaces()) + .Any(t => t.IsConstructedGenericType && t.GetGenericTypeDefinition() == baseDefinition); + } + + // simplified versions of Traverse methods since Traverse is not strong-named + + private static IEnumerable TraverseDepthFirst(T root, Func> children) + { + yield return root; + + var stack = new Stack>(); + stack.Push(children(root).GetEnumerator()); + + try + { + while (true) + { + if (stack.Peek().MoveNext()) + { + yield return stack.Peek().Current; + stack.Push(children(stack.Peek().Current).GetEnumerator()); + } + else + { + stack.Peek().Dispose(); + stack.Pop(); + if (stack.Count == 0) { break; } + } + } + } + finally + { + while (stack.Count > 0) { stack.Pop().Dispose(); } + } + } + + private static IEnumerable TraverseAlong(T? root, Func next) + where T : class + { + for (var node = root; node != null; node = next(node)) + { + yield return node; + } + } +} +#endif \ No newline at end of file diff --git a/src/DistributedLock.Tests/Tests/WaitHandles/EventWaitHandleDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/WaitHandles/EventWaitHandleDistributedLockTest.cs new file mode 100644 index 00000000..92d7e809 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/WaitHandles/EventWaitHandleDistributedLockTest.cs @@ -0,0 +1,88 @@ +using Medallion.Threading.WaitHandles; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.WaitHandles; + +[Category("CIWindows")] +public class EventWaitHandleDistributedLockTest +{ + [TestCase(null, NameStyle.Exact, ExpectedResult = typeof(ArgumentNullException))] + [TestCase(null, NameStyle.Safe, ExpectedResult = typeof(ArgumentNullException))] + [TestCase("abc", NameStyle.Exact, ExpectedResult = typeof(FormatException))] + [TestCase(@"gLoBaL\weirdPrefixCasing", NameStyle.Exact, ExpectedResult = typeof(FormatException))] + [TestCase(@"global\weirdPrefixCasing2", NameStyle.Exact, ExpectedResult = typeof(FormatException))] + [TestCase("", NameStyle.AddPrefix, ExpectedResult = typeof(FormatException))] + [TestCase(@"a\b", NameStyle.AddPrefix, ExpectedResult = typeof(FormatException))] + public Type TestBadName(string? name, NameStyle nameStyle) + { + if (name != null) + { + this.TestWorkingName(name, NameStyle.Safe); // should always work + } + + return Assert.Catch(() => CreateLock(name!, nameStyle))!.GetType(); + } + + [TestCase(" \t", NameStyle.AddPrefix)] + [TestCase("/a/b/c", NameStyle.AddPrefix)] + [TestCase("\r\n", NameStyle.AddPrefix)] + public void TestWorkingName(string name, NameStyle nameStyle) => + Assert.DoesNotThrow(() => CreateLock(name, nameStyle).Acquire().Dispose()); + + [Test] + public void TestMaxLengthNames() + { + var maxLengthName = DistributedWaitHandleHelpers.GlobalPrefix + + new string('a', DistributedWaitHandleHelpers.MaxNameLength - DistributedWaitHandleHelpers.GlobalPrefix.Length); + this.TestWorkingName(maxLengthName, NameStyle.Exact); + this.TestBadName(maxLengthName + "a", NameStyle.Exact); + } + + [Test] + public void TestGarbageCollection() + { + var @lock = CreateLock("gc_test", NameStyle.AddPrefix); + WeakReference AbandonLock() => new(@lock.Acquire()); + + var weakHandle = AbandonLock(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + + weakHandle.IsAlive.ShouldEqual(false); + using var handle = @lock.TryAcquire(); + Assert.That(handle, Is.Not.Null); + } + + [Test] + public void TestGetSafeLockNameCompat() + { + // stored separately for testing compat + const int MaxNameLengthWithoutGlobalPrefix = 253; + (DistributedWaitHandleHelpers.MaxNameLength - DistributedWaitHandleHelpers.GlobalPrefix.Length) + .ShouldEqual(MaxNameLengthWithoutGlobalPrefix); + + new EventWaitHandleDistributedLock("").Name.ShouldEqual(@"Global\EMPTYz4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg=="); + new EventWaitHandleDistributedLock("abc").Name.ShouldEqual(@"Global\abc"); + new EventWaitHandleDistributedLock("\\").Name.ShouldEqual(@"Global\_CgzRFsLFf7El/ZraEx9sqWRYeplYohSBSmI9sYIe1c4y2u7ECFoU4x2QCjV7HiVJMZsuDMLIz7r8akpKr+viAw=="); + new EventWaitHandleDistributedLock(new string('a', MaxNameLengthWithoutGlobalPrefix)).Name + .ShouldEqual(@"Global\" + new string('a', MaxNameLengthWithoutGlobalPrefix)); + new EventWaitHandleDistributedLock(new string('\\', MaxNameLengthWithoutGlobalPrefix)).Name + .ShouldEqual(@"Global\_____________________________________________________________________________________________________________________________________________________________________Y7DJXlpJeJjeX5XAOWV+ka/3ONBj5dHhKWcSH4pd5AC9YHFm+l1gBArGpBSBn3WcX00ArcDtKw7g24kJaHLifQ=="); + new EventWaitHandleDistributedLock(new string('x', MaxNameLengthWithoutGlobalPrefix + 1)).Name + .ShouldEqual(@"Global\xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxsrCnXZ1XHiT//dOSBfAU0iC4Gtnlr0dQACBUK8Ev2OdRYJ9jcvbiqVCv/rjyPemTW9AvOonkdr0B2bG04gmeYA=="); + } + + private static EventWaitHandleDistributedLock CreateLock(string name, NameStyle nameStyle) => + new( + (nameStyle == NameStyle.AddPrefix ? DistributedWaitHandleHelpers.GlobalPrefix + name : name), + abandonmentCheckCadence: TimeSpan.FromSeconds(.3), + exactName: nameStyle != NameStyle.Safe + ); + + public enum NameStyle + { + Exact, + AddPrefix, + Safe, + } +} diff --git a/src/DistributedLock.Tests/Tests/WaitHandles/WaitHandleDistributedSemaphoreTest.cs b/src/DistributedLock.Tests/Tests/WaitHandles/WaitHandleDistributedSemaphoreTest.cs new file mode 100644 index 00000000..48829571 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/WaitHandles/WaitHandleDistributedSemaphoreTest.cs @@ -0,0 +1,153 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.WaitHandles; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.WaitHandles; + +[Category("CIWindows")] +public class WaitHandleDistributedSemaphoreTest +{ + [TestCase(null, NameStyle.Exact, ExpectedResult = typeof(ArgumentNullException))] + [TestCase(null, NameStyle.Safe, ExpectedResult = typeof(ArgumentNullException))] + [TestCase("abc", NameStyle.Exact, ExpectedResult = typeof(FormatException))] + [TestCase(@"gLoBaL\weirdPrefixCasing", NameStyle.Exact, ExpectedResult = typeof(FormatException))] + [TestCase(@"global\weirdPrefixCasing2", NameStyle.Exact, ExpectedResult = typeof(FormatException))] + [TestCase("", NameStyle.AddPrefix, ExpectedResult = typeof(FormatException))] + [TestCase(@"a\b", NameStyle.AddPrefix, ExpectedResult = typeof(FormatException))] + public Type TestBadName(string? name, NameStyle nameStyle) + { + if (name != null) + { + this.TestWorkingName(name, NameStyle.Safe); // should always work + } + + return Assert.Catch(() => CreateAsLock(name!, nameStyle))!.GetType(); + } + + [TestCase(" \t", NameStyle.AddPrefix)] + [TestCase("/a/b/c", NameStyle.AddPrefix)] + [TestCase("\r\n", NameStyle.AddPrefix)] + public void TestWorkingName(string name, NameStyle nameStyle) => + Assert.DoesNotThrow(() => CreateAsLock(name, nameStyle).Acquire().Dispose()); + + [Test] + public void TestMaxLengthNames() + { + var maxLengthName = DistributedWaitHandleHelpers.GlobalPrefix + + new string('a', DistributedWaitHandleHelpers.MaxNameLength - DistributedWaitHandleHelpers.GlobalPrefix.Length); + this.TestWorkingName(maxLengthName, NameStyle.Exact); + this.TestBadName(maxLengthName + "a", NameStyle.Exact); + } + + [Test] + public async Task TestGarbageCollection() + { + var @lock = CreateAsLock("gc_test", NameStyle.AddPrefix); + WeakReference AbandonLock() => new(@lock.Acquire()); + + var weakHandle = AbandonLock(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + await ManagedFinalizerQueue.Instance.FinalizeAsync(); + + weakHandle.IsAlive.ShouldEqual(false); + using var handle = @lock.TryAcquire(); + Assert.That(handle, Is.Not.Null); + } + + [Test] + public void TestGetSafeLockNameCompat() + { + // stored separately for testing compat + const int MaxNameLengthWithoutGlobalPrefix = 253; + (DistributedWaitHandleHelpers.MaxNameLength - DistributedWaitHandleHelpers.GlobalPrefix.Length) + .ShouldEqual(MaxNameLengthWithoutGlobalPrefix); + + new WaitHandleDistributedSemaphore("", 1).Name.ShouldEqual(@"Global\EMPTYz4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg=="); + new WaitHandleDistributedSemaphore("abc", 1).Name.ShouldEqual(@"Global\abc"); + new WaitHandleDistributedSemaphore("\\", 1).Name.ShouldEqual(@"Global\_CgzRFsLFf7El/ZraEx9sqWRYeplYohSBSmI9sYIe1c4y2u7ECFoU4x2QCjV7HiVJMZsuDMLIz7r8akpKr+viAw=="); + new WaitHandleDistributedSemaphore(new string('a', MaxNameLengthWithoutGlobalPrefix), 1).Name + .ShouldEqual(@"Global\" + new string('a', MaxNameLengthWithoutGlobalPrefix)); + new WaitHandleDistributedSemaphore(new string('\\', MaxNameLengthWithoutGlobalPrefix), 1).Name + .ShouldEqual(@"Global\_____________________________________________________________________________________________________________________________________________________________________Y7DJXlpJeJjeX5XAOWV+ka/3ONBj5dHhKWcSH4pd5AC9YHFm+l1gBArGpBSBn3WcX00ArcDtKw7g24kJaHLifQ=="); + new WaitHandleDistributedSemaphore(new string('x', MaxNameLengthWithoutGlobalPrefix + 1), 1).Name + .ShouldEqual(@"Global\xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxsrCnXZ1XHiT//dOSBfAU0iC4Gtnlr0dQACBUK8Ev2OdRYJ9jcvbiqVCv/rjyPemTW9AvOonkdr0B2bG04gmeYA=="); + } + + /// + /// Attempts to reproduce https://github.com/madelson/DistributedLock/issues/120. + /// + /// NOTE: in practice this race condition is so slim that to reproduce with any reliability requires + /// adding a call to Thread.Sleep(1) at the start of WaitHandleExtensions.Resignal(). + /// + [Test] + public async Task TestCancellationDoesNotLeadToLostSignal([Values] bool async) + { + var semaphore = new WaitHandleDistributedSemaphore(TestHelper.UniqueName, 2); + await using var _ = await semaphore.AcquireAsync(TimeSpan.FromSeconds(1)); + + Random random = new(); + for (var i = 0; i < 50; ++i) + { + using var blockingHandle = semaphore.TryAcquire(TimeSpan.Zero); // claim the last slot on the semaphore + Assert.That(blockingHandle, Is.Not.Null); + + using CancellationTokenSource source = new(); + + using SemaphoreSlim acquiringEvent = new(initialCount: 0, maxCount: 1); + var acquireTask = Task.Run(async () => + { + try + { + if (async) + { + var acquireHandleTask = semaphore.AcquireAsync(TimeSpan.FromSeconds(30), source.Token); + acquiringEvent.Release(); + (await acquireHandleTask).Dispose(); + } + else + { + acquiringEvent.Release(); + semaphore.Acquire(TimeSpan.FromSeconds(30), source.Token).Dispose(); + } + } + catch (OperationCanceledException) { } + }); + await acquiringEvent.WaitAsync(); + Assert.That(acquireTask.IsCompleted, Is.False); + + using Barrier barrier = new(participantCount: 2); + var releaseTask = Task.Run(() => + { + barrier.SignalAndWait(); + blockingHandle!.Dispose(); + }); + var cancelTask = Task.Run(() => + { + barrier.SignalAndWait(); + var yieldCount = random.Next(5, 25); + for (var i = 0; i < yieldCount; ++i) { Thread.Yield(); } + source.Cancel(); + }); + await Task.WhenAll(acquireTask, releaseTask, cancelTask); + } + + await using var handle = await semaphore.TryAcquireAsync(); + Assert.That(handle, Is.Not.Null); // if we lost even a single signal due to cancellation in the loop above, this will fail + } + + private static WaitHandleDistributedSemaphore CreateAsLock(string name, NameStyle nameStyle) => + new( + nameStyle == NameStyle.AddPrefix ? DistributedWaitHandleHelpers.GlobalPrefix + name : name, + maxCount: 1, + abandonmentCheckCadence: TimeSpan.FromSeconds(.3), + exactName: nameStyle != NameStyle.Safe + ); + + public enum NameStyle + { + Exact, + AddPrefix, + Safe, + } +} diff --git a/src/DistributedLock.Tests/Tests/WaitHandles/WaitHandleDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/WaitHandles/WaitHandleDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..9b28362d --- /dev/null +++ b/src/DistributedLock.Tests/Tests/WaitHandles/WaitHandleDistributedSynchronizationProviderTest.cs @@ -0,0 +1,30 @@ +using Medallion.Threading.WaitHandles; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.WaitHandles; + +public class WaitHandleDistributedSynchronizationProviderTest +{ + [Test] + public async Task BasicTest() + { + var provider = new WaitHandleDistributedSynchronizationProvider(); + + const string LockName = TargetFramework.Current + "ProviderBasicTest"; + await using (await provider.AcquireLockAsync(LockName)) + { + await using var handle = await provider.TryAcquireLockAsync(LockName); + Assert.That(handle, Is.Null); + } + + const string SemaphoreName = TargetFramework.Current + "ProviderBasicTest_Semaphore"; + await using (await provider.AcquireSemaphoreAsync(SemaphoreName, 2)) + { + await using var handle = await provider.TryAcquireSemaphoreAsync(SemaphoreName, 2); + Assert.That(handle, Is.Not.Null); + + await using var failedHandle = await provider.TryAcquireSemaphoreAsync(SemaphoreName, 2); + Assert.That(failedHandle, Is.Null); + } + } +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperApiTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperApiTest.cs new file mode 100644 index 00000000..fc542cd5 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperApiTest.cs @@ -0,0 +1,35 @@ +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; +using System.Reflection; + +namespace Medallion.Threading.Tests.ZooKeeper; + +[Category("CI")] +public class ZooKeeperApiTest +{ + [Test] + public void TestSynchronousAcquireAndDisposeMethodsAreImplementedExplicitly() + { + Assert.That( + GetPublicTypes().SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Instance)) + .Where(m => m.Name == "Dispose" || (m.Name.Contains("Acquire") && !m.Name.EndsWith("Async"))), + Is.Empty); + } + + [Test] + public void TestNamePropertyIsImplementedExplicitlyInFavorOfPath() + { + Assert.That(GetPublicTypes().Select(t => t.GetProperty("Name")).Where(p => p != null), Is.Empty); + foreach (var lockType in GetPublicTypes() + .Where(t => t.GetInterfaces().Any(i => i.GetProperty("Name") != null))) + { + var pathProperty = lockType.GetProperty("Path"); + Assert.That(pathProperty, Is.Not.Null, $"{lockType} missing Path"); + pathProperty!.PropertyType.ShouldEqual(typeof(ZooKeeperPath)); + Assert.That(lockType.GetProperty("Name"), Is.Null); + } + } + + private static IEnumerable GetPublicTypes() => typeof(ZooKeeperDistributedLock).Assembly.GetTypes() + .Where(t => t.IsPublic || t.IsNestedPublic); +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperConnectionInfoTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperConnectionInfoTest.cs new file mode 100644 index 00000000..8b0eb4ff --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperConnectionInfoTest.cs @@ -0,0 +1,37 @@ +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.ZooKeeper; + +[Category("CI")] +public class ZooKeeperConnectionInfoTest +{ + [Test] + public void TestEquality() + { + var connectionA = new ZooKeeperConnectionInfo( + "cs", + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + new EquatableReadOnlyList(new[] { new ZooKeeperAuthInfo("s", new EquatableReadOnlyList(new byte[] { 10 })) }) + ); + var connectionB = new ZooKeeperConnectionInfo( + "cs", + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + new EquatableReadOnlyList(new[] { new ZooKeeperAuthInfo("s", new EquatableReadOnlyList(new byte[] { 10 })) }) + ); + var connectionC = connectionA with { + AuthInfo = new EquatableReadOnlyList(new[] + { + new ZooKeeperAuthInfo("s", new EquatableReadOnlyList(new byte[] { 10 })), + new ZooKeeperAuthInfo("s2", new EquatableReadOnlyList(new byte[] { 11 })), + }) + }; + + Assert.That(connectionA == connectionB, Is.True); + connectionA.GetHashCode().ShouldEqual(connectionB.GetHashCode()); + Assert.That(connectionA == connectionC, Is.False); + Assert.That(connectionC.GetHashCode(), Is.Not.EqualTo(connectionA.GetHashCode())); + } +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperConnectionTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperConnectionTest.cs new file mode 100644 index 00000000..c39e37a6 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperConnectionTest.cs @@ -0,0 +1,107 @@ +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; +using Medallion.Threading.Internal; + +namespace Medallion.Threading.Tests.ZooKeeper; + +using org.apache.zookeeper; + +public class ZooKeeperConnectionTest +{ + [Test] + public async Task TestSharesConnections() + { + var pool = new ZooKeeperConnection.Pool(maxAge: TimeSpan.FromSeconds(10)); + using var connection1 = await pool.ConnectAsync(GetConnectionInfo(), CancellationToken.None); + using var connection2 = await pool.ConnectAsync(GetConnectionInfo(), CancellationToken.None); + + Assert.That(connection2, Is.Not.SameAs(connection1)); + Assert.That(connection2.ZooKeeper, Is.SameAs(connection1.ZooKeeper)); + + connection1.Dispose(); + connection2.ZooKeeper.getState().ShouldEqual(ZooKeeper.States.CONNECTED); + } + + [Test] + public async Task TestDoesNotShareDifferentConnections() + { + var pool = new ZooKeeperConnection.Pool(maxAge: TimeSpan.FromSeconds(10)); + using var connection1 = await pool.ConnectAsync(GetConnectionInfo(connectTimeout: TimeSpan.FromSeconds(30)), CancellationToken.None); + using var connection2 = await pool.ConnectAsync(GetConnectionInfo(connectTimeout: TimeSpan.FromSeconds(20)), CancellationToken.None); + + Assert.That(connection2, Is.Not.SameAs(connection1)); + Assert.That(connection2.ZooKeeper, Is.Not.SameAs(connection1.ZooKeeper)); + } + + [Test] + [NonParallelizable, Retry(tryCount: 3)] // timing-sensitive + public async Task TestConnectionIsClosedAndNotSharedAfterMaxAgeElapses() + { + var pool = new ZooKeeperConnection.Pool(maxAge: TimeSpan.FromSeconds(2)); + + using var connection1 = await pool.ConnectAsync(GetConnectionInfo(), CancellationToken.None); + var zooKeeper1 = connection1.ZooKeeper; + + connection1.Dispose(); + Assert.That(await TestHelper.WaitForAsync(() => (zooKeeper1.getState() == ZooKeeper.States.CLOSED).AsValueTask(), TimeSpan.FromSeconds(3)), Is.True); + + using var connection2 = await pool.ConnectAsync(GetConnectionInfo(), CancellationToken.None); + Assert.That(connection2.ZooKeeper, Is.Not.SameAs(zooKeeper1)); + } + + [Test] + [NonParallelizable, Retry(tryCount: 3)] // timing-sensitive + public async Task TestConnectionCanBeHeldOpenAfterMaxAgeButDoesNotShareAndClosesAfterwards() + { + var pool = new ZooKeeperConnection.Pool(maxAge: TimeSpan.FromSeconds(2)); + + using var connection = await pool.ConnectAsync(GetConnectionInfo(), CancellationToken.None); + Assert.That(await TestHelper.WaitForAsync( + async () => + { + using var testConnectionInfo = await pool.ConnectAsync(GetConnectionInfo(), CancellationToken.None); + return testConnectionInfo.ZooKeeper != connection.ZooKeeper; + }, + TimeSpan.FromSeconds(3) + ), Is.True); + + connection.ZooKeeper.getState().ShouldEqual(ZooKeeper.States.CONNECTED); + var zooKeeper = connection.ZooKeeper; + connection.Dispose(); + Assert.That(await TestHelper.WaitForAsync(() => (zooKeeper.getState() != ZooKeeper.States.CONNECTED).AsValueTask(), TimeSpan.FromSeconds(1)), Is.True); + } + + [Test] + [NonParallelizable, Retry(tryCount: 3)] // timing-sensitive + public void TestConnectTimeout() + { + var pool = new ZooKeeperConnection.Pool(maxAge: TimeSpan.FromSeconds(10)); + Assert.ThrowsAsync(() => pool.ConnectAsync(GetConnectionInfo(connectTimeout: TimeSpan.Zero), CancellationToken.None)); + } + + [Test] + public void TestThreadSafety() + { + var connectionInfos = Enumerable.Range(1, 3) + .Select(i => GetConnectionInfo(connectTimeout: TimeSpan.FromSeconds(10 * i))) + .ToArray(); + var tasks = Enumerable.Range(0, 100) + .Select(i => Task.Run(async () => + { + using var connection = await ZooKeeperConnection.DefaultPool.ConnectAsync(connectionInfos[i % connectionInfos.Length], CancellationToken.None); + await connection.ZooKeeper.existsAsync("/zookeeper"); + await Task.Delay(1); + await connection.ZooKeeper.existsAsync($"/{Guid.NewGuid()}"); + })) + .ToArray(); + Assert.DoesNotThrow(() => Task.WaitAll(tasks)); + } + + private static ZooKeeperConnectionInfo GetConnectionInfo(TimeoutValue? connectTimeout = null) => + new( + ZooKeeperPorts.DefaultConnectionString, + ConnectTimeout: connectTimeout ?? TimeSpan.FromSeconds(30), + SessionTimeout: TimeSpan.FromSeconds(30), + new EquatableReadOnlyList(Array.Empty()) + ); +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedLockTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedLockTest.cs new file mode 100644 index 00000000..599087e7 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedLockTest.cs @@ -0,0 +1,35 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.ZooKeeper; + +public class ZooKeeperDistributedLockTest +{ + [Test, Category("CI")] + public void TestValidatesConstructorArguments() + { + Assert.Throws(() => new ZooKeeperDistributedLock(null!, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedLock("name", null!)); + Assert.Throws(() => new ZooKeeperDistributedLock(default(ZooKeeperPath), ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedLock(new ZooKeeperPath("/name"), null!)); + Assert.Throws(() => new ZooKeeperDistributedLock(ZooKeeperPath.Root, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedLock(default(ZooKeeperPath), "name", ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedLock(new ZooKeeperPath("/dir"), null!, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedLock(new ZooKeeperPath("/dir"), "name", default(string)!)); + } + + [Test, Category("CI")] + public void TestNameReturnsPathString() + { + var @lock = new ZooKeeperDistributedLock("some/crazy/name", ZooKeeperPorts.DefaultConnectionString); + @lock.As().Name.ShouldEqual(@lock.Path.ToString()); + } + + [Test, Category("CI")] + public void TestProperlyCombinesDirectoryAndName() + { + new ZooKeeperDistributedLock(new ZooKeeperPath("/dir"), "a", ZooKeeperPorts.DefaultConnectionString).Path.ToString().ShouldEqual("/dir/a"); + Assert.That(new ZooKeeperDistributedLock(new ZooKeeperPath("/a/b"), "c/d", ZooKeeperPorts.DefaultConnectionString).Path.ToString(), Does.StartWith("/a/b/")); + } +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedReaderWriterLockTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedReaderWriterLockTest.cs new file mode 100644 index 00000000..63516758 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedReaderWriterLockTest.cs @@ -0,0 +1,35 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.ZooKeeper; + +public class ZooKeeperDistributedReaderWriterLockTest +{ + [Test, Category("CI")] + public void TestValidatesConstructorArguments() + { + Assert.Throws(() => new ZooKeeperDistributedReaderWriterLock(null!, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedReaderWriterLock("name", null!)); + Assert.Throws(() => new ZooKeeperDistributedReaderWriterLock(default(ZooKeeperPath), ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedReaderWriterLock(new ZooKeeperPath("/name"), null!)); + Assert.Throws(() => new ZooKeeperDistributedReaderWriterLock(ZooKeeperPath.Root, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedReaderWriterLock(default(ZooKeeperPath), "name", ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedReaderWriterLock(new ZooKeeperPath("/dir"), null!, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedReaderWriterLock(new ZooKeeperPath("/dir"), "name", default(string)!)); + } + + [Test, Category("CI")] + public void TestNameReturnsPathString() + { + var @lock = new ZooKeeperDistributedReaderWriterLock("some/crazy/name", ZooKeeperPorts.DefaultConnectionString); + @lock.As().Name.ShouldEqual(@lock.Path.ToString()); + } + + [Test, Category("CI")] + public void TestProperlyCombinesDirectoryAndName() + { + new ZooKeeperDistributedReaderWriterLock(new ZooKeeperPath("/dir"), "a", ZooKeeperPorts.DefaultConnectionString).Path.ToString().ShouldEqual("/dir/a"); + Assert.That(new ZooKeeperDistributedReaderWriterLock(new ZooKeeperPath("/a/b"), "c/d", ZooKeeperPorts.DefaultConnectionString).Path.ToString(), Does.StartWith("/a/b/")); + } +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedSemaphoreTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedSemaphoreTest.cs new file mode 100644 index 00000000..2f2d545c --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedSemaphoreTest.cs @@ -0,0 +1,37 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.ZooKeeper; + +public class ZooKeeperDistributedSemaphoreTest +{ + [Test, Category("CI")] + public void TestValidatesConstructorArguments() + { + Assert.Throws(() => new ZooKeeperDistributedSemaphore(null!, 2, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedSemaphore("name", -1, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedSemaphore("name", 0, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedSemaphore("name", 2, null!)); + Assert.Throws(() => new ZooKeeperDistributedSemaphore(default(ZooKeeperPath), 2, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedSemaphore(new ZooKeeperPath("/name"), 2, null!)); + Assert.Throws(() => new ZooKeeperDistributedSemaphore(ZooKeeperPath.Root, 2, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedSemaphore(default(ZooKeeperPath), "name", 2, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedSemaphore(new ZooKeeperPath("/dir"), null!, 2, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedSemaphore(new ZooKeeperPath("/dir"), "name", 2, default(string)!)); + } + + [Test, Category("CI")] + public void TestNameReturnsPathString() + { + var @lock = new ZooKeeperDistributedSemaphore("some/crazy/name", 2, ZooKeeperPorts.DefaultConnectionString); + @lock.As().Name.ShouldEqual(@lock.Path.ToString()); + } + + [Test, Category("CI")] + public void TestProperlyCombinesDirectoryAndName() + { + new ZooKeeperDistributedSemaphore(new ZooKeeperPath("/dir"), "a", 2, ZooKeeperPorts.DefaultConnectionString).Path.ToString().ShouldEqual("/dir/a"); + Assert.That(new ZooKeeperDistributedSemaphore(new ZooKeeperPath("/a/b"), "c/d", 2, ZooKeeperPorts.DefaultConnectionString).Path.ToString(), Does.StartWith("/a/b/")); + } +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedSynchronizationOptionsBuilderTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedSynchronizationOptionsBuilderTest.cs new file mode 100644 index 00000000..a3b2ad85 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedSynchronizationOptionsBuilderTest.cs @@ -0,0 +1,24 @@ +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.ZooKeeper; + +[Category("CI")] +public class ZooKeeperDistributedSynchronizationOptionsBuilderTest +{ + [Test] + public void TestValidatesArguments() + { + Assert.Throws(Create(b => b.ConnectTimeout(TimeSpan.FromSeconds(-2)))); + Assert.Throws(Create(b => b.SessionTimeout(TimeSpan.FromSeconds(-2)))); + Assert.Throws(Create(b => b.SessionTimeout(TimeSpan.Zero))); + Assert.Throws(Create(b => b.SessionTimeout(Timeout.InfiniteTimeSpan))); + Assert.Throws(Create(b => b.AddAuthInfo(null!, Array.Empty()))); + Assert.Throws(Create(b => b.AddAuthInfo("scheme", null!))); + Assert.Throws(Create(b => b.AddAccessControl(null!, "id", 0x1f))); + Assert.Throws(Create(b => b.AddAccessControl("scheme", null!, 0x1f))); + + static TestDelegate Create(Action action) => + () => ZooKeeperDistributedSynchronizationOptionsBuilder.GetOptions(action); + } +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedSynchronizationProviderTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..8c4cdba3 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperDistributedSynchronizationProviderTest.cs @@ -0,0 +1,78 @@ +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.ZooKeeper; + +public class ZooKeeperDistributedSynchronizationProviderTest +{ + [Test, Category("CI")] + public void TestArgumentValidation() + { + Assert.Throws(() => new ZooKeeperDistributedSynchronizationProvider(default, ZooKeeperPorts.DefaultConnectionString)); + Assert.Throws(() => new ZooKeeperDistributedSynchronizationProvider(null!)); + } + + [Test] + public async Task BasicTest() + { + var provider = new ZooKeeperDistributedSynchronizationProvider(ZooKeeperPorts.DefaultConnectionString); + + var lockName = TestHelper.UniqueName + "Lock"; + await using (await provider.AcquireLockAsync(lockName)) + { + await using var handle = await provider.TryAcquireLockAsync(lockName); + Assert.That(handle, Is.Null); + } + + var readerWriterLockName = TestHelper.UniqueName + "ReaderWriterLock"; + await using (await provider.AcquireReadLockAsync(readerWriterLockName)) + { + await using var handle = await provider.TryAcquireWriteLockAsync(readerWriterLockName); + Assert.That(handle, Is.Null); + } + + var semaphoreName = TestHelper.UniqueName + "Semaphore"; + await using (await provider.AcquireSemaphoreAsync(semaphoreName, 2)) + { + await using var handle = await provider.TryAcquireSemaphoreAsync(semaphoreName, 2); + Assert.That(handle, Is.Not.Null); + + await using var failedHandle = await provider.TryAcquireSemaphoreAsync(semaphoreName, 2); + Assert.That(failedHandle, Is.Null); + } + } + + [Test] + public async Task TestDifferentPrimitivesDoNotCollide() + { + var provider = new ZooKeeperDistributedSynchronizationProvider(ZooKeeperPorts.DefaultConnectionString); + + var name = TestHelper.UniqueName; + var @lock = provider.CreateLock(name); + var readerWriterLock = provider.CreateReaderWriterLock(name); + var semaphore = provider.CreateSemaphore(name, maxCount: 1); + + @lock.Path.ShouldEqual(readerWriterLock.Path); + @lock.Path.ShouldEqual(semaphore.Path); + + await using var lockHandle = await @lock.TryAcquireAsync(); + Assert.That(lockHandle, Is.Not.Null); + await using var readLockHandle = await readerWriterLock.TryAcquireReadLockAsync(); + Assert.That(readLockHandle, Is.Not.Null); + await using var semaphoreHandle = await semaphore.TryAcquireAsync(); + Assert.That(semaphoreHandle, Is.Not.Null); + + await readLockHandle!.DisposeAsync(); + await using var writeLockHandle = await readerWriterLock.TryAcquireWriteLockAsync(); + Assert.That(writeLockHandle, Is.Not.Null); + } + + [Test, Category("CI")] + public void TestIncorporatesDirectoryNameIfProvided() + { + var provider = new ZooKeeperDistributedSynchronizationProvider(new ZooKeeperPath("/foo"), ZooKeeperPorts.DefaultConnectionString); + provider.CreateLock("bar").Path.ToString().ShouldEqual("/foo/bar"); + provider.CreateReaderWriterLock("baz").Path.ToString().ShouldEqual("/foo/baz"); + provider.CreateSemaphore("qux", 1).Path.ToString().ShouldEqual("/foo/qux"); + } +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperPathTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperPathTest.cs new file mode 100644 index 00000000..518a09cd --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperPathTest.cs @@ -0,0 +1,100 @@ +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.ZooKeeper; + +[Category("CI")] +public class ZooKeeperPathTest +{ + [Test] + public void TestRejectsNull() => Assert.Throws(() => new ZooKeeperPath(null!)); + + [TestCase("")] + [TestCase("a")] + [TestCase("/a/")] + [TestCase("/x\0b")] + [TestCase("/.")] + [TestCase("/b/./a")] + [TestCase("/x/..")] + [TestCase("/../r")] + public void TestRejectsInvalidPaths(string path) => Assert.Throws(() => new ZooKeeperPath(path)); + + [Test] + public void TestRejectsPathsWithControlCharacters() + { + // these can't be test cases because the control characters confuse the test explorer + this.TestRejectsInvalidPaths("/\u0000a"); + this.TestRejectsInvalidPaths("/a/\u007f"); + this.TestRejectsInvalidPaths("/a\uf8ff/b"); + this.TestRejectsInvalidPaths("/a\uffff"); + } + + [TestCase("/a.js")] + [TestCase("/...m")] + [TestCase("/...")] + [TestCase("/..foo")] + [TestCase("/a..")] + [TestCase("/.x")] + public void TestAllowsDotInNonRelativePath(string path) => Assert.DoesNotThrow(() => new ZooKeeperPath(path)); + + [TestCase("/", "abc", ExpectedResult = "/abc")] + [TestCase("/xyz/foo", "bar", ExpectedResult = "/xyz/foo/bar")] + [TestCase("/", "...", ExpectedResult = "/...")] + [TestCase("/", "", ExpectedResult = "/EMPTYz4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg_SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==")] + [TestCase("/", "/", ExpectedResult = "/_XIbwNE7SSUJciq0_Jytyos4P84h5HzFJfq8lf6cmKUh_qv1_0n6w3WNV1VCeLz+vdnEQFc2SB9JI1VD96hUnTw==")] + [TestCase("/", "a/", ExpectedResult = "/a__H_H9kmjf3WHGaL30_9h_RNn+GipsxwkovtbOR9nXFUa9lT++YHfmM7FomA2RoilE23u5yCsImr8ImvgukZ3lQ==")] + [TestCase("/bar", "\0", ExpectedResult = "/bar/_uCRNAomB1pOve0Vq+O+kytY9KC4Z_xSULCRuUNk1HSJwSoAqccNYC2Nw3kzrKTwySoQjNCVX1OXDhDjw42kQ7g==")] + [TestCase("/a.a", ".", ExpectedResult = "/a.a/._C2EkHXwXvLsbrucJTRS3xFHv7Mf_y9klmKDxPTE8yevCoH5h8Ae69Y+_lP+ahpW91crnzgO78elOk2E6APJfIQ==")] + [TestCase("/b..b/a", "..", ExpectedResult = "/b..b/a/.._Rh_NqllQfVidZsBWJatUTHt1u_RuBCeYLhNba59jtMe0u1rD7QNtp2QKxdN3Ohz1E1VnJ_yfFMevOUWeSrtxxw==")] + [TestCase("/a/b", "/..", ExpectedResult = "/a/b/_..p0p_PzMVsjkHpwCrL63ktSqk0bLNJX9_X+7BtDMfHWr5usgqZ3n5VVI3FSGJ0YPNrps_Pf7f9yxeCzz+AiD0sw==")] + [TestCase("/", "zookeeper", ExpectedResult = "/zookeeper_yX0zkYBzsEZ1VDADNUx54LUSt9VL9M9lOPoez38tnk4FrBtllVz+ksFllir7N2yla_wy22pIOnbpcfRkcVXBag==")] + [TestCase("/", "zooKeeper", ExpectedResult = "/zooKeeper")] + [TestCase("/a", "zookeeper", ExpectedResult = "/a/zookeeper")] + public string TestGetChildNodePathWithSafeName(string path, string name) + { + var result = new ZooKeeperPath(path).GetChildNodePathWithSafeName(name); + Assert.DoesNotThrow(() => new ZooKeeperPath(result.ToString()), "should pass path validation"); + return result.ToString(); + } + + [Test] + public void TestGetChildNodePathWithSafeNameHandlesControlCharacters() => + this.TestGetChildNodePathWithSafeName("/", "\u0000\u007f\uf8ff\uffff").ShouldEqual("/____7A++E8vPxbYKJmhCX1bUTCwjqJqW1POHfCeBk62R9hqB0Fd_uTUBIpv9mssG7K68FHZ_7wJ70UNRKsVH8CrngA=="); + + [Test] + public void TestEquality() + { + var paths = new[] { null, "/", "/a", "/A" }.Select(p => p == null ? default : new ZooKeeperPath(p)).ToArray(); + for (var i = 0; i < paths.Length; ++i) + { + for (var j = 0; j < paths.Length; ++j) + { + if (i == j) + { + Assert.That(paths[i] == paths[j], Is.True); + Assert.That(paths[i] != paths[j], Is.False); + Assert.That(paths[i].Equals(paths[j]), Is.True); + Assert.That(Equals(paths[i], paths[j]), Is.True); + } + else + { + Assert.That(paths[i] == paths[j], Is.False); + Assert.That(paths[i] != paths[j], Is.True); + Assert.That(paths[i].Equals(paths[j]), Is.False); + Assert.That(Equals(paths[i], paths[j]), Is.False); + Assert.That(paths[j].GetHashCode(), Is.Not.EqualTo(paths[i].GetHashCode())); + } + } + } + } + + [Test] + public void TestExposesMinimalApi() + { + var publicMembers = typeof(ZooKeeperPath).GetMembers() + .Where(m => m.DeclaringType == typeof(ZooKeeperPath)); + Assert.That( + publicMembers.Select(m => m.Name), + Is.EquivalentTo(new[] { "ToString", "Equals", "Equals", "GetHashCode", "op_Equality", "op_Inequality", ".ctor" })); + } +} diff --git a/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperSequentialPathHelperTest.cs b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperSequentialPathHelperTest.cs new file mode 100644 index 00000000..4457b240 --- /dev/null +++ b/src/DistributedLock.Tests/Tests/ZooKeeper/ZooKeeperSequentialPathHelperTest.cs @@ -0,0 +1,212 @@ +using Medallion.Threading.ZooKeeper; +using NUnit.Framework; + +namespace Medallion.Threading.Tests.ZooKeeper; + +[Category("CI")] +public class ZooKeeperSequentialPathHelperTest +{ + [TestCase("/", "a", ExpectedResult = null)] + [TestCase("/ba0000000002", "a", ExpectedResult = null)] + [TestCase("/ba0000000002", "ba", ExpectedResult = 2)] + [TestCase("/ba000000002", "ba", ExpectedResult = null)] + [TestCase("/ba00000000112", "ba", ExpectedResult = null)] + [TestCase("/ba-000000002", "ba", ExpectedResult = -2)] + [TestCase("/ba0000000002", "/ba", ExpectedResult = null)] + [TestCase("/c/d/ba0000000402", "ba", ExpectedResult = 402)] + [TestCase("lock-2147483647", "lock-", ExpectedResult = int.MaxValue)] + [TestCase("read--000000001", "read-", ExpectedResult = -1)] + [TestCase("write--2147483648", "write-", ExpectedResult = int.MinValue)] + [TestCase("x 000000002", "x", ExpectedResult = null)] + [TestCase("x000000002 ", "x", ExpectedResult = null)] + public int? TestGetSequenceNumberOrDefault(string pathOrName, string prefix) => + ZooKeeperSequentialPathHelper.GetSequenceNumberOrDefault(pathOrName, prefix); + + [Test] + public Task TestFilterAndSortLowPositiveNumbers() => TestFilterAndSortHelper( + new[] { 5, 3, 1 }, + expectedSequenceNumbers: new[] { 1, 3, 5 } + ); + + [Test] + public Task TestFilterAndSortMediumNegativeNumbers() => TestFilterAndSortHelper( + new[] { -1000000000, -1000000050, -1000000500 }, + expectedSequenceNumbers: new[] { -1000000500, -1000000050, -1000000000 } + ); + + [Test] + public Task TestFilterAndSortHighPositiveAndLowNegativeNumbers() => TestFilterAndSortHelper( + new[] { int.MaxValue, int.MaxValue - 1, int.MaxValue - 100, int.MinValue, int.MinValue + 9, int.MinValue + 90 }, + expectedSequenceNumbers: new[] { int.MaxValue - 100, int.MaxValue - 1, int.MaxValue, int.MinValue, int.MinValue + 9, int.MinValue + 90 } + ); + + [Test] + public Task TestFilterAndSortHighNegativeAndLowPositiveNumbers() => TestFilterAndSortHelper( + new[] { 1, 15, 6, -1, -3 }, + expectedSequenceNumbers: new[] { -3, -1, 1, 6, 15 } + ); + + [Test] + public Task TestFilterAndSortLowAndHighPositiveNumbersLowsOlder() => TestFilterAndSortHelper( + new[] { 4, 2, 0, int.MaxValue, int.MaxValue - 3, int.MaxValue - 5 }, + expectedSequenceNumbers: new[] { 0, 2, 4, int.MaxValue - 5, int.MaxValue - 3, int.MaxValue }, + creationTimes: new Dictionary + { + [0] = 1, + [2] = 1, + [4] = 2, + [int.MaxValue - 5] = 3, + [int.MaxValue - 3] = 4, + [int.MaxValue] = 5, + } + ); + + [Test] + public Task TestFilterAndSortLowAndHighPositiveNumbersHighsOlder() => TestFilterAndSortHelper( + new[] { 4, 2, 0, int.MaxValue, int.MaxValue - 3, int.MaxValue - 5 }, + expectedSequenceNumbers: new[] { int.MaxValue - 5, int.MaxValue - 3, int.MaxValue, 0, 2, 4 }, + creationTimes: new Dictionary + { + [0] = 3, + [2] = 4, + [4] = 5, + [int.MaxValue - 5] = 1, + [int.MaxValue - 3] = 1, + [int.MaxValue] = 2, + } + ); + + [Test] + public Task TestFilterAndSortLowAndHighNegativeNumbersLowsOlder() => TestFilterAndSortHelper( + new[] { -300, -301, -302, int.MinValue, int.MinValue + 10, int.MinValue + 100 }, + expectedSequenceNumbers: new[] { int.MinValue, int.MinValue + 10, int.MinValue + 100, -302, -301, -300 }, + creationTimes: new Dictionary + { + [-302] = 300, + [-301] = 300, + [-300] = 300, + [int.MinValue] = 100, + [int.MinValue + 10] = 100, + [int.MinValue + 100] = 100, + } + ); + + [Test] + public Task TestFilterAndSortLowAndHighNegativeNumbersHighsOlder() => TestFilterAndSortHelper( + new[] { -300, -301, -302, int.MinValue, int.MinValue + 10, int.MinValue + 100 }, + expectedSequenceNumbers: new[] { -302, -301, -300, int.MinValue, int.MinValue + 10, int.MinValue + 100 }, + creationTimes: new Dictionary + { + [-302] = 30, + [-301] = 30, + [-300] = 30, + [int.MinValue] = 100, + [int.MinValue + 10] = 100, + [int.MinValue + 100] = 100, + } + ); + + [Test] + public Task TestFilterAndSortHighNegativeAndPositiveNumbersPositivesOlder() => TestFilterAndSortHelper( + new[] { int.MaxValue, -1000 }, + expectedSequenceNumbers: new[] { int.MaxValue, -1000 }, + creationTimes: new Dictionary + { + [int.MaxValue] = 1, + [-1000] = 2000, + } + ); + + [Test] + public Task TestFilterAndSortHighNegativeAndPositiveNumbersNegativesOlder() => TestFilterAndSortHelper( + new[] { int.MaxValue, -1000 }, + expectedSequenceNumbers: new[] { -1000, int.MaxValue }, + creationTimes: new Dictionary + { + [int.MaxValue] = long.MaxValue, + [-1000] = long.MaxValue - 1, + } + ); + + [Test] + public Task TestFilterAndSortLowNegativeAndPositiveNumbersPositivesOlder() => TestFilterAndSortHelper( + new[] { int.MinValue, 2_000_000 }, + expectedSequenceNumbers: new[] { 2_000_000, int.MinValue }, + creationTimes: new Dictionary + { + [int.MinValue] = 10, + [2_000_000] = 1, + } + ); + + [Test] + public Task TestFilterAndSortLowNegativeAndPositiveNumbersNegativesOlder() => TestFilterAndSortHelper( + new[] { int.MinValue, 2_000_000 }, + expectedSequenceNumbers: new[] { int.MinValue, 2_000_000 }, + creationTimes: new Dictionary + { + [int.MinValue] = 1, + [2_000_000] = 10, + } + ); + + [Test] + public Task TestFilterAndSortEmpty() => TestFilterAndSortHelper(Array.Empty(), expectedSequenceNumbers: Array.Empty()); + + [Test] + public Task TestFilterAndSortEmptyAfterChecking() => TestFilterAndSortHelper( + Enumerable.Range(1, 100).Concat(new[] { int.MaxValue }).ToArray(), + expectedSequenceNumbers: Array.Empty(), + creationTimes: new Dictionary { [-1] = 1 } + ); + + [Test] + public Task TestFilterAndSortFilteredAfterChecking() => TestFilterAndSortHelper( + new[] { 1, 2, 3, int.MaxValue - 10, int.MaxValue }, + expectedSequenceNumbers: new[] { int.MaxValue - 10, 2 }, + creationTimes: new Dictionary + { + [int.MaxValue - 10] = long.MinValue, + [2] = long.MaxValue + } + ); + + private static async Task TestFilterAndSortHelper( + IReadOnlyList sequenceNumbers, + IReadOnlyList expectedSequenceNumbers, + Dictionary? creationTimes = null) + { + var random = new Random(12345); + var paths = sequenceNumbers.Select(n => MakeName(random.Next(2) == 0 ? "a" : "b", n)) + .Concat(new[] { MakeName("c", 0), MakeName("d", 1), "a", "b" }) + .OrderBy(_ => random.Next()) + .ToArray(); + var parentNode = $"/parent{random.Next()}"; + + var result = await ZooKeeperSequentialPathHelper.FilterAndSortAsync( + parentNode, + paths, + n => Task.FromResult( + (creationTimes ?? throw new AssertionException("Shouldn't check creation time")).TryGetValue(GetSequenceNumber(n), out var creationTime) + ? creationTime + : default(long?) + ), + prefix: "a", + alternatePrefix: "b" + ); + + Assert.That(result.Select(t => t.SequenceNumber), Is.EqualTo(expectedSequenceNumbers).AsCollection); + foreach (var info in result) + { + info.Path.ShouldEqual($"{parentNode}/{MakeName(info.Prefix, info.SequenceNumber)}"); + } + + int GetSequenceNumber(string name) => ZooKeeperSequentialPathHelper.GetSequenceNumberOrDefault(name, "a") + ?? ZooKeeperSequentialPathHelper.GetSequenceNumberOrDefault(name, "b") + ?? throw new AssertionException($"Can't get sequence number for '{name}'"); + } + + private static string MakeName(string prefix, int sequenceNumber) => $"{prefix}{sequenceNumber:0000000000}"; + + private static Task CannotGetNodeCreationTime(string node) => throw new AssertionException("Should not be called"); +} diff --git a/src/DistributedLock.Tests/packages.lock.json b/src/DistributedLock.Tests/packages.lock.json new file mode 100644 index 00000000..fffd48a0 --- /dev/null +++ b/src/DistributedLock.Tests/packages.lock.json @@ -0,0 +1,1408 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.7.2": { + "MedallionShell.StrongName": { + "type": "Direct", + "requested": "[1.6.2, )", + "resolved": "1.6.2", + "contentHash": "x7kIh8HiLHQrm5tcLEwNXhYfIHjQoK8ZS9MPx/LcCgNubtfFVJZm8Kk5/FSOalHjlXizcLAm6733L691l8cr/Q==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.9.0, )", + "resolved": "17.9.0", + "contentHash": "7GUNAUbJYn644jzwLm5BD3a2p9C1dmP8Hr6fDPDxgItQk9hBs1Svdxzz07KQ/UphMSmgza9AbijBJGmw5D658A==", + "dependencies": { + "Microsoft.CodeCoverage": "17.9.0" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.70, )", + "resolved": "4.20.70", + "contentHash": "4rNnAwdpXJBuxqrOCzCyICXHSImOTRktCgCWXWykuF1qwoIsVvEnR7PjbMk/eLOxWvhmj5Kwt+kDV3RGUYcNwg==", + "dependencies": { + "Castle.Core": "5.1.1", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "NUnit": { + "type": "Direct", + "requested": "[3.14.0, )", + "resolved": "3.14.0", + "contentHash": "R7iPwD7kbOaP3o2zldWJbWeMQAvDKD0uld27QvA3PAALl1unl7x0v2J7eGiJOYjimV/BuGT4VJmr45RjS7z4LA==" + }, + "NUnit.Analyzers": { + "type": "Direct", + "requested": "[4.1.0, )", + "resolved": "4.1.0", + "contentHash": "Odd1RusSMnfswIiCPbokAqmlcCCXjQ20poaXWrw+CWDnBY1vQ/x6ZGqgyJXpebPq5Uf8uEBe5iOAySsCdSrWdQ==" + }, + "NUnit3TestAdapter": { + "type": "Direct", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "s8JpqTe9bI2f49Pfr3dFRfoVSuFQyraTj68c3XXjIS/MRGvvkLnrg6RLqnTjdShX+AdFUCCU/4Xex58AdUfs6A==" + }, + "System.Data.SqlClient": { + "type": "Direct", + "requested": "[4.8.6, )", + "resolved": "4.8.6", + "contentHash": "2Ij/LCaTQRyAi5lAv7UUTV9R2FobC8xN9mE0fXBZohum/xLl8IZVmE98Rq5ugQHjCgTBRKqpXRb4ORulRdA6Ig==" + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.6", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0", + "System.Memory": "4.6.3" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==" + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0", + "System.Buffers": "4.5.1" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.9.0", + "contentHash": "RGD37ZSrratfScYXm7M0HjvxMxZyWZL4jm+XgMZbkIY1UPgjUpbNA/t+WTGj/rC/0Hm9A3IrH3ywbKZkOCnoZA==" + }, + "Microsoft.Data.SqlClient.SNI": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "p3Pm/+7oPSn4At6vKrttRpUOVdrcer3oZln0XeYZ94DTTQirUVzQy5QmHjdMmbyIaTaYb6BYf+8N7ob5t1ctQA==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1", + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.9.0", + "contentHash": "J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Text.Encoding.CodePages": "8.0.0" + } + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==", + "dependencies": { + "System.Memory": "4.6.3" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Data.Common": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "lm6E3T5u7BOuEH0u18JpbJHxBfOJPuCyl4Kg1RH10ktYLp5uEEE1xKrHW56/We4SnZpGAuCc9N0MJpSDhTHZGQ==" + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.ValueTuple": "4.5.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==" + }, + "System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==" + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.5" + } + }, + "System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.4", + "contentHash": "aOa2d51SEbmM+H+Csw7yJOuNZoHkrP2XnAurye5HWYgGVVU54YZDvsLUYRv6h18X3sPnjNCANmN7ZhIPiqMcjA==", + "dependencies": { + "System.Security.Cryptography.X509Certificates": "4.3.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "System.IO": "4.3.0", + "System.Runtime": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "System.Security.Cryptography.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4", + "System.ValueTuple": "4.5.0" + } + }, + "System.Text.RegularExpressions": { + "type": "Transitive", + "resolved": "4.3.1", + "contentHash": "N0kNRrWe4+nXOWlpLT4LAY5brb8caNFlUuIRpraCVMDLYutKkol1aV079rQjLuSxKMJT2SpBQsYX9xbcTMmzwg==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "System.Memory": "4.5.5" + } + }, + "distributedlock": { + "type": "Project", + "dependencies": { + "DistributedLock.Azure": "[1.0.2, )", + "DistributedLock.FileSystem": "[1.0.3, )", + "DistributedLock.MongoDB": "[1.0.1, )", + "DistributedLock.MySql": "[1.0.2, )", + "DistributedLock.Oracle": "[1.0.5, )", + "DistributedLock.Postgres": "[1.3.1, )", + "DistributedLock.Redis": "[1.1.1, )", + "DistributedLock.SqlServer": "[1.0.7, )", + "DistributedLock.WaitHandles": "[1.0.1, )", + "DistributedLock.ZooKeeper": "[1.0.0, )" + } + }, + "distributedlock.azure": { + "type": "Project", + "dependencies": { + "Azure.Storage.Blobs": "[12.19.1, )", + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "distributedlock.filesystem": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.mongodb": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MongoDB.Driver": "[3.9.0, )", + "System.Diagnostics.DiagnosticSource": "[10.0.5, )" + } + }, + "distributedlock.mysql": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MySqlConnector": "[2.3.5, )" + } + }, + "distributedlock.oracle": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Oracle.ManagedDataAccess": "[23.6.1, )" + } + }, + "distributedlock.postgres": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Npgsql": "[8.0.6, )" + } + }, + "distributedlock.redis": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "StackExchange.Redis": "[2.7.33, )" + } + }, + "distributedlock.sqlserver": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Microsoft.Data.SqlClient": "[6.1.4, )" + } + }, + "distributedlock.waithandles": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.zookeeper": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "ZooKeeperNetEx": "[3.4.12.4, )" + } + }, + "Azure.Storage.Blobs": { + "type": "CentralTransitive", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "System.Buffers": "4.6.1", + "System.Data.Common": "4.3.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Memory": "4.6.3", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6", + "System.Text.RegularExpressions": "4.3.1" + } + }, + "MongoDB.Driver": { + "type": "CentralTransitive", + "requested": "[3.9.0, )", + "resolved": "3.9.0", + "contentHash": "XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Net.Http": "4.3.4", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "ZstdSharp.Port": "0.7.3" + } + }, + "MySqlConnector": { + "type": "CentralTransitive", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1", + "System.Diagnostics.DiagnosticSource": "7.0.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "System.Collections.Immutable": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Json": "8.0.5", + "System.Threading.Channels": "8.0.0" + } + }, + "Oracle.ManagedDataAccess": { + "type": "CentralTransitive", + "requested": "[23.6.1, )", + "resolved": "23.6.1", + "contentHash": "EZi+mahzUwQFWs9Is8ed94eTzWOlfCLMd+DDWukf/h/brTz1wB9Qk3fsxBrjw9+fEXrxDgx4uXNiPHNPRS3BeQ==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.Text.Json": "8.0.5", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "StackExchange.Redis": { + "type": "CentralTransitive", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8", + "System.IO.Compression": "4.3.0", + "System.Threading.Channels": "5.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "ZooKeeperNetEx": { + "type": "CentralTransitive", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + } + }, + "net8.0": { + "MedallionShell.StrongName": { + "type": "Direct", + "requested": "[1.6.2, )", + "resolved": "1.6.2", + "contentHash": "x7kIh8HiLHQrm5tcLEwNXhYfIHjQoK8ZS9MPx/LcCgNubtfFVJZm8Kk5/FSOalHjlXizcLAm6733L691l8cr/Q==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.9.0, )", + "resolved": "17.9.0", + "contentHash": "7GUNAUbJYn644jzwLm5BD3a2p9C1dmP8Hr6fDPDxgItQk9hBs1Svdxzz07KQ/UphMSmgza9AbijBJGmw5D658A==", + "dependencies": { + "Microsoft.CodeCoverage": "17.9.0", + "Microsoft.TestPlatform.TestHost": "17.9.0" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.70, )", + "resolved": "4.20.70", + "contentHash": "4rNnAwdpXJBuxqrOCzCyICXHSImOTRktCgCWXWykuF1qwoIsVvEnR7PjbMk/eLOxWvhmj5Kwt+kDV3RGUYcNwg==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "NUnit": { + "type": "Direct", + "requested": "[3.14.0, )", + "resolved": "3.14.0", + "contentHash": "R7iPwD7kbOaP3o2zldWJbWeMQAvDKD0uld27QvA3PAALl1unl7x0v2J7eGiJOYjimV/BuGT4VJmr45RjS7z4LA==", + "dependencies": { + "NETStandard.Library": "2.0.0" + } + }, + "NUnit.Analyzers": { + "type": "Direct", + "requested": "[4.1.0, )", + "resolved": "4.1.0", + "contentHash": "Odd1RusSMnfswIiCPbokAqmlcCCXjQ20poaXWrw+CWDnBY1vQ/x6ZGqgyJXpebPq5Uf8uEBe5iOAySsCdSrWdQ==" + }, + "NUnit3TestAdapter": { + "type": "Direct", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "s8JpqTe9bI2f49Pfr3dFRfoVSuFQyraTj68c3XXjIS/MRGvvkLnrg6RLqnTjdShX+AdFUCCU/4Xex58AdUfs6A==" + }, + "System.Data.SqlClient": { + "type": "Direct", + "requested": "[4.8.6, )", + "resolved": "4.8.6", + "contentHash": "2Ij/LCaTQRyAi5lAv7UUTV9R2FobC8xN9mE0fXBZohum/xLl8IZVmE98Rq5ugQHjCgTBRKqpXRb4ORulRdA6Ig==", + "dependencies": { + "Microsoft.Win32.Registry": "4.7.0", + "System.Security.Principal.Windows": "4.7.0", + "runtime.native.System.Data.SqlClient.sni": "4.7.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.9.0", + "contentHash": "RGD37ZSrratfScYXm7M0HjvxMxZyWZL4jm+XgMZbkIY1UPgjUpbNA/t+WTGj/rC/0Hm9A3IrH3ywbKZkOCnoZA==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.9.0", + "contentHash": "1ilw/8vgmjLyKU+2SKXKXaOqpYFJCQfGqGz+x0cosl981VzjrY74Sv6qAJv+neZMZ9ZMxF3ArN6kotaQ4uvEBw==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.9.0", + "contentHash": "Spmg7Wx49Ya3SxBjyeAR+nQpjMTKZwTwpZ7KyeOTIqI/WHNPnBU4HUvl5kuHPQAwGWqMy4FGZja1HvEwvoaDiA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.9.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.9.0", + "contentHash": "J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "NETStandard.Library": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "7jnbRU+L08FXKMxqUflxEXtVymWvNOrS8yHgu9s6EM8Anr6T/wIX4nZ08j/u3Asz+tCufp3YVwFSEvFTPYmBPA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "Oracle.ManagedDataAccess.Core": { + "type": "Transitive", + "resolved": "23.6.1", + "contentHash": "Oc8AX7xme05xrp4/aCxKBH4+bpWgMCFafXI7LbLO/7OBMJLZRXhMtejDgIb8aYvIVyV7vSdAy3LkCYcJorxn1A==", + "dependencies": { + "System.Diagnostics.PerformanceCounter": "8.0.0", + "System.DirectoryServices.Protocols": "8.0.0", + "System.Formats.Asn1": "8.0.1", + "System.Security.Cryptography.Pkcs": "8.0.0" + } + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "9kyFSIdN3T0qjDQ2R0HRXYIhS3l5psBzQi6qqhdLz+SzFyEy4sVxNOke+yyYv8Cu8rPER12c3RDjLT8wF3WBYQ==", + "dependencies": { + "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": "4.4.0", + "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": "4.4.0", + "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": "4.4.0" + } + }, + "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "LbrynESTp3bm5O/+jGL8v0Qg5SJlTV08lpIpFesXjF6uGNMWqFnUQbYBJwZTeua6E/Y7FIM1C54Ey1btLWupdg==" + }, + "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "38ugOfkYJqJoX9g6EYRlZB5U2ZJH51UP8ptxZgdpS07FgOEToV+lS11ouNK2PM12Pr6X/PpT5jK82G3DwH/SxQ==" + }, + "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "YhEdSQUsTx+C8m8Bw7ar5/VesXvCFMItyZF7G1AUY+OM0VPZUOeAVpJ4Wl6fydBGUYZxojTDR3I6Bj/+BPkJNA==" + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==" + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==", + "dependencies": { + "System.Diagnostics.EventLog": "8.0.1", + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "vaoWjvkG1aenR2XdjaVivlCV9fADfgyhW5bZtXT23qaEea0lWiUljdQuze4E31vKM7ZWJaSUsbYIKE3rnzfZUg==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "n1ZP7NM2Gkn/MgD8+eOT5MulMj6wfeQMNS2Pizvq5GHCZfjlFMXV2irQlQmJhwA2VABC57M0auudO89Iu2uRLg==" + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "lX6DXxtJqVGWw7N/QmVoiCyVQ+Q/Xp+jVXPr3gLK1jJExSn1qmAjJQeb8gnOYeeBTG3E3PmG1nu92eYj/TEjpg==", + "dependencies": { + "System.Configuration.ConfigurationManager": "8.0.0" + } + }, + "System.DirectoryServices.Protocols": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "puwJxURHDrYLGTQdsHyeMS72ClTqYa4lDYz6LHSbkZEk5hq8H8JfsO4MyYhB5BMMxg93jsQzLUwrnCumj11UIg==" + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZD9TMpsmYJLrxbbmdvhwt9YEgG5WntEnZ/d1eH8JBX9LBp+Ju8BSBhUGbZMNVHHomWo2KVImJhTDl2hIgw/6MA==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==" + }, + "distributedlock": { + "type": "Project", + "dependencies": { + "DistributedLock.Azure": "[1.0.2, )", + "DistributedLock.FileSystem": "[1.0.3, )", + "DistributedLock.MongoDB": "[1.0.1, )", + "DistributedLock.MySql": "[1.0.2, )", + "DistributedLock.Oracle": "[1.0.5, )", + "DistributedLock.Postgres": "[1.3.1, )", + "DistributedLock.Redis": "[1.1.1, )", + "DistributedLock.SqlServer": "[1.0.7, )", + "DistributedLock.WaitHandles": "[1.0.1, )", + "DistributedLock.ZooKeeper": "[1.0.0, )" + } + }, + "distributedlock.azure": { + "type": "Project", + "dependencies": { + "Azure.Storage.Blobs": "[12.19.1, )", + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.core": { + "type": "Project" + }, + "distributedlock.filesystem": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.mongodb": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MongoDB.Driver": "[3.9.0, )" + } + }, + "distributedlock.mysql": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MySqlConnector": "[2.3.5, )" + } + }, + "distributedlock.oracle": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Oracle.ManagedDataAccess.Core": "[23.6.1, )" + } + }, + "distributedlock.postgres": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Npgsql": "[8.0.6, )" + } + }, + "distributedlock.redis": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "StackExchange.Redis": "[2.7.33, )" + } + }, + "distributedlock.sqlserver": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Microsoft.Data.SqlClient": "[6.1.4, )" + } + }, + "distributedlock.waithandles": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "System.Threading.AccessControl": "[8.0.0, )" + } + }, + "distributedlock.zookeeper": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "ZooKeeperNetEx": "[3.4.12.4, )" + } + }, + "Azure.Storage.Blobs": { + "type": "CentralTransitive", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "8.0.1", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Security.Cryptography.Pkcs": "8.0.1" + } + }, + "MongoDB.Driver": { + "type": "CentralTransitive", + "requested": "[3.9.0, )", + "resolved": "3.9.0", + "contentHash": "XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "System.Buffers": "4.6.1", + "ZstdSharp.Port": "0.7.3" + } + }, + "MySqlConnector": { + "type": "CentralTransitive", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "StackExchange.Redis": { + "type": "CentralTransitive", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, + "System.Threading.AccessControl": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "cIed5+HuYz+eV9yu9TH95zPkqmm1J9Qps9wxjB335sU8tsqc2kGdlTEH9FZzZeCS8a7mNSEsN8ZkyhQp1gfdEw==" + }, + "ZooKeeperNetEx": { + "type": "CentralTransitive", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + } + } + } +} \ No newline at end of file diff --git a/DistributedLock.WaitHandles/AssemblyAttributes.cs b/src/DistributedLock.WaitHandles/AssemblyAttributes.cs similarity index 100% rename from DistributedLock.WaitHandles/AssemblyAttributes.cs rename to src/DistributedLock.WaitHandles/AssemblyAttributes.cs diff --git a/DistributedLock.WaitHandles/DistributedLock.WaitHandles.csproj b/src/DistributedLock.WaitHandles/DistributedLock.WaitHandles.csproj similarity index 59% rename from DistributedLock.WaitHandles/DistributedLock.WaitHandles.csproj rename to src/DistributedLock.WaitHandles/DistributedLock.WaitHandles.csproj index 6de629b7..753171d4 100644 --- a/DistributedLock.WaitHandles/DistributedLock.WaitHandles.csproj +++ b/src/DistributedLock.WaitHandles/DistributedLock.WaitHandles.csproj @@ -1,22 +1,23 @@ - netstandard2.0;netstandard2.1;net461 + netstandard2.0;netstandard2.1;net462 Medallion.Threading.WaitHandles True 4 Latest enable + enable - 1.0.0-alpha01 + 1.0.1 1.0.0.0 Michael Adelson - TODO + Provides a distributed lock implementation based on global WaitHandle objects in Windows Copyright © 2020 Michael Adelson MIT - TODO + distributed lock async mutex waithandle https://github.com/madelson/DistributedLock https://github.com/madelson/DistributedLock 1.0.0.0 @@ -30,6 +31,11 @@ True True + + embedded + + true + true @@ -39,10 +45,18 @@ - + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.WaitHandles/DistributedWaitHandleHelpers.cs b/src/DistributedLock.WaitHandles/DistributedWaitHandleHelpers.cs new file mode 100644 index 00000000..5b8feb74 --- /dev/null +++ b/src/DistributedLock.WaitHandles/DistributedWaitHandleHelpers.cs @@ -0,0 +1,178 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.WaitHandles; + +internal static class DistributedWaitHandleHelpers +{ + internal const string GlobalPrefix = @"Global\"; + private static readonly TimeoutValue DefaultAbandonmentCheckCadence = TimeSpan.FromSeconds(2); + + // 260 based on LINQPad experimentation + public const int MaxNameLength = 260; + + public static string GetSafeName(string name) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + // Note: the reason we don't add GlobalPrefix inside the ToSafeLockName callback + // is for backwards compat with the SystemDistributedLock.GetSafeLockName in 1.0. + // In that version, the global prefix was not exposed as part of the name, and as + // such it was not accounted for in the hashing performed by ToSafeLockName. + + if (name.StartsWith(GlobalPrefix, StringComparison.Ordinal)) + { + var suffix = name.Substring(GlobalPrefix.Length); + var safeSuffix = ConvertToSafeSuffix(suffix); + return safeSuffix == suffix ? name : GlobalPrefix + safeSuffix; + } + + return GlobalPrefix + ConvertToSafeSuffix(name); + + static string ConvertToSafeSuffix(string suffix) => DistributedLockHelpers.ToSafeName( + suffix, + MaxNameLength - GlobalPrefix.Length, + s => s.Length == 0 ? "EMPTY" : s.Replace('\\', '_') + ); + } + + public static string ValidateAndFinalizeName(string name, bool exactName) + { + if (exactName) + { + ValidateName(name); + return name; + } + + return GetSafeName(name); + } + + private static void ValidateName(string name) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + if (name.Length > MaxNameLength) { throw new FormatException($"{nameof(name)}: must be at most {MaxNameLength} characters"); } + if (!name.StartsWith(GlobalPrefix, StringComparison.Ordinal)) { throw new FormatException($"{nameof(name)}: must start with '{GlobalPrefix}'"); } + if (name == GlobalPrefix) { throw new FormatException($"{nameof(name)} must not be exactly '{GlobalPrefix}'"); } + if (name.IndexOf('\\', startIndex: GlobalPrefix.Length) >= 0) { throw new FormatException(nameof(name) + @": must not contain '\'"); } + } + + public static TimeoutValue ValidateAndFinalizeAbandonmentCheckCadence(TimeSpan? abandonmentCheckCadence) + { + if (abandonmentCheckCadence.HasValue) + { + var result = new TimeoutValue(abandonmentCheckCadence, nameof(abandonmentCheckCadence)); + if (result.IsZero) { throw new ArgumentOutOfRangeException(nameof(abandonmentCheckCadence), "must not be zero"); } + return result; + } + return DefaultAbandonmentCheckCadence; + } + + public static TWaitHandle CreateDistributedWaitHandle( + Func createNew, + TryOpenExisting tryOpenExisting) + where TWaitHandle : WaitHandle + { + const int MaxTries = 3; + var tries = 0; + + while (true) + { + ++tries; + try + { + return createNew(); + } + // fallback handling based on https://stackoverflow.com/questions/1784392/my-eventwaithandle-says-access-to-the-path-is-denied-but-its-not + catch (UnauthorizedAccessException) when (tries <= MaxTries) + { + if (tryOpenExisting(out var existing)) + { + return existing; + } + } + + // if we fail both, we might be in a race. Add in a small random sleep to attempt desynchronization + Thread.Sleep(new Random(Guid.NewGuid().GetHashCode()).Next(10 * tries)); + } + } + + public delegate bool TryOpenExisting(out TWaitHandle existing) where TWaitHandle : WaitHandle; + + public static async ValueTask CreateAndWaitAsync( + Func createHandle, + TimeoutValue abandonmentCheckCadence, + TimeoutValue timeout, + CancellationToken cancellationToken) + where TWaitHandle : WaitHandle + { + var handle = createHandle(); + var cleanup = true; + try + { + if (abandonmentCheckCadence.IsInfinite) + { + // no abandonment check: just acquire once + if (await handle.WaitOneAsync(timeout, cancellationToken).ConfigureAwait(false)) + { + cleanup = false; + return handle; + } + return null; + } + + if (timeout.IsInfinite) + { + // infinite timeout: just loop forever with the abandonment check + while (true) + { + if (await handle.WaitOneAsync(abandonmentCheckCadence, cancellationToken).ConfigureAwait(false)) + { + cleanup = false; + return handle; + } + + // refresh the event in case it was abandoned by the original owner + RefreshEvent(); + } + } + + // fixed timeout: loop in abandonment check chunks + var elapsedMillis = 0; + do + { + var nextWaitMillis = Math.Min(abandonmentCheckCadence.InMilliseconds, timeout.InMilliseconds - elapsedMillis); + if (await handle.WaitOneAsync(TimeSpan.FromMilliseconds(nextWaitMillis), cancellationToken).ConfigureAwait(false)) + { + cleanup = false; + return handle; + } + + elapsedMillis += nextWaitMillis; + + // refresh the event in case it was abandoned by the original owner + RefreshEvent(); + } + while (elapsedMillis < timeout.InMilliseconds); + + return null; + } + catch + { + // just in case we fail to create a scope or something + cleanup = true; + throw; + } + finally + { + if (cleanup) + { + handle.Dispose(); + } + } + + void RefreshEvent() + { + handle.Dispose(); + handle = createHandle(); + } + } +} diff --git a/src/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.IDistributedLock.cs b/src/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..c8214e6d --- /dev/null +++ b/src/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.IDistributedLock.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.WaitHandles; + +public partial class EventWaitHandleDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public EventWaitHandleDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public EventWaitHandleDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// An which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.cs b/src/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.cs new file mode 100644 index 00000000..a885aa4d --- /dev/null +++ b/src/DistributedLock.WaitHandles/EventWaitHandleDistributedLock.cs @@ -0,0 +1,70 @@ +using Medallion.Threading.Internal; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace Medallion.Threading.WaitHandles; + +/// +/// A distributed lock based on a global on Windows. +/// +public sealed partial class EventWaitHandleDistributedLock : IInternalDistributedLock +{ + private readonly TimeoutValue _abandonmentCheckCadence; + + /// + /// Constructs a lock with the given . + /// + /// specifies how frequently we refresh our object in case it is abandoned by + /// its original owner. The default is 2s. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public EventWaitHandleDistributedLock(string name, TimeSpan? abandonmentCheckCadence = null, bool exactName = false) + { + this.Name = DistributedWaitHandleHelpers.ValidateAndFinalizeName(name, exactName); + this._abandonmentCheckCadence = DistributedWaitHandleHelpers.ValidateAndFinalizeAbandonmentCheckCadence(abandonmentCheckCadence); + } + + /// + /// Implements + /// + public string Name { get; } + + async ValueTask IInternalDistributedLock.InternalTryAcquireAsync( + TimeoutValue timeout, + CancellationToken cancellationToken) + { + var @event = await DistributedWaitHandleHelpers.CreateAndWaitAsync( + createHandle: this.CreateEvent, + abandonmentCheckCadence: this._abandonmentCheckCadence, + timeout: timeout, + cancellationToken: cancellationToken + ).ConfigureAwait(false); + return @event != null ? new EventWaitHandleDistributedLockHandle(@event) : null; + } + + private EventWaitHandle CreateEvent() => DistributedWaitHandleHelpers.CreateDistributedWaitHandle( + createNew: () => + { + // based on http://stackoverflow.com/questions/2590334/creating-a-cross-process-eventwaithandle + var security = new EventWaitHandleSecurity(); + // allow anyone to wait on and signal this lock + security.AddAccessRule(new EventWaitHandleAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, domainSid: null), + EventWaitHandleRights.FullControl, // doesn't seem to work without this :-/ + AccessControlType.Allow + )); + var @event = new EventWaitHandle( + // if we create, start as unlocked + initialState: true, + // allow only one thread to hold the lock + mode: EventResetMode.AutoReset, + name: this.Name, + createdNew: out var createdNew + ); + if (createdNew) { @event.SetAccessControl(security); } + return @event; + }, + tryOpenExisting: delegate (out EventWaitHandle existing) { return EventWaitHandle.TryOpenExisting(this.Name, out existing); } + ); +} diff --git a/src/DistributedLock.WaitHandles/EventWaitHandleDistributedLockHandle.cs b/src/DistributedLock.WaitHandles/EventWaitHandleDistributedLockHandle.cs new file mode 100644 index 00000000..72a44922 --- /dev/null +++ b/src/DistributedLock.WaitHandles/EventWaitHandleDistributedLockHandle.cs @@ -0,0 +1,41 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.WaitHandles; + +/// +/// Implements +/// +public sealed class EventWaitHandleDistributedLockHandle : IDistributedSynchronizationHandle +{ + private EventWaitHandle? _event; + + internal EventWaitHandleDistributedLockHandle(EventWaitHandle @event) + { + this._event = @event; + } + + CancellationToken IDistributedSynchronizationHandle.HandleLostToken => + Volatile.Read(ref this._event) != null ? CancellationToken.None : throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() + { + var @event = Interlocked.Exchange(ref this._event, null); + if (@event != null) + { + @event.Set(); // signal + @event.Dispose(); + } + } + + /// + /// Releases the lock asynchronously + /// + public ValueTask DisposeAsync() + { + this.Dispose(); + return default; + } +} diff --git a/src/DistributedLock.WaitHandles/PublicAPI.Shipped.txt b/src/DistributedLock.WaitHandles/PublicAPI.Shipped.txt new file mode 100644 index 00000000..e8d15587 --- /dev/null +++ b/src/DistributedLock.WaitHandles/PublicAPI.Shipped.txt @@ -0,0 +1,27 @@ +#nullable enable +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLock +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLock.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.WaitHandles.EventWaitHandleDistributedLockHandle! +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLock.EventWaitHandleDistributedLock(string! name, System.TimeSpan? abandonmentCheckCadence = null, bool exactName = false) -> void +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLock.Name.get -> string! +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLock.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.WaitHandles.EventWaitHandleDistributedLockHandle? +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLockHandle +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLockHandle.Dispose() -> void +Medallion.Threading.WaitHandles.EventWaitHandleDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphore +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphore.Acquire(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphoreHandle! +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphore.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphore.MaxCount.get -> int +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphore.Name.get -> string! +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphore.TryAcquire(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphoreHandle? +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphore.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphore.WaitHandleDistributedSemaphore(string! name, int maxCount, System.TimeSpan? abandonmentCheckCadence = null, bool exactName = false) -> void +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphoreHandle +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphoreHandle.Dispose() -> void +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphoreHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphoreHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.WaitHandles.WaitHandleDistributedSynchronizationProvider +Medallion.Threading.WaitHandles.WaitHandleDistributedSynchronizationProvider.CreateLock(string! name, bool exactName = false) -> Medallion.Threading.WaitHandles.EventWaitHandleDistributedLock! +Medallion.Threading.WaitHandles.WaitHandleDistributedSynchronizationProvider.CreateSemaphore(string! name, int maxCount, bool exactName = false) -> Medallion.Threading.WaitHandles.WaitHandleDistributedSemaphore! +Medallion.Threading.WaitHandles.WaitHandleDistributedSynchronizationProvider.WaitHandleDistributedSynchronizationProvider(System.TimeSpan? abandonmentCheckCadence = null) -> void \ No newline at end of file diff --git a/src/DistributedLock.WaitHandles/PublicAPI.Unshipped.txt b/src/DistributedLock.WaitHandles/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/DistributedLock.WaitHandles/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/DistributedLock.WaitHandles/WaitHandleDistributedSemaphore.IDistributedSemaphore.cs b/src/DistributedLock.WaitHandles/WaitHandleDistributedSemaphore.IDistributedSemaphore.cs new file mode 100644 index 00000000..5ec9994c --- /dev/null +++ b/src/DistributedLock.WaitHandles/WaitHandleDistributedSemaphore.IDistributedSemaphore.cs @@ -0,0 +1,81 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.WaitHandles; + +public partial class WaitHandleDistributedSemaphore +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedSemaphore.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedSemaphore.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedSemaphore.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedSemaphore.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire a semaphore ticket synchronously. Usage: + /// + /// using (var handle = mySemaphore.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the ticket! */ } + /// } + /// // dispose releases the ticket if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket or null on failure + public WaitHandleDistributedSemaphoreHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires a semaphore ticket synchronously, failing with if the attempt times out. Usage: + /// + /// using (mySemaphore.Acquire(...)) + /// { + /// /* we have the ticket! */ + /// } + /// // dispose releases the ticket + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket + public WaitHandleDistributedSemaphoreHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire a semaphore ticket asynchronously. Usage: + /// + /// await using (var handle = await mySemaphore.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the ticket! */ } + /// } + /// // dispose releases the ticket if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires a semaphore ticket asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await mySemaphore.AcquireAsync(...)) + /// { + /// /* we have the ticket! */ + /// } + /// // dispose releases the ticket + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.WaitHandles/WaitHandleDistributedSemaphore.cs b/src/DistributedLock.WaitHandles/WaitHandleDistributedSemaphore.cs new file mode 100644 index 00000000..1d805ecc --- /dev/null +++ b/src/DistributedLock.WaitHandles/WaitHandleDistributedSemaphore.cs @@ -0,0 +1,68 @@ +using Medallion.Threading.Internal; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace Medallion.Threading.WaitHandles; + +/// +/// Implements a distributed semaphore based on a global +/// +public sealed partial class WaitHandleDistributedSemaphore : IInternalDistributedSemaphore +{ + private readonly TimeoutValue _abandonmentCheckCadence; + + /// + /// Constructs a lock with the given . + /// + /// specifies how frequently we refresh our object in case it is abandoned by + /// its original owner. The default is 2s. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public WaitHandleDistributedSemaphore(string name, int maxCount, TimeSpan? abandonmentCheckCadence = null, bool exactName = false) + { + if (maxCount < 1) { throw new ArgumentOutOfRangeException(nameof(maxCount), maxCount, "must be positive"); } + + this.Name = DistributedWaitHandleHelpers.ValidateAndFinalizeName(name, exactName); + this.MaxCount = maxCount; + this._abandonmentCheckCadence = DistributedWaitHandleHelpers.ValidateAndFinalizeAbandonmentCheckCadence(abandonmentCheckCadence); + } + + /// + /// Implements + /// + public string Name { get; } + + /// + /// Implements + /// + public int MaxCount { get; } + + async ValueTask IInternalDistributedSemaphore.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) + { + var semaphore = await DistributedWaitHandleHelpers.CreateAndWaitAsync( + createHandle: this.CreateSemaphore, + abandonmentCheckCadence: this._abandonmentCheckCadence, + timeout: timeout, + cancellationToken: cancellationToken + ).ConfigureAwait(false); + return semaphore != null ? new WaitHandleDistributedSemaphoreHandle(semaphore) : null; + } + + private Semaphore CreateSemaphore() => DistributedWaitHandleHelpers.CreateDistributedWaitHandle( + createNew: () => + { + var security = new SemaphoreSecurity(); + // allow anyone to wait on and signal this semaphore + security.AddAccessRule(new SemaphoreAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, domainSid: null), + SemaphoreRights.FullControl, + AccessControlType.Allow + )); + var semaphore = new Semaphore(initialCount: this.MaxCount, maximumCount: this.MaxCount, name: this.Name, createdNew: out var createdNew); + if (createdNew) { semaphore.SetAccessControl(security); } + return semaphore; + }, + tryOpenExisting: delegate (out Semaphore existing) { return Semaphore.TryOpenExisting(this.Name, out existing); } + ); +} diff --git a/src/DistributedLock.WaitHandles/WaitHandleDistributedSemaphoreHandle.cs b/src/DistributedLock.WaitHandles/WaitHandleDistributedSemaphoreHandle.cs new file mode 100644 index 00000000..e16e5a7d --- /dev/null +++ b/src/DistributedLock.WaitHandles/WaitHandleDistributedSemaphoreHandle.cs @@ -0,0 +1,70 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.WaitHandles; + +/// +/// Implements +/// +public sealed class WaitHandleDistributedSemaphoreHandle : IDistributedSynchronizationHandle +{ + private readonly SemaphoreReleaser _semaphoreReleaser; + private IDisposable? _finalizerRegistration; + + internal WaitHandleDistributedSemaphoreHandle(Semaphore semaphore) + { + this._semaphoreReleaser = new SemaphoreReleaser(semaphore); + // We need a managed finalizer here because an abandoned Semaphore instance won't release its tickets unless + // all instances of that Semaphore are also abandoned (any one live instance tracks the current ticket count). + this._finalizerRegistration = ManagedFinalizerQueue.Instance.Register(this, this._semaphoreReleaser); + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => + Volatile.Read(ref this._finalizerRegistration) != null ? CancellationToken.None : throw this.ObjectDisposed(); + + /// + /// Releases the semaphore ticket + /// + public void Dispose() + { + Interlocked.Exchange(ref this._finalizerRegistration, null)?.Dispose(); + this._semaphoreReleaser.Dispose(); + } + + /// + /// Releases the semaphore ticket + /// + public ValueTask DisposeAsync() + { + this.Dispose(); + return default; + } + + private class SemaphoreReleaser : IDisposable, IAsyncDisposable + { + private Semaphore? _semaphore; + + public SemaphoreReleaser(Semaphore semaphore) + { + this._semaphore = semaphore; + } + + public void Dispose() + { + var semaphore = Interlocked.Exchange(ref this._semaphore, null); + if (semaphore != null) + { + try { semaphore.Release(); } + finally { semaphore.Dispose(); } + } + } + + public ValueTask DisposeAsync() + { + this.Dispose(); + return default; + } + } +} diff --git a/src/DistributedLock.WaitHandles/WaitHandleDistributedSynchronizationProvider.cs b/src/DistributedLock.WaitHandles/WaitHandleDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..709cd9b4 --- /dev/null +++ b/src/DistributedLock.WaitHandles/WaitHandleDistributedSynchronizationProvider.cs @@ -0,0 +1,38 @@ +namespace Medallion.Threading.WaitHandles; + +/// +/// Implements for +/// and for . +/// +public sealed class WaitHandleDistributedSynchronizationProvider : IDistributedLockProvider, IDistributedSemaphoreProvider +{ + private readonly TimeSpan? _abandonmentCheckCadence; + + /// + /// Constructs a using the provided . + /// + public WaitHandleDistributedSynchronizationProvider(TimeSpan? abandonmentCheckCadence = null) + { + this._abandonmentCheckCadence = abandonmentCheckCadence; + } + + /// + /// Creates a with the given . Unless + /// is specified, invalid wait handle names will be escaped/hashed. + /// + public EventWaitHandleDistributedLock CreateLock(string name, bool exactName = false) => + new(name, this._abandonmentCheckCadence, exactName); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); + + /// + /// Creates a with the given + /// and . Unless is specified, invalid wait + /// handle names will be escaped/hashed. + /// + public WaitHandleDistributedSemaphore CreateSemaphore(string name, int maxCount, bool exactName = false) => + new(name, maxCount, this._abandonmentCheckCadence, exactName); + + IDistributedSemaphore IDistributedSemaphoreProvider.CreateSemaphore(string name, int maxCount) => + this.CreateSemaphore(name, maxCount); +} diff --git a/src/DistributedLock.WaitHandles/WaitHandleExtensions.cs b/src/DistributedLock.WaitHandles/WaitHandleExtensions.cs new file mode 100644 index 00000000..e274ca34 --- /dev/null +++ b/src/DistributedLock.WaitHandles/WaitHandleExtensions.cs @@ -0,0 +1,134 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.WaitHandles; + +internal static class WaitHandleExtensions +{ + public static async ValueTask WaitOneAsync(this WaitHandle waitHandle, TimeoutValue timeout, CancellationToken cancellationToken) + { + if (timeout.IsZero || SyncViaAsync.IsSynchronous) + { + return waitHandle.InternalWaitOne(timeout, cancellationToken); + } + + // when doing an async wait, still do a quick sync check first with timeout zero to optimize the already-signaled case + return waitHandle.InternalWaitOne(TimeSpan.Zero, cancellationToken) + || await waitHandle.InternalWaitOneAsync(timeout, cancellationToken).ConfigureAwait(false); + } + + private static bool InternalWaitOne(this WaitHandle waitHandle, TimeoutValue timeout, CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + return waitHandle.WaitOne(timeout.InMilliseconds); + } + + // if, upon entering the method we are already both canceled and signaled, this check + // ensures that we cancel + cancellationToken.ThrowIfCancellationRequested(); + + // optimize the already-signaled case + if (waitHandle.WaitOne(TimeSpan.Zero)) + { + return true; + } + if (timeout.IsZero) + { + return false; + } + + // cancellable wait based on + // http://www.thomaslevesque.com/2015/06/04/async-and-cancellation-support-for-wait-handles/ + var index = WaitHandle.WaitAny(new[] { waitHandle, cancellationToken.WaitHandle }, timeout.InMilliseconds); + return index switch + { + // timeout + WaitHandle.WaitTimeout => false, + // event + 0 => true, + // canceled + _ => throw new OperationCanceledException(cancellationToken), + }; + } + + // based on http://www.thomaslevesque.com/2015/06/04/async-and-cancellation-support-for-wait-handles/ + private static async ValueTask InternalWaitOneAsync(this WaitHandle waitHandle, TimeoutValue timeout, CancellationToken cancellationToken) + { + Invariant.Require(!cancellationToken.CanBeCanceled || waitHandle is EventWaitHandle or Semaphore); // keep in sync with Resignal() + + var taskCompletionSource = new TaskCompletionSource(); + + RegisteredWaitHandle? registeredHandle = null; + CancellationTokenRegistration tokenRegistration = default; + try + { + // if, upon entering the method we are already both canceled and signaled, + // putting this first ensures that we cancel + tokenRegistration = cancellationToken.Register( + static state => ((TaskCompletionSource)state).TrySetCanceled(), + state: taskCompletionSource + ); + registeredHandle = ThreadPool.RegisterWaitForSingleObject( + waitHandle, + static (state, timedOut) => OnSignaled(state, timedOut), + state: Tuple.Create(taskCompletionSource, waitHandle), + millisecondsTimeOutInterval: timeout.InMilliseconds, + executeOnlyOnce: true + ); + return await taskCompletionSource.Task.ConfigureAwait(false); + } + finally + { + if (registeredHandle != null) + { + if (taskCompletionSource.Task.IsCanceled) + { + // If the task got canceled, then there is a slim chance of a race condition where + // the wait callback is still running, and hasn't re-signaled the handle yet. If we + // return before that point then we might dispose the handle, before getting to re-signal + // it. To prevent that, we pass in an MRE which will be signaled when the reservation fully + // completes and we wait for that signal before returning. + using ManualResetEvent unregisterCompleteEvent = new(initialState: false); + registeredHandle.Unregister(unregisterCompleteEvent); + await unregisterCompleteEvent.WaitOneAsync(Timeout.InfiniteTimeSpan, CancellationToken.None).ConfigureAwait(false); + } + else + { + registeredHandle.Unregister(null); + } + } + tokenRegistration.Dispose(); + } + + static void OnSignaled(object state, bool timedOut) + { + var (taskCompletionSource, waitHandle) = (Tuple, WaitHandle>)state; + if (!taskCompletionSource.TrySetResult(!timedOut) && !timedOut && taskCompletionSource.Task.IsCanceled) + { + // If we received a signal (not a timeout) and we lost the race with cancellation, resignal + // the handle to avoid the signal being lost. See https://github.com/madelson/DistributedLock/issues/120 + Resignal(waitHandle); + } + } + } + + private static void Resignal(WaitHandle waitHandle) + { + try + { + if (waitHandle is EventWaitHandle @event) + { + @event.Set(); + } + else if (waitHandle is Semaphore semaphore) + { + semaphore.Release(); + } + } + catch + { + // Since this method runs in a threadpool thread, we don't want it to throw + // even if the methods above fail (e.g. with SemaphoreFullException). + } + } +} diff --git a/src/DistributedLock.WaitHandles/packages.lock.json b/src/DistributedLock.WaitHandles/packages.lock.json new file mode 100644 index 00000000..b4872e3d --- /dev/null +++ b/src/DistributedLock.WaitHandles/packages.lock.json @@ -0,0 +1,273 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.ValueTuple": { + "type": "CentralTransitive", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + } + }, + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "System.Threading.AccessControl": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "cIed5+HuYz+eV9yu9TH95zPkqmm1J9Qps9wxjB335sU8tsqc2kGdlTEH9FZzZeCS8a7mNSEsN8ZkyhQp1gfdEw==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Security.AccessControl": "6.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "AUADIc0LIEQe7MzC+I0cl0rAT8RrTAKFHl53yHjEUzNVIaUlhFY11vc2ebiVJzVBuOzun6F7FBA+8KAbGTTedQ==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "System.Threading.AccessControl": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "cIed5+HuYz+eV9yu9TH95zPkqmm1J9Qps9wxjB335sU8tsqc2kGdlTEH9FZzZeCS8a7mNSEsN8ZkyhQp1gfdEw==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Security.AccessControl": "6.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "AUADIc0LIEQe7MzC+I0cl0rAT8RrTAKFHl53yHjEUzNVIaUlhFY11vc2ebiVJzVBuOzun6F7FBA+8KAbGTTedQ==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "distributedlock.core": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLock.ZooKeeper/AssemblyAttributes.cs b/src/DistributedLock.ZooKeeper/AssemblyAttributes.cs new file mode 100644 index 00000000..a9e3a34f --- /dev/null +++ b/src/DistributedLock.ZooKeeper/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] diff --git a/src/DistributedLock.ZooKeeper/DistributedLock.ZooKeeper.csproj b/src/DistributedLock.ZooKeeper/DistributedLock.ZooKeeper.csproj new file mode 100644 index 00000000..cd5c163c --- /dev/null +++ b/src/DistributedLock.ZooKeeper/DistributedLock.ZooKeeper.csproj @@ -0,0 +1,68 @@ + + + + netstandard2.0;netstandard2.1;net462 + Medallion.Threading.ZooKeeper + True + 4 + Latest + enable + enable + + + + 1.0.0 + 1.0.0.0 + Michael Adelson + Provides a distributed locking implementation based on Apache ZooKeeper + Copyright © 2020 Michael Adelson + MIT + distributed lock async zookeeper + https://github.com/madelson/DistributedLock + https://github.com/madelson/DistributedLock + 1.0.0.0 + See https://github.com/madelson/DistributedLock#release-notes + true + ..\DistributedLock.snk + + + + True + True + True + + + embedded + + true + true + + + + False + 1591 + TRACE;DEBUG + + + + + + + all + + + all + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock.ZooKeeper/PublicAPI.Shipped.txt b/src/DistributedLock.ZooKeeper/PublicAPI.Shipped.txt new file mode 100644 index 00000000..aa90d463 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/PublicAPI.Shipped.txt @@ -0,0 +1,54 @@ +#nullable enable +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLock +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLock.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLock.Path.get -> Medallion.Threading.ZooKeeper.ZooKeeperPath +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLock.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLock.ZooKeeperDistributedLock(Medallion.Threading.ZooKeeper.ZooKeeperPath directoryPath, string! name, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLock.ZooKeeperDistributedLock(Medallion.Threading.ZooKeeper.ZooKeeperPath path, string! connectionString, bool assumePathExists = false, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLock.ZooKeeperDistributedLock(string! name, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLockHandle +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock.AcquireReadLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock.AcquireWriteLockAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock.Path.get -> Medallion.Threading.ZooKeeper.ZooKeeperPath +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock.TryAcquireReadLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock.TryAcquireWriteLockAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock.ZooKeeperDistributedReaderWriterLock(Medallion.Threading.ZooKeeper.ZooKeeperPath directoryPath, string! name, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock.ZooKeeperDistributedReaderWriterLock(Medallion.Threading.ZooKeeper.ZooKeeperPath path, string! connectionString, bool assumePathExists = false, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock.ZooKeeperDistributedReaderWriterLock(string! name, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLockHandle +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLockHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLockHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphore +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphore.AcquireAsync(System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphore.MaxCount.get -> int +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphore.Path.get -> Medallion.Threading.ZooKeeper.ZooKeeperPath +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphore.TryAcquireAsync(System.TimeSpan timeout = default(System.TimeSpan), System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphore.ZooKeeperDistributedSemaphore(Medallion.Threading.ZooKeeper.ZooKeeperPath directoryPath, string! name, int maxCount, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphore.ZooKeeperDistributedSemaphore(Medallion.Threading.ZooKeeper.ZooKeeperPath path, int maxCount, string! connectionString, bool assumePathExists = false, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphore.ZooKeeperDistributedSemaphore(string! name, int maxCount, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphoreHandle +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphoreHandle.DisposeAsync() -> System.Threading.Tasks.ValueTask +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphoreHandle.HandleLostToken.get -> System.Threading.CancellationToken +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationOptionsBuilder +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationOptionsBuilder.AddAccessControl(string! scheme, string! id, int permissionFlags) -> Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationOptionsBuilder! +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationOptionsBuilder.AddAuthInfo(string! scheme, System.Collections.Generic.IReadOnlyList! auth) -> Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationOptionsBuilder! +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationOptionsBuilder.ConnectTimeout(System.TimeSpan connectTimeout) -> Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationOptionsBuilder! +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationOptionsBuilder.SessionTimeout(System.TimeSpan sessionTimeout) -> Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationOptionsBuilder! +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationProvider +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationProvider.CreateLock(string! name) -> Medallion.Threading.ZooKeeper.ZooKeeperDistributedLock! +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationProvider.CreateReaderWriterLock(string! name) -> Medallion.Threading.ZooKeeper.ZooKeeperDistributedReaderWriterLock! +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationProvider.CreateSemaphore(string! name, int maxCount) -> Medallion.Threading.ZooKeeper.ZooKeeperDistributedSemaphore! +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationProvider.ZooKeeperDistributedSynchronizationProvider(Medallion.Threading.ZooKeeper.ZooKeeperPath directoryPath, string! connectionString, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperDistributedSynchronizationProvider.ZooKeeperDistributedSynchronizationProvider(string! connectionString, System.Action? options = null) -> void +Medallion.Threading.ZooKeeper.ZooKeeperPath +Medallion.Threading.ZooKeeper.ZooKeeperPath.Equals(Medallion.Threading.ZooKeeper.ZooKeeperPath that) -> bool +Medallion.Threading.ZooKeeper.ZooKeeperPath.ZooKeeperPath() -> void +Medallion.Threading.ZooKeeper.ZooKeeperPath.ZooKeeperPath(string! path) -> void +override Medallion.Threading.ZooKeeper.ZooKeeperPath.Equals(object! obj) -> bool +override Medallion.Threading.ZooKeeper.ZooKeeperPath.GetHashCode() -> int +override Medallion.Threading.ZooKeeper.ZooKeeperPath.ToString() -> string! +static Medallion.Threading.ZooKeeper.ZooKeeperPath.operator !=(Medallion.Threading.ZooKeeper.ZooKeeperPath this, Medallion.Threading.ZooKeeper.ZooKeeperPath that) -> bool +static Medallion.Threading.ZooKeeper.ZooKeeperPath.operator ==(Medallion.Threading.ZooKeeper.ZooKeeperPath this, Medallion.Threading.ZooKeeper.ZooKeeperPath that) -> bool \ No newline at end of file diff --git a/src/DistributedLock.ZooKeeper/PublicAPI.Unshipped.txt b/src/DistributedLock.ZooKeeper/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperConnection.cs b/src/DistributedLock.ZooKeeper/ZooKeeperConnection.cs new file mode 100644 index 00000000..1c4cbabc --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperConnection.cs @@ -0,0 +1,258 @@ +using Medallion.Threading.Internal; +using System.Collections; + +namespace Medallion.Threading.ZooKeeper; + +using org.apache.zookeeper; + +/// +/// We don't want to use one session () per lock because "The creation and closing of sessions are +/// costly in ZooKeeper because they need quorum confirmations, they become the bottleneck of a ZooKeeper ensemble when it needs to handle +/// thousands of client connections" (https://zookeeper.apache.org/doc/r3.6.2/zookeeperProgrammers.html). +/// +/// However, ZooKeeper sessions do "leak" watches over time (see https://issues.apache.org/jira/browse/ZOOKEEPER-442). +/// +/// This class attempts to balance those two concerns by managing a pool of cached sessions that live for a while but not forever. +/// +internal class ZooKeeperConnection : IDisposable +{ + /// + /// Hopefully, 10m prevents leaks from ever getting too bad while granting efficiencies by allowing us to re-use + /// sessions under load. + /// + public static readonly Pool DefaultPool = new(maxAge: TimeSpan.FromMinutes(10)); + + private readonly InternalConnection _internalConnection; + private Action? _releaseToPool; + + private ZooKeeperConnection(InternalConnection internalConnection, Action releaseToPool) + { + this._internalConnection = internalConnection; + this._releaseToPool = releaseToPool; + } + + public ZooKeeper ZooKeeper => this._internalConnection.ZooKeeper; + + public CancellationToken ConnectionLostToken => this._internalConnection.ConnectionLostToken; + + public void Dispose() => Interlocked.Exchange(ref this._releaseToPool, null)?.Invoke(); + + public sealed class Pool + { + private readonly Dictionary Connections = new(); + private readonly TimeoutValue _maxAge; + + public Pool(TimeoutValue maxAge) + { + Invariant.Require(!maxAge.IsInfinite); + this._maxAge = maxAge.TimeSpan; + } + + private object PoolLock => Connections.As().SyncRoot; + + public Task ConnectAsync(ZooKeeperConnectionInfo connectionInfo, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + lock (this.PoolLock) + { + // if we have an entry in the pool, use that + if (this.Connections.TryGetValue(connectionInfo, out var entry)) + { + ++entry.UserCount; + return ToResultAsync(entry); + } + + // create a new connection + var newConnectionTask = this.InternalConnectAsync(connectionInfo); + var newEntry = new ConnectionEntry(newConnectionTask) + { + // 2 because we have both the current request and the timeout task we're about to create + UserCount = 2 + }; + this.Connections.Add(connectionInfo, newEntry); + newConnectionTask.ContinueWith(OnConnectionTaskCompleted); + return ToResultAsync(newEntry); + + void OnConnectionTaskCompleted(Task internalConnectionTask) + { + // if we never connected, just release our hold on the task + if (internalConnectionTask.Status != TaskStatus.RanToCompletion) + { + this.ReleaseEntry(connectionInfo, newEntry, remove: true); + } + // Otherwise, wait until either max age has elapsed or the connection is lost to release our hold. This + // ensures both that the connection won't be removed from the pool prematurely as well as that it will be + // eventually removed even if it stops being used + else + { + Task.Delay(this._maxAge.TimeSpan, internalConnectionTask.Result.ConnectionLostToken) + .ContinueWith(_ => this.ReleaseEntry(connectionInfo, newEntry, remove: true)); + } + } + } + + async Task ToResultAsync(ConnectionEntry entry) + { + try + { + var internalConnection = await entry.ConnectionTask.ConfigureAwait(false); + return new ZooKeeperConnection(internalConnection, releaseToPool: () => this.ReleaseEntry(connectionInfo, entry, remove: false)); + } + catch + { + // if we fail to construct a connection, still release our hold on the entry to allow cleanup + this.ReleaseEntry(connectionInfo, entry, remove: false); + throw; + } + } + } + + private async Task InternalConnectAsync(ZooKeeperConnectionInfo connectionInfo) + { + var watcher = new ConnectionWatcher(connectionInfo.SessionTimeout); + var zooKeeper = new ZooKeeper( + connectstring: connectionInfo.ConnectionString, + sessionTimeout: connectionInfo.SessionTimeout.InMilliseconds, + watcher: watcher + ); + + using var timeoutSource = new CancellationTokenSource(connectionInfo.ConnectTimeout.TimeSpan); + using var timeoutRegistration = timeoutSource.Token.Register( + () => watcher.TaskCompletionSource.TrySetException(new TimeoutException($"Timed out connecting to ZooKeeper after {connectionInfo.ConnectTimeout.InMilliseconds}ms")) + ); + + foreach (var authInfo in connectionInfo.AuthInfo) + { + zooKeeper.addAuthInfo(authInfo.Scheme, authInfo.Auth.ToArray()); + } + + try + { + await watcher.TaskCompletionSource.Task.ConfigureAwait(false); + return new InternalConnection(zooKeeper, watcher); + } + catch + { + // on failure, clean up the instance we created + try { await zooKeeper.closeAsync().ConfigureAwait(false); } + finally { watcher.Dispose(); } + throw; + } + } + + private void ReleaseEntry(ZooKeeperConnectionInfo connectionInfo, ConnectionEntry entry, bool remove) + { + bool shouldDispose; + lock (this.PoolLock) + { + if (remove) + { + this.Connections.As>>() + .Remove(new KeyValuePair(connectionInfo, entry)); + } + + shouldDispose = --entry.UserCount == 0; + // this guarantee is upheld by the fact that we include the timeout watcher task as a "user" + Invariant.Require( + !shouldDispose || !(this.Connections.TryGetValue(connectionInfo, out var registeredEntry) && registeredEntry == entry), + "If we're disposing then the entry must be removed from the pool" + ); + } + + if (shouldDispose) + { + // kick off connection disposal in the background to + // avoid blocking/throwing and to handle the case where the task hasn't yet completed + entry.ConnectionTask.ContinueWith( + t => { var ignored = t.Result.DisposeAsync(); }, + TaskContinuationOptions.OnlyOnRanToCompletion + ); + } + } + + private class ConnectionEntry + { + public ConnectionEntry(Task connectionTask) + { + this.ConnectionTask = connectionTask; + } + + public Task ConnectionTask { get; } + // protected by the pool's lock + public int UserCount { get; set; } + } + } + + private class InternalConnection : IAsyncDisposable + { + private readonly ConnectionWatcher _watcher; + + public InternalConnection(ZooKeeper zooKeeper, ConnectionWatcher watcher) + { + this.ZooKeeper = zooKeeper; + this._watcher = watcher; + } + + public ZooKeeper ZooKeeper { get; } + + public CancellationToken ConnectionLostToken => this._watcher.ConnectionLost; + + public async ValueTask DisposeAsync() + { + try { await this.ZooKeeper.closeAsync().ConfigureAwait(false); } + finally { this._watcher.Dispose(); } + } + } + + private class ConnectionWatcher : Watcher, IDisposable + { + private readonly CancellationTokenSource _connectionLostSource = new(); + private readonly TimeoutValue _sessionTimeout; + private int _isWaitingForReconnect; + + public ConnectionWatcher(TimeoutValue sessionTimeout) + { + this._sessionTimeout = sessionTimeout; + } + + public TaskCompletionSource TaskCompletionSource { get; } = new TaskCompletionSource(); + public CancellationToken ConnectionLost => this._connectionLostSource.Token; + + public override Task process(WatchedEvent @event) + { + if (@event.getState() == Event.KeeperState.SyncConnected) + { + if (!this.TaskCompletionSource.TrySetResult(default)) + { + // if we're reconnecting, clear any scheduled cancellation + this._connectionLostSource.CancelAfter(Timeout.Infinite); + Volatile.Write(ref this._isWaitingForReconnect, 0); + } + } + else + { + if (!this.TaskCompletionSource.TrySetException(new InvalidOperationException($"Failed to connect to ZooKeeper. State: {@event.getState()}"))) + { + // if we see the expired event (which might not come if we never reconnect), just fire connection lost + if (@event.getState() == Event.KeeperState.Expired) + { + this._connectionLostSource.Cancel(); + } + // Otherwise, zookeeper will be attempting reconnection. Give it up to the session timeout to + // do so before we fire. See https://zookeeper.apache.org/doc/r3.6.2/zookeeperProgrammers.html + else if (Interlocked.Exchange(ref this._isWaitingForReconnect, 1) == 0) + { + // Only do this if we changed from !reconnecting to reconnecting; otherwise if we get multiple + // failure events we'll keep re-upping the timer + this._connectionLostSource.CancelAfter(this._sessionTimeout.TimeSpan); + } + } + } + + return Task.CompletedTask; + } + + public void Dispose() => this._connectionLostSource.Dispose(); + } +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperConnectionInfo.cs b/src/DistributedLock.ZooKeeper/ZooKeeperConnectionInfo.cs new file mode 100644 index 00000000..b680eaad --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperConnectionInfo.cs @@ -0,0 +1,40 @@ +using Medallion.Threading.Internal; +using System.Collections; + +namespace Medallion.Threading.ZooKeeper; + +internal sealed record ZooKeeperAuthInfo(string Scheme, EquatableReadOnlyList Auth); + +internal sealed record ZooKeeperConnectionInfo(string ConnectionString, TimeoutValue ConnectTimeout, TimeoutValue SessionTimeout, EquatableReadOnlyList AuthInfo); + +internal readonly struct EquatableReadOnlyList : IReadOnlyList, IEquatable> +{ + private readonly T[] _array; + + public EquatableReadOnlyList(IEnumerable items) + { + this._array = items.ToArray(); + } + + public T this[int index] => this._array[index]; + + public int Count => this._array.Length; + + public bool Equals(EquatableReadOnlyList other) => this._array.SequenceEqual(other._array); + + public override bool Equals(object obj) => obj is EquatableReadOnlyList that && this.Equals(that); + + public override int GetHashCode() + { + var hash = 0; + foreach (var item in this._array) + { + hash = (hash, item).GetHashCode(); + } + return hash; + } + + public IEnumerator GetEnumerator() => this._array.As>().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedLock.IDistributedLock.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..257fd336 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedLock.IDistributedLock.cs @@ -0,0 +1,55 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +public partial class ZooKeeperDistributedLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.As>().TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.As>().Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + ZooKeeperDistributedLockHandle? IInternalDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + ZooKeeperDistributedLockHandle IInternalDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedLock.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedLock.cs new file mode 100644 index 00000000..60b1a643 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedLock.cs @@ -0,0 +1,92 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +/// +/// An implementation of based on ZooKeeper. Uses the lock recipe described in +/// https://zookeeper.apache.org/doc/r3.1.2/recipes.html +/// +public sealed partial class ZooKeeperDistributedLock : IInternalDistributedLock +{ + private readonly ZooKeeperSynchronizationHelper _synchronizationHelper; + + /// + /// Constructs a new lock based on the provided , , and . + /// + /// If is specified, then the node will not be created as part of acquiring nor will it be + /// deleted after releasing (defaults to false). + /// + public ZooKeeperDistributedLock( + ZooKeeperPath path, + string connectionString, + bool assumePathExists = false, + Action? options = null) + : this(path, assumePathExists: assumePathExists, connectionString, options) + { + if (path == default) { throw new ArgumentNullException(nameof(path)); } + if (path == ZooKeeperPath.Root) { throw new ArgumentException("Cannot be the root", nameof(path)); } + } + + /// + /// Constructs a new lock based on the provided , , and . + /// + /// The lock's path will be a parent node of the root directory '/'. If is not a valid node name, it will be transformed to ensure + /// validity. + /// + public ZooKeeperDistributedLock(string name, string connectionString, Action? options = null) + : this(ZooKeeperPath.Root, name, connectionString, options) + { + } + + /// + /// Constructs a new lock based on the provided , , , and . + /// + /// The lock's path will be a parent node of . If is not a valid node name, it will be transformed to ensure + /// validity. + /// + public ZooKeeperDistributedLock(ZooKeeperPath directoryPath, string name, string connectionString, Action? options = null) + : this( + (directoryPath == default ? throw new ArgumentNullException(nameof(directoryPath)) : directoryPath).GetChildNodePathWithSafeName(name), + assumePathExists: false, + connectionString, + options) + { + } + + private ZooKeeperDistributedLock(ZooKeeperPath path, bool assumePathExists, string connectionString, Action? optionsBuilder) => + this._synchronizationHelper = new ZooKeeperSynchronizationHelper(path, assumePathExists, connectionString, optionsBuilder); + + /// + /// The zookeeper node path + /// + public ZooKeeperPath Path => this._synchronizationHelper.Path; + + /// + /// Implements . Implemented explicitly to avoid confusion with the fact + /// that this will include the leading "/" and base directory alongside the passed-in name. + /// + string IDistributedLock.Name => this.Path.ToString(); + + async ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) + { + var nodeHandle = await this._synchronizationHelper.TryAcquireAsync( + hasAcquired: state => state.SortedChildren[0].Path == state.EphemeralNodePath, + waitAsync: async (zooKeeper, state, watcher) => + { + var ephemeralNodeIndex = Array.FindIndex(state.SortedChildren, t => t.Path == state.EphemeralNodePath); + var nextLowestChildNode = state.SortedChildren[ephemeralNodeIndex - 1].Path; + // If the next lowest child node is already gone, then the wait is done. Otherwise, leave the watcher on that + // node so that we'll be notified when it changes (we can't acquire the lock before then) + return await zooKeeper.existsAsync(nextLowestChildNode, watcher).ConfigureAwait(false) == null; + }, + timeout, + cancellationToken, + nodePrefix: "lock-" + ) + // we're forced to use sync-over-async here because ZooKeeperNetEx doesn't have synchronous APIs + .AwaitSyncOverAsync() + .ConfigureAwait(false); + + return nodeHandle != null ? new ZooKeeperDistributedLockHandle(nodeHandle) : null; + } +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedLockHandle.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedLockHandle.cs new file mode 100644 index 00000000..8b87ff45 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedLockHandle.cs @@ -0,0 +1,39 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +/// +/// Implements +/// +public sealed class ZooKeeperDistributedLockHandle : IDistributedSynchronizationHandle +{ + private ZooKeeperNodeHandle? _innerHandle; + private IDisposable? _finalizerRegistration; + + internal ZooKeeperDistributedLockHandle(ZooKeeperNodeHandle innerHandle) + { + this._innerHandle = innerHandle; + // If the process exits, the fact that we use ephemeral nodes gives us guaranteed + // abandonment protection. Until that point, though, we're vulnerable to abandonment because + // we pool zookeeper sessions. While those sessions have a max age, they won't be able to exit + // so long as a handle to them remains open + this._finalizerRegistration = ManagedFinalizerQueue.Instance.Register(this, innerHandle); + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => (Volatile.Read(ref this._innerHandle) ?? throw this.ObjectDisposed()).HandleLostToken; + + // explicit because this is sync-over-async + void IDisposable.Dispose() => this.DisposeSyncViaAsync(); + + /// + /// Releases the lock + /// + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref this._finalizerRegistration, null)?.Dispose(); + return Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; + } +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedReaderWriterLock.IDistributedReaderWriterLock.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedReaderWriterLock.IDistributedReaderWriterLock.cs new file mode 100644 index 00000000..1e20b351 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedReaderWriterLock.IDistributedReaderWriterLock.cs @@ -0,0 +1,102 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +public partial class ZooKeeperDistributedReaderWriterLock +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireReadLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.As>().TryAcquireReadLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.As>().AcquireReadLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireReadLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireReadLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireReadLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + IDistributedSynchronizationHandle? IDistributedReaderWriterLock.TryAcquireWriteLock(TimeSpan timeout, CancellationToken cancellationToken) => + this.As>().TryAcquireWriteLock(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedReaderWriterLock.AcquireWriteLock(TimeSpan? timeout, CancellationToken cancellationToken) => + this.As>().AcquireWriteLock(timeout, cancellationToken); + ValueTask IDistributedReaderWriterLock.TryAcquireWriteLockAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedReaderWriterLock.AcquireWriteLockAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireWriteLockAsync(timeout, cancellationToken).Convert(To.ValueTask); + + ZooKeeperDistributedReaderWriterLockHandle? IInternalDistributedReaderWriterLock.TryAcquireReadLock(TimeSpan timeout, CancellationToken cancellationToken) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken, isWrite: false); + + ZooKeeperDistributedReaderWriterLockHandle IInternalDistributedReaderWriterLock.AcquireReadLock(TimeSpan? timeout, CancellationToken cancellationToken) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken, isWrite: false); + + /// + /// Attempts to acquire a READ lock asynchronously. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// await using (var handle = await myLock.TryAcquireReadLockAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireReadLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken, isWrite: false); + + /// + /// Acquires a READ lock asynchronously, failing with if the attempt times out. Multiple readers are allowed. Not compatible with a WRITE lock. Usage: + /// + /// await using (await myLock.AcquireReadLockAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireReadLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken, isWrite: false); + + ZooKeeperDistributedReaderWriterLockHandle? IInternalDistributedReaderWriterLock.TryAcquireWriteLock(TimeSpan timeout, CancellationToken cancellationToken) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken, isWrite: true); + + ZooKeeperDistributedReaderWriterLockHandle IInternalDistributedReaderWriterLock.AcquireWriteLock(TimeSpan? timeout, CancellationToken cancellationToken) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken, isWrite: true); + + /// + /// Attempts to acquire a WRITE lock asynchronously. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// await using (var handle = await myLock.TryAcquireWriteLockAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireWriteLockAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken, isWrite: true); + + /// + /// Acquires a WRITE lock asynchronously, failing with if the attempt times out. Not compatible with another WRITE lock or an UPGRADE lock. Usage: + /// + /// await using (await myLock.AcquireWriteLockAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireWriteLockAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken, isWrite: true); + +} \ No newline at end of file diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedReaderWriterLock.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedReaderWriterLock.cs new file mode 100644 index 00000000..08196fe1 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedReaderWriterLock.cs @@ -0,0 +1,109 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +using org.apache.zookeeper; + +/// +/// A distributed reader-writer lock based on the ZooKeeper shared lock recipe (https://zookeeper.apache.org/doc/current/recipes.html). +/// +public sealed partial class ZooKeeperDistributedReaderWriterLock : IInternalDistributedReaderWriterLock +{ + private const string ReadNodePrefix = "read-", + WriteNodePrefix = "write-"; + + private readonly ZooKeeperSynchronizationHelper _synchronizationHelper; + + /// + /// Constructs a new lock based on the provided , , and . + /// + /// If is specified, then the node will not be created as part of acquiring nor will it be + /// deleted after releasing (defaults to false). + /// + public ZooKeeperDistributedReaderWriterLock( + ZooKeeperPath path, + string connectionString, + bool assumePathExists = false, + Action? options = null) + : this(path, assumePathExists: assumePathExists, connectionString, options) + { + if (path == default) { throw new ArgumentNullException(nameof(path)); } + if (path == ZooKeeperPath.Root) { throw new ArgumentException("Cannot be the root", nameof(path)); } + } + + /// + /// Constructs a new lock based on the provided , , and . + /// + /// The lock's path will be a parent node of the root directory '/'. If is not a valid node name, it will be transformed to ensure + /// validity. + /// + public ZooKeeperDistributedReaderWriterLock(string name, string connectionString, Action? options = null) + : this(ZooKeeperPath.Root, name, connectionString, options) + { + } + + /// + /// Constructs a new lock based on the provided , , , and . + /// + /// The lock's path will be a parent node of . If is not a valid node name, it will be transformed to ensure + /// validity. + /// + public ZooKeeperDistributedReaderWriterLock(ZooKeeperPath directoryPath, string name, string connectionString, Action? options = null) + : this( + (directoryPath == default ? throw new ArgumentNullException(nameof(directoryPath)) : directoryPath).GetChildNodePathWithSafeName(name), + assumePathExists: false, + connectionString, + options) + { + } + + private ZooKeeperDistributedReaderWriterLock(ZooKeeperPath nodePath, bool assumePathExists, string connectionString, Action? optionsBuilder) => + this._synchronizationHelper = new ZooKeeperSynchronizationHelper(nodePath, assumePathExists, connectionString, optionsBuilder); + + /// + /// The zookeeper node path + /// + public ZooKeeperPath Path => this._synchronizationHelper.Path; + + /// + /// Implements . Implemented explicitly to avoid confusion with the fact + /// that this will include the leading "/" and base directory alongside the passed-in name. + /// + string IDistributedReaderWriterLock.Name => this.Path.ToString(); + + async ValueTask IInternalDistributedReaderWriterLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken, bool isWrite) + { + var nodeHandleTask = isWrite + ? this._synchronizationHelper.TryAcquireAsync(HasAcquiredWriteLock, WaitForWriteLockAcquiredOrChange, timeout, cancellationToken, WriteNodePrefix, alternateNodePrefix: ReadNodePrefix) + : this._synchronizationHelper.TryAcquireAsync(HasAcquiredReadLock, WaitForReadLockAcquiredOrChange, timeout, cancellationToken, ReadNodePrefix, alternateNodePrefix: WriteNodePrefix); + // we're forced to use sync-over-async here because ZooKeeperNetEx doesn't have synchronous APIs + var nodeHandle = await nodeHandleTask.AwaitSyncOverAsync().ConfigureAwait(false); + + return nodeHandle != null ? new ZooKeeperDistributedReaderWriterLockHandle(nodeHandle) : null; + } + + private static bool HasAcquiredReadLock(ZooKeeperSynchronizationHelper.State state) => + // We have the read lock if there are no writers ahead of us + state.SortedChildren.TakeWhile(t => t.Path != state.EphemeralNodePath).All(t => t.Prefix != WriteNodePrefix); + + private static async Task WaitForReadLockAcquiredOrChange(ZooKeeper zooKeeper, ZooKeeperSynchronizationHelper.State state, Watcher watcher) + { + var nextLowestWriteNode = state.SortedChildren.TakeWhile(t => t.Path != state.EphemeralNodePath) + .Last(t => t.Prefix == WriteNodePrefix); + // If the next lowest write node is already gone, then the wait is done. Otherwise, leave the watcher on that + // node so that we'll be notified when it changes (we can't acquire the lock before then) + return await zooKeeper.existsAsync(nextLowestWriteNode.Path, watcher).ConfigureAwait(false) == null; + } + + private static bool HasAcquiredWriteLock(ZooKeeperSynchronizationHelper.State state) => + state.SortedChildren[0].Path == state.EphemeralNodePath; + + private static async Task WaitForWriteLockAcquiredOrChange(ZooKeeper zooKeeper, ZooKeeperSynchronizationHelper.State state, Watcher watcher) + { + var ephemeralNodeIndex = Array.FindIndex(state.SortedChildren, t => t.Path == state.EphemeralNodePath); + var nextLowestChildNode = state.SortedChildren[ephemeralNodeIndex - 1].Path; + // If the next lowest child node is already gone, then the wait is done. Otherwise, leave the watcher on that + // node so that we'll be notified when it changes (we can't acquire the lock before then) + return await zooKeeper.existsAsync(nextLowestChildNode, watcher).ConfigureAwait(false) == null; + } +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedReaderWriterLockHandle.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedReaderWriterLockHandle.cs new file mode 100644 index 00000000..705ad69a --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedReaderWriterLockHandle.cs @@ -0,0 +1,39 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +/// +/// Implements +/// +public sealed class ZooKeeperDistributedReaderWriterLockHandle : IDistributedSynchronizationHandle +{ + private ZooKeeperNodeHandle? _innerHandle; + private IDisposable? _finalizerRegistration; + + internal ZooKeeperDistributedReaderWriterLockHandle(ZooKeeperNodeHandle innerHandle) + { + this._innerHandle = innerHandle; + // If the process exits, the fact that we use ephemeral nodes gives us guaranteed + // abandonment protection. Until that point, though, we're vulnerable to abandonment because + // we pool zookeeper sessions. While those sessions have a max age, they won't be able to exit + // so long as a handle to them remains open + this._finalizerRegistration = ManagedFinalizerQueue.Instance.Register(this, innerHandle); + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => (Volatile.Read(ref this._innerHandle) ?? throw this.ObjectDisposed()).HandleLostToken; + + // explicit because this is sync-over-async + void IDisposable.Dispose() => this.DisposeSyncViaAsync(); + + /// + /// Releases the lock + /// + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref this._finalizerRegistration, null)?.Dispose(); + return Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; + } +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSemaphore.IDistributedSemaphore.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSemaphore.IDistributedSemaphore.cs new file mode 100644 index 00000000..c2ac2afc --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSemaphore.IDistributedSemaphore.cs @@ -0,0 +1,55 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +public partial class ZooKeeperDistributedSemaphore +{ + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedSemaphore.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.As>().TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedSemaphore.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.As>().Acquire(timeout, cancellationToken); + ValueTask IDistributedSemaphore.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedSemaphore.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + ZooKeeperDistributedSemaphoreHandle? IInternalDistributedSemaphore.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + ZooKeeperDistributedSemaphoreHandle IInternalDistributedSemaphore.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire a semaphore ticket asynchronously. Usage: + /// + /// await using (var handle = await mySemaphore.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the ticket! */ } + /// } + /// // dispose releases the ticket if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires a semaphore ticket asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await mySemaphore.AcquireAsync(...)) + /// { + /// /* we have the ticket! */ + /// } + /// // dispose releases the ticket + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the ticket + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +} \ No newline at end of file diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSemaphore.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSemaphore.cs new file mode 100644 index 00000000..9e4c9804 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSemaphore.cs @@ -0,0 +1,117 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +/// +/// An implementation of based on ZooKeeper. Uses an approach similar to . +/// +public sealed partial class ZooKeeperDistributedSemaphore : IInternalDistributedSemaphore +{ + private readonly ZooKeeperSynchronizationHelper _synchronizationHelper; + + /// + /// Constructs a new semaphore based on the provided , , and . + /// + /// If is specified, then the node will not be created as part of acquiring nor will it be + /// deleted after releasing (defaults to false). + /// + public ZooKeeperDistributedSemaphore( + ZooKeeperPath path, + int maxCount, + string connectionString, + bool assumePathExists = false, + Action? options = null) + : this(path, maxCount, assumePathExists: assumePathExists, connectionString, options) + { + if (path == default) { throw new ArgumentNullException(nameof(path)); } + if (path == ZooKeeperPath.Root) { throw new ArgumentException("Cannot be the root", nameof(path)); } + } + + /// + /// Constructs a new semaphore based on the provided , , and . + /// + /// The semaphore's path will be a parent node of the root directory '/'. If is not a valid node name, it will be transformed to ensure + /// validity. + /// + public ZooKeeperDistributedSemaphore(string name, int maxCount, string connectionString, Action? options = null) + : this(ZooKeeperPath.Root, name, maxCount, connectionString, options) + { + } + + /// + /// Constructs a new semaphore based on the provided , , , and . + /// + /// The semaphore's path will be a parent node of . If is not a valid node name, it will be transformed to ensure + /// validity. + /// + public ZooKeeperDistributedSemaphore(ZooKeeperPath directoryPath, string name, int maxCount, string connectionString, Action? options = null) + : this( + (directoryPath == default ? throw new ArgumentNullException(nameof(directoryPath)) : directoryPath).GetChildNodePathWithSafeName(name), + maxCount, + assumePathExists: false, + connectionString, + options) + { + } + + private ZooKeeperDistributedSemaphore(ZooKeeperPath nodePath, int maxCount, bool assumePathExists, string connectionString, Action? optionsBuilder) + { + if (maxCount < 1) { throw new ArgumentOutOfRangeException(nameof(maxCount), maxCount, "must be positive"); } + this.MaxCount = maxCount; + // setAcquiredMarker is needed because we use data changes as part of our wait procedure below + this._synchronizationHelper = new ZooKeeperSynchronizationHelper(nodePath, assumePathExists, connectionString, optionsBuilder, setAcquiredMarker: true); + } + + /// + /// The zookeeper node path + /// + public ZooKeeperPath Path => this._synchronizationHelper.Path; + + /// + /// Implements . Implemented explicitly to avoid confusion with the fact + /// that this will include the leading "/" and base directory alongside the passed-in name. + /// + string IDistributedSemaphore.Name => this.Path.ToString(); + + /// + /// Implements + /// + public int MaxCount { get; } + + async ValueTask IInternalDistributedSemaphore.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) + { + var nodeHandle = await this._synchronizationHelper.TryAcquireAsync( + hasAcquired: state => Array.FindIndex(state.SortedChildren, t => t.Path == state.EphemeralNodePath) < this.MaxCount, + waitAsync: async (zooKeeper, state, watcher) => + { + var ephemeralNodeIndex = Array.FindIndex(state.SortedChildren, t => t.Path == state.EphemeralNodePath); + Invariant.Require(ephemeralNodeIndex >= this.MaxCount); + + // if we're the next node in line for a ticket, wait for any changes in the collection of children + if (ephemeralNodeIndex == this.MaxCount) + { + var childNames = new HashSet((await zooKeeper.getChildrenAsync(this.Path.ToString(), watcher).ConfigureAwait(false)).Children); + // If any of the children in front of us are missing, then the wait is done. Otherwise, + // let the watcher notify us when there is any change to the set of children + return state.SortedChildren.Take(ephemeralNodeIndex) + .Any(t => !childNames.Contains(t.Path.Substring(t.Path.LastIndexOf(ZooKeeperPath.Separator) + 1))); + } + + // Otherwise, we just watch for the node ahead of us in line to have its data changed to the acquired marker. While we could + // watch all children in this case as well, that approach is less efficient because it will generate a herd effect where each + // new waiter or released waiter wakes up everyone else. + var nextLowestChildData = await zooKeeper.getDataAsync(state.SortedChildren[ephemeralNodeIndex - 1].Path, watcher).ConfigureAwait(false); + // If it's already acquired, then the wait is done. Otherwise, the watcher will notify us on any data change or on deletion of that node + return nextLowestChildData.Data.SequenceEqual(ZooKeeperSynchronizationHelper.AcquiredMarker); + }, + timeout, + cancellationToken, + nodePrefix: "semaphore-" + ) + // we're forced to use sync-over-async here because ZooKeeperNetEx doesn't have synchronous APIs + .AwaitSyncOverAsync() + .ConfigureAwait(false); + + return nodeHandle != null ? new ZooKeeperDistributedSemaphoreHandle(nodeHandle) : null; + } +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSemaphoreHandle.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSemaphoreHandle.cs new file mode 100644 index 00000000..d209092d --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSemaphoreHandle.cs @@ -0,0 +1,39 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +/// +/// Implements +/// +public sealed class ZooKeeperDistributedSemaphoreHandle : IDistributedSynchronizationHandle +{ + private ZooKeeperNodeHandle? _innerHandle; + private IDisposable? _finalizerRegistration; + + internal ZooKeeperDistributedSemaphoreHandle(ZooKeeperNodeHandle innerHandle) + { + this._innerHandle = innerHandle; + // If the process exits, the fact that we use ephemeral nodes gives us guaranteed + // abandonment protection. Until that point, though, we're vulnerable to abandonment because + // we pool zookeeper sessions. While those sessions have a max age, they won't be able to exit + // so long as a handle to them remains open + this._finalizerRegistration = ManagedFinalizerQueue.Instance.Register(this, innerHandle); + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => (Volatile.Read(ref this._innerHandle) ?? throw this.ObjectDisposed()).HandleLostToken; + + // explicit because this is sync-over-async + void IDisposable.Dispose() => this.DisposeSyncViaAsync(); + + /// + /// Releases the semaphore + /// + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref this._finalizerRegistration, null)?.Dispose(); + return Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; + } +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSynchronizationOptionsBuilder.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSynchronizationOptionsBuilder.cs new file mode 100644 index 00000000..4e085043 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSynchronizationOptionsBuilder.cs @@ -0,0 +1,99 @@ +using Medallion.Threading.Internal; +using org.apache.zookeeper.data; + +namespace Medallion.Threading.ZooKeeper; + +/// +/// Options for configuring ZooKeeper-based synchronization primitives +/// +public sealed class ZooKeeperDistributedSynchronizationOptionsBuilder +{ + private static readonly IReadOnlyList DefaultAcl = new[] { ZooKeeperNodeCreator.PublicAcl }; + + /// + /// According to https://bowenli86.github.io/2016/09/15/distributed%20system/zookeeper/ZooKeeper-Sessions-and-Session-Management/, + /// timeout can be a minimum of 2x the tick time and a maximum of 20x the tick time. The default tick time is 2s, so this default + /// is set to be high enough to require relatively few heartbeats but also low enough to support either a 2s or 1s tick time by default. + /// + private TimeoutValue _sessionTimeout = TimeSpan.FromSeconds(20); + + /// + /// Default value (arbitrarily) matches the default connection timeout for SQL Server + /// + private TimeoutValue _connectTimeout = TimeSpan.FromSeconds(15); + + private readonly List _authInfo = new(); + private readonly List _acl = new(); + + private ZooKeeperDistributedSynchronizationOptionsBuilder() + { + } + + /// + /// Configures the for connections to ZooKeeper. Because the underlying ZooKeeper client periodically renews + /// the session, this value generally will not impact behavior. Lower values mean that locks will be released more quickly following a crash + /// of the lock-holding process, but also increase the risk that transient connection issues will result in a dropped lock. + /// + /// Defaults to 20s. + /// + public ZooKeeperDistributedSynchronizationOptionsBuilder SessionTimeout(TimeSpan sessionTimeout) + { + var sessionTimeoutValue = new TimeoutValue(sessionTimeout, nameof(sessionTimeout)); + if (sessionTimeoutValue.IsZero) { throw new ArgumentOutOfRangeException(nameof(sessionTimeout), "must be positive"); } + if (sessionTimeoutValue.IsInfinite) { throw new ArgumentOutOfRangeException(nameof(sessionTimeout), "must not be infinite"); } + + this._sessionTimeout = sessionTimeoutValue; + return this; + } + + /// + /// Configures how long to wait to establish a connection to ZooKeeper before failing with a . + /// + /// Defaults to 15s. + /// + public ZooKeeperDistributedSynchronizationOptionsBuilder ConnectTimeout(TimeSpan connectTimeout) + { + this._connectTimeout = new TimeoutValue(connectTimeout, nameof(connectTimeout)); + return this; + } + + /// + /// Specifies authentication info to be added to the Zookeeper connection with . Each call + /// to this method adds another entry to the list of auth info. See https://zookeeper.apache.org/doc/r3.5.4-beta/zookeeperProgrammers.html for more + /// information on ZooKeeper auth. + /// + /// By default, no auth info is added. + /// + public ZooKeeperDistributedSynchronizationOptionsBuilder AddAuthInfo(string scheme, IReadOnlyList auth) + { + this._authInfo.Add(new ZooKeeperAuthInfo( + scheme ?? throw new ArgumentNullException(nameof(scheme)), + new EquatableReadOnlyList(auth ?? throw new ArgumentNullException(nameof(auth))) + )); + return this; + } + + /// + /// Configures the access control list (ACL) for any created ZooKeeper nodes. Each call to this method adds another entry to the access control + /// list. See https://zookeeper.apache.org/doc/r3.5.4-beta/zookeeperProgrammers.html for more information on ZooKeeper ACLs. + /// + /// If no ACL entries are specified, the ACL used will be a singleton list that grants all permissions to (world, anyone). + /// + public ZooKeeperDistributedSynchronizationOptionsBuilder AddAccessControl(string scheme, string id, int permissionFlags) + { + this._acl.Add(new ACL(permissionFlags, new Id(scheme ?? throw new ArgumentNullException(nameof(scheme)), id ?? throw new ArgumentNullException(nameof(id))))); + return this; + } + + internal static (TimeoutValue SessionTimeout, TimeoutValue ConnectTimeout, EquatableReadOnlyList AuthInfo, IReadOnlyList Acl) GetOptions(Action? options) + { + var builder = new ZooKeeperDistributedSynchronizationOptionsBuilder(); + options?.Invoke(builder); + return ( + SessionTimeout: builder._sessionTimeout, + ConnectTimeout: builder._connectTimeout, + AuthInfo: new EquatableReadOnlyList(builder._authInfo), + Acl: builder._acl.Count != 0 ? builder._acl.ToArray() : DefaultAcl + ); + } +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSynchronizationProvider.cs b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..f72eff90 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperDistributedSynchronizationProvider.cs @@ -0,0 +1,55 @@ +namespace Medallion.Threading.ZooKeeper; + +/// +/// Implements for , +/// for , +/// and for . +/// +public sealed class ZooKeeperDistributedSynchronizationProvider : IDistributedLockProvider, IDistributedReaderWriterLockProvider, IDistributedSemaphoreProvider +{ + private readonly ZooKeeperPath _directoryPath; + private readonly string _connectionString; + private readonly Action? _options; + + /// + /// Constructs a provider which uses and . Lock and semaphore nodes will be created + /// in the root directory '/'. + /// + public ZooKeeperDistributedSynchronizationProvider(string connectionString, Action? options = null) + : this(ZooKeeperPath.Root, connectionString, options) { } + + /// + /// Constructs a provider which uses and . Lock and semaphore nodes will be created + /// in . + /// + public ZooKeeperDistributedSynchronizationProvider(ZooKeeperPath directoryPath, string connectionString, Action? options = null) + { + this._directoryPath = directoryPath != default ? directoryPath : throw new ArgumentNullException(nameof(directoryPath)); + this._connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + this._options = options; + } + + /// + /// Creates a using the given . + /// + public ZooKeeperDistributedLock CreateLock(string name) => + new(this._directoryPath, name, this._connectionString, this._options); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); + + /// + /// Creates a using the given . + /// + public ZooKeeperDistributedReaderWriterLock CreateReaderWriterLock(string name) => + new(this._directoryPath, name, this._connectionString, this._options); + + IDistributedReaderWriterLock IDistributedReaderWriterLockProvider.CreateReaderWriterLock(string name) => this.CreateReaderWriterLock(name); + + /// + /// Creates a using the given and . + /// + public ZooKeeperDistributedSemaphore CreateSemaphore(string name, int maxCount) => + new(this._directoryPath, name, maxCount, this._connectionString, this._options); + + IDistributedSemaphore IDistributedSemaphoreProvider.CreateSemaphore(string name, int maxCount) => this.CreateSemaphore(name, maxCount); +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperNodeCreator.cs b/src/DistributedLock.ZooKeeper/ZooKeeperNodeCreator.cs new file mode 100644 index 00000000..00eaf506 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperNodeCreator.cs @@ -0,0 +1,113 @@ +namespace Medallion.Threading.ZooKeeper; + +using org.apache.zookeeper; +using org.apache.zookeeper.data; +using System.Linq; + +internal static class ZooKeeperNodeCreator +{ + /// + /// See https://zookeeper.apache.org/doc/r3.5.4-beta/zookeeperProgrammers.html under "Builtin ACL Schemes" + /// + public static readonly ACL PublicAcl = new(0x1f, new Id("world", "anyone")); + + public static async Task CreateEphemeralSequentialNode( + this ZooKeeperConnection connection, + ZooKeeperPath directory, + string namePrefix, + IEnumerable aclEnumerable, + bool ensureDirectoryExists) + { + // If we are in charge of ensuring the directory, this algorithm loops until either we EnsureDirectory fails or we hit an error other than the directory + // not existing. This supports concurrent node creation and directory creation as well as node creation and directory deletion. + + var acl = aclEnumerable.ToList(); + while (true) + { + var createdDirectories = ensureDirectoryExists + ? await EnsureDirectoryAndGetCreatedPathsAsync().ConfigureAwait(false) + : Array.Empty(); + + try + { + return await connection.ZooKeeper.createAsync($"{directory}{ZooKeeperPath.Separator}{namePrefix}", data: Array.Empty(), acl, CreateMode.EPHEMERAL_SEQUENTIAL).ConfigureAwait(false); + } + catch (KeeperException.NoNodeException ex) + { + // If we're not ensuring the directory, rethrow a more helpful error message. Otherwise, + // swallow the error and go around the loop again + if (!ensureDirectoryExists) + { + throw new InvalidOperationException($"Node '{directory}' does not exist", ex); + } + } + catch + { + // on an unhandled error, clean up any directories we created + await TryCleanUpCreatedDirectoriesAsync(createdDirectories).ConfigureAwait(false); + + throw; + } + } + + async Task> EnsureDirectoryAndGetCreatedPathsAsync() + { + // This algorithm loops until either the directory exists or our creation attempt fails with something other + // than NoNodeException or NodeExistsException. This supports concurrent directory creation as well as concurrent + // creation and deletion via optimistic concurrency + + var toCreate = new Stack(); + toCreate.Push(directory); + List? created = null; + do + { + var directoryToCreate = toCreate.Peek(); + if (directoryToCreate == ZooKeeperPath.Root) + { + throw new InvalidOperationException($"Received {typeof(KeeperException.NoNodeException)} when creating child node of directory '{ZooKeeperPath.Root}'"); + } + + try + { + await connection.ZooKeeper.createAsync(directoryToCreate.ToString(), data: Array.Empty(), acl, CreateMode.PERSISTENT).ConfigureAwait(false); + toCreate.Pop(); + (created ??= new List()).Add(directoryToCreate); + } + catch (KeeperException.NodeExistsException) // someone else created it + { + toCreate.Pop(); + } + catch (KeeperException.NoNodeException) // parent needs to be created + { + toCreate.Push(directoryToCreate.GetDirectory()!.Value); + } + catch + { + // on an unhandled failure, attempt to clean up our work + if (created != null) { await TryCleanUpCreatedDirectoriesAsync(created).ConfigureAwait(false); } + + throw; + } + } + while (toCreate.Count != 0); + + return created ?? (IReadOnlyList)Array.Empty(); + } + + async Task TryCleanUpCreatedDirectoriesAsync(IReadOnlyList createdDirectories) + { + try + { + // delete in reverse order of creation + for (var i = createdDirectories.Count - 1; i >= 0; --i) + { + await connection.ZooKeeper.deleteAsync(createdDirectories[i].ToString()).ConfigureAwait(false); + } + } + catch + { + // swallow errors, since there's a good chance this cleanup fails the same way that the creation did + } + } + } +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperNodeHandle.cs b/src/DistributedLock.ZooKeeper/ZooKeeperNodeHandle.cs new file mode 100644 index 00000000..be32aacc --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperNodeHandle.cs @@ -0,0 +1,158 @@ +using Medallion.Threading.Internal; +using org.apache.zookeeper; + +namespace Medallion.Threading.ZooKeeper; + +/// +/// implementation where holding the primitive +/// is based on the existence of an ephemeral zookeeper node +/// +internal sealed class ZooKeeperNodeHandle : IDistributedSynchronizationHandle +{ + private readonly ZooKeeperConnection _connection; + private readonly ZooKeeperPath _nodePath; + private readonly bool _shouldDeleteParent; + private readonly Lazy _handleLostState; + + private volatile bool _disposed; + + public ZooKeeperNodeHandle(ZooKeeperConnection connection, string nodePath, bool shouldDeleteParent) + { + this._connection = connection; + this._nodePath = new ZooKeeperPath(nodePath); + this._shouldDeleteParent = shouldDeleteParent; + + this._handleLostState = new Lazy(() => + { + var handleLostSource = CancellationTokenSource.CreateLinkedTokenSource(this._connection.ConnectionLostToken); + var handleLostToken = handleLostSource.Token; // grab this now before the source is disposed + var disposalSource = new CancellationTokenSource(); + var disposalSourceToken = disposalSource.Token; + var monitoringTask = Task.Run(async () => + { + try + { + while (true) + { + var result = await WaitForNotExistsOrChangedAsync( + this._connection, + this._nodePath.ToString(), + timeoutToken: disposalSource.Token + ).ConfigureAwait(false); + switch (result) + { + case false: // disposalSource triggered + return; + case true: // node no longer exists + handleLostSource.Cancel(); + return; + default: // something changed + break; // continue looping + } + } + } + finally + { + handleLostSource.Dispose(); + } + }); + return new HandleLostState(handleLostToken, disposalSource, monitoringTask); + }); + } + + public CancellationToken HandleLostToken => this._disposed ? throw this.ObjectDisposed() : this._handleLostState.Value.Token; + + public void Dispose() => this.DisposeSyncViaAsync(); + + public ValueTask DisposeAsync() => + // we're forced to use sync-over-async here because ZooKeeperNetEx doesn't have synchronous APIs + this.InternalDisposeAsync().AwaitSyncOverAsync(); + + private async Task InternalDisposeAsync() + { + if (this._disposed) { return; } + this._disposed = true; + + try + { + // clean up monitoring + if (this._handleLostState.IsValueCreated) + { + this._handleLostState.Value.DisposalSource.Cancel(); + this._handleLostState.Value.DisposalSource.Dispose(); + await this._handleLostState.Value.MonitoringTask.ConfigureAwait(false); + } + } + finally + { + try + { + // clean up the node + await this._connection.ZooKeeper.deleteAsync(this._nodePath.ToString()).ConfigureAwait(false); + + if (this._shouldDeleteParent) + { + try { await this._connection.ZooKeeper.deleteAsync(this._nodePath.GetDirectory()!.Value.ToString()).ConfigureAwait(false); } + catch (KeeperException.NotEmptyException) { } // can't delete nodes which have other children + catch (KeeperException.NoNodeException) { } // can't delete nodes which don't exist (race condition) + } + } + finally + { + this._connection.Dispose(); + } + } + } + + /// + /// Returns true when does not exist. + /// Returns null when we receive a watch event indicating that has changed. + /// Returns false if the fires. + /// + public static async Task WaitForNotExistsOrChangedAsync( + ZooKeeperConnection connection, + string path, + CancellationToken timeoutToken) + { + using var watcher = new TaskWatcher((_, s) => s.TrySetResult(null)); + using var timeoutRegistration = timeoutToken.Register( + state => ((TaskWatcher)state).TaskCompletionSource.TrySetResult(false), + state: watcher + ); + // this is needed because if the connection goes down and never recovers, we'll never get the session expired notification + using var connectionLostRegistration = connection.ConnectionLostToken.Register( + state => ((TaskWatcher)state).TaskCompletionSource.TrySetException(new InvalidOperationException("Lost connection to ZooKeeper")), + state: watcher + ); + + var exists = await connection.ZooKeeper.existsAsync(path, watcher).ConfigureAwait(false); + return exists == null ? true : await watcher.TaskCompletionSource.Task.ConfigureAwait(false); + } + + private sealed class TaskWatcher : Watcher, IDisposable + { + private volatile Action>? _watchedEventHandler; + + public TaskWatcher(Action> watchedEventHandler) + { + this._watchedEventHandler = watchedEventHandler; + } + + public TaskCompletionSource TaskCompletionSource { get; } = new TaskCompletionSource(); + + public void Dispose() => this._watchedEventHandler = null; + + public override Task process(WatchedEvent @event) + { + // only care about connected state events; the ConnectionLostToken takes care of the other states for us + if (@event.getState() == Event.KeeperState.SyncConnected) + { + this._watchedEventHandler?.Invoke(@event, this.TaskCompletionSource); + } + + return Task.CompletedTask; + } + } + + private record HandleLostState(CancellationToken Token, CancellationTokenSource DisposalSource, Task MonitoringTask); +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperPath.cs b/src/DistributedLock.ZooKeeper/ZooKeeperPath.cs new file mode 100644 index 00000000..01ec85f0 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperPath.cs @@ -0,0 +1,163 @@ +using Medallion.Threading.Internal; + +namespace Medallion.Threading.ZooKeeper; + +/// +/// Represents a path to a ZooKeeper node. The constructor validates that the input is a valid path. +/// Call to get the path value. +/// +public readonly struct ZooKeeperPath : IEquatable +{ + internal const char Separator = '/'; + + internal static ZooKeeperPath Root { get; } = new ZooKeeperPath("/"); + + private readonly string _path; + + /// + /// Constructs a new based on the given string. + /// + public ZooKeeperPath(string path) : this(path, checkPath: true) { } + + private ZooKeeperPath(string path, bool checkPath, string? paramName = null) + { + if (path == null) { throw new ArgumentNullException(paramName ?? nameof(path)); } + if (checkPath && ValidatePath(path) is { } error) + { + throw new FormatException($"{paramName ?? nameof(path)} {error.Reason}{(error.Index.HasValue ? $" (index {error.Index})" : string.Empty)}"); + } + this._path = path; + } + + internal ZooKeeperPath? GetDirectory() + { + if (this == Root) { return null; } + var lastSeparatorIndex = this._path.LastIndexOf(Separator); + return lastSeparatorIndex == 0 ? Root : new ZooKeeperPath(this._path.Substring(0, lastSeparatorIndex), checkPath: false); + } + + /// + /// Returns the path value as a string + /// + public override string ToString() => this._path; + + /// + /// Implements equality based on the path string + /// + public override bool Equals(object obj) => obj is ZooKeeperPath that && this.Equals(that); + + /// + /// Implements equality based on the path string + /// + public bool Equals(ZooKeeperPath that) => this._path == that._path; + + /// + /// Implements hashing based on the path string + /// + public override int GetHashCode() => this._path?.GetHashCode() ?? 0; + + /// + /// Implements equality based on the path string + /// + public static bool operator ==(ZooKeeperPath @this, ZooKeeperPath that) => @this.Equals(that); + + /// + /// Implements inequality based on the path string + /// + public static bool operator !=(ZooKeeperPath @this, ZooKeeperPath that) => !(@this == that); + + internal ZooKeeperPath GetChildNodePathWithSafeName(string name) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + var isRoot = this == Root; + var safeName = DistributedLockHelpers.ToSafeName( + name, + maxNameLength: int.MaxValue, // no max + convertToValidName: ConvertToValidNodeName + ) + // If ToSafeName adds a hash, it uses Base64 encoding which can include the separator character. We replace + // with '_' which is not in Base64 so that the output name remains safe without weakening the hash + .Replace(Separator, '_'); + return new ZooKeeperPath((this == Root ? this._path : (this._path + Separator)) + safeName, checkPath: false); + + string ConvertToValidNodeName(string name) + { + // in order to be a valid node name: + + // must not be empty (special-case this because our generic conversion method will map empty to itself) + if (name.Length == 0) { return "EMPTY"; } + + // must not be ., .., or (this this is root), the reserved path "zookeeper" + // (see https://zookeeper.apache.org/doc/current/zookeeperProgrammers.html#ch_zkDataModel) + switch (name) + { + case ".": + case "..": + case "zookeeper" when isRoot: + return name + "_"; + default: + break; // keep going + } + + if (name.IndexOf(Separator) < 0 // must not contain the path separator + && !ValidatePath(Separator + name).HasValue) // "/name" must be a valid path + { + return name; + } + + var converted = name.ToCharArray(); + for (var i = 0; i < name.Length; ++i) + { + switch (name[i]) + { + // note: we don't have to replace '.' because it is only invalid if '.' or '..' is a full path + // segment. Since we'll be appending on a hash, that doesn't matter + case Separator: // separator cannot appear in names, only in paths + case '\0': + case char @char when IsNonNullInvalidPathChar(@char): + converted[i] = '_'; // replace with placeholder + break; + } + } + + return new string(converted); + } + } + + private static (string Reason, int? Index)? ValidatePath(string path) + { + // logic based on https://github.com/apache/zookeeper/blob/master/zookeeper-server/src/main/java/org/apache/zookeeper/common/PathUtils.java#L43 + // (cited in https://stackoverflow.com/questions/55463167/node-name-limitations-in-zookeeper) + + if (path.Length == 0) { return ("may not be empty", null); } + if (path[0] != Separator) { return ("must start with the '/' character", null); } + if (path.Length == 1) { return null; } + if (path[path.Length - 1] == Separator) { return ("must not end with the '/' character", path.Length - 1); } + + for (var i = 1; i < path.Length; ++i) + { + // keep in sync with ConvertToValidNodeName() + + switch (path[i]) + { + case '\0': + return ("may not contain the null character", i); + case Separator when path[i - 1] == Separator: + return ("may not contain empty segments", i); + case '.' when path[i - (path[i - 1] == '.' ? 2 : 1)] == Separator && ((i + 1 == path.Length) || path[i + 1] == Separator): + return ("may not be a relative path", i); + case char @char when IsNonNullInvalidPathChar(@char): + return ("invalid character", i); + } + } + + return null; + } + + private static bool IsNonNullInvalidPathChar(char @char) => + @char > '\u0000' && @char <= '\u001f' + || @char >= '\u007f' && @char <= '\u009F' + || @char >= '\ud800' && @char <= '\uf8ff' + || @char >= '\ufff0' && @char <= '\uffff'; +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperSequentialPathHelper.cs b/src/DistributedLock.ZooKeeper/ZooKeeperSequentialPathHelper.cs new file mode 100644 index 00000000..d618f45c --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperSequentialPathHelper.cs @@ -0,0 +1,141 @@ +using Medallion.Threading.Internal; +using System.Globalization; + +namespace Medallion.Threading.ZooKeeper; + +internal static class ZooKeeperSequentialPathHelper +{ + /// + /// Given a set of child node names (), filters them to include only sequential nodes + /// with the prefix or . + /// + /// Then, sorts the nodes from oldest to newest. In most, cases, this sort can be done purely using the sequence number. However, because sequence + /// numbers roll over at , more complex logic is needed to get a correct sort in certain scenarios. + /// + public static async ValueTask<(string Path, int SequenceNumber, string Prefix)[]> FilterAndSortAsync( + string parentNode, + IEnumerable childrenNames, + Func> getNodeCreationTimeAsync, + string prefix, + string? alternatePrefix = null) + { + var ephemeralChildrenWithPrefix = GetEphemeralChildrenWithPrefix(); + + if (ephemeralChildrenWithPrefix.Count == 0) { return Array.Empty<(string, int, string)>(); } + + // first, sort by the unsigned value + ephemeralChildrenWithPrefix.Sort((a, b) => a.UnsignedSequenceNumber.CompareTo(b.UnsignedSequenceNumber)); + + // next, measure the gaps between each pair of sequential values to see if the break point is obvious + + // This is > 90% of the uint space; there can only be one gap of this size and if there is such a gap + // then we can safely assume that the gap identifies the end of the sequence + const uint LargeGapSize = 4_000_000_000u; + + var maxGap = 0u; + var maxGapEndIndex = -1; + for (var i = 0; i < ephemeralChildrenWithPrefix.Count; ++i) + { + var gapEndIndex = (i + 1) % ephemeralChildrenWithPrefix.Count; + var gap = unchecked(ephemeralChildrenWithPrefix[gapEndIndex].UnsignedSequenceNumber - ephemeralChildrenWithPrefix[i].UnsignedSequenceNumber); + if (gap > maxGap) + { + maxGap = gap; + maxGapEndIndex = gapEndIndex; + } + } + if (maxGap >= LargeGapSize) + { + return ReorderByLowestIndex(maxGapEndIndex); + } + + // finally, fall back to determining the start point via creation time + var creationTimeTasksByChildPath = ephemeralChildrenWithPrefix.ToDictionary(t => t.Path, t => getNodeCreationTimeAsync(t.Path)); + await Task.WhenAll(creationTimeTasksByChildPath.Values).ConfigureAwait(false); + + ephemeralChildrenWithPrefix.RemoveAll(t => creationTimeTasksByChildPath[t.Path].Result == null); // remove all nodes with no creation time (they no longer exist) + if (ephemeralChildrenWithPrefix.Count == 0) { return Array.Empty<(string, int, string)>(); } + + var oldestChild = ephemeralChildrenWithPrefix.Select((t, index) => (creationTime: creationTimeTasksByChildPath[t.Path].Result!.Value, index)) + .OrderBy(t => t.creationTime) + .ThenBy(t => t.index) + .First(); + return ReorderByLowestIndex(oldestChild.index); + + List<(string Path, uint UnsignedSequenceNumber, string Prefix)> GetEphemeralChildrenWithPrefix() + { + var result = new List<(string Path, uint UnsignedSequenceNumber, string Prefix)>(); + foreach (var childName in childrenNames) + { + int? childSequenceNumber; + string? childPrefix; + if (GetSequenceNumberOrDefault(childName, prefix) is { } prefixSequenceNumber) + { + childSequenceNumber = prefixSequenceNumber; + childPrefix = prefix; + } + else if (alternatePrefix != null + && GetSequenceNumberOrDefault(childName, alternatePrefix) is { } alternatePrefixSequenceNumber) + { + childSequenceNumber = alternatePrefixSequenceNumber; + childPrefix = alternatePrefix; + } + else + { + childSequenceNumber = null; + childPrefix = null; + } + + if (childPrefix != null) + { + result.Add(($"{parentNode.TrimEnd(ZooKeeperPath.Separator)}/{childName}", unchecked((uint)childSequenceNumber!.Value), childPrefix)); + } + } + + return result; + } + + (string Path, int SequenceNumber, string Prefix)[] ReorderByLowestIndex(int lowestIndex) + { + var result = new (string Path, int SequenceNumber, string Prefix)[ephemeralChildrenWithPrefix.Count]; + for (var i = 0; i < result.Length; ++i) + { + var element = ephemeralChildrenWithPrefix[(i + lowestIndex) % result.Length]; + result[i] = (element.Path, unchecked((int)element.UnsignedSequenceNumber), element.Prefix); + } + return result; + } + } + + /// + /// If is of the form [.../]prefix{sequence number}, returns the sequence + /// number. Otherwise, returns null. + /// + internal static int? GetSequenceNumberOrDefault(string pathOrName, string prefix) + { + Invariant.Require(prefix.Length > 0); + + // when processing child path names, this should be -1; that means we'll expect the prefix at the start + var prefixStartIndex = pathOrName.LastIndexOf(ZooKeeperPath.Separator) + 1; + if (pathOrName.IndexOf(prefix, startIndex: prefixStartIndex) != prefixStartIndex) + { + return null; + } + + // FROM https://zookeeper.apache.org/doc/r3.5.4-beta/zookeeperProgrammers.html#Sequence+Nodes+--+Unique+Naming + // "The counter has a format of %010d -- that is 10 digits with 0 (zero) padding (the counter is formatted in this way to simplify sorting), + // i.e. "0000000001". See Queue Recipe for an example use of this feature. Note: the counter used to store the next sequence number + // is a signed int (4bytes) maintained by the parent node, the counter will overflow when incremented beyond 2147483647 (resulting in a name "-2147483648")." + var counterSuffix = pathOrName.Substring(prefixStartIndex + prefix.Length); + return ( + (counterSuffix.Length == 10 && counterSuffix[0] != '+') // 10-char number; don't allow leading + + || (counterSuffix.Length == 11 && counterSuffix[0] == '-') // 11-char number MUST be - and then 10 digits + ) + && int.TryParse(counterSuffix, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var sequenceNumber) + ? sequenceNumber + : default(int?); + } + + public static async Task GetNodeCreationTimeAsync(this ZooKeeperConnection connection, string path) => + (await connection.ZooKeeper.existsAsync(path).ConfigureAwait(false))?.getCtime(); +} diff --git a/src/DistributedLock.ZooKeeper/ZooKeeperSynchronizationHelper.cs b/src/DistributedLock.ZooKeeper/ZooKeeperSynchronizationHelper.cs new file mode 100644 index 00000000..ed8fe596 --- /dev/null +++ b/src/DistributedLock.ZooKeeper/ZooKeeperSynchronizationHelper.cs @@ -0,0 +1,182 @@ +using Medallion.Threading.Internal; +using org.apache.zookeeper.data; +using System.Text; + +namespace Medallion.Threading.ZooKeeper; + +using org.apache.zookeeper; + +/// +/// Provides a common structure for ZooKeeper synchronization algorithms. The structure is: +/// * Create a sequential ephemeral node +/// * Loop +/// * Compare our node's sequence number to its siblings to see if we've acquired +/// * If we haven't acquired, wait for a watcher to tell us that something has changed +/// +internal class ZooKeeperSynchronizationHelper +{ + public static readonly IReadOnlyList AcquiredMarker = Encoding.UTF8.GetBytes("ACQUIRED"); + + private readonly ZooKeeperConnectionInfo _connectionInfo; + private readonly IReadOnlyList _acl; + private readonly bool _assumePathExists, _setAcquiredMarker; + + public ZooKeeperSynchronizationHelper( + ZooKeeperPath path, + bool assumePathExists, + string connectionString, + Action? optionsBuilder, + bool setAcquiredMarker = false) + { + this.Path = path; + this._assumePathExists = assumePathExists; + var options = ZooKeeperDistributedSynchronizationOptionsBuilder.GetOptions(optionsBuilder); + this._connectionInfo = new ZooKeeperConnectionInfo( + connectionString ?? throw new ArgumentNullException(nameof(connectionString)), + ConnectTimeout: options.ConnectTimeout, + SessionTimeout: options.SessionTimeout, + AuthInfo: options.AuthInfo + ); + this._acl = options.Acl; + this._setAcquiredMarker = setAcquiredMarker; + } + + public ZooKeeperPath Path { get; } + + public async Task TryAcquireAsync( + Func hasAcquired, + Func> waitAsync, + TimeoutValue timeout, + CancellationToken cancellationToken, + string nodePrefix, + string? alternateNodePrefix = null) + { + var acquired = false; + var ephemeralNodeLost = false; + ZooKeeperConnection? connection = null; + string? ephemeralNodePath = null; + var timeoutSource = new CancellationTokenSource(timeout.TimeSpan); + + try + { + connection = await ZooKeeperConnection.DefaultPool.ConnectAsync(this._connectionInfo, cancellationToken).ConfigureAwait(false); + + // create a path that represents both our hold on the synch object and our place in line waiting for it + ephemeralNodePath = await connection.CreateEphemeralSequentialNode(this.Path, nodePrefix, this._acl, ensureDirectoryExists: !this._assumePathExists).ConfigureAwait(false); + + while (true) + { + // get the children of the node, filter them by prefix, and sort them by age; we'll use this to determine our place in the list + var children = await connection.ZooKeeper.getChildrenAsync(this.Path.ToString()).ConfigureAwait(false); + var sortedChildren = await ZooKeeperSequentialPathHelper.FilterAndSortAsync( + parentNode: this.Path.ToString(), + childrenNames: children.Children, + getNodeCreationTimeAsync: connection.GetNodeCreationTimeAsync, + prefix: nodePrefix, + alternatePrefix: alternateNodePrefix + ).ConfigureAwait(false); + + // Sanity check; this could happen if someone else deletes it out from under us. We must check + // this first because it covers the empty collection case + if (!sortedChildren.Any(t => t.Path == ephemeralNodePath)) + { + ephemeralNodeLost = true; + throw new InvalidOperationException($"Node '{ephemeralNodePath}' was created, but no longer exists"); + } + + // see if we've acquired + var state = new State(ephemeralNodePath, sortedChildren); + if (hasAcquired(state)) + { + if (this._setAcquiredMarker) + { + await connection.ZooKeeper.setDataAsync(ephemeralNodePath, AcquiredMarker.ToArray()).ConfigureAwait(false); + } + acquired = true; + return new ZooKeeperNodeHandle(connection, ephemeralNodePath, shouldDeleteParent: !this._assumePathExists); + } + + // wait for something to change + var waitCompletionSource = new TaskCompletionSource(); + using var timeoutRegistration = timeoutSource.Token.Register(state => ((TaskCompletionSource)state).TrySetResult(false), waitCompletionSource); + using var cancellationRegistration = cancellationToken.Register(state => ((TaskCompletionSource)state).TrySetCanceled(), waitCompletionSource); + // this is needed because if the connection goes down and never recovers, we'll never get the session expired notification + using var connectionLostRegistration = connection.ConnectionLostToken.Register( + state => ((TaskCompletionSource)state).TrySetException(new InvalidOperationException("Lost connection to ZooKeeper")), + state: waitCompletionSource + ); + if (!waitCompletionSource.Task.IsCompleted + && await waitAsync(connection.ZooKeeper, state, new WaitCompletionSourceWatcher(waitCompletionSource)).ConfigureAwait(false)) + { + waitCompletionSource.TrySetResult(true); + } + + if (!await waitCompletionSource.Task.ConfigureAwait(false)) + { + return null; // wait timed out + } + } + } + finally + { + timeoutSource.Dispose(); + + // if we failed to acquire, clean up the connection/node path + if (!acquired) + { + await this.CleanUpOnFailureAsync(connection, ephemeralNodePath, ephemeralNodeLost).ConfigureAwait(false); + } + } + } + + private async Task CleanUpOnFailureAsync(ZooKeeperConnection? connection, string? ephemeralNodePath, bool ephemeralNodeLost) + { + if (connection == null) { return; } + + try + { + if (ephemeralNodePath != null) + { + if (!ephemeralNodeLost) + { + await connection.ZooKeeper.deleteAsync(ephemeralNodePath).ConfigureAwait(false); + } + if (!this._assumePathExists) + { + // If the parent node should be cleaned up, try to do so. This attempt will almost certainly fail because + // someone else is holding the lock. However, we could have encountered a race condition where the other holder + // released right after we failed to acquire and our ephemeral node prevented them from deleting. Therefore, we + // fire and forget this deletion to cover that case without slowing us down + _ = connection.ZooKeeper.deleteAsync(this.Path.ToString()); + } + } + } + finally + { + connection.Dispose(); + } + } + + private sealed class WaitCompletionSourceWatcher : Watcher + { + private readonly TaskCompletionSource _waitCompletionSource; + + public WaitCompletionSourceWatcher(TaskCompletionSource waitCompletionSource) + { + this._waitCompletionSource = waitCompletionSource; + } + + public override Task process(WatchedEvent @event) + { + // only care about connected state events; the ConnectionLostToken takes care of the other states for us + if (@event.getState() == Event.KeeperState.SyncConnected) + { + this._waitCompletionSource.TrySetResult(true); + } + + return Task.CompletedTask; + } + } + + public record State(string EphemeralNodePath, (string Path, int SequenceNumber, string Prefix)[] SortedChildren); +} diff --git a/src/DistributedLock.ZooKeeper/packages.lock.json b/src/DistributedLock.ZooKeeper/packages.lock.json new file mode 100644 index 00000000..791be22a --- /dev/null +++ b/src/DistributedLock.ZooKeeper/packages.lock.json @@ -0,0 +1,273 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "IsExternalInit": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "jcdN7vpDwjnV7U9KGCv/oNIWHJ/RIbyrpVsLQL2qbj6Cbi5U3iafKsaMn1rP+qCbkLbkB0v6oAwbVEkqrUnlyg==" + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "Nullable": { + "type": "Direct", + "requested": "[1.3.1, )", + "resolved": "1.3.1", + "contentHash": "Mk4ZVDfAORTjvckQprCSehi1XgOAAlk5ez06Va/acRYEloN9t6d6zpzJRn5MEq7+RnagyFIq9r+kbWzLGd+6QA==" + }, + "ZooKeeperNetEx": { + "type": "Direct", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3TIsJhD1EiiT0w2CcDMN/iSSwnNnsrnbzeVHSKkaEgV85txMprmuO+Yq2AdSbeVGcg28pdNDTPK87tJhX7VFHw==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.ValueTuple": { + "type": "CentralTransitive", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + } + }, + ".NETStandard,Version=v2.0": { + "IsExternalInit": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "jcdN7vpDwjnV7U9KGCv/oNIWHJ/RIbyrpVsLQL2qbj6Cbi5U3iafKsaMn1rP+qCbkLbkB0v6oAwbVEkqrUnlyg==" + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Nullable": { + "type": "Direct", + "requested": "[1.3.1, )", + "resolved": "1.3.1", + "contentHash": "Mk4ZVDfAORTjvckQprCSehi1XgOAAlk5ez06Va/acRYEloN9t6d6zpzJRn5MEq7+RnagyFIq9r+kbWzLGd+6QA==" + }, + "ZooKeeperNetEx": { + "type": "Direct", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + } + }, + ".NETStandard,Version=v2.1": { + "IsExternalInit": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "jcdN7vpDwjnV7U9KGCv/oNIWHJ/RIbyrpVsLQL2qbj6Cbi5U3iafKsaMn1rP+qCbkLbkB0v6oAwbVEkqrUnlyg==" + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "ZooKeeperNetEx": { + "type": "Direct", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "distributedlock.core": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/DistributedLock.sln b/src/DistributedLock.sln similarity index 56% rename from DistributedLock.sln rename to src/DistributedLock.sln index a712597d..39196b2d 100644 --- a/DistributedLock.sln +++ b/src/DistributedLock.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29613.14 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11116.177 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock", "DistributedLock\DistributedLock.csproj", "{C1F56B68-C2EE-48E5-A99B-B40D397AE34F}" EndProject @@ -19,7 +19,35 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLockCodeGen", "D EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock.SqlServer", "DistributedLock.SqlServer\DistributedLock.SqlServer.csproj", "{A7C39A34-B8FA-48B0-87C4-65FE2A8BD62B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedLock.Azure", "DistributedLock.Azure\DistributedLock.Azure.csproj", "{1C5E80A9-343A-44AA-A40E-00EAFE485D36}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock.Azure", "DistributedLock.Azure\DistributedLock.Azure.csproj", "{1C5E80A9-343A-44AA-A40E-00EAFE485D36}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock.FileSystem", "DistributedLock.FileSystem\DistributedLock.FileSystem.csproj", "{6B0DBD7E-3A43-4FED-82BA-9927360E1D92}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock.Redis", "DistributedLock.Redis\DistributedLock.Redis.csproj", "{8ADAA171-8373-432E-8D2B-3DA73E35EB76}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{D14FE348-E866-4784-8345-DD15FB697E4E}" + ProjectSection(SolutionItems) = preProject + CopyPackageToPublishDirectory.targets = CopyPackageToPublishDirectory.targets + FixDistributedLockCoreDependencyVersion.targets = FixDistributedLockCoreDependencyVersion.targets + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock.ZooKeeper", "DistributedLock.ZooKeeper\DistributedLock.ZooKeeper.csproj", "{710F287B-02FB-4F89-9BEC-BAA97250037F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock.MySql", "DistributedLock.MySql\DistributedLock.MySql.csproj", "{6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock.Oracle", "DistributedLock.Oracle\DistributedLock.Oracle.csproj", "{1CAB9A1D-0C02-459C-A90E-47819832BD58}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedLock.MongoDB", "DistributedLock.MongoDB\DistributedLock.MongoDB.csproj", "{92074E6D-99D1-46B1-A0AE-442EA1FEA397}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FC144BB6-BA58-41E6-BB7E-76069FCDD293}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets + Directory.Packages.props = Directory.Packages.props + FixDistributedLockCoreDependencyVersion.targets = FixDistributedLockCoreDependencyVersion.targets + package.readme.md = package.readme.md + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -63,6 +91,30 @@ Global {1C5E80A9-343A-44AA-A40E-00EAFE485D36}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C5E80A9-343A-44AA-A40E-00EAFE485D36}.Release|Any CPU.ActiveCfg = Release|Any CPU {1C5E80A9-343A-44AA-A40E-00EAFE485D36}.Release|Any CPU.Build.0 = Release|Any CPU + {6B0DBD7E-3A43-4FED-82BA-9927360E1D92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B0DBD7E-3A43-4FED-82BA-9927360E1D92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B0DBD7E-3A43-4FED-82BA-9927360E1D92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B0DBD7E-3A43-4FED-82BA-9927360E1D92}.Release|Any CPU.Build.0 = Release|Any CPU + {8ADAA171-8373-432E-8D2B-3DA73E35EB76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ADAA171-8373-432E-8D2B-3DA73E35EB76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ADAA171-8373-432E-8D2B-3DA73E35EB76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ADAA171-8373-432E-8D2B-3DA73E35EB76}.Release|Any CPU.Build.0 = Release|Any CPU + {710F287B-02FB-4F89-9BEC-BAA97250037F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {710F287B-02FB-4F89-9BEC-BAA97250037F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {710F287B-02FB-4F89-9BEC-BAA97250037F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {710F287B-02FB-4F89-9BEC-BAA97250037F}.Release|Any CPU.Build.0 = Release|Any CPU + {6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}.Release|Any CPU.Build.0 = Release|Any CPU + {1CAB9A1D-0C02-459C-A90E-47819832BD58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CAB9A1D-0C02-459C-A90E-47819832BD58}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CAB9A1D-0C02-459C-A90E-47819832BD58}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CAB9A1D-0C02-459C-A90E-47819832BD58}.Release|Any CPU.Build.0 = Release|Any CPU + {92074E6D-99D1-46B1-A0AE-442EA1FEA397}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92074E6D-99D1-46B1-A0AE-442EA1FEA397}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92074E6D-99D1-46B1-A0AE-442EA1FEA397}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92074E6D-99D1-46B1-A0AE-442EA1FEA397}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DistributedLock.snk b/src/DistributedLock.snk similarity index 100% rename from DistributedLock.snk rename to src/DistributedLock.snk diff --git a/DistributedLock/DistributedLock.csproj b/src/DistributedLock/DistributedLock.csproj similarity index 53% rename from DistributedLock/DistributedLock.csproj rename to src/DistributedLock/DistributedLock.csproj index 28b8da47..48d488db 100644 --- a/DistributedLock/DistributedLock.csproj +++ b/src/DistributedLock/DistributedLock.csproj @@ -1,7 +1,7 @@  - netstandard2.0;netstandard2.1;net461 + netstandard2.0;netstandard2.1;net462;net472 Medallion.Threading True 4 @@ -10,13 +10,14 @@ - 2.0.0-alpha01 + 2.8.3 2.0.0.0 Michael Adelson - Provides easy-to-use mutexes, reader-writer locks, and semaphores that can synchronize across processes and machines + Provides easy-to-use mutexes, reader-writer locks, and semaphores that can synchronize across processes and machines. This is an umbrella package that brings in the entire family of DistributedLock.* packages (e. g. DistributedLock.SqlServer) as references. Those packages can also be installed individually. + Copyright © 2017 Michael Adelson MIT - distributed lock async waithandle mutex sql sqlserver reader writer azure semaphore + distributed lock async mutex sql reader writer semaphore azure sqlserver postgres mysql mariadb oracle redis waithandle zookeeper https://github.com/madelson/DistributedLock https://github.com/madelson/DistributedLock 1.0.0.0 @@ -30,6 +31,11 @@ True True + + embedded + + true + true @@ -43,6 +49,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/DistributedLock/packages.lock.json b/src/DistributedLock/packages.lock.json new file mode 100644 index 00000000..0913e314 --- /dev/null +++ b/src/DistributedLock/packages.lock.json @@ -0,0 +1,2558 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.6.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==" + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.6", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0", + "System.Memory": "4.6.3" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.Data.SqlClient.SNI": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "p3Pm/+7oPSn4At6vKrttRpUOVdrcer3oZln0XeYZ94DTTQirUVzQy5QmHjdMmbyIaTaYb6BYf+8N7ob5t1ctQA==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1", + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Data.Common": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "lm6E3T5u7BOuEH0u18JpbJHxBfOJPuCyl4Kg1RH10ktYLp5uEEE1xKrHW56/We4SnZpGAuCc9N0MJpSDhTHZGQ==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "vaoWjvkG1aenR2XdjaVivlCV9fADfgyhW5bZtXT23qaEea0lWiUljdQuze4E31vKM7ZWJaSUsbYIKE3rnzfZUg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.ValueTuple": "4.5.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==" + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.5" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4", + "System.ValueTuple": "4.5.0" + } + }, + "System.Text.RegularExpressions": { + "type": "Transitive", + "resolved": "4.3.1", + "contentHash": "N0kNRrWe4+nXOWlpLT4LAY5brb8caNFlUuIRpraCVMDLYutKkol1aV079rQjLuSxKMJT2SpBQsYX9xbcTMmzwg==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "distributedlock.azure": { + "type": "Project", + "dependencies": { + "Azure.Storage.Blobs": "[12.19.1, )", + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "distributedlock.filesystem": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.mysql": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MySqlConnector": "[2.3.5, )" + } + }, + "distributedlock.postgres": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Npgsql": "[8.0.6, )" + } + }, + "distributedlock.redis": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "StackExchange.Redis": "[2.7.33, )" + } + }, + "distributedlock.sqlserver": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Microsoft.Data.SqlClient": "[6.1.4, )" + } + }, + "distributedlock.waithandles": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.zookeeper": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "ZooKeeperNetEx": "[3.4.12.4, )" + } + }, + "Azure.Storage.Blobs": { + "type": "CentralTransitive", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "System.Buffers": "4.6.1", + "System.Data.Common": "4.3.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Memory": "4.6.3", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6", + "System.Text.RegularExpressions": "4.3.1" + } + }, + "MySqlConnector": { + "type": "CentralTransitive", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1", + "System.Diagnostics.DiagnosticSource": "7.0.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "System.Collections.Immutable": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Json": "8.0.5", + "System.Threading.Channels": "8.0.0" + } + }, + "StackExchange.Redis": { + "type": "CentralTransitive", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8", + "System.IO.Compression": "4.3.0", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "System.Threading.Channels": "5.0.0" + } + }, + "System.ValueTuple": { + "type": "CentralTransitive", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "ZooKeeperNetEx": { + "type": "CentralTransitive", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + } + }, + ".NETFramework,Version=v4.7.2": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.6", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0", + "System.Memory": "4.6.3" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0", + "System.Buffers": "4.5.1" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Data.SqlClient.SNI": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "p3Pm/+7oPSn4At6vKrttRpUOVdrcer3oZln0XeYZ94DTTQirUVzQy5QmHjdMmbyIaTaYb6BYf+8N7ob5t1ctQA==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1", + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.9.0", + "contentHash": "J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Text.Encoding.CodePages": "8.0.0" + } + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==", + "dependencies": { + "System.Memory": "4.6.3" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Data.Common": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "lm6E3T5u7BOuEH0u18JpbJHxBfOJPuCyl4Kg1RH10ktYLp5uEEE1xKrHW56/We4SnZpGAuCc9N0MJpSDhTHZGQ==" + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.ValueTuple": "4.5.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==" + }, + "System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==" + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.5" + } + }, + "System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.4", + "contentHash": "aOa2d51SEbmM+H+Csw7yJOuNZoHkrP2XnAurye5HWYgGVVU54YZDvsLUYRv6h18X3sPnjNCANmN7ZhIPiqMcjA==", + "dependencies": { + "System.Security.Cryptography.X509Certificates": "4.3.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "System.IO": "4.3.0", + "System.Runtime": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "System.Security.Cryptography.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4", + "System.ValueTuple": "4.5.0" + } + }, + "System.Text.RegularExpressions": { + "type": "Transitive", + "resolved": "4.3.1", + "contentHash": "N0kNRrWe4+nXOWlpLT4LAY5brb8caNFlUuIRpraCVMDLYutKkol1aV079rQjLuSxKMJT2SpBQsYX9xbcTMmzwg==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "System.Memory": "4.5.5" + } + }, + "distributedlock.azure": { + "type": "Project", + "dependencies": { + "Azure.Storage.Blobs": "[12.19.1, )", + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "distributedlock.filesystem": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.mongodb": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MongoDB.Driver": "[3.9.0, )", + "System.Diagnostics.DiagnosticSource": "[10.0.5, )" + } + }, + "distributedlock.mysql": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MySqlConnector": "[2.3.5, )" + } + }, + "distributedlock.oracle": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Oracle.ManagedDataAccess": "[23.6.1, )" + } + }, + "distributedlock.postgres": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Npgsql": "[8.0.6, )" + } + }, + "distributedlock.redis": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "StackExchange.Redis": "[2.7.33, )" + } + }, + "distributedlock.sqlserver": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Microsoft.Data.SqlClient": "[6.1.4, )" + } + }, + "distributedlock.waithandles": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.zookeeper": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "ZooKeeperNetEx": "[3.4.12.4, )" + } + }, + "Azure.Storage.Blobs": { + "type": "CentralTransitive", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "System.Buffers": "4.6.1", + "System.Data.Common": "4.3.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Memory": "4.6.3", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6", + "System.Text.RegularExpressions": "4.3.1" + } + }, + "MongoDB.Driver": { + "type": "CentralTransitive", + "requested": "[3.9.0, )", + "resolved": "3.9.0", + "contentHash": "XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Net.Http": "4.3.4", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "ZstdSharp.Port": "0.7.3" + } + }, + "MySqlConnector": { + "type": "CentralTransitive", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1", + "System.Diagnostics.DiagnosticSource": "7.0.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "System.Collections.Immutable": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Json": "8.0.5", + "System.Threading.Channels": "8.0.0" + } + }, + "Oracle.ManagedDataAccess": { + "type": "CentralTransitive", + "requested": "[23.6.1, )", + "resolved": "23.6.1", + "contentHash": "EZi+mahzUwQFWs9Is8ed94eTzWOlfCLMd+DDWukf/h/brTz1wB9Qk3fsxBrjw9+fEXrxDgx4uXNiPHNPRS3BeQ==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.Text.Json": "8.0.5", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "StackExchange.Redis": { + "type": "CentralTransitive", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8", + "System.IO.Compression": "4.3.0", + "System.Threading.Channels": "5.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "ZooKeeperNetEx": { + "type": "CentralTransitive", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + } + }, + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.6", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0", + "System.Memory": "4.6.3" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.CSharp": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "kaj6Wb4qoMuH3HySFJhxwQfe8R/sJsNJnANrvv8WdFPMoNbKY5htfNscv+LHCu5ipz+49m2e+WQXpLXr9XYemQ==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ComponentModel.Annotations": "5.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.Security.Cryptography.Cng": "5.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.CSharp": "4.5.0", + "Microsoft.IdentityModel.Logging": "7.7.1", + "System.Security.Cryptography.Cng": "4.5.0", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.ComponentModel.Annotations": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg==" + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "vaoWjvkG1aenR2XdjaVivlCV9fADfgyhW5bZtXT23qaEea0lWiUljdQuze4E31vKM7ZWJaSUsbYIKE3rnzfZUg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.5" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "AUADIc0LIEQe7MzC+I0cl0rAT8RrTAKFHl53yHjEUzNVIaUlhFY11vc2ebiVJzVBuOzun6F7FBA+8KAbGTTedQ==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "jIMXsKn94T9JY7PvPq/tMfqa6GAaHpElRDpmG+SuL+D3+sTw2M8VhnibKnN8Tq+4JqbPJ/f+BwtLeDMEnzAvRg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Formats.Asn1": "8.0.1", + "System.Memory": "4.5.5", + "System.Security.Cryptography.Cng": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==", + "dependencies": { + "System.Memory": "4.5.5" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "distributedlock.azure": { + "type": "Project", + "dependencies": { + "Azure.Storage.Blobs": "[12.19.1, )", + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )" + } + }, + "distributedlock.filesystem": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.mysql": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MySqlConnector": "[2.3.5, )" + } + }, + "distributedlock.postgres": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Npgsql": "[8.0.6, )" + } + }, + "distributedlock.redis": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "StackExchange.Redis": "[2.7.33, )" + } + }, + "distributedlock.sqlserver": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Microsoft.Data.SqlClient": "[6.1.4, )" + } + }, + "distributedlock.waithandles": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "System.Threading.AccessControl": "[8.0.0, )" + } + }, + "distributedlock.zookeeper": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "ZooKeeperNetEx": "[3.4.12.4, )" + } + }, + "Azure.Storage.Blobs": { + "type": "CentralTransitive", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "8.0.1", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "MySqlConnector": { + "type": "CentralTransitive", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1", + "System.Diagnostics.DiagnosticSource": "7.0.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "System.Collections.Immutable": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Json": "8.0.5", + "System.Threading.Channels": "8.0.0" + } + }, + "StackExchange.Redis": { + "type": "CentralTransitive", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8", + "System.Threading.Channels": "5.0.0" + } + }, + "System.Threading.AccessControl": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "cIed5+HuYz+eV9yu9TH95zPkqmm1J9Qps9wxjB335sU8tsqc2kGdlTEH9FZzZeCS8a7mNSEsN8ZkyhQp1gfdEw==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Security.AccessControl": "6.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "ZooKeeperNetEx": { + "type": "CentralTransitive", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + } + }, + ".NETStandard,Version=v2.1": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.6", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0", + "System.Memory": "4.6.3" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.CSharp": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "kaj6Wb4qoMuH3HySFJhxwQfe8R/sJsNJnANrvv8WdFPMoNbKY5htfNscv+LHCu5ipz+49m2e+WQXpLXr9XYemQ==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ComponentModel.Annotations": "5.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.Security.Cryptography.Cng": "5.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.CSharp": "4.5.0", + "Microsoft.IdentityModel.Logging": "7.7.1", + "System.Security.Cryptography.Cng": "4.5.0", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.9.0", + "contentHash": "J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Text.Encoding.CodePages": "8.0.0" + } + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.ComponentModel.Annotations": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg==" + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "dDl7Gx3bmSrM2k2ZIm+ucEJnLloZRyvfQF1DvfvATcGF3jtaUBiPvChma+6ZcZzxWMirN3kCywkW7PILphXyMQ==" + }, + "System.DirectoryServices.Protocols": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "vDDPWwHn3/DNZ+kPkdXHoada+tKPEC9bVqDOr4hK6HBSP7hGCUTA0Zw6WU5qpGaqa5M1/V+axHMIv+DNEbIf6g==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.5" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "AUADIc0LIEQe7MzC+I0cl0rAT8RrTAKFHl53yHjEUzNVIaUlhFY11vc2ebiVJzVBuOzun6F7FBA+8KAbGTTedQ==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "jIMXsKn94T9JY7PvPq/tMfqa6GAaHpElRDpmG+SuL+D3+sTw2M8VhnibKnN8Tq+4JqbPJ/f+BwtLeDMEnzAvRg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==", + "dependencies": { + "System.Formats.Asn1": "8.0.1", + "System.Security.Cryptography.Cng": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==", + "dependencies": { + "System.Memory": "4.5.5" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "distributedlock.azure": { + "type": "Project", + "dependencies": { + "Azure.Storage.Blobs": "[12.19.1, )", + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.core": { + "type": "Project" + }, + "distributedlock.filesystem": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.mongodb": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MongoDB.Driver": "[3.9.0, )", + "System.Diagnostics.DiagnosticSource": "[10.0.5, )" + } + }, + "distributedlock.mysql": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MySqlConnector": "[2.3.5, )" + } + }, + "distributedlock.oracle": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Oracle.ManagedDataAccess.Core": "[23.6.1, )" + } + }, + "distributedlock.postgres": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Npgsql": "[8.0.6, )" + } + }, + "distributedlock.redis": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "StackExchange.Redis": "[2.7.33, )" + } + }, + "distributedlock.sqlserver": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Microsoft.Data.SqlClient": "[6.1.4, )" + } + }, + "distributedlock.waithandles": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "System.Threading.AccessControl": "[8.0.0, )" + } + }, + "distributedlock.zookeeper": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "ZooKeeperNetEx": "[3.4.12.4, )" + } + }, + "Azure.Storage.Blobs": { + "type": "CentralTransitive", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "8.0.1", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "MongoDB.Driver": { + "type": "CentralTransitive", + "requested": "[3.9.0, )", + "resolved": "3.9.0", + "contentHash": "XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "ZstdSharp.Port": "0.7.3" + } + }, + "MySqlConnector": { + "type": "CentralTransitive", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1", + "System.Diagnostics.DiagnosticSource": "7.0.2" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "System.Collections.Immutable": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Json": "8.0.5", + "System.Threading.Channels": "8.0.0" + } + }, + "Oracle.ManagedDataAccess.Core": { + "type": "CentralTransitive", + "requested": "[23.6.1, )", + "resolved": "23.6.1", + "contentHash": "Oc8AX7xme05xrp4/aCxKBH4+bpWgMCFafXI7LbLO/7OBMJLZRXhMtejDgIb8aYvIVyV7vSdAy3LkCYcJorxn1A==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Diagnostics.PerformanceCounter": "6.0.1", + "System.DirectoryServices.Protocols": "6.0.2", + "System.Formats.Asn1": "6.0.1", + "System.Security.Cryptography.Pkcs": "6.0.4", + "System.Text.Json": "6.0.10" + } + }, + "StackExchange.Redis": { + "type": "CentralTransitive", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8", + "System.Threading.Channels": "5.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Threading.AccessControl": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "cIed5+HuYz+eV9yu9TH95zPkqmm1J9Qps9wxjB335sU8tsqc2kGdlTEH9FZzZeCS8a7mNSEsN8ZkyhQp1gfdEw==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Security.AccessControl": "6.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "ZooKeeperNetEx": { + "type": "CentralTransitive", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + } + } + } +} \ No newline at end of file diff --git a/src/DistributedLockCodeGen/CodeGenHelpers.cs b/src/DistributedLockCodeGen/CodeGenHelpers.cs new file mode 100644 index 00000000..80da686b --- /dev/null +++ b/src/DistributedLockCodeGen/CodeGenHelpers.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace DistributedLockCodeGen; + +internal static class CodeGenHelpers +{ + public static string SolutionDirectory => Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..")); + + public static IEnumerable EnumerateSolutionFiles() => Directory.EnumerateFiles(SolutionDirectory, "*.csproj", SearchOption.AllDirectories) + .Select(Path.GetDirectoryName) + .Where(d => !Regex.IsMatch(Path.GetFileName(d)!, "^(DistributedLock|DistributedLock.Tests|DistributedLockCodeGen)$", RegexOptions.IgnoreCase)) + .SelectMany(d => Directory.EnumerateFiles(d!, "*.cs", SearchOption.AllDirectories)); + + public static string NormalizeCodeWhitespace(string code) => code.Trim().Replace("\r\n", "\n"); + + public static bool HasPublicType(string code, out (string typeName, bool isInterface) info) + { + var match = Regex.Match(code, @"\n( |\t)?public.*?(class|interface)\s+(?\w+)"); + if (match.Success) + { + info = (typeName: match.Groups["name"].Value, isInterface: match.Value.Contains("interface")); + return true; + } + + info = default; + return false; + } + + public static bool SupportsSyncApis(string path) => + // zookeeper is inherently asynchronous (watch-based), so any synchronous APIs it has are just sync-over-async + !path.Contains("ZooKeeper", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/DistributedLockCodeGen/DistributedLockCodeGen.csproj b/src/DistributedLockCodeGen/DistributedLockCodeGen.csproj new file mode 100644 index 00000000..48b48a54 --- /dev/null +++ b/src/DistributedLockCodeGen/DistributedLockCodeGen.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + Latest + false + enable + true + ..\DistributedLock.snk + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/DistributedLockCodeGen/DocCommentGenerator.cs b/src/DistributedLockCodeGen/DocCommentGenerator.cs new file mode 100644 index 00000000..252fe850 --- /dev/null +++ b/src/DistributedLockCodeGen/DocCommentGenerator.cs @@ -0,0 +1,126 @@ +using NUnit.Framework; +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace DistributedLockCodeGen; + +[Category("CI")] +public class DocCommentGenerator +{ + [Test] + public void GenerateDocComments() + { + var changes = CodeGenHelpers.EnumerateSolutionFiles() + .Select(f => (file: f, code: File.ReadAllText(f))) + .Select(t => (t.file, t.code, updatedCode: AddDocComments(t.code))) + .Where(t => CodeGenHelpers.NormalizeCodeWhitespace(t.updatedCode) != CodeGenHelpers.NormalizeCodeWhitespace(t.code)) + .ToList(); + changes.ForEach(t => File.WriteAllText(t.file, t.updatedCode)); + Assert.That(changes.Select(t => t.file), Is.Empty); + } + + internal static string AddDocComments(string code) + { + if (!CodeGenHelpers.HasPublicType(code, out var typeInfo)) { return code; } + + var acquireMethods = Regex.Matches( + code, + $@"(?([ \t]+///.*?\n)*)(? ( )?|\t(\t)?){(typeInfo.isInterface ? "" : "public ")}(?\S+) (?([a-zA-Z])+)\(" + + @"((?\S+) (?([a-zA-Z])+)( = \S+)(, )?)+\)" + ); + + var updatedCode = code; + foreach (var acquireMethod in acquireMethods.Cast()) + { + var name = acquireMethod.Groups["name"].Value; + if (!name.Contains("Acquire")) { continue; } + + var isTry = name.StartsWith("Try"); + var isAsync = name.EndsWith("Async"); + var lockType = name.Contains("Upgradeable") ? LockType.Upgrade + : name.Contains("Write") ? LockType.Write + : name.Contains("Read") ? LockType.Read + : typeInfo.typeName.Contains("Semaphore") ? LockType.Semaphore + : LockType.Mutex; + + var @object = lockType == LockType.Semaphore ? "semaphore" : "lock"; + var handleObject = lockType == LockType.Semaphore ? "ticket" : "lock"; + + var lineStart = acquireMethod.Groups["indent"].Value + "/// "; + var docComment = new StringBuilder(); + + docComment.AppendLine(lineStart + ""); + + docComment.Append(lineStart); + if (isTry) { docComment.Append("Attempts to acquire "); } + else { docComment.Append("Acquires "); } + docComment.Append(lockType switch + { + LockType.Read => "a READ lock ", + LockType.Upgrade => "an UPGRADE lock ", + LockType.Write => "a WRITE lock ", + LockType.Mutex => "the lock ", + LockType.Semaphore => "a semaphore ticket ", + _ => throw new NotSupportedException() + }); + docComment.Append(isAsync ? "asynchronously" : "synchronously"); + docComment.Append(isTry ? ". " : ", failing with if the attempt times out. "); + docComment.Append(lockType switch + { + LockType.Read => "Multiple readers are allowed. Not compatible with a WRITE lock. ", + LockType.Upgrade => "Not compatible with another UPGRADE lock or a WRITE lock. ", + LockType.Write => "Not compatible with another WRITE lock or an UPGRADE lock. ", + LockType.Mutex => "", + LockType.Semaphore => "", + _ => throw new NotSupportedException(), + }); + docComment.AppendLine("Usage: "); + + docComment.Append(lineStart).AppendLine(""); + docComment.Append(lineStart).Append($" {(isAsync ? "await " : "")}using (") + .Append(isTry ? "var handle = " : "") + .Append(isAsync ? "await " : "") + .Append($"my{char.ToUpper(@object[0])}{@object[1..]}.") + .AppendLine($"{name}(...))"); + docComment.Append(lineStart).AppendLine(" {"); + docComment.Append(lineStart).Append(' ', 8) + .Append(isTry ? "if (handle != null) { " : "") + .Append($"/* we have the {handleObject}! */") + .AppendLine(isTry ? " }" : ""); + docComment.Append(lineStart).AppendLine(" }"); + docComment.Append(lineStart) + .Append($" // dispose releases the {handleObject}") + .AppendLine(isTry ? " if we took it" : ""); + docComment.Append(lineStart).AppendLine(""); + + docComment.Append(lineStart).AppendLine(""); + + docComment.Append(lineStart).Append("How long to wait before giving up on the acquisition attempt. ") + .AppendLine($"Defaults to {(isTry ? "0" : "")}"); + docComment.Append(lineStart).AppendLine("Specifies a token by which the wait can be canceled"); + + var returnType = acquireMethod.Groups["returnType"].Value; + if (returnType.StartsWith("ValueTask<")) { returnType = returnType.Replace("ValueTask<", "").TrimEnd('>'); } + var useAn = "aeiou".Contains(char.ToLower(returnType[0])); + docComment.Append(lineStart).Append($"A{(useAn ? "n" : "")} which can be used to release the {handleObject}") + .Append(isTry ? " or null on failure" : "") + .AppendLine(""); + + updatedCode = updatedCode.Replace(acquireMethod.Value, docComment + acquireMethod.Value[acquireMethod.Groups["docComment"].Length..]); + } + + return updatedCode; + } +} + +internal enum LockType +{ + Mutex, + Read, + Write, + Upgrade, + Semaphore, +} diff --git a/src/DistributedLockCodeGen/GenerateIDistributedLockImplementations.cs b/src/DistributedLockCodeGen/GenerateIDistributedLockImplementations.cs new file mode 100644 index 00000000..f6543897 --- /dev/null +++ b/src/DistributedLockCodeGen/GenerateIDistributedLockImplementations.cs @@ -0,0 +1,205 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace DistributedLockCodeGen; + +[Category("CI")] +public class GenerateIDistributedLockImplementations +{ + [Test] + public void GenerateForIDistributedLockAndSemaphore([Values("Lock", "Semaphore")] string name) + { + var files = CodeGenHelpers.EnumerateSolutionFiles() + .Where(f => !f.Contains($"Distributed{name}.Core", StringComparison.OrdinalIgnoreCase)) + .Where(f => f.EndsWith($"Distributed{name}.cs", StringComparison.OrdinalIgnoreCase) && Path.GetFileName(f)[0] != 'I'); + + var errors = new List(); + foreach (var file in files) + { + var lockCode = File.ReadAllText(file); + if (lockCode.Contains("AUTO-GENERATED") + || !CodeGenHelpers.HasPublicType(lockCode, out _)) + { + continue; + } + + if (!lockCode.Contains($": IInternalDistributed{name}<")) + { + errors.Add($"{file} does not implement the expected interface"); + continue; + } + + var lockType = Path.GetFileNameWithoutExtension(file); + var handleType = lockType + "Handle"; + var supportsSyncApis = CodeGenHelpers.SupportsSyncApis(file); + + var explicitImplementations = new StringBuilder(); + var @interface = $"IDistributed{name}"; + foreach (var method in new[] { "TryAcquire", "Acquire", "TryAcquireAsync", "AcquireAsync" }) + { + AppendExplicitInterfaceMethod( + explicitImplementations, + @interface, + method, + "IDistributedSynchronizationHandle", + @as: supportsSyncApis || method.EndsWith("Async") ? null : $"IInternalDistributed{name}<{handleType}>" + ); + } + + var @namespace = Regex.Match(lockCode, @"\nnamespace (?[^\s;]+)").Groups["namespace"].Value; + var code = +$@"using Medallion.Threading.Internal; + +namespace {@namespace}; + +public partial class {lockType} +{{ + // AUTO-GENERATED + +{explicitImplementations} + {IfSyncApis("public ")}{handleType}? {(supportsSyncApis ? "" : $"IInternalDistributed{name}<{handleType}>.")}TryAcquire(TimeSpan timeout{IfSyncApis(" = default")}, CancellationToken cancellationToken{IfSyncApis(" = default")}) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + {IfSyncApis("public ")}{handleType} {(supportsSyncApis ? "" : $"IInternalDistributed{name}<{handleType}>.")}Acquire(TimeSpan? timeout{IfSyncApis(" = null")}, CancellationToken cancellationToken{IfSyncApis(" = default")}) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + public ValueTask<{handleType}?> TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + public ValueTask<{handleType}> AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); +}}"; + code = DocCommentGenerator.AddDocComments(code); + + var outputPath = Path.Combine(Path.GetDirectoryName(file)!, Path.GetFileNameWithoutExtension(file) + $".IDistributed{name}.cs"); + if (!File.Exists(outputPath) || CodeGenHelpers.NormalizeCodeWhitespace(File.ReadAllText(outputPath)) != CodeGenHelpers.NormalizeCodeWhitespace(code)) + { + File.WriteAllText(outputPath, code); + errors.Add($"updated {file}"); + } + + string IfSyncApis(string value) => supportsSyncApis ? value : string.Empty; + } + + Assert.That(errors, Is.Empty); + } + + [Test] + public void GenerateForIDistributedReaderWriterLock() + { + var files = CodeGenHelpers.EnumerateSolutionFiles() + .Where(f => f.IndexOf("DistributedLock.Core", StringComparison.OrdinalIgnoreCase) < 0) + .Where(f => Regex.IsMatch(Path.GetFileName(f), @"Distributed.*?ReaderWriterLock\.cs$", RegexOptions.IgnoreCase)); + + var errors = new List(); + foreach (var file in files) + { + var lockCode = File.ReadAllText(file); + if (lockCode.Contains("AUTO-GENERATED") + || !CodeGenHelpers.HasPublicType(lockCode, out _)) + { + continue; + } + + bool isUpgradeable; + if (lockCode.Contains(": IInternalDistributedUpgradeableReaderWriterLock<")) + { + isUpgradeable = true; + } + else if (lockCode.Contains(": IInternalDistributedReaderWriterLock<")) + { + isUpgradeable = false; + } + else + { + errors.Add($"{file} does not implement the expected interface"); + continue; + } + + var lockType = Path.GetFileNameWithoutExtension(file); + var supportsSyncApis = CodeGenHelpers.SupportsSyncApis(file); + + var explicitImplementations = new StringBuilder(); + var publicMethods = new StringBuilder(); + foreach (var methodLockType in new[] { LockType.Read, LockType.Upgrade, LockType.Write }.Where(t => isUpgradeable || t != LockType.Upgrade)) + foreach (var isAsync in new[] { false, true }) + foreach (var isTry in new[] { true, false }) + { + var upgradeableText = methodLockType == LockType.Upgrade ? "Upgradeable" : ""; + var handleType = lockType + upgradeableText + "Handle"; + + var methodName = $"{(isTry ? "Try" : "")}Acquire{upgradeableText}{(methodLockType == LockType.Write ? "Write" : "Read")}Lock{(isAsync ? "Async" : "")}"; + AppendExplicitInterfaceMethod( + explicitImplementations, + $"IDistributed{upgradeableText}ReaderWriterLock", + methodName, + $"IDistributed{(methodLockType == LockType.Upgrade ? "LockUpgradeable" : "Synchronization")}Handle", + @as: supportsSyncApis || isAsync ? null : $"IInternalDistributed{upgradeableText}ReaderWriterLock<{handleType}>" + ); + + var simplifiedMethodName = methodLockType == LockType.Upgrade ? methodName : methodName.Replace("ReadLock", "").Replace("WriteLock", ""); + + publicMethods.AppendLine() + .Append(' ', 8).Append(IfPublic("public ")) + .Append(isAsync ? "ValueTask<" : "").Append(handleType).Append(isTry ? "?" : "").Append(isAsync ? ">" : "").Append(' ') + .Append(supportsSyncApis || isAsync ? "" : $"IInternalDistributed{upgradeableText}ReaderWriterLock<{handleType}>.") + .Append(methodName) + .Append("(").Append("TimeSpan").Append(isTry ? "" : "?").AppendLine($" timeout{IfPublic($" = {(isTry ? "default" : "null")}")}, CancellationToken cancellationToken{IfPublic(" = default")}) =>") + .Append(' ', 12) + .Append( + isTry && isAsync + ? $"this.As>()" + + $".Internal{simplifiedMethodName}(timeout, cancellationToken" + : $"DistributedLockHelpers.{simplifiedMethodName}(this, timeout, cancellationToken" + ) + .Append(methodLockType == LockType.Read ? ", isWrite: false" : methodLockType == LockType.Write ? ", isWrite: true" : "") + .AppendLine(");"); + + string? IfPublic(string content) => supportsSyncApis || isAsync ? content : null; + } + + var @namespace = Regex.Match(lockCode, @"\nnamespace (?[^\s;]+)").Groups["namespace"].Value; + var code = +$@"using Medallion.Threading.Internal; + +namespace {@namespace}; + +public partial class {lockType} +{{ + // AUTO-GENERATED + +{explicitImplementations}{publicMethods} +}}"; + code = DocCommentGenerator.AddDocComments(code); + + var outputPath = Path.Combine(Path.GetDirectoryName(file)!, $"{Path.GetFileNameWithoutExtension(file)}.IDistributed{(isUpgradeable ? "Upgradeable" : "")}ReaderWriterLock.cs"); + if (!File.Exists(outputPath) || CodeGenHelpers.NormalizeCodeWhitespace(File.ReadAllText(outputPath)) != CodeGenHelpers.NormalizeCodeWhitespace(code)) + { + File.WriteAllText(outputPath, code); + errors.Add($"updated {file}"); + } + } + + Assert.That(errors, Is.Empty); + } + + private static void AppendExplicitInterfaceMethod(StringBuilder code, string @interface, string method, string returnType, string? @as = null) + { + var isAsync = method.EndsWith("Async"); + var isTry = method.StartsWith("Try"); + var returnTypeToUse = isTry ? returnType + "?" : returnType; + + code.Append(' ', 4) + .Append(isAsync ? $"ValueTask<{returnTypeToUse}>" : returnTypeToUse) + .AppendLine($" {@interface}.{method}(TimeSpan{(isTry ? string.Empty : "?")} timeout, CancellationToken cancellationToken) =>") + .Append(' ', 8) + .Append($"this{(@as != null ? $".As<{@as}>()" : "")}.{method}(timeout, cancellationToken)") + .Append(isAsync ? $".Convert(To<{returnTypeToUse}>.ValueTask)" : string.Empty) + .AppendLine(";"); + } +} diff --git a/src/DistributedLockCodeGen/GenerateProviders.cs b/src/DistributedLockCodeGen/GenerateProviders.cs new file mode 100644 index 00000000..5258ff67 --- /dev/null +++ b/src/DistributedLockCodeGen/GenerateProviders.cs @@ -0,0 +1,155 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace DistributedLockCodeGen; + +[Category("CI")] +public class GenerateProviders +{ + public static readonly IReadOnlyList Interfaces = + [ + "IDistributedLock", + "IDistributedReaderWriterLock", + "IDistributedUpgradeableReaderWriterLock", + "IDistributedSemaphore" + ]; + + private static readonly IReadOnlyDictionary ExcludedInterfacesForCompositeMethods = new Dictionary + { + ["IDistributedUpgradeableReaderWriterLock"] = "a composite acquire operation must be able to roll back and upgrade does not support that." + }; + + [TestCaseSource(nameof(Interfaces))] + public void GenerateProviderInterfaceAndExtensions(string interfaceName) + { + var interfaceFile = Directory + .GetFiles(CodeGenHelpers.SolutionDirectory, interfaceName + ".cs", SearchOption.AllDirectories) + .Single(); + var providerInterfaceName = interfaceName + "Provider"; + + var createMethodName = $"Create{interfaceName.Replace("IDistributed", string.Empty)}"; + var providerInterfaceCode = $$""" + // AUTO-GENERATED + namespace Medallion.Threading; + + /// + /// Acts as a factory for instances of a certain type. This interface may be + /// easier to use than in dependency injection scenarios. + /// + public interface {{providerInterfaceName}}{{(interfaceName == "IDistributedUpgradeableReaderWriterLock" ? ": IDistributedReaderWriterLockProvider" : string.Empty)}} + { + /// + /// Constructs an instance with the given . + /// + {{interfaceName}} {{createMethodName}}(string name{{(interfaceName.Contains("Semaphore") ? ", int maxCount" : string.Empty)}}); + } + """; + + var interfaceMethods = Regex.Matches( + File.ReadAllText(interfaceFile), + @"(?\S+) (?\S+)\((?((?\S*) (?\w+)[^,)]*(\, )?)*)\);", + RegexOptions.ExplicitCapture + ); + + var extensionSingleMethodBodies = interfaceMethods + .Select(m => + $""" + /// + /// Equivalent to calling and then + /// c.Value))})" />. + /// + public static {m.Groups["returnType"].Value} {GetExtensionMethodName(m.Groups["name"].Value)}(this {providerInterfaceName} provider, string name{(interfaceName.Contains("Semaphore") ? ", int maxCount" : string.Empty)}, {m.Groups["parameters"].Value}) => + (provider ?? throw new ArgumentNullException(nameof(provider))).{createMethodName}(name{(interfaceName.Contains("Semaphore") ? ", maxCount" : string.Empty)}).{m.Groups["name"].Value}({string.Join(", ", m.Groups["parameterName"].Captures.Select(c => c.Value))}); + """ + ); + + var extensionCompositeMethodBodies = ExcludedInterfacesForCompositeMethods.TryGetValue(interfaceName, out var exclusionReason) + ? + [ + $""" + // Composite methods are not supported for {interfaceName} + // because {exclusionReason} + """ + ] + : interfaceMethods + .Select(m => + { + var baseExtensionMethodName = GetExtensionMethodName(m.Groups["name"].Value); + var isAsync = baseExtensionMethodName.EndsWith("Async"); + var isTry = baseExtensionMethodName.StartsWith("Try"); + var extensionMethodName = baseExtensionMethodName.Replace("Async", "") + .Replace("Acquire", "AcquireAll") + + "s" + + (isAsync ? "Async" : ""); + var isSemaphore = interfaceName.Contains("Semaphore"); + string MaxCountArg(string prefix = "") => isSemaphore ? prefix + "maxCount, " : ""; + + return $""" + /// + /// Equivalent to calling for each name in and then + /// c.Value))})" /> on each created instance, combining the results into a composite handle. + /// + public static {m.Groups["returnType"].Value} {extensionMethodName}(this {providerInterfaceName} provider, IReadOnlyList names{(isSemaphore ? ", int maxCount" : string.Empty)}, {m.Groups["parameters"].Value}) => + {( + isAsync + ? $"provider.Try{extensionMethodName.Replace("Try", "").Replace("Async", "InternalAsync")}(names, {MaxCountArg()}timeout, cancellationToken).GetHandleOr{(isTry ? "Default" : "Timeout")}();" + : $"SyncViaAsync.Run(static s => s.provider.{extensionMethodName}Async(s.names, {MaxCountArg("s.")}s.timeout, s.cancellationToken), (provider, names, {MaxCountArg()}timeout, cancellationToken));" + )} + """; + } + ); + + var providerExtensionsName = providerInterfaceName.TrimStart('I') + "Extensions"; + + var providerExtensionsCode = $$""" + // AUTO-GENERATED + + using Medallion.Threading.Internal; + + namespace Medallion.Threading; + + /// + /// Productivity helper methods for + /// + public static class {{providerExtensionsName}} + { + # region Single Lock Methods + + {{string.Join(Environment.NewLine + Environment.NewLine, extensionSingleMethodBodies)}} + + # endregion + + # region Composite Lock Methods + + {{string.Join(Environment.NewLine + Environment.NewLine, extensionCompositeMethodBodies)}} + + # endregion + } + """; + + var changes = new[] + { + (name: providerInterfaceName, code: providerInterfaceCode), + (name: providerExtensionsName, code: providerExtensionsCode) + } + .Select(t => (file: Path.Combine(Path.GetDirectoryName(interfaceFile)!, t.name + ".cs"), t.code)) + .Select(t => (t.file, t.code, originalCode: File.Exists(t.file) ? File.ReadAllText(t.file) : string.Empty)) + .Where(t => CodeGenHelpers.NormalizeCodeWhitespace(t.code) != + CodeGenHelpers.NormalizeCodeWhitespace(t.originalCode)) + .ToList(); + changes.ForEach(t => File.WriteAllText(t.file, t.code)); + Assert.That(changes.Select(t => t.file), Is.Empty); + + string GetExtensionMethodName(string interfaceMethodName) => + Regex.IsMatch(interfaceMethodName, "^(Try)?Acquire(Async)?$") + // make it more specific to differentiate when one concrete provider implements multiple provider interfaces + ? interfaceMethodName.Replace("Async", string.Empty) + + interfaceName.Replace("IDistributed", string.Empty) + + (interfaceMethodName.EndsWith("Async") ? "Async" : string.Empty) + : interfaceMethodName; + } +} \ No newline at end of file diff --git a/src/DistributedLockCodeGen/packages.lock.json b/src/DistributedLockCodeGen/packages.lock.json new file mode 100644 index 00000000..7107b5bb --- /dev/null +++ b/src/DistributedLockCodeGen/packages.lock.json @@ -0,0 +1,83 @@ +{ + "version": 2, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.9.0, )", + "resolved": "17.9.0", + "contentHash": "7GUNAUbJYn644jzwLm5BD3a2p9C1dmP8Hr6fDPDxgItQk9hBs1Svdxzz07KQ/UphMSmgza9AbijBJGmw5D658A==", + "dependencies": { + "Microsoft.CodeCoverage": "17.9.0", + "Microsoft.TestPlatform.TestHost": "17.9.0" + } + }, + "NUnit": { + "type": "Direct", + "requested": "[3.14.0, )", + "resolved": "3.14.0", + "contentHash": "R7iPwD7kbOaP3o2zldWJbWeMQAvDKD0uld27QvA3PAALl1unl7x0v2J7eGiJOYjimV/BuGT4VJmr45RjS7z4LA==", + "dependencies": { + "NETStandard.Library": "2.0.0" + } + }, + "NUnit.Analyzers": { + "type": "Direct", + "requested": "[4.1.0, )", + "resolved": "4.1.0", + "contentHash": "Odd1RusSMnfswIiCPbokAqmlcCCXjQ20poaXWrw+CWDnBY1vQ/x6ZGqgyJXpebPq5Uf8uEBe5iOAySsCdSrWdQ==" + }, + "NUnit3TestAdapter": { + "type": "Direct", + "requested": "[4.5.0, )", + "resolved": "4.5.0", + "contentHash": "s8JpqTe9bI2f49Pfr3dFRfoVSuFQyraTj68c3XXjIS/MRGvvkLnrg6RLqnTjdShX+AdFUCCU/4Xex58AdUfs6A==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.9.0", + "contentHash": "RGD37ZSrratfScYXm7M0HjvxMxZyWZL4jm+XgMZbkIY1UPgjUpbNA/t+WTGj/rC/0Hm9A3IrH3ywbKZkOCnoZA==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.9.0", + "contentHash": "1ilw/8vgmjLyKU+2SKXKXaOqpYFJCQfGqGz+x0cosl981VzjrY74Sv6qAJv+neZMZ9ZMxF3ArN6kotaQ4uvEBw==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.9.0", + "contentHash": "Spmg7Wx49Ya3SxBjyeAR+nQpjMTKZwTwpZ7KyeOTIqI/WHNPnBU4HUvl5kuHPQAwGWqMy4FGZja1HvEwvoaDiA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.9.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "NETStandard.Library": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "7jnbRU+L08FXKMxqUflxEXtVymWvNOrS8yHgu9s6EM8Anr6T/wIX4nZ08j/u3Asz+tCufp3YVwFSEvFTPYmBPA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + } + } + } +} \ No newline at end of file diff --git a/DistributedLockTaker/App.config b/src/DistributedLockTaker/App.config similarity index 100% rename from DistributedLockTaker/App.config rename to src/DistributedLockTaker/App.config diff --git a/DistributedLockTaker/DistributedLockTaker.csproj b/src/DistributedLockTaker/DistributedLockTaker.csproj similarity index 85% rename from DistributedLockTaker/DistributedLockTaker.csproj rename to src/DistributedLockTaker/DistributedLockTaker.csproj index 10757f14..d7197b25 100644 --- a/DistributedLockTaker/DistributedLockTaker.csproj +++ b/src/DistributedLockTaker/DistributedLockTaker.csproj @@ -1,7 +1,7 @@  - net471;netcoreapp3.1 + net472;net8.0 Exe false Latest @@ -30,8 +30,5 @@ - - - - + \ No newline at end of file diff --git a/src/DistributedLockTaker/Program.cs b/src/DistributedLockTaker/Program.cs new file mode 100644 index 00000000..ff0866c6 --- /dev/null +++ b/src/DistributedLockTaker/Program.cs @@ -0,0 +1,186 @@ +using System; +using Medallion.Threading.SqlServer; +using Medallion.Threading.WaitHandles; +using Medallion.Threading.Postgres; +using Medallion.Threading.Tests; +using Medallion.Threading.Azure; +using Azure.Storage.Blobs; +using Medallion.Threading.FileSystem; +using System.IO; +using Medallion.Threading.Redis; +using StackExchange.Redis; +using System.Linq; +using Medallion.Threading; +using System.Collections.Generic; +using Medallion.Threading.ZooKeeper; +using Medallion.Threading.MySql; +using Medallion.Threading.Oracle; +using Medallion.Threading.MongoDB; +using Medallion.Threading.Tests.MongoDB; + +namespace DistributedLockTaker; + +internal static class Program +{ + public static int Main(string[] args) + { + var type = args[0]; + var name = args[1]; + IDisposable? handle; + switch (type) + { + case nameof(SqlDistributedLock): + handle = new SqlDistributedLock(name, SqlServerCredentials.ConnectionString).Acquire(); + break; + case "Write" + nameof(SqlDistributedReaderWriterLock): + handle = new SqlDistributedReaderWriterLock(name, SqlServerCredentials.ConnectionString).AcquireWriteLock(); + break; + case nameof(SqlDistributedSemaphore) + "1AsMutex": + handle = new SqlDistributedSemaphore(name, maxCount: 1, connectionString: SqlServerCredentials.ConnectionString).Acquire(); + break; + case nameof(SqlDistributedSemaphore) + "5AsMutex": + handle = new SqlDistributedSemaphore(name, maxCount: 5, connectionString: SqlServerCredentials.ConnectionString).Acquire(); + break; + case nameof(PostgresDistributedLock): + handle = new PostgresDistributedLock(new PostgresAdvisoryLockKey(name), PostgresCredentials.GetConnectionString(Environment.CurrentDirectory)).Acquire(); + break; + case "Write" + nameof(PostgresDistributedReaderWriterLock): + handle = new PostgresDistributedReaderWriterLock(new PostgresAdvisoryLockKey(name), PostgresCredentials.GetConnectionString(Environment.CurrentDirectory)).AcquireWriteLock(); + break; + case nameof(MySqlDistributedLock): + handle = new MySqlDistributedLock(name, MySqlCredentials.GetConnectionString(Environment.CurrentDirectory)).Acquire(); + break; + case "MariaDB" + nameof(MySqlDistributedLock): + handle = new MySqlDistributedLock(name, MariaDbCredentials.GetConnectionString(Environment.CurrentDirectory)).Acquire(); + break; + case nameof(OracleDistributedLock): + handle = new OracleDistributedLock(name, OracleCredentials.GetConnectionString(Environment.CurrentDirectory)).Acquire(); + break; + case "Write" + nameof(OracleDistributedReaderWriterLock): + handle = new OracleDistributedReaderWriterLock(name, OracleCredentials.GetConnectionString(Environment.CurrentDirectory)).AcquireWriteLock(); + break; + case nameof(EventWaitHandleDistributedLock): + handle = new EventWaitHandleDistributedLock(name).Acquire(); + break; + case nameof(WaitHandleDistributedSemaphore) + "1AsMutex": + handle = new WaitHandleDistributedSemaphore(name, maxCount: 1).Acquire(); + break; + case nameof(WaitHandleDistributedSemaphore) + "5AsMutex": + handle = new WaitHandleDistributedSemaphore(name, maxCount: 5).Acquire(); + break; + case nameof(AzureBlobLeaseDistributedLock): + handle = new AzureBlobLeaseDistributedLock( + new BlobClient(AzureCredentials.ConnectionString, AzureCredentials.DefaultBlobContainerName, name), + o => o.Duration(TimeSpan.FromSeconds(15)) + ) + .Acquire(); + break; + case nameof(FileDistributedLock): + handle = new FileDistributedLock(new FileInfo(name)).Acquire(); + break; + case nameof(RedisDistributedLock) + "1": + handle = AcquireRedisLock(name, serverCount: 1); + break; + case nameof(RedisDistributedLock) + "3": + handle = AcquireRedisLock(name, serverCount: 3); + break; + case nameof(RedisDistributedLock) + "2x1": + handle = AcquireRedisLock(name, serverCount: 2); // we know the last will fail; don't bother (we also don't know its port) + break; + case nameof(RedisDistributedLock) + "1WithPrefix": + handle = AcquireRedisLock("distributed_locks:" + name, serverCount: 1); + break; + case "Write" + nameof(RedisDistributedReaderWriterLock) + "1": + handle = AcquireRedisWriteLock(name, serverCount: 1); + break; + case "Write" + nameof(RedisDistributedReaderWriterLock) + "3": + handle = AcquireRedisWriteLock(name, serverCount: 3); + break; + case "Write" + nameof(RedisDistributedReaderWriterLock) + "2x1": + handle = AcquireRedisWriteLock(name, serverCount: 2); // we know the last will fail; don't bother (we also don't know its port) + break; + case "Write" + nameof(RedisDistributedReaderWriterLock) + "1WithPrefix": + handle = AcquireRedisWriteLock("distributed_locks:" + name, serverCount: 1); + break; + case string _ when type.StartsWith(nameof(RedisDistributedSemaphore)): + { + var maxCount = type.EndsWith("1AsMutex") ? 1 + : type.EndsWith("5AsMutex") ? 5 + : throw new ArgumentException(type); + handle = new RedisDistributedSemaphore( + name, + maxCount, + GetRedisDatabases(serverCount: 1).Single(), + // in order to see abandonment work in a reasonable timeframe, use very short expiry + options => options.Expiry(TimeSpan.FromSeconds(1)) + .BusyWaitSleepTime(TimeSpan.FromSeconds(.1), TimeSpan.FromSeconds(.3)) + ).Acquire(); + break; + } + case nameof(ZooKeeperDistributedLock): + handle = new ZooKeeperDistributedLock(new ZooKeeperPath(name), ZooKeeperPorts.DefaultConnectionString, options: ZooKeeperOptions).AcquireAsync().Result; + break; + case "Write" + nameof(ZooKeeperDistributedReaderWriterLock): + handle = new ZooKeeperDistributedReaderWriterLock(new ZooKeeperPath(name), ZooKeeperPorts.DefaultConnectionString, options: ZooKeeperOptions).AcquireWriteLockAsync().Result; + break; + case string _ when type.StartsWith(nameof(ZooKeeperDistributedSemaphore)): + { + var maxCount = type.EndsWith("1AsMutex") ? 1 + : type.EndsWith("5AsMutex") ? 5 + : throw new ArgumentException(type); + handle = new ZooKeeperDistributedSemaphore( + new ZooKeeperPath(name), + maxCount, + ZooKeeperPorts.DefaultConnectionString, + options: ZooKeeperOptions + ).AcquireAsync().Result; + break; + } + case nameof(MongoDistributedLock): + handle = new MongoDistributedLock(name, MongoDBCredentials.GetDefaultDatabase(Environment.CurrentDirectory), options => options.Expiry(TimeSpan.FromSeconds(2))).Acquire(); + break; + case nameof(TestingCompositeFileDistributedLock): + handle = new TestingCompositeFileDistributedLock(name).Acquire(); + break; + case nameof(TestingCompositeWaitHandleDistributedSemaphore) + "1AsMutex": + handle = new TestingCompositeWaitHandleDistributedSemaphore(name, maxCount: 1).Acquire(); + break; + case nameof(TestingCompositeWaitHandleDistributedSemaphore) + "5AsMutex": + handle = new TestingCompositeWaitHandleDistributedSemaphore(name, maxCount: 5).Acquire(); + break; + case "Write" + nameof(TestingCompositePostgresReaderWriterLock): + handle = new TestingCompositePostgresReaderWriterLock(name, PostgresCredentials.GetConnectionString(Environment.CurrentDirectory)).AcquireWriteLock(); + break; + default: + Console.Error.WriteLine($"type: {type}"); + return 123; + } + + Console.WriteLine("Acquired"); + Console.Out.Flush(); + + if (Console.ReadLine() != "abandon") + { + handle.Dispose(); + } + + return 0; + } + + private static IDistributedSynchronizationHandle AcquireRedisLock(string name, int serverCount) => + new RedisDistributedLock(name, GetRedisDatabases(serverCount), RedisOptions).Acquire(); + + private static IDistributedSynchronizationHandle AcquireRedisWriteLock(string name, int serverCount) => + new RedisDistributedReaderWriterLock(name, GetRedisDatabases(serverCount), RedisOptions).AcquireWriteLock(); + + private static IEnumerable GetRedisDatabases(int serverCount) => RedisPorts.DefaultPorts.Take(serverCount) + .Select(port => ConnectionMultiplexer.Connect($"localhost:{port}").GetDatabase()); + + private static void RedisOptions(RedisDistributedSynchronizationOptionsBuilder options) => + options.Expiry(TimeSpan.FromSeconds(.5)) // short expiry for abandonment testing + .BusyWaitSleepTime(TimeSpan.FromSeconds(.1), TimeSpan.FromSeconds(.3)); + + private static void ZooKeeperOptions(ZooKeeperDistributedSynchronizationOptionsBuilder options) => + // use a very short session timeout to support abandonment + options.SessionTimeout(TimeSpan.FromSeconds(.1)); +} diff --git a/src/DistributedLockTaker/packages.lock.json b/src/DistributedLockTaker/packages.lock.json new file mode 100644 index 00000000..98f233a4 --- /dev/null +++ b/src/DistributedLockTaker/packages.lock.json @@ -0,0 +1,1408 @@ +{ + "version": 2, + "dependencies": { + ".NETFramework,Version=v4.7.2": { + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.6", + "System.Threading.Tasks.Extensions": "4.6.0" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0", + "System.Memory": "4.6.3" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0", + "System.Buffers": "4.5.1" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.Data.SqlClient.SNI": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "p3Pm/+7oPSn4At6vKrttRpUOVdrcer3oZln0XeYZ94DTTQirUVzQy5QmHjdMmbyIaTaYb6BYf+8N7ob5t1ctQA==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1", + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.9.0", + "contentHash": "J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Text.Encoding.CodePages": "8.0.0" + } + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==", + "dependencies": { + "System.Memory": "4.6.3" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.Memory.Data": "8.0.1", + "System.Text.Json": "8.0.6" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Data.Common": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "lm6E3T5u7BOuEH0u18JpbJHxBfOJPuCyl4Kg1RH10ktYLp5uEEE1xKrHW56/We4SnZpGAuCc9N0MJpSDhTHZGQ==" + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.ValueTuple": "4.5.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==" + }, + "System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==" + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.5" + } + }, + "System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.4", + "contentHash": "aOa2d51SEbmM+H+Csw7yJOuNZoHkrP2XnAurye5HWYgGVVU54YZDvsLUYRv6h18X3sPnjNCANmN7ZhIPiqMcjA==", + "dependencies": { + "System.Security.Cryptography.X509Certificates": "4.3.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "System.IO": "4.3.0", + "System.Runtime": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "System.Security.Cryptography.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4", + "System.ValueTuple": "4.5.0" + } + }, + "System.Text.RegularExpressions": { + "type": "Transitive", + "resolved": "4.3.1", + "contentHash": "N0kNRrWe4+nXOWlpLT4LAY5brb8caNFlUuIRpraCVMDLYutKkol1aV079rQjLuSxKMJT2SpBQsYX9xbcTMmzwg==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "I5G6Y8jb0xRtGUC9Lahy7FUvlYlnGMMkbuKAQBy8Jb7Y6Yn8OlBEiUOY0PqZ0hy6Ua8poVA1ui1tAIiXNxGdsg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.0" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "System.Memory": "4.5.5" + } + }, + "distributedlock": { + "type": "Project", + "dependencies": { + "DistributedLock.Azure": "[1.0.2, )", + "DistributedLock.FileSystem": "[1.0.3, )", + "DistributedLock.MongoDB": "[1.0.1, )", + "DistributedLock.MySql": "[1.0.2, )", + "DistributedLock.Oracle": "[1.0.5, )", + "DistributedLock.Postgres": "[1.3.1, )", + "DistributedLock.Redis": "[1.1.1, )", + "DistributedLock.SqlServer": "[1.0.7, )", + "DistributedLock.WaitHandles": "[1.0.1, )", + "DistributedLock.ZooKeeper": "[1.0.0, )" + } + }, + "distributedlock.azure": { + "type": "Project", + "dependencies": { + "Azure.Storage.Blobs": "[12.19.1, )", + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.core": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[8.0.0, )", + "System.ValueTuple": "[4.5.0, )" + } + }, + "distributedlock.filesystem": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.mongodb": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MongoDB.Driver": "[3.9.0, )", + "System.Diagnostics.DiagnosticSource": "[10.0.5, )" + } + }, + "distributedlock.mysql": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MySqlConnector": "[2.3.5, )" + } + }, + "distributedlock.oracle": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Oracle.ManagedDataAccess": "[23.6.1, )" + } + }, + "distributedlock.postgres": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Npgsql": "[8.0.6, )" + } + }, + "distributedlock.redis": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "StackExchange.Redis": "[2.7.33, )" + } + }, + "distributedlock.sqlserver": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Microsoft.Data.SqlClient": "[6.1.4, )" + } + }, + "distributedlock.waithandles": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.zookeeper": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "ZooKeeperNetEx": "[3.4.12.4, )" + } + }, + "Azure.Storage.Blobs": { + "type": "CentralTransitive", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "System.Buffers": "4.6.1", + "System.Data.Common": "4.3.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Memory": "4.6.3", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6", + "System.Text.RegularExpressions": "4.3.1" + } + }, + "MongoDB.Driver": { + "type": "CentralTransitive", + "requested": "[3.9.0, )", + "resolved": "3.9.0", + "contentHash": "XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Net.Http": "4.3.4", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "ZstdSharp.Port": "0.7.3" + } + }, + "MySqlConnector": { + "type": "CentralTransitive", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1", + "System.Diagnostics.DiagnosticSource": "7.0.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "System.Collections.Immutable": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Json": "8.0.5", + "System.Threading.Channels": "8.0.0" + } + }, + "Oracle.ManagedDataAccess": { + "type": "CentralTransitive", + "requested": "[23.6.1, )", + "resolved": "23.6.1", + "contentHash": "EZi+mahzUwQFWs9Is8ed94eTzWOlfCLMd+DDWukf/h/brTz1wB9Qk3fsxBrjw9+fEXrxDgx4uXNiPHNPRS3BeQ==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Formats.Asn1": "8.0.1", + "System.Text.Json": "8.0.5", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "StackExchange.Redis": { + "type": "CentralTransitive", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8", + "System.IO.Compression": "4.3.0", + "System.Threading.Channels": "5.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "CCbzHQ26L3jskdwHh+4bxxW84lUMIrAAmeSlpO69AlrQV0DKbj1/I+feLaLSuZeqXPr9UlSy0OcgZoXOk2a6/g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "ZooKeeperNetEx": { + "type": "CentralTransitive", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + } + }, + ".NETFramework,Version=v4.7.2/win-x86": { + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==" + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.4", + "contentHash": "aOa2d51SEbmM+H+Csw7yJOuNZoHkrP2XnAurye5HWYgGVVU54YZDvsLUYRv6h18X3sPnjNCANmN7ZhIPiqMcjA==", + "dependencies": { + "System.Security.Cryptography.X509Certificates": "4.3.0" + } + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "System.IO": "4.3.0", + "System.Runtime": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "System.Buffers": "4.6.1", + "System.Data.Common": "4.3.0", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Memory": "4.6.3", + "System.Security.Cryptography.Pkcs": "8.0.1", + "System.Text.Json": "8.0.6", + "System.Text.RegularExpressions": "4.3.1" + } + } + }, + "net8.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.26, )", + "resolved": "8.0.26", + "contentHash": "o7/yVssM2r9Wyln2s9edBd5ANZXqdSdBI+g7JqXkyJmXrhs2WsJp25K5yPnYrTgdKBCjKB8bg+O2oew4sgzFaA==" + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.80.0", + "contentHash": "nmg+q17mKdNafWvaX7Of5Xh8sxc4acsD6xOOczp7kgjAzR7bpseYGZzg38XPoS/vW7k92sGKCWgHSogB0K62KQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.9.0", + "contentHash": "J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "Oracle.ManagedDataAccess.Core": { + "type": "Transitive", + "resolved": "23.6.1", + "contentHash": "Oc8AX7xme05xrp4/aCxKBH4+bpWgMCFafXI7LbLO/7OBMJLZRXhMtejDgIb8aYvIVyV7vSdAy3LkCYcJorxn1A==", + "dependencies": { + "System.Diagnostics.PerformanceCounter": "8.0.0", + "System.DirectoryServices.Protocols": "8.0.0", + "System.Formats.Asn1": "8.0.1", + "System.Security.Cryptography.Pkcs": "8.0.0" + } + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==" + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==", + "dependencies": { + "System.Diagnostics.EventLog": "8.0.1", + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "vaoWjvkG1aenR2XdjaVivlCV9fADfgyhW5bZtXT23qaEea0lWiUljdQuze4E31vKM7ZWJaSUsbYIKE3rnzfZUg==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "n1ZP7NM2Gkn/MgD8+eOT5MulMj6wfeQMNS2Pizvq5GHCZfjlFMXV2irQlQmJhwA2VABC57M0auudO89Iu2uRLg==" + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "lX6DXxtJqVGWw7N/QmVoiCyVQ+Q/Xp+jVXPr3gLK1jJExSn1qmAjJQeb8gnOYeeBTG3E3PmG1nu92eYj/TEjpg==", + "dependencies": { + "System.Configuration.ConfigurationManager": "8.0.0" + } + }, + "System.DirectoryServices.Protocols": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "puwJxURHDrYLGTQdsHyeMS72ClTqYa4lDYz6LHSbkZEk5hq8H8JfsO4MyYhB5BMMxg93jsQzLUwrnCumj11UIg==" + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZD9TMpsmYJLrxbbmdvhwt9YEgG5WntEnZ/d1eH8JBX9LBp+Ju8BSBhUGbZMNVHHomWo2KVImJhTDl2hIgw/6MA==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==" + }, + "distributedlock": { + "type": "Project", + "dependencies": { + "DistributedLock.Azure": "[1.0.2, )", + "DistributedLock.FileSystem": "[1.0.3, )", + "DistributedLock.MongoDB": "[1.0.1, )", + "DistributedLock.MySql": "[1.0.2, )", + "DistributedLock.Oracle": "[1.0.5, )", + "DistributedLock.Postgres": "[1.3.1, )", + "DistributedLock.Redis": "[1.1.1, )", + "DistributedLock.SqlServer": "[1.0.7, )", + "DistributedLock.WaitHandles": "[1.0.1, )", + "DistributedLock.ZooKeeper": "[1.0.0, )" + } + }, + "distributedlock.azure": { + "type": "Project", + "dependencies": { + "Azure.Storage.Blobs": "[12.19.1, )", + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.core": { + "type": "Project" + }, + "distributedlock.filesystem": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )" + } + }, + "distributedlock.mongodb": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MongoDB.Driver": "[3.9.0, )" + } + }, + "distributedlock.mysql": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "MySqlConnector": "[2.3.5, )" + } + }, + "distributedlock.oracle": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Oracle.ManagedDataAccess.Core": "[23.6.1, )" + } + }, + "distributedlock.postgres": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Npgsql": "[8.0.6, )" + } + }, + "distributedlock.redis": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "StackExchange.Redis": "[2.7.33, )" + } + }, + "distributedlock.sqlserver": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "Microsoft.Data.SqlClient": "[6.1.4, )" + } + }, + "distributedlock.waithandles": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "System.Threading.AccessControl": "[8.0.0, )" + } + }, + "distributedlock.zookeeper": { + "type": "Project", + "dependencies": { + "DistributedLock.Core": "[1.0.9, )", + "ZooKeeperNetEx": "[3.4.12.4, )" + } + }, + "Azure.Storage.Blobs": { + "type": "CentralTransitive", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "8.0.1", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Security.Cryptography.Pkcs": "8.0.1" + } + }, + "MongoDB.Driver": { + "type": "CentralTransitive", + "requested": "[3.9.0, )", + "resolved": "3.9.0", + "contentHash": "XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "System.Buffers": "4.6.1", + "ZstdSharp.Port": "0.7.3" + } + }, + "MySqlConnector": { + "type": "CentralTransitive", + "requested": "[2.3.5, )", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1" + } + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "KaS6CY5kY2Sd0P00MSeFcOI3t2DiQ4UWG8AuRpVOUeDWITOKfoEEG91DP3cmT6aerixPkjwKgXxnpDxIkDpO6g==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "StackExchange.Redis": { + "type": "CentralTransitive", + "requested": "[2.7.33, )", + "resolved": "2.7.33", + "contentHash": "2kCX5fvhEE824a4Ab5Imyi8DRuGuTxyklXV01kegkRpsWJcPmO6+GAQ+HegKxvXAxlXZ8yaRspvWJ8t3mMClfQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, + "System.Threading.AccessControl": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "cIed5+HuYz+eV9yu9TH95zPkqmm1J9Qps9wxjB335sU8tsqc2kGdlTEH9FZzZeCS8a7mNSEsN8ZkyhQp1gfdEw==" + }, + "ZooKeeperNetEx": { + "type": "CentralTransitive", + "requested": "[3.4.12.4, )", + "resolved": "3.4.12.4", + "contentHash": "YECtByVSH7TRjQKplwOWiKyanCqYE5eEkGk5YtHJgsnbZ6+p1o0Gvs5RIsZLotiAVa6Niez1BJyKY/RDY/L6zg==" + } + }, + "net8.0/win-x86": { + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "n1ZP7NM2Gkn/MgD8+eOT5MulMj6wfeQMNS2Pizvq5GHCZfjlFMXV2irQlQmJhwA2VABC57M0auudO89Iu2uRLg==" + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "lX6DXxtJqVGWw7N/QmVoiCyVQ+Q/Xp+jVXPr3gLK1jJExSn1qmAjJQeb8gnOYeeBTG3E3PmG1nu92eYj/TEjpg==", + "dependencies": { + "System.Configuration.ConfigurationManager": "8.0.0" + } + }, + "System.DirectoryServices.Protocols": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "puwJxURHDrYLGTQdsHyeMS72ClTqYa4lDYz6LHSbkZEk5hq8H8JfsO4MyYhB5BMMxg93jsQzLUwrnCumj11UIg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "Microsoft.Data.SqlClient": { + "type": "CentralTransitive", + "requested": "[6.1.4, )", + "resolved": "6.1.4", + "contentHash": "lQcSog5LLImg4yNEuuG6ccvdzXnCvER8Rms9Ngk9zB4Q8na4f+S7/abSoC7gnEltBg4e5xTnLAWmMLIOtLg4pg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Identity.Client": "4.80.0", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "8.0.1", + "System.Diagnostics.DiagnosticSource": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Security.Cryptography.Pkcs": "8.0.1" + } + }, + "System.Threading.AccessControl": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "cIed5+HuYz+eV9yu9TH95zPkqmm1J9Qps9wxjB335sU8tsqc2kGdlTEH9FZzZeCS8a7mNSEsN8ZkyhQp1gfdEw==" + } + } + } +} \ No newline at end of file diff --git a/src/FixDistributedLockCoreDependencyVersion.targets b/src/FixDistributedLockCoreDependencyVersion.targets new file mode 100644 index 00000000..e1c66967 --- /dev/null +++ b/src/FixDistributedLockCoreDependencyVersion.targets @@ -0,0 +1,21 @@ + + + + + + <_ProjectReferencesWithExactVersions Include="@(_ProjectReferencesWithVersions)"> + [%(_ProjectReferencesWithVersions.ProjectVersion), $([MSBuild]::Add($([System.Text.RegularExpressions.Regex]::Match('%(_ProjectReferencesWithVersions.ProjectVersion)', '^\d+\.\d+').Value), '0.1'))) + + + + <_ProjectReferencesWithVersions Remove="@(_ProjectReferencesWithVersions)" /> + <_ProjectReferencesWithVersions Include="@(_ProjectReferencesWithExactVersions)" /> + + + \ No newline at end of file diff --git a/src/package.readme.md b/src/package.readme.md new file mode 100644 index 00000000..0b319fcb --- /dev/null +++ b/src/package.readme.md @@ -0,0 +1,11 @@ +DistributedLock is a .NET library that provides robust and easy-to-use distributed mutexes, reader-writer locks, and semaphores based on a variety of underlying technologies. + +With DistributedLock, synchronizing access to a region of code across multiple applications/machines is as simple as: +```C# +await using (await myDistributedLock.AcquireAsync()) +{ + // I hold the lock here +} +``` + +**Read the documentation [here](https://github.com/madelson/DistributedLock).** \ No newline at end of file