From 487e85cacdaea03c85141e89d2bcf73caeab8c12 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Fri, 12 May 2023 09:25:19 +0200 Subject: [PATCH] Entity Framework Core Support (#14109) * Add UmbracoEFCore project * Add EFCore composer * Add Locking Mechanisms * Add scope interfaces * Add excecute scalar extension method * fix up query in locking mechanism * Add scoping * Add scoping * Add test DbContext classes * add locking test of EFCore * Creat ScopedFileSystemsTests * Add EFCoreScopeInfrastructureScopeLockTests * Add EFCoreScopeInfrastructureScopeTests * Add EFCoreScopeNotificationsTest.cs * Add EFCoreScopeTest.cs * Remake AddUmbracoEFCoreContext to use connection string * Remove unused code from extension method * Reference EFCore reference to Cms.csproj * Remove unused parameter * Dont have default implementation, breaking change instead * Add compatability suppression file * Updated EFCore packages * Use timespan for timeout * Allow overriding default EF Core actions * Option lifetime needs to be singleton * Use given timeout in database call * dont use timespan.zero, use null instead * Use variable timeout * Update test to use locking mechanism * Remove unneccesary duplicate code * Change to catch proper exception number --------- Co-authored-by: Zeegaan Co-authored-by: Bjarke Berg --- .../Extensions/DbContextExtensions.cs | 53 ++ ...mbracoEFCoreServiceCollectionExtensions.cs | 57 ++ ...ServerEFCoreDistributedLockingMechanism.cs | 185 +++++ ...SqliteEFCoreDistributedLockingMechanism.cs | 181 +++++ .../Scoping/AmbientEFCoreScopeStack.cs | 40 + .../Scoping/EFCoreDetachableScope.cs | 110 +++ .../Scoping/EFCoreScope.cs | 237 ++++++ .../Scoping/EFCoreScopeAccessor.cs | 14 + .../Scoping/EFCoreScopeProvider.cs | 207 +++++ .../Scoping/IAmbientEfCoreScopeStack.cs | 12 + .../Scoping/IEFCoreScope.cs | 30 + .../Scoping/IEFCoreScopeAccessor.cs | 10 + .../Scoping/IEFCoreScopeProvider.cs | 16 + .../Umbraco.Cms.Persistence.EFCore.csproj | 25 + src/Umbraco.Cms/Umbraco.Cms.csproj | 1 + .../CompatibilitySuppressions.xml | 9 +- src/Umbraco.Core/Scoping/CoreScope.cs | 272 +++++++ src/Umbraco.Core/Scoping/ICoreScope.cs | 2 + src/Umbraco.Core/Scoping/ILockingMechanism.cs | 58 ++ src/Umbraco.Core/Scoping/LockingMechanism.cs | 433 +++++++++++ .../Scoping/IAmbientScopeContextStack.cs | 2 +- src/Umbraco.Infrastructure/Scoping/Scope.cs | 705 +----------------- .../Scoping/ScopeContext.cs | 2 +- .../Scoping/ScopeProvider.cs | 6 +- .../UmbracoBuilderExtensions.cs | 51 +- .../DbContext/TestUmbracoDbContext.cs | 31 + .../DbContext/UmbracoLock.cs | 10 + .../Scoping/EFCoreLockTests.cs | 403 ++++++++++ ...EFCoreScopeInfrastructureScopeLockTests.cs | 139 ++++ .../EFCoreScopeInfrastructureScopeTests.cs | 208 ++++++ .../Scoping/EFCoreScopeNotificationsTest.cs | 212 ++++++ .../Scoping/EFCoreScopeTest.cs | 670 +++++++++++++++++ .../Scoping/EFCoreScopedFileSystemsTests.cs | 211 ++++++ .../Umbraco.Tests.Integration.csproj | 1 + umbraco.sln | 8 + 35 files changed, 3930 insertions(+), 681 deletions(-) create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Extensions/DbContextExtensions.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Locking/SqlServerEFCoreDistributedLockingMechanism.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Locking/SqliteEFCoreDistributedLockingMechanism.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Scoping/AmbientEFCoreScopeStack.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreDetachableScope.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScope.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScopeAccessor.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Scoping/EFCoreScopeProvider.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Scoping/IAmbientEfCoreScopeStack.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScope.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScopeAccessor.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Scoping/IEFCoreScopeProvider.cs create mode 100644 src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj create mode 100644 src/Umbraco.Core/Scoping/CoreScope.cs create mode 100644 src/Umbraco.Core/Scoping/ILockingMechanism.cs create mode 100644 src/Umbraco.Core/Scoping/LockingMechanism.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/TestUmbracoDbContext.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/DbContext/UmbracoLock.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreLockTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeInfrastructureScopeLockTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeInfrastructureScopeTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeNotificationsTest.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeTest.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopedFileSystemsTests.cs 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)GetRequiredService>(); + + private IScopeAccessor InfrastructureScopeAccessor => GetRequiredService(); + + [Test] + public void CanCreateNestedInfrastructureScope() + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + Assert.IsInstanceOf>(scope); + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.IsNotNull(InfrastructureScopeAccessor.AmbientScope); + Assert.AreSame(scope, EfCoreScopeAccessor.AmbientScope); + using (var infrastructureScope = InfrastructureScopeProvider.CreateScope()) + { + Assert.AreSame(infrastructureScope, InfrastructureScopeAccessor.AmbientScope); + } + + Assert.IsNotNull(InfrastructureScopeAccessor.AmbientScope); + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + Assert.IsNull(InfrastructureScopeAccessor.AmbientScope); + } + + [Test] + public async Task? TransactionWithEfCoreScopeAsParent() + { + using (IEfCoreScope parentScope = EfCoreScopeProvider.CreateScope()) + { + await parentScope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"CREATE TABLE tmp3 (id INT, name NVARCHAR(64))"); + }); + + // This should be using same transaction, so insert data into table we're creating + using (IScope childScope = InfrastructureScopeProvider.CreateScope()) + { + childScope.Database.Execute("INSERT INTO tmp3 (id, name) VALUES (1, 'a')"); + string n = ScopeAccessor.AmbientScope.Database.ExecuteScalar( + "SELECT name FROM tmp3 WHERE id=1"); + Assert.AreEqual("a", n); + childScope.Complete(); + } + + await parentScope.ExecuteWithContextAsync(async database => + { + string? result = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.AreEqual("a", result); + }); + + + parentScope.Complete(); + } + + // Check that its not rolled back + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + string? result = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.IsNotNull(result); + }); + } + } + + [Test] + public async Task? TransactionWithInfrastructureScopeAsParent() + { + using (IScope parentScope = InfrastructureScopeProvider.CreateScope()) + { + parentScope.Database.Execute("CREATE TABLE tmp3 (id INT, name NVARCHAR(64))"); + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"INSERT INTO tmp3 (id, name) VALUES (1, 'a')"); + + string? result = + await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.AreEqual("a", result); + }); + + scope.Complete(); + } + + parentScope.Complete(); + } + + // Check that its not rolled back + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + string? result = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.IsNotNull(result); + }); + } + } + + [Test] + public async Task EFCoreAsParent_DontCompleteWhenChildScopeDoesNotComplete() + { + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"CREATE TABLE tmp3 (id INT, name NVARCHAR(64))"); + }); + scope.Complete(); + } + + using (IEfCoreScope parentScope = EfCoreScopeProvider.CreateScope()) + { + using (IScope scope = InfrastructureScopeProvider.CreateScope()) + { + scope.Database.Execute("INSERT INTO tmp3 (id, name) VALUES (1, 'a')"); + string n = ScopeAccessor.AmbientScope.Database.ExecuteScalar("SELECT name FROM tmp3 WHERE id=1"); + Assert.AreEqual("a", n); + } + + await parentScope.ExecuteWithContextAsync(async database => + { + // Should still be in transaction and not rolled back yet + string? result = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.AreEqual("a", result); + }); + + parentScope.Complete(); + } + + // Check that its rolled back + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + // Should still be in transaction and not rolled back yet + string? result = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.IsNull(result); + }); + } + } + + [Test] + public async Task InfrastructureScopeAsParent_DontCompleteWhenChildScopeDoesNotComplete() + { + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"CREATE TABLE tmp3 (id INT, name NVARCHAR(64))"); + }); + + scope.Complete(); + } + + using (IScope parentScope = InfrastructureScopeProvider.CreateScope()) + { + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"INSERT INTO tmp3 (id, name) VALUES (1, 'a')"); + + string? result = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.AreEqual("a", result); + }); + + string n = parentScope.Database.ExecuteScalar("SELECT name FROM tmp3 WHERE id=1"); + Assert.AreEqual("a", n); + } + + parentScope.Complete(); + } + + // Check that its rolled back + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + string? result = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.IsNull(result); + }); + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeNotificationsTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeNotificationsTest.cs new file mode 100644 index 0000000000..f75d51d0e3 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeNotificationsTest.cs @@ -0,0 +1,212 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +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 EFCoreScopeNotificationsTest : UmbracoIntegrationTest +{ + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + base.CustomTestSetup(builder); + + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + } + + private IEFCoreScopeProvider EfCoreScopeProvider => GetRequiredService>(); + + [Test] + public void Scope_Can_Send_Notification() + { + // We do asserts in the setup of Umbraco, therefore get the + // current number of asserts right how, and assert later that this + // has only gone up by 1 + var initialAssertCount = TestContext.CurrentContext.AssertCount; + + using (var scope = EfCoreScopeProvider.CreateScope()) + { + var savingNotification = new TestSendNotification(); + scope.Notifications.Publish(savingNotification); + scope.Complete(); + } + + Assert.AreEqual(initialAssertCount + 1, TestContext.CurrentContext.AssertCount); + } + + [Test] + public void Child_Scope_Can_Send_Notification() + { + var initialAssertCount = TestContext.CurrentContext.AssertCount; + using (var scope = EfCoreScopeProvider.CreateScope()) + { + using (var childScope = EfCoreScopeProvider.CreateScope()) + { + var savingNotification = new TestSendNotification(); + childScope.Notifications.Publish(savingNotification); + childScope.Complete(); + } + + scope.Complete(); + } + + Assert.AreEqual(initialAssertCount + 1, TestContext.CurrentContext.AssertCount); + } + + [Test] + public void Scope_Does_Not_Send_Notification_When_Not_Completed() + { + using var scope = EfCoreScopeProvider.CreateScope(); + + var savingNotification = new TestDoNotSendNotification(); + scope.Notifications.Publish(savingNotification); + } + + [Test] + public void Scope_Does_Not_Send_Notification_When_Suppressing() + { + using var scope = EfCoreScopeProvider.CreateScope(); + scope.Notifications.Suppress(); + var savingNotification = new TestDoNotSendNotification(); + scope.Notifications.Publish(savingNotification); + scope.Complete(); + } + + [Test] + public void Child_Scope_Cannot_Send_Suppressed_Notification() + { + using var scope = EfCoreScopeProvider.CreateScope(); + + using (var childScope = EfCoreScopeProvider.CreateScope()) + { + childScope.Notifications.Suppress(); + var savingNotification = new TestDoNotSendNotification(); + childScope.Notifications.Publish(savingNotification); + } + + scope.Complete(); + } + + [Test] + public void Parent_Scope_Can_Send_Notification_Before_Child_Suppressing() + { + var initialAssertCount = TestContext.CurrentContext.AssertCount; + + using (var scope = EfCoreScopeProvider.CreateScope()) + { + var savingParentNotification = new TestSendNotification(); + scope.Notifications.Publish(savingParentNotification); + using (var childScope = EfCoreScopeProvider.CreateScope()) + { + childScope.Notifications.Suppress(); + var savingNotification = new TestDoNotSendNotification(); + childScope.Notifications.Publish(savingNotification); + childScope.Complete(); + } + + scope.Complete(); + } + + Assert.AreEqual(initialAssertCount + 1, TestContext.CurrentContext.AssertCount); + } + + [Test] + public void Parent_Scope_Can_Send_Notification_After_Child_Suppressing() + { + var initialAssertCount = TestContext.CurrentContext.AssertCount; + + + using (var scope = EfCoreScopeProvider.CreateScope()) + { + using (var childScope = EfCoreScopeProvider.CreateScope()) + { + using (childScope.Notifications.Suppress()) + { + var savingNotification = new TestDoNotSendNotification(); + childScope.Notifications.Publish(savingNotification); + childScope.Complete(); + } + } + + var savingParentNotificationTwo = new TestSendNotification(); + scope.Notifications.Publish(savingParentNotificationTwo); + + scope.Complete(); + } + + Assert.AreEqual(initialAssertCount + 1, TestContext.CurrentContext.AssertCount); + } + + [Test] + public void Scope_Can_Send_Notification_After_Suppression_Disposed() + { + var initialAssertCount = TestContext.CurrentContext.AssertCount; + + using (var scope = EfCoreScopeProvider.CreateScope()) + { + using (scope.Notifications.Suppress()) + { + var savingNotification = new TestDoNotSendNotification(); + scope.Notifications.Publish(savingNotification); + } + + var savingParentNotificationTwo = new TestSendNotification(); + scope.Notifications.Publish(savingParentNotificationTwo); + + scope.Complete(); + } + + Assert.AreEqual(initialAssertCount + 1, TestContext.CurrentContext.AssertCount); + } + + [Test] + public void Child_Scope_Does_Not_Send_Notification_When_Parent_Suppressing() + { + using var scope = EfCoreScopeProvider.CreateScope(); + scope.Notifications.Suppress(); + + using (var childScope = EfCoreScopeProvider.CreateScope()) + { + var savingNotification = new TestDoNotSendNotification(); + childScope.Notifications.Publish(savingNotification); + childScope.Complete(); + } + + scope.Complete(); + } + + [Test] + public void Cant_Suppress_Notifactions_On_Child_When_Parent_Suppressing() + { + using var parentScope = EfCoreScopeProvider.CreateScope(); + using var parentSuppressed = parentScope.Notifications.Suppress(); + using var childScope = EfCoreScopeProvider.CreateScope(); + Assert.Throws(() => childScope.Notifications.Suppress()); + } + + 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/EFCoreScopeTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeTest.cs new file mode 100644 index 0000000000..deefb0d99b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopeTest.cs @@ -0,0 +1,670 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Persistence.EFCore.Scoping; +using Umbraco.Cms.Tests.Common; +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 EFCoreScopeTest : UmbracoIntegrationTest +{ + private IEFCoreScopeProvider EfCoreScopeProvider => + GetRequiredService>(); + + private EFCoreScopeAccessor EfCoreScopeAccessor => (EFCoreScopeAccessor)GetRequiredService>(); + + [Test] + public void CanCreateScope() + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + Assert.IsInstanceOf>(scope); + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(scope, EfCoreScopeAccessor.AmbientScope); + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + } + + [Test] + public void CanCreateScopeTwice() => + Assert.DoesNotThrow(() => + { + using (var scope = EfCoreScopeProvider.CreateScope()) + { + scope.Complete(); + } + + using (var scopeTwo = EfCoreScopeProvider.CreateScope()) + { + scopeTwo.Complete(); + } + }); + + [Test] + public void NestedCreateScope() + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + Assert.IsInstanceOf>(scope); + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(scope, EfCoreScopeAccessor.AmbientScope); + using (IEfCoreScope nested = EfCoreScopeProvider.CreateScope()) + { + Assert.IsInstanceOf>(nested); + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(nested, EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(scope, ((EFCoreScope)nested).ParentScope); + } + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + } + + [Test] + public async Task NestedCreateScopeInnerException() + { + bool scopeCompleted = false; + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + try + { + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + // scopeProvider.Context.Enlist("test", completed => scopeCompleted = completed); + await scope.ExecuteWithContextAsync(async database => + { + scope.ScopeContext!.Enlist("test", completed => scopeCompleted = completed); + Assert.IsInstanceOf>(scope); + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(scope, EfCoreScopeAccessor.AmbientScope); + using (IEfCoreScope nested = EfCoreScopeProvider.CreateScope()) + { + Assert.IsInstanceOf>(nested); + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(nested, EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(scope, ((EFCoreScope)nested).ParentScope); + nested.Complete(); + throw new Exception("bang!"); + } + + return true; + }); + + scope.Complete(); + } + + Assert.Fail("Expected exception."); + } + catch (Exception e) + { + if (e.Message != "bang!") + { + Assert.Fail("Wrong exception."); + } + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + Assert.IsFalse(scopeCompleted); + } + + [Test] + public async Task CanAccessDbContext() + { + using var scope = EfCoreScopeProvider.CreateScope(); + await scope.ExecuteWithContextAsync(async database => + { + Assert.IsTrue(await database.Database.CanConnectAsync()); + Assert.IsNotNull(database.Database.CurrentTransaction); // in a transaction + }); + scope.Complete(); + } + + [Test] + public async Task CanAccessDbContextTwice() + { + using (var scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + Assert.IsTrue(await database.Database.CanConnectAsync()); + Assert.IsNotNull(database.Database.CurrentTransaction); // in a transaction + }); + scope.Complete(); + } + + using (var scopeTwo = EfCoreScopeProvider.CreateScope()) + { + await scopeTwo.ExecuteWithContextAsync(async database => + { + Assert.IsTrue(await database.Database.CanConnectAsync()); + Assert.IsNotNull(database.Database.CurrentTransaction); // in a transaction + }); + + scopeTwo.Complete(); + } + } + + [Test] + public async Task CanAccessNestedDbContext() + { + using (var scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + Assert.IsTrue(await database.Database.CanConnectAsync()); + var parentTransaction = database.Database.CurrentTransaction; + + using (var nestedSCope = EfCoreScopeProvider.CreateScope()) + { + await nestedSCope.ExecuteWithContextAsync(async nestedDatabase => + { + Assert.IsTrue(await nestedDatabase.Database.CanConnectAsync()); + Assert.IsNotNull(nestedDatabase.Database.CurrentTransaction); // in a transaction + var childTransaction = nestedDatabase.Database.CurrentTransaction; + Assert.AreSame(parentTransaction, childTransaction); + }); + } + }); + scope.Complete(); + } + } + + [Test] + public void GivenUncompletedScopeOnChildThread_WhenTheParentCompletes_TheTransactionIsRolledBack() + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + IEfCoreScope mainScope = EfCoreScopeProvider.CreateScope(); + + var t = Task.Run(() => + { + IEfCoreScope nested = EfCoreScopeProvider.CreateScope(); + Thread.Sleep(2000); + nested.Dispose(); + }); + + Thread.Sleep(1000); // mimic some long running operation that is shorter than the other thread + mainScope.Complete(); + Assert.Throws(() => mainScope.Dispose()); + + Task.WaitAll(t); + } + + [Test] + public void GivenNonDisposedChildScope_WhenTheParentDisposes_ThenInvalidOperationExceptionThrows() + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + IEfCoreScope mainScope = EfCoreScopeProvider.CreateScope(); + + IEfCoreScope nested = EfCoreScopeProvider.CreateScope(); // not disposing + + InvalidOperationException ex = Assert.Throws(() => mainScope.Dispose()); + Console.WriteLine(ex); + } + + [Test] + public void GivenChildThread_WhenParentDisposedBeforeChild_ParentScopeThrows() + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + IEfCoreScope mainScope = EfCoreScopeProvider.CreateScope(); + + var t = Task.Run(() => + { + Console.WriteLine("Child Task start: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + + // This will push the child scope to the top of the Stack + IEfCoreScope nested = EfCoreScopeProvider.CreateScope(); + Console.WriteLine("Child Task scope created: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + Thread.Sleep(5000); // block for a bit to ensure the parent task is disposed first + Console.WriteLine("Child Task before dispose: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + nested.Dispose(); + Console.WriteLine("Child Task after dispose: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + }); + + // provide some time for the child thread to start so the ambient context is copied in AsyncLocal + Thread.Sleep(2000); + + // now dispose the main without waiting for the child thread to join + Console.WriteLine("Parent Task disposing: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + + // This will throw because at this stage a child scope has been created which means + // it is the Ambient (top) scope but here we're trying to dispose the non top scope. + Assert.Throws(() => mainScope.Dispose()); + t.Wait(); // wait for the child to dispose + mainScope.Dispose(); // now it's ok + Console.WriteLine("Parent Task disposed: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + } + + [Test] + public void GivenChildThread_WhenChildDisposedBeforeParent_OK() + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + IEfCoreScope mainScope = EfCoreScopeProvider.CreateScope(); + + // Task.Run will flow the execution context unless ExecutionContext.SuppressFlow() is explicitly called. + // This is what occurs in normal async behavior since it is expected to await (and join) the main thread, + // but if Task.Run is used as a fire and forget thread without being done correctly then the Scope will + // flow to that thread. + var t = Task.Run(() => + { + Console.WriteLine("Child Task start: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + IEfCoreScope nested = EfCoreScopeProvider.CreateScope(); + Console.WriteLine("Child Task before dispose: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + nested.Dispose(); + Console.WriteLine("Child Task after disposed: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + }); + + Console.WriteLine("Parent Task waiting: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + t.Wait(); + Console.WriteLine("Parent Task disposing: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + mainScope.Dispose(); + Console.WriteLine("Parent Task disposed: " + EfCoreScopeAccessor.AmbientScope?.InstanceId); + + Assert.Pass(); + } + + [Test] + public async Task Transaction() + { + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"CREATE TABLE tmp3 (id INT, name NVARCHAR(64))"); + }); + scope.Complete(); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"INSERT INTO tmp3 (id, name) VALUES (1, 'a')"); + + string? result = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.AreEqual("a", result); + }); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + string n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.IsNull(n); + }); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"INSERT INTO tmp3 (id, name) VALUES (1, 'a')"); + }); + + scope.Complete(); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + string n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp3 WHERE id=1"); + Assert.AreEqual("a", n); + }); + + scope.Complete(); + } + } + + [Test] + public async Task NestedTransactionInnerFail() + { + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"CREATE TABLE tmp1 (id INT, name NVARCHAR(64))"); + }); + + scope.Complete(); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + string n; + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"INSERT INTO tmp1 (id, name) VALUES (1, 'a')"); + n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp1 WHERE id=1"); + Assert.AreEqual("a", n); + + using (IEfCoreScope nested = EfCoreScopeProvider.CreateScope()) + { + await nested.ExecuteWithContextAsync(async nestedDatabase => + { + await nestedDatabase.Database.ExecuteSqlAsync($"INSERT INTO tmp1 (id, name) VALUES (2, 'b')"); + string nn = await nestedDatabase.Database.ExecuteScalarAsync( + "SELECT name FROM tmp1 WHERE id=2"); + Assert.AreEqual("b", nn); + }); + } + + n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp1 WHERE id=2"); + Assert.AreEqual("b", n); + }); + + scope.Complete(); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + string n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp1 WHERE id=1"); + Assert.IsNull(n); + n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp1 WHERE id=2"); + Assert.IsNull(n); + }); + } + } + + [Test] + public async Task NestedTransactionOuterFail() + { + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"CREATE TABLE tmp2 (id INT, name NVARCHAR(64))"); + }); + + scope.Complete(); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"INSERT INTO tmp2 (id, name) VALUES (1, 'a')"); + string n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp2 WHERE id=1"); + Assert.AreEqual("a", n); + + using (IEfCoreScope nested = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async nestedDatabase => + { + await nestedDatabase.Database.ExecuteSqlAsync($"INSERT INTO tmp2 (id, name) VALUES (2, 'b')"); + string nn = await nestedDatabase.Database.ExecuteScalarAsync( + "SELECT name FROM tmp2 WHERE id=2"); + Assert.AreEqual("b", nn); + }); + + nested.Complete(); + } + + n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp2 WHERE id=2"); + Assert.AreEqual("b", n); + }); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + string n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp2 WHERE id=1"); + Assert.IsNull(n); + n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp2 WHERE id=2"); + Assert.IsNull(n); + }); + } + } + + [Test] + public async Task NestedTransactionComplete() + { + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"CREATE TABLE tmp (id INT, name NVARCHAR(64))"); + }); + scope.Complete(); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + await database.Database.ExecuteSqlAsync($"INSERT INTO tmp (id, name) VALUES (1, 'a')"); + string n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp WHERE id=1"); + Assert.AreEqual("a", n); + + using (IEfCoreScope nested = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async nestedDatabase => + { + await nestedDatabase.Database.ExecuteSqlAsync($"INSERT INTO tmp (id, name) VALUES (2, 'b')"); + string nn = + await nestedDatabase.Database.ExecuteScalarAsync("SELECT name FROM tmp WHERE id=2"); + Assert.AreEqual("b", nn); + }); + + nested.Complete(); + } + + n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp WHERE id=2"); + Assert.AreEqual("b", n); + }); + + scope.Complete(); + } + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + await scope.ExecuteWithContextAsync(async database => + { + string n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp WHERE id=1"); + Assert.AreEqual("a", n); + n = await database.Database.ExecuteScalarAsync("SELECT name FROM tmp WHERE id=2"); + Assert.AreEqual("b", n); + }); + } + } + + [Test] + public void CallContextScope1() + { + var taskHelper = new TaskHelper(Mock.Of>()); + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + + // Run on another thread without a flowed context + Task t = taskHelper.ExecuteBackgroundTask(() => + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + + using (IEfCoreScope newScope = EfCoreScopeProvider.CreateScope()) + { + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.IsNull(EfCoreScopeAccessor.AmbientScope.ParentScope); + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + + return Task.CompletedTask; + }); + + Task.WaitAll(t); + + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(scope, EfCoreScopeAccessor.AmbientScope); + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + } + + [Test] + public void CallContextScope2() + { + var taskHelper = new TaskHelper(Mock.Of>()); + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + + // Run on another thread without a flowed context + Task t = taskHelper.ExecuteBackgroundTask(() => + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + + using (IEfCoreScope newScope = EfCoreScopeProvider.CreateScope()) + { + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.IsNull(EfCoreScopeAccessor.AmbientScope.ParentScope); + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + return Task.CompletedTask; + }); + + Task.WaitAll(t); + + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(scope, EfCoreScopeAccessor.AmbientScope); + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + } + + [TestCase(true)] + [TestCase(false)] + public void ScopeContextEnlist(bool complete) + { + bool? completed = null; + IEfCoreScope ambientScope = null; + IScopeContext ambientContext = null; + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + scope.ScopeContext.Enlist("name", c => + { + completed = c; + ambientScope = EfCoreScopeAccessor.AmbientScope; + ambientContext = EfCoreScopeProvider.AmbientScopeContext; + }); + if (complete) + { + scope.Complete(); + } + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + Assert.IsNull(EfCoreScopeProvider.AmbientScopeContext); + Assert.IsNotNull(completed); + Assert.AreEqual(complete, completed.Value); + Assert.IsNull(ambientScope); // the scope is gone + Assert.IsNotNull(ambientContext); // the context is still there + } + + [TestCase(true)] + [TestCase(false)] + public void ScopeContextEnlistAgain(bool complete) + { + bool? completed = null; + bool? completed2 = null; + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + scope.ScopeContext.Enlist("name", c => + { + completed = c; + + // at that point the scope is gone, but the context is still there + IScopeContext ambientContext = EfCoreScopeProvider.AmbientScopeContext; + ambientContext.Enlist("another", c2 => completed2 = c2); + }); + if (complete) + { + scope.Complete(); + } + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + Assert.IsNull(EfCoreScopeProvider.AmbientScopeContext); + Assert.IsNotNull(completed); + Assert.AreEqual(complete, completed.Value); + Assert.AreEqual(complete, completed2.Value); + } + + [Test] + public void DetachableScope() + { + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + using (IEfCoreScope scope = EfCoreScopeProvider.CreateScope()) + { + Assert.IsInstanceOf>(scope); + Assert.IsNotNull(EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(scope, EfCoreScopeAccessor.AmbientScope); + + Assert.IsNotNull(EfCoreScopeProvider.AmbientScopeContext); // the ambient context + Assert.IsNotNull(scope.ScopeContext); // the ambient context too (getter only) + IScopeContext context = scope.ScopeContext; + + IEfCoreScope detached = EfCoreScopeProvider.CreateDetachedScope(); + EfCoreScopeProvider.AttachScope(detached); + + Assert.AreEqual(detached, EfCoreScopeAccessor.AmbientScope); + Assert.AreNotSame(context, EfCoreScopeProvider.AmbientScopeContext); + + // nesting under detached! + using (IEfCoreScope nested = EfCoreScopeProvider.CreateScope()) + { + Assert.Throws(() => + + // cannot detach a non-detachable scope + EfCoreScopeProvider.DetachScope()); + nested.Complete(); + } + + Assert.AreEqual(detached, EfCoreScopeAccessor.AmbientScope); + Assert.AreNotSame(context, EfCoreScopeProvider.AmbientScopeContext); + + // can detach + Assert.AreSame(detached, EfCoreScopeProvider.DetachScope()); + + Assert.AreSame(scope, EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(context, EfCoreScopeProvider.AmbientScopeContext); + + Assert.Throws(() => + + // cannot disposed a non-attached scope + // in fact, only the ambient scope can be disposed + detached.Dispose()); + + EfCoreScopeProvider.AttachScope(detached); + detached.Complete(); + detached.Dispose(); + + // has self-detached, and is gone! + Assert.AreSame(scope, EfCoreScopeAccessor.AmbientScope); + Assert.AreSame(context, EfCoreScopeProvider.AmbientScopeContext); + } + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + Assert.IsNull(EfCoreScopeProvider.AmbientScopeContext); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopedFileSystemsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopedFileSystemsTests.cs new file mode 100644 index 0000000000..df91eed751 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Persistence.EFCore/Scoping/EFCoreScopedFileSystemsTests.cs @@ -0,0 +1,211 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Persistence.EFCore.Scoping; +using Umbraco.Cms.Tests.Common; +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, Logger = UmbracoTestOptions.Logger.Console)] +public class EFCoreScopedFileSystemsTests : UmbracoIntegrationTest +{ + [SetUp] + public void SetUp() => ClearFiles(IOHelper); + + [TearDown] + public void Teardown() => ClearFiles(IOHelper); + + private MediaFileManager MediaFileManager => GetRequiredService(); + + private IHostingEnvironment HostingEnvironment => GetRequiredService(); + + private IEFCoreScopeProvider EfCoreScopeProvider => GetRequiredService>(); + private IEFCoreScopeAccessor EfCoreScopeAccessor => GetRequiredService>(); + + private void ClearFiles(IIOHelper ioHelper) + { + TestHelper.DeleteDirectory(ioHelper.MapPath("media")); + TestHelper.DeleteDirectory(ioHelper.MapPath("FileSysTests")); + TestHelper.DeleteDirectory(ioHelper.MapPath(Constants.SystemDirectories.TempData.EnsureEndsWith('/') + "ShadowFs")); + } + + [Test] + public void MediaFileManager_Does_Not_Write_To_Physical_File_System_When_Scoped_If_Scope_Does_Not_Complete() + { + var rootPath = HostingEnvironment.MapPathWebRoot(GlobalSettings.UmbracoMediaPhysicalRootPath); + var rootUrl = HostingEnvironment.ToAbsolute(GlobalSettings.UmbracoMediaPath); + var physMediaFileSystem = new PhysicalFileSystem(IOHelper, HostingEnvironment, GetRequiredService>(), rootPath, rootUrl); + var mediaFileManager = MediaFileManager; + + Assert.IsFalse(physMediaFileSystem.FileExists("f1.txt")); + + using (EfCoreScopeProvider.CreateScope(scopeFileSystems: true)) + { + using (var ms = new MemoryStream("foo"u8.ToArray())) + { + MediaFileManager.FileSystem.AddFile("f1.txt", ms); + } + + Assert.IsTrue(mediaFileManager.FileSystem.FileExists("f1.txt")); + Assert.IsFalse(physMediaFileSystem.FileExists("f1.txt")); + } + + // After scope is disposed ensure shadow wrapper didn't commit to physical + Assert.IsFalse(mediaFileManager.FileSystem.FileExists("f1.txt")); + Assert.IsFalse(physMediaFileSystem.FileExists("f1.txt")); + } + + [Test] + public void MediaFileManager_Writes_To_Physical_File_System_When_Scoped_And_Scope_Is_Completed() + { + var rootPath = HostingEnvironment.MapPathWebRoot(GlobalSettings.UmbracoMediaPhysicalRootPath); + var rootUrl = HostingEnvironment.ToAbsolute(GlobalSettings.UmbracoMediaPath); + var physMediaFileSystem = new PhysicalFileSystem(IOHelper, HostingEnvironment, GetRequiredService>(), rootPath, rootUrl); + var mediaFileManager = MediaFileManager; + + Assert.IsFalse(physMediaFileSystem.FileExists("f1.txt")); + + using (var scope = EfCoreScopeProvider.CreateScope(scopeFileSystems: true)) + { + using (var ms = new MemoryStream("foo"u8.ToArray())) + { + mediaFileManager.FileSystem.AddFile("f1.txt", ms); + } + + Assert.IsTrue(mediaFileManager.FileSystem.FileExists("f1.txt")); + Assert.IsFalse(physMediaFileSystem.FileExists("f1.txt")); + + scope.Complete(); + + Assert.IsTrue(mediaFileManager.FileSystem.FileExists("f1.txt")); + Assert.IsFalse(physMediaFileSystem.FileExists("f1.txt")); + } + + // After scope is disposed ensure shadow wrapper writes to physical file system + Assert.IsTrue(mediaFileManager.FileSystem.FileExists("f1.txt")); + Assert.IsTrue(physMediaFileSystem.FileExists("f1.txt")); + } + + [Test] + public void MultiThread() + { + var rootPath = HostingEnvironment.MapPathWebRoot(GlobalSettings.UmbracoMediaPhysicalRootPath); + var rootUrl = HostingEnvironment.ToAbsolute(GlobalSettings.UmbracoMediaPath); + var physMediaFileSystem = new PhysicalFileSystem(IOHelper, HostingEnvironment, GetRequiredService>(), rootPath, rootUrl); + var mediaFileManager = MediaFileManager; + var taskHelper = new TaskHelper(Mock.Of>()); + + using (EfCoreScopeProvider.CreateScope(scopeFileSystems: true)) + { + using (var ms = new MemoryStream("foo"u8.ToArray())) + { + mediaFileManager.FileSystem.AddFile("f1.txt", ms); + } + + Assert.IsTrue(mediaFileManager.FileSystem.FileExists("f1.txt")); + Assert.IsFalse(physMediaFileSystem.FileExists("f1.txt")); + + // execute on another disconnected thread (execution context will not flow) + var t = taskHelper.ExecuteBackgroundTask(() => + { + Assert.IsFalse(mediaFileManager.FileSystem.FileExists("f1.txt")); + + using (var ms = new MemoryStream("foo"u8.ToArray())) + { + mediaFileManager.FileSystem.AddFile("f2.txt", ms); + } + + Assert.IsTrue(mediaFileManager.FileSystem.FileExists("f2.txt")); + Assert.IsTrue(physMediaFileSystem.FileExists("f2.txt")); + + return Task.CompletedTask; + }); + + t.Wait(); + + Assert.IsTrue(mediaFileManager.FileSystem.FileExists("f2.txt")); + Assert.IsTrue(physMediaFileSystem.FileExists("f2.txt")); + } + } + + [Test] + public void SingleShadow() + { + var taskHelper = new TaskHelper(Mock.Of>()); + var isThrown = false; + using (EfCoreScopeProvider.CreateScope(scopeFileSystems: true)) + { + // This is testing when another thread concurrently tries to create a scoped file system + // because at the moment we don't support concurrent scoped filesystems. + var t = taskHelper.ExecuteBackgroundTask(() => + { + // ok to create a 'normal' other scope + using (var other = EfCoreScopeProvider.CreateScope()) + { + other.Complete(); + } + + // not ok to create a 'scoped filesystems' other scope + // we will get a "Already shadowing." exception. + Assert.Throws(() => + { + using var other = EfCoreScopeProvider.CreateScope(scopeFileSystems: true); + }); + + isThrown = true; + + return Task.CompletedTask; + }); + + t.Wait(); + } + + Assert.IsTrue(isThrown); + } + + [Test] + public void SingleShadowEvenDetached() + { + var taskHelper = new TaskHelper(Mock.Of>()); + using (var scope = EfCoreScopeProvider.CreateScope(scopeFileSystems: true)) + { + // This is testing when another thread concurrently tries to create a scoped file system + // because at the moment we don't support concurrent scoped filesystems. + var t = taskHelper.ExecuteBackgroundTask(() => + { + // not ok to create a 'scoped filesystems' other scope + // because at the moment we don't support concurrent scoped filesystems + // even a detached one + // we will get a "Already shadowing." exception. + Assert.Throws(() => + { + using var other = EfCoreScopeProvider.CreateDetachedScope(scopeFileSystems: true); + }); + + return Task.CompletedTask; + }); + + t.Wait(); + } + + var detached = EfCoreScopeProvider.CreateDetachedScope(scopeFileSystems: true); + + Assert.IsNull(EfCoreScopeAccessor.AmbientScope); + + Assert.Throws(() => + { + // even if there is no ambient scope, there's a single shadow + using var other = EfCoreScopeProvider.CreateScope(scopeFileSystems: true); + }); + + EfCoreScopeProvider.AttachScope(detached); + detached.Dispose(); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 4a11fd91ee..4b29b73602 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -21,6 +21,7 @@ + diff --git a/umbraco.sln b/umbraco.sln index c382c480f7..f6bd2d1719 100644 --- a/umbraco.sln +++ b/umbraco.sln @@ -151,6 +151,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Umbraco.Cms.Api.Common", "s EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Umbraco.Cms.Imaging.ImageSharp", "src\Umbraco.Cms.Imaging.ImageSharp\Umbraco.Cms.Imaging.ImageSharp.csproj", "{35E3DA10-5549-41DE-B7ED-CC29355BA9FD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.Cms.Persistence.EFCore", "src\Umbraco.Cms.Persistence.EFCore\Umbraco.Cms.Persistence.EFCore.csproj", "{9046F56E-4AC3-4603-A6A3-3ACCF632997E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -297,6 +299,12 @@ Global {D48B5D6B-82FF-4235-986C-CDE646F41DEC}.Release|Any CPU.Build.0 = Release|Any CPU {D48B5D6B-82FF-4235-986C-CDE646F41DEC}.SkipTests|Any CPU.ActiveCfg = Debug|Any CPU {D48B5D6B-82FF-4235-986C-CDE646F41DEC}.SkipTests|Any CPU.Build.0 = Debug|Any CPU + {9046F56E-4AC3-4603-A6A3-3ACCF632997E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9046F56E-4AC3-4603-A6A3-3ACCF632997E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9046F56E-4AC3-4603-A6A3-3ACCF632997E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9046F56E-4AC3-4603-A6A3-3ACCF632997E}.Release|Any CPU.Build.0 = Release|Any CPU + {9046F56E-4AC3-4603-A6A3-3ACCF632997E}.SkipTests|Any CPU.ActiveCfg = Debug|Any CPU + {9046F56E-4AC3-4603-A6A3-3ACCF632997E}.SkipTests|Any CPU.Build.0 = Debug|Any CPU {35E3DA10-5549-41DE-B7ED-CC29355BA9FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {35E3DA10-5549-41DE-B7ED-CC29355BA9FD}.Debug|Any CPU.Build.0 = Debug|Any CPU {35E3DA10-5549-41DE-B7ED-CC29355BA9FD}.Release|Any CPU.ActiveCfg = Release|Any CPU