diff --git a/src/OrchardCoreContrib.Garnet/Manifest.cs b/src/OrchardCoreContrib.Garnet/Manifest.cs index 19a7600..c95f913 100644 --- a/src/OrchardCoreContrib.Garnet/Manifest.cs +++ b/src/OrchardCoreContrib.Garnet/Manifest.cs @@ -38,3 +38,11 @@ Dependencies = ["OrchardCoreContrib.Garnet"], Category = "Distributed Caching" )] + +[assembly: Feature( + Id = "OrchardCoreContrib.Garnet.Lock", + Name = "Garnet Lock", + Description = "Distributed Lock using Garnet.", + Dependencies = ["OrchardCoreContrib.Garnet"], + Category = "Distributed Caching" +)] diff --git a/src/OrchardCoreContrib.Garnet/Services/GarnetLock.cs b/src/OrchardCoreContrib.Garnet/Services/GarnetLock.cs new file mode 100644 index 0000000..c63814e --- /dev/null +++ b/src/OrchardCoreContrib.Garnet/Services/GarnetLock.cs @@ -0,0 +1,239 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Environment.Shell; +using OrchardCore.Locking; +using OrchardCore.Locking.Distributed; +using StackExchange.Redis; +using System.Diagnostics; +using System.Net; + +namespace OrchardCoreContrib.Garnet.Services; + +/// +/// Represents a distributed lock implementation based on Garnet service. +/// +/// The . +/// The . +/// The . +/// The . +public class GarnetLock( + IGarnetService garnetService, + IOptions garnetOptions, + ShellSettings shellSettings, + ILogger logger) : IDistributedLock +{ + private static readonly double _baseDelay = 100; + private static readonly double _maxDelay = 10000; + + private readonly GarnetOptions _garnetOptions = garnetOptions.Value; + private readonly string _hostName = Dns.GetHostName() + ':' + Environment.ProcessId; + private readonly string _prefix = garnetService.InstancePrefix + shellSettings.Name + ':'; + + /// + /// Waits indefinitely until acquiring a named lock with a given expiration for the current tenant + /// + /// The key. + /// The expiration time for the lock. + public async Task AcquireLockAsync(string key, TimeSpan? expiration = null) + => (await TryAcquireLockAsync(key, TimeSpan.MaxValue, expiration)).locker; + + /// + /// Tries to acquire a named lock in a given timeout with a given expiration for the current tenant. + /// + /// The key. + /// The timeout for acquiring the lock. + /// The expiration time for the lock. + /// + public async Task<(ILocker locker, bool locked)> TryAcquireLockAsync(string key, TimeSpan timeout, TimeSpan? expiration = null) + { + using (var cts = new CancellationTokenSource(timeout != TimeSpan.MaxValue ? timeout : Timeout.InfiniteTimeSpan)) + { + var retries = 0.0; + + while (!cts.IsCancellationRequested) + { + var locked = await LockAsync(key, expiration ?? TimeSpan.MaxValue); + + if (locked) + { + return (new Locker(this, key), locked); + } + + try + { + await Task.Delay(GetDelay(++retries), cts.Token); + } + catch (TaskCanceledException) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Timeout elapsed before acquiring the named lock '{LockName}' after the given timeout of '{Timeout}'.", + _prefix + key, timeout.ToString()); + } + } + } + } + + return (null, false); + } + + public async Task IsLockAcquiredAsync(string key) + { + if (garnetService.Client == null) + { + await garnetService.ConnectAsync(); + + if (garnetService.Client == null) + { + logger.LogError("Fails to check whether the named lock '{LockName}' is already acquired.", _prefix + key); + + return false; + } + } + + try + { + var database = (await ConnectionMultiplexer + .ConnectAsync(GetConfigurationOptions(_garnetOptions))) + .GetDatabase(); + + return (await database.LockQueryAsync(_prefix + key)).HasValue; + } + catch (Exception e) + { + logger.LogError(e, "Fails to check whether the named lock '{LockName}' is already acquired.", _prefix + key); + } + + return false; + } + + private async Task LockAsync(string key, TimeSpan expiry) + { + if (garnetService.Client == null) + { + await garnetService.ConnectAsync(); + + if (garnetService.Client == null) + { + logger.LogError("Fails to acquire the named lock '{LockName}'.", _prefix + key); + + return false; + } + } + + try + { + var database = (await ConnectionMultiplexer + .ConnectAsync(GetConfigurationOptions(_garnetOptions))) + .GetDatabase(); + + return await database.LockTakeAsync(_prefix + key, _hostName, expiry); + } + catch (Exception e) + { + logger.LogError(e, "Fails to acquire the named lock '{LockName}'.", _prefix + key); + } + + return false; + } + + private async ValueTask ReleaseAsync(string key) + { + try + { + var database = (await ConnectionMultiplexer + .ConnectAsync(GetConfigurationOptions(_garnetOptions))) + .GetDatabase(); + + await database.LockReleaseAsync(_prefix + key, _hostName); + } + catch (Exception e) + { + logger.LogError(e, "Fails to release the named lock '{LockName}'.", _prefix + key); + } + } + + private void Release(string key) + { + try + { + var database = ConnectionMultiplexer + .ConnectAsync(GetConfigurationOptions(_garnetOptions)) + .GetAwaiter() + .GetResult() + .GetDatabase(); + + database.LockRelease(_prefix + key, _hostName); + } + catch (Exception e) + { + logger.LogError(e, "Fails to release the named lock '{LockName}'.", _prefix + key); + } + } + + private sealed class Locker(GarnetLock garnetLock, string key) : ILocker + { + private bool _disposed; + + public ValueTask DisposeAsync() + { + if (_disposed) + { + return default; + } + + _disposed = true; + + return garnetLock.ReleaseAsync(key); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + garnetLock.Release(key); + } + } + + private static TimeSpan GetDelay(double retries) + { + var delay = _baseDelay * (1.0 + ((Math.Pow(1.8, retries - 1.0) - 1.0) * (0.6 + new Random().NextDouble() * 0.4))); + + return TimeSpan.FromMilliseconds(Math.Min(delay, _maxDelay)); + } + + // TODO: Use explicit conversion operators to convert between GarnetOptions and ConfigurationOptions + private static ConfigurationOptions GetConfigurationOptions(GarnetOptions garnetOptions) + { + var endPoints = new EndPointCollection + { + new DnsEndPoint(garnetOptions.Host, garnetOptions.Port) + }; + var configOptions = new ConfigurationOptions + { + EndPoints = endPoints, + ConnectTimeout = (int)TimeSpan.FromSeconds(2).TotalMilliseconds, + SyncTimeout = (int)TimeSpan.FromSeconds(30).TotalMilliseconds, + AsyncTimeout = (int)TimeSpan.FromSeconds(30).TotalMilliseconds, + ReconnectRetryPolicy = new LinearRetry((int)TimeSpan.FromSeconds(10).TotalMilliseconds), + ConnectRetry = 5, + IncludeDetailInExceptions = true, + AbortOnConnectFail = true, + User = garnetOptions.UserName, + Password = garnetOptions.Password + }; + + if (Debugger.IsAttached) + { + configOptions.SyncTimeout = (int)TimeSpan.FromHours(2).TotalMilliseconds; + configOptions.AsyncTimeout = (int)TimeSpan.FromHours(2).TotalMilliseconds; + } + + return configOptions; + } +} diff --git a/src/OrchardCoreContrib.Garnet/Startups/GarnetLockStartup.cs b/src/OrchardCoreContrib.Garnet/Startups/GarnetLockStartup.cs new file mode 100644 index 0000000..ee29051 --- /dev/null +++ b/src/OrchardCoreContrib.Garnet/Startups/GarnetLockStartup.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; +using OrchardCoreContrib.Garnet.Services; + +namespace OrchardCoreContrib.Garnet; + +/// +/// Represensts a startup point to register the required services by Garnet lock feature. +/// +[Feature("OrchardCoreContrib.Garnet.Lock")] +public class GarnetLockStartup : StartupBase +{ + /// + public override void ConfigureServices(IServiceCollection services) + { + if (services.Any(d => d.ServiceType == typeof(IGarnetService))) + { + services.AddSingleton(); + } + } +}