diff --git a/DistributedLock.Core/AssemblyAttributes.cs b/DistributedLock.Core/AssemblyAttributes.cs index 9a42671f..d2e9bff3 100644 --- a/DistributedLock.Core/AssemblyAttributes.cs +++ b/DistributedLock.Core/AssemblyAttributes.cs @@ -14,4 +14,5 @@ [assembly: InternalsVisibleTo("DistributedLock.Redis, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] [assembly: InternalsVisibleTo("DistributedLock.ZooKeeper, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] [assembly: InternalsVisibleTo("DistributedLock.MySql, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.Oracle, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] #endif diff --git a/DistributedLock.Core/DistributedLock.Core.csproj b/DistributedLock.Core/DistributedLock.Core.csproj index 258f24cf..37e245bb 100644 --- a/DistributedLock.Core/DistributedLock.Core.csproj +++ b/DistributedLock.Core/DistributedLock.Core.csproj @@ -10,7 +10,7 @@ - 1.0.3 + 1.0.4 1.0.0.0 Michael Adelson Core interfaces and utilities that support the DistributedLock.* family of packages diff --git a/DistributedLock.Core/Internal/Data/DatabaseCommand.cs b/DistributedLock.Core/Internal/Data/DatabaseCommand.cs index 3cf82b6f..917d6b95 100644 --- a/DistributedLock.Core/Internal/Data/DatabaseCommand.cs +++ b/DistributedLock.Core/Internal/Data/DatabaseCommand.cs @@ -159,7 +159,9 @@ private async ValueTask InternalExecuteAndPropagateCancellationAsync - 1.0.0 + 1.0.1 1.0.0.0 Michael Adelson Provides a distributed lock implementation based on file locks diff --git a/DistributedLock.FileSystem/FileDistributedLock.cs b/DistributedLock.FileSystem/FileDistributedLock.cs index 80b00f0d..754086db 100644 --- a/DistributedLock.FileSystem/FileDistributedLock.cs +++ b/DistributedLock.FileSystem/FileDistributedLock.cs @@ -13,6 +13,13 @@ namespace Medallion.Threading.FileSystem /// 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 = 400; + // 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 @@ -64,15 +71,13 @@ public FileDistributedLock(DirectoryInfo lockFileDirectory, string name) private FileDistributedLockHandle? TryAcquire(CancellationToken cancellationToken) { + var retryCount = 0; + while (true) { cancellationToken.ThrowIfCancellationRequested(); - try { System.IO.Directory.CreateDirectory(this.Directory); } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to ensure that lock file directory {this.Directory} exists", ex); - } + this.EnsureDirectoryExists(); FileStream lockFileStream; try @@ -88,9 +93,36 @@ public FileDistributedLock(DirectoryInfo lockFileDirectory, string name) // this should almost never happen because we just created the directory but in a race condition it could. Just retry continue; } - catch (UnauthorizedAccessException) when (System.IO.Directory.Exists(this.Name)) + catch (UnauthorizedAccessException) { - throw new InvalidOperationException($"Failed to create lock file '{this.Name}' because it is already the name of a directory"); + // 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 (++retryCount <= MaxUnauthorizedAccessExceptionRetries) + { + 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) @@ -104,5 +136,26 @@ public FileDistributedLock(DirectoryInfo lockFileDirectory, string name) return new FileDistributedLockHandle(lockFileStream); } } + + private void EnsureDirectoryExists() + { + var retryCount = 0; + + while (true) + { + try + { + System.IO.Directory.CreateDirectory(this.Directory); + return; + } + // 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. + catch (UnauthorizedAccessException) when (++retryCount <= MaxUnauthorizedAccessExceptionRetries) { } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to ensure that lock file directory {this.Directory} exists", ex); + } + } + } } } diff --git a/DistributedLock.MySql/MySqlDistributedLock.cs b/DistributedLock.MySql/MySqlDistributedLock.cs index 57e600e8..1c829a76 100644 --- a/DistributedLock.MySql/MySqlDistributedLock.cs +++ b/DistributedLock.MySql/MySqlDistributedLock.cs @@ -164,14 +164,14 @@ private static IDbDistributedLock CreateInternalLock(string name, string connect return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new MySqlDatabaseConnection(connectionString), useTransaction: false, keepaliveCadence); } - static IDbDistributedLock CreateInternalLock(string name, IDbConnection connection) + private static IDbDistributedLock CreateInternalLock(string name, IDbConnection connection) { if (connection == null) { throw new ArgumentNullException(nameof(connection)); } return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new MySqlDatabaseConnection(connection)); } - static IDbDistributedLock CreateInternalLock(string name, IDbTransaction transaction) + private static IDbDistributedLock CreateInternalLock(string name, IDbTransaction transaction) { if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); } diff --git a/DistributedLock.Oracle/AssemblyAttributes.cs b/DistributedLock.Oracle/AssemblyAttributes.cs new file mode 100644 index 00000000..a9e3a34f --- /dev/null +++ b/DistributedLock.Oracle/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] diff --git a/DistributedLock.Oracle/DistributedLock.Oracle.csproj b/DistributedLock.Oracle/DistributedLock.Oracle.csproj new file mode 100644 index 00000000..3be6fe94 --- /dev/null +++ b/DistributedLock.Oracle/DistributedLock.Oracle.csproj @@ -0,0 +1,55 @@ + + + + netstandard2.1;net462 + Medallion.Threading.Oracle + True + 4 + Latest + enable + + + + 1.0.0 + 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 + + + + False + 1591 + TRACE;DEBUG + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DistributedLock.Oracle/OracleConnectionOptionsBuilder.cs b/DistributedLock.Oracle/OracleConnectionOptionsBuilder.cs new file mode 100644 index 00000000..f11cafc1 --- /dev/null +++ b/DistributedLock.Oracle/OracleConnectionOptionsBuilder.cs @@ -0,0 +1,74 @@ +using Medallion.Threading.Internal; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +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/DistributedLock.Oracle/OracleDatabaseConnection.cs b/DistributedLock.Oracle/OracleDatabaseConnection.cs new file mode 100644 index 00000000..21cbd4a2 --- /dev/null +++ b/DistributedLock.Oracle/OracleDatabaseConnection.cs @@ -0,0 +1,89 @@ +using Medallion.Threading.Internal.Data; +using Oracle.ManagedDataAccess.Client; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +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); + var connection = new OracleConnection(connectionString.Substring(startIndex: firstSeparatorIndex + 1)); + connection.ConnectionOpen += _ => connection.ClientInfo = applicationName; + return connection; + } + + return new OracleConnection(connectionString); + } + } +} diff --git a/DistributedLock.Oracle/OracleDbmsLock.cs b/DistributedLock.Oracle/OracleDbmsLock.cs new file mode 100644 index 00000000..7b301c1f --- /dev/null +++ b/DistributedLock.Oracle/OracleDbmsLock.cs @@ -0,0 +1,140 @@ +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.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 OracleDbmsLock(Mode.Shared), + UpdateLock = new OracleDbmsLock(Mode.Update), + ExclusiveLock = new OracleDbmsLock(Mode.Exclusive), + UpgradeLock = new OracleDbmsLock(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/DistributedLock.Oracle/OracleDistributedLock.IDistributedLock.cs b/DistributedLock.Oracle/OracleDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..a1a3e2bd --- /dev/null +++ b/DistributedLock.Oracle/OracleDistributedLock.IDistributedLock.cs @@ -0,0 +1,85 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +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/DistributedLock.Oracle/OracleDistributedLock.cs b/DistributedLock.Oracle/OracleDistributedLock.cs new file mode 100644 index 00000000..d16f2ec0 --- /dev/null +++ b/DistributedLock.Oracle/OracleDistributedLock.cs @@ -0,0 +1,92 @@ +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.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/DistributedLock.Oracle/OracleDistributedLockHandle.cs b/DistributedLock.Oracle/OracleDistributedLockHandle.cs new file mode 100644 index 00000000..0b3b1127 --- /dev/null +++ b/DistributedLock.Oracle/OracleDistributedLockHandle.cs @@ -0,0 +1,37 @@ +using Medallion.Threading.Internal; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +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/DistributedLock.Oracle/OracleDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs b/DistributedLock.Oracle/OracleDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs new file mode 100644 index 00000000..65fe1d67 --- /dev/null +++ b/DistributedLock.Oracle/OracleDistributedReaderWriterLock.IDistributedUpgradeableReaderWriterLock.cs @@ -0,0 +1,230 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +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/DistributedLock.Oracle/OracleDistributedReaderWriterLock.cs b/DistributedLock.Oracle/OracleDistributedReaderWriterLock.cs new file mode 100644 index 00000000..f292c55e --- /dev/null +++ b/DistributedLock.Oracle/OracleDistributedReaderWriterLock.cs @@ -0,0 +1,74 @@ +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.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/DistributedLock.Oracle/OracleDistributedReaderWriterLockHandle.cs b/DistributedLock.Oracle/OracleDistributedReaderWriterLockHandle.cs new file mode 100644 index 00000000..e671129b --- /dev/null +++ b/DistributedLock.Oracle/OracleDistributedReaderWriterLockHandle.cs @@ -0,0 +1,129 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +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/DistributedLock.Oracle/OracleDistributedSynchronizationProvider.cs b/DistributedLock.Oracle/OracleDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..a747202c --- /dev/null +++ b/DistributedLock.Oracle/OracleDistributedSynchronizationProvider.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; + +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/DistributedLock.Oracle/OracleMultiplexedConnectionLockPool.cs b/DistributedLock.Oracle/OracleMultiplexedConnectionLockPool.cs new file mode 100644 index 00000000..2e9f6ffd --- /dev/null +++ b/DistributedLock.Oracle/OracleMultiplexedConnectionLockPool.cs @@ -0,0 +1,12 @@ +using Medallion.Threading.Internal.Data; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Medallion.Threading.Oracle +{ + internal static class OracleMultiplexedConnectionLockPool + { + public static readonly MultiplexedConnectionLockPool Instance = new(s => new OracleDatabaseConnection(s)); + } +} diff --git a/DistributedLock.Postgres/DistributedLock.Postgres.csproj b/DistributedLock.Postgres/DistributedLock.Postgres.csproj index c16ea55e..7d1aec27 100644 --- a/DistributedLock.Postgres/DistributedLock.Postgres.csproj +++ b/DistributedLock.Postgres/DistributedLock.Postgres.csproj @@ -10,7 +10,7 @@ - 1.0.1 + 1.0.2 1.0.0.0 Michael Adelson Provides a distributed lock implementation based on Postgresql diff --git a/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs index 0e943542..af1d7538 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/ConnectionStringStrategyTestCases.cs @@ -10,7 +10,7 @@ namespace Medallion.Threading.Tests.Data public abstract class ConnectionStringStrategyTestCases where TLockProvider : TestingLockProvider, new() where TStrategy : TestingConnectionStringSynchronizationStrategy, new() - where TDb : ITestingPrimaryClientDb, new() + where TDb : TestingPrimaryClientDb, new() { private TLockProvider _lockProvider = default!; @@ -28,7 +28,7 @@ public void TestConnectionDoesNotLeak() 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 applicationName = this._lockProvider.Strategy.Db.SetUniqueApplicationName(); var @lock = this._lockProvider.CreateLock(nameof(TestConnectionDoesNotLeak)); for (var i = 0; i < 30; ++i) @@ -37,8 +37,8 @@ public void TestConnectionDoesNotLeak() { 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); + // still alive due to pooling, except in Oracle where the application name (client info) is not part of the pool key + Assert.LessOrEqual(this._lockProvider.Strategy.Db.CountActiveSessions(applicationName), 1, this.GetType().Name); } using (var connection = this._lockProvider.Strategy.Db.CreateConnection()) @@ -65,7 +65,7 @@ public void TestConnectionDoesNotLeak() [NonParallelizable, Retry(5)] // timing-sensitive public void TestKeepaliveProtectsFromIdleSessionKiller() { - var applicationName = this._lockProvider.Strategy.SetUniqueApplicationName(); + 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 diff --git a/DistributedLock.Tests/AbstractTestCases/Data/DbSemaphoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/DbSemaphoreTestCases.cs index 01b1a034..0fb58b29 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/DbSemaphoreTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/DbSemaphoreTestCases.cs @@ -8,7 +8,7 @@ namespace Medallion.Threading.Tests.Data public abstract class DbSemaphoreTestCases where TSemaphoreProvider : TestingSemaphoreProvider, new() where TStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy, new() - where TDb : ITestingDb, new() + where TDb : TestingDb, new() { private TSemaphoreProvider _semaphoreProvider = default!; diff --git a/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs index b7f9ebf3..c0ba41b9 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs @@ -12,7 +12,7 @@ namespace Medallion.Threading.Tests.Data public abstract class ExternalConnectionOrTransactionStrategyTestCases where TLockProvider : TestingLockProvider, new() where TStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy, new() - where TDb : ITestingDb, new() + where TDb : TestingDb, new() { private TLockProvider _lockProvider = default!; @@ -23,7 +23,7 @@ public abstract class ExternalConnectionOrTransactionStrategyTestCases { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true, this.GetType().Name); + Task.WhenAll(tasks).ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(15)).ShouldEqual(true, this.GetType().Name); // MariaDB fails both tasks due to deadlock instead of just picking a single victim Assert.GreaterOrEqual(tasks.Count(t => t.IsFaulted), 1); @@ -91,8 +91,15 @@ public void TestStateChangeHandlerIsNotLeaked() 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)) + // 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/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs index bc7cd116..d2fd3685 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionStrategyTestCases.cs @@ -5,7 +5,7 @@ namespace Medallion.Threading.Tests.Data { public abstract class ExternalConnectionStrategyTestCases where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingDb, new() + where TDb : TestingDb, new() { private TLockProvider _lockProvider = default!; diff --git a/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs index 70edd6f8..1f751ac5 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs @@ -8,7 +8,7 @@ namespace Medallion.Threading.Tests.Data { public abstract class ExternalTransactionStrategyTestCases where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingDb, new() + where TDb : TestingDb, new() { private TLockProvider _lockProvider = default!; diff --git a/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs index 1c7aeae0..cc025dc3 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/MultiplexingConnectionStrategyTestCases.cs @@ -12,7 +12,7 @@ namespace Medallion.Threading.Tests.Data { public abstract class MultiplexingConnectionStrategyTestCases where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingPrimaryClientDb, new() + where TDb : TestingPrimaryClientDb, new() { private TLockProvider _lockProvider = default!; @@ -89,7 +89,7 @@ public void TestHighConcurrencyWithSmallPool() } // assign a unique app name to make sure we'll own the entire pool - this._lockProvider.Strategy.SetUniqueApplicationName(); + this._lockProvider.Strategy.Db.SetUniqueApplicationName(); this._lockProvider.Strategy.Db.MaxPoolSize = 1; async Task Test() @@ -131,7 +131,7 @@ public async Task TestBrokenConnectionDoesNotCorruptPool() // application name and therefore won't be killed this._lockProvider.CreateLock("1"); this._lockProvider.CreateLock("2"); - var applicationName = this._lockProvider.Strategy.SetUniqueApplicationName(); + var applicationName = this._lockProvider.Strategy.Db.SetUniqueApplicationName(); var lock1 = this._lockProvider.CreateLock("1"); await using var handle1 = await lock1.AcquireAsync(); diff --git a/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs index fc7b4b9f..2c35d338 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/OwnedConnectionStrategyTestCases.cs @@ -11,7 +11,7 @@ namespace Medallion.Threading.Tests.Data { public abstract class OwnedConnectionStrategyTestCases where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingPrimaryClientDb, new() + where TDb : TestingPrimaryClientDb, new() { private TLockProvider _lockProvider = default!; @@ -32,7 +32,7 @@ public void TestIdleSessionKiller() // application name and therefore won't be counted or killed this._lockProvider.CreateLock(nameof(TestIdleSessionKiller)); - var applicationName = this._lockProvider.Strategy.SetUniqueApplicationName(); + 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 diff --git a/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs index d8bed0de..4ed8b928 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs @@ -1,12 +1,13 @@ using Medallion.Threading.Internal; using NUnit.Framework; +using System; using System.Data; namespace Medallion.Threading.Tests.Data { public abstract class OwnedTransactionStrategyTestCases where TLockProvider : TestingLockProvider>, new() - where TDb : ITestingPrimaryClientDb, new() + where TDb : TestingPrimaryClientDb, new() { private TLockProvider _lockProvider = default!; @@ -38,7 +39,15 @@ public void TestIsolationLevelLeakage() using (var connection = this._lockProvider.Strategy.Db.CreateConnection()) { connection.Open(); - defaultIsolationLevel = this._lockProvider.Strategy.Db.GetIsolationLevel(connection); + 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 @@ -47,7 +56,7 @@ public void TestIsolationLevelLeakage() 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.SetUniqueApplicationName(); this._lockProvider.Strategy.Db.MaxPoolSize = 1; AssertHasDefaultIsolationLevel(); diff --git a/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs index a6fbd390..f54c9346 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/UpgradeableReaderWriterLockConnectionStringStrategyTestCases.cs @@ -10,7 +10,7 @@ namespace Medallion.Threading.Tests.Data public abstract class UpgradeableReaderWriterLockConnectionStringStrategyTestCases where TLockProvider : TestingUpgradeableReaderWriterLockProvider, new() where TStrategy : TestingConnectionStringSynchronizationStrategy, new() - where TDb : ITestingPrimaryClientDb, new() + where TDb : TestingPrimaryClientDb, new() { private TLockProvider _lockProvider = default!; @@ -24,7 +24,7 @@ public abstract class UpgradeableReaderWriterLockConnectionStringStrategyTestCas [NonParallelizable, Retry(tryCount: 5)] // this test is somewhat timing sensitive public void TestKeepaliveProtectsFromIdleSessionKillerAfterFailedUpgrade() { - var applicationName = this._lockProvider.Strategy.SetUniqueApplicationName(); + var applicationName = this._lockProvider.Strategy.Db.SetUniqueApplicationName(); this._lockProvider.Strategy.KeepaliveCadence = TimeSpan.FromSeconds(.1); var @lock = this._lockProvider.CreateUpgradeableReaderWriterLock(Guid.NewGuid().ToString()); diff --git a/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs index dfba0e85..05d28947 100644 --- a/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs @@ -150,7 +150,8 @@ public void CancellationTest() var source = new CancellationTokenSource(); using (var handle = this._lockProvider.CreateLock(lockName).Acquire()) { - var blocked = @lock.AcquireAsync(cancellationToken: source.Token).AsTask(); + // 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); @@ -164,20 +165,24 @@ public void CancellationTest() } [Test] - public void TestParallelism() + public async Task TestParallelism() { - this._lockProvider.Strategy.PrepareForHighContention(); + 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. + // /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; - var tasks = Enumerable.Range(1, 100).Select(async _ => + // Task.Run() ensures true parallelism even for locks that don't support it + var tasks = Enumerable.Range(0, taskCount).Select(i => Task.Run(async () => { - var @lock = this._lockProvider.CreateLock("parallel_test"); - await using (await @lock.AcquireAsync()) + await using (await locks[i].AcquireAsync()) { // increment going in if (Interlocked.Increment(ref counter) == 2) @@ -191,10 +196,20 @@ public void TestParallelism() // 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); + 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.AreNotSame(failure.Task, completed, $"Failed with {(failure.Task.IsFaulted ? failure.Task.Exception!.ToString() : null)}"); + Assert.AreNotSame(timeout, completed, $"Timed out! (only {tasks.Count(t => t.IsCompleted)}/{taskCount} completed)"); tasks.ForEach(t => t.Result.ShouldEqual(0)); } @@ -288,27 +303,31 @@ public async Task TestHandleLostTriggersCorrectly() 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) + var handle = await @lock.AcquireAsync(); + try { - using var canceledEvent = new ManualResetEventSlim(initialState: false); - using var registration = handle.HandleLostToken.Register(canceledEvent.Set); + handle.HandleLostToken.CanBeCanceled.ShouldEqual(handleLostHelper != null); + Assert.IsFalse(handle.HandleLostToken.IsCancellationRequested); - Assert.IsFalse(canceledEvent.Wait(TimeSpan.FromSeconds(.05))); + if (handleLostHelper != null) + { + using var canceledEvent = new ManualResetEventSlim(initialState: false); + using var registration = handle.HandleLostToken.Register(canceledEvent.Set); - handleLostHelper.Dispose(); + Assert.IsFalse(canceledEvent.Wait(TimeSpan.FromSeconds(.05))); - Assert.IsTrue(canceledEvent.Wait(TimeSpan.FromSeconds(10))); - Assert.IsTrue(handle.HandleLostToken.IsCancellationRequested); - } + handleLostHelper.Dispose(); - // when the handle is lost, Dispose() may throw - try { await handle.DisposeAsync(); } - catch { } + Assert.IsTrue(canceledEvent.Wait(TimeSpan.FromSeconds(10))); + Assert.IsTrue(handle.HandleLostToken.IsCancellationRequested); + } + } + finally + { + // when the handle is lost, Dispose() may throw + try { await handle.DisposeAsync(); } + catch { } + } Assert.Throws(() => handle.HandleLostToken.GetType()); } @@ -321,15 +340,12 @@ public async Task TestHandleLostReturnsAlreadyCanceledIfHandleAlreadyLost() this._lockProvider.CreateLock(nameof(TestHandleLostReturnsAlreadyCanceledIfHandleAlreadyLost)); var handleLostHelper = this._lockProvider.Strategy.PrepareForHandleLost(); - if (handleLostHelper == null) - { - Assert.Pass(); - } + 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); @@ -418,7 +434,8 @@ private void CrossProcessAbandonmentHelper(bool asyncWait, bool kill) var @lock = this._lockProvider.CreateLockWithExactName(name); var acquireTask = asyncWait - ? @lock.TryAcquireAsync(TimeSpan.FromSeconds(20)).AsTask() + // 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); diff --git a/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs index 6138da04..4d790c6d 100644 --- a/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/DistributedReaderWriterLockCoreTestCases.cs @@ -27,7 +27,7 @@ IDistributedReaderWriterLock Lock() => using var writeHandle1 = Lock().TryAcquireWriteLock(); Assert.IsNull(writeHandle1); - var writeHandleTask = Lock().AcquireWriteLockAsync().AsTask(); + var writeHandleTask = Task.Run(() => Lock().AcquireWriteLockAsync().AsTask()); Assert.IsFalse(writeHandleTask.Wait(TimeSpan.FromSeconds(.05))); readHandle1!.Dispose(); @@ -54,7 +54,7 @@ IDistributedReaderWriterLock Lock() => await using var readerHandle = await Lock().AcquireReadLockAsync(); - var writerHandleTask = Lock().AcquireWriteLockAsync().AsTask(); + var writerHandleTask = Task.Run(() => Lock().AcquireWriteLockAsync().AsTask()); Assert.IsFalse(await writerHandleTask.WaitAsync(TimeSpan.FromSeconds(0.2))); // trying to take a read lock here fails because there is a writer waiting diff --git a/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs index 9ca2af09..64010732 100644 --- a/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/DistributedUpgradeableReaderWriterLockCoreTestCases.cs @@ -64,7 +64,7 @@ IDistributedUpgradeableReaderWriterLock Lock() => upgradeableHandle.TryUpgradeToWriteLock().ShouldEqual(true); - readTask = Lock().AcquireReadLockAsync().AsTask(); + readTask = Task.Run(() => Lock().AcquireReadLockAsync().AsTask()); readTask.Wait(TimeSpan.FromSeconds(.1)).ShouldEqual(false, "write lock held"); } diff --git a/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs b/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs index ab7631fe..ebe5ec89 100644 --- a/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs +++ b/DistributedLock.Tests/Infrastructure/Azure/TestingAzureBlobLeaseSynchronizationStrategy.cs @@ -40,7 +40,7 @@ public sealed class TestingAzureBlobLeaseSynchronizationStrategy : TestingSynchr return new HandleLostScope(this.ContainerName); } - public override void PrepareForHighContention() + public override void PrepareForHighContention(ref int maxConcurrentAcquires) { this.Options = null; // reduces # of requests under high contention this.CreateBlobBeforeLockIsCreated = true; diff --git a/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs b/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs deleted file mode 100644 index 9703ab0c..00000000 --- a/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs +++ /dev/null @@ -1,59 +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; } - - TransactionSupport TransactionSupport { get; } - - DbConnection CreateConnection(); - - void ClearPool(DbConnection connection); - - int CountActiveSessions(string applicationName); - - IsolationLevel GetIsolationLevel(DbConnection connection); - } - - 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 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 index 08593a63..305fbb71 100644 --- a/DistributedLock.Tests/Infrastructure/Data/IdleSessionKiller.cs +++ b/DistributedLock.Tests/Infrastructure/Data/IdleSessionKiller.cs @@ -11,7 +11,7 @@ internal class IdleSessionKiller : IDisposable private readonly CancellationTokenSource _cancellationTokenSource; private readonly Task _task; - public IdleSessionKiller(ITestingPrimaryClientDb db, string applicationName, TimeSpan idleTimeout) + public IdleSessionKiller(TestingPrimaryClientDb db, string applicationName, TimeSpan idleTimeout) { this._cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = this._cancellationTokenSource.Token; diff --git a/DistributedLock.Tests/Infrastructure/Data/TestingDb.cs b/DistributedLock.Tests/Infrastructure/Data/TestingDb.cs new file mode 100644 index 00000000..ae1c7904 --- /dev/null +++ b/DistributedLock.Tests/Infrastructure/Data/TestingDb.cs @@ -0,0 +1,93 @@ +using Medallion.Threading.Internal; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +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/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs b/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs index 53396cbb..36b13ae8 100644 --- a/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs +++ b/DistributedLock.Tests/Infrastructure/Data/TestingDbSynchronizationStrategy.cs @@ -13,30 +13,21 @@ namespace Medallion.Threading.Tests.Data /// public abstract class TestingDbSynchronizationStrategy : TestingSynchronizationStrategy { - protected TestingDbSynchronizationStrategy(ITestingDb db) + protected TestingDbSynchronizationStrategy(TestingDb db) { this.Db = db; } - public ITestingDb Db { get; } + public TestingDb Db { get; } public abstract TestingDbConnectionOptions GetConnectionOptions(); - public string SetUniqueApplicationName(string baseName = "") - { - // note: due to retries, we incorporate a GUID here to ensure that we have a fresh connection pool - var applicationName = DistributedLockHelpers.ToSafeName( - $"{(baseName.Length > 0 ? baseName + "_" : string.Empty)}{TestContext.CurrentContext.Test.FullName}_{TargetFramework.Current}_{Guid.NewGuid()}", - maxNameLength: this.Db.MaxApplicationNameLength, - s => s - ); - this.Db.ConnectionStringBuilder["Application Name"] = applicationName; - return applicationName; - } + public override void PrepareForHighContention(ref int maxConcurrentAcquires) => + this.Db.PrepareForHighContention(ref maxConcurrentAcquires); } public abstract class TestingDbSynchronizationStrategy : TestingDbSynchronizationStrategy - where TDb : ITestingDb, new() + where TDb : TestingDb, new() { protected TestingDbSynchronizationStrategy() : base(new TDb()) { } @@ -46,7 +37,7 @@ 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"])) + if (!Equals(this.Db.ApplicationName, new TDb().ApplicationName)) { using var connection = this.Db.CreateConnection(); this.Db.ClearPool(connection); @@ -59,7 +50,7 @@ public override void 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() + where TDb : TestingPrimaryClientDb, new() { protected abstract bool? UseMultiplexingNotTransaction { get; } public TimeSpan? KeepaliveCadence { get; set; } @@ -67,14 +58,14 @@ public abstract class TestingConnectionStringSynchronizationStrategy : Test public sealed override TestingDbConnectionOptions GetConnectionOptions() => new TestingDbConnectionOptions { - ConnectionString = this.Db.ConnectionStringBuilder.ConnectionString, + ConnectionString = this.Db.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); + public sealed override IDisposable? PrepareForHandleLost() => + new HandleLostScope(this.Db.SetUniqueApplicationName(nameof(this.PrepareForHandleLost)), this.Db); private class HandleLostScope : IDisposable { @@ -99,25 +90,25 @@ public void Dispose() } public sealed class TestingConnectionMultiplexingSynchronizationStrategy : TestingConnectionStringSynchronizationStrategy - where TDb : ITestingPrimaryClientDb, new() + where TDb : TestingPrimaryClientDb, new() { protected override bool? UseMultiplexingNotTransaction => true; } public sealed class TestingOwnedConnectionSynchronizationStrategy : TestingConnectionStringSynchronizationStrategy - where TDb : ITestingPrimaryClientDb, new() + where TDb : TestingPrimaryClientDb, new() { protected override bool? UseMultiplexingNotTransaction => null; } public sealed class TestingOwnedTransactionSynchronizationStrategy : TestingConnectionStringSynchronizationStrategy - where TDb : ITestingPrimaryClientDb, new() + where TDb : TestingPrimaryClientDb, new() { protected override bool? UseMultiplexingNotTransaction => false; } public abstract class TestingExternalConnectionOrTransactionSynchronizationStrategy : TestingDbSynchronizationStrategy - where TDb : ITestingDb, new() + where TDb : TestingDb, new() { /// /// Starts a new "ambient" connection or transaction that future locks will be created with @@ -149,7 +140,7 @@ public sealed override void PerformAdditionalCleanupForHandleAbandonment() } public sealed class TestingExternalConnectionSynchronizationStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy - where TDb : ITestingDb, new() + where TDb : TestingDb, new() { private readonly DisposableCollection _disposables = new DisposableCollection(); private DbConnection? _ambientConnection; @@ -190,7 +181,7 @@ public override void Dispose() } public sealed class TestingExternalTransactionSynchronizationStrategy : TestingExternalConnectionOrTransactionSynchronizationStrategy - where TDb : ITestingDb, new() + where TDb : TestingDb, new() { private readonly DisposableCollection _disposables = new DisposableCollection(); diff --git a/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs b/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs index 5fd6c5e9..5ea6cd78 100644 --- a/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs +++ b/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs @@ -11,7 +11,7 @@ namespace Medallion.Threading.Tests.MySql { - public class TestingMySqlDb : ITestingPrimaryClientDb + public class TestingMySqlDb : TestingPrimaryClientDb { private readonly string _defaultConnectionString; private readonly MySqlConnectionStringBuilder _connectionStringBuilder; @@ -27,23 +27,17 @@ protected TestingMySqlDb(string defaultConnectionString) this._connectionStringBuilder = new MySqlConnectionStringBuilder(this._defaultConnectionString); } - public DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; + public override DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; - public int MaxPoolSize - { - get => (int)this._connectionStringBuilder.MaximumPoolSize; - set => this._connectionStringBuilder.MaximumPoolSize = (uint)value; - } + public override int MaxPoolSize { get => (int)this._connectionStringBuilder.MaximumPoolSize; set => this._connectionStringBuilder.MaximumPoolSize = (uint)value; } - public int MaxApplicationNameLength => 65390; // based on empirical testing + public override int MaxApplicationNameLength => 65390; // based on empirical testing - public TransactionSupport TransactionSupport => TransactionSupport.ExplicitParticipation; + public override TransactionSupport TransactionSupport => TransactionSupport.ExplicitParticipation; protected virtual string IsolationLevelVariableName => "transaction_isolation"; - public void ClearPool(DbConnection connection) => MySqlConnection.ClearPool((MySqlConnection)connection); - - public int CountActiveSessions(string applicationName) + public override int CountActiveSessions(string applicationName) { using var connection = new MySqlConnection(this._defaultConnectionString); connection.Open(); @@ -53,9 +47,8 @@ public int CountActiveSessions(string applicationName) return (int)(long)command.ExecuteScalar()!; } - public DbConnection CreateConnection() => new MySqlConnection(this.ConnectionStringBuilder.ConnectionString); - - public IsolationLevel GetIsolationLevel(DbConnection connection) + 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; @@ -63,7 +56,7 @@ public IsolationLevel GetIsolationLevel(DbConnection connection) return (IsolationLevel)Enum.Parse(typeof(IsolationLevel), rawIsolationLevel.Replace("-", string.Empty), ignoreCase: true); } - public async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince = null) + public override async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince = null) { var minTimeSeconds = idleSince.HasValue ? (int?)(DateTimeOffset.UtcNow - idleSince.Value).TotalSeconds diff --git a/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleDb.cs b/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleDb.cs new file mode 100644 index 00000000..7c45a29a --- /dev/null +++ b/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleDb.cs @@ -0,0 +1,93 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Oracle; +using Medallion.Threading.Tests.Data; +using NUnit.Framework; +using Oracle.ManagedDataAccess.Client; +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Text; +using System.Threading.Tasks; + +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 OracleConnectionStringBuilder(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); + + 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) + { + // The free Oracle Autonomous database has a fixed 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. + 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/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleProviders.cs b/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleProviders.cs new file mode 100644 index 00000000..8ee6ab04 --- /dev/null +++ b/DistributedLock.Tests/Infrastructure/Oracle/TestingOracleProviders.cs @@ -0,0 +1,42 @@ +using Medallion.Threading.Oracle; +using Medallion.Threading.Tests.Data; +using System; +using System.Collections.Generic; +using System.Text; + +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/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs b/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs index f1823e92..63b17076 100644 --- a/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs +++ b/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs @@ -12,33 +12,29 @@ namespace Medallion.Threading.Tests.Postgres { - public sealed class TestingPostgresDb : ITestingPrimaryClientDb + public sealed class TestingPostgresDb : TestingPrimaryClientDb { - internal static readonly string ConnectionString = PostgresCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory); + internal static readonly string DefaultConnectionString = PostgresCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory); - private readonly NpgsqlConnectionStringBuilder _connectionStringBuilder = new NpgsqlConnectionStringBuilder(ConnectionString); + private readonly NpgsqlConnectionStringBuilder _connectionStringBuilder = new NpgsqlConnectionStringBuilder(DefaultConnectionString); - public DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; - - public int MaxPoolSize { get => this._connectionStringBuilder.MaxPoolSize; set => this._connectionStringBuilder.MaxPoolSize = value; } + public override DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; // https://til.hashrocket.com/posts/8f87c65a0a-postgresqls-max-identifier-length-is-63-bytes - public int MaxApplicationNameLength => 63; + 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 TransactionSupport TransactionSupport => TransactionSupport.ImplicitParticipation; - - public void ClearPool(DbConnection connection) => NpgsqlConnection.ClearPool((NpgsqlConnection)connection); + public override TransactionSupport TransactionSupport => TransactionSupport.ImplicitParticipation; - public int CountActiveSessions(string applicationName) + public override int CountActiveSessions(string applicationName) { Invariant.Require(applicationName.Length <= this.MaxApplicationNameLength); - using var connection = new NpgsqlConnection(ConnectionString); + 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"; @@ -46,7 +42,7 @@ public int CountActiveSessions(string applicationName) return (int)command.ExecuteScalar()!; } - public IsolationLevel GetIsolationLevel(DbConnection connection) + public override IsolationLevel GetIsolationLevel(DbConnection connection) { using var command = connection.CreateCommand(); // values based on https://www.postgresql.org/docs/12/transaction-iso.html @@ -54,11 +50,11 @@ public IsolationLevel GetIsolationLevel(DbConnection connection) return (IsolationLevel)Enum.Parse(typeof(IsolationLevel), (string)command.ExecuteScalar(), ignoreCase: true); } - public DbConnection CreateConnection() => new NpgsqlConnection(this.ConnectionStringBuilder.ConnectionString); + public override DbConnection CreateConnection() => new NpgsqlConnection(this.ConnectionStringBuilder.ConnectionString); - public async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince) + public override async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince) { - using var connection = new NpgsqlConnection(ConnectionString); + 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 diff --git a/DistributedLock.Tests/Infrastructure/Shared/OracleCredentials.cs b/DistributedLock.Tests/Infrastructure/Shared/OracleCredentials.cs new file mode 100644 index 00000000..a04d6a32 --- /dev/null +++ b/DistributedLock.Tests/Infrastructure/Shared/OracleCredentials.cs @@ -0,0 +1,64 @@ +using Oracle.ManagedDataAccess.Client; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +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")); + ConfigureWallet(credentialDirectory); + var (datasource, username, password) = GetCredentials(credentialDirectory); + + return new OracleConnectionStringBuilder + { + DataSource = datasource, + UserID = username, + Password = password, + PersistSecurityInfo = true, + // The free-tier autonomous database only allows 20 connections maximum (presumably across all clients) so this limit + // should help keep us below this limit. Running up against the limit throws errors on Connection.Open() + MaxPoolSize = 15, + }.ConnectionString; + } + + private static void ConfigureWallet(string credentialDirectory) + { + var walletDirectory = Directory.GetDirectories(credentialDirectory, "Wallet_*").Single(); + if (OracleConfiguration.TnsAdmin != walletDirectory) + { + // directory containing tnsnames.ora and sqlnet.ora + OracleConfiguration.TnsAdmin = walletDirectory; + } + if (OracleConfiguration.WalletLocation != walletDirectory) + { + // directory containing cwallet.sso + OracleConfiguration.WalletLocation = walletDirectory; + } + } + + private static (string DataSource, 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 != 3) { throw new FormatException($"{file} must contain exactly 2 lines of text"); } + return (lines[0], lines[1], lines[2]); + } + } +} diff --git a/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs b/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs index ed57d0a4..a81be9fa 100644 --- a/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs +++ b/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs @@ -9,31 +9,27 @@ namespace Medallion.Threading.Tests.SqlServer { - public interface ITestingSqlServerDb : ITestingDb { } + public interface ITestingSqlServerDb { } - public sealed class TestingSqlServerDb : ITestingSqlServerDb, ITestingPrimaryClientDb + public sealed class TestingSqlServerDb : TestingPrimaryClientDb, ITestingSqlServerDb { - internal static readonly string ConnectionString = SqlServerCredentials.ConnectionString; + internal static readonly string DefaultConnectionString = SqlServerCredentials.ConnectionString; private readonly Microsoft.Data.SqlClient.SqlConnectionStringBuilder _connectionStringBuilder = - new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(ConnectionString); + new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(DefaultConnectionString); - public DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; - - public int MaxPoolSize { get => this._connectionStringBuilder.MaxPoolSize; set => this._connectionStringBuilder.MaxPoolSize = value; } + public override DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; // https://stackoverflow.com/questions/5808332/sql-server-maximum-character-length-of-object-names/41502228 - public int MaxApplicationNameLength => 128; - - public TransactionSupport TransactionSupport => TransactionSupport.TransactionScoped; + public override int MaxApplicationNameLength => 128; - public void ClearPool(DbConnection connection) => Microsoft.Data.SqlClient.SqlConnection.ClearPool((Microsoft.Data.SqlClient.SqlConnection)connection); + public override TransactionSupport TransactionSupport => TransactionSupport.TransactionScoped; - public int CountActiveSessions(string applicationName) + public override int CountActiveSessions(string applicationName) { Invariant.Require(applicationName.Length <= this.MaxApplicationNameLength); - using var connection = new Microsoft.Data.SqlClient.SqlConnection(ConnectionString); + 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"; @@ -41,7 +37,7 @@ public int CountActiveSessions(string applicationName) return (int)command.ExecuteScalar(); } - public IsolationLevel GetIsolationLevel(DbConnection connection) + public override IsolationLevel GetIsolationLevel(DbConnection connection) { using var command = connection.CreateCommand(); command.CommandText = @" @@ -58,11 +54,11 @@ FROM sys.dm_exec_sessions return (IsolationLevel)Enum.Parse(typeof(IsolationLevel), (string)command.ExecuteScalar()); } - public DbConnection CreateConnection() => new Microsoft.Data.SqlClient.SqlConnection(this.ConnectionStringBuilder.ConnectionString); + public override DbConnection CreateConnection() => new Microsoft.Data.SqlClient.SqlConnection(this.ConnectionStringBuilder.ConnectionString); - public async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince) + public override async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince) { - using var connection = new Microsoft.Data.SqlClient.SqlConnection(ConnectionString); + using var connection = new Microsoft.Data.SqlClient.SqlConnection(DefaultConnectionString); await connection.OpenAsync(); var findIdleSessionsCommand = connection.CreateCommand(); @@ -99,25 +95,21 @@ @idleSince IS NULL } } - public sealed class TestingSystemDataSqlServerDb : ITestingSqlServerDb + public sealed class TestingSystemDataSqlServerDb : TestingDb, 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; } + new System.Data.SqlClient.SqlConnectionStringBuilder(TestingSqlServerDb.DefaultConnectionString); - public int MaxApplicationNameLength => new TestingSqlServerDb().MaxApplicationNameLength; + public override DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; - public TransactionSupport TransactionSupport => TransactionSupport.TransactionScoped; + public override int MaxApplicationNameLength => new TestingSqlServerDb().MaxApplicationNameLength; - public void ClearPool(DbConnection connection) => System.Data.SqlClient.SqlConnection.ClearPool((System.Data.SqlClient.SqlConnection)connection); + public override TransactionSupport TransactionSupport => TransactionSupport.TransactionScoped; - public int CountActiveSessions(string applicationName) => new TestingSqlServerDb().CountActiveSessions(applicationName); + public override int CountActiveSessions(string applicationName) => new TestingSqlServerDb().CountActiveSessions(applicationName); - public IsolationLevel GetIsolationLevel(DbConnection connection) => new TestingSqlServerDb().GetIsolationLevel(connection); + public override IsolationLevel GetIsolationLevel(DbConnection connection) => new TestingSqlServerDb().GetIsolationLevel(connection); - public DbConnection CreateConnection() => new System.Data.SqlClient.SqlConnection(this.ConnectionStringBuilder.ConnectionString); + public override 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 index e4151b46..f1dc7385 100644 --- a/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerProviders.cs +++ b/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerProviders.cs @@ -8,7 +8,7 @@ namespace Medallion.Threading.Tests.SqlServer { public sealed class TestingSqlDistributedLockProvider : TestingLockProvider where TStrategy : TestingDbSynchronizationStrategy, new() - where TDb : ITestingSqlServerDb, new() + where TDb : TestingDb, ITestingSqlServerDb, new() { public override IDistributedLock CreateLockWithExactName(string name) => this.Strategy.GetConnectionOptions() @@ -28,7 +28,7 @@ internal static Action ToSqlOptions((bool useMultip public sealed class TestingSqlDistributedReaderWriterLockProvider : TestingUpgradeableReaderWriterLockProvider where TStrategy : TestingDbSynchronizationStrategy, new() - where TDb : ITestingSqlServerDb, new() + where TDb : TestingDb, ITestingSqlServerDb, new() { public override IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderWriterLockWithExactName(string name) => this.Strategy.GetConnectionOptions() @@ -43,7 +43,7 @@ public override IDistributedUpgradeableReaderWriterLock CreateUpgradeableReaderW public sealed class TestingSqlDistributedSemaphoreProvider : TestingSemaphoreProvider where TStrategy : TestingDbSynchronizationStrategy, new() - where TDb : ITestingSqlServerDb, new() + where TDb : TestingDb, ITestingSqlServerDb, new() { public override IDistributedSemaphore CreateSemaphoreWithExactName(string name, int maxCount) => this.Strategy.GetConnectionOptions() diff --git a/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs b/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs index 8ac80695..4b5a5a2d 100644 --- a/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs +++ b/DistributedLock.Tests/Infrastructure/TestingSynchronizationStrategy.cs @@ -19,7 +19,7 @@ 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 PrepareForHighContention(ref int maxConcurrentAcquires) { } public virtual void Dispose() { } } } diff --git a/DistributedLock.Tests/Tests/CombinatorialTests.cs b/DistributedLock.Tests/Tests/CombinatorialTests.cs index 1c322518..5db0de62 100644 --- a/DistributedLock.Tests/Tests/CombinatorialTests.cs +++ b/DistributedLock.Tests/Tests/CombinatorialTests.cs @@ -50,6 +50,53 @@ public class OwnedTransactionStrategy_MySql_OwnedTransactionSynchronizationStrat 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> { } diff --git a/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockTest.cs b/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockTest.cs index dd70c595..159f5a50 100644 --- a/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockTest.cs +++ b/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockTest.cs @@ -7,6 +7,7 @@ using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace Medallion.Threading.Tests.FileSystem @@ -365,6 +366,126 @@ public void TestBase32Hashing() } } + /// + /// 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); diff --git a/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockWindowsTest.cs b/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockWindowsTest.cs new file mode 100644 index 00000000..6cec4b18 --- /dev/null +++ b/DistributedLock.Tests/Tests/FileSystem/FileDistributedLockWindowsTest.cs @@ -0,0 +1,36 @@ +using Medallion.Threading.FileSystem; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +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.IsInstanceOf(exception.InnerException); + Assert.IsFalse(Directory.Exists(Path.GetDirectoryName(@lock.Name))); + } + } +} diff --git a/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs b/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs index e8207b1e..accde245 100644 --- a/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs +++ b/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs @@ -49,7 +49,7 @@ public void TestGetSafeLockNameCompat() [TestCase(typeof(TestingMariaDbDb))] public async Task TestMySqlCommandMustExplicitlyParticipateInTransaction(Type testingDbType) { - var db = (ITestingDb)Activator.CreateInstance(testingDbType)!; + var db = (TestingDb)Activator.CreateInstance(testingDbType)!; using var connection = new MySqlConnection(db.ConnectionStringBuilder.ConnectionString); await connection.OpenAsync(); diff --git a/DistributedLock.Tests/Tests/MySql/MySqlDistributedSynchronizationProviderTest.cs b/DistributedLock.Tests/Tests/MySql/MySqlDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..c8e76074 --- /dev/null +++ b/DistributedLock.Tests/Tests/MySql/MySqlDistributedSynchronizationProviderTest.cs @@ -0,0 +1,37 @@ +using Medallion.Threading.MySql; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; +using System.Threading.Tasks; + +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() + { + foreach (var db in new[] { new TestingMySqlDb(), new TestingMariaDbDb() }) + { + 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.IsNull(handle, db.GetType().Name); + } + } + } + } +} diff --git a/DistributedLock.Tests/Tests/Oracle/OracleBehaviorTest.cs b/DistributedLock.Tests/Tests/Oracle/OracleBehaviorTest.cs new file mode 100644 index 00000000..b7039f1f --- /dev/null +++ b/DistributedLock.Tests/Tests/Oracle/OracleBehaviorTest.cs @@ -0,0 +1,32 @@ +using NUnit.Framework; +using Oracle.ManagedDataAccess.Client; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +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/DistributedLock.Tests/Tests/Oracle/OracleConnectionOptionsBuilderTest.cs b/DistributedLock.Tests/Tests/Oracle/OracleConnectionOptionsBuilderTest.cs new file mode 100644 index 00000000..05e8d27d --- /dev/null +++ b/DistributedLock.Tests/Tests/Oracle/OracleConnectionOptionsBuilderTest.cs @@ -0,0 +1,29 @@ +using Medallion.Threading.Oracle; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +namespace Medallion.Threading.Tests.Oracle +{ + 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.IsTrue(options.useMultiplexing); + options.ShouldEqual(OracleConnectionOptionsBuilder.GetOptions(o => { })); + } + } +} diff --git a/DistributedLock.Tests/Tests/Oracle/OracleDistributedLockTest.cs b/DistributedLock.Tests/Tests/Oracle/OracleDistributedLockTest.cs new file mode 100644 index 00000000..9fb988cd --- /dev/null +++ b/DistributedLock.Tests/Tests/Oracle/OracleDistributedLockTest.cs @@ -0,0 +1,37 @@ +using Medallion.Threading.Oracle; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; + +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/DistributedLock.Tests/Tests/Oracle/OracleDistributedReaderWriterLockTest.cs b/DistributedLock.Tests/Tests/Oracle/OracleDistributedReaderWriterLockTest.cs new file mode 100644 index 00000000..77fc0d35 --- /dev/null +++ b/DistributedLock.Tests/Tests/Oracle/OracleDistributedReaderWriterLockTest.cs @@ -0,0 +1,44 @@ +using Medallion.Threading.Oracle; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; + +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/DistributedLock.Tests/Tests/Oracle/OracleDistributedSynchronizationProviderTest.cs b/DistributedLock.Tests/Tests/Oracle/OracleDistributedSynchronizationProviderTest.cs new file mode 100644 index 00000000..49e8b376 --- /dev/null +++ b/DistributedLock.Tests/Tests/Oracle/OracleDistributedSynchronizationProviderTest.cs @@ -0,0 +1,49 @@ +using Medallion.Threading.Oracle; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; +using System.Threading.Tasks; + +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.IsNull(handle); + } + + await using (await provider.AcquireReadLockAsync(LockName)) + { + await using var readHandle = await provider.TryAcquireReadLockAsync(LockName); + Assert.IsNotNull(readHandle); + + await using (var upgradeHandle = await provider.TryAcquireUpgradeableReadLockAsync(LockName)) + { + Assert.IsNotNull(upgradeHandle); + Assert.IsFalse(await upgradeHandle!.TryUpgradeToWriteLockAsync()); + } + + await using var writeHandle = await provider.TryAcquireWriteLockAsync(LockName); + Assert.IsNull(writeHandle); + } + } + } +} diff --git a/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs b/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs index 34ce2866..eb8f8b07 100644 --- a/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs +++ b/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs @@ -23,7 +23,7 @@ public class PostgresBehaviorTest [Test] public async Task TestPostgresCommandAutomaticallyParticipatesInTransaction() { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); await connection.OpenAsync(); using var transaction = @@ -71,7 +71,7 @@ private async Task TestTransactionCancellationOrTimeoutRecovery(bool useTimeout) async Task RunTransactionWithAbortAsync(bool useSavePoint) { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); await connection.OpenAsync(); using (connection.BeginTransaction()) @@ -107,7 +107,7 @@ async Task RunTransactionWithAbortAsync(bool useSavePoint) [Test] public async Task TestCanDetectTransactionWithBeginTransactionException() { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); await connection.OpenAsync(); Assert.DoesNotThrow(() => connection.BeginTransaction().Dispose()); @@ -121,7 +121,7 @@ public async Task TestCanDetectTransactionWithBeginTransactionException() [Test] public async Task TestDoesNotDetectConnectionBreakViaState() { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); await connection.OpenAsync(); using var getPidCommand = connection.CreateCommand(); @@ -132,7 +132,7 @@ public async Task TestDoesNotDetectConnectionBreakViaState() connection.StateChange += (_, _2) => stateChangedEvent.Set(); // kill the connection from the back end - using var killingConnection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); + using var killingConnection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); await killingConnection.OpenAsync(); using var killCommand = killingConnection.CreateCommand(); killCommand.CommandText = $"SELECT pg_terminate_backend({pid})"; @@ -150,7 +150,7 @@ public async Task TestExecutingQueryOnKilledConnectionFiresStateChanged() { using var stateChangedEvent = new ManualResetEventSlim(initialState: false); - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); + using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); await connection.OpenAsync(); connection.StateChange += (o, e) => stateChangedEvent.Set(); @@ -161,7 +161,7 @@ public async Task TestExecutingQueryOnKilledConnectionFiresStateChanged() Assert.AreEqual(ConnectionState.Open, connection.State); // kill the connection from the back end - using var killingConnection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); + using var killingConnection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString); await killingConnection.OpenAsync(); using var killCommand = killingConnection.CreateCommand(); killCommand.CommandText = $"SELECT pg_terminate_backend({pid})"; diff --git a/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs b/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs index 3ec6a0c7..1e0ad977 100644 --- a/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs +++ b/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs @@ -24,7 +24,7 @@ public void TestValidatesConstructorArguments() [Test] public async Task TestInt64AndInt32PairKeyNamespacesAreDifferent() { - var connectionString = TestingPostgresDb.ConnectionString; + var connectionString = TestingPostgresDb.DefaultConnectionString; var key1 = new PostgresAdvisoryLockKey(0); var key2 = new PostgresAdvisoryLockKey(0, 0); var @lock1 = new PostgresDistributedLock(key1, connectionString); @@ -40,11 +40,11 @@ public async Task TestInt64AndInt32PairKeyNamespacesAreDifferent() [Test] public async Task TestWorksWithAmbientTransaction() { - using var connection = new NpgsqlConnection(TestingPostgresDb.ConnectionString); + 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.ConnectionString); + var otherLock = new PostgresDistributedLock(connectionLock.Key, TestingPostgresDb.DefaultConnectionString); using var otherLockHandle = await otherLock.AcquireAsync(); using (var transaction = connection.BeginTransaction()) diff --git a/DistributedLock.Tests/Tests/Postgres/PostgresDistributedSynchronizationProviderTest.cs b/DistributedLock.Tests/Tests/Postgres/PostgresDistributedSynchronizationProviderTest.cs index c0ba80c5..2e6044a5 100644 --- a/DistributedLock.Tests/Tests/Postgres/PostgresDistributedSynchronizationProviderTest.cs +++ b/DistributedLock.Tests/Tests/Postgres/PostgresDistributedSynchronizationProviderTest.cs @@ -20,7 +20,7 @@ public void TestArgumentValidation() [Test] public async Task BasicTest() { - var provider = new PostgresDistributedSynchronizationProvider(TestingPostgresDb.ConnectionString); + var provider = new PostgresDistributedSynchronizationProvider(TestingPostgresDb.DefaultConnectionString); const string LockName = TargetFramework.Current + "ProviderBasicTest"; await using (await provider.AcquireLockAsync(LockName)) diff --git a/DistributedLock.Tests/Tests/SqlServer/SqlDatabaseConnectionTest.cs b/DistributedLock.Tests/Tests/SqlServer/SqlDatabaseConnectionTest.cs index 0d4940e9..9e67f78a 100644 --- a/DistributedLock.Tests/Tests/SqlServer/SqlDatabaseConnectionTest.cs +++ b/DistributedLock.Tests/Tests/SqlServer/SqlDatabaseConnectionTest.cs @@ -72,8 +72,8 @@ public async Task TestExecuteNonQueryCanCancel([Values] bool isAsync, [Values] b private static SqlDatabaseConnection CreateConnection(bool isSystemDataSqlClient) => new SqlDatabaseConnection( isSystemDataSqlClient - ? new System.Data.SqlClient.SqlConnection(TestingSqlServerDb.ConnectionString).As() - : new Microsoft.Data.SqlClient.SqlConnection(TestingSqlServerDb.ConnectionString), + ? new System.Data.SqlClient.SqlConnection(TestingSqlServerDb.DefaultConnectionString).As() + : new Microsoft.Data.SqlClient.SqlConnection(TestingSqlServerDb.DefaultConnectionString), isExternallyOwned: false ); } diff --git a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs index dddd15ab..92bd89ae 100644 --- a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs +++ b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedLockTest.cs @@ -12,13 +12,13 @@ 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(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.ConnectionString, exactName: true)); - Assert.DoesNotThrow(() => new SqlDistributedLock(new string('a', SqlDistributedLock.MaxNameLength), TestingSqlServerDb.ConnectionString, exactName: true)); + 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] @@ -40,7 +40,7 @@ public void TestGetSafeLockNameCompat() [Test] public async Task TestSqlCommandMustParticipateInTransaction() { - using var connection = new SqlConnection(TestingSqlServerDb.ConnectionString); + using var connection = new SqlConnection(TestingSqlServerDb.DefaultConnectionString); await connection.OpenAsync(); using var transaction = connection.BeginTransaction(); diff --git a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs index 14966a4a..358514d5 100644 --- a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs +++ b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedReaderWriterLockTest.cs @@ -10,13 +10,13 @@ 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(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.ConnectionString, exactName: true)); - Assert.DoesNotThrow(() => new SqlDistributedReaderWriterLock(new string('a', SqlDistributedReaderWriterLock.MaxNameLength), TestingSqlServerDb.ConnectionString, exactName: true)); + 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] diff --git a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs index c659bbff..b4b5677d 100644 --- a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs +++ b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSemaphoreTest.cs @@ -14,9 +14,9 @@ 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(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)!)); @@ -24,7 +24,7 @@ public void TestBadConstructorArguments() 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)); + Assert.DoesNotThrow(() => new SqlDistributedSemaphore(Encoding.UTF8.GetString(bytes), int.MaxValue, TestingSqlServerDb.DefaultConnectionString)); } [Test] @@ -72,7 +72,7 @@ public void TestNameManglingCompatibility() [Test] public void TestTicketsTakenOnBothConnectionAndTransactionForThatConnection() { - using var connection = new SqlConnection(TestingSqlServerDb.ConnectionString); + using var connection = new SqlConnection(TestingSqlServerDb.DefaultConnectionString); connection.Open(); var semaphore1 = new SqlDistributedSemaphore( diff --git a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSynchronizationProviderTest.cs b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSynchronizationProviderTest.cs index b6b610fa..e4fc9dbc 100644 --- a/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSynchronizationProviderTest.cs +++ b/DistributedLock.Tests/Tests/SqlServer/SqlDistributedSynchronizationProviderTest.cs @@ -21,7 +21,7 @@ public void TestArgumentValidation() [Test] public async Task BasicTest() { - var provider = new SqlDistributedSynchronizationProvider(TestingSqlServerDb.ConnectionString); + var provider = new SqlDistributedSynchronizationProvider(TestingSqlServerDb.DefaultConnectionString); const string LockName = TargetFramework.Current + "ProviderBasicTest"; await using (await provider.AcquireLockAsync(LockName)) diff --git a/DistributedLock.sln b/DistributedLock.sln index 565ccbd5..699cbe67 100644 --- a/DistributedLock.sln +++ b/DistributedLock.sln @@ -33,7 +33,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{D14FE348 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock.ZooKeeper", "DistributedLock.ZooKeeper\DistributedLock.ZooKeeper.csproj", "{710F287B-02FB-4F89-9BEC-BAA97250037F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedLock.MySql", "DistributedLock.MySql\DistributedLock.MySql.csproj", "{6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistributedLock.MySql", "DistributedLock.MySql\DistributedLock.MySql.csproj", "{6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedLock.Oracle", "DistributedLock.Oracle\DistributedLock.Oracle.csproj", "{1CAB9A1D-0C02-459C-A90E-47819832BD58}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -93,6 +95,10 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DistributedLock/DistributedLock.csproj b/DistributedLock/DistributedLock.csproj index c7c31000..4b62fa9a 100644 --- a/DistributedLock/DistributedLock.csproj +++ b/DistributedLock/DistributedLock.csproj @@ -1,7 +1,7 @@  - netstandard2.0;netstandard2.1;net461 + netstandard2.0;netstandard2.1;net461;net462 Medallion.Threading True 4 @@ -10,14 +10,14 @@ - 2.2.0 + 2.3.0 2.0.0.0 Michael Adelson 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 postgres 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 @@ -50,6 +50,7 @@ + diff --git a/DistributedLockTaker/Program.cs b/DistributedLockTaker/Program.cs index e59af60b..f1d85871 100644 --- a/DistributedLockTaker/Program.cs +++ b/DistributedLockTaker/Program.cs @@ -15,6 +15,7 @@ using System.Collections.Generic; using Medallion.Threading.ZooKeeper; using Medallion.Threading.MySql; +using Medallion.Threading.Oracle; #if NET471 using System.Data.SqlClient; #elif NETCOREAPP3_1 @@ -56,6 +57,12 @@ public static int Main(string[] args) 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; diff --git a/README.md b/README.md index 782ff0ea..d7717f12 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ DistributedLock contains implementations based on various technologies; you can - **[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/): 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/): 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/): 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/): 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/): 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/): uses Azure blobs - **[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/): uses Apache ZooKeeper @@ -134,8 +135,12 @@ public class SomeService 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. ## Release notes +- 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) + - 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 diff --git a/docs/DistributedLock.Oracle.md b/docs/DistributedLock.Oracle.md new file mode 100644 index 00000000..2f61e45d --- /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. + +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!). + + +