Introduce a new IMainDomLock and both default and sql implementations

This commit is contained in:
Shannon
2019-12-10 13:33:59 +01:00
parent 3e48022949
commit 1513a12549
16 changed files with 375 additions and 69 deletions

View File

@@ -151,6 +151,8 @@ namespace Umbraco.Core.Migrations.Install
_database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Domains, Name = "Domains" });
_database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.KeyValues, Name = "KeyValues" });
_database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Languages, Name = "Languages" });
_database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" });
}
private void CreateContentTypeData()

View File

@@ -185,6 +185,7 @@ namespace Umbraco.Core.Migrations.Upgrade
// to 8.6.0
To<AddPropertyTypeValidationMessageColumns>("{3D67D2C8-5E65-47D0-A9E1-DC2EE0779D6B}");
To<AddMainDomLock>("{2AB29964-02A1-474D-BD6B-72148D2A53A2}");
//FINAL
}

View File

@@ -0,0 +1,16 @@
using Umbraco.Core.Persistence.Dtos;
namespace Umbraco.Core.Migrations.Upgrade.V_8_6_0
{
public class AddMainDomLock : MigrationBase
{
public AddMainDomLock(IMigrationContext context)
: base(context)
{ }
public override void Migrate()
{
Database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" });
}
}
}

View File

