From 5cb90f710ba7af77d34066ebb97109f9289053be Mon Sep 17 00:00:00 2001 From: Michael Adelson Date: Fri, 9 Jul 2021 07:13:33 -0400 Subject: [PATCH 1/8] MySql project setup --- DistributedLock.Core/AssemblyAttributes.cs | 1 + DistributedLock.MySql/AssemblyAttributes.cs | 3 ++ .../DistributedLock.MySql.csproj | 54 +++++++++++++++++++ DistributedLock.sln | 8 ++- DistributedLock/DistributedLock.csproj | 1 + 5 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 DistributedLock.MySql/AssemblyAttributes.cs create mode 100644 DistributedLock.MySql/DistributedLock.MySql.csproj diff --git a/DistributedLock.Core/AssemblyAttributes.cs b/DistributedLock.Core/AssemblyAttributes.cs index 6cbdd148..9a42671f 100644 --- a/DistributedLock.Core/AssemblyAttributes.cs +++ b/DistributedLock.Core/AssemblyAttributes.cs @@ -13,4 +13,5 @@ [assembly: InternalsVisibleTo("DistributedLock.FileSystem, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] [assembly: InternalsVisibleTo("DistributedLock.Redis, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] [assembly: InternalsVisibleTo("DistributedLock.ZooKeeper, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] +[assembly: InternalsVisibleTo("DistributedLock.MySql, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] #endif diff --git a/DistributedLock.MySql/AssemblyAttributes.cs b/DistributedLock.MySql/AssemblyAttributes.cs new file mode 100644 index 00000000..a9e3a34f --- /dev/null +++ b/DistributedLock.MySql/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DistributedLock.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")] diff --git a/DistributedLock.MySql/DistributedLock.MySql.csproj b/DistributedLock.MySql/DistributedLock.MySql.csproj new file mode 100644 index 00000000..60eda120 --- /dev/null +++ b/DistributedLock.MySql/DistributedLock.MySql.csproj @@ -0,0 +1,54 @@ + + + + netstandard2.0;netstandard2.1;net461 + Medallion.Threading.MySql + True + 4 + Latest + enable + + + + 1.0.0 + 1.0.0.0 + Michael Adelson + Provides a distributed lock implementation based on MySql + Copyright © 2020 Michael Adelson + MIT + distributed lock async mutex sql mysql + https://github.com/madelson/DistributedLock + https://github.com/madelson/DistributedLock + 1.0.0.0 + See https://github.com/madelson/DistributedLock#release-notes + true + ..\DistributedLock.snk + + + + True + True + True + + + embedded + + + + False + 1591 + TRACE;DEBUG + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DistributedLock.sln b/DistributedLock.sln index 0b66e25a..565ccbd5 100644 --- a/DistributedLock.sln +++ b/DistributedLock.sln @@ -31,7 +31,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{D14FE348 FixDistributedLockCoreDependencyVersion.targets = FixDistributedLockCoreDependencyVersion.targets EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedLock.ZooKeeper", "DistributedLock.ZooKeeper\DistributedLock.ZooKeeper.csproj", "{710F287B-02FB-4F89-9BEC-BAA97250037F}" +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}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -87,6 +89,10 @@ Global {710F287B-02FB-4F89-9BEC-BAA97250037F}.Debug|Any CPU.Build.0 = Debug|Any CPU {710F287B-02FB-4F89-9BEC-BAA97250037F}.Release|Any CPU.ActiveCfg = Release|Any CPU {710F287B-02FB-4F89-9BEC-BAA97250037F}.Release|Any CPU.Build.0 = Release|Any CPU + {6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C13E55C-51A7-47CD-88A5-7C8564EBCB3C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DistributedLock/DistributedLock.csproj b/DistributedLock/DistributedLock.csproj index 4d6229c7..65cb3b98 100644 --- a/DistributedLock/DistributedLock.csproj +++ b/DistributedLock/DistributedLock.csproj @@ -49,6 +49,7 @@ + From 4c0b7ffcc8ad7e069bbbf6e1042260e22abcb0af Mon Sep 17 00:00:00 2001 From: Michael Adelson Date: Tue, 24 Aug 2021 20:19:40 -0400 Subject: [PATCH 2/8] Implement MySql distributed lock (fix #95) --- .../Internal/Data/DatabaseConnection.cs | 2 +- ...onnectionOrTransactionDbDistributedLock.cs | 37 ++-- .../DistributedLock.MySql.csproj | 5 +- .../MySqlConnectionOptionsBuilder.cs | 76 ++++++++ .../MySqlDatabaseConnection.cs | 49 +++++ .../MySqlDistributedLock.IDistributedLock.cs | 85 ++++++++ DistributedLock.MySql/MySqlDistributedLock.cs | 183 ++++++++++++++++++ .../MySqlDistributedLockHandle.cs | 37 ++++ ...MySqlDistributedSynchronizationProvider.cs | 53 +++++ .../MySqlMultiplexedConnectionLockPool.cs | 12 ++ DistributedLock.MySql/MySqlUserLock.cs | 83 ++++++++ .../PostgresMultiplexedConnectionLockPool.cs | 3 +- .../SqlDatabaseConnection.cs | 6 +- .../SqlMultiplexedConnectionLockPool.cs | 3 +- ...onnectionOrTransactionStrategyTestCases.cs | 13 +- .../ExternalTransactionStrategyTestCases.cs | 25 ++- .../Data/OwnedTransactionStrategyTestCases.cs | 10 +- .../DistributedLockCoreTestCases.cs | 4 +- .../Infrastructure/Data/ITestingDb.cs | 20 +- .../Infrastructure/MySql/TestingMySqlDb.cs | 107 ++++++++++ .../MySql/TestingMySqlProviders.cs | 31 +++ .../Postgres/TestingPostgresDb.cs | 2 +- .../Shared/MariaDbCredentials.cs | 45 +++++ .../Infrastructure/Shared/MySqlCredentials.cs | 39 ++++ .../SqlServer/TestingSqlServerDb.cs | 4 +- .../Tests/CombinatorialTests.cs | 34 ++++ .../MySqlConnectionOptionsBuilderTest.cs | 28 +++ .../Tests/MySql/MySqlDistributedLockTest.cs | 77 ++++++++ .../Postgres/PostgresDistributedLockTest.cs | 8 + ...PostgresDistributedReaderWriterLockTest.cs | 21 ++ DistributedLockTaker/Program.cs | 7 + 31 files changed, 1062 insertions(+), 47 deletions(-) create mode 100644 DistributedLock.MySql/MySqlConnectionOptionsBuilder.cs create mode 100644 DistributedLock.MySql/MySqlDatabaseConnection.cs create mode 100644 DistributedLock.MySql/MySqlDistributedLock.IDistributedLock.cs create mode 100644 DistributedLock.MySql/MySqlDistributedLock.cs create mode 100644 DistributedLock.MySql/MySqlDistributedLockHandle.cs create mode 100644 DistributedLock.MySql/MySqlDistributedSynchronizationProvider.cs create mode 100644 DistributedLock.MySql/MySqlMultiplexedConnectionLockPool.cs create mode 100644 DistributedLock.MySql/MySqlUserLock.cs create mode 100644 DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs create mode 100644 DistributedLock.Tests/Infrastructure/MySql/TestingMySqlProviders.cs create mode 100644 DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs create mode 100644 DistributedLock.Tests/Infrastructure/Shared/MySqlCredentials.cs create mode 100644 DistributedLock.Tests/Tests/MySql/MySqlConnectionOptionsBuilderTest.cs create mode 100644 DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs create mode 100644 DistributedLock.Tests/Tests/Postgres/PostgresDistributedReaderWriterLockTest.cs diff --git a/DistributedLock.Core/Internal/Data/DatabaseConnection.cs b/DistributedLock.Core/Internal/Data/DatabaseConnection.cs index 7471531b..a7a85f61 100644 --- a/DistributedLock.Core/Internal/Data/DatabaseConnection.cs +++ b/DistributedLock.Core/Internal/Data/DatabaseConnection.cs @@ -60,7 +60,7 @@ public DatabaseCommand CreateCommand() // without closing the connection. However, we don't currently have any use-cases for that public async ValueTask BeginTransactionAsync() { - Invariant.Require(this._transaction == null); + Invariant.Require(!this.HasTransaction); using var _ = await this.ConnectionMonitor.AcquireConnectionLockAsync(CancellationToken.None).ConfigureAwait(false); diff --git a/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs b/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs index e0c38190..78e2f0a3 100644 --- a/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs +++ b/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs @@ -18,11 +18,14 @@ sealed class DedicatedConnectionOrTransactionDbDistributedLock : IDbDistributedL { private readonly string _name; private readonly Func _connectionFactory; - private readonly bool _scopeToOwnedTransaction; + private readonly bool _transactionScopedIfPossible; private readonly TimeoutValue _keepaliveCadence; + /// + /// Constructs an instance using the given EXTERNALLY OWNED . + /// public DedicatedConnectionOrTransactionDbDistributedLock(string name, Func externalConnectionFactory) - : this(name, externalConnectionFactory, useTransaction: false, keepaliveCadence: Timeout.InfiniteTimeSpan) + : this(name, externalConnectionFactory, useTransaction: true, keepaliveCadence: Timeout.InfiniteTimeSpan) { } @@ -34,7 +37,7 @@ public DedicatedConnectionOrTransactionDbDistributedLock( { this._name = name; this._connectionFactory = connectionFactory; - this._scopeToOwnedTransaction = useTransaction; + this._transactionScopedIfPossible = useTransaction; this._keepaliveCadence = keepaliveCadence; } @@ -50,39 +53,34 @@ public DedicatedConnectionOrTransactionDbDistributedLock( try { DatabaseConnection connection; - bool transactionScoped; if (contextHandle != null) { connection = GetContextHandleConnection(contextHandle); - transactionScoped = false; } else { connectionResource = connection = this._connectionFactory(); if (connection.IsExernallyOwned) { - Invariant.Require(!this._scopeToOwnedTransaction); if (!connection.CanExecuteQueries) { throw new InvalidOperationException("The connection and/or transaction are disposed or closed"); } - transactionScoped = false; } else { await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - if (this._scopeToOwnedTransaction) + if (this._transactionScopedIfPossible) // for an internally-owned connection, we must create the transaction { await connection.BeginTransactionAsync().ConfigureAwait(false); } - transactionScoped = this._scopeToOwnedTransaction; } } var lockCookie = await strategy.TryAcquireAsync(connection, this._name, timeout, cancellationToken).ConfigureAwait(false); if (lockCookie != null) { - result = new Handle(connection, strategy, this._name, lockCookie, transactionScoped, connectionResource); + result = new Handle(connection, strategy, this._name, lockCookie, transactionScoped: this._transactionScopedIfPossible && connection.HasTransaction, connectionResource); if (!this._keepaliveCadence.IsInfinite) { connection.SetKeepaliveCadence(this._keepaliveCadence); @@ -148,7 +146,7 @@ private sealed class InnerHandle : IAsyncDisposable private readonly IDbSynchronizationStrategy _strategy; private readonly string _name; private readonly TLockCookie _lockCookie; - private readonly bool _scopedToOwnedTransaction; + private readonly bool _transactionScoped; private readonly IAsyncDisposable? _connectionResource; private object? _connectionMonitoringHandleOrDisposedSentinel; @@ -157,14 +155,14 @@ public InnerHandle( IDbSynchronizationStrategy strategy, string name, TLockCookie lockCookie, - bool scopedToOwnTransaction, + bool transactionScoped, IAsyncDisposable? connectionResource) { this.Connection = connection; this._strategy = strategy; this._name = name; this._lockCookie = lockCookie; - this._scopedToOwnedTransaction = scopedToOwnTransaction; + this._transactionScoped = transactionScoped; this._connectionResource = connectionResource; } @@ -215,13 +213,12 @@ public async ValueTask DisposeAsync() try { - // If we're not scoped to a transaction, explicit release is required regardless of whether - // we are about to dispose the connection due to connection pooling. For a pooled connection, - // simply calling Dispose() will not release the lock: it just returns the connection to the pool. - if (!(this._scopedToOwnedTransaction - // For external transaction-scoped locks, we're not about to dispose the transaction but if the transaction is - // dead (e. g. completed or rolled back) then we know the lock has been released. - || (this.Connection.IsExernallyOwned && this.Connection.HasTransaction && !this.Connection.CanExecuteQueries))) + // For transaction-scoped locks, we can sometimes skip the explicit release step. This comes up when either + // (a) We own the connection and therefore the transaction. In this case we're about to dispose the transaction and release that way + // (b) The transaction is dead (e. g. completed or rolled back) in which case the lock has already been released + var canSkipExplicitRelease = + this._transactionScoped && (!this.Connection.IsExernallyOwned || !this.Connection.CanExecuteQueries); + if (!canSkipExplicitRelease) { await this._strategy.ReleaseAsync(this.Connection, this._name, this._lockCookie).ConfigureAwait(false); } diff --git a/DistributedLock.MySql/DistributedLock.MySql.csproj b/DistributedLock.MySql/DistributedLock.MySql.csproj index 60eda120..bc66182f 100644 --- a/DistributedLock.MySql/DistributedLock.MySql.csproj +++ b/DistributedLock.MySql/DistributedLock.MySql.csproj @@ -14,7 +14,7 @@ 1.0.0.0 Michael Adelson Provides a distributed lock implementation based on MySql - Copyright © 2020 Michael Adelson + Copyright © 2021 Michael Adelson MIT distributed lock async mutex sql mysql https://github.com/madelson/DistributedLock @@ -41,7 +41,8 @@ - + + diff --git a/DistributedLock.MySql/MySqlConnectionOptionsBuilder.cs b/DistributedLock.MySql/MySqlConnectionOptionsBuilder.cs new file mode 100644 index 00000000..38c11a66 --- /dev/null +++ b/DistributedLock.MySql/MySqlConnectionOptionsBuilder.cs @@ -0,0 +1,76 @@ +using Medallion.Threading.Internal; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +namespace Medallion.Threading.MySql +{ + /// + /// Specifies options for connecting to and locking against a MySQL database + /// + public sealed class MySqlConnectionOptionsBuilder + { + private TimeoutValue? _keepaliveCadence; + private bool? _useMultiplexing; + + internal MySqlConnectionOptionsBuilder() { } + + /// + /// MySQL's wait_timeout system variable determines how long the server will allow a connection to be idle before killing it. + /// For more information, see https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_wait_timeout. + /// + /// To prevent this, this option sets the cadence at which we run a no-op "keepalive" query on a connection that is holding a lock. + /// + /// Because MySQL's default for this setting is 8 hours, the default is 3.5 hours. + /// + /// Setting a value of disables keepalive. + /// + public MySqlConnectionOptionsBuilder KeepaliveCadence(TimeSpan keepaliveCadence) + { + this._keepaliveCadence = new TimeoutValue(keepaliveCadence, nameof(keepaliveCadence)); + return this; + } + + /// + /// This mode takes advantage of the fact that while "holding" a lock (or other synchronization primitive) + /// a connection is essentially idle. Thus, rather than creating a new connection for each held lock it is + /// often possible to multiplex a shared connection so that that connection can hold multiple locks at the same time. + /// + /// Multiplexing is on by default. + /// + /// This is implemented in such a way that releasing a lock held on such a connection will never be blocked by an + /// Acquire() call that is waiting to acquire a lock on that same connection. For this reason, the multiplexing + /// strategy is "optimistic": if the lock can't be acquired instantaneously on the shared connection, a new (shareable) + /// connection will be allocated. + /// + /// This option can improve performance and avoid connection pool starvation in high-load scenarios. It is also + /// particularly applicable to cases where + /// semantics are used with a zero-length timeout. + /// + public MySqlConnectionOptionsBuilder UseMultiplexing(bool useMultiplexing = true) + { + this._useMultiplexing = useMultiplexing; + return this; + } + + internal static (TimeoutValue keepaliveCadence, bool useMultiplexing) GetOptions(Action? optionsBuilder) + { + MySqlConnectionOptionsBuilder? options; + if (optionsBuilder != null) + { + options = new MySqlConnectionOptionsBuilder(); + optionsBuilder(options); + } + else + { + options = null; + } + + var keepaliveCadence = options?._keepaliveCadence ?? TimeSpan.FromHours(3.5); + var useMultiplexing = options?._useMultiplexing ?? true; + + return (keepaliveCadence, useMultiplexing); + } + } +} diff --git a/DistributedLock.MySql/MySqlDatabaseConnection.cs b/DistributedLock.MySql/MySqlDatabaseConnection.cs new file mode 100644 index 00000000..6de275b4 --- /dev/null +++ b/DistributedLock.MySql/MySqlDatabaseConnection.cs @@ -0,0 +1,49 @@ +using Medallion.Threading.Internal.Data; +using MySqlConnector; +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Threading.MySql +{ + internal class MySqlDatabaseConnection : DatabaseConnection + { + public MySqlDatabaseConnection(IDbConnection connection) + : base(connection, isExternallyOwned: true) + { + } + + public MySqlDatabaseConnection(IDbTransaction transaction) + : base(transaction, isExternallyOwned: true) + { + } + + public MySqlDatabaseConnection(string connectionString) + : base(new MySqlConnection(connectionString), isExternallyOwned: false) + { + } + + // Seems like this only helps when executing a statement multiple times on the + // same connection (unclear since there's limited documentation) + public override bool ShouldPrepareCommands => false; + + public override bool IsCommandCancellationException(Exception exception) + { + // see https://mysqlconnector.net/overview/command-cancellation/ + return exception is MySqlException ex && ex.ErrorCode == MySqlErrorCode.QueryInterrupted; + } + + public override async Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor) + { + using var sleepCommand = this.CreateCommand(); + sleepCommand.SetCommandText("SELECT SLEEP(@durationSeconds)"); + sleepCommand.AddParameter("durationSeconds", sleepTime.TotalSeconds); + sleepCommand.SetTimeout(sleepTime); + + await executor(sleepCommand, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/DistributedLock.MySql/MySqlDistributedLock.IDistributedLock.cs b/DistributedLock.MySql/MySqlDistributedLock.IDistributedLock.cs new file mode 100644 index 00000000..c9973013 --- /dev/null +++ b/DistributedLock.MySql/MySqlDistributedLock.IDistributedLock.cs @@ -0,0 +1,85 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Medallion.Threading.Internal; + +namespace Medallion.Threading.MySql +{ + public partial class MySqlDistributedLock + { + // AUTO-GENERATED + + IDistributedSynchronizationHandle? IDistributedLock.TryAcquire(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquire(timeout, cancellationToken); + IDistributedSynchronizationHandle IDistributedLock.Acquire(TimeSpan? timeout, CancellationToken cancellationToken) => + this.Acquire(timeout, cancellationToken); + ValueTask IDistributedLock.TryAcquireAsync(TimeSpan timeout, CancellationToken cancellationToken) => + this.TryAcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + ValueTask IDistributedLock.AcquireAsync(TimeSpan? timeout, CancellationToken cancellationToken) => + this.AcquireAsync(timeout, cancellationToken).Convert(To.ValueTask); + + /// + /// Attempts to acquire the lock synchronously. Usage: + /// + /// using (var handle = myLock.TryAcquire(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public MySqlDistributedLockHandle? TryAcquire(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + DistributedLockHelpers.TryAcquire(this, timeout, cancellationToken); + + /// + /// Acquires the lock synchronously, failing with if the attempt times out. Usage: + /// + /// using (myLock.Acquire(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public MySqlDistributedLockHandle Acquire(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.Acquire(this, timeout, cancellationToken); + + /// + /// Attempts to acquire the lock asynchronously. Usage: + /// + /// await using (var handle = await myLock.TryAcquireAsync(...)) + /// { + /// if (handle != null) { /* we have the lock! */ } + /// } + /// // dispose releases the lock if we took it + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to 0 + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock or null on failure + public ValueTask TryAcquireAsync(TimeSpan timeout = default, CancellationToken cancellationToken = default) => + this.As>().InternalTryAcquireAsync(timeout, cancellationToken); + + /// + /// Acquires the lock asynchronously, failing with if the attempt times out. Usage: + /// + /// await using (await myLock.AcquireAsync(...)) + /// { + /// /* we have the lock! */ + /// } + /// // dispose releases the lock + /// + /// + /// How long to wait before giving up on the acquisition attempt. Defaults to + /// Specifies a token by which the wait can be canceled + /// A which can be used to release the lock + public ValueTask AcquireAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + DistributedLockHelpers.AcquireAsync(this, timeout, cancellationToken); + } +} \ No newline at end of file diff --git a/DistributedLock.MySql/MySqlDistributedLock.cs b/DistributedLock.MySql/MySqlDistributedLock.cs new file mode 100644 index 00000000..57e600e8 --- /dev/null +++ b/DistributedLock.MySql/MySqlDistributedLock.cs @@ -0,0 +1,183 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using System; +using System.Collections.Generic; +using System.Data; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Threading.MySql +{ + /// + /// Implements a distributed lock for MySQL or MariaDB based on the GET_LOCK family of functions + /// + public sealed partial class MySqlDistributedLock : IInternalDistributedLock + { + /// + /// From https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html + /// + internal const int MaxNameLength = 64; + + private readonly IDbDistributedLock _internalLock; + + /// + /// Constructs a lock with the given that connects using the provided and + /// . + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public MySqlDistributedLock(string name, string connectionString, Action? options = null, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, connectionString, options)) + { + } + + /// + /// Constructs a lock with the given that connects using the provided . + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public MySqlDistributedLock(string name, IDbConnection connection, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, connection)) + { + } + + /// + /// Constructs a lock with the given that connects using the connection from the provided . + /// + /// NOTE that the lock will not be scoped to the and must still be explicitly released before the transaction ends. + /// However, this constructor allows the lock to PARTICIPATE in an ongoing transaction on a connection. + /// + /// Unless is specified, will be escaped/hashed to ensure name validity. + /// + public MySqlDistributedLock(string name, IDbTransaction transaction, bool exactName = false) + : this(name, exactName, n => CreateInternalLock(n, transaction)) + { + } + + private MySqlDistributedLock(string name, bool exactName, Func internalLockFactory) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + if (exactName) + { + if (name.Length > MaxNameLength) { throw new FormatException($"{nameof(name)}: must be at most {MaxNameLength} characters"); } + if (name.Length == 0) { throw new FormatException($"{nameof(name)}: must not be empty"); } + if (name.ToLowerInvariant() != name) { throw new FormatException($"{nameof(name)}: must not container uppercase letters"); } + this.Name = name; + } + else + { + this.Name = GetSafeName(name); + } + + this._internalLock = internalLockFactory(this.Name); + } + + /// + /// Implements + /// + public string Name { get; } + + ValueTask IInternalDistributedLock.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) => + this._internalLock.TryAcquireAsync(timeout, new MySqlUserLock(), cancellationToken, contextHandle: null).Wrap(h => new MySqlDistributedLockHandle(h)); + + private static string GetSafeName(string name) => + ToSafeName( + name, + MaxNameLength, + convertToValidName: s => + { + if (s.Length == 0) { return "__empty__"; } + return s.ToLowerInvariant(); + }, + hash: ComputeHash + ); + + private static string ToSafeName(string name, int maxNameLength, Func convertToValidName, Func hash) + { + if (name == null) { throw new ArgumentNullException(nameof(name)); } + + var validBaseLockName = convertToValidName(name); + if (validBaseLockName == name && validBaseLockName.Length <= maxNameLength) + { + return name; + } + + var nameHash = hash(Encoding.UTF8.GetBytes(name)); + + if (nameHash.Length >= maxNameLength) + { + return nameHash.Substring(0, length: maxNameLength); + } + + var prefix = validBaseLockName.Substring(0, Math.Min(validBaseLockName.Length, maxNameLength - nameHash.Length)); + return prefix + nameHash; + } + + private static string ComputeHash(byte[] bytes) + { + using var sha = SHA512.Create(); + var hashBytes = sha.ComputeHash(bytes); + + // We truncate to 160 bits, which is 32 chars of Base32. This should still give us good collision resistance but allows for a 64-char + // name to include a good portion of the original provided name, which is good for debugging. See + // https://crypto.stackexchange.com/questions/9435/is-truncating-a-sha512-hash-to-the-first-160-bits-as-secure-as-using-sha1#:~:text=Yes.,time%20is%20still%20pretty%20big + const int Base32CharBits = 5; + const int HashLengthInChars = 160 / Base32CharBits; + + // we use Base32 because it is case-insensitive (like MySQL) and a bit more compact than Base16 + // RFC 4648 from https://en.wikipedia.org/wiki/Base32 + const string Base32Alphabet = "abcdefghijklmnopqrstuvwxyz234567"; + + var chars = new char[HashLengthInChars]; + var byteIndex = 0; + var bitBuffer = 0; + var bitsRemaining = 0; + for (var charIndex = 0; charIndex < chars.Length; ++charIndex) + { + if (bitsRemaining < Base32CharBits) + { + bitBuffer |= hashBytes[byteIndex++] << bitsRemaining; + bitsRemaining += 8; + } + chars[charIndex] = Base32Alphabet[bitBuffer & 31]; + bitBuffer >>= Base32CharBits; + bitsRemaining -= Base32CharBits; + } + + return new string(chars); + } + + private static IDbDistributedLock CreateInternalLock(string name, string connectionString, Action? options) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + var (keepaliveCadence, useMultiplexing) = MySqlConnectionOptionsBuilder.GetOptions(options); + + if (useMultiplexing) + { + return new OptimisticConnectionMultiplexingDbDistributedLock(name, connectionString, MySqlMultiplexedConnectionLockPool.Instance, keepaliveCadence); + } + + return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new MySqlDatabaseConnection(connectionString), useTransaction: false, keepaliveCadence); + } + + 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) + { + if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); } + + // Note: we pass useTransaction:false here because MYSQL locks are always session-scoped; we only support locking against a transaction + // so that your lock can participate in the connection. + return new DedicatedConnectionOrTransactionDbDistributedLock(name, () => new MySqlDatabaseConnection(transaction), useTransaction: false, keepaliveCadence: Timeout.InfiniteTimeSpan); + } + } +} diff --git a/DistributedLock.MySql/MySqlDistributedLockHandle.cs b/DistributedLock.MySql/MySqlDistributedLockHandle.cs new file mode 100644 index 00000000..646f4a9b --- /dev/null +++ b/DistributedLock.MySql/MySqlDistributedLockHandle.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.MySql +{ + /// + /// Implements + /// + public sealed class MySqlDistributedLockHandle : IDistributedSynchronizationHandle + { + private IDistributedSynchronizationHandle? _innerHandle; + + internal MySqlDistributedLockHandle(IDistributedSynchronizationHandle innerHandle) + { + this._innerHandle = innerHandle; + } + + /// + /// Implements + /// + public CancellationToken HandleLostToken => this._innerHandle?.HandleLostToken ?? throw this.ObjectDisposed(); + + /// + /// Releases the lock + /// + public void Dispose() => Interlocked.Exchange(ref this._innerHandle, null)?.Dispose(); + + /// + /// Releases the lock asynchronously + /// + public ValueTask DisposeAsync() => Interlocked.Exchange(ref this._innerHandle, null)?.DisposeAsync() ?? default; + } +} diff --git a/DistributedLock.MySql/MySqlDistributedSynchronizationProvider.cs b/DistributedLock.MySql/MySqlDistributedSynchronizationProvider.cs new file mode 100644 index 00000000..00b3388f --- /dev/null +++ b/DistributedLock.MySql/MySqlDistributedSynchronizationProvider.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Text; + +namespace Medallion.Threading.MySql +{ + /// + /// Implements for + /// + public sealed class MySqlDistributedSynchronizationProvider : IDistributedLockProvider + { + private readonly Func _lockFactory; + + /// + /// Constructs a provider that connects with and . + /// + public MySqlDistributedSynchronizationProvider(string connectionString, Action? options = null) + { + if (connectionString == null) { throw new ArgumentNullException(nameof(connectionString)); } + + this._lockFactory = (name, exactName) => new MySqlDistributedLock(name, connectionString, options, exactName); + } + + /// + /// Constructs a provider that connects with . + /// + public MySqlDistributedSynchronizationProvider(IDbConnection connection) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + + this._lockFactory = (name, exactName) => new MySqlDistributedLock(name, connection, exactName); + } + + /// + /// Constructs a provider that connects with . + /// + public MySqlDistributedSynchronizationProvider(IDbTransaction transaction) + { + if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); } + + this._lockFactory = (name, exactName) => new MySqlDistributedLock(name, transaction, exactName); + } + + /// + /// Creates a with the provided . Unless + /// is specified, invalid names will be escaped/hashed. + /// + public MySqlDistributedLock CreateLock(string name, bool exactName = false) => this._lockFactory(name, exactName); + + IDistributedLock IDistributedLockProvider.CreateLock(string name) => this.CreateLock(name); + } +} diff --git a/DistributedLock.MySql/MySqlMultiplexedConnectionLockPool.cs b/DistributedLock.MySql/MySqlMultiplexedConnectionLockPool.cs new file mode 100644 index 00000000..4ba0303a --- /dev/null +++ b/DistributedLock.MySql/MySqlMultiplexedConnectionLockPool.cs @@ -0,0 +1,12 @@ +using Medallion.Threading.Internal.Data; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Medallion.Threading.MySql +{ + internal static class MySqlMultiplexedConnectionLockPool + { + public static readonly MultiplexedConnectionLockPool Instance = new(s => new MySqlDatabaseConnection(s)); + } +} diff --git a/DistributedLock.MySql/MySqlUserLock.cs b/DistributedLock.MySql/MySqlUserLock.cs new file mode 100644 index 00000000..0e4aa551 --- /dev/null +++ b/DistributedLock.MySql/MySqlUserLock.cs @@ -0,0 +1,83 @@ +using Medallion.Threading.Internal; +using Medallion.Threading.Internal.Data; +using MySqlConnector; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Threading.MySql +{ + /// + /// Implements using user-level locking functions. See + /// https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html + /// + internal class MySqlUserLock : IDbSynchronizationStrategy + { + // matches SqlApplicationLock + private const int AlreadyHeldReturnCode = 103; + // see behavior documented at https://mariadb.com/kb/en/get_lock/ for when GET_LOCK returns NULL + private const int GetLockErrorReturnCode = 104; + + private static readonly object Cookie = new(); + + public bool IsUpgradeable => false; + + public async ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, object lockCookie) + { + using var command = connection.CreateCommand(); + command.SetCommandText("DO RELEASE_LOCK(@name)"); + command.AddParameter("name", resourceName); + await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); + } + + public async ValueTask TryAcquireAsync(DatabaseConnection connection, string resourceName, TimeoutValue timeout, CancellationToken cancellationToken) + { + try + { + using var command = connection.CreateCommand(); + command.SetCommandText($"SELECT CASE WHEN IS_USED_LOCK(@name) = CONNECTION_ID() THEN {AlreadyHeldReturnCode} ELSE IFNULL(GET_LOCK(@name, @timeoutSeconds), {GetLockErrorReturnCode}) END"); + command.AddParameter("name", resourceName); + // Note: -1 works for MySQL but not for MariaDB (https://stackoverflow.com/questions/49792089/set-infinite-timeout-get-lock-in-mariadb/49809919) + command.AddParameter("timeoutSeconds", timeout.IsInfinite ? 0xFFFFFFFF : timeout.InSeconds); + + // Convert required because the value comes back as int on MariaDB and long on MySQL + var acquireCommandResult = Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false)); + switch (acquireCommandResult) + { + case 0: // timeout + return null; + case 1: // success + return Cookie; + case AlreadyHeldReturnCode: + if (timeout.IsZero) { return null; } + if (timeout.IsInfinite) { throw new DeadlockException("Attempted to acquire a lock that is already held on the same connection"); } + await SyncViaAsync.Delay(timeout, cancellationToken).ConfigureAwait(false); + return null; + case GetLockErrorReturnCode: + cancellationToken.ThrowIfCancellationRequested(); // this error can also indicate cancellation in MariaDB + throw new InvalidOperationException("An error occurred such as running out of memory on the thread or a mysqladmin kill when trying to acquire the lock"); + default: + throw new InvalidOperationException($"Unexpected return code {acquireCommandResult}"); + } + } + catch (MySqlException ex) + // from https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html#error_er_user_lock_deadlock + when ((ex.Number == 3058 && ex.SqlState == "HY000") + // from https://mariadb.com/kb/en/mariadb-error-codes/ + || (ex.Number == 1213 && ex.SqlState == "40001")) + { + throw new DeadlockException($"The request for the distributed lock failed with deadlock exit code {ex.Number}", ex); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // If the command is canceled, I believe there's a slim chance that acquisition just completed before the cancellation went through. + // In that case, I'm pretty sure it won't be rolled back. Therefore, to be safe we issue a release + await this.ReleaseAsync(connection, resourceName, Cookie).ConfigureAwait(false); + throw; + } + } + } +} diff --git a/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs b/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs index d012fc28..a2d0c631 100644 --- a/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs +++ b/DistributedLock.Postgres/PostgresMultiplexedConnectionLockPool.cs @@ -8,7 +8,6 @@ namespace Medallion.Threading.Postgres { internal static class PostgresMultiplexedConnectionLockPool { - public static readonly MultiplexedConnectionLockPool Instance = - new MultiplexedConnectionLockPool(s => new PostgresDatabaseConnection(s)); + public static readonly MultiplexedConnectionLockPool Instance = new(s => new PostgresDatabaseConnection(s)); } } diff --git a/DistributedLock.SqlServer/SqlDatabaseConnection.cs b/DistributedLock.SqlServer/SqlDatabaseConnection.cs index 6357d2c3..1bcad50c 100644 --- a/DistributedLock.SqlServer/SqlDatabaseConnection.cs +++ b/DistributedLock.SqlServer/SqlDatabaseConnection.cs @@ -58,16 +58,16 @@ public override bool IsCommandCancellationException(Exception exception) return exception is InvalidOperationException; } - public override Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor) + public override async Task SleepAsync(TimeSpan sleepTime, CancellationToken cancellationToken, Func> executor) { Invariant.Require(sleepTime >= TimeSpan.Zero && sleepTime < TimeSpan.FromDays(1)); - var command = this.CreateCommand(); + using var command = this.CreateCommand(); command.SetCommandText(@"WAITFOR DELAY @delay"); command.AddParameter("delay", sleepTime.ToString(@"hh\:mm\:ss\.fff"), DbType.AnsiStringFixedLength); command.SetTimeout(sleepTime); - return executor(command, cancellationToken).AsTask(); + await executor(command, cancellationToken).ConfigureAwait(false); } } } diff --git a/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs b/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs index 75e59f9a..ce5602bb 100644 --- a/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs +++ b/DistributedLock.SqlServer/SqlMultiplexedConnectionLockPool.cs @@ -8,7 +8,6 @@ namespace Medallion.Threading.SqlServer { internal static class SqlMultiplexedConnectionLockPool { - public static readonly MultiplexedConnectionLockPool Instance = - new MultiplexedConnectionLockPool(s => new SqlDatabaseConnection(s)); + public static readonly MultiplexedConnectionLockPool Instance = new(s => new SqlDatabaseConnection(s)); } } diff --git a/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs index ff91cd4b..b7f9ebf3 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/ExternalConnectionOrTransactionStrategyTestCases.cs @@ -48,11 +48,16 @@ Task RunDeadlock(bool isFirst) Task.WhenAll(tasks).ContinueWith(_ => { }).Wait(TimeSpan.FromSeconds(10)).ShouldEqual(true, this.GetType().Name); - var deadlockVictim = tasks.Single(t => t.IsFaulted); - Assert.IsInstanceOf(deadlockVictim.Exception!.GetBaseException()); // backwards compat check - Assert.IsInstanceOf(deadlockVictim.Exception.GetBaseException()); + // MariaDB fails both tasks due to deadlock instead of just picking a single victim + Assert.GreaterOrEqual(tasks.Count(t => t.IsFaulted), 1); + Assert.LessOrEqual(tasks.Count(t => t.Status == TaskStatus.RanToCompletion), 1); + Assert.IsEmpty(tasks.Where(t => t.IsCanceled)); - tasks.Count(t => t.Status == TaskStatus.RanToCompletion).ShouldEqual(1); + foreach (var deadlockVictim in tasks.Where(t => t.IsFaulted)) + { + Assert.IsInstanceOf(deadlockVictim.Exception!.GetBaseException()); // backwards compat check + Assert.IsInstanceOf(deadlockVictim.Exception.GetBaseException()); + } } [Test] diff --git a/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs index 59fa0268..70edd6f8 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/ExternalTransactionStrategyTestCases.cs @@ -113,18 +113,31 @@ private void TestLockOnCompletedTransactionHelper(Action complete complete(this._lockProvider.Strategy.AmbientTransaction!); - Assert.DoesNotThrow(handle.Dispose); + var transactionSupport = this._lockProvider.Strategy.Db.TransactionSupport; + if (transactionSupport == TransactionSupport.ExplicitParticipation) + { + // this will throw because the lock will still be trying to use the transaction and we've ended it + Assert.Throws(handle.Dispose); + } + else + { + Assert.DoesNotThrow(handle.Dispose); + } - // now lock can be re-acquired - Assert.IsFalse(nonAmbientTransactionLock.IsHeld()); + nonAmbientTransactionLock.IsHeld() + // explicit participation will fail to release above, so it is still held + .ShouldEqual(transactionSupport == TransactionSupport.ExplicitParticipation ? true : false); - if (this._lockProvider.Strategy.Db.SupportsTransactionScopedSynchronization) + if (transactionSupport == TransactionSupport.ImplicitParticipation) { - Assert.Catch(() => ambientTransactionLock.Acquire()); + // If we use transactions implicitly then we can keep using our lock without issue + // because we're just using the underlyign connection which is still good. + Assert.DoesNotThrow(() => ambientTransactionLock.Acquire().Dispose()); } else { - Assert.DoesNotThrow(() => ambientTransactionLock.Acquire().Dispose()); + // Otherwise we'll fail to use a transaction that has been ended + Assert.Catch(() => ambientTransactionLock.Acquire()); } } } diff --git a/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs b/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs index d9221394..d8bed0de 100644 --- a/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/Data/OwnedTransactionStrategyTestCases.cs @@ -33,6 +33,14 @@ public abstract class OwnedTransactionStrategyTestCases [Test] public void TestIsolationLevelLeakage() { + // Needed because MySQL has RepeatableRead while SqlServer and Postgres have ReadCommitted + IsolationLevel defaultIsolationLevel; + using (var connection = this._lockProvider.Strategy.Db.CreateConnection()) + { + connection.Open(); + defaultIsolationLevel = this._lockProvider.Strategy.Db.GetIsolationLevel(connection); + } + // Pre-generate the lock we will use. This is necessary for our Semaphore5 strategy, where the first lock created // takes 4 of the 5 tickets (and thus may need more connections than a single-connection pool can support). For other // lock types this does nothing since creating a lock might open a connection but otherwise won't run any commands @@ -55,7 +63,7 @@ void AssertHasDefaultIsolationLevel() { using var connection = this._lockProvider.Strategy.Db.CreateConnection(); connection.Open(); - this._lockProvider.Strategy.Db.GetIsolationLevel(connection).ShouldEqual(IsolationLevel.ReadCommitted); + this._lockProvider.Strategy.Db.GetIsolationLevel(connection).ShouldEqual(defaultIsolationLevel); } } } diff --git a/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs index bd73b2d3..4b2ab692 100644 --- a/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs @@ -252,8 +252,8 @@ public async Task TestLockNamesAreCaseSensitive() else { // otherwise, check that the names still contain the suffixes we added - Assert.That(lowerName, Does.Contain(lowerBaseName)); - Assert.That(upperName, Does.Contain(upperBaseName)); + Assert.IsTrue(lowerName.IndexOf(lowerBaseName, StringComparison.OrdinalIgnoreCase) >= 0); + Assert.IsTrue(upperName.IndexOf(upperBaseName, StringComparison.OrdinalIgnoreCase) >= 0); } } diff --git a/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs b/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs index e02621b8..9703ab0c 100644 --- a/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs +++ b/DistributedLock.Tests/Infrastructure/Data/ITestingDb.cs @@ -19,7 +19,7 @@ public interface ITestingDb int MaxApplicationNameLength { get; } - bool SupportsTransactionScopedSynchronization { get; } + TransactionSupport TransactionSupport { get; } DbConnection CreateConnection(); @@ -30,6 +30,24 @@ public interface ITestingDb 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 diff --git a/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs b/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs new file mode 100644 index 00000000..5fd6c5e9 --- /dev/null +++ b/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlDb.cs @@ -0,0 +1,107 @@ +using Medallion.Threading.Tests.Data; +using MySqlConnector; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Medallion.Threading.Tests.MySql +{ + public class TestingMySqlDb : ITestingPrimaryClientDb + { + private readonly string _defaultConnectionString; + private readonly MySqlConnectionStringBuilder _connectionStringBuilder; + + public TestingMySqlDb() : + this(MySqlCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory)) + { + } + + protected TestingMySqlDb(string defaultConnectionString) + { + this._defaultConnectionString = defaultConnectionString; + this._connectionStringBuilder = new MySqlConnectionStringBuilder(this._defaultConnectionString); + } + + public DbConnectionStringBuilder ConnectionStringBuilder => this._connectionStringBuilder; + + public int MaxPoolSize + { + get => (int)this._connectionStringBuilder.MaximumPoolSize; + set => this._connectionStringBuilder.MaximumPoolSize = (uint)value; + } + + public int MaxApplicationNameLength => 65390; // based on empirical testing + + public TransactionSupport TransactionSupport => TransactionSupport.ExplicitParticipation; + + protected virtual string IsolationLevelVariableName => "transaction_isolation"; + + public void ClearPool(DbConnection connection) => MySqlConnection.ClearPool((MySqlConnection)connection); + + public int CountActiveSessions(string applicationName) + { + using var connection = new MySqlConnection(this._defaultConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM performance_schema.session_connect_attrs WHERE ATTR_NAME = 'program_name' AND ATTR_VALUE = @applicationName"; + command.Parameters.AddWithValue(nameof(applicationName), applicationName); + return (int)(long)command.ExecuteScalar()!; + } + + public DbConnection CreateConnection() => new MySqlConnection(this.ConnectionStringBuilder.ConnectionString); + + public IsolationLevel GetIsolationLevel(DbConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT @@" + this.IsolationLevelVariableName; + var rawIsolationLevel = (string)command.ExecuteScalar()!; + return (IsolationLevel)Enum.Parse(typeof(IsolationLevel), rawIsolationLevel.Replace("-", string.Empty), ignoreCase: true); + } + + public async Task KillSessionsAsync(string applicationName, DateTimeOffset? idleSince = null) + { + var minTimeSeconds = idleSince.HasValue + ? (int?)(DateTimeOffset.UtcNow - idleSince.Value).TotalSeconds + : null; + + using var connection = new MySqlConnection(this._defaultConnectionString); + await connection.OpenAsync(); + using var idleSessionsCommand = connection.CreateCommand(); + idleSessionsCommand.CommandText = @" + SELECT a.PROCESSLIST_ID + FROM performance_schema.session_connect_attrs a + JOIN information_schema.processlist p + ON p.ID = a.PROCESSLIST_ID + WHERE a.ATTR_NAME = 'program_name' + AND a.ATTR_VALUE = @applicationName + AND (@minTimeSeconds IS NULL OR p.TIME > @minTimeSeconds)"; + idleSessionsCommand.Parameters.AddWithValue(nameof(applicationName), applicationName); + idleSessionsCommand.Parameters.AddWithValue(nameof(minTimeSeconds), minTimeSeconds); + + var idsToKill = new List(); + await using (var reader = await idleSessionsCommand.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) { idsToKill.Add(reader.GetInt32(0)); } + } + + foreach (var idToKill in idsToKill) + { + using var killCommand = connection.CreateCommand(); + killCommand.CommandText = $"KILL {idToKill}"; + await killCommand.ExecuteNonQueryAsync(); + } + } + } + + public sealed class TestingMariaDbDb : TestingMySqlDb + { + public TestingMariaDbDb() : base(MariaDbCredentials.GetConnectionString(TestContext.CurrentContext.TestDirectory)) { } + + protected override string IsolationLevelVariableName => "tx_isolation"; + } +} diff --git a/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlProviders.cs b/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlProviders.cs new file mode 100644 index 00000000..6aa82b16 --- /dev/null +++ b/DistributedLock.Tests/Infrastructure/MySql/TestingMySqlProviders.cs @@ -0,0 +1,31 @@ +using Medallion.Threading.MySql; +using Medallion.Threading.Tests.Data; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Medallion.Threading.Tests.MySql +{ + public sealed class TestingMySqlDistributedLockProvider : TestingLockProvider + where TStrategy : TestingDbSynchronizationStrategy, new() + where TDb : TestingMySqlDb, new() + { + public override IDistributedLock CreateLockWithExactName(string name) => + this.Strategy.GetConnectionOptions() + .Create( + (connectionString, options) => new MySqlDistributedLock(name, connectionString, options: ToMySqlOptions(options)), + connection => new MySqlDistributedLock(name, connection, exactName: true), + transaction => new MySqlDistributedLock(name, transaction, exactName: true) + ); + + public override string GetSafeName(string name) => new MySqlDistributedLock(name, new TDb().ConnectionStringBuilder.ConnectionString).Name; + + public override string GetCrossProcessLockType() => (typeof(TDb) == typeof(TestingMariaDbDb) ? "MariaDB" : string.Empty) + base.GetCrossProcessLockType(); + + internal static Action ToMySqlOptions((bool useMultiplexing, bool useTransaction, TimeSpan? keepaliveCadence) options) => o => + { + o.UseMultiplexing(options.useMultiplexing); + if (options.keepaliveCadence is { } keepaliveCadence) { o.KeepaliveCadence(keepaliveCadence); } + }; + } +} diff --git a/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs b/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs index c4d221b3..f1823e92 100644 --- a/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs +++ b/DistributedLock.Tests/Infrastructure/Postgres/TestingPostgresDb.cs @@ -30,7 +30,7 @@ public sealed class TestingPostgresDb : ITestingPrimaryClientDb /// abort semantics and largely unnecessary for our purposes since, unlike SQLServer, a connection-scoped Postgres lock can still /// participate in an ongoing transaction. /// - public bool SupportsTransactionScopedSynchronization => false; + public TransactionSupport TransactionSupport => TransactionSupport.ImplicitParticipation; public void ClearPool(DbConnection connection) => NpgsqlConnection.ClearPool((NpgsqlConnection)connection); diff --git a/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs b/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs new file mode 100644 index 00000000..28061afd --- /dev/null +++ b/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs @@ -0,0 +1,45 @@ +using MySqlConnector; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Medallion.Threading.Tests +{ + internal static class MariaDbCredentials + { + // MARIADB SETUP NOTE + // + // In order to enable application name tracking, we mus enable the performance schema. Add the following to + // C:\Program Files\MariaDB 10.6\data\my.ini in the [mysqld] section + // + // ;from https://mariadb.com/kb/en/performance-schema-overview/#activating-the-performance-schema + // performance_schema=ON + + private static (string username, string password) GetCredentials(string baseDirectory) + { + var file = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "credentials", "mariadb.txt")); + if (!File.Exists(file)) { throw new InvalidOperationException($"Unable to find MariaDB credentials file {file}"); } + var lines = File.ReadAllLines(file); + if (lines.Length != 2) { throw new FormatException($"{file} must contain exactly 2 lines of text"); } + return (lines[0], lines[1]); + } + + public static string GetConnectionString(string baseDirectory) + { + var (username, password) = GetCredentials(baseDirectory); + + return new MySqlConnectionStringBuilder + { + Port = 3307, + Server = "localhost", + Database = "mysql", + UserID = username, + Password = password, + PersistSecurityInfo = true, + // set a high pool size so that we don't empty the pool through things like lock abandonment tests + MaximumPoolSize = 500, + }.ConnectionString; + } + } +} diff --git a/DistributedLock.Tests/Infrastructure/Shared/MySqlCredentials.cs b/DistributedLock.Tests/Infrastructure/Shared/MySqlCredentials.cs new file mode 100644 index 00000000..57365582 --- /dev/null +++ b/DistributedLock.Tests/Infrastructure/Shared/MySqlCredentials.cs @@ -0,0 +1,39 @@ +using MySqlConnector; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Medallion.Threading.Tests +{ + internal static class MySqlCredentials + { + private static (string username, string password) GetCredentials(string baseDirectory) + { + var file = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "credentials", "mysql.txt")); + if (!File.Exists(file)) { throw new InvalidOperationException($"Unable to find mysql credentials file {file}"); } + var lines = File.ReadAllLines(file); + if (lines.Length != 2) { throw new FormatException($"{file} must contain exactly 2 lines of text"); } + return (lines[0], lines[1]); + } + + public static string GetConnectionString(string baseDirectory) + { + var (username, password) = GetCredentials(baseDirectory); + + return new MySqlConnectionStringBuilder + { + Port = 3306, + Server = "localhost", + Database = "mysql", + UserID = username, + Password = password, + PersistSecurityInfo = true, + // set a high pool size so that we don't empty the pool through things like lock abandonment tests + MaximumPoolSize = 500, + }.ConnectionString; + } + } +} diff --git a/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs b/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs index a2dca553..ed57d0a4 100644 --- a/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs +++ b/DistributedLock.Tests/Infrastructure/SqlServer/TestingSqlServerDb.cs @@ -25,7 +25,7 @@ public sealed class TestingSqlServerDb : ITestingSqlServerDb, ITestingPrimaryCli // https://stackoverflow.com/questions/5808332/sql-server-maximum-character-length-of-object-names/41502228 public int MaxApplicationNameLength => 128; - public bool SupportsTransactionScopedSynchronization => true; + public TransactionSupport TransactionSupport => TransactionSupport.TransactionScoped; public void ClearPool(DbConnection connection) => Microsoft.Data.SqlClient.SqlConnection.ClearPool((Microsoft.Data.SqlClient.SqlConnection)connection); @@ -110,7 +110,7 @@ public sealed class TestingSystemDataSqlServerDb : ITestingSqlServerDb public int MaxApplicationNameLength => new TestingSqlServerDb().MaxApplicationNameLength; - public bool SupportsTransactionScopedSynchronization => true; + public TransactionSupport TransactionSupport => TransactionSupport.TransactionScoped; public void ClearPool(DbConnection connection) => System.Data.SqlClient.SqlConnection.ClearPool((System.Data.SqlClient.SqlConnection)connection); diff --git a/DistributedLock.Tests/Tests/CombinatorialTests.cs b/DistributedLock.Tests/Tests/CombinatorialTests.cs index 016ec4f9..1c322518 100644 --- a/DistributedLock.Tests/Tests/CombinatorialTests.cs +++ b/DistributedLock.Tests/Tests/CombinatorialTests.cs @@ -16,6 +16,40 @@ namespace Medallion.Threading.Tests.FileSystem [Category("CI")] public class Core_File_FileSynchronizationStrategyTest : DistributedLockCoreTestCases { } } +namespace Medallion.Threading.Tests.MySql +{ + public class ConnectionStringStrategy_MySql_ConnectionMultiplexingSynchronizationStrategy_MariaDbDb_MariaDbDb_ConnectionMultiplexingSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ConnectionStringStrategyTestCases, TestingMariaDbDb>, TestingConnectionMultiplexingSynchronizationStrategy, TestingMariaDbDb> { } + public class ConnectionStringStrategy_MySql_ConnectionMultiplexingSynchronizationStrategy_MySqlDb_MySqlDb_ConnectionMultiplexingSynchronizationStrategy_MySqlDb_MySqlDbTest : ConnectionStringStrategyTestCases, TestingMySqlDb>, TestingConnectionMultiplexingSynchronizationStrategy, TestingMySqlDb> { } + public class ConnectionStringStrategy_MySql_OwnedConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_OwnedConnectionSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ConnectionStringStrategyTestCases, TestingMariaDbDb>, TestingOwnedConnectionSynchronizationStrategy, TestingMariaDbDb> { } + public class ConnectionStringStrategy_MySql_OwnedConnectionSynchronizationStrategy_MySqlDb_MySqlDb_OwnedConnectionSynchronizationStrategy_MySqlDb_MySqlDbTest : ConnectionStringStrategyTestCases, TestingMySqlDb>, TestingOwnedConnectionSynchronizationStrategy, TestingMySqlDb> { } + public class ConnectionStringStrategy_MySql_OwnedTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_OwnedTransactionSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ConnectionStringStrategyTestCases, TestingMariaDbDb>, TestingOwnedTransactionSynchronizationStrategy, TestingMariaDbDb> { } + public class ConnectionStringStrategy_MySql_OwnedTransactionSynchronizationStrategy_MySqlDb_MySqlDb_OwnedTransactionSynchronizationStrategy_MySqlDb_MySqlDbTest : ConnectionStringStrategyTestCases, TestingMySqlDb>, TestingOwnedTransactionSynchronizationStrategy, TestingMySqlDb> { } + public class Core_MySql_ConnectionMultiplexingSynchronizationStrategy_MariaDbDb_MariaDbDb_ConnectionMultiplexingSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingConnectionMultiplexingSynchronizationStrategy> { } + public class Core_MySql_ConnectionMultiplexingSynchronizationStrategy_MySqlDb_MySqlDb_ConnectionMultiplexingSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingConnectionMultiplexingSynchronizationStrategy> { } + public class Core_MySql_ExternalConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_ExternalConnectionSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingExternalConnectionSynchronizationStrategy> { } + public class Core_MySql_ExternalConnectionSynchronizationStrategy_MySqlDb_MySqlDb_ExternalConnectionSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingExternalConnectionSynchronizationStrategy> { } + public class Core_MySql_ExternalTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_ExternalTransactionSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingExternalTransactionSynchronizationStrategy> { } + public class Core_MySql_ExternalTransactionSynchronizationStrategy_MySqlDb_MySqlDb_ExternalTransactionSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingExternalTransactionSynchronizationStrategy> { } + public class Core_MySql_OwnedConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_OwnedConnectionSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingOwnedConnectionSynchronizationStrategy> { } + public class Core_MySql_OwnedConnectionSynchronizationStrategy_MySqlDb_MySqlDb_OwnedConnectionSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingOwnedConnectionSynchronizationStrategy> { } + public class Core_MySql_OwnedTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_OwnedTransactionSynchronizationStrategy_MariaDbDbTest : DistributedLockCoreTestCases, TestingMariaDbDb>, TestingOwnedTransactionSynchronizationStrategy> { } + public class Core_MySql_OwnedTransactionSynchronizationStrategy_MySqlDb_MySqlDb_OwnedTransactionSynchronizationStrategy_MySqlDbTest : DistributedLockCoreTestCases, TestingMySqlDb>, TestingOwnedTransactionSynchronizationStrategy> { } + public class ExternalConnectionOrTransactionStrategy_MySql_ExternalConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_ExternalConnectionSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingMariaDbDb>, TestingExternalConnectionSynchronizationStrategy, TestingMariaDbDb> { } + public class ExternalConnectionOrTransactionStrategy_MySql_ExternalConnectionSynchronizationStrategy_MySqlDb_MySqlDb_ExternalConnectionSynchronizationStrategy_MySqlDb_MySqlDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingMySqlDb>, TestingExternalConnectionSynchronizationStrategy, TestingMySqlDb> { } + public class ExternalConnectionOrTransactionStrategy_MySql_ExternalTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_ExternalTransactionSynchronizationStrategy_MariaDbDb_MariaDbDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingMariaDbDb>, TestingExternalTransactionSynchronizationStrategy, TestingMariaDbDb> { } + public class ExternalConnectionOrTransactionStrategy_MySql_ExternalTransactionSynchronizationStrategy_MySqlDb_MySqlDb_ExternalTransactionSynchronizationStrategy_MySqlDb_MySqlDbTest : ExternalConnectionOrTransactionStrategyTestCases, TestingMySqlDb>, TestingExternalTransactionSynchronizationStrategy, TestingMySqlDb> { } + public class ExternalConnectionStrategy_MySql_ExternalConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : ExternalConnectionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class ExternalConnectionStrategy_MySql_ExternalConnectionSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : ExternalConnectionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } + public class ExternalTransactionStrategy_MySql_ExternalTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : ExternalTransactionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class ExternalTransactionStrategy_MySql_ExternalTransactionSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : ExternalTransactionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } + public class MultiplexingConnectionStrategy_MySql_ConnectionMultiplexingSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : MultiplexingConnectionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class MultiplexingConnectionStrategy_MySql_ConnectionMultiplexingSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : MultiplexingConnectionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } + public class OwnedConnectionStrategy_MySql_OwnedConnectionSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : OwnedConnectionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class OwnedConnectionStrategy_MySql_OwnedConnectionSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : OwnedConnectionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } + public class OwnedTransactionStrategy_MySql_OwnedTransactionSynchronizationStrategy_MariaDbDb_MariaDbDb_MariaDbDbTest : OwnedTransactionStrategyTestCases, TestingMariaDbDb>, TestingMariaDbDb> { } + public class OwnedTransactionStrategy_MySql_OwnedTransactionSynchronizationStrategy_MySqlDb_MySqlDb_MySqlDbTest : OwnedTransactionStrategyTestCases, TestingMySqlDb>, TestingMySqlDb> { } +} + namespace Medallion.Threading.Tests.Postgres { public class ConnectionStringStrategy_Postgres_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_ConnectionMultiplexingSynchronizationStrategy_PostgresDb_PostgresDbTest : ConnectionStringStrategyTestCases>, TestingConnectionMultiplexingSynchronizationStrategy, TestingPostgresDb> { } diff --git a/DistributedLock.Tests/Tests/MySql/MySqlConnectionOptionsBuilderTest.cs b/DistributedLock.Tests/Tests/MySql/MySqlConnectionOptionsBuilderTest.cs new file mode 100644 index 00000000..c543bb62 --- /dev/null +++ b/DistributedLock.Tests/Tests/MySql/MySqlConnectionOptionsBuilderTest.cs @@ -0,0 +1,28 @@ +using Medallion.Threading.MySql; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Medallion.Threading.Tests.MySql +{ + public class MySqlConnectionOptionsBuilderTest + { + [Test] + public void TestValidatesArguments() + { + var builder = new MySqlConnectionOptionsBuilder(); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.FromMilliseconds(-2))); + Assert.Throws(() => builder.KeepaliveCadence(TimeSpan.MaxValue)); + } + + [Test] + public void TestDefaults() + { + var options = MySqlConnectionOptionsBuilder.GetOptions(null); + options.keepaliveCadence.ShouldEqual(TimeSpan.FromHours(3.5)); + Assert.IsTrue(options.useMultiplexing); + options.ShouldEqual(MySqlConnectionOptionsBuilder.GetOptions(o => { })); + } + } +} diff --git a/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs b/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs new file mode 100644 index 00000000..e8207b1e --- /dev/null +++ b/DistributedLock.Tests/Tests/MySql/MySqlDistributedLockTest.cs @@ -0,0 +1,77 @@ +using Medallion.Threading.MySql; +using Medallion.Threading.Tests.Data; +using MySqlConnector; +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 MySqlDistributedLockTest + { + private static readonly string ConnectionString = new TestingMySqlDb().ConnectionStringBuilder.ConnectionString; + + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Catch(() => new MySqlDistributedLock(null!, ConnectionString)); + Assert.Catch(() => new MySqlDistributedLock(null!, ConnectionString, exactName: true)); + Assert.Catch(() => new MySqlDistributedLock("a", default(string)!)); + Assert.Catch(() => new MySqlDistributedLock("a", default(IDbTransaction)!)); + Assert.Catch(() => new MySqlDistributedLock("a", default(IDbConnection)!)); + Assert.Catch(() => new MySqlDistributedLock(new string('a', MySqlDistributedLock.MaxNameLength + 1), ConnectionString, exactName: true)); + Assert.DoesNotThrow(() => new MySqlDistributedLock(new string('a', MySqlDistributedLock.MaxNameLength), ConnectionString, exactName: true)); + } + + [Test] + public void TestGetSafeLockNameCompat() + { + GetSafeName(string.Empty).ShouldEqual("__empty__p6ad62yppho33ytkibum5wbqhqvbcsxa"); + GetSafeName("abc").ShouldEqual("abc"); + GetSafeName("ABC").ShouldEqual("abczj4qr6tvn4a3kmgq4bukhowqyfrlxsb3"); + GetSafeName("\\").ShouldEqual("\\"); + GetSafeName(new string('a', MySqlDistributedLock.MaxNameLength)).ShouldEqual(new string('a', MySqlDistributedLock.MaxNameLength)); + GetSafeName(new string('\\', MySqlDistributedLock.MaxNameLength)).ShouldEqual(@"\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"); + GetSafeName(new string('x', MySqlDistributedLock.MaxNameLength + 1)).ShouldEqual("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxgkd2zq6c6ey6mhs45clqg7vij6ycgo43"); + + static string GetSafeName(string name) => new MySqlDistributedLock(name, ConnectionString).Name; + } + + /// + /// This test justifies why we have constructors for MySQL locks that take in a . + /// Otherwise, you can't have a lock use the same connection as a transaction you're working on. Compare to + /// + /// + [TestCase(typeof(TestingMySqlDb))] + [TestCase(typeof(TestingMariaDbDb))] + public async Task TestMySqlCommandMustExplicitlyParticipateInTransaction(Type testingDbType) + { + var db = (ITestingDb)Activator.CreateInstance(testingDbType)!; + + using var connection = new MySqlConnection(db.ConnectionStringBuilder.ConnectionString); + await connection.OpenAsync(); + + using var createTableCommand = connection.CreateCommand(); + createTableCommand.CommandText = "CREATE TEMPORARY TABLE world.temp (id INT)"; + await createTableCommand.ExecuteNonQueryAsync(); + + using var transaction = connection.BeginTransaction(); + + using var commandInTransaction = connection.CreateCommand(); + commandInTransaction.Transaction = transaction; + commandInTransaction.CommandText = @"INSERT INTO world.temp (id) VALUES (1), (2)"; + await commandInTransaction.ExecuteNonQueryAsync(); + + using var commandOutsideTransaction = connection.CreateCommand(); + commandOutsideTransaction.CommandText = "SELECT COUNT(*) FROM world.temp"; + var exception = Assert.ThrowsAsync(() => commandOutsideTransaction.ExecuteScalarAsync()); + Assert.That(exception.Message, Does.Contain("The transaction associated with this command is not the connection's active transaction")); + + commandInTransaction.CommandText = "SELECT COUNT(*) FROM world.temp"; + (await commandInTransaction.ExecuteScalarAsync()).ShouldEqual(2); + } + } +} diff --git a/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs b/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs index cb915692..3ec6a0c7 100644 --- a/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs +++ b/DistributedLock.Tests/Tests/Postgres/PostgresDistributedLockTest.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using System; using System.Collections.Generic; +using System.Data; using System.Data.Common; using System.Linq; using System.Text; @@ -13,6 +14,13 @@ namespace Medallion.Threading.Tests.Postgres { public class PostgresDistributedLockTest { + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Throws(() => new PostgresDistributedLock(new(0), default(string)!)); + Assert.Throws(() => new PostgresDistributedLock(new(0), default(IDbConnection)!)); + } + [Test] public async Task TestInt64AndInt32PairKeyNamespacesAreDifferent() { diff --git a/DistributedLock.Tests/Tests/Postgres/PostgresDistributedReaderWriterLockTest.cs b/DistributedLock.Tests/Tests/Postgres/PostgresDistributedReaderWriterLockTest.cs new file mode 100644 index 00000000..821f4662 --- /dev/null +++ b/DistributedLock.Tests/Tests/Postgres/PostgresDistributedReaderWriterLockTest.cs @@ -0,0 +1,21 @@ +using Medallion.Threading.Postgres; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Medallion.Threading.Tests.Postgres +{ + public class PostgresDistributedReaderWriterLockTest + { + [Test] + public void TestValidatesConstructorArguments() + { + Assert.Throws(() => new PostgresDistributedReaderWriterLock(new(0), default(string)!)); + Assert.Throws(() => new PostgresDistributedReaderWriterLock(new(0), default(IDbConnection)!)); + } + } +} diff --git a/DistributedLockTaker/Program.cs b/DistributedLockTaker/Program.cs index 81bdc5b1..e59af60b 100644 --- a/DistributedLockTaker/Program.cs +++ b/DistributedLockTaker/Program.cs @@ -14,6 +14,7 @@ using System.Drawing.Text; using System.Collections.Generic; using Medallion.Threading.ZooKeeper; +using Medallion.Threading.MySql; #if NET471 using System.Data.SqlClient; #elif NETCOREAPP3_1 @@ -49,6 +50,12 @@ public static int Main(string[] args) case "Write" + nameof(PostgresDistributedReaderWriterLock): handle = new PostgresDistributedReaderWriterLock(new PostgresAdvisoryLockKey(name), PostgresCredentials.GetConnectionString(Environment.CurrentDirectory)).AcquireWriteLock(); break; + case nameof(MySqlDistributedLock): + handle = new MySqlDistributedLock(name, MySqlCredentials.GetConnectionString(Environment.CurrentDirectory)).Acquire(); + break; + case "MariaDB" + nameof(MySqlDistributedLock): + handle = new MySqlDistributedLock(name, MariaDbCredentials.GetConnectionString(Environment.CurrentDirectory)).Acquire(); + break; case nameof(EventWaitHandleDistributedLock): handle = new EventWaitHandleDistributedLock(name).Acquire(); break; From a41378497292148f58601d71bd72b9d4640e593b Mon Sep 17 00:00:00 2001 From: Michael Adelson Date: Tue, 24 Aug 2021 20:53:04 -0400 Subject: [PATCH 3/8] Bump distributedlock.core version --- DistributedLock.Core/DistributedLock.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DistributedLock.Core/DistributedLock.Core.csproj b/DistributedLock.Core/DistributedLock.Core.csproj index 2523c9a4..258f24cf 100644 --- a/DistributedLock.Core/DistributedLock.Core.csproj +++ b/DistributedLock.Core/DistributedLock.Core.csproj @@ -10,7 +10,7 @@ - 1.0.2 + 1.0.3 1.0.0.0 Michael Adelson Core interfaces and utilities that support the DistributedLock.* family of packages From d8b68d6fea118df8946da201f423191c5f0808a9 Mon Sep 17 00:00:00 2001 From: Michael Adelson Date: Wed, 25 Aug 2021 06:47:06 -0400 Subject: [PATCH 4/8] Minor cleanup --- .../Data/DedicatedConnectionOrTransactionDbDistributedLock.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs b/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs index 78e2f0a3..5c235a38 100644 --- a/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs +++ b/DistributedLock.Core/Internal/Data/DedicatedConnectionOrTransactionDbDistributedLock.cs @@ -141,7 +141,7 @@ public ValueTask DisposeAsync() private sealed class InnerHandle : IAsyncDisposable { - private static readonly object DisposedSentinel = new object(); + private static readonly object DisposedSentinel = new(); private readonly IDbSynchronizationStrategy _strategy; private readonly string _name; From fe2959638405c17dc4295fb670964815295bedeb Mon Sep 17 00:00:00 2001 From: Michael Adelson Date: Wed, 25 Aug 2021 07:04:11 -0400 Subject: [PATCH 5/8] Update docs for MySQL --- README.md | 3 +++ docs/DistributedLock.MySql.md | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 docs/DistributedLock.MySql.md diff --git a/README.md b/README.md index 32f3c35c..a8aac546 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,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.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 @@ -131,6 +132,8 @@ 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.2.0 + - Added MySQL/MariaDB-based implementation ([#95](https://github.com/madelson/DistributedLock/issues/95), DistributedLock.MySql 1.0.0) - 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.MySql.md b/docs/DistributedLock.MySql.md new file mode 100644 index 00000000..1dbcd14d --- /dev/null +++ b/docs/DistributedLock.MySql.md @@ -0,0 +1,36 @@ +# DistributedLock.MySql + +[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.MySql) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.MySql.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.MySql/) + +The DistributedLock.MySql package offers distributed synchronization primitives based on [MySQL/MariaDB user locks](https://dev.mysql.com/doc/refman/5.7/en/locking-functions.html). For example: + +```C# +var @lock = new MySqlDistributedLock("mylockname"), connectionString); +await using (await @lock.AcquireAsync()) +{ + // I have the lock +} +``` + +## APIs + +- The `MySqlDistributedLock` class implements the `IDistributedLock` interface. +- The `MySqlDistributedSynchronizationProvider` class implements the `IDistributedLockProvider` and `IDistributedReaderWriterLockProvider` interfaces. + +## Implementation notes + +MySQL-based locks have been tested against and work with both the [MySQL](https://www.mysql.com/) and [MariaDB](https://mariadb.org/). + +MySQL-based locks locks can be constructed with a `connectionString`, an `IDbConnection` or an `IDbTransaction` as a means of connecting to the database. In most cases, using a `connectionString` is preferred because it allows for the library to efficiently multiplex connections under the hood and eliminates the risk that the passed-in `IDbConnection` gets used in a way that disrupts the locking process. Using an `IDbTransaction` is generally equivalent to using an `IDbConnection` (the lock is still connection-scoped), but it allows the lock to participate in an ongoing transaction. + +Natively, MySQL's locking functions are case-insensitive with respect to the lock name. Since the DistributedLock library as a whole uses case-sensitive names, lock names containing uppercase characters will be transformed/hashed under the hood (as will empty names or names that are too long). If your program needs to coordinate with other code that is using `GET_LOCK` directly, be sure to express the name in lower case and to pass `exactName: true` when constructing the lock instance (in `exactName` mode, an invalid name will throw an exception rather than silently being transformed into a valid one). + +## Options + +In addition to specifying the `name`, several tuning options are available for `connectionString`-based locks: + +- `KeepaliveCadence` allows you to have the implementation periodically issue a cheap query on a connection holding a lock. This helps in configurations which are set up to aggressively kill idle connections. Defaults to OFF (`Timeout.InfiniteTimeSpan`). +- `UseMultiplexing` allows the implementation to re-use connections under the hood to hold multiple locks under certain scenarios, leading to lower resource consumption. This behavior defaults to ON; you should not disable it unless you suspect that it is causing issues for you (please file an issue here if so!). + + + From eab0ae755f3cfb760fdb4e851a705363cb10bda8 Mon Sep 17 00:00:00 2001 From: Michael Adelson Date: Sat, 2 Oct 2021 09:46:03 -0400 Subject: [PATCH 6/8] Bump DistributedLock version --- DistributedLock/DistributedLock.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DistributedLock/DistributedLock.csproj b/DistributedLock/DistributedLock.csproj index 65cb3b98..c7c31000 100644 --- a/DistributedLock/DistributedLock.csproj +++ b/DistributedLock/DistributedLock.csproj @@ -10,7 +10,7 @@ - 2.1.0 + 2.2.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. From fe507d8dafd37b9ab3ac5f2d5b919b6b9d723bf5 Mon Sep 17 00:00:00 2001 From: Michael Adelson Date: Sat, 2 Oct 2021 10:25:24 -0400 Subject: [PATCH 7/8] Comment typo fix --- .../Infrastructure/Shared/MariaDbCredentials.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs b/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs index 28061afd..70ce51a6 100644 --- a/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs +++ b/DistributedLock.Tests/Infrastructure/Shared/MariaDbCredentials.cs @@ -10,7 +10,7 @@ internal static class MariaDbCredentials { // MARIADB SETUP NOTE // - // In order to enable application name tracking, we mus enable the performance schema. Add the following to + // In order to enable application name tracking, we must enable the performance schema. Add the following to // C:\Program Files\MariaDB 10.6\data\my.ini in the [mysqld] section // // ;from https://mariadb.com/kb/en/performance-schema-overview/#activating-the-performance-schema From ae60a27a36d8a505b4912f3bd5a02c1ff76b6809 Mon Sep 17 00:00:00 2001 From: Michael Adelson Date: Sat, 2 Oct 2021 10:27:56 -0400 Subject: [PATCH 8/8] Improve TestParallelism error message on AppVeyor --- .../AbstractTestCases/DistributedLockCoreTestCases.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs b/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs index 4b2ab692..dfba0e85 100644 --- a/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs +++ b/DistributedLock.Tests/AbstractTestCases/DistributedLockCoreTestCases.cs @@ -182,7 +182,7 @@ public void TestParallelism() // increment going in if (Interlocked.Increment(ref counter) == 2) { - Assert.Fail("Concurrent lock acquisitions"); + Assert.Fail($"Concurrent lock acquisitions ({this.GetType()}"); } // hang out for a bit to ensure concurrency