using System.Data; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DistributedLocking; using Umbraco.Cms.Core.DistributedLocking.Exceptions; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Persistence.SqlServer.Services; /// /// SQL Server implementation of . /// public class SqlServerDistributedLockingMechanism : IDistributedLockingMechanism { private readonly IOptionsMonitor _connectionStrings; private readonly IOptionsMonitor _globalSettings; private readonly ILogger _logger; private readonly Lazy _scopeAccessor; // Hooray it's a circular dependency. /// /// Initializes a new instance of the class. /// public SqlServerDistributedLockingMechanism( ILogger logger, Lazy scopeAccessor, IOptionsMonitor globalSettings, IOptionsMonitor connectionStrings) { _logger = logger; _scopeAccessor = scopeAccessor; _globalSettings = globalSettings; _connectionStrings = connectionStrings; } /// public bool Enabled => _connectionStrings.CurrentValue.IsConnectionStringConfigured() && string.Equals(_connectionStrings.CurrentValue.ProviderName,Constants.ProviderName, StringComparison.InvariantCultureIgnoreCase); /// public IDistributedLock ReadLock(int lockId, TimeSpan? obtainLockTimeout = null) { obtainLockTimeout ??= _globalSettings.CurrentValue.DistributedLockingReadLockDefaultTimeout; return new SqlServerDistributedLock(this, lockId, DistributedLockType.ReadLock, obtainLockTimeout.Value); } /// public IDistributedLock WriteLock(int lockId, TimeSpan? obtainLockTimeout = null) { obtainLockTimeout ??= _globalSettings.CurrentValue.DistributedLockingWriteLockDefaultTimeout; return new SqlServerDistributedLock(this, lockId, DistributedLockType.WriteLock, obtainLockTimeout.Value); } private class SqlServerDistributedLock : IDistributedLock { private readonly SqlServerDistributedLockingMechanism _parent; private readonly TimeSpan _timeout; public SqlServerDistributedLock( SqlServerDistributedLockingMechanism parent, int lockId, DistributedLockType lockType, TimeSpan timeout) { _parent = parent; _timeout = timeout; LockId = lockId; LockType = lockType; if (_parent._logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { _parent._logger.LogDebug("Requesting {lockType} for id {id}", LockType, LockId); } try { switch (lockType) { case DistributedLockType.ReadLock: ObtainReadLock(); break; case DistributedLockType.WriteLock: ObtainWriteLock(); break; default: throw new ArgumentOutOfRangeException(nameof(lockType), lockType, @"Unsupported lockType"); } } catch (SqlException ex) when (ex.Number == 1222) { if (LockType == DistributedLockType.ReadLock) { throw new DistributedReadLockTimeoutException(LockId); } throw new DistributedWriteLockTimeoutException(LockId); } if (_parent._logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { _parent._logger.LogDebug("Acquired {lockType} for id {id}", LockType, LockId); } } public int LockId { get; } public DistributedLockType LockType { get; } public void Dispose() { if (_parent._logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { // Mostly no op, cleaned up by completing transaction in scope. _parent._logger.LogDebug("Dropped {lockType} for id {id}", LockType, LockId); } } public override string ToString() => $"SqlServerDistributedLock({LockId}, {LockType}"; private void ObtainReadLock() { IUmbracoDatabase? db = _parent._scopeAccessor.Value.AmbientScope?.Database; if (db is null) { throw new PanicException("Could not find a database"); } if (!db.InTransaction) { throw new InvalidOperationException( "SqlServerDistributedLockingMechanism requires a transaction to function."); } if (db.Transaction.IsolationLevel < IsolationLevel.ReadCommitted) { throw new InvalidOperationException( "A transaction with minimum ReadCommitted isolation level is required."); } const string query = "SELECT value FROM umbracoLock WITH (REPEATABLEREAD) WHERE id=@id"; var lockTimeoutQuery = $"SET LOCK_TIMEOUT {_timeout.TotalMilliseconds}"; // execute the lock timeout query and the actual query in a single server roundtrip var i = db.ExecuteScalar($"{lockTimeoutQuery};{query}", new { id = LockId }); if (i == null) { // ensure we are actually locking! throw new ArgumentException(@$"LockObject with id={LockId} does not exist.", nameof(LockId)); } } private void ObtainWriteLock() { IUmbracoDatabase? db = _parent._scopeAccessor.Value.AmbientScope?.Database; if (db is null) { throw new PanicException("Could not find a database"); } if (!db.InTransaction) { throw new InvalidOperationException( "SqlServerDistributedLockingMechanism requires a transaction to function."); } if (db.Transaction.IsolationLevel < IsolationLevel.ReadCommitted) { throw new InvalidOperationException( "A transaction with minimum ReadCommitted isolation level is required."); } const string query = @"UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id"; var lockTimeoutQuery = $"SET LOCK_TIMEOUT {_timeout.TotalMilliseconds}"; // execute the lock timeout query and the actual query in a single server roundtrip var i = db.Execute($"{lockTimeoutQuery};{query}", new { id = LockId }); if (i == 0) { // ensure we are actually locking! throw new ArgumentException($"LockObject with id={LockId} does not exist."); } } } }