@@ -3,6 +3,7 @@ using Umbraco.Core.Persistence.Dtos;
namespace Umbraco.Core.Migrations.Upgrade.V_8_6_0
{
public class AddPropertyTypeValidationMessageColumns : MigrationBase
{
public AddPropertyTypeValidationMessageColumns(IMigrationContext context)

View File

@@ -8,6 +8,11 @@ namespace Umbraco.Core
/// </summary>
public static class Locks
{
/// <summary>
/// The <see cref="IMainDom"/> lock
/// </summary>
public const int MainDom = -1000;
/// <summary>
/// All servers.
/// </summary>

View File

@@ -250,6 +250,11 @@ where tbl.[name]=@0 and col.[name]=@1;", tableName, columnName)
}
public override void WriteLock(IDatabase db, params int[] lockIds)
{
WriteLock(db, 1800, lockIds);
}
public void WriteLock(IDatabase db, int millisecondsTimeout, params int[] lockIds)
{
// soon as we get Database, a transaction is started
@@ -260,7 +265,7 @@ where tbl.[name]=@0 and col.[name]=@1;", tableName, columnName)
// *not* using a unique 'WHERE IN' query here because the *order* of lockIds is important to avoid deadlocks
foreach (var lockId in lockIds)
{
db.Execute(@"SET LOCK_TIMEOUT 1800;");
db.Execute($"SET LOCK_TIMEOUT {millisecondsTimeout};");
var i = db.Execute(@"UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id", new { id = lockId });
if (i == 0) // ensure we are actually locking!
throw new ArgumentException($"LockObject with id={lockId} does not exist.");

View File

@@ -147,7 +147,7 @@ namespace Umbraco.Core.Runtime
// TODO: remove this in netcore, this is purely backwards compat hacks with the empty ctor
if (MainDom == null)
{
MainDom = new MainDom(Logger);
MainDom = new MainDom(Logger, new MainDomSemaphoreLock());
}

View File

@@ -1,5 +1,6 @@
using System;
// TODO: Can't change namespace due to breaking changes, change in netcore
namespace Umbraco.Core
{
/// <summary>

View File

@@ -0,0 +1,30 @@
using System;
using System.Threading.Tasks;
namespace Umbraco.Core.Runtime
{
/// <summary>
/// An application-wide distributed lock
/// </summary>
/// <remarks>
/// Disposing releases the lock
/// </remarks>
public interface IMainDomLock : IDisposable
{
/// <summary>
/// Acquires an application-wide distributed lock
/// </summary>
/// <param name="millisecondsTimeout"></param>
/// <returns>
/// A disposable object which will be disposed in order to release the lock
/// </returns>
/// <exception cref="TimeoutException">Throws a <see cref="TimeoutException"/> if the elapsed millsecondsTimeout value is exceeded</exception>
Task<bool> AcquireLockAsync(int millisecondsTimeout);
/// <summary>
/// Wait on a background thread to receive a signal from another AppDomain
/// </summary>
/// <returns></returns>
Task ListenAsync();
}
}

View File

@@ -4,10 +4,13 @@ using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Web.Hosting;
using Umbraco.Core;
using Umbraco.Core.Logging;
using Umbraco.Core.Persistence;
namespace Umbraco.Core
namespace Umbraco.Core.Runtime
{
/// <summary>
/// Provides the full implementation of <see cref="IMainDom"/>.
/// </summary>
@@ -20,18 +23,11 @@ namespace Umbraco.Core
#region Vars
private readonly ILogger _logger;
private readonly IMainDomLock _mainDomLock;
// our own lock for local consistency
private object _locko = new object();
// async lock representing the main domain lock
private readonly SystemLock _systemLock;
private IDisposable _systemLocker;
// event wait handle used to notify current main domain that it should
// release the lock because a new domain wants to be the main domain
private readonly EventWaitHandle _signal;
private bool _isInitialized;
// indicates whether...
private bool _isMainDom; // we are the main domain
@@ -47,32 +43,12 @@ namespace Umbraco.Core
#region Ctor
// initializes a new instance of MainDom
public MainDom(ILogger logger)
public MainDom(ILogger logger, IMainDomLock systemLock)
{
HostingEnvironment.RegisterObject(this);
_logger = logger;
// HostingEnvironment.ApplicationID is null in unit tests, making ReplaceNonAlphanumericChars fail
var appId = HostingEnvironment.ApplicationID?.ReplaceNonAlphanumericChars(string.Empty) ?? string.Empty;
// combining with the physical path because if running on eg IIS Express,
// two sites could have the same appId even though they are different.
//
// now what could still collide is... two sites, running in two different processes
// and having the same appId, and running on the same app physical path
//
// we *cannot* use the process ID here because when an AppPool restarts it is
// a new process for the same application path
var appPath = HostingEnvironment.ApplicationPhysicalPath?.ToLowerInvariant() ?? string.Empty;
var hash = (appId + ":::" + appPath).GenerateHash<SHA1>();
var lockName = "UMBRACO-" + hash + "-MAINDOM-LCK";
_systemLock = new SystemLock(lockName);
var eventName = "UMBRACO-" + hash + "-MAINDOM-EVT";
_signal = new EventWaitHandle(false, EventResetMode.AutoReset, eventName);
_mainDomLock = systemLock;
}
#endregion
@@ -130,7 +106,6 @@ namespace Umbraco.Core
{
_logger.Info<MainDom>("Stopping ({SignalSource})", source);
foreach (var callback in _callbacks.OrderBy(x => x.Key).Select(x => x.Value))
{
try
{
callback(); // no timeout on callbacks
@@ -140,14 +115,13 @@ namespace Umbraco.Core
_logger.Error<MainDom>(e, "Error while running callback");
continue;
}
}
_logger.Debug<MainDom>("Stopped ({SignalSource})", source);
}
finally
{
// in any case...
_isMainDom = false;
_systemLocker?.Dispose();
_mainDomLock.Dispose();
_logger.Info<MainDom>("Released ({SignalSource})", source);
}
@@ -167,35 +141,11 @@ namespace Umbraco.Core
_logger.Info<MainDom>("Acquiring.");
// signal other instances that we want the lock, then wait one the lock,
// which may timeout, and this is accepted - see comments below
// Get the lock
_mainDomLock.AcquireLockAsync(LockTimeoutMilliseconds).Wait();
// signal, then wait for the lock, then make sure the event is
// reset (maybe there was noone listening..)
_signal.Set();
// if more than 1 instance reach that point, one will get the lock
// and the other one will timeout, which is accepted
//This can throw a TimeoutException - in which case should this be in a try/finally to ensure the signal is always reset.
try
{
_systemLocker = _systemLock.Lock(LockTimeoutMilliseconds);
}
finally
{
// we need to reset the event, because otherwise we would end up
// signaling ourselves and committing suicide immediately.
// only 1 instance can reach that point, but other instances may
// have started and be trying to get the lock - they will timeout,
// which is accepted
_signal.Reset();
}
//WaitOneAsync (ext method) will wait for a signal without blocking the main thread, the waiting is done on a background thread
_signal.WaitOneAsync()
// Listen for the signal from another AppDomain coming online to release the lock
_mainDomLock.ListenAsync()
.ContinueWith(_ => OnSignal("signal"));
_logger.Info<MainDom>("Acquired.");
@@ -230,8 +180,7 @@ namespace Umbraco.Core
{
if (disposing)
{
_signal?.Close();
_signal?.Dispose();
_mainDomLock.Dispose();
}
disposedValue = true;
@@ -244,5 +193,25 @@ namespace Umbraco.Core
}
#endregion
public static string GetMainDomId()
{
// HostingEnvironment.ApplicationID is null in unit tests, making ReplaceNonAlphanumericChars fail
var appId = HostingEnvironment.ApplicationID?.ReplaceNonAlphanumericChars(string.Empty) ?? string.Empty;
// combining with the physical path because if running on eg IIS Express,
// two sites could have the same appId even though they are different.
//
// now what could still collide is... two sites, running in two different processes
// and having the same appId, and running on the same app physical path
//
// we *cannot* use the process ID here because when an AppPool restarts it is
// a new process for the same application path
var appPath = HostingEnvironment.ApplicationPhysicalPath?.ToLowerInvariant() ?? string.Empty;
var hash = (appId + ":::" + appPath).GenerateHash<SHA1>();
return hash;
}
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Umbraco.Core.Runtime
{
/// <summary>
/// Uses a system-wide Semaphore and EventWaitHandle to synchronize the current AppDomain
/// </summary>
internal class MainDomSemaphoreLock : IMainDomLock
{
private readonly SystemLock _systemLock;
// event wait handle used to notify current main domain that it should
// release the lock because a new domain wants to be the main domain
private readonly EventWaitHandle _signal;
private IDisposable _lockRelease;
public MainDomSemaphoreLock()
{
var lockName = "UMBRACO-" + MainDom.GetMainDomId() + "-MAINDOM-LCK";
_systemLock = new SystemLock(lockName);
var eventName = "UMBRACO-" + MainDom.GetMainDomId() + "-MAINDOM-EVT";
_signal = new EventWaitHandle(false, EventResetMode.AutoReset, eventName);
}
//WaitOneAsync (ext method) will wait for a signal without blocking the main thread, the waiting is done on a background thread
public Task ListenAsync() => _signal.WaitOneAsync();
public Task<bool> AcquireLockAsync(int millisecondsTimeout)
{
// signal other instances that we want the lock, then wait on the lock,
// which may timeout, and this is accepted - see comments below
// signal, then wait for the lock, then make sure the event is
// reset (maybe there was noone listening..)
_signal.Set();
// if more than 1 instance reach that point, one will get the lock
// and the other one will timeout, which is accepted
//This can throw a TimeoutException - in which case should this be in a try/finally to ensure the signal is always reset.
try
{
_lockRelease = _systemLock.Lock(millisecondsTimeout);
return Task.FromResult(true);
}
catch (TimeoutException)
{
return Task.FromResult(false);
}
finally
{
// we need to reset the event, because otherwise we would end up
// signaling ourselves and committing suicide immediately.
// only 1 instance can reach that point, but other instances may
// have started and be trying to get the lock - they will timeout,
// which is accepted
_signal.Reset();
}
}
#region IDisposable Support
private bool disposedValue = false; // To detect redundant calls
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
_lockRelease?.Dispose();
_signal.Close();
_signal.Dispose();
}
disposedValue = true;
}
}
// This code added to correctly implement the disposable pattern.
public void Dispose()
{
// Do not change this code. Put cleanup code in Dispose(bool disposing) above.
Dispose(true);
}
#endregion
}
}

View File

@@ -0,0 +1,178 @@
using System;
using System.Data;
using System.Data.SqlClient;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Umbraco.Core.Logging;
using Umbraco.Core.Persistence;
using Umbraco.Core.Persistence.Dtos;
using Umbraco.Core.Persistence.Mappers;
using Umbraco.Core.Persistence.SqlSyntax;
namespace Umbraco.Core.Runtime
{
internal class SqlMainDomLock : IMainDomLock
{
private string _appDomainId;
private const string MainDomKey = "Umbraco.Core.Runtime.SqlMainDom";
private readonly ILogger _logger;
private IUmbracoDatabase _db;
private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
private SqlServerSyntaxProvider _sqlServerSyntax = new SqlServerSyntaxProvider();
public SqlMainDomLock(ILogger logger)
{
_appDomainId = AppDomain.CurrentDomain.Id.ToString();
_logger = logger;
}
public Task<bool> AcquireLockAsync(int millisecondsTimeout)
{
var factory = new UmbracoDatabaseFactory(
Constants.System.UmbracoConnectionName,
_logger,
new Lazy<IMapperCollection>(() => new Persistence.Mappers.MapperCollection(Enumerable.Empty<BaseMapper>())));
_db = factory.CreateDatabase();
try
{
_db.BeginTransaction(IsolationLevel.ReadCommitted);
try
{
// wait to get a write lock
_sqlServerSyntax.WriteLock(_db, millisecondsTimeout, Constants.Locks.MainDom);
}
catch (Exception ex)
{
if (IsLockTimeoutException(ex))
{
return Task.FromResult(false);
}
// unexpected
throw;
}
InsertLockRecord();
return Task.FromResult(true);
}
catch(Exception)
{
_db.AbortTransaction();
// unexpected
throw;
}
finally
{
_db.CompleteTransaction();
}
}
public Task ListenAsync()
{
// Create a long running task (dedicated thread)
// to poll to check if we are still the MainDom registered in the DB
return Task.Factory.StartNew(() =>
{
while(true)
{
if (_cancellationTokenSource.IsCancellationRequested)
break;
// poll every 1 second
Thread.Sleep(1000);
try
{
_db.BeginTransaction(IsolationLevel.ReadCommitted);
// get a read lock
_sqlServerSyntax.ReadLock(_db, Constants.Locks.MainDom);
if (!IsStillMainDom())
{
// we are no longer main dom, exit
return;
}
}
catch (Exception)
{
_db.AbortTransaction();
throw;
}
finally
{
_db.CompleteTransaction();
}
}
}, _cancellationTokenSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
/// <summary>
/// Inserts or updates the key/value row to check if the current appdomain is registerd as the maindom
/// </summary>
private void InsertLockRecord()
{
_db.InsertOrUpdate(new KeyValueDto
{
Key = MainDomKey,
Value = _appDomainId,
Updated = DateTime.Now
});
}
/// <summary>
/// Checks if the DB row value is our current appdomain value
/// </summary>
/// <returns></returns>
private bool IsStillMainDom()
{
return _db.ExecuteScalar<int>("SELECT COUNT(*) FROM umbracoKeyValue WHERE [key] = @key AND [value] = @val",
new { key = MainDomKey, val = _appDomainId }) == 1;
}
/// <summary>
/// Checks if the exception is an SQL timeout
/// </summary>
/// <param name="exception"></param>
/// <returns></returns>
private bool IsLockTimeoutException(Exception exception) => exception is SqlException sqlException && sqlException.Number == 1222;
#region IDisposable Support
private bool disposedValue = false; // To detect redundant calls
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
_db.Dispose();
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
}
disposedValue = true;
}
}
// This code added to correctly implement the disposable pattern.
public void Dispose()
{
// Do not change this code. Put cleanup code in Dispose(bool disposing) above.
Dispose(true);
}
#endregion
}
}

View File

@@ -13,7 +13,7 @@ namespace Umbraco.Core.Scoping
/// Provides scopes.
/// </summary>
public interface IScopeProvider
{
{
/// <summary>
/// Creates an ambient scope.
/// </summary>

View File

@@ -128,6 +128,10 @@
</Compile>
-->
<Compile Include="AssemblyExtensions.cs" />
<Compile Include="Migrations\Upgrade\V_8_6_0\AddMainDomLock.cs" />
<Compile Include="Runtime\IMainDomLock.cs" />
<Compile Include="Runtime\MainDomSemaphoreLock.cs" />
<Compile Include="Runtime\SqlMainDomLock.cs" />
<Compile Include="SystemLock.cs" />
<Compile Include="Attempt.cs" />
<Compile Include="AttemptOfTResult.cs" />
@@ -398,7 +402,7 @@
<Compile Include="Events\ExportedMemberEventArgs.cs" />
<Compile Include="Events\RolesEventArgs.cs" />
<Compile Include="Events\UserGroupWithUsers.cs" />
<Compile Include="IMainDom.cs" />
<Compile Include="Runtime\IMainDom.cs" />
<Compile Include="IO\IFileSystems.cs" />
<Compile Include="IO\IMediaFileSystem.cs" />
<Compile Include="GuidUtils.cs" />
@@ -729,7 +733,7 @@
<Compile Include="Logging\ProfilingLogger.cs" />
<Compile Include="Logging\VoidProfiler.cs" />
<Compile Include="Macros\MacroErrorBehaviour.cs" />
<Compile Include="MainDom.cs" />
<Compile Include="Runtime\MainDom.cs" />
<Compile Include="Manifest\ManifestParser.cs" />
<Compile Include="Manifest\ValueValidatorConverter.cs" />
<Compile Include="Manifest\ManifestWatcher.cs" />

View File

@@ -9,6 +9,7 @@ using Umbraco.Core.Models;
using Umbraco.Core.Models.Membership;
using Umbraco.Core.Models.PublishedContent;
using Umbraco.Core.Persistence.Repositories;
using Umbraco.Core.Runtime;
using Umbraco.Core.Scoping;
using Umbraco.Core.Services;
using Umbraco.Web;

View File

@@ -14,6 +14,7 @@ using Umbraco.Core.Models;
using Umbraco.Core.Persistence;
using Umbraco.Core.Persistence.Repositories;
using Umbraco.Core.Persistence.Repositories.Implement;
using Umbraco.Core.Runtime;
using Umbraco.Core.Scoping;
using Umbraco.Core.Services;
using Umbraco.Core.Services.Changes;