diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/Extensions/ServiceCollectionExtensions.cs b/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..e0a39fc --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,125 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Options = RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.PostgreSQLNotificationConfigurationReloaderOptions; + +namespace RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.Extensions; + +public static class ServiceCollectionExtensions +{ + + public delegate OptionsBuilder OptionsTransformer(OptionsBuilder transform) where T : class; + + + /// + /// Adds (a implementation) + /// and supporting services to the . + /// + /// Type of the to use to obtain the underlying database connection + /// The service collection to add the services too + /// The service collection it was called on now with added services + public static IServiceCollection AddPostgreSQLNotificationConfigurationReloadService(this IServiceCollection services) + where TDbContext : DbContext + { + return services.AddPostgreSQLNotificationConfigurationReloadService(static _ => { }); + } + + /// + /// Adds (a implementation) + /// and supporting services to the . + /// + /// Type of the to use to obtain the underlying database connection + /// The service collection to add the services too + /// + /// Action to manually configure the instance consumed by eg. + /// + /// options => { + /// options.DebounceInterval = TimeSpan.FromSeconds(2); + /// options.ChannelName = "awesome_channel"; + /// } + /// + /// + /// The service collection it was called on now with added services + public static IServiceCollection AddPostgreSQLNotificationConfigurationReloadService(this IServiceCollection services, Action configure) + where TDbContext : DbContext + { + var optionsBuilder = services.AddCoreServices(); + + optionsBuilder.Configure(configure); + + return services; + } + + /// + /// Adds (a implementation) + /// and supporting services to the . + /// + /// Type of the to use to obtain the underlying database connection + /// The service collection to add the services too + /// + /// Transformer delegate to apply to the instance consumed by eg. + /// + /// optionsBuilder => optionsBuilder.Bind(context.Configuration) + /// + /// + /// The service collection it was called on now with added services + public static IServiceCollection AddPostgreSQLNotificationConfigurationReloadService(this IServiceCollection services, OptionsTransformer optionsTransform) + where TDbContext : DbContext + { + var optionsBuilder = services.AddCoreServices(); + + optionsTransform(optionsBuilder); + + return services; + } + + + private static OptionsBuilder AddCoreServices(this IServiceCollection services) + where TDbContext : DbContext + { + services.AddSingleton(static provider => provider.GetRequiredService().Database) + .AddSingleton(static provider => + { + var configRoot = (IConfigurationRoot)provider.GetRequiredService(); // DEBT: Is this cast always safe? + return configRoot.Providers.OfType().Single(); + }) + .AddHostedService(); + + return services.AddOptions().ValidateDataAnnotations().ValidateOnStart(); + } +} + + +/* + * +CREATE OR REPLACE FUNCTION settings_poc.notify_setting_change() +RETURNS trigger AS $$ +BEGIN +PERFORM pg_notify('setting_channel', concat(NEW.key,'=',NEW.value)); +RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION settings_poc.notify_setting_remove() +RETURNS trigger AS $$ +BEGIN +PERFORM pg_notify('setting_channel', OLD.key); +RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER setting_insert_trigger +AFTER INSERT ON settings_poc.settings +FOR EACH ROW EXECUTE FUNCTION settings_poc.notify_setting_change(); + +CREATE TRIGGER setting_update_trigger +AFTER UPDATE ON settings_poc.settings +FOR EACH ROW WHEN (NEW.value <> OLD.value) EXECUTE FUNCTION settings_poc.notify_setting_change(); + +CREATE TRIGGER setting_delete_trigger +AFTER DELETE ON settings_poc.settings +FOR EACH ROW EXECUTE FUNCTION settings_poc.notify_setting_remove(); + + * + */ diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/PostgreSQLNotificationConfigurationReloader.cs b/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/PostgreSQLNotificationConfigurationReloader.cs new file mode 100644 index 0000000..659bc58 --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/PostgreSQLNotificationConfigurationReloader.cs @@ -0,0 +1,187 @@ +using System.ComponentModel.DataAnnotations; +using System.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Options = RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.PostgreSQLNotificationConfigurationReloaderOptions; + +namespace RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL; + +public class PostgreSQLNotificationConfigurationReloaderOptions // must be public because it appears in public method signatures (various ServiceCollectionExtension methods) +{ + private const string DefaultChannelName = "settings_channel"; + + [Required] + public TimeSpan DebounceInterval { get; set; } = TimeSpan.FromSeconds(1); + + [Required] + public string ChannelName { get; set; } = DefaultChannelName; + + [Required] + public byte MaxReconnectionAttempts { get; set; } = 5; + + [Required] + public TimeSpan InitialReconnectionDelay { get; set; } = TimeSpan.FromSeconds(1); +} + + + +internal class PostgreSQLNotificationConfigurationReloader : Microsoft.Extensions.Hosting.BackgroundService +{ + private readonly DatabaseFacade _db; + private readonly IEntityFrameworkCoreDbSetConfigurationProvider _configProvider; + private readonly IDisposable? _onChangeHandler; + private readonly ILogger? _logger; + + private CancellationTokenSource _cts = new(); + private Options _options; + + + public PostgreSQLNotificationConfigurationReloader(DatabaseFacade dbFacade, IEntityFrameworkCoreDbSetConfigurationProvider configProvider, IOptionsMonitor options, ILogger? logger = null) + { + _db = dbFacade; + _configProvider = configProvider; + _options = options.CurrentValue; + _onChangeHandler = options.OnChange(opt => _options = opt); + _logger = logger; + } + + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var dbConnection = Initialise(); + + do + { + await ListenForNotifications(stoppingToken); + + try + { + await dbConnection.WaitAsync(stoppingToken); + } + catch (Exception e) when (e is not TaskCanceledException) + { + _logger?.LogWarning(e, "Exception while waiting for notifications on channel '{channel}'", _options.ChannelName); + } + } + while (await IsReconnectionPossible(stoppingToken)); + + _logger?.LogWarning("Giving up listening for notifications on channel '{channel}' because reconnection attempts exhausted. Configuration updates from database will no longer occur", _options.ChannelName); + } + + private Npgsql.NpgsqlConnection Initialise() + { + var dbConnection = (Npgsql.NpgsqlConnection)_db.GetDbConnection(); + dbConnection.Notification += OnNotification; + + return dbConnection; + } + + private async Task ListenForNotifications(CancellationToken stoppingToken) + { + var listenStatement = $"LISTEN {_options.ChannelName}"; + await _db.OpenConnectionAsync(stoppingToken); + await _db.ExecuteSqlRawAsync(listenStatement, stoppingToken); + _logger?.LogInformation("Listening for notifications on channel '{channel}'", _options.ChannelName); + } + + private void OnNotification(object sender, Npgsql.NpgsqlNotificationEventArgs args) + { + _logger?.LogTrace("Received notification '{payload}' on channel '{channel}'", args.Payload, args.Channel); + if (args.Channel != _options.ChannelName) + return; + + using (var previousCts = Interlocked.Exchange(ref _cts, new())) + { + previousCts.Cancel(); + } + + ReadOnlySpan keyValue = args.Payload.Split('=', 2); // DEBT: Do split without heap allocation + switch (keyValue.Length) + { + case 2: + _configProvider.Set(keyValue[0], keyValue[1]); + break; + case 1: + _configProvider.Remove(keyValue[0]); + break; + default: + _logger?.LogWarning("Invalid '{channel}' payload '{payload}'", args.Channel, args.Payload); + return; // Don't proceed to debounced reload + } + + var _ = DebouncedProviderReload(); // Intentionally running without awaiting, may be cancelled + } + + + /// + /// Trigger reload of the after waiting for a [cancelable] debounce period + /// + /// Does not log each time debounce task is cancelled, unlike + private async ValueTask DebouncedProviderReload() + { + var delayTask = Task.Delay(_options.DebounceInterval); + var cancelableTask = Task.Delay(Timeout.Infinite, _cts.Token); + var completedTask = await Task.WhenAny(delayTask, cancelableTask).ConfigureAwait(false); + + if (completedTask == delayTask) // If the completed task is the delayTask, we reached debounce delay, proceed with reload + { + _configProvider.OnReload(); + } + else + { + _logger?.LogTrace("Debounce operation canceled due to new notification"); + } + } + + + /// + /// Trigger reload of the after waiting for a [cancelable] debounce period + /// + /// Logs each time debounce task is cancelled + private async ValueTask SimpleDebouncedProviderReload() + { + try + { + await Task.Delay(_options.DebounceInterval, _cts.Token); + _configProvider.OnReload(); + } + catch (OperationCanceledException) + { + _logger?.LogTrace("Debounce operation canceled due to new notification"); + } + } + + + private async Task IsReconnectionPossible(CancellationToken stoppingToken) + { + await Task.Delay(_options.InitialReconnectionDelay, stoppingToken); + + for (var i = 1; i <= _options.MaxReconnectionAttempts; ++i) + { + _logger?.LogInformation("Reconnection check #{i}", i); + var canConnectTask = _db.CanConnectAsync(stoppingToken); + var backoffTask = Task.Delay(_options.InitialReconnectionDelay * (1 << i), stoppingToken); + var completedTask = await Task.WhenAny(backoffTask, canConnectTask); + + if (completedTask == canConnectTask) // connect finished first + { + if (await canConnectTask) return true; // if can connect return immediately + await backoffTask; // connect failed, wait for backoff time before trying again + } + else // backoff finished first, wait for connection task result before decided whether to go around again or return immediately + { + if (await canConnectTask) return true; + } + } + return false; + } + + public override void Dispose() + { + base.Dispose(); + _cts.Cancel(); // following implementation in base class which doesn't Dispose() its _stoppingCts, just calls Cancel() + _onChangeHandler?.Dispose(); + } +} \ No newline at end of file diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.csproj b/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.csproj new file mode 100644 index 0000000..0c99dbe --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/README.md b/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/README.md new file mode 100644 index 0000000..1381380 --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL/README.md @@ -0,0 +1,110 @@ +# RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL + +This library enhances `RAIC.Extensions.Configuration.EntityFrameworkCore` with support for reload on update via PostgreSQL's `LISTEN`/`NOTIFY` +[capabilities](https://www.postgresql.org/docs/current/sql-listen.html) as exposed via [npgsql](https://www.npgsql.org/doc/wait.html). + +## 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 +* Setting values cannot be `null` (as signified by the `RequiredAttribute` on `ISetting.Value`) +* Setting keys must not contain the `=` character (similar to `CommandLineConfigurationProvider` & `EnvironmentVariablesConfigurationProvider`) +* Small window of opportunity for updates to be missed during reconnection process +* Consider adding `Keepalive` to your conenction string (https://www.npgsql.org/doc/keepalive.html) if its not already present + +## Known Issues +* Not tested under load + +## Configuration Options +There are four properties which can be configured (cf. the `PostgreSQLNotificationConfigurationBackgroundServiceOptions` POCO) +1. `ChannelName` - the name of the notification channel to listen to for updates +1. `DebounceInterval` - the time interval to wait after a notification before signaling the `IEntityFrameworkCoreDbSetConfigurationProvider` to reload. +1. `MaxReconnectionAttempts` - Maximum number of times to retry connecting after a connection dropout (eg. PostgreSQL restart, network connectivity issue) +1. `InitialReconnectionDelay` - How long to wait after connection dropout before attempting to reconnection. + +## Setup +For `PostgreSQLNotificationConfigurationReloader` to work it requires triggers on the `Settings` table to be established which send notifications to the channel +corresponding to that specified by the `ChannelName` option (defaults to `settings_channel`). SQL similar to below is one (but not only) way this can be acheived: + +```sql +CREATE OR REPLACE FUNCTION notify_setting_change() +RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('settings_channel', concat(NEW.key,'=',NEW.value)); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION notify_setting_remove() +RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('settings_channel', OLD.key); + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER setting_insert_trigger +AFTER INSERT ON settings +FOR EACH ROW EXECUTE FUNCTION notify_setting_change(); + +CREATE TRIGGER setting_update_trigger +AFTER UPDATE ON settings +FOR EACH ROW WHEN (NEW.value <> OLD.value) EXECUTE FUNCTION notify_setting_change(); + +CREATE TRIGGER setting_key_update_trigger +AFTER UPDATE ON settings +FOR EACH ROW WHEN (NEW.key <> OLD.key) EXECUTE FUNCTION notify_setting_remove(); + +CREATE TRIGGER setting_delete_trigger +AFTER DELETE ON settings +FOR EACH ROW EXECUTE FUNCTION notify_setting_remove(); +``` + +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.PostgreSQL.Extensions; + +public record Setting : ISetting +{ + [Key] + public required string Key { get; set; } + + [Required] + public required string Value { get; set; } +} + +public class MyDbContext(DbContextOptions options) : DbContext(options), ISettingsDbContext, Setting> +{ + public DbSet Settings { get; set; } +} + + +var builder = Host.CreateApplicationBuilder(args); // or WebApplication.CreateBuilder(args); + +// build an initial configuration +builder.Configuration.AddJsonFile("appsettings.json") + ... + .AddUserSecrets(); // or wherever your connection string lives + +builder.Configuration.AddDbSet(dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); + +... +// Add the PostgreSQLNotificationConfigurationReloader background service and supporting services to obtain setting reloading functionalty +builder.Services.AddPostgreSQLNotificationConfigurationReloadService(); // uses default settings, other overrides exist - see code 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. \ No newline at end of file diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/Extensions/ServiceCollectionExtensions.cs b/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c5210a6 --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/Extensions/ServiceCollectionExtensions.cs @@ -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 +{ + /// + /// Adds (a implementation) + /// and supporting services to the , obtaining its connection string from a instance. + /// + /// Type of the which implements + /// Concrete type which implements + /// The service collection to add the services too + /// The service collection it was called on now with added services + /// If your connection string contains a password then this method may not work, please use another overload + /// If your does not have a connection string + public static IServiceCollection AddSqlServerNotificationConfigurationReloadService(this IServiceCollection services) + where TDbContext : DbContext, ISettingsDbContext, TSetting> + where TSetting : class, ISetting + { + var optionsBuilder = services.AddCoreServices(); + + optionsBuilder.Configure((options, dependency) => + { + options.ConnectionString = dependency.Database.GetConnectionString() ?? throw new NullReferenceException($"{typeof(TDbContext).Name} ConnectionString is null"); + }); + + return services; + } + + /// + /// Adds (a implementation) + /// and supporting services to the . + /// + /// Type of the which implements + /// Concrete type which implements + /// The service collection to add the services too + /// + /// Action to manually configure the instance consumed by eg. + /// + /// options => { + /// options.ConnectionString = context.Configuration.GetConnectionString("Default"); + /// } + /// + /// + /// The service collection it was called on now with added services + public static IServiceCollection AddSqlServerNotificationConfigurationReloadService(this IServiceCollection services, Action configure) + where TDbContext : DbContext, ISettingsDbContext, TSetting> + where TSetting : class, ISetting + { + var optionsBuilder = services.AddCoreServices(); + + optionsBuilder.Configure(configure); + + return services; + } + + + private static OptionsBuilder AddCoreServices(this IServiceCollection services) + where TDbContext : DbContext, ISettingsDbContext, TSetting> + where TSetting : class, ISetting + { + services.AddSingleton(static provider => + { + var configRoot = (IConfigurationRoot)provider.GetRequiredService(); // DEBT: Is this cast always safe? + return configRoot.Providers.OfType().Single(); + }); + + return services.AddHostedService>() + .AddOptions().ValidateDataAnnotations().ValidateOnStart(); + } +} \ No newline at end of file diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.csproj b/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.csproj new file mode 100644 index 0000000..6d84e93 --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/README.md b/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/README.md new file mode 100644 index 0000000..22971cd --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/README.md @@ -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 options) : DbContext(options), ISettingsDbContext,Setting> +{ + public DbSet Settings { get; set; } +} + + +var builder = Host.CreateApplicationBuilder(args); // or WebApplication.CreateBuilder(args); + +// build an initial configuration +builder.Configuration.AddJsonFile("appsettings.json") + ... + .AddUserSecrets(); // or wherever your connection string lives + +builder.Configuration.AddDbSet(dbContextOptions => dbContextOptions.UseSqlServer(builder.Configuration.GetConnectionString("Default"))); + +... +// Add the SqlServerNotificationConfigurationReloader background service and supporting services to obtain setting reloading functionalty +builder.Services.AddSqlServerNotificationConfigurationReloadService(); // 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. \ No newline at end of file diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/SqlServerNotificationConfigurationReloader.cs b/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/SqlServerNotificationConfigurationReloader.cs new file mode 100644 index 0000000..a8a029c --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer/SqlServerNotificationConfigurationReloader.cs @@ -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 : Microsoft.Extensions.Hosting.BackgroundService + where TDbContext : DbContext, ISettingsDbContext, 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, ILogger>? 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 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 GetChangeTrackingVersion(CancellationToken stoppingToken) + { + var query = "SELECT CHANGE_TRACKING_CURRENT_VERSION() as value"; + return await _dbContext.Database.SqlQueryRaw(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(_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 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>( + 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(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(); + } +} \ No newline at end of file diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore/EntityFrameworkCoreDbSetConfigurationProvider.cs b/RAIC.Extensions.Configuration.EntityFrameworkCore/EntityFrameworkCoreDbSetConfigurationProvider.cs new file mode 100644 index 0000000..0383077 --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore/EntityFrameworkCoreDbSetConfigurationProvider.cs @@ -0,0 +1,53 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace RAIC.Extensions.Configuration.EntityFrameworkCore; + + +internal interface IEntityFrameworkCoreDbSetConfigurationProvider : IConfigurationProvider +{ + void OnReload(); + + bool Remove(string key); +} + + +internal class EntityFrameworkCoreDbSetConfigurationProvider : ConfigurationProvider, IEntityFrameworkCoreDbSetConfigurationProvider + where TDbContext : DbContext, ISettingsDbContext, TSetting> + where TSetting : class, ISetting +{ + private readonly IEntityFrameworkCoreDbSetConfigurationSource _configurationSource; + + internal EntityFrameworkCoreDbSetConfigurationProvider(IEntityFrameworkCoreDbSetConfigurationSource configurationSource) : base() + { + _configurationSource = configurationSource; + } + + public override void Load() + { + using var dbContext = _configurationSource.DbContextFactory!.CreateDbContext(); + Data = dbContext.Settings.ToDictionary(s => s.Key, s => (string?)s.Value); + } + + public bool Remove(string key) => Data.Remove(key); + + public new void OnReload() => base.OnReload(); +} + +internal interface IEntityFrameworkCoreDbSetConfigurationSource where TDbContext : DbContext +{ + internal IDbContextFactory DbContextFactory { get; } +} + +internal class EntityFrameworkCoreDbSetConfigurationSource : IConfigurationSource, IEntityFrameworkCoreDbSetConfigurationSource + where TDbContext : DbContext, ISettingsDbContext, TSetting> + where TSetting : class, ISetting +{ + public required IDbContextFactory DbContextFactory { get; init; } + + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + return new EntityFrameworkCoreDbSetConfigurationProvider(this); + } +} \ No newline at end of file diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore/Extensions/ConfigurationBuilderExtensions.cs b/RAIC.Extensions.Configuration.EntityFrameworkCore/Extensions/ConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..61a37d4 --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore/Extensions/ConfigurationBuilderExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; + +namespace RAIC.Extensions.Configuration.EntityFrameworkCore.Extensions; + +public static class ConfigurationBuilderExtensions +{ + public delegate DbContextOptionsBuilder DbContextOptionsTransformer(DbContextOptionsBuilder transform) where T : DbContext; + + /// + /// Adds a off as a configuration provider to the . + /// + /// Type of the which implements + /// Concrete type which implements + /// + /// a which configures your . eg. + /// + /// dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default")) + /// + /// + /// The + public static IConfigurationBuilder AddDbSet(this IConfigurationBuilder builder, DbContextOptionsTransformer optionsTransformer) + where TDbContext : DbContext, ISettingsDbContext, TSetting> + where TSetting : class, ISetting + { + // DEBT: Find way to create non-pooled DbContextFactory since this is only a short lived usage + var configurationSource = new EntityFrameworkCoreDbSetConfigurationSource() + { + DbContextFactory = new PooledDbContextFactory(optionsTransformer(new DbContextOptionsBuilder()).Options, poolSize: 1) + }; + + return builder.Add(configurationSource); + } +} diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore/ISettingsDbContext.cs b/RAIC.Extensions.Configuration.EntityFrameworkCore/ISettingsDbContext.cs new file mode 100644 index 0000000..fbf1a70 --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore/ISettingsDbContext.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; + +namespace RAIC.Extensions.Configuration.EntityFrameworkCore; + +public interface ISettingsDbContext : IDisposable + where TSettingDbSet : DbSet + where TSetting : class, ISetting +{ + TSettingDbSet Settings { get; } +} + + +public interface ISetting +{ + [Key] + string Key { get; } + + [Required] + string Value { get; } +} \ No newline at end of file diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore/RAIC.Extensions.Configuration.EntityFrameworkCore.csproj b/RAIC.Extensions.Configuration.EntityFrameworkCore/RAIC.Extensions.Configuration.EntityFrameworkCore.csproj new file mode 100644 index 0000000..72dcc9a --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore/RAIC.Extensions.Configuration.EntityFrameworkCore.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + latest + enable + + + + + + + + + + + + + diff --git a/RAIC.Extensions.Configuration.EntityFrameworkCore/README.md b/RAIC.Extensions.Configuration.EntityFrameworkCore/README.md new file mode 100644 index 0000000..b3be4e0 --- /dev/null +++ b/RAIC.Extensions.Configuration.EntityFrameworkCore/README.md @@ -0,0 +1,56 @@ +# RAIC.Extensions.Configuration.EntityFrameworkCore + +This library is a `Microsoft.Extensions.Configuration.IConfigurationProvider` that reads settings from a `DbSet Settings` property +present on your Entity Framework Core `DbContext`. + +## Goals +1. Usable with minimal constraints on your entity model +1. Follows conventional configuration patterns +1. `IOptionsMonitor` update support through optional opt-in services (see `RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL` & `RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer`) + + +## Requirements +* .NET 8 + + +## Gotchas +* Setting values cannot be `null` (as signified by the `RequiredAttribute` on `ISetting.Value`) +* Setting keys should not contain the `=` character (similar to `CommandLineConfigurationProvider` & `EnvironmentVariablesConfigurationProvider`) + + +## Usage Example + +```csharp + +using RAIC.Extensions.Configuration.EntityFrameworkCore.Extensions; + +public record Setting : ISetting +{ + [Key] + public required string Key { get; set; } + + [Required] + public required string Value { get; set; } +} + +public class MyDbContext(DbContextOptions options) : DbContext(options), ISettingsDbContext, Setting> +{ + public DbSet Settings { get; set; } +} + +var builder = Host.CreateApplicationBuilder(args) // or WebApplication.CreateBuilder(args); + +// build an initial configuration +builder.Configuration.AddJsonFile("appsettings.json") + .AddUserSecrets(); // or whereever your connection string lives + +// obtain connection string from preliminary config so can initialise other settings from DbSet +builder.Configuration.AddDbSet(dbContextOptions => dbContextOptions.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); + +... + +await builder.Build().RunAsync(); // use config as normal + +``` + +Read more about [Configuration](https://docs.microsoft.com/en-us/dotnet/core/extensions/configuration) on the Microsoft Docs site. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b4b0e8 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# RAIC.Extensions.Configuration.EntityFrameworkCore + +Contains source code for the following packages +* `RAIC.Extensions.Configuration.EntityFrameworkCore` +* `RAIC.Extensions.Configuration.EntityFrameworkCore.PostgreSQL` +* `RAIC.Extensions.Configuration.EntityFrameworkCore.SqlServer`