From 8d912d655289fd8a49999baad5d29831f4768760 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 19 Oct 2026 16:14:44 +0100 Subject: [PATCH 1/2] Generalise DbContextResolver and query timing --- Shared.Tests/DbContextResolverTests.cs | 125 +++++++++++++++++- Shared.Tests/QueryTimingInterceptorTests.cs | 107 +++++++++++++++ Shared/EntityFramework/DbContextResolver.cs | 95 +++++++++---- .../DbContextResolverOptions.cs | 16 +++ .../EntityFramework/QueryTimingInterceptor.cs | 90 +++++++++++++ .../QueryTimingInterceptorOptions.cs | 14 ++ Shared/EntityFramework/ResolvedDbContext.cs | 30 ++++- ...ServerDbContextOptionsBuilderExtensions.cs | 4 +- Shared/README.md | 43 ++++++ 9 files changed, 489 insertions(+), 35 deletions(-) create mode 100644 Shared.Tests/QueryTimingInterceptorTests.cs create mode 100644 Shared/EntityFramework/DbContextResolverOptions.cs create mode 100644 Shared/EntityFramework/QueryTimingInterceptor.cs create mode 100644 Shared/EntityFramework/QueryTimingInterceptorOptions.cs create mode 100644 Shared/README.md diff --git a/Shared.Tests/DbContextResolverTests.cs b/Shared.Tests/DbContextResolverTests.cs index 1c8d4068..fd1a382a 100644 --- a/Shared.Tests/DbContextResolverTests.cs +++ b/Shared.Tests/DbContextResolverTests.cs @@ -1,4 +1,7 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.SqlServer; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Imposter.Abstractions; using Shared.EntityFramework; @@ -19,6 +22,20 @@ public TestDbContext(DbContextOptions options) : base(options) { } } +public sealed class DisposableTestDbContext : DbContext +{ + public DisposableTestDbContext(DbContextOptions options) : base(options) { } + + public bool WasDisposed { get; private set; } + + public override void Dispose() + { + this.WasDisposed = true; + base.Dispose(); + } +} + +public sealed class NoOpCommandInterceptor : DbCommandInterceptor { } public class DbContextResolverTests { [Fact] public void ResolvedDbContext_RejectsNullScope() @@ -111,4 +128,110 @@ public void Resolve_WithConnectionIdentifier_UpdatesInitialCatalog() { result.Context.Database.GetDbConnection().Database.ShouldBe("DefaultDb-Tenant1"); result.Dispose(); } + + [Fact] + public void Resolve_WithSuffix_UsesConfiguredRetryOptionsAndInterceptors() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:ReadModel"] = "Server=localhost;Initial Catalog=ReadModel;Integrated Security=True;TrustServerCertificate=True" + }) + .Build(); + NoOpCommandInterceptor interceptor = new(); + DbContextResolverOptions resolverOptions = new() + { + RetryOptionsFactory = key => + { + key.ShouldBe("ReadModel"); + return new SqlServerRetryOptions + { + MaxRetryCount = 2, + MaxRetryDelay = TimeSpan.FromSeconds(5), + AdditionalTransientErrorNumbers = [4060] + }; + }, + Interceptors = [interceptor] + }; + using ServiceProvider rootProvider = new ServiceCollection().BuildServiceProvider(); + DbContextResolver resolver = new(rootProvider, configuration, resolverOptions); + + using ResolvedDbContext resolved = resolver.Resolve("ReadModel", "estate-123"); + + resolved.Context.Database.GetConnectionString().ShouldContain("Initial Catalog=ReadModel-estate-123"); + SqlServerRetryingExecutionStrategy strategy = (SqlServerRetryingExecutionStrategy)resolved.Context.Database.CreateExecutionStrategy(); + strategy.MaxRetryCount.ShouldBe(2); + strategy.MaxRetryDelay.ShouldBe(TimeSpan.FromSeconds(5)); + strategy.AdditionalErrorNumbers.ShouldBe(new[] { 4060 }); + resolved.Context.GetService().Aggregate().ShouldBe(interceptor); + } + + [Fact] + public void Resolve_WithSuffix_UsesDefaultRetryPolicyWhenNoRetryOptionsAreConfigured() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:ReadModel"] = "Server=localhost;Initial Catalog=ReadModel;Integrated Security=True;TrustServerCertificate=True" + }) + .Build(); + using ServiceProvider rootProvider = new ServiceCollection().BuildServiceProvider(); + DbContextResolver resolver = new(rootProvider, configuration); + + using ResolvedDbContext resolved = resolver.Resolve("ReadModel", "estate-123"); + + SqlServerRetryingExecutionStrategy strategy = (SqlServerRetryingExecutionStrategy) + resolved.Context.Database.CreateExecutionStrategy(); + strategy.MaxRetryCount.ShouldBe(6); + strategy.MaxRetryDelay.ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void Resolve_WithSuffix_DisposesTheDynamicallyCreatedContext() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:ReadModel"] = "Server=localhost;Initial Catalog=ReadModel;Integrated Security=True;TrustServerCertificate=True" + }) + .Build(); + using ServiceProvider rootProvider = new ServiceCollection().BuildServiceProvider(); + DbContextResolver resolver = new(rootProvider, configuration); + + ResolvedDbContext resolved = resolver.Resolve("ReadModel", "estate-123"); + DisposableTestDbContext context = resolved.Context; + resolved.Dispose(); + + context.WasDisposed.ShouldBeTrue(); + } + + [Fact] + public void Resolve_WithMalformedConnectionString_ThrowsBeforeResolvingContext() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:ReadModel"] = "NotAValidConnectionString" + }) + .Build(); + using ServiceProvider rootProvider = new ServiceCollection().BuildServiceProvider(); + DbContextResolver resolver = new(rootProvider, configuration); + + Should.Throw(() => resolver.Resolve("ReadModel", "estate-123")); + } + + [Fact] + public void Resolve_WithMalformedUnsuffixedConnectionString_ThrowsBeforeResolvingContext() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:ReadModel"] = "NotAValidConnectionString" + }) + .Build(); + using ServiceProvider rootProvider = new ServiceCollection().BuildServiceProvider(); + DbContextResolver resolver = new(rootProvider, configuration); + + Should.Throw(() => resolver.Resolve("ReadModel")); + } } diff --git a/Shared.Tests/QueryTimingInterceptorTests.cs b/Shared.Tests/QueryTimingInterceptorTests.cs new file mode 100644 index 00000000..8910a98e --- /dev/null +++ b/Shared.Tests/QueryTimingInterceptorTests.cs @@ -0,0 +1,107 @@ +#nullable enable +using System; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using Shared.EntityFramework; +using Shouldly; +using Xunit; + +namespace Shared.Tests; + +public sealed class QueryTimingInterceptorTests +{ + [Fact] + public async Task Interceptor_LogsSyncAndAsyncReaderScalarAndNonQueryCommandsWithoutSqlByDefault() + { + RecordingLoggerProvider provider = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(provider)); + QueryTimingInterceptor interceptor = new(loggerFactory.CreateLogger()); + using DbCommand command = new SqlCommand("SELECT sensitive_value"); + + using DbDataReader reader = new DataTable().CreateDataReader(); + interceptor.ReaderExecuted(command, CreateEventData(command, DbCommandMethod.ExecuteReader), reader); + await interceptor.ReaderExecutedAsync(command, CreateEventData(command, DbCommandMethod.ExecuteReader), reader, TestContext.Current.CancellationToken); + interceptor.ScalarExecuted(command, CreateEventData(command, DbCommandMethod.ExecuteScalar), 1); + await interceptor.ScalarExecutedAsync(command, CreateEventData(command, DbCommandMethod.ExecuteScalar), 1, TestContext.Current.CancellationToken); + interceptor.NonQueryExecuted(command, CreateEventData(command, DbCommandMethod.ExecuteNonQuery), 1); + await interceptor.NonQueryExecutedAsync(command, CreateEventData(command, DbCommandMethod.ExecuteNonQuery), 1, TestContext.Current.CancellationToken); + + provider.Count.ShouldBe(6); + provider.Messages.ShouldAllBe(message => message.Contains("completed in 600 ms")); + provider.Messages.ShouldAllBe(message => !message.Contains("sensitive_value")); + } + + [Fact] + public void Interceptor_UsesConfiguredThresholdAndCanIncludeCommandText() + { + RecordingLoggerProvider provider = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(provider)); + QueryTimingInterceptor interceptor = new(loggerFactory.CreateLogger(), new QueryTimingInterceptorOptions + { + ThresholdMilliseconds = 750, + IncludeCommandText = true + }); + using DbCommand command = new SqlCommand("SELECT explicitly_enabled_value"); + + interceptor.ScalarExecuted(command, CreateEventData(command, DbCommandMethod.ExecuteScalar, TimeSpan.FromMilliseconds(749)), 1); + provider.Count.ShouldBe(0); + + interceptor.ScalarExecuted(command, CreateEventData(command, DbCommandMethod.ExecuteScalar, TimeSpan.FromMilliseconds(750)), 1); + provider.Count.ShouldBe(1); + provider.Messages[0].ShouldContain("explicitly_enabled_value"); + } + + private static CommandExecutedEventData CreateEventData(DbCommand command, + DbCommandMethod method, + TimeSpan? duration = null) + { + return new CommandExecutedEventData( + null!, + (_, _) => String.Empty, + null!, + command, + command.CommandText, + null, + method, + Guid.NewGuid(), + Guid.NewGuid(), + null, + false, + false, + DateTimeOffset.UtcNow, + duration ?? TimeSpan.FromMilliseconds(600), + CommandSource.Unknown); + } + + private sealed class RecordingLoggerProvider : ILoggerProvider + { + public int Count => this.Messages.Count; + public System.Collections.Generic.List Messages { get; } = new(); + + public ILogger CreateLogger(string categoryName) => new RecordingLogger(this.Messages); + public void Dispose() { } + } + + private sealed class RecordingLogger : ILogger + { + private readonly System.Collections.Generic.List _messages; + + public RecordingLogger(System.Collections.Generic.List messages) => _messages = messages; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + _messages.Add(formatter(state, exception)); + } + } +} diff --git a/Shared/EntityFramework/DbContextResolver.cs b/Shared/EntityFramework/DbContextResolver.cs index 23aa69bd..4f98b58e 100644 --- a/Shared/EntityFramework/DbContextResolver.cs +++ b/Shared/EntityFramework/DbContextResolver.cs @@ -1,13 +1,10 @@ -using Microsoft.EntityFrameworkCore; +#nullable enable +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Data.SqlClient; namespace Shared.EntityFramework; @@ -17,43 +14,83 @@ public interface IDbContextResolver where TContext : DbContext ResolvedDbContext Resolve(String connectionStringKey, String databaseNameSuffix); } -public class DbContextResolver : IDbContextResolver where TContext : DbContext { +public class DbContextResolver : IDbContextResolver where TContext : DbContext +{ private readonly IServiceProvider _rootProvider; private readonly IConfiguration _config; + private readonly DbContextResolverOptions _options; + + public DbContextResolver(IServiceProvider rootProvider, IConfiguration config) + : this(rootProvider, config, new DbContextResolverOptions()) + { + } public DbContextResolver(IServiceProvider rootProvider, - IConfiguration config) { - _rootProvider = rootProvider; - _config = config; + IConfiguration config, + DbContextResolverOptions options) + { + _rootProvider = rootProvider ?? throw new ArgumentNullException(nameof(rootProvider)); + _config = config ?? throw new ArgumentNullException(nameof(config)); + _options = options ?? throw new ArgumentNullException(nameof(options)); } - public ResolvedDbContext Resolve(String connectionStringKey) { + public ResolvedDbContext Resolve(String connectionStringKey) + { return this.Resolve(connectionStringKey, String.Empty); } - public ResolvedDbContext Resolve(String connectionStringKey, - String databaseNameSuffix) { - IServiceScope scope = _rootProvider.CreateScope(); - String connectionString = _config.GetConnectionString(connectionStringKey); + public ResolvedDbContext Resolve(String connectionStringKey, String databaseNameSuffix) + { + String? connectionString = _config.GetConnectionString(connectionStringKey); if (String.IsNullOrWhiteSpace(connectionString)) throw new InvalidOperationException($"Connection string for '{connectionStringKey}' not found."); - // Update the connection string with the identifier if needed - if (!String.IsNullOrWhiteSpace(databaseNameSuffix)) { - SqlConnectionStringBuilder builder = new(connectionString); - builder.InitialCatalog = $"{builder.InitialCatalog}-{databaseNameSuffix}"; - connectionString = builder.ConnectionString; + SqlConnectionStringBuilder connectionStringBuilder = new(connectionString); + if (String.IsNullOrWhiteSpace(databaseNameSuffix)) + { + IServiceScope regularScope = _rootProvider.CreateScope(); + try + { + return new ResolvedDbContext(regularScope); + } + catch + { + regularScope.Dispose(); + throw; + } + } + connectionStringBuilder.InitialCatalog = $"{connectionStringBuilder.InitialCatalog}-{databaseNameSuffix}"; + connectionString = connectionStringBuilder.ConnectionString; - // Create an isolated service collection and provider - ServiceCollection services = new(); - services.AddDbContext(options => { options.UseSharedSqlServer(connectionString); }); + IServiceScope scope = _rootProvider.CreateScope(); + try + { + DbContextOptionsBuilder optionsBuilder = new(); + SqlServerRetryOptions retryOptions = _options.RetryOptionsFactory?.Invoke(connectionStringKey) + ?? new SqlServerRetryOptions + { + MaxRetryCount = SqlServerDbContextOptionsBuilderExtensions.DefaultMaxRetryCount, + MaxRetryDelay = SqlServerDbContextOptionsBuilderExtensions.DefaultMaxRetryDelay + }; - ServiceProvider provider = services.BuildServiceProvider(); - scope = provider.CreateScope(); - // Standard resolution using DI container - } + optionsBuilder.UseSharedSqlServer(connectionString, retry => + { + retry.MaxRetryCount = retryOptions.MaxRetryCount; + retry.MaxRetryDelay = retryOptions.MaxRetryDelay; + retry.AdditionalTransientErrorNumbers = retryOptions.AdditionalTransientErrorNumbers; + }); - return new ResolvedDbContext(scope); + if (_options.Interceptors.Count > 0) + optionsBuilder.AddInterceptors(_options.Interceptors); + + TContext context = ActivatorUtilities.CreateInstance(scope.ServiceProvider, optionsBuilder.Options); + return new ResolvedDbContext(scope, context); + } + catch + { + scope.Dispose(); + throw; + } } } diff --git a/Shared/EntityFramework/DbContextResolverOptions.cs b/Shared/EntityFramework/DbContextResolverOptions.cs new file mode 100644 index 00000000..076d2b12 --- /dev/null +++ b/Shared/EntityFramework/DbContextResolverOptions.cs @@ -0,0 +1,16 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Shared.EntityFramework; + +/// Options for dynamically resolved DbContexts. +public sealed class DbContextResolverOptions +{ + /// Gets a retry policy for a connection string key. Returning an empty options object disables retries. + public Func? RetryOptionsFactory { get; init; } + + /// Gets interceptors to apply to contexts created with a database suffix. + public IReadOnlyCollection Interceptors { get; init; } = Array.Empty(); +} diff --git a/Shared/EntityFramework/QueryTimingInterceptor.cs b/Shared/EntityFramework/QueryTimingInterceptor.cs new file mode 100644 index 00000000..20b50a1a --- /dev/null +++ b/Shared/EntityFramework/QueryTimingInterceptor.cs @@ -0,0 +1,90 @@ +#nullable enable +using System; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Shared.EntityFramework; + +/// Logs EF Core database commands whose execution time exceeds a configured threshold. +public sealed class QueryTimingInterceptor : DbCommandInterceptor +{ + private readonly ILogger _logger; + private readonly QueryTimingInterceptorOptions _options; + + public QueryTimingInterceptor(ILogger logger) + : this(logger, new QueryTimingInterceptorOptions()) + { + } + + public QueryTimingInterceptor(ILogger logger, QueryTimingInterceptorOptions options) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + if (_options.ThresholdMilliseconds < 0) + throw new ArgumentOutOfRangeException(nameof(options), "The timing threshold cannot be negative."); + } + + public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result) + { + this.LogIfSlow(command, eventData); + return result; + } + + public override ValueTask ReaderExecutedAsync(DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result, + CancellationToken cancellationToken = default) + { + this.LogIfSlow(command, eventData); + return new ValueTask(result); + } + + public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result) + { + this.LogIfSlow(command, eventData); + return result; + } + + public override ValueTask ScalarExecutedAsync(DbCommand command, + CommandExecutedEventData eventData, + object? result, + CancellationToken cancellationToken = default) + { + this.LogIfSlow(command, eventData); + return new ValueTask(result); + } + + public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result) + { + this.LogIfSlow(command, eventData); + return result; + } + + public override ValueTask NonQueryExecutedAsync(DbCommand command, + CommandExecutedEventData eventData, + int result, + CancellationToken cancellationToken = default) + { + this.LogIfSlow(command, eventData); + return new ValueTask(result); + } + + private void LogIfSlow(DbCommand command, CommandExecutedEventData eventData) + { + if (eventData.Duration.TotalMilliseconds < _options.ThresholdMilliseconds) + return; + + if (_options.IncludeCommandText) + { + _logger.LogWarning("EF command {CommandKind} completed in {DurationMilliseconds} ms. SQL: {CommandText}", + eventData.ExecuteMethod, eventData.Duration.TotalMilliseconds, command.CommandText); + return; + } + + _logger.LogWarning("EF command {CommandKind} completed in {DurationMilliseconds} ms", + eventData.ExecuteMethod, eventData.Duration.TotalMilliseconds); + } +} diff --git a/Shared/EntityFramework/QueryTimingInterceptorOptions.cs b/Shared/EntityFramework/QueryTimingInterceptorOptions.cs new file mode 100644 index 00000000..0bdd5c70 --- /dev/null +++ b/Shared/EntityFramework/QueryTimingInterceptorOptions.cs @@ -0,0 +1,14 @@ +#nullable enable +using System; + +namespace Shared.EntityFramework; + +/// Controls which slow EF Core commands are logged. +public sealed class QueryTimingInterceptorOptions +{ + /// Gets the minimum duration in milliseconds required for logging. Defaults to 500. + public int ThresholdMilliseconds { get; init; } = 500; + + /// Gets whether SQL command text is included in warning logs. Defaults to false. + public bool IncludeCommandText { get; init; } +} diff --git a/Shared/EntityFramework/ResolvedDbContext.cs b/Shared/EntityFramework/ResolvedDbContext.cs index 4e520ce7..2a42f40e 100644 --- a/Shared/EntityFramework/ResolvedDbContext.cs +++ b/Shared/EntityFramework/ResolvedDbContext.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -8,6 +8,8 @@ public sealed class ResolvedDbContext : IDisposable where TContext : D { public TContext Context { get; } private readonly IServiceScope _scope; + private readonly bool _ownsContext; + private bool _disposed; public ResolvedDbContext(IServiceScope scope) { @@ -15,5 +17,27 @@ public ResolvedDbContext(IServiceScope scope) this.Context = this._scope.ServiceProvider.GetRequiredService(); } - public void Dispose() => this._scope.Dispose(); -} \ No newline at end of file + internal ResolvedDbContext(IServiceScope scope, TContext context) + { + this._scope = scope ?? throw new ArgumentNullException(nameof(scope)); + this.Context = context ?? throw new ArgumentNullException(nameof(context)); + this._ownsContext = true; + } + + public void Dispose() + { + if (this._disposed) + return; + + this._disposed = true; + try + { + if (this._ownsContext) + this.Context.Dispose(); + } + finally + { + this._scope.Dispose(); + } + } +} diff --git a/Shared/EntityFramework/SqlServerDbContextOptionsBuilderExtensions.cs b/Shared/EntityFramework/SqlServerDbContextOptionsBuilderExtensions.cs index 04d6e77a..c3ed9cc5 100644 --- a/Shared/EntityFramework/SqlServerDbContextOptionsBuilderExtensions.cs +++ b/Shared/EntityFramework/SqlServerDbContextOptionsBuilderExtensions.cs @@ -17,8 +17,8 @@ public sealed class SqlServerRetryOptions public static class SqlServerDbContextOptionsBuilderExtensions { - private const int DefaultMaxRetryCount = 6; - private static readonly TimeSpan DefaultMaxRetryDelay = TimeSpan.FromSeconds(30); + internal const int DefaultMaxRetryCount = 6; + internal static readonly TimeSpan DefaultMaxRetryDelay = TimeSpan.FromSeconds(30); public static DbContextOptionsBuilder UseSharedSqlServer( this DbContextOptionsBuilder optionsBuilder, diff --git a/Shared/README.md b/Shared/README.md new file mode 100644 index 00000000..9bce7547 --- /dev/null +++ b/Shared/README.md @@ -0,0 +1,43 @@ +# Shared Entity Framework helpers + +## Resolving a context for a suffixed database + +`DbContextResolver` keeps the existing unsuffixed resolution behavior. For a database-per-tenant or database-per-estate context, pass a suffix; the resolver rewrites the connection string's initial catalog and creates the requested generic context within a scope of the application's service provider. + +```csharp +DbContextResolverOptions resolverOptions = new() +{ + RetryOptionsFactory = key => configuration + .GetSection($"AppSettings:SqlServerRetry:{key}") + .Get(), + Interceptors = [queryTimingInterceptor] +}; + +IDbContextResolver resolver = new DbContextResolver( + serviceProvider, + configuration, + resolverOptions); + +using ResolvedDbContext resolved = resolver.Resolve("ReadModel", "estate-123"); +// Use resolved.Context while the wrapper is in scope. +``` + +When `RetryOptionsFactory` returns `null` or is omitted, suffixed contexts use SQL Server's standard retry values (6 retries, 30 seconds maximum delay). Return an empty `SqlServerRetryOptions` instance to disable retries. The factory receives the connection string key, so read and write databases can use different policies. + +## Measuring slow EF commands + +The shared `QueryTimingInterceptor` logs commands taking at least 500 ms. It measures sync and async reader, scalar, and non-query commands. SQL text is omitted by default; setting `IncludeCommandText` opts into logging the command text. + +```csharp +QueryTimingInterceptor queryTimingInterceptor = new( + loggerFactory.CreateLogger(), + new QueryTimingInterceptorOptions + { + ThresholdMilliseconds = 500, + IncludeCommandText = false + }); + +optionsBuilder.AddInterceptors(queryTimingInterceptor); +``` + +Register the interceptor with EF Core's `AddInterceptors` for ordinary DI-created contexts. To use it for suffixed contexts resolved through `DbContextResolver`, include it in `DbContextResolverOptions.Interceptors`. From ff7c4d58ef700de9c24d9d0dddf51319f6df546a Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 19 Oct 2026 16:20:25 +0100 Subject: [PATCH 2/2] Preserve unsuffixed DbContext resolution --- Shared.Tests/DbContextResolverTests.cs | 13 +++++++++---- Shared/EntityFramework/DbContextResolver.cs | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Shared.Tests/DbContextResolverTests.cs b/Shared.Tests/DbContextResolverTests.cs index fd1a382a..f6ec310f 100644 --- a/Shared.Tests/DbContextResolverTests.cs +++ b/Shared.Tests/DbContextResolverTests.cs @@ -221,17 +221,22 @@ public void Resolve_WithMalformedConnectionString_ThrowsBeforeResolvingContext() } [Fact] - public void Resolve_WithMalformedUnsuffixedConnectionString_ThrowsBeforeResolvingContext() + public void Resolve_WithNonSqlUnsuffixedConnectionString_ResolvesRegisteredContext() { + ServiceCollection services = new(); + services.AddDbContext(options => options.UseInMemoryDatabase("TestDbNonSqlConnectionString")); IConfigurationRoot configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["ConnectionStrings:ReadModel"] = "NotAValidConnectionString" + ["ConnectionStrings:ReadModel"] = "Mode=Memory;Cache=Shared;Data Source=test" }) .Build(); - using ServiceProvider rootProvider = new ServiceCollection().BuildServiceProvider(); + using ServiceProvider rootProvider = services.BuildServiceProvider(); DbContextResolver resolver = new(rootProvider, configuration); - Should.Throw(() => resolver.Resolve("ReadModel")); + using ResolvedDbContext resolved = resolver.Resolve("ReadModel"); + + resolved.Context.ShouldNotBeNull(); + resolved.Context.Database.ProviderName.ShouldContain("InMemory"); } } diff --git a/Shared/EntityFramework/DbContextResolver.cs b/Shared/EntityFramework/DbContextResolver.cs index 4f98b58e..4299f862 100644 --- a/Shared/EntityFramework/DbContextResolver.cs +++ b/Shared/EntityFramework/DbContextResolver.cs @@ -45,7 +45,6 @@ public ResolvedDbContext Resolve(String connectionStringKey, String da if (String.IsNullOrWhiteSpace(connectionString)) throw new InvalidOperationException($"Connection string for '{connectionStringKey}' not found."); - SqlConnectionStringBuilder connectionStringBuilder = new(connectionString); if (String.IsNullOrWhiteSpace(databaseNameSuffix)) { IServiceScope regularScope = _rootProvider.CreateScope(); @@ -60,6 +59,7 @@ public ResolvedDbContext Resolve(String connectionStringKey, String da } } + SqlConnectionStringBuilder connectionStringBuilder = new(connectionString); connectionStringBuilder.InitialCatalog = $"{connectionStringBuilder.InitialCatalog}-{databaseNameSuffix}"; connectionString = connectionStringBuilder.ConnectionString;