-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <nge@umbraco.dk> Co-authored-by: Bjarke Berg <mail@bergmania.dk>
- Loading branch information
1 parent
36fe2f7
commit 487e85c
Showing
35 changed files
with
3,930 additions
and
681 deletions.
There are no files selected for viewing
53 changes: 53 additions & 0 deletions
53
src/Umbraco.Cms.Persistence.EFCore/Extensions/DbContextExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
{ | ||
/// <summary> | ||
/// Executes a raw SQL query and returns the result. | ||
/// </summary> | ||
/// <param name="database">The database.</param> | ||
/// <param name="sql">The sql query.</param> | ||
/// <param name="parameters">The list of db parameters.</param> | ||
/// <param name="commandType">The command type.</param> | ||
/// <param name="commandTimeOut">The amount of time to wait before the command times out.</param> | ||
/// <typeparam name="T">the type to return.</typeparam> | ||
/// <returns>Returns an object of the given type T.</returns> | ||
public static async Task<T?> ExecuteScalarAsync<T>(this DatabaseFacade database, string sql, List<DbParameter>? 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; | ||
} | ||
} |
57 changes: 57 additions & 0 deletions
57
src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<T>(this IServiceCollection services, string connectionString, string providerName, DefaultEFCoreOptionsAction? defaultEFCoreOptionsAction = null) | ||
where T : DbContext | ||
{ | ||
defaultEFCoreOptionsAction ??= DefaultOptionsAction; | ||
|
||
services.AddDbContext<T>( | ||
options => | ||
{ | ||
defaultEFCoreOptionsAction(options, providerName, connectionString); | ||
}, | ||
optionsLifetime: ServiceLifetime.Singleton); | ||
|
||
services.AddDbContextFactory<T>(options => | ||
{ | ||
defaultEFCoreOptionsAction(options, providerName, connectionString); | ||
}); | ||
|
||
services.AddUnique<IAmbientEFCoreScopeStack<T>, AmbientEFCoreScopeStack<T>>(); | ||
services.AddUnique<IEFCoreScopeAccessor<T>, EFCoreScopeAccessor<T>>(); | ||
services.AddUnique<IEFCoreScopeProvider<T>, EFCoreScopeProvider<T>>(); | ||
services.AddSingleton<IDistributedLockingMechanism, SqliteEFCoreDistributedLockingMechanism<T>>(); | ||
services.AddSingleton<IDistributedLockingMechanism, SqlServerEFCoreDistributedLockingMechanism<T>>(); | ||
|
||
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; | ||
} | ||
} | ||
} |
185 changes: 185 additions & 0 deletions
185
src/Umbraco.Cms.Persistence.EFCore/Locking/SqlServerEFCoreDistributedLockingMechanism.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<T> : IDistributedLockingMechanism | ||
where T : DbContext | ||
{ | ||
private readonly IOptionsMonitor<ConnectionStrings> _connectionStrings; | ||
private readonly IOptionsMonitor<GlobalSettings> _globalSettings; | ||
private readonly ILogger<SqlServerEFCoreDistributedLockingMechanism<T>> _logger; | ||
private readonly Lazy<IEFCoreScopeAccessor<T>> _scopeAccessor; // Hooray it's a circular dependency. | ||
|
||
/// <summary> | ||
/// Initializes a new instance of the <see cref="SqlServerDistributedLockingMechanism" /> class. | ||
/// </summary> | ||
public SqlServerEFCoreDistributedLockingMechanism( | ||
ILogger<SqlServerEFCoreDistributedLockingMechanism<T>> logger, | ||
Lazy<IEFCoreScopeAccessor<T>> scopeAccessor, | ||
IOptionsMonitor<GlobalSettings> globalSettings, | ||
IOptionsMonitor<ConnectionStrings> connectionStrings) | ||
{ | ||
_logger = logger; | ||
_scopeAccessor = scopeAccessor; | ||
_globalSettings = globalSettings; | ||
_connectionStrings = connectionStrings; | ||
} | ||
|
||
public bool HasActiveRelatedScope => _scopeAccessor.Value.AmbientScope is not null; | ||
|
||
/// <inheritdoc /> | ||
public bool Enabled => _connectionStrings.CurrentValue.IsConnectionStringConfigured() && | ||
string.Equals(_connectionStrings.CurrentValue.ProviderName, "Microsoft.Data.SqlClient", StringComparison.InvariantCultureIgnoreCase) && _scopeAccessor.Value.AmbientScope is not null; | ||
|
||
/// <inheritdoc /> | ||
public IDistributedLock ReadLock(int lockId, TimeSpan? obtainLockTimeout = null) | ||
{ | ||
obtainLockTimeout ??= _globalSettings.CurrentValue.DistributedLockingReadLockDefaultTimeout; | ||
return new SqlServerDistributedLock(this, lockId, DistributedLockType.ReadLock, obtainLockTimeout.Value); | ||
} | ||
|
||
/// <inheritdoc /> | ||
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<T> _parent; | ||
private readonly TimeSpan _timeout; | ||
|
||
public SqlServerDistributedLock( | ||
SqlServerEFCoreDistributedLockingMechanism<T> 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<T>? scope = _parent._scopeAccessor.Value.AmbientScope; | ||
|
||
if (scope is null) | ||
{ | ||
throw new PanicException("No ambient scope"); | ||
} | ||
|
||
scope.ExecuteWithContextAsync<Task>(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<int?>($"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<T>? scope = _parent._scopeAccessor.Value.AmbientScope; | ||
if (scope is null) | ||
{ | ||
throw new PanicException("No ambient scope"); | ||
} | ||
|
||
scope.ExecuteWithContextAsync<Task>(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(); | ||
} | ||
} | ||
} |
Oops, something went wrong.