diff --git a/src/Umbraco.Cms.Persistence.EFCore/Extensions/DbContextExtensions.cs b/src/Umbraco.Cms.Persistence.EFCore/Extensions/DbContextExtensions.cs
new file mode 100644
index 0000000000..573f57e75f
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Extensions/DbContextExtensions.cs
@@ -0,0 +1,53 @@
+using System.Data;
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage;
+
+namespace Umbraco.Extensions;
+
+public static class DbContextExtensions
+{
+ ///
+ /// Executes a raw SQL query and returns the result.
+ ///
+ /// The database.
+ /// The sql query.
+ /// The list of db parameters.
+ /// The command type.
+ /// The amount of time to wait before the command times out.
+ /// the type to return.
+ /// Returns an object of the given type T.
+ public static async Task ExecuteScalarAsync(this DatabaseFacade database, string sql, List? parameters = null, CommandType commandType = CommandType.Text, TimeSpan? commandTimeOut = null)
+ {
+ ArgumentNullException.ThrowIfNull(database);
+ ArgumentNullException.ThrowIfNull(sql);
+
+ await using DbCommand dbCommand = database.GetDbConnection().CreateCommand();
+
+ if (database.CurrentTransaction is not null)
+ {
+ dbCommand.Transaction = database.CurrentTransaction.GetDbTransaction();
+ }
+
+ if (dbCommand.Connection?.State != ConnectionState.Open)
+ {
+ await dbCommand.Connection!.OpenAsync();
+ }
+
+ dbCommand.CommandText = sql;
+ dbCommand.CommandType = commandType;
+ if (commandTimeOut is not null)
+ {
+ dbCommand.CommandTimeout = (int)commandTimeOut.Value.TotalSeconds;
+ }
+
+ if (parameters != null)
+ {
+ dbCommand.Parameters.AddRange(parameters.ToArray());
+ }
+
+ var result = await dbCommand.ExecuteScalarAsync();
+ return (T?)result;
+ }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..857661fd83
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs
@@ -0,0 +1,57 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Umbraco.Cms.Core.DistributedLocking;
+using Umbraco.Cms.Persistence.EFCore.Locking;
+using Umbraco.Cms.Persistence.EFCore.Scoping;
+
+namespace Umbraco.Extensions;
+
+public static class UmbracoEFCoreServiceCollectionExtensions
+{
+ public delegate void DefaultEFCoreOptionsAction(DbContextOptionsBuilder options, string? providerName, string? connectionString);
+
+ public static IServiceCollection AddUmbracoEFCoreContext(this IServiceCollection services, string connectionString, string providerName, DefaultEFCoreOptionsAction? defaultEFCoreOptionsAction = null)
+ where T : DbContext
+ {
+ defaultEFCoreOptionsAction ??= DefaultOptionsAction;
+
+ services.AddDbContext(
+ options =>
+ {
+ defaultEFCoreOptionsAction(options, providerName, connectionString);
+ },
+ optionsLifetime: ServiceLifetime.Singleton);
+
+ services.AddDbContextFactory(options =>
+ {
+ defaultEFCoreOptionsAction(options, providerName, connectionString);
+ });
+
+ services.AddUnique, AmbientEFCoreScopeStack>();
+ services.AddUnique, EFCoreScopeAccessor>();
+ services.AddUnique, EFCoreScopeProvider>();
+ services.AddSingleton>();
+ services.AddSingleton>();
+
+ return services;
+ }
+
+ private static void DefaultOptionsAction(DbContextOptionsBuilder options, string? providerName, string? connectionString)
+ {
+ if (connectionString.IsNullOrWhiteSpace())
+ {
+ return;
+ }
+
+ switch (providerName)
+ {
+ case "Microsoft.Data.Sqlite":
+ options.UseSqlite(connectionString);
+ break;
+ case "Microsoft.Data.SqlClient":
+ options.UseSqlServer(connectionString);
+ break;
+ }
+ }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Locking/SqlServerEFCoreDistributedLockingMechanism.cs b/src/Umbraco.Cms.Persistence.EFCore/Locking/SqlServerEFCoreDistributedLockingMechanism.cs
new file mode 100644
index 0000000000..d5d83f8ecf
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Locking/SqlServerEFCoreDistributedLockingMechanism.cs
@@ -0,0 +1,185 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+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.Persistence.EFCore.Scoping;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Persistence.EFCore.Locking;
+
+internal class SqlServerEFCoreDistributedLockingMechanism : IDistributedLockingMechanism
+ where T : DbContext
+{
+ 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 SqlServerEFCoreDistributedLockingMechanism(
+ ILogger> logger,
+ Lazy> scopeAccessor,
+ IOptionsMonitor globalSettings,
+ IOptionsMonitor connectionStrings)
+ {
+ _logger = logger;
+ _scopeAccessor = scopeAccessor;
+ _globalSettings = globalSettings;
+ _connectionStrings = connectionStrings;
+ }
+
+ public bool HasActiveRelatedScope => _scopeAccessor.Value.AmbientScope is not null;
+
+ ///
+ public bool Enabled => _connectionStrings.CurrentValue.IsConnectionStringConfigured() &&
+ string.Equals(_connectionStrings.CurrentValue.ProviderName, "Microsoft.Data.SqlClient", StringComparison.InvariantCultureIgnoreCase) && _scopeAccessor.Value.AmbientScope is not null;
+
+ ///
+ 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 SqlServerEFCoreDistributedLockingMechanism _parent;
+ private readonly TimeSpan _timeout;
+
+ public SqlServerDistributedLock(
+ SqlServerEFCoreDistributedLockingMechanism parent,
+ int lockId,
+ DistributedLockType lockType,
+ TimeSpan timeout)
+ {
+ _parent = parent;
+ _timeout = timeout;
+ LockId = lockId;
+ LockType = lockType;
+
+ _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);
+ }
+
+ _parent._logger.LogDebug("Acquired {lockType} for id {id}", LockType, LockId);
+ }
+
+ public int LockId { get; }
+
+ public DistributedLockType LockType { get; }
+
+ public void Dispose() =>
+ // 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()
+ {
+ IEfCoreScope? scope = _parent._scopeAccessor.Value.AmbientScope;
+
+ if (scope is null)
+ {
+ throw new PanicException("No ambient scope");
+ }
+
+ scope.ExecuteWithContextAsync(async dbContext =>
+ {
+ if (dbContext.Database.CurrentTransaction is null)
+ {
+ throw new InvalidOperationException(
+ "SqlServerDistributedLockingMechanism requires a transaction to function.");
+ }
+
+ if (dbContext.Database.CurrentTransaction.GetDbTransaction().IsolationLevel <
+ IsolationLevel.ReadCommitted)
+ {
+ throw new InvalidOperationException(
+ "A transaction with minimum ReadCommitted isolation level is required.");
+ }
+
+ await dbContext.Database.ExecuteSqlRawAsync($"SET LOCK_TIMEOUT {(int)_timeout.TotalMilliseconds};");
+
+ var number = await dbContext.Database.ExecuteScalarAsync($"SELECT value FROM dbo.umbracoLock WITH (REPEATABLEREAD) WHERE id={LockId}");
+
+ if (number == null)
+ {
+ // ensure we are actually locking!
+ throw new ArgumentException(@$"LockObject with id={LockId} does not exist.", nameof(LockId));
+ }
+ }).GetAwaiter().GetResult();
+ }
+
+ private void ObtainWriteLock()
+ {
+ IEfCoreScope? scope = _parent._scopeAccessor.Value.AmbientScope;
+ if (scope is null)
+ {
+ throw new PanicException("No ambient scope");
+ }
+
+ scope.ExecuteWithContextAsync(async dbContext =>
+ {
+ if (dbContext.Database.CurrentTransaction is null)
+ {
+ throw new InvalidOperationException(
+ "SqlServerDistributedLockingMechanism requires a transaction to function.");
+ }
+
+ if (dbContext.Database.CurrentTransaction.GetDbTransaction().IsolationLevel < IsolationLevel.ReadCommitted)
+ {
+ throw new InvalidOperationException(
+ "A transaction with minimum ReadCommitted isolation level is required.");
+ }
+
+ await dbContext.Database.ExecuteSqlRawAsync($"SET LOCK_TIMEOUT {(int)_timeout.TotalMilliseconds};");
+
+ var rowsAffected = await dbContext.Database.ExecuteSqlAsync(@$"UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id={LockId}");
+
+ if (rowsAffected == 0)
+ {
+ // ensure we are actually locking!
+ throw new ArgumentException($"LockObject with id={LockId} does not exist.");
+ }
+ }).GetAwaiter().GetResult();
+ }
+ }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Locking/SqliteEFCoreDistributedLockingMechanism.cs b/src/Umbraco.Cms.Persistence.EFCore/Locking/SqliteEFCoreDistributedLockingMechanism.cs
new file mode 100644
index 0000000000..8d92ec0e03
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Locking/SqliteEFCoreDistributedLockingMechanism.cs
@@ -0,0 +1,181 @@
+using System.Globalization;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using SQLitePCL;
+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.Persistence.EFCore.Scoping;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Persistence.EFCore.Locking;
+
+internal class SqliteEFCoreDistributedLockingMechanism : IDistributedLockingMechanism
+ where T : DbContext
+{
+ private readonly IOptionsMonitor _connectionStrings;
+ private readonly IOptionsMonitor _globalSettings;
+ private readonly ILogger> _logger;
+ private readonly Lazy> _efCoreScopeAccessor;
+
+ public SqliteEFCoreDistributedLockingMechanism(
+ ILogger> logger,
+ Lazy> efCoreScopeAccessor,
+ IOptionsMonitor globalSettings,
+ IOptionsMonitor connectionStrings)
+ {
+ _logger = logger;
+ _efCoreScopeAccessor = efCoreScopeAccessor;
+ _connectionStrings = connectionStrings;
+ _globalSettings = globalSettings;
+ }
+
+ public bool HasActiveRelatedScope => _efCoreScopeAccessor.Value.AmbientScope is not null;
+
+ ///
+ public bool Enabled => _connectionStrings.CurrentValue.IsConnectionStringConfigured() &&
+ string.Equals(_connectionStrings.CurrentValue.ProviderName, "Microsoft.Data.Sqlite", StringComparison.InvariantCultureIgnoreCase) && _efCoreScopeAccessor.Value.AmbientScope is not null;
+
+ // With journal_mode=wal we can always read a snapshot.
+ public IDistributedLock ReadLock(int lockId, TimeSpan? obtainLockTimeout = null)
+ {
+ obtainLockTimeout ??= _globalSettings.CurrentValue.DistributedLockingReadLockDefaultTimeout;
+ return new SqliteDistributedLock(this, lockId, DistributedLockType.ReadLock, obtainLockTimeout.Value);
+ }
+
+ // With journal_mode=wal only a single write transaction can exist at a time.
+ public IDistributedLock WriteLock(int lockId, TimeSpan? obtainLockTimeout = null)
+ {
+ obtainLockTimeout ??= _globalSettings.CurrentValue.DistributedLockingWriteLockDefaultTimeout;
+ return new SqliteDistributedLock(this, lockId, DistributedLockType.WriteLock, obtainLockTimeout.Value);
+ }
+
+ private class SqliteDistributedLock : IDistributedLock
+ {
+ private readonly SqliteEFCoreDistributedLockingMechanism _parent;
+ private readonly TimeSpan _timeout;
+
+ public SqliteDistributedLock(
+ SqliteEFCoreDistributedLockingMechanism parent,
+ int lockId,
+ DistributedLockType lockType,
+ TimeSpan timeout)
+ {
+ _parent = parent;
+ _timeout = timeout;
+ LockId = lockId;
+ LockType = lockType;
+
+ _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 (SqliteException ex) when (ex.SqliteErrorCode == SQLitePCL.raw.SQLITE_BUSY)
+ {
+ if (LockType == DistributedLockType.ReadLock)
+ {
+ throw new DistributedReadLockTimeoutException(LockId);
+ }
+
+ throw new DistributedWriteLockTimeoutException(LockId);
+ }
+
+ _parent._logger.LogDebug("Acquired {lockType} for id {id}", LockType, LockId);
+ }
+
+ public int LockId { get; }
+
+ public DistributedLockType LockType { get; }
+
+ public void Dispose() =>
+ // Mostly no op, cleaned up by completing transaction in scope.
+ _parent._logger.LogDebug("Dropped {lockType} for id {id}", LockType, LockId);
+
+ public override string ToString()
+ => $"SqliteDistributedLock({LockId})";
+
+ // Can always obtain a read lock (snapshot isolation in wal mode)
+ // Mostly no-op just check that we didn't end up ReadUncommitted for real.
+ private void ObtainReadLock()
+ {
+ IEfCoreScope? efCoreScope = _parent._efCoreScopeAccessor.Value.AmbientScope;
+
+ if (efCoreScope is null)
+ {
+ throw new PanicException("No current ambient scope");
+ }
+
+ efCoreScope.ExecuteWithContextAsync(async database =>
+ {
+ if (database.Database.CurrentTransaction is null)
+ {
+ throw new InvalidOperationException(
+ "SqliteDistributedLockingMechanism requires a transaction to function.");
+ }
+ });
+ }
+
+ // Only one writer is possible at a time
+ // lock occurs for entire database as opposed to row/table.
+ private void ObtainWriteLock()
+ {
+ IEfCoreScope? efCoreScope = _parent._efCoreScopeAccessor.Value.AmbientScope;
+
+ if (efCoreScope is null)
+ {
+ throw new PanicException("No ambient scope");
+ }
+
+ efCoreScope.ExecuteWithContextAsync(async database =>
+ {
+ if (database.Database.CurrentTransaction is null)
+ {
+ throw new InvalidOperationException(
+ "SqliteDistributedLockingMechanism requires a transaction to function.");
+ }
+
+ var query = @$"UPDATE umbracoLock SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id = {LockId.ToString(CultureInfo.InvariantCulture)}";
+
+ try
+ {
+ // imagine there is an existing writer, whilst elapsed time is < command timeout sqlite will busy loop
+ // Important to note that if this value == 0 then Command.DefaultTimeout (30s) is used.
+ // Math.Ceiling such that (0 < totalseconds < 1) is rounded up to 1.
+ database.Database.SetCommandTimeout((int)Math.Ceiling(_timeout.TotalSeconds));
+ var i = await database.Database.ExecuteScalarAsync(query);
+
+ if (i == 0)
+ {
+ // ensure we are actually locking!
+ throw new ArgumentException($"LockObject with id={LockId} does not exist.");
+ }
+ }
+ catch (SqliteException ex) when (IsBusyOrLocked(ex))
+ {
+ throw new DistributedWriteLockTimeoutException(LockId);
+ }
+ });
+ }
+
+ private bool IsBusyOrLocked(SqliteException ex) =>
+ ex.SqliteErrorCode
+ is raw.SQLITE_BUSY
+ or raw.SQLITE_LOCKED
+ or raw.SQLITE_LOCKED_SHAREDCACHE;
+ }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/AmbientEFCoreScopeStack.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/AmbientEFCoreScopeStack.cs
new file mode 100644
index 0000000000..dc948f36f3
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/AmbientEFCoreScopeStack.cs
@@ -0,0 +1,40 @@
+using System.Collections.Concurrent;
+using Microsoft.EntityFrameworkCore;
+
+namespace Umbraco.Cms.Persistence.EFCore.Scoping;
+
+public class AmbientEFCoreScopeStack : IAmbientEFCoreScopeStack where TDbContext : DbContext
+{
+
+ private static AsyncLocal>> _stack = new();
+
+ public IEfCoreScope? AmbientScope
+ {
+ get
+ {
+ if (_stack.Value?.TryPeek(out IEfCoreScope? ambientScope) ?? false)
+ {
+ return ambientScope;
+ }
+
+ return null;
+ }
+ }
+
+ public IEfCoreScope Pop()
+ {
+ if (_stack.Value?.TryPop(out IEfCoreScope? ambientScope) ?? false)
+ {
+ return ambientScope;
+ }
+
+ throw new InvalidOperationException("No AmbientScope was found.");
+ }
+
+ public void Push(IEfCoreScope scope)
+ {
+ _stack.Value ??= new ConcurrentStack>();
+
+ _stack.Value.Push(scope);
+ }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreDetachableScope.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreDetachableScope.cs
new file mode 100644
index 0000000000..e23a830e3f
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreDetachableScope.cs
@@ -0,0 +1,110 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.DistributedLocking;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Scoping;
+
+namespace Umbraco.Cms.Persistence.EFCore.Scoping;
+
+internal class EFCoreDetachableScope : EFCoreScope where TDbContext : DbContext
+{
+ private readonly IEFCoreScopeAccessor _efCoreScopeAccessor;
+ private readonly EFCoreScopeProvider _efCoreScopeProvider;
+
+ public EFCoreDetachableScope(
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
+ IEFCoreScopeAccessor efCoreScopeAccessor,
+ FileSystems fileSystems,
+ IEFCoreScopeProvider efCoreScopeProvider,
+ IScopeContext? scopeContext,
+ IEventAggregator eventAggregator,
+ IDbContextFactory dbContextFactory,
+ RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
+ bool? scopeFileSystems = null)
+ : base(
+ distributedLockingMechanismFactory,
+ loggerFactory,
+ efCoreScopeAccessor,
+ fileSystems,
+ efCoreScopeProvider,
+ scopeContext,
+ eventAggregator,
+ dbContextFactory,
+ repositoryCacheMode,
+ scopeFileSystems)
+ {
+ if (scopeContext is not null)
+ {
+ throw new ArgumentException("Cannot set context on detachable scope.", nameof(scopeContext));
+ }
+
+ _efCoreScopeAccessor = efCoreScopeAccessor;
+ _efCoreScopeProvider = (EFCoreScopeProvider)efCoreScopeProvider;
+
+ Detachable = true;
+
+ ScopeContext = new ScopeContext();
+ }
+
+ public EFCoreDetachableScope(
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
+ IEFCoreScopeAccessor efCoreScopeAccessor,
+ FileSystems fileSystems,
+ IEFCoreScopeProvider efCoreScopeProvider,
+ EFCoreScope parentScope,
+ IScopeContext? scopeContext,
+ IEventAggregator eventAggregator,
+ IDbContextFactory dbContextFactory)
+ : base(
+ parentScope,
+ distributedLockingMechanismFactory,
+ loggerFactory,
+ efCoreScopeAccessor,
+ fileSystems,
+ efCoreScopeProvider,
+ scopeContext,
+ eventAggregator,
+ dbContextFactory) =>
+ throw new NotImplementedException();
+
+ public EFCoreScope? OriginalScope { get; set; }
+
+ public IScopeContext? OriginalContext { get; set; }
+
+ public bool Detachable { get; }
+
+ public bool Attached { get; set; }
+
+ public new void Dispose()
+ {
+ HandleDetachedScopes();
+ base.Dispose();
+ }
+
+ private void HandleDetachedScopes()
+ {
+ if (Detachable)
+ {
+ // get out of the way, restore original
+
+ // TODO: Difficult to know if this is correct since this is all required
+ // by Deploy which I don't fully understand since there is limited tests on this in the CMS
+ if (OriginalScope != _efCoreScopeAccessor.AmbientScope)
+ {
+ _efCoreScopeProvider.PopAmbientScope();
+ }
+
+ if (OriginalContext != _efCoreScopeProvider.AmbientScopeContext)
+ {
+ _efCoreScopeProvider.PopAmbientScopeContext();
+ }
+
+ Attached = false;
+ OriginalScope = null;
+ OriginalContext = null;
+ }
+ }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScope.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScope.cs
new file mode 100644
index 0000000000..461b09334c
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScope.cs
@@ -0,0 +1,237 @@
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.DistributedLocking;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Scoping;
+using IScope = Umbraco.Cms.Infrastructure.Scoping.IScope;
+
+namespace Umbraco.Cms.Persistence.EFCore.Scoping;
+
+internal class EFCoreScope : CoreScope, IEfCoreScope
+ where TDbContext : DbContext
+{
+ private readonly IEFCoreScopeAccessor _efCoreScopeAccessor;
+ private readonly EFCoreScopeProvider _efCoreScopeProvider;
+ private readonly IScope? _innerScope;
+ private bool _disposed;
+ private TDbContext? _dbContext;
+ private IDbContextFactory _dbContextFactory;
+
+ protected EFCoreScope(
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
+ IEFCoreScopeAccessor efCoreScopeAccessor,
+ FileSystems scopedFileSystem,
+ IEFCoreScopeProvider iefCoreScopeProvider,
+ IScopeContext? scopeContext,
+ IEventAggregator eventAggregator,
+ IDbContextFactory dbContextFactory,
+ RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
+ bool? scopeFileSystems = null)
+ : base(distributedLockingMechanismFactory, loggerFactory, scopedFileSystem, eventAggregator, repositoryCacheMode, scopeFileSystems)
+ {
+ _efCoreScopeAccessor = efCoreScopeAccessor;
+ _efCoreScopeProvider = (EFCoreScopeProvider)iefCoreScopeProvider;
+ ScopeContext = scopeContext;
+ _dbContextFactory = dbContextFactory;
+ }
+
+ public EFCoreScope(
+ IScope parentScope,
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
+ IEFCoreScopeAccessor efCoreScopeAccessor,
+ FileSystems scopedFileSystem,
+ IEFCoreScopeProvider iefCoreScopeProvider,
+ IScopeContext? scopeContext,
+ IEventAggregator eventAggregator,
+ IDbContextFactory dbContextFactory,
+ RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
+ bool? scopeFileSystems = null)
+ : base(parentScope, distributedLockingMechanismFactory, loggerFactory, scopedFileSystem, eventAggregator, repositoryCacheMode, scopeFileSystems)
+ {
+ _efCoreScopeAccessor = efCoreScopeAccessor;
+ _efCoreScopeProvider = (EFCoreScopeProvider)iefCoreScopeProvider;
+ ScopeContext = scopeContext;
+ _innerScope = parentScope;
+ _dbContextFactory = dbContextFactory;
+ }
+
+ public EFCoreScope(
+ EFCoreScope parentScope,
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
+ IEFCoreScopeAccessor efCoreScopeAccessor,
+ FileSystems scopedFileSystem,
+ IEFCoreScopeProvider iefCoreScopeProvider,
+ IScopeContext? scopeContext,
+ IEventAggregator eventAggregator,
+ IDbContextFactory dbContextFactory,
+ RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
+ bool? scopeFileSystems = null)
+ : base(parentScope, distributedLockingMechanismFactory, loggerFactory, scopedFileSystem, eventAggregator, repositoryCacheMode, scopeFileSystems)
+ {
+ _efCoreScopeAccessor = efCoreScopeAccessor;
+ _efCoreScopeProvider = (EFCoreScopeProvider)iefCoreScopeProvider;
+ ScopeContext = scopeContext;
+ ParentScope = parentScope;
+ _dbContextFactory = dbContextFactory;
+ }
+
+
+ public EFCoreScope? ParentScope { get; }
+
+ public IScopeContext? ScopeContext { get; set; }
+
+ public async Task ExecuteWithContextAsync(Func> method)
+ {
+ if (_disposed)
+ {
+ throw new InvalidOperationException(
+ "The scope has been disposed, therefore the database is not available.");
+ }
+
+ if (_dbContext is null)
+ {
+ InitializeDatabase();
+ }
+
+ return await method(_dbContext!);
+ }
+
+ public async Task ExecuteWithContextAsync(Func method) =>
+ await ExecuteWithContextAsync(async db =>
+ {
+ await method(db);
+ return true; // Do nothing
+ });
+
+ public void Reset() => Completed = null;
+
+ public override void Dispose()
+ {
+ if (this != _efCoreScopeAccessor.AmbientScope)
+ {
+ var failedMessage =
+ $"The {nameof(EFCoreScope)} {InstanceId} being disposed is not the Ambient {nameof(EFCoreScope)} {_efCoreScopeAccessor.AmbientScope?.InstanceId.ToString() ?? "NULL"}. This typically indicates that a child {nameof(EFCoreScope)} was not disposed, or flowed to a child thread that was not awaited, or concurrent threads are accessing the same {nameof(EFCoreScope)} (Ambient context) which is not supported. If using Task.Run (or similar) as a fire and forget tasks or to run threads in parallel you must suppress execution context flow with ExecutionContext.SuppressFlow() and ExecutionContext.RestoreFlow().";
+ throw new InvalidOperationException(failedMessage);
+ }
+
+ if (ParentScope is null)
+ {
+ DisposeEfCoreDatabase();
+ }
+
+ Locks.ClearLocks(InstanceId);
+
+ if (ParentScope is null)
+ {
+ Locks.EnsureLocksCleared(InstanceId);
+ }
+
+ _efCoreScopeProvider.PopAmbientScope();
+
+ HandleScopeContext();
+ base.Dispose();
+
+ _disposed = true;
+ if (ParentScope is null)
+ {
+ if (Completed.HasValue && Completed.Value)
+ {
+ _innerScope?.Complete();
+ }
+
+ _innerScope?.Dispose();
+ }
+ }
+
+ private void InitializeDatabase()
+ {
+ if (_dbContext is null)
+ {
+ _dbContext = FindDbContext();
+ }
+
+ // Check if we are already in a transaction before starting one
+ if (_dbContext.Database.CurrentTransaction is null)
+ {
+ DbTransaction? transaction = _innerScope?.Database.Transaction;
+ _dbContext.Database.SetDbConnection(transaction?.Connection);
+ Locks.EnsureLocks(InstanceId);
+
+ if (transaction is null)
+ {
+ _dbContext.Database.BeginTransaction();
+ }
+ else
+ {
+ _dbContext.Database.UseTransaction(transaction);
+ }
+ }
+ }
+
+ private TDbContext FindDbContext()
+ {
+ if (ParentScope is not null)
+ {
+ return ParentScope.FindDbContext();
+ }
+
+ return _dbContext ??= _dbContextFactory.CreateDbContext();
+ }
+
+ private void HandleScopeContext()
+ {
+ // if *we* created it, then get rid of it
+ if (_efCoreScopeProvider.AmbientScopeContext == ScopeContext)
+ {
+ try
+ {
+ _efCoreScopeProvider.AmbientScopeContext?.ScopeExit(Completed.HasValue && Completed.Value);
+ }
+ finally
+ {
+ // removes the ambient context (ambient scope already gone)
+ _efCoreScopeProvider.PopAmbientScopeContext();
+ }
+ }
+ }
+
+ private void DisposeEfCoreDatabase()
+ {
+ var completed = Completed.HasValue && Completed.Value;
+ {
+ try
+ {
+ if (_dbContext is null || _innerScope is not null)
+ {
+ return;
+ }
+
+ // Transaction connection can be null here if we get chosen as the deadlock victim.
+ if (_dbContext.Database.CurrentTransaction?.GetDbTransaction().Connection is null)
+ {
+ return;
+ }
+
+ if (completed)
+ {
+ _dbContext.Database.CommitTransaction();
+ }
+ else
+ {
+ _dbContext.Database.RollbackTransaction();
+ }
+ }
+ finally
+ {
+ _dbContext?.Dispose();
+ _dbContext = null;
+ }
+ }
+ }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScopeAccessor.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScopeAccessor.cs
new file mode 100644
index 0000000000..098a6957c4
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScopeAccessor.cs
@@ -0,0 +1,14 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Umbraco.Cms.Persistence.EFCore.Scoping;
+
+internal class EFCoreScopeAccessor : IEFCoreScopeAccessor where TDbContext : DbContext
+{
+ private readonly IAmbientEFCoreScopeStack _ambientEfCoreScopeStack;
+
+ public EFCoreScopeAccessor(IAmbientEFCoreScopeStack ambientEfCoreScopeStack) => _ambientEfCoreScopeStack = ambientEfCoreScopeStack;
+
+ public EFCoreScope? AmbientScope => (EFCoreScope?)_ambientEfCoreScopeStack.AmbientScope;
+
+ IEfCoreScope? IEFCoreScopeAccessor.AmbientScope => _ambientEfCoreScopeStack.AmbientScope;
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScopeProvider.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScopeProvider.cs
new file mode 100644
index 0000000000..9e41eedb3c
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScopeProvider.cs
@@ -0,0 +1,207 @@
+using System.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.DependencyInjection;
+using Umbraco.Cms.Core.DistributedLocking;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Scoping;
+using Umbraco.Cms.Infrastructure.Scoping;
+using IScope = Umbraco.Cms.Infrastructure.Scoping.IScope;
+using IScopeProvider = Umbraco.Cms.Infrastructure.Scoping.IScopeProvider;
+
+namespace Umbraco.Cms.Persistence.EFCore.Scoping;
+
+internal class EFCoreScopeProvider : IEFCoreScopeProvider where TDbContext : DbContext
+{
+ private readonly IAmbientEFCoreScopeStack _ambientEfCoreScopeStack;
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly IEFCoreScopeAccessor _efCoreScopeAccessor;
+ private readonly IAmbientScopeContextStack _ambientEfCoreScopeContextStack;
+ private readonly IDistributedLockingMechanismFactory _distributedLockingMechanismFactory;
+ private readonly IEventAggregator _eventAggregator;
+ private readonly FileSystems _fileSystems;
+ private readonly IScopeProvider _scopeProvider;
+ private readonly IDbContextFactory _dbContextFactory;
+
+ // Needed for DI as IAmbientEfCoreScopeStack is internal
+ public EFCoreScopeProvider()
+ : this(
+ StaticServiceProvider.Instance.GetRequiredService>(),
+ StaticServiceProvider.Instance.GetRequiredService(),
+ StaticServiceProvider.Instance.GetRequiredService>(),
+ StaticServiceProvider.Instance.GetRequiredService(),
+ StaticServiceProvider.Instance.GetRequiredService(),
+ StaticServiceProvider.Instance.GetRequiredService(),
+ StaticServiceProvider.Instance.GetRequiredService(),
+ StaticServiceProvider.Instance.GetRequiredService(),
+ StaticServiceProvider.Instance.GetRequiredService>())
+ {
+ }
+
+ internal EFCoreScopeProvider(
+ IAmbientEFCoreScopeStack ambientEfCoreScopeStack,
+ ILoggerFactory loggerFactory,
+ IEFCoreScopeAccessor efCoreScopeAccessor,
+ IAmbientScopeContextStack ambientEfCoreScopeContextStack,
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ IEventAggregator eventAggregator,
+ FileSystems fileSystems,
+ IScopeProvider scopeProvider,
+ IDbContextFactory dbContextFactory)
+ {
+ _ambientEfCoreScopeStack = ambientEfCoreScopeStack;
+ _loggerFactory = loggerFactory;
+ _efCoreScopeAccessor = efCoreScopeAccessor;
+ _ambientEfCoreScopeContextStack = ambientEfCoreScopeContextStack;
+ _distributedLockingMechanismFactory = distributedLockingMechanismFactory;
+ _eventAggregator = eventAggregator;
+ _fileSystems = fileSystems;
+ _scopeProvider = scopeProvider;
+ _dbContextFactory = dbContextFactory;
+ _fileSystems.IsScoped = () => efCoreScopeAccessor.AmbientScope != null && ((EFCoreScope)efCoreScopeAccessor.AmbientScope).ScopedFileSystems;
+ }
+
+ public IEfCoreScope CreateDetachedScope(
+ RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
+ bool? scopeFileSystems = null) =>
+ new EFCoreDetachableScope(
+ _distributedLockingMechanismFactory,
+ _loggerFactory,
+ _efCoreScopeAccessor,
+ _fileSystems,
+ this,
+ null,
+ _eventAggregator,
+ _dbContextFactory,
+ repositoryCacheMode,
+ scopeFileSystems);
+
+ public void AttachScope(IEfCoreScope other)
+ {
+ // IScopeProvider.AttachScope works with an IEFCoreScope
+ // but here we can only deal with our own Scope class
+ if (other is not EFCoreDetachableScope otherScope)
+ {
+ throw new ArgumentException("Not a Scope instance.");
+ }
+
+ if (otherScope.Detachable == false)
+ {
+ throw new ArgumentException("Not a detachable scope.");
+ }
+
+ if (otherScope.Attached)
+ {
+ throw new InvalidOperationException("Already attached.");
+ }
+
+ otherScope.Attached = true;
+ otherScope.OriginalScope = (EFCoreScope)_ambientEfCoreScopeStack.AmbientScope!;
+ otherScope.OriginalContext = AmbientScopeContext;
+
+ PushAmbientScopeContext(otherScope.ScopeContext);
+ _ambientEfCoreScopeStack.Push(otherScope);
+ }
+
+ public IEfCoreScope DetachScope()
+ {
+ if (_ambientEfCoreScopeStack.AmbientScope is not EFCoreDetachableScope ambientScope)
+ {
+ throw new InvalidOperationException("Ambient scope is not detachable");
+ }
+
+ if (ambientScope == null)
+ {
+ throw new InvalidOperationException("There is no ambient scope.");
+ }
+
+ if (ambientScope.Detachable == false)
+ {
+ throw new InvalidOperationException("Ambient scope is not detachable.");
+ }
+
+ PopAmbientScope();
+ PopAmbientScopeContext();
+
+ var originalScope = (EFCoreScope)_ambientEfCoreScopeStack.AmbientScope!;
+ if (originalScope != ambientScope.OriginalScope)
+ {
+ throw new InvalidOperationException($"The detatched scope ({ambientScope.InstanceId}) does not match the original ({originalScope.InstanceId})");
+ }
+
+ IScopeContext? originalScopeContext = AmbientScopeContext;
+ if (originalScopeContext != ambientScope.OriginalContext)
+ {
+ throw new InvalidOperationException($"The detatched scope context does not match the original");
+ }
+
+ ambientScope.OriginalScope = null;
+ ambientScope.OriginalContext = null;
+ ambientScope.Attached = false;
+ return ambientScope;
+ }
+
+
+ public IScopeContext? AmbientScopeContext => _ambientEfCoreScopeContextStack.AmbientContext;
+
+ public IEfCoreScope CreateScope(
+ RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified, bool? scopeFileSystems = null)
+ {
+ if (_ambientEfCoreScopeStack.AmbientScope is null)
+ {
+ ScopeContext? newContext = _ambientEfCoreScopeContextStack.AmbientContext == null ? new ScopeContext() : null;
+ IScope parentScope = _scopeProvider.CreateScope(IsolationLevel.Unspecified, repositoryCacheMode, null, null, scopeFileSystems);
+ var ambientScope = new EFCoreScope(
+ parentScope,
+ _distributedLockingMechanismFactory,
+ _loggerFactory,
+ _efCoreScopeAccessor,
+ _fileSystems,
+ this,
+ newContext,
+ _eventAggregator,
+ _dbContextFactory,
+ repositoryCacheMode,
+ scopeFileSystems);
+
+ if (newContext != null)
+ {
+ PushAmbientScopeContext(newContext);
+ }
+
+ _ambientEfCoreScopeStack.Push(ambientScope);
+ return ambientScope;
+ }
+
+ var efCoreScope = new EFCoreScope(
+ (EFCoreScope)_ambientEfCoreScopeStack.AmbientScope,
+ _distributedLockingMechanismFactory,
+ _loggerFactory,
+ _efCoreScopeAccessor,
+ _fileSystems,
+ this,
+ null,
+ _eventAggregator,
+ _dbContextFactory,
+ repositoryCacheMode,
+ scopeFileSystems);
+
+ _ambientEfCoreScopeStack.Push(efCoreScope);
+ return efCoreScope;
+ }
+
+ public void PopAmbientScope() => _ambientEfCoreScopeStack.Pop();
+
+ public void PushAmbientScopeContext(IScopeContext? scopeContext)
+ {
+ if (scopeContext is null)
+ {
+ throw new ArgumentNullException(nameof(scopeContext));
+ }
+ _ambientEfCoreScopeContextStack.Push(scopeContext);
+ }
+
+ public void PopAmbientScopeContext() => _ambientEfCoreScopeContextStack.Pop();
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/IAmbientEfCoreScopeStack.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/IAmbientEfCoreScopeStack.cs
new file mode 100644
index 0000000000..01b66c7443
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/IAmbientEfCoreScopeStack.cs
@@ -0,0 +1,12 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Umbraco.Cms.Persistence.EFCore.Scoping;
+
+internal interface IAmbientEFCoreScopeStack : IEFCoreScopeAccessor where TDbContext : DbContext
+{
+ public IEfCoreScope? AmbientScope { get; }
+
+ IEfCoreScope Pop();
+
+ void Push(IEfCoreScope scope);
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScope.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScope.cs
new file mode 100644
index 0000000000..5595fd5295
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScope.cs
@@ -0,0 +1,30 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.Scoping;
+
+namespace Umbraco.Cms.Persistence.EFCore.Scoping;
+
+public interface IEfCoreScope : ICoreScope
+{
+ ///
+ /// Executes the given function on the database.
+ ///
+ /// Function to execute.
+ /// Type to use and return.
+ ///
+ Task ExecuteWithContextAsync(Func> method);
+
+ public IScopeContext? ScopeContext { get; set; }
+
+ ///
+ /// Executes the given function on the database.
+ ///
+ /// Function to execute.
+ /// Type to use and return.
+ ///
+ Task ExecuteWithContextAsync(Func method);
+
+ ///
+ /// Gets the scope notification publisher
+ ///
+ IScopedNotificationPublisher Notifications { get; }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScopeAccessor.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScopeAccessor.cs
new file mode 100644
index 0000000000..05db299370
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScopeAccessor.cs
@@ -0,0 +1,10 @@
+namespace Umbraco.Cms.Persistence.EFCore.Scoping;
+
+public interface IEFCoreScopeAccessor
+{
+ ///
+ /// Gets the ambient scope.
+ ///
+ /// Returns null if there is no ambient scope.
+ IEfCoreScope? AmbientScope { get; }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScopeProvider.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScopeProvider.cs
new file mode 100644
index 0000000000..8b872d9f14
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScopeProvider.cs
@@ -0,0 +1,16 @@
+using Umbraco.Cms.Core.Scoping;
+
+namespace Umbraco.Cms.Persistence.EFCore.Scoping;
+
+public interface IEFCoreScopeProvider
+{
+ IEfCoreScope CreateScope(RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified, bool? scopeFileSystems = null);
+
+ IEfCoreScope CreateDetachedScope(RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified, bool? scopeFileSystems = null);
+
+ void AttachScope(IEfCoreScope other);
+
+ IEfCoreScope DetachScope();
+
+ IScopeContext? AmbientScopeContext { get; }
+}
diff --git a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj
new file mode 100644
index 0000000000..af566ab67a
--- /dev/null
+++ b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj
@@ -0,0 +1,25 @@
+
+
+ Umbraco CMS - Persistence - EFCore
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>Umbraco.Tests.Integration
+
+
+
+
diff --git a/src/Umbraco.Cms/Umbraco.Cms.csproj b/src/Umbraco.Cms/Umbraco.Cms.csproj
index da6be4c30c..034c42356c 100644
--- a/src/Umbraco.Cms/Umbraco.Cms.csproj
+++ b/src/Umbraco.Cms/Umbraco.Cms.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml
index edd49bcf95..61c15feaf4 100644
--- a/src/Umbraco.Core/CompatibilitySuppressions.xml
+++ b/src/Umbraco.Core/CompatibilitySuppressions.xml
@@ -29,4 +29,11 @@
lib/net7.0/Umbraco.Core.dll
true
-
+
+ CP0006
+ P:Umbraco.Cms.Core.Scoping.ICoreScope.Locks
+ lib/net7.0/Umbraco.Core.dll
+ lib/net7.0/Umbraco.Core.dll
+ true
+
+
\ No newline at end of file
diff --git a/src/Umbraco.Core/Scoping/CoreScope.cs b/src/Umbraco.Core/Scoping/CoreScope.cs
new file mode 100644
index 0000000000..a05b44f4a7
--- /dev/null
+++ b/src/Umbraco.Core/Scoping/CoreScope.cs
@@ -0,0 +1,272 @@
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.Cache;
+using Umbraco.Cms.Core.DistributedLocking;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Core.Scoping;
+
+public class CoreScope : ICoreScope
+{
+ protected bool? Completed;
+ private ICompletable? _scopedFileSystem;
+ private IScopedNotificationPublisher? _notificationPublisher;
+ private IsolatedCaches? _isolatedCaches;
+ private ICoreScope? _parentScope;
+
+ private readonly RepositoryCacheMode _repositoryCacheMode;
+ private readonly bool? _shouldScopeFileSystems;
+ private readonly IEventAggregator _eventAggregator;
+
+ private bool _disposed;
+
+ protected CoreScope(
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
+ FileSystems scopedFileSystem,
+ IEventAggregator eventAggregator,
+ RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
+ bool? shouldScopeFileSystems = null,
+ IScopedNotificationPublisher? notificationPublisher = null)
+ {
+ _eventAggregator = eventAggregator;
+ InstanceId = Guid.NewGuid();
+ CreatedThreadId = Environment.CurrentManagedThreadId;
+ Locks = ParentScope is null
+ ? new LockingMechanism(distributedLockingMechanismFactory, loggerFactory.CreateLogger())
+ : ResolveLockingMechanism();
+ _repositoryCacheMode = repositoryCacheMode;
+ _shouldScopeFileSystems = shouldScopeFileSystems;
+ _notificationPublisher = notificationPublisher;
+
+ if (_shouldScopeFileSystems is true)
+ {
+ _scopedFileSystem = scopedFileSystem.Shadow();
+ }
+ }
+
+ protected CoreScope(
+ ICoreScope? parentScope,
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
+ FileSystems scopedFileSystem,
+ IEventAggregator eventAggregator,
+ RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
+ bool? shouldScopeFileSystems = null,
+ IScopedNotificationPublisher? notificationPublisher = null)
+ {
+ _eventAggregator = eventAggregator;
+ InstanceId = Guid.NewGuid();
+ CreatedThreadId = Environment.CurrentManagedThreadId;
+ _repositoryCacheMode = repositoryCacheMode;
+ _shouldScopeFileSystems = shouldScopeFileSystems;
+ _notificationPublisher = notificationPublisher;
+
+ if (parentScope is null)
+ {
+ Locks = new LockingMechanism(distributedLockingMechanismFactory, loggerFactory.CreateLogger());
+ if (_shouldScopeFileSystems is true)
+ {
+ _scopedFileSystem = scopedFileSystem.Shadow();
+ }
+
+ return;
+ }
+
+ Locks = parentScope.Locks;
+
+ // cannot specify a different mode!
+ // TODO: means that it's OK to go from L2 to None for reading purposes, but writing would be BAD!
+ // this is for XmlStore that wants to bypass caches when rebuilding XML (same for NuCache)
+ if (repositoryCacheMode != RepositoryCacheMode.Unspecified &&
+ parentScope.RepositoryCacheMode > repositoryCacheMode)
+ {
+ throw new ArgumentException(
+ $"Value '{repositoryCacheMode}' cannot be lower than parent value '{parentScope.RepositoryCacheMode}'.", nameof(repositoryCacheMode));
+ }
+
+ // Only the outermost scope can specify the notification publisher
+ if (_notificationPublisher != null)
+ {
+ throw new ArgumentException("Value cannot be specified on nested scope.", nameof(_notificationPublisher));
+ }
+
+ _parentScope = parentScope;
+
+ // cannot specify a different fs scope!
+ // can be 'true' only on outer scope (and false does not make much sense)
+ if (_shouldScopeFileSystems != null && ParentScope?._shouldScopeFileSystems != _shouldScopeFileSystems)
+ {
+ throw new ArgumentException(
+ $"Value '{_shouldScopeFileSystems.Value}' be different from parent value '{ParentScope?._shouldScopeFileSystems}'.",
+ nameof(_shouldScopeFileSystems));
+ }
+ }
+
+ private CoreScope? ParentScope => (CoreScope?)_parentScope;
+
+ public int Depth
+ {
+ get
+ {
+ if (ParentScope == null)
+ {
+ return 0;
+ }
+
+ return ParentScope.Depth + 1;
+ }
+ }
+
+ public Guid InstanceId { get; }
+
+ public int CreatedThreadId { get; }
+
+ public ILockingMechanism Locks { get; }
+
+ public IScopedNotificationPublisher Notifications
+ {
+ get
+ {
+ EnsureNotDisposed();
+ if (ParentScope != null)
+ {
+ return ParentScope.Notifications;
+ }
+
+ return _notificationPublisher ??= new ScopedNotificationPublisher(_eventAggregator);
+ }
+ }
+
+ public RepositoryCacheMode RepositoryCacheMode
+ {
+ get
+ {
+ if (_repositoryCacheMode != RepositoryCacheMode.Unspecified)
+ {
+ return _repositoryCacheMode;
+ }
+
+ return ParentScope?.RepositoryCacheMode ?? RepositoryCacheMode.Default;
+ }
+ }
+
+ public IsolatedCaches IsolatedCaches
+ {
+ get
+ {
+ if (ParentScope != null)
+ {
+ return ParentScope.IsolatedCaches;
+ }
+
+ return _isolatedCaches ??= new IsolatedCaches(_ => new DeepCloneAppCache(new ObjectCacheAppCache()));
+ }
+ }
+
+ public bool ScopedFileSystems
+ {
+ get
+ {
+ if (ParentScope != null)
+ {
+ return ParentScope.ScopedFileSystems;
+ }
+
+ return _scopedFileSystem != null;
+ }
+ }
+
+ ///
+ /// Completes a scope
+ ///
+ /// A value indicating whether the scope is completed or not.
+ public bool Complete()
+ {
+ if (Completed.HasValue == false)
+ {
+ Completed = true;
+ }
+
+ return Completed.Value;
+ }
+
+ public void ReadLock(params int[] lockIds) => Locks.ReadLock(InstanceId, null, lockIds);
+
+ public void WriteLock(params int[] lockIds) => Locks.WriteLock(InstanceId, null, lockIds);
+
+ public void WriteLock(TimeSpan timeout, int lockId) => Locks.ReadLock(InstanceId, timeout, lockId);
+
+ public void ReadLock(TimeSpan timeout, int lockId) => Locks.WriteLock(InstanceId, timeout, lockId);
+
+ public void EagerWriteLock(params int[] lockIds) => Locks.EagerWriteLock(InstanceId, null, lockIds);
+
+ public void EagerWriteLock(TimeSpan timeout, int lockId) => Locks.EagerWriteLock(InstanceId, timeout, lockId);
+
+ public void EagerReadLock(TimeSpan timeout, int lockId) => Locks.EagerReadLock(InstanceId, timeout, lockId);
+
+ public void EagerReadLock(params int[] lockIds) => Locks.EagerReadLock(InstanceId, TimeSpan.Zero, lockIds);
+
+ public virtual void Dispose()
+ {
+ if (ParentScope is null)
+ {
+ HandleScopedFileSystems();
+ HandleScopedNotifications();
+ }
+ else
+ {
+ ParentScope.ChildCompleted(Completed);
+ }
+
+ _disposed = true;
+ }
+
+ protected void ChildCompleted(bool? completed)
+ {
+ // if child did not complete we cannot complete
+ if (completed.HasValue == false || completed.Value == false)
+ {
+ Completed = false;
+ }
+ }
+
+ private void HandleScopedFileSystems()
+ {
+ if (_shouldScopeFileSystems == true)
+ {
+ if (Completed.HasValue && Completed.Value)
+ {
+ _scopedFileSystem?.Complete();
+ }
+
+ _scopedFileSystem?.Dispose();
+ _scopedFileSystem = null;
+ }
+ }
+
+ protected void SetParentScope(ICoreScope coreScope)
+ {
+ _parentScope = coreScope;
+ }
+
+ private void HandleScopedNotifications() => _notificationPublisher?.ScopeExit(Completed.HasValue && Completed.Value);
+
+ private void EnsureNotDisposed()
+ {
+ // We can't be disposed
+ if (_disposed)
+ {
+ throw new ObjectDisposedException($"The {nameof(CoreScope)} with ID ({InstanceId}) is already disposed");
+ }
+
+ // And neither can our ancestors if we're trying to be disposed since
+ // a child must always be disposed before it's parent.
+ // This is a safety check, it's actually not entirely possible that a parent can be
+ // disposed before the child since that will end up with a "not the Ambient" exception.
+ ParentScope?.EnsureNotDisposed();
+ }
+
+ private ILockingMechanism ResolveLockingMechanism() =>
+ ParentScope is not null ? ParentScope.ResolveLockingMechanism() : Locks;
+}
diff --git a/src/Umbraco.Core/Scoping/ICoreScope.cs b/src/Umbraco.Core/Scoping/ICoreScope.cs
index fe2a9489f3..713ecc7954 100644
--- a/src/Umbraco.Core/Scoping/ICoreScope.cs
+++ b/src/Umbraco.Core/Scoping/ICoreScope.cs
@@ -16,6 +16,8 @@ public interface ICoreScope : IDisposable, IInstanceIdentifiable
///
public int Depth => -1;
+ public ILockingMechanism Locks { get; }
+
///
/// Gets the scope notification publisher
///
diff --git a/src/Umbraco.Core/Scoping/ILockingMechanism.cs b/src/Umbraco.Core/Scoping/ILockingMechanism.cs
new file mode 100644
index 0000000000..22dded1652
--- /dev/null
+++ b/src/Umbraco.Core/Scoping/ILockingMechanism.cs
@@ -0,0 +1,58 @@
+namespace Umbraco.Cms.Core.Scoping;
+
+public interface ILockingMechanism : IDisposable
+{
+ ///
+ /// Read-locks some lock objects lazily.
+ ///
+ /// Instance id of the scope who is requesting the lock
+ /// Array of lock object identifiers.
+ void ReadLock(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds);
+
+ void ReadLock(Guid instanceId, params int[] lockIds);
+
+ ///
+ /// Write-locks some lock objects lazily.
+ ///
+ /// Instance id of the scope who is requesting the lock
+ /// Array of object identifiers.
+ void WriteLock(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds);
+
+ void WriteLock(Guid instanceId, params int[] lockIds);
+
+ ///
+ /// Eagerly acquires a read-lock
+ ///
+ ///
+ ///
+ void EagerReadLock(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds);
+
+ void EagerReadLock(Guid instanceId, params int[] lockIds);
+
+ ///
+ /// Eagerly acquires a write-lock
+ ///
+ ///
+ ///
+ void EagerWriteLock(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds);
+
+ void EagerWriteLock(Guid instanceId, params int[] lockIds);
+
+ ///
+ /// Clears all the locks held
+ ///
+ ///
+ void ClearLocks(Guid instanceId);
+
+ ///
+ /// Acquires all the non-eagerly requested locks.
+ ///
+ ///
+ void EnsureLocks(Guid scopeInstanceId);
+
+ void EnsureLocksCleared(Guid instanceId);
+
+ Dictionary>? GetReadLocks();
+
+ Dictionary>? GetWriteLocks();
+}
diff --git a/src/Umbraco.Core/Scoping/LockingMechanism.cs b/src/Umbraco.Core/Scoping/LockingMechanism.cs
new file mode 100644
index 0000000000..e41fe2d874
--- /dev/null
+++ b/src/Umbraco.Core/Scoping/LockingMechanism.cs
@@ -0,0 +1,433 @@
+using System.Text;
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.Collections;
+using Umbraco.Cms.Core.DistributedLocking;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Core.Scoping;
+
+///
+/// Mechanism for handling read and write locks
+///
+public class LockingMechanism : ILockingMechanism
+{
+ private readonly IDistributedLockingMechanismFactory _distributedLockingMechanismFactory;
+ private readonly ILogger _logger;
+ private readonly object _lockQueueLocker = new();
+ private readonly object _dictionaryLocker = new();
+ private StackQueue<(DistributedLockType lockType, TimeSpan timeout, Guid instanceId, int lockId)>? _queuedLocks;
+ private HashSet? _readLocks;
+ private Dictionary>? _readLocksDictionary;
+ private HashSet? _writeLocks;
+ private Dictionary>? _writeLocksDictionary;
+ private Queue? _acquiredLocks;
+
+ ///
+ /// Constructs an instance of LockingMechanism
+ ///
+ ///
+ ///
+ public LockingMechanism(IDistributedLockingMechanismFactory distributedLockingMechanismFactory, ILogger logger)
+ {
+ _distributedLockingMechanismFactory = distributedLockingMechanismFactory;
+ _logger = logger;
+ _acquiredLocks = new Queue();
+ }
+
+ ///
+ public void ReadLock(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds) => LazyReadLockInner(instanceId, timeout, lockIds);
+
+ public void ReadLock(Guid instanceId, params int[] lockIds) => ReadLock(instanceId, null, lockIds);
+
+ ///
+ public void WriteLock(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds) => LazyWriteLockInner(instanceId, timeout, lockIds);
+
+ public void WriteLock(Guid instanceId, params int[] lockIds) => WriteLock(instanceId, null, lockIds);
+
+ ///
+ public void EagerReadLock(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds) => EagerReadLockInner(instanceId, timeout, lockIds);
+
+ public void EagerReadLock(Guid instanceId, params int[] lockIds) =>
+ EagerReadLock(instanceId, null, lockIds);
+
+ ///
+ public void EagerWriteLock(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds) => EagerWriteLockInner(instanceId, timeout, lockIds);
+
+ public void EagerWriteLock(Guid instanceId, params int[] lockIds) =>
+ EagerWriteLock(instanceId, null, lockIds);
+
+ ///
+ /// Handles acquiring a write lock with a specified timeout, will delegate it to the parent if there are any.
+ ///
+ /// Instance ID of the requesting scope.
+ /// Optional database timeout in milliseconds.
+ /// Array of lock object identifiers.
+ private void EagerWriteLockInner(Guid instanceId, TimeSpan? timeout, params int[] lockIds)
+ {
+ lock (_dictionaryLocker)
+ {
+ foreach (var lockId in lockIds)
+ {
+ IncrementLock(lockId, instanceId, ref _writeLocksDictionary);
+
+ // We are the outermost scope, handle the lock request.
+ LockInner(
+ instanceId,
+ ref _writeLocksDictionary!,
+ ref _writeLocks!,
+ ObtainWriteLock,
+ timeout,
+ lockId);
+ }
+ }
+ }
+
+ ///
+ /// Obtains a write lock with a custom timeout.
+ ///
+ /// Lock object identifier to lock.
+ /// TimeSpan specifying the timout period.
+ private void ObtainWriteLock(int lockId, TimeSpan? timeout)
+ {
+ if (_acquiredLocks == null)
+ {
+ throw new InvalidOperationException(
+ $"Cannot obtain a write lock as the {nameof(_acquiredLocks)} queue is null.");
+ }
+
+ _acquiredLocks.Enqueue(_distributedLockingMechanismFactory.DistributedLockingMechanism.WriteLock(lockId, timeout));
+ }
+
+ ///
+ /// Handles acquiring a read lock, will delegate it to the parent if there are any.
+ ///
+ /// The id of the scope requesting the lock.
+ /// Optional database timeout in milliseconds.
+ /// Array of lock object identifiers.
+ private void EagerReadLockInner(Guid instanceId, TimeSpan? timeout, params int[] lockIds)
+ {
+ lock (_dictionaryLocker)
+ {
+ foreach (var lockId in lockIds)
+ {
+ IncrementLock(lockId, instanceId, ref _readLocksDictionary);
+
+ // We are the outermost scope, handle the lock request.
+ LockInner(
+ instanceId,
+ ref _readLocksDictionary!,
+ ref _readLocks!,
+ ObtainReadLock,
+ timeout,
+ lockId);
+ }
+ }
+ }
+
+ ///
+ /// Obtains a read lock with a custom timeout.
+ ///
+ /// Lock object identifier to lock.
+ /// TimeSpan specifying the timout period.
+ private void ObtainReadLock(int lockId, TimeSpan? timeout)
+ {
+ if (_acquiredLocks == null)
+ {
+ throw new InvalidOperationException(
+ $"Cannot obtain a read lock as the {nameof(_acquiredLocks)} queue is null.");
+ }
+
+ _acquiredLocks.Enqueue(
+ _distributedLockingMechanismFactory.DistributedLockingMechanism.ReadLock(lockId, timeout));
+ }
+
+ ///
+ /// Handles acquiring a lock, this should only be called from the outermost scope.
+ ///
+ /// Instance ID of the scope requesting the lock.
+ /// Reference to the applicable locks dictionary (ReadLocks or WriteLocks).
+ /// Reference to the applicable locks hashset (_readLocks or _writeLocks).
+ /// Delegate used to request the lock from the locking mechanism.
+ /// Optional timeout parameter to specify a timeout.
+ /// Lock identifier.
+ private void LockInner(
+ Guid instanceId,
+ ref Dictionary> locks,
+ ref HashSet? locksSet,
+ Action obtainLock,
+ TimeSpan? timeout,
+ int lockId)
+ {
+ locksSet ??= new HashSet();
+
+ // Only acquire the lock if we haven't done so yet.
+ if (locksSet.Contains(lockId))
+ {
+ return;
+ }
+
+ locksSet.Add(lockId);
+ try
+ {
+ obtainLock(lockId, timeout);
+ }
+ catch
+ {
+ // Something went wrong and we didn't get the lock
+ // Since we at this point have determined that we haven't got any lock with an ID of LockID, it's safe to completely remove it instead of decrementing.
+ locks[instanceId].Remove(lockId);
+
+ // It needs to be removed from the HashSet as well, because that's how we determine to acquire a lock.
+ locksSet.Remove(lockId);
+ throw;
+ }
+ }
+
+ ///
+ /// Increment the counter of a locks dictionary, either ReadLocks or WriteLocks,
+ /// for a specific scope instance and lock identifier. Must be called within a lock.
+ ///
+ /// Lock ID to increment.
+ /// Instance ID of the scope requesting the lock.
+ /// Reference to the dictionary to increment on
+ private void IncrementLock(int lockId, Guid instanceId, ref Dictionary>? locks)
+ {
+ // Since we've already checked that we're the parent in the WriteLockInner method, we don't need to check again.
+ // If it's the very first time a lock has been requested the WriteLocks dict hasn't been instantiated yet.
+ locks ??= new Dictionary>();
+
+ // Try and get the dict associated with the scope id.
+ var locksDictFound = locks.TryGetValue(instanceId, out Dictionary? locksDict);
+ if (locksDictFound)
+ {
+ locksDict!.TryGetValue(lockId, out var value);
+ locksDict[lockId] = value + 1;
+ }
+ else
+ {
+ // The scope hasn't requested a lock yet, so we have to create a dict for it.
+ locks.Add(instanceId, new Dictionary());
+ locks[instanceId][lockId] = 1;
+ }
+ }
+
+ private void LazyWriteLockInner(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds) =>
+ LazyLockInner(DistributedLockType.WriteLock, instanceId, timeout, lockIds);
+
+ private void LazyReadLockInner(Guid instanceId, TimeSpan? timeout = null, params int[] lockIds) =>
+ LazyLockInner(DistributedLockType.ReadLock, instanceId, timeout, lockIds);
+
+ private void LazyLockInner(DistributedLockType lockType, Guid instanceId, TimeSpan? timeout = null, params int[] lockIds)
+ {
+ lock (_lockQueueLocker)
+ {
+ if (_queuedLocks == null)
+ {
+ _queuedLocks = new StackQueue<(DistributedLockType, TimeSpan, Guid, int)>();
+ }
+
+ foreach (var lockId in lockIds)
+ {
+ _queuedLocks.Enqueue((lockType, timeout ?? TimeSpan.Zero, instanceId, lockId));
+ }
+ }
+ }
+
+ ///
+ /// Clears all lock counters for a given scope instance, signalling that the scope has been disposed.
+ ///
+ /// Instance ID of the scope to clear.
+ public void ClearLocks(Guid instanceId)
+ {
+ lock (_dictionaryLocker)
+ {
+ _readLocksDictionary?.Remove(instanceId);
+ _writeLocksDictionary?.Remove(instanceId);
+
+ // remove any queued locks for this instance that weren't used.
+ while (_queuedLocks?.Count > 0)
+ {
+ // It's safe to assume that the locks on the top of the stack belong to this instance,
+ // since any child scopes that might have added locks to the stack must be disposed before we try and dispose this instance.
+ (DistributedLockType lockType, TimeSpan timeout, Guid instanceId, int lockId) top =
+ _queuedLocks.PeekStack();
+ if (top.instanceId == instanceId)
+ {
+ _queuedLocks.Pop();
+ }
+ else
+ {
+ break;
+ }
+ }
+ }
+ }
+
+ public void EnsureLocksCleared(Guid instanceId)
+ {
+ while (!_acquiredLocks?.IsCollectionEmpty() ?? false)
+ {
+ _acquiredLocks?.Dequeue().Dispose();
+ }
+
+ // We're the parent scope, make sure that locks of all scopes has been cleared
+ // Since we're only reading we don't have to be in a lock
+ if (!(_readLocksDictionary?.Count > 0) && !(_writeLocksDictionary?.Count > 0))
+ {
+ return;
+ }
+
+ var exception = new InvalidOperationException(
+ $"All scopes has not been disposed from parent scope: {instanceId}, see log for more details.");
+ throw exception;
+ }
+
+ ///
+ /// When we require a ReadLock or a WriteLock we don't immediately request these locks from the database,
+ /// instead we only request them when necessary (lazily).
+ /// To do this, we queue requests for read/write locks.
+ /// This is so that if there's a request for either of these
+ /// locks, but the service/repository returns an item from the cache, we don't end up making a DB call to make the
+ /// read/write lock.
+ /// This executes the queue of requested locks in order in an efficient way lazily whenever the database instance is
+ /// resolved.
+ ///
+ public void EnsureLocks(Guid scopeInstanceId)
+ {
+ lock (_lockQueueLocker)
+ {
+ if (!(_queuedLocks?.Count > 0))
+ {
+ return;
+ }
+
+ DistributedLockType currentType = DistributedLockType.ReadLock;
+ TimeSpan currentTimeout = TimeSpan.Zero;
+ Guid currentInstanceId = scopeInstanceId;
+ var collectedIds = new HashSet();
+
+ var i = 0;
+ while (_queuedLocks.Count > 0)
+ {
+ (DistributedLockType lockType, TimeSpan timeout, Guid instanceId, var lockId) =
+ _queuedLocks.Dequeue();
+
+ if (i == 0)
+ {
+ currentType = lockType;
+ currentTimeout = timeout;
+ currentInstanceId = instanceId;
+ }
+ else if (lockType != currentType || timeout != currentTimeout ||
+ instanceId != currentInstanceId)
+ {
+ // the lock type, instanceId or timeout switched.
+ // process the lock ids collected
+ switch (currentType)
+ {
+ case DistributedLockType.ReadLock:
+ EagerReadLockInner(
+ currentInstanceId,
+ currentTimeout == TimeSpan.Zero ? null : currentTimeout,
+ collectedIds.ToArray());
+ break;
+ case DistributedLockType.WriteLock:
+ EagerWriteLockInner(
+ currentInstanceId,
+ currentTimeout == TimeSpan.Zero ? null : currentTimeout,
+ collectedIds.ToArray());
+ break;
+ }
+
+ // clear the collected and set new type
+ collectedIds.Clear();
+ currentType = lockType;
+ currentTimeout = timeout;
+ currentInstanceId = instanceId;
+ }
+
+ collectedIds.Add(lockId);
+ i++;
+ }
+
+ // process the remaining
+ switch (currentType)
+ {
+ case DistributedLockType.ReadLock:
+ EagerReadLockInner(
+ currentInstanceId,
+ currentTimeout == TimeSpan.Zero ? null : currentTimeout,
+ collectedIds.ToArray());
+ break;
+ case DistributedLockType.WriteLock:
+ EagerWriteLockInner(
+ currentInstanceId,
+ currentTimeout == TimeSpan.Zero ? null : currentTimeout,
+ collectedIds.ToArray());
+ break;
+ }
+ }
+ }
+
+
+ public Dictionary>? GetReadLocks() => _readLocksDictionary;
+
+ public Dictionary>? GetWriteLocks() => _writeLocksDictionary;
+
+ ///
+ public void Dispose()
+ {
+ while (!_acquiredLocks?.IsCollectionEmpty() ?? false)
+ {
+ _acquiredLocks?.Dequeue().Dispose();
+ }
+
+ // We're the parent scope, make sure that locks of all scopes has been cleared
+ // Since we're only reading we don't have to be in a lock
+ if (_readLocksDictionary?.Count > 0 || _writeLocksDictionary?.Count > 0)
+ {
+ var exception = new InvalidOperationException(
+ $"All locks have not been cleared, this usually means that all scopes have not been disposed from the parent scope");
+ _logger.LogError(exception, GenerateUnclearedScopesLogMessage());
+ throw exception;
+ }
+ }
+
+ ///
+ /// Generates a log message with all scopes that hasn't cleared their locks, including how many, and what locks they
+ /// have requested.
+ ///
+ /// Log message.
+ private string GenerateUnclearedScopesLogMessage()
+ {
+ // Dump the dicts into a message for the locks.
+ var builder = new StringBuilder();
+ builder.AppendLine(
+ $"Lock counters aren't empty, suggesting a scope hasn't been properly disposed");
+ WriteLockDictionaryToString(_readLocksDictionary!, builder, "read locks");
+ WriteLockDictionaryToString(_writeLocksDictionary!, builder, "write locks");
+ return builder.ToString();
+ }
+
+ ///
+ /// Writes a locks dictionary to a for logging purposes.
+ ///
+ /// Lock dictionary to report on.
+ /// String builder to write to.
+ /// The name to report the dictionary as.
+ private void WriteLockDictionaryToString(Dictionary> dict, StringBuilder builder, string dictName)
+ {
+ if (dict?.Count > 0)
+ {
+ builder.AppendLine($"Remaining {dictName}:");
+ foreach (KeyValuePair> instance in dict)
+ {
+ builder.AppendLine($"Scope {instance.Key}");
+ foreach (KeyValuePair lockCounter in instance.Value)
+ {
+ builder.AppendLine($"\tLock ID: {lockCounter.Key} - times requested: {lockCounter.Value}");
+ }
+ }
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Scoping/IAmbientScopeContextStack.cs b/src/Umbraco.Infrastructure/Scoping/IAmbientScopeContextStack.cs
index 28da9a6427..f481166d8f 100644
--- a/src/Umbraco.Infrastructure/Scoping/IAmbientScopeContextStack.cs
+++ b/src/Umbraco.Infrastructure/Scoping/IAmbientScopeContextStack.cs
@@ -2,7 +2,7 @@ using Umbraco.Cms.Core.Scoping;
namespace Umbraco.Cms.Infrastructure.Scoping;
-internal interface IAmbientScopeContextStack
+public interface IAmbientScopeContextStack
{
IScopeContext? AmbientContext { get; }
IScopeContext Pop();
diff --git a/src/Umbraco.Infrastructure/Scoping/Scope.cs b/src/Umbraco.Infrastructure/Scoping/Scope.cs
index 000b6a602e..0ff1fa5d30 100644
--- a/src/Umbraco.Infrastructure/Scoping/Scope.cs
+++ b/src/Umbraco.Infrastructure/Scoping/Scope.cs
@@ -1,13 +1,10 @@
using System.Data;
using System.Text;
using Microsoft.Extensions.Logging;
-using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DistributedLocking;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.IO;
-using Umbraco.Cms.Core;
-using Umbraco.Cms.Core.Collections;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Extensions;
@@ -18,46 +15,28 @@ namespace Umbraco.Cms.Infrastructure.Scoping
/// Implements .
///
/// Not thread-safe obviously.
- internal class Scope : ICoreScope, IScope, Core.Scoping.IScope
+ internal class Scope : CoreScope, ICoreScope, IScope, Core.Scoping.IScope
{
private readonly bool _autoComplete;
private readonly CoreDebugSettings _coreDebugSettings;
-
- private readonly object _dictionaryLocker;
- private readonly IEventAggregator _eventAggregator;
private readonly IsolationLevel _isolationLevel;
- private readonly object _lockQueueLocker = new();
private readonly ILogger _logger;
private readonly MediaFileManager _mediaFileManager;
- private readonly RepositoryCacheMode _repositoryCacheMode;
- private readonly bool? _scopeFileSystem;
private readonly ScopeProvider _scopeProvider;
- private bool? _completed;
private IUmbracoDatabase? _database;
private bool _disposed;
private IEventDispatcher? _eventDispatcher;
- private ICompletable? _fscope;
private EventMessages? _messages;
- private IsolatedCaches? _isolatedCaches;
- private IScopedNotificationPublisher? _notificationPublisher;
-
- private StackQueue<(DistributedLockType lockType, TimeSpan timeout, Guid instanceId, int lockId)>? _queuedLocks;
-
- // This is all used to safely track read/write locks at given Scope levels so that
- // when we dispose we can verify that everything has been cleaned up correctly.
- private HashSet? _readLocks;
- private Dictionary>? _readLocksDictionary;
- private HashSet? _writeLocks;
- private Dictionary>? _writeLocksDictionary;
- private Queue? _acquiredLocks;
// initializes a new scope
private Scope(
ScopeProvider scopeProvider,
CoreDebugSettings coreDebugSettings,
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
MediaFileManager mediaFileManager,
IEventAggregator eventAggregator,
ILogger logger,
@@ -72,22 +51,26 @@ namespace Umbraco.Cms.Infrastructure.Scoping
bool? scopeFileSystems = null,
bool callContext = false,
bool autoComplete = false)
+ : base(
+ parent,
+ distributedLockingMechanismFactory,
+ loggerFactory,
+ fileSystems,
+ eventAggregator,
+ repositoryCacheMode,
+ scopeFileSystems,
+ notificationPublisher)
{
_scopeProvider = scopeProvider;
_coreDebugSettings = coreDebugSettings;
_mediaFileManager = mediaFileManager;
- _eventAggregator = eventAggregator;
_logger = logger;
Context = scopeContext;
_isolationLevel = isolationLevel;
- _repositoryCacheMode = repositoryCacheMode;
_eventDispatcher = eventDispatcher;
- _notificationPublisher = notificationPublisher;
- _scopeFileSystem = scopeFileSystems;
_autoComplete = autoComplete;
Detachable = detachable;
- _dictionaryLocker = new object();
#if DEBUG_SCOPES
_scopeProvider.RegisterScope(this);
@@ -114,14 +97,6 @@ namespace Umbraco.Cms.Infrastructure.Scoping
// detachable creates its own scope context
Context = new ScopeContext();
- // see note below
- if (scopeFileSystems == true)
- {
- _fscope = fileSystems.Shadow();
- }
-
- _acquiredLocks = new Queue();
-
return;
}
@@ -129,47 +104,11 @@ namespace Umbraco.Cms.Infrastructure.Scoping
{
ParentScope = parent;
- // cannot specify a different mode!
- // TODO: means that it's OK to go from L2 to None for reading purposes, but writing would be BAD!
- // this is for XmlStore that wants to bypass caches when rebuilding XML (same for NuCache)
- if (repositoryCacheMode != RepositoryCacheMode.Unspecified &&
- parent.RepositoryCacheMode > repositoryCacheMode)
- {
- throw new ArgumentException(
- $"Value '{repositoryCacheMode}' cannot be lower than parent value '{parent.RepositoryCacheMode}'.", nameof(repositoryCacheMode));
- }
-
// cannot specify a dispatcher!
if (_eventDispatcher != null)
{
throw new ArgumentException("Value cannot be specified on nested scope.", nameof(eventDispatcher));
}
-
- // Only the outermost scope can specify the notification publisher
- if (_notificationPublisher != null)
- {
- throw new ArgumentException("Value cannot be specified on nested scope.", nameof(notificationPublisher));
- }
-
- // cannot specify a different fs scope!
- // can be 'true' only on outer scope (and false does not make much sense)
- if (scopeFileSystems != null && parent._scopeFileSystem != scopeFileSystems)
- {
- throw new ArgumentException(
- $"Value '{scopeFileSystems.Value}' be different from parent value '{parent._scopeFileSystem}'.", nameof(scopeFileSystems));
- }
- }
- else
- {
- _acquiredLocks = new Queue();
-
- // the FS scope cannot be "on demand" like the rest, because we would need to hook into
- // every scoped FS to trigger the creation of shadow FS "on demand", and that would be
- // pretty pointless since if scopeFileSystems is true, we *know* we want to shadow
- if (scopeFileSystems == true)
- {
- _fscope = fileSystems.Shadow();
- }
}
}
@@ -178,6 +117,8 @@ namespace Umbraco.Cms.Infrastructure.Scoping
ScopeProvider scopeProvider,
CoreDebugSettings coreDebugSettings,
MediaFileManager mediaFileManager,
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
IEventAggregator eventAggregator,
ILogger logger,
FileSystems fileSystems,
@@ -193,6 +134,8 @@ namespace Umbraco.Cms.Infrastructure.Scoping
: this(
scopeProvider,
coreDebugSettings,
+ distributedLockingMechanismFactory,
+ loggerFactory,
mediaFileManager,
eventAggregator,
logger,
@@ -215,6 +158,8 @@ namespace Umbraco.Cms.Infrastructure.Scoping
ScopeProvider scopeProvider,
CoreDebugSettings coreDebugSettings,
MediaFileManager mediaFileManager,
+ IDistributedLockingMechanismFactory distributedLockingMechanismFactory,
+ ILoggerFactory loggerFactory,
IEventAggregator eventAggregator,
ILogger logger,
FileSystems fileSystems,
@@ -229,6 +174,8 @@ namespace Umbraco.Cms.Infrastructure.Scoping
: this(
scopeProvider,
coreDebugSettings,
+ distributedLockingMechanismFactory,
+ loggerFactory,
mediaFileManager,
eventAggregator,
logger,
@@ -257,19 +204,6 @@ namespace Umbraco.Cms.Infrastructure.Scoping
}
}
- public bool ScopedFileSystems
- {
- get
- {
- if (ParentScope != null)
- {
- return ParentScope.ScopedFileSystems;
- }
-
- return _fscope != null;
- }
- }
-
// a value indicating whether the scope is detachable
// ie whether it was created by CreateDetachedScope
public bool Detachable { get; }
@@ -309,10 +243,6 @@ namespace Umbraco.Cms.Infrastructure.Scoping
// true if Umbraco.CoreDebugSettings.LogUncompletedScope appSetting is set to "true"
private bool LogUncompletedScopes => _coreDebugSettings.LogIncompletedScopes;
- public Guid InstanceId { get; } = Guid.NewGuid();
-
- public int CreatedThreadId { get; } = Thread.CurrentThread.ManagedThreadId;
-
public ISqlContext SqlContext
{
get
@@ -327,39 +257,6 @@ namespace Umbraco.Cms.Infrastructure.Scoping
}
}
- ///
- public RepositoryCacheMode RepositoryCacheMode
- {
- get
- {
- if (_repositoryCacheMode != RepositoryCacheMode.Unspecified)
- {
- return _repositoryCacheMode;
- }
-
- if (ParentScope != null)
- {
- return ParentScope.RepositoryCacheMode;
- }
-
- return RepositoryCacheMode.Default;
- }
- }
-
- ///
- public IsolatedCaches IsolatedCaches
- {
- get
- {
- if (ParentScope != null)
- {
- return ParentScope.IsolatedCaches;
- }
-
- return _isolatedCaches ??= new IsolatedCaches(type => new DeepCloneAppCache(new ObjectCacheAppCache()));
- }
- }
-
///
public IUmbracoDatabase Database
{
@@ -383,7 +280,7 @@ namespace Umbraco.Cms.Infrastructure.Scoping
// UmbracoDatabase instance directly and ensure it's called when OnExecutingCommand
// (so long as the executing command isn't a lock command itself!)
// If we could do that, that would be the ultimate lazy executed locks.
- EnsureDbLocks();
+ Locks.EnsureLocks(InstanceId);
return _database;
}
@@ -407,7 +304,7 @@ namespace Umbraco.Cms.Infrastructure.Scoping
try
{
_database.BeginTransaction(IsolationLevel);
- EnsureDbLocks();
+ Locks.EnsureLocks(InstanceId);
return _database;
}
catch
@@ -442,6 +339,7 @@ namespace Umbraco.Cms.Infrastructure.Scoping
}
///
+ [Obsolete("Will be removed in 14, please use notifications instead")]
public IEventDispatcher Events
{
get
@@ -456,45 +354,6 @@ namespace Umbraco.Cms.Infrastructure.Scoping
}
}
- public int Depth
- {
- get
- {
- if (ParentScope == null)
- {
- return 0;
- }
-
- return ParentScope.Depth + 1;
- }
- }
-
- public IScopedNotificationPublisher Notifications
- {
- get
- {
- EnsureNotDisposed();
- if (ParentScope != null)
- {
- return ParentScope.Notifications;
- }
-
- return _notificationPublisher ??
- (_notificationPublisher = new ScopedNotificationPublisher(_eventAggregator));
- }
- }
-
- ///
- public bool Complete()
- {
- if (_completed.HasValue == false)
- {
- _completed = true;
- }
-
- return _completed.Value;
- }
-
public void Dispose()
{
EnsureNotDisposed();
@@ -522,24 +381,11 @@ namespace Umbraco.Cms.Infrastructure.Scoping
#endif
}
- // Decrement the lock counters on the parent if any.
- ClearLocks(InstanceId);
+ Locks.ClearLocks(InstanceId);
+
if (ParentScope is null)
{
- while (!_acquiredLocks?.IsCollectionEmpty() ?? false)
- {
- _acquiredLocks?.Dequeue().Dispose();
- }
-
- // We're the parent scope, make sure that locks of all scopes has been cleared
- // Since we're only reading we don't have to be in a lock
- if (_readLocksDictionary?.Count > 0 || _writeLocksDictionary?.Count > 0)
- {
- var exception = new InvalidOperationException(
- $"All scopes has not been disposed from parent scope: {InstanceId}, see log for more details.");
- _logger.LogError(exception, GenerateUnclearedScopesLogMessage());
- throw exception;
- }
+ Locks.EnsureLocksCleared(InstanceId);
}
_scopeProvider.PopAmbientScope(); // might be null = this is how scopes are removed from context objects
@@ -548,64 +394,33 @@ namespace Umbraco.Cms.Infrastructure.Scoping
_scopeProvider.Disposed(this);
#endif
- if (_autoComplete && _completed == null)
+ if (_autoComplete && Completed == null)
{
- _completed = true;
+ Completed = true;
}
if (ParentScope != null)
{
- ParentScope.ChildCompleted(_completed);
+ ParentScope.ChildCompleted(Completed);
}
else
{
DisposeLastScope();
}
- lock (_lockQueueLocker)
- {
- _queuedLocks?.Clear();
- }
+ base.Dispose();
_disposed = true;
}
- public void EagerReadLock(params int[] lockIds) => EagerReadLockInner(InstanceId, null, lockIds);
-
- ///
- public void ReadLock(params int[] lockIds) => LazyReadLockInner(InstanceId, lockIds);
-
- public void EagerReadLock(TimeSpan timeout, int lockId) =>
- EagerReadLockInner(InstanceId, timeout, lockId);
-
- ///
- public void ReadLock(TimeSpan timeout, int lockId) => LazyReadLockInner(InstanceId, timeout, lockId);
-
- public void EagerWriteLock(params int[] lockIds) => EagerWriteLockInner(InstanceId, null, lockIds);
-
- ///
- public void WriteLock(params int[] lockIds) => LazyWriteLockInner(InstanceId, lockIds);
-
- public void EagerWriteLock(TimeSpan timeout, int lockId) =>
- EagerWriteLockInner(InstanceId, timeout, lockId);
-
- ///
- public void WriteLock(TimeSpan timeout, int lockId) => LazyWriteLockInner(InstanceId, timeout, lockId);
-
///
/// Used for testing. Ensures and gets any queued read locks.
///
///
internal Dictionary>? GetReadLocks()
{
- EnsureDbLocks();
- // always delegate to root/parent scope.
- if (ParentScope is not null)
- {
- return ParentScope.GetReadLocks();
- }
-
- return _readLocksDictionary;
+ Locks.EnsureLocks(InstanceId);
+ return ((LockingMechanism)Locks).GetReadLocks();
}
///
@@ -614,113 +429,13 @@ namespace Umbraco.Cms.Infrastructure.Scoping
///
internal Dictionary>? GetWriteLocks()
{
- EnsureDbLocks();
- // always delegate to root/parent scope.
- if (ParentScope is not null)
- {
- return ParentScope.GetWriteLocks();
- }
-
- return _writeLocksDictionary;
+ Locks.EnsureLocks(InstanceId);
+ return ((LockingMechanism)Locks).GetWriteLocks();
}
- public void Reset() => _completed = null;
+ public void Reset() => Completed = null;
- public void ChildCompleted(bool? completed)
- {
- // if child did not complete we cannot complete
- if (completed.HasValue == false || completed.Value == false)
- {
- if (_coreDebugSettings.LogIncompletedScopes)
- {
- _logger.LogWarning("Uncompleted Child Scope at\r\n {StackTrace}", Environment.StackTrace);
- }
-
- _completed = false;
- }
- }
-
- ///
- /// When we require a ReadLock or a WriteLock we don't immediately request these locks from the database,
- /// instead we only request them when necessary (lazily).
- /// To do this, we queue requests for read/write locks.
- /// This is so that if there's a request for either of these
- /// locks, but the service/repository returns an item from the cache, we don't end up making a DB call to make the
- /// read/write lock.
- /// This executes the queue of requested locks in order in an efficient way lazily whenever the database instance is
- /// resolved.
- ///
- private void EnsureDbLocks()
- {
- // always delegate to the root parent
- if (ParentScope is not null)
- {
- ParentScope.EnsureDbLocks();
- }
- else
- {
- lock (_lockQueueLocker)
- {
- if (_queuedLocks?.Count > 0)
- {
- DistributedLockType currentType = DistributedLockType.ReadLock;
- TimeSpan currentTimeout = TimeSpan.Zero;
- Guid currentInstanceId = InstanceId;
- var collectedIds = new HashSet();
-
- var i = 0;
- while (_queuedLocks.Count > 0)
- {
- (DistributedLockType lockType, TimeSpan timeout, Guid instanceId, var lockId) = _queuedLocks.Dequeue();
-
- if (i == 0)
- {
- currentType = lockType;
- currentTimeout = timeout;
- currentInstanceId = instanceId;
- }
- else if (lockType != currentType || timeout != currentTimeout ||
- instanceId != currentInstanceId)
- {
- // the lock type, instanceId or timeout switched.
- // process the lock ids collected
- switch (currentType)
- {
- case DistributedLockType.ReadLock:
- EagerReadLockInner(currentInstanceId, currentTimeout == TimeSpan.Zero ? null : currentTimeout, collectedIds.ToArray());
- break;
- case DistributedLockType.WriteLock:
- EagerWriteLockInner(currentInstanceId, currentTimeout == TimeSpan.Zero ? null : currentTimeout, collectedIds.ToArray());
- break;
- }
-
- // clear the collected and set new type
- collectedIds.Clear();
- currentType = lockType;
- currentTimeout = timeout;
- currentInstanceId = instanceId;
- }
-
- collectedIds.Add(lockId);
- i++;
- }
-
- // process the remaining
- switch (currentType)
- {
- case DistributedLockType.ReadLock:
- EagerReadLockInner(currentInstanceId, currentTimeout == TimeSpan.Zero ? null : currentTimeout, collectedIds.ToArray());
- break;
- case DistributedLockType.WriteLock:
- EagerWriteLockInner(currentInstanceId, currentTimeout == TimeSpan.Zero ? null : currentTimeout, collectedIds.ToArray());
- break;
- }
- }
- }
- }
- }
-
- private void EnsureNotDisposed()
+ internal void EnsureNotDisposed()
{
// We can't be disposed
if (_disposed)
@@ -739,48 +454,10 @@ namespace Umbraco.Cms.Infrastructure.Scoping
// throw new ObjectDisposedException(GetType().FullName);
}
- ///
- /// Generates a log message with all scopes that hasn't cleared their locks, including how many, and what locks they
- /// have requested.
- ///
- /// Log message.
- private string GenerateUnclearedScopesLogMessage()
- {
- // Dump the dicts into a message for the locks.
- var builder = new StringBuilder();
- builder.AppendLine(
- $"Lock counters aren't empty, suggesting a scope hasn't been properly disposed, parent id: {InstanceId}");
- WriteLockDictionaryToString(_readLocksDictionary!, builder, "read locks");
- WriteLockDictionaryToString(_writeLocksDictionary!, builder, "write locks");
- return builder.ToString();
- }
-
- ///
- /// Writes a locks dictionary to a for logging purposes.
- ///
- /// Lock dictionary to report on.
- /// String builder to write to.
- /// The name to report the dictionary as.
- private void WriteLockDictionaryToString(Dictionary> dict, StringBuilder builder, string dictName)
- {
- if (dict?.Count > 0)
- {
- builder.AppendLine($"Remaining {dictName}:");
- foreach (KeyValuePair> instance in dict)
- {
- builder.AppendLine($"Scope {instance.Key}");
- foreach (KeyValuePair lockCounter in instance.Value)
- {
- builder.AppendLine($"\tLock ID: {lockCounter.Key} - times requested: {lockCounter.Value}");
- }
- }
- }
- }
-
private void DisposeLastScope()
{
// figure out completed
- var completed = _completed.HasValue && _completed.Value;
+ var completed = Completed.HasValue && Completed.Value;
// deal with database
var databaseException = false;
@@ -837,29 +514,6 @@ namespace Umbraco.Cms.Infrastructure.Scoping
completed = false;
}
- void HandleScopedFileSystems()
- {
- if (_scopeFileSystem == true)
- {
- if (completed)
- {
- _fscope?.Complete();
- }
-
- _fscope?.Dispose();
- _fscope = null;
- }
- }
-
- void HandleScopedNotifications()
- {
- if (onException == false)
- {
- _eventDispatcher?.ScopeExit(completed);
- _notificationPublisher?.ScopeExit(completed);
- }
- }
-
void HandleScopeContext()
{
// if *we* created it, then get rid of it
@@ -902,8 +556,6 @@ namespace Umbraco.Cms.Infrastructure.Scoping
}
TryFinally(
- HandleScopedFileSystems,
- HandleScopedNotifications,
HandleScopeContext,
HandleDetachedScopes);
}
@@ -929,288 +581,5 @@ namespace Umbraco.Cms.Infrastructure.Scoping
throw new AggregateException(exceptions);
}
}
-
- ///
- /// Increment the counter of a locks dictionary, either ReadLocks or WriteLocks,
- /// for a specific scope instance and lock identifier. Must be called within a lock.
- ///
- /// Lock ID to increment.
- /// Instance ID of the scope requesting the lock.
- /// Reference to the dictionary to increment on
- private void IncrementLock(int lockId, Guid instanceId, ref Dictionary>? locks)
- {
- // Since we've already checked that we're the parent in the WriteLockInner method, we don't need to check again.
- // If it's the very first time a lock has been requested the WriteLocks dict hasn't been instantiated yet.
- locks ??= new Dictionary>();
-
- // Try and get the dict associated with the scope id.
- var locksDictFound = locks.TryGetValue(instanceId, out Dictionary? locksDict);
- if (locksDictFound)
- {
- locksDict!.TryGetValue(lockId, out var value);
- locksDict[lockId] = value + 1;
- }
- else
- {
- // The scope hasn't requested a lock yet, so we have to create a dict for it.
- locks.Add(instanceId, new Dictionary());
- locks[instanceId][lockId] = 1;
- }
- }
-
- ///
- /// Clears all lock counters for a given scope instance, signalling that the scope has been disposed.
- ///
- /// Instance ID of the scope to clear.
- private void ClearLocks(Guid instanceId)
- {
- if (ParentScope is not null)
- {
- ParentScope.ClearLocks(instanceId);
- }
- else
- {
- lock (_dictionaryLocker)
- {
- _readLocksDictionary?.Remove(instanceId);
- _writeLocksDictionary?.Remove(instanceId);
-
- // remove any queued locks for this instance that weren't used.
- while (_queuedLocks?.Count > 0)
- {
- // It's safe to assume that the locks on the top of the stack belong to this instance,
- // since any child scopes that might have added locks to the stack must be disposed before we try and dispose this instance.
- (DistributedLockType lockType, TimeSpan timeout, Guid instanceId, int lockId) top =
- _queuedLocks.PeekStack();
- if (top.instanceId == instanceId)
- {
- _queuedLocks.Pop();
- }
- else
- {
- break;
- }
- }
- }
- }
- }
-
- public void LazyReadLockInner(Guid instanceId, params int[] lockIds)
- {
- if (ParentScope != null)
- {
- ParentScope.LazyReadLockInner(instanceId, lockIds);
- }
- else
- {
- LazyLockInner(DistributedLockType.ReadLock, instanceId, lockIds);
- }
- }
-
- public void LazyReadLockInner(Guid instanceId, TimeSpan timeout, int lockId)
- {
- if (ParentScope != null)
- {
- ParentScope.LazyReadLockInner(instanceId, timeout, lockId);
- }
- else
- {
- LazyLockInner(DistributedLockType.ReadLock, instanceId, timeout, lockId);
- }
- }
-
- public void LazyWriteLockInner(Guid instanceId, params int[] lockIds)
- {
- if (ParentScope != null)
- {
- ParentScope.LazyWriteLockInner(instanceId, lockIds);
- }
- else
- {
- LazyLockInner(DistributedLockType.WriteLock, instanceId, lockIds);
- }
- }
-
- public void LazyWriteLockInner(Guid instanceId, TimeSpan timeout, int lockId)
- {
- if (ParentScope != null)
- {
- ParentScope.LazyWriteLockInner(instanceId, timeout, lockId);
- }
- else
- {
- LazyLockInner(DistributedLockType.WriteLock, instanceId, timeout, lockId);
- }
- }
-
- private void LazyLockInner(DistributedLockType lockType, Guid instanceId, params int[] lockIds)
- {
- lock (_lockQueueLocker)
- {
- if (_queuedLocks == null)
- {
- _queuedLocks = new StackQueue<(DistributedLockType, TimeSpan, Guid, int)>();
- }
-
- foreach (var lockId in lockIds)
- {
- _queuedLocks.Enqueue((lockType, TimeSpan.Zero, instanceId, lockId));
- }
- }
- }
-
- private void LazyLockInner(DistributedLockType lockType, Guid instanceId, TimeSpan timeout, int lockId)
- {
- lock (_lockQueueLocker)
- {
- if (_queuedLocks == null)
- {
- _queuedLocks = new StackQueue<(DistributedLockType, TimeSpan, Guid, int)>();
- }
-
-
- _queuedLocks.Enqueue((lockType, timeout, instanceId, lockId));
- }
- }
-
- ///
- /// Handles acquiring a read lock, will delegate it to the parent if there are any.
- ///
- /// Instance ID of the requesting scope.
- /// Optional database timeout in milliseconds.
- /// Array of lock object identifiers.
- private void EagerReadLockInner(Guid instanceId, TimeSpan? timeout, params int[] lockIds)
- {
- if (ParentScope is not null)
- {
- // If we have a parent we delegate lock creation to parent.
- ParentScope.EagerReadLockInner(instanceId, timeout, lockIds);
- }
- else
- {
- lock (_dictionaryLocker)
- {
- foreach (var lockId in lockIds)
- {
- IncrementLock(lockId, instanceId, ref _readLocksDictionary);
-
- // We are the outermost scope, handle the lock request.
- LockInner(
- instanceId,
- ref _readLocksDictionary!,
- ref _readLocks!,
- ObtainReadLock,
- timeout,
- lockId);
- }
- }
- }
- }
-
- ///
- /// Handles acquiring a write lock with a specified timeout, will delegate it to the parent if there are any.
- ///
- /// Instance ID of the requesting scope.
- /// Optional database timeout in milliseconds.
- /// Array of lock object identifiers.
- private void EagerWriteLockInner(Guid instanceId, TimeSpan? timeout, params int[] lockIds)
- {
- if (ParentScope is not null)
- {
- // If we have a parent we delegate lock creation to parent.
- ParentScope.EagerWriteLockInner(instanceId, timeout, lockIds);
- }
- else
- {
- lock (_dictionaryLocker)
- {
- foreach (var lockId in lockIds)
- {
- IncrementLock(lockId, instanceId, ref _writeLocksDictionary);
-
- // We are the outermost scope, handle the lock request.
- LockInner(
- instanceId,
- ref _writeLocksDictionary!,
- ref _writeLocks!,
- ObtainWriteLock,
- timeout,
- lockId);
- }
- }
- }
- }
-
- ///
- /// Handles acquiring a lock, this should only be called from the outermost scope.
- ///
- /// Instance ID of the scope requesting the lock.
- /// Reference to the applicable locks dictionary (ReadLocks or WriteLocks).
- /// Reference to the applicable locks hashset (_readLocks or _writeLocks).
- /// Delegate used to request the lock from the locking mechanism.
- /// Optional timeout parameter to specify a timeout.
- /// Lock identifier.
- private void LockInner(
- Guid instanceId,
- ref Dictionary> locks,
- ref HashSet locksSet,
- Action obtainLock,
- TimeSpan? timeout,
- int lockId)
- {
- locksSet ??= new HashSet();
-
- // Only acquire the lock if we haven't done so yet.
- if (locksSet.Contains(lockId))
- {
- return;
- }
-
- locksSet.Add(lockId);
- try
- {
- obtainLock(lockId, timeout);
- }
- catch
- {
- // Something went wrong and we didn't get the lock
- // Since we at this point have determined that we haven't got any lock with an ID of LockID, it's safe to completely remove it instead of decrementing.
- locks[instanceId].Remove(lockId);
-
- // It needs to be removed from the HashSet as well, because that's how we determine to acquire a lock.
- locksSet.Remove(lockId);
- throw;
- }
- }
-
- ///
- /// Obtains a read lock with a custom timeout.
- ///
- /// Lock object identifier to lock.
- /// TimeSpan specifying the timout period.
- private void ObtainReadLock(int lockId, TimeSpan? timeout)
- {
- if (_acquiredLocks == null)
- {
- throw new InvalidOperationException($"Cannot obtain a read lock as the {nameof(_acquiredLocks)} queue is null.");
- }
-
- _acquiredLocks.Enqueue(_scopeProvider.DistributedLockingMechanismFactory.DistributedLockingMechanism.ReadLock(lockId, timeout));
- }
-
- ///
- /// Obtains a write lock with a custom timeout.
- ///
- /// Lock object identifier to lock.
- /// TimeSpan specifying the timout period.
- private void ObtainWriteLock(int lockId, TimeSpan? timeout)
- {
- if (_acquiredLocks == null)
- {
- throw new InvalidOperationException($"Cannot obtain a write lock as the {nameof(_acquiredLocks)} queue is null.");
- }
-
- _acquiredLocks.Enqueue(_scopeProvider.DistributedLockingMechanismFactory.DistributedLockingMechanism.WriteLock(lockId, timeout));
- }
}
}
diff --git a/src/Umbraco.Infrastructure/Scoping/ScopeContext.cs b/src/Umbraco.Infrastructure/Scoping/ScopeContext.cs
index 0008626e29..fbaad205a2 100644
--- a/src/Umbraco.Infrastructure/Scoping/ScopeContext.cs
+++ b/src/Umbraco.Infrastructure/Scoping/ScopeContext.cs
@@ -1,6 +1,6 @@
namespace Umbraco.Cms.Core.Scoping;
-internal class ScopeContext : IScopeContext, IInstanceIdentifiable
+public class ScopeContext : IScopeContext, IInstanceIdentifiable
{
private Dictionary? _enlisted;
diff --git a/src/Umbraco.Infrastructure/Scoping/ScopeProvider.cs b/src/Umbraco.Infrastructure/Scoping/ScopeProvider.cs
index 2468a7f80e..5eb367a1b0 100644
--- a/src/Umbraco.Infrastructure/Scoping/ScopeProvider.cs
+++ b/src/Umbraco.Infrastructure/Scoping/ScopeProvider.cs
@@ -151,7 +151,7 @@ namespace Umbraco.Cms.Infrastructure.Scoping
IEventDispatcher? eventDispatcher = null,
IScopedNotificationPublisher? scopedNotificationPublisher = null,
bool? scopeFileSystems = null)
- => new Scope(this, _coreDebugSettings, _mediaFileManager, _eventAggregator, _loggerFactory.CreateLogger(), _fileSystems, true, null, isolationLevel, repositoryCacheMode, eventDispatcher, scopedNotificationPublisher, scopeFileSystems);
+ => new Scope(this, _coreDebugSettings, _mediaFileManager, DistributedLockingMechanismFactory, _loggerFactory, _eventAggregator, _loggerFactory.CreateLogger(), _fileSystems, true, null, isolationLevel, repositoryCacheMode, eventDispatcher, scopedNotificationPublisher, scopeFileSystems);
///
public void AttachScope(IScope other, bool callContext = false)
@@ -231,7 +231,7 @@ namespace Umbraco.Cms.Infrastructure.Scoping
{
IScopeContext? ambientContext = AmbientContext;
ScopeContext? newContext = ambientContext == null ? new ScopeContext() : null;
- var scope = new Scope(this, _coreDebugSettings, _mediaFileManager, _eventAggregator, _loggerFactory.CreateLogger(), _fileSystems, false, newContext, isolationLevel, repositoryCacheMode, eventDispatcher, notificationPublisher, scopeFileSystems, callContext, autoComplete);
+ var scope = new Scope(this, _coreDebugSettings, _mediaFileManager, DistributedLockingMechanismFactory, _loggerFactory, _eventAggregator, _loggerFactory.CreateLogger(), _fileSystems, false, newContext, isolationLevel, repositoryCacheMode, eventDispatcher, notificationPublisher, scopeFileSystems, callContext, autoComplete);
// assign only if scope creation did not throw!
PushAmbientScope(scope);
@@ -242,7 +242,7 @@ namespace Umbraco.Cms.Infrastructure.Scoping
return scope;
}
- var nested = new Scope(this, _coreDebugSettings, _mediaFileManager, _eventAggregator, _loggerFactory.CreateLogger(), _fileSystems, ambientScope, isolationLevel, repositoryCacheMode, eventDispatcher, notificationPublisher, scopeFileSystems, callContext, autoComplete);
+ var nested = new Scope(this, _coreDebugSettings, _mediaFileManager, DistributedLockingMechanismFactory, _loggerFactory, _eventAggregator, _loggerFactory.CreateLogger(), _fileSystems, ambientScope, isolationLevel, repositoryCacheMode, eventDispatcher, notificationPublisher, scopeFileSystems, callContext, autoComplete);
PushAmbientScope(nested);
return nested;
}
diff --git a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs
index 1424b4bf4d..0336b05f22 100644
--- a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs
+++ b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs
@@ -1,12 +1,9 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
using Examine;
-using Examine.Lucene.Directories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
@@ -16,7 +13,7 @@ using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Configuration.Models;
-using Umbraco.Cms.Core.DependencyInjection;
+using Umbraco.Cms.Core.DistributedLocking;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Runtime;
using Umbraco.Cms.Core.Services;
@@ -25,9 +22,12 @@ using Umbraco.Cms.Core.WebAssets;
using Umbraco.Cms.Infrastructure.Examine;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Cms.Infrastructure.PublishedCache;
+using Umbraco.Cms.Persistence.EFCore.Locking;
+using Umbraco.Cms.Persistence.EFCore.Scoping;
using Umbraco.Cms.Tests.Common.TestHelpers.Stubs;
using Umbraco.Cms.Tests.Integration.Implementations;
-using Umbraco.Extensions;
+using Umbraco.Cms.Tests.Integration.Testing;
+using Umbraco.Cms.Tests.Integration.Umbraco.Persistence.EFCore.DbContext;
namespace Umbraco.Cms.Tests.Integration.DependencyInjection;
@@ -63,6 +63,43 @@ public static class UmbracoBuilderExtensions
builder.Services.AddUnique();
builder.Services.AddUnique();
+ builder.Services.AddDbContext(
+ (serviceProvider, options) =>
+ {
+ var testDatabaseType = builder.Config.GetValue("Tests:Database:DatabaseType");
+ if (testDatabaseType is TestDatabaseSettings.TestDatabaseType.Sqlite)
+ {
+ options.UseSqlite(serviceProvider.GetRequiredService>().CurrentValue.ConnectionString);
+ }
+ else
+ {
+ // If not Sqlite, assume SqlServer
+ options.UseSqlServer(serviceProvider.GetRequiredService>().CurrentValue.ConnectionString);
+ }
+ },
+ optionsLifetime: ServiceLifetime.Singleton);
+
+ builder.Services.AddDbContextFactory(
+ (serviceProvider, options) =>
+ {
+ var testDatabaseType = builder.Config.GetValue("Tests:Database:DatabaseType");
+ if (testDatabaseType is TestDatabaseSettings.TestDatabaseType.Sqlite)
+ {
+ options.UseSqlite(serviceProvider.GetRequiredService>().CurrentValue.ConnectionString);
+ }
+ else
+ {
+ // If not Sqlite, assume SqlServer
+ options.UseSqlServer(serviceProvider.GetRequiredService>().CurrentValue.ConnectionString);
+ }
+ });
+
+ builder.Services.AddUnique, AmbientEFCoreScopeStack>();
+ builder.Services.AddUnique, EFCoreScopeAccessor>();
+ builder.Services.AddUnique, EFCoreScopeProvider>();
+ builder.Services.AddSingleton>();
+ builder.Services.AddSingleton>();
+
return builder;
}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/TestUmbracoDbContext.cs b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/TestUmbracoDbContext.cs
new file mode 100644
index 0000000000..35759543e8
--- /dev/null
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/TestUmbracoDbContext.cs
@@ -0,0 +1,31 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Umbraco.Cms.Tests.Integration.Umbraco.Persistence.EFCore.DbContext;
+
+public class TestUmbracoDbContext : Microsoft.EntityFrameworkCore.DbContext
+{
+ public TestUmbracoDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ internal virtual DbSet UmbracoLocks { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("umbracoLock");
+
+ entity.Property(e => e.Id)
+ .ValueGeneratedNever()
+ .HasColumnName("id");
+
+ entity.Property(e => e.Name)
+ .HasMaxLength(64)
+ .HasColumnName("name");
+
+ entity.Property(e => e.Value).HasColumnName("value");
+ });
+ }
+}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/UmbracoLock.cs b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/UmbracoLock.cs
new file mode 100644
index 0000000000..02a3c648cb
--- /dev/null
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/UmbracoLock.cs
@@ -0,0 +1,10 @@
+namespace Umbraco.Cms.Tests.Integration.Umbraco.Persistence.EFCore.DbContext;
+
+internal class UmbracoLock
+{
+ public int Id { get; set; }
+
+ public int Value { get; set; } = 1;
+
+ public string Name { get; set; } = null!;
+}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreLockTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreLockTests.cs
new file mode 100644
index 0000000000..5103c2e2fa
--- /dev/null
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreLockTests.cs
@@ -0,0 +1,403 @@
+using Microsoft.Extensions.DependencyInjection;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.DistributedLocking;
+using Umbraco.Cms.Core.DistributedLocking.Exceptions;
+using Umbraco.Cms.Persistence.EFCore.Locking;
+using Umbraco.Cms.Persistence.EFCore.Scoping;
+using Umbraco.Cms.Persistence.Sqlite.Interceptors;
+using Umbraco.Cms.Tests.Common.Testing;
+using Umbraco.Cms.Tests.Integration.Testing;
+using Umbraco.Cms.Tests.Integration.Umbraco.Persistence.EFCore.DbContext;
+
+namespace Umbraco.Cms.Tests.Integration.Umbraco.Persistence.EFCore.Scoping;
+
+[TestFixture]
+[Timeout(60000)]
+[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Console)]
+public class EFCoreLockTests : UmbracoIntegrationTest
+{
+ private IEFCoreScopeProvider EFScopeProvider =>
+ GetRequiredService>();
+
+ protected override void ConfigureTestServices(IServiceCollection services)
+ {
+ // SQLite + retry policy makes tests fail, we retry before throwing distributed locking timeout.
+ services.RemoveAll(x => x.ImplementationType == typeof(SqliteAddRetryPolicyInterceptor));
+
+ // Remove all locking implementations to ensure we only use EFCoreDistributedLockingMechanisms
+ services.RemoveAll(x => x.ServiceType == typeof(IDistributedLockingMechanism));
+ services.AddSingleton>();
+ services.AddSingleton>();
+ }
+
+ [SetUp]
+ protected async Task SetUp()
+ {
+ // create a few lock objects
+ using var scope = EFScopeProvider.CreateScope();
+ await scope.ExecuteWithContextAsync(async database =>
+ {
+ database.UmbracoLocks.Add(new UmbracoLock { Id = 1, Name = "Lock.1" });
+ database.UmbracoLocks.Add(new UmbracoLock { Id = 2, Name = "Lock.2" });
+ database.UmbracoLocks.Add(new UmbracoLock { Id = 3, Name = "Lock.3" });
+
+ await database.SaveChangesAsync();
+ });
+
+ scope.Complete();
+ }
+
+ [Test]
+ public void SingleEagerReadLockTest()
+ {
+ using var scope = EFScopeProvider.CreateScope();
+ scope.Locks.EagerReadLock(scope.InstanceId, Constants.Locks.Servers);
+ scope.Complete();
+ }
+
+ [Test]
+ public void SingleReadLockTest()
+ {
+ using var scope = EFScopeProvider.CreateScope();
+ scope.Locks.ReadLock(scope.InstanceId, Constants.Locks.Servers);
+ scope.Complete();
+ }
+
+ [Test]
+ public void SingleWriteLockTest()
+ {
+ using var scope = EFScopeProvider.CreateScope();
+ scope.Locks.WriteLock(scope.InstanceId, Constants.Locks.Servers);
+ scope.Complete();
+ }
+
+ [Test]
+ public void SingleEagerWriteLockTest()
+ {
+ using var scope = EFScopeProvider.CreateScope();
+ scope.Locks.EagerWriteLock(scope.InstanceId, Constants.Locks.Servers);
+ scope.Complete();
+ }
+
+ [Test]
+ public void Can_Reacquire_Read_Lock()
+ {
+ using (var scope = EFScopeProvider.CreateScope())
+ {
+ scope.Locks.EagerReadLock(scope.InstanceId, Constants.Locks.Servers);
+ scope.Complete();
+ }
+
+ using (var scope = EFScopeProvider.CreateScope())
+ {
+ scope.Locks.EagerReadLock(scope.InstanceId, Constants.Locks.Servers);
+ scope.Complete();
+ }
+ }
+
+ [Test]
+ public void Can_Reacquire_Write_Lock()
+ {
+ using (var scope = EFScopeProvider.CreateScope())
+ {
+ scope.Locks.EagerWriteLock(scope.InstanceId, Constants.Locks.Servers);
+ scope.Complete();
+ }
+
+ using (var scope = EFScopeProvider.CreateScope())
+ {
+ scope.Locks.EagerWriteLock(scope.InstanceId, Constants.Locks.Servers);
+ scope.Complete();
+ }
+ }
+
+ [Test]
+ public void ConcurrentReadersTest()
+ {
+ if (BaseTestDatabase.IsSqlite())
+ {
+ Assert.Ignore(
+ "This test doesn't work with Microsoft.Data.Sqlite in EFCore as we no longer use deferred transactions");
+ return;
+ }
+
+ const int threadCount = 8;
+ var threads = new Thread[threadCount];
+ var exceptions = new Exception[threadCount];
+ var locker = new object();
+ var acquired = 0;
+ var m2 = new ManualResetEventSlim(false);
+ var m1 = new ManualResetEventSlim(false);
+
+ for (var i = 0; i < threadCount; i++)
+ {
+ var ic = i; // capture
+ threads[i] = new Thread(() =>
+ {
+ using (var scope = EFScopeProvider.CreateScope())
+ {
+ try
+ {
+ scope.Locks.EagerReadLock(scope.InstanceId, Constants.Locks.Servers);
+ lock (locker)
+ {
+ acquired++;
+ if (acquired == threadCount)
+ {
+ m2.Set();
+ }
+ }
+
+ m1.Wait();
+ lock (locker)
+ {
+ acquired--;
+ }
+ }
+ catch (Exception e)
+ {
+ exceptions[ic] = e;
+ }
+
+ scope.Complete();
+ }
+ });
+ }
+
+ // ensure that current scope does not leak into starting threads
+ using (ExecutionContext.SuppressFlow())
+ {
+ foreach (var thread in threads)
+ {
+ thread.Start();
+ }
+ }
+
+ m2.Wait();
+ // all threads have locked in parallel
+ var maxAcquired = acquired;
+ m1.Set();
+
+ foreach (var thread in threads)
+ {
+ thread.Join();
+ }
+
+ Assert.AreEqual(threadCount, maxAcquired);
+ Assert.AreEqual(0, acquired);
+
+ for (var i = 0; i < threadCount; i++)
+ {
+ Assert.IsNull(exceptions[i]);
+ }
+ }
+
+ [Test]
+ public void ConcurrentWritersTest()
+ {
+ if (BaseTestDatabase.IsSqlite())
+ {
+ Assert.Ignore(
+ "This test doesn't work with Microsoft.Data.Sqlite in EFCore as we no longer use deferred transactions");
+ return;
+ }
+
+ const int threadCount = 3;
+ var threads = new Thread[threadCount];
+ var exceptions = new Exception[threadCount];
+ var locker = new object();
+ var acquired = 0;
+ int triedAcquiringWriteLock = 0;
+ var entered = 0;
+ var ms = new AutoResetEvent[threadCount];
+ for (var i = 0; i < threadCount; i++)
+ {
+ ms[i] = new AutoResetEvent(false);
+ }
+
+ var m1 = new ManualResetEventSlim(false);
+ var m2 = new ManualResetEventSlim(false);
+
+ for (var i = 0; i < threadCount; i++)
+ {
+ var ic = i; // capture
+ threads[i] = new Thread(() =>
+ {
+ using (var scope = EFScopeProvider.CreateScope())
+ {
+ try
+ {
+ lock (locker)
+ {
+ entered++;
+ if (entered == threadCount)
+ {
+ m1.Set();
+ }
+ }
+
+ ms[ic].WaitOne();
+
+ lock (locker)
+ {
+ triedAcquiringWriteLock++;
+ if (triedAcquiringWriteLock == threadCount)
+ {
+ m2.Set();
+ }
+ }
+
+ scope.Locks.EagerWriteLock(scope.InstanceId, Constants.Locks.Servers);
+
+ lock (locker)
+ {
+ acquired++;
+ }
+
+ ms[ic].WaitOne();
+ lock (locker)
+ {
+ acquired--;
+ }
+ }
+ catch (Exception e)
+ {
+ exceptions[ic] = e;
+ }
+
+ scope.Complete();
+ }
+ });
+ }
+
+ // ensure that current scope does not leak into starting threads
+ using (ExecutionContext.SuppressFlow())
+ {
+ foreach (var thread in threads)
+ {
+ thread.Start();
+ }
+ }
+
+ m1.Wait();
+ // all threads have entered
+ ms[0].Set(); // let 0 go
+ // TODO: This timing is flaky
+ Thread.Sleep(1000);
+ for (var i = 1; i < threadCount; i++)
+ {
+ ms[i].Set(); // let others go
+ }
+
+ m2.Wait();
+ // only 1 thread has locked
+ Assert.AreEqual(1, acquired);
+ for (var i = 0; i < threadCount; i++)
+ {
+ ms[i].Set(); // let all go
+ }
+
+ foreach (var thread in threads)
+ {
+ thread.Join();
+ }
+
+ Assert.AreEqual(0, acquired);
+
+ for (var i = 0; i < threadCount; i++)
+ {
+ Assert.IsNull(exceptions[i]);
+ }
+ }
+
+ [Retry(10)] // TODO make this test non-flaky.
+ [Test]
+ public void DeadLockTest()
+ {
+ if (BaseTestDatabase.IsSqlite())
+ {
+ Assert.Ignore("This test doesn't work with Microsoft.Data.Sqlite - SELECT * FROM sys.dm_tran_locks;");
+ return;
+ }
+
+ Exception e1 = null, e2 = null;
+ AutoResetEvent ev1 = new(false), ev2 = new(false);
+
+ // testing:
+ // two threads will each obtain exclusive write locks over two
+ // identical lock objects deadlock each other
+
+ var thread1 = new Thread(() => DeadLockTestThread(1, 2, ev1, ev2, ref e1));
+ var thread2 = new Thread(() => DeadLockTestThread(2, 1, ev2, ev1, ref e2));
+
+ // ensure that current scope does not leak into starting threads
+ using (ExecutionContext.SuppressFlow())
+ {
+ thread1.Start();
+ thread2.Start();
+ }
+
+ ev2.Set();
+
+ thread1.Join();
+ thread2.Join();
+
+ Assert.IsNotNull(e1);
+ if (e1 != null)
+ {
+ AssertIsDistributedLockingTimeoutException(e1);
+ }
+
+ // the assertion below depends on timing conditions - on a fast enough environment,
+ // thread1 dies (deadlock) and frees thread2, which succeeds - however on a slow
+ // environment (CI) both threads can end up dying due to deadlock - so, cannot test
+ // that e2 is null - but if it's not, can test that it's a timeout
+ //
+ //Assert.IsNull(e2);
+ if (e2 != null)
+ {
+ AssertIsDistributedLockingTimeoutException(e2);
+ }
+ }
+
+ private void AssertIsDistributedLockingTimeoutException(Exception e)
+ {
+ var sqlException = e as DistributedLockingTimeoutException;
+ Assert.IsNotNull(sqlException);
+ }
+
+ private void DeadLockTestThread(int id1, int id2, EventWaitHandle myEv, WaitHandle otherEv, ref Exception exception)
+ {
+ using var scope = EFScopeProvider.CreateScope();
+ try
+ {
+ otherEv.WaitOne();
+ Console.WriteLine($"[{id1}] WAIT {id1}");
+ scope.Locks.EagerWriteLock(scope.InstanceId, id1);
+ Console.WriteLine($"[{id1}] GRANT {id1}");
+ myEv.Set();
+
+ if (id1 == 1)
+ {
+ otherEv.WaitOne();
+ }
+ else
+ {
+ Thread.Sleep(5200); // wait for deadlock...
+ }
+
+ Console.WriteLine($"[{id1}] WAIT {id2}");
+ scope.Locks.EagerWriteLock(scope.InstanceId, id2);
+ Console.WriteLine($"[{id1}] GRANT {id2}");
+ }
+ catch (Exception e)
+ {
+ exception = e;
+ }
+ finally
+ {
+ scope.Complete();
+ }
+ }
+}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeInfrastructureScopeLockTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeInfrastructureScopeLockTests.cs
new file mode 100644
index 0000000000..262382cdda
--- /dev/null
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeInfrastructureScopeLockTests.cs
@@ -0,0 +1,139 @@
+using Microsoft.EntityFrameworkCore;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.Notifications;
+using Umbraco.Cms.Infrastructure.Scoping;
+using Umbraco.Cms.Persistence.EFCore.Scoping;
+using Umbraco.Cms.Tests.Common.Testing;
+using Umbraco.Cms.Tests.Integration.Testing;
+using Umbraco.Cms.Tests.Integration.Umbraco.Persistence.EFCore.DbContext;
+
+namespace Umbraco.Cms.Tests.Integration.Umbraco.Persistence.EFCore.Scoping;
+
+[TestFixture]
+[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
+public class EFCoreScopeInfrastructureScopeLockTests : UmbracoIntegrationTest
+{
+ private IEFCoreScopeProvider EfCoreScopeProvider =>
+ GetRequiredService>();
+
+ private IScopeProvider InfrastructureScopeProvider =>
+ GetRequiredService();
+
+ protected override void CustomTestSetup(IUmbracoBuilder builder)
+ {
+ base.CustomTestSetup(builder);
+
+ builder.AddNotificationHandler();
+ builder.AddNotificationHandler();
+ }
+
+ [Test]
+ public async Task ScopesCanShareNonEagerLocks()
+ {
+ using IEfCoreScope parentScope = EfCoreScopeProvider.CreateScope();
+ await parentScope.ExecuteWithContextAsync(async database =>
+ {
+ parentScope.Locks.WriteLock(parentScope.InstanceId, Constants.Locks.Servers);
+ await database.Database.ExecuteSqlAsync($"CREATE TABLE tmp3 (id INT, name NVARCHAR(64))");
+ await database.Database.ExecuteSqlAsync($"INSERT INTO tmp3 (id, name) VALUES (1, 'a')");
+ });
+
+ using (var childScope = InfrastructureScopeProvider.CreateScope())
+ {
+ childScope.Locks.WriteLock(childScope.InstanceId, Constants.Locks.Servers);
+ string n = childScope.Database.ExecuteScalar("SELECT name FROM tmp3 WHERE id=1");
+ Assert.AreEqual("a", n);
+ childScope.Complete();
+ }
+
+ parentScope.Complete();
+ }
+
+ [Test]
+ public async Task ScopesCanShareEagerLocks()
+ {
+ using IEfCoreScope parentScope = EfCoreScopeProvider.CreateScope();
+ await parentScope.ExecuteWithContextAsync(async database =>
+ {
+ parentScope.Locks.EagerWriteLock(parentScope.InstanceId, Constants.Locks.Servers);
+ await database.Database.ExecuteSqlAsync($"CREATE TABLE tmp3 (id INT, name NVARCHAR(64))");
+ await database.Database.ExecuteSqlAsync($"INSERT INTO tmp3 (id, name) VALUES (1, 'a')");
+ });
+
+ using (var childScope = InfrastructureScopeProvider.CreateScope())
+ {
+ childScope.Locks.EagerWriteLock(childScope.InstanceId, Constants.Locks.Servers);
+ string n = childScope.Database.ExecuteScalar("SELECT name FROM tmp3 WHERE id=1");
+ Assert.AreEqual("a", n);
+ childScope.Complete();
+ }
+
+ parentScope.Complete();
+ }
+
+ [Test]
+ public void EFCoreScopeAsParent_Child_Scope_Can_Send_Notification()
+ {
+ var currentAssertCount = TestContext.CurrentContext.AssertCount;
+ using (var scope = EfCoreScopeProvider.CreateScope())
+ {
+ using (var childScope = InfrastructureScopeProvider.CreateScope())
+ {
+ var savingNotification = new TestSendNotification();
+ childScope.Notifications.Publish(savingNotification);
+ childScope.Complete();
+ }
+
+ // Assert notifications arent send on completion of scope
+ Assert.AreEqual(currentAssertCount, TestContext.CurrentContext.AssertCount);
+
+ scope.Complete();
+ }
+
+ Assert.AreEqual(currentAssertCount + 2, TestContext.CurrentContext.AssertCount);
+ }
+
+ [Test]
+ public void InfrastructureScopeAsParent_Child_Scope_Can_Send_Notification()
+ {
+ var currentAssertCount = TestContext.CurrentContext.AssertCount;
+ using (var scope = InfrastructureScopeProvider.CreateScope())
+ {
+ using (var childScope = EfCoreScopeProvider.CreateScope())
+ {
+ var savingNotification = new TestSendNotification();
+ childScope.Notifications.Publish(savingNotification);
+ childScope.Complete();
+ }
+
+ // Assert notifications arent send on completion of scope
+ Assert.AreEqual(currentAssertCount, TestContext.CurrentContext.AssertCount);
+
+ scope.Complete();
+ }
+
+ Assert.AreEqual(currentAssertCount + 2, TestContext.CurrentContext.AssertCount);
+ }
+
+ private class TestSendNotification : INotification
+ {
+ }
+
+ private class TestDoNotSendNotification : INotification
+ {
+ }
+
+ private class TestSendNotificationHandler : INotificationHandler
+ {
+ public void Handle(TestSendNotification notification)
+ => Assert.IsNotNull(notification);
+ }
+
+ private class TestDoNotSendNotificationHandler : INotificationHandler
+ {
+ public void Handle(TestDoNotSendNotification notification)
+ => Assert.Fail("Notification was sent");
+ }
+}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeInfrastructureScopeTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeInfrastructureScopeTests.cs
new file mode 100644
index 0000000000..130b807c73
--- /dev/null
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeInfrastructureScopeTests.cs
@@ -0,0 +1,208 @@
+using Microsoft.EntityFrameworkCore;
+using NUnit.Framework;
+using Umbraco.Cms.Infrastructure.Scoping;
+using Umbraco.Cms.Persistence.EFCore.Scoping;
+using Umbraco.Cms.Tests.Common.Testing;
+using Umbraco.Cms.Tests.Integration.Testing;
+using Umbraco.Cms.Tests.Integration.Umbraco.Persistence.EFCore.DbContext;
+
+namespace Umbraco.Cms.Tests.Integration.Umbraco.Persistence.EFCore.Scoping;
+
+[TestFixture]
+[UmbracoTest(Database = UmbracoTestOptions.Database.NewEmptyPerTest)]
+public class EFCoreScopeInfrastructureScopeTests : UmbracoIntegrationTest
+{
+ private IEFCoreScopeProvider EfCoreScopeProvider =>
+ GetRequiredService>();
+
+ private IScopeProvider InfrastructureScopeProvider =>
+ GetRequiredService();
+
+ private EFCoreScopeAccessor EfCoreScopeAccessor => (EFCoreScopeAccessor