Skip to content

Commit

Permalink
Entity Framework Core Support (#14109)
Browse files Browse the repository at this point in the history
* 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
3 people authored May 12, 2023
1 parent 36fe2f7 commit 487e85c
Show file tree
Hide file tree
Showing 35 changed files with 3,930 additions and 681 deletions.
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;
}
}
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;
}
}
}
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();
}
}
}
Loading

0 comments on commit 487e85c

Please sign in to comment.