0
0

Working implementation

This commit is contained in:
Rhys Ickeringill
2025-12-06 01:58:57 +11:00
parent 3505e44e89
commit 30cd4249b4
14 changed files with 1078 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Options = RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.SqlServerNotificationConfigurationReloaderOptions;
namespace RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds <see cref="SqlServerNotificationConfigurationReloader{,}"/> (a <see cref="Microsoft.Extensions.Hosting.IHostedService"/> implementation)
/// and supporting services to the <see cref="IServiceCollection"/>, obtaining its connection string from a <typeparamref name="TDbContext"/> instance.
/// </summary>
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> which implements <see cref="ISettingsDbContext{TSettingDbSet, TSetting}"/></typeparam>
/// <typeparam name="TSetting">Concrete type which implements <see cref="ISetting"/></typeparam>
/// <param name="services">The service collection to add the services too</param>
/// <returns>The service collection it was called on now with added services</returns>
/// <remarks>If your connection string contains a password then this method may not work, please use another overload</remarks>
/// <exception cref="NullReferenceException">If your <typeparamref name="TDbContext"/> does not have a connection string</exception>
public static IServiceCollection AddSqlServerNotificationConfigurationReloadService<TDbContext, TSetting>(this IServiceCollection services)
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
where TSetting : class, ISetting
{
var optionsBuilder = services.AddCoreServices<TDbContext, TSetting>();
optionsBuilder.Configure<TDbContext>((options, dependency) =>
{
options.ConnectionString = dependency.Database.GetConnectionString() ?? throw new NullReferenceException($"{typeof(TDbContext).Name} ConnectionString is null");
});
return services;
}
/// <summary>
/// Adds <see cref="SqlServerNotificationConfigurationReloader{,}"/> (a <see cref="Microsoft.Extensions.Hosting.IHostedService"/> implementation)
/// and supporting services to the <see cref="IServiceCollection"/>.
/// </summary>
/// <typeparam name="TDbContext">Type of the <see cref="DbContext"/> which implements <see cref="ISettingsDbContext{,}"/></typeparam>
/// <typeparam name="TSetting">Concrete type which implements <see cref="ISetting"/></typeparam>
/// <param name="services">The service collection to add the services too</param>
/// <param name="configure">
/// Action to manually configure the <see cref="Options"/> instance consumed by <see cref="SqlServerNotificationConfigurationReloader{,}"/> eg.
/// <code>
/// options => {
/// options.ConnectionString = context.Configuration.GetConnectionString("Default");
/// }
/// </code>
/// </param>
/// <returns>The service collection it was called on now with added services</returns>
public static IServiceCollection AddSqlServerNotificationConfigurationReloadService<TDbContext, TSetting>(this IServiceCollection services, Action<Options> configure)
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
where TSetting : class, ISetting
{
var optionsBuilder = services.AddCoreServices<TDbContext, TSetting>();
optionsBuilder.Configure(configure);
return services;
}
private static OptionsBuilder<Options> AddCoreServices<TDbContext, TSetting>(this IServiceCollection services)
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
where TSetting : class, ISetting
{
services.AddSingleton(static provider =>
{
var configRoot = (IConfigurationRoot)provider.GetRequiredService<IConfiguration>(); // DEBT: Is this cast always safe?
return configRoot.Providers.OfType<IEntityFrameworkCoreDbSetConfigurationProvider>().Single();
});
return services.AddHostedService<SqlServerNotificationConfigurationReloader<TDbContext, TSetting>>()
.AddOptions<Options>().ValidateDataAnnotations().ValidateOnStart();
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RAIC.Extensions.Configuration.EntityFrameworkCore\RAIC.Extensions.Configuration.EntityFrameworkCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,83 @@
# RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer
This library enhances `RAIC.Extensions.Configuration.EntityFrameworkCore` with support for reload on update the via the
[query change notifications](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/sql/query-notifications-in-sql-server) capability exposed by
`Microsoft.Data.SqlClient.SqlDependency` along with SQL Server's
[Change Tracking](https://learn.microsoft.com/en-us/sql/relational-databases/track-changes/about-change-tracking-sql-server) feature.
## Goals
1. No polling!
1. Updates happen in background via worker service (`IHostedService`)
1. Only update settings which change rather than reloading all of them
## Requirements
* .NET 8
## Gotchas
* Won't work with Azure SQL until Microsoft adds/enables Service Broker support
* Setting values cannot be `null` (as signified by the `RequiredAttribute` on `ISetting.Value`)
## Known Issues
* Not tested under load
## Configuration Options
There is a single property which can be configured (cf. the `SqlServerNotificationConfigurationReloaderOptions` POCO)
1. `ConnectionString` - the full connection string for the SQL Server instance
## Setup
For `SqlServerNotificationConfigurationReloader` to work it requires Change Tracking to be enable on the `Settings` table (and therefore also on the database itself)
eg:
```sql
ALTER DATABASE myDatabase
SET CHANGE_TRACKING = ON (AUTO_CLEANUP = ON);
ALTER TABLE dbo.Settings
ENABLE CHANGE_TRACKING;
```
Reccommend adding your SQL to the migration which adds the `Settings` table/view (or a new migration if that table/view already exists).
## Usage Example
```csharp
using RAIC.Extensions.Configuration.EntityFrameworkCore;
using RAIC.Extensions.Configuration.EntityFrameworkCore.Extensions;
using RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.Extensions;
[Table("settings", Schema = "dbo")] // Need to explicitly set schema somehow, this is one way to do it
public record Setting : ISetting
{
[Key]
public required string Key { get; set; }
[Required]
public required string Value { get; set; }
}
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options), ISettingsDbContext<DbSet<Setting>,Setting>
{
public DbSet<Setting> Settings { get; set; }
}
var builder = Host.CreateApplicationBuilder(args); // or WebApplication.CreateBuilder(args);
// build an initial configuration
builder.Configuration.AddJsonFile("appsettings.json")
...
.AddUserSecrets<Program>(); // or wherever your connection string lives
builder.Configuration.AddDbSet<MyDbContext, Setting>(dbContextOptions => dbContextOptions.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
...
// Add the SqlServerNotificationConfigurationReloader background service and supporting services to obtain setting reloading functionalty
builder.Services.AddSqlServerNotificationConfigurationReloadService<MyDbContext, Settings>(); // uses connection string from MyDbContext. Other overrides exist if this doesn't work for you - see cods docs
await builder.Build().RunAsync(); // use config as normal
```
Read more about [Configuration](https://docs.microsoft.com/en-us/dotnet/core/extensions/configuration) and [Options](https://docs.microsoft.com/en-us/dotnet/core/extensions/options) on the Microsoft Docs site.

View File

@@ -0,0 +1,268 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Options = RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.SqlServerNotificationConfigurationReloaderOptions;
namespace RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer;
public class SqlServerNotificationConfigurationReloaderOptions // must be public because it appears in public method signatures (various ServiceCollectionExtension methods)
{
[Required]
public required string ConnectionString { get; set; }
}
internal class SqlServerNotificationConfigurationReloader<TDbContext, TSetting> : Microsoft.Extensions.Hosting.BackgroundService
where TDbContext : DbContext, ISettingsDbContext<DbSet<TSetting>, TSetting>
where TSetting : class, ISetting
{
private static string? _changesQueryTemplate;
private readonly TDbContext _dbContext;
private readonly IEntityFrameworkCoreDbSetConfigurationProvider _configProvider;
private readonly IDisposable? _onChangeHandler;
private readonly ILogger? _logger;
private Options _options;
public SqlServerNotificationConfigurationReloader(TDbContext dbContext, IEntityFrameworkCoreDbSetConfigurationProvider configProvider, IOptionsMonitor<Options> options, ILogger<SqlServerNotificationConfigurationReloader<TDbContext, TSetting>>? logger = null)
{
_dbContext = dbContext;
_configProvider = configProvider;
_options = options.CurrentValue;
_onChangeHandler = options.OnChange(opt => _options = opt);
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var settingsQuery = await Initialise(stoppingToken);
do
{
do
{
var (taskCompletionSource, cancelTokenRegistration) = await ListenForNotifications(settingsQuery, stoppingToken);
using (cancelTokenRegistration)
{
var lastVersion = await GetChangeTrackingVersion(stoppingToken);
try
{
await taskCompletionSource.Task;
UpdateConfiguration(lastVersion);
}
catch (Exception e) when (e is not TaskCanceledException)
{
_logger?.LogWarning(e, "Exception while listening for notifications on query '{query}'", settingsQuery);
break;
}
}
}
while (true); // each notification is one-and-done, so must keep re-regesitering indefinitely
}
while (await ReconnectOnDependencyException(stoppingToken));
_logger?.LogWarning("Giving up listening for notifications on query '{query}' because reconnection failed. Configuration updates from database will no longer occur", settingsQuery);
SqlDependency.Stop(_options.ConnectionString);
}
private async Task<string> Initialise(CancellationToken stoppingToken)
{
var openTask = _dbContext.Database.OpenConnectionAsync(stoppingToken);
try
{
SqlDependency.Start(_options.ConnectionString);
}
catch (Exception e)
{
_logger?.LogError(e, "Exception while attempting to start dependency change listener");
throw;
}
var settingsQuery = _dbContext.Settings.ToQueryString();
_logger?.LogInformation("Listening for notifications on query '{query}'", settingsQuery);
await openTask;
return settingsQuery;
}
private async Task<(TaskCompletionSource, CancellationTokenRegistration)> ListenForNotifications(string settingsQuery, CancellationToken stoppingToken)
{
using var command = new SqlCommand()
{
Connection = (SqlConnection)_dbContext.Database.GetDbConnection(),
CommandType = CommandType.Text,
CommandText = settingsQuery,
};
var dependency = new SqlDependency(command);
var tcs = new TaskCompletionSource();
dependency.OnChange += OnChange;
var tcsCancelRegistration = stoppingToken.Register(() => tcs.TrySetCanceled());
try
{
await command.ExecuteNonQueryAsync(stoppingToken);
}
catch (Exception e) when (e is not TaskCanceledException)
{
_logger?.LogError(e, "Exception while attempting to register query dependency");
throw;
}
return (tcs, tcsCancelRegistration);
void OnChange(object sender, SqlNotificationEventArgs args)
{
switch (args.Info)
{
case SqlNotificationInfo.Insert:
case SqlNotificationInfo.Update:
case SqlNotificationInfo.Delete:
case SqlNotificationInfo.Expired:
case SqlNotificationInfo.Truncate:
tcs.TrySetResult();
return;
case SqlNotificationInfo.Error:
_logger?.LogWarning("SqlDependency '{info}' from {type}@{source}", args.Info, args.Type, args.Source);
tcs.TrySetException(new Exception($"SqlDependency {args.Info} from {args.Type}@{args.Source}"));
return;
default:
_logger?.LogWarning("Ignoring '{info}' from {type}@{source} received from SqlDependency", args.Info, args.Type, args.Source);
return;
}
}
}
private async Task<long> GetChangeTrackingVersion(CancellationToken stoppingToken)
{
var query = "SELECT CHANGE_TRACKING_CURRENT_VERSION() as value";
return await _dbContext.Database.SqlQueryRaw<long>(query).FirstOrDefaultAsync(stoppingToken);
}
private void UpdateConfiguration(long lastVersion)
{
var changed = false;
foreach (var (key, value) in GetChanges(lastVersion))
{
if (value is null)
{
changed = true;
_configProvider.Remove(key);
}
else if (!_configProvider.TryGet(key, out var oldValue) || !string.Equals(oldValue, value))
{
changed = true;
_configProvider.Set(key, value);
}
}
if (changed) _configProvider.OnReload();
}
private IQueryable<(string key, string? value)> GetChanges(long lastVersion)
{
_changesQueryTemplate ??= GetQueryTemplate();
return _dbContext.Database.SqlQueryRaw<TSetting>(_changesQueryTemplate, lastVersion)
.Select(s => ValueTuple.Create(s.Key, string.Equals(s.Value, null) ? null : s.Value)); // hack to get EF to not throw if Value is null
}
private string GetQueryTemplate()
{
var entityType = _dbContext.Model.FindEntityType(typeof(TSetting))!;
var schemaQualifiedTableName = $"[{entityType.GetSchema()}].[{entityType.GetTableName()}]";
var keyColumn = $"[{entityType.FindPrimaryKey()!.Properties.Single().GetColumnName()}]";
var valueColumn = $"[{entityType.FindProperty(nameof(ISetting.Value))!.GetColumnName()}]";
var changesQueryTemplate = $$"""
SELECT ct.{{keyColumn}}, s.{{valueColumn}}
FROM CHANGETABLE( CHANGES {{schemaQualifiedTableName}}, {0} ) AS ct
LEFT JOIN {{schemaQualifiedTableName}} AS s ON ct.{{keyColumn}} = s.{{keyColumn}}
""";
return changesQueryTemplate;
}
private async Task<bool> ReconnectOnDependencyException(CancellationToken stoppingToken)
{
// explicitly close connection to force subsequent OpenConnectionAsync() to actually try to open the connection instead of it possibly being a no-op
await _dbContext.Database.CloseConnectionAsync();
try
{
await _dbContext.Database.OpenConnectionAsync(stoppingToken);
return true;
}
catch (Exception e)
{
_logger?.LogWarning(e, "Exception while attempting to reconnect to database. Configuration updates from database will no longer occur");
}
return false;
}
/*
Query here is throwing exception for some reason. Think it is getting confused about key column being aliased as value perhaps?
From the logs:
warn: RelationalEventId.QueryPossibleUnintendedUseOfEqualsWarning[20501] (Microsoft.EntityFrameworkCore.Query)
Possible unintended use of method 'Equals' for arguments 's.Value' and 's0.Key' of different types in a query.This comparison will always return false.
...
Generated query execution expression:
'queryContext => SingleQueryingEnumerable.Create<ValueTuple<string, string>>(
relationalQueryContext: (RelationalQueryContext)queryContext,
relationalCommandResolver: parameters => [LIFTABLE Constant: RelationalCommandCache.QueryExpression(
Client Projections:
0 -> 0
1 -> 1
SELECT s.Value, CASE
WHEN s0.Value == NULL THEN NULL
ELSE s0.Value
END
FROM SELECT [Key] AS Value FROM CHANGETABLE( CHANGES [dbo].[Settings], {0} )
LEFT JOIN dbo.Settings AS s0 ON CAST(0 AS bit)) | Resolver: c => new RelationalCommandCache(
What's up with that JOIN .. ON clause?!
*/
private IQueryable<(string key, string? value)> GetChangesNotWorking(long lastVersion)
{
var entityType = _dbContext.Model.FindEntityType(typeof(TSetting))!;
var schemaQualifiedTableName = $"[{entityType.GetSchema()}].[{entityType.GetTableName()}]";
var keyColumn = $"[{entityType.FindPrimaryKey()!.Properties.Single().GetColumnName()}]";
var changesQueryTemplate = $"SELECT {keyColumn} AS Value FROM CHANGETABLE( CHANGES {schemaQualifiedTableName}, {{0}} )";
var query = from changedKey in _dbContext.Database.SqlQueryRaw<string>(changesQueryTemplate, lastVersion)
join s in _dbContext.Settings
on changedKey equals s.Key into joined
from j in joined.DefaultIfEmpty()
select ValueTuple.Create(changedKey, string.Equals(j.Value, null) ? null : j.Value);
return query;
}
public override void Dispose()
{
base.Dispose();
_onChangeHandler?.Dispose();
}
}