From 4be255d6f67ebe1671a54202df964310582a1aef Mon Sep 17 00:00:00 2001 From: chunty <6099589+chunty@users.noreply.github.com> Date: Fri, 1 May 2026 23:15:58 +0100 Subject: [PATCH 1/3] Add TaskTurnstileBuilder, SqlServer table initializer, and Directory.Build.props Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 9 +++ .../SqlServerTableInitializerHostedService.cs | 60 +++++++++++++++++ .../TaskTurnstile.SqlServer.csproj | 4 -- .../TaskTurnstileBuilder.cs | 65 +++++++++++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 Directory.Build.props create mode 100644 TaskTurnstile.SqlServer/SqlServerTableInitializerHostedService.cs create mode 100644 TaskTurnstile/DependencyInjection/TaskTurnstileBuilder.cs diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..b939ffc --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + 9.0.15 + 10.0.7 + + + diff --git a/TaskTurnstile.SqlServer/SqlServerTableInitializerHostedService.cs b/TaskTurnstile.SqlServer/SqlServerTableInitializerHostedService.cs new file mode 100644 index 0000000..36414e9 --- /dev/null +++ b/TaskTurnstile.SqlServer/SqlServerTableInitializerHostedService.cs @@ -0,0 +1,60 @@ +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Hosting; +using System.Data; + +namespace TaskTurnstile.SqlServer; + +internal sealed class SqlServerTableInitializerHostedService( + string connectionString, + string schemaName, + string tableName) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + var quotedTable = $"[{schemaName.Replace("]", "]]")}].[{tableName.Replace("]", "]]")}]"; + var escapedSchema = schemaName.Replace("'", "''"); + var escapedTable = tableName.Replace("'", "''"); + + var tableInfoSql = + $"SELECT 1 FROM INFORMATION_SCHEMA.TABLES " + + $"WHERE TABLE_SCHEMA = '{escapedSchema}' AND TABLE_NAME = '{escapedTable}'"; + + var createTableSql = + $"CREATE TABLE {quotedTable}(" + + "Id nvarchar(449) COLLATE SQL_Latin1_General_CP1_CS_AS NOT NULL, " + + "Value varbinary(MAX) NOT NULL, " + + "ExpiresAtTime datetimeoffset NOT NULL, " + + "SlidingExpirationInSeconds bigint NULL, " + + "AbsoluteExpiration datetimeoffset NULL, " + + "PRIMARY KEY (Id))"; + + var createIndexSql = + $"CREATE NONCLUSTERED INDEX Index_ExpiresAtTime ON {quotedTable}(ExpiresAtTime)"; + + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + await using var checkCommand = new SqlCommand(tableInfoSql, connection); + await using var reader = await checkCommand.ExecuteReaderAsync(CommandBehavior.SingleRow, cancellationToken); + var exists = await reader.ReadAsync(cancellationToken); + await reader.CloseAsync(); + + if (exists) + return; + + await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(cancellationToken); + try + { + await new SqlCommand(createTableSql, connection, transaction).ExecuteNonQueryAsync(cancellationToken); + await new SqlCommand(createIndexSql, connection, transaction).ExecuteNonQueryAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj b/TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj index 5f4335d..864226d 100644 --- a/TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj +++ b/TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj @@ -20,10 +20,6 @@ - - - - diff --git a/TaskTurnstile/DependencyInjection/TaskTurnstileBuilder.cs b/TaskTurnstile/DependencyInjection/TaskTurnstileBuilder.cs new file mode 100644 index 0000000..7b21c90 --- /dev/null +++ b/TaskTurnstile/DependencyInjection/TaskTurnstileBuilder.cs @@ -0,0 +1,65 @@ +using TaskTurnstile.Stores; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace TaskTurnstile.DependencyInjection; + +public sealed class TaskTurnstileBuilder(IServiceCollection services) +{ + public IServiceCollection Services { get; } = services; + + /// + /// Uses the app's registered as the backing store. + /// The app must register a distributed cache (e.g. AddStackExchangeRedisCache) before building the container. + /// Task keys are prefixed with (default "cm:") to avoid collisions. + /// + public TaskTurnstileBuilder AddDistributedStore() + { + ReplaceStore(services => services.AddSingleton(sp => + { + var opts = sp.GetRequiredService(); + return new DistributedCacheTaskStateStore( + sp.GetRequiredService(), + opts.KeyPrefix); + })); + return this; + } + + /// + /// Uses a dedicated instance created by the provided factory, + /// completely isolated from the app's own cache. + /// Task keys are prefixed with (default "cm:"). + /// + public TaskTurnstileBuilder AddDistributedStore(Func cacheFactory) + { + ReplaceStore(services => services.AddSingleton(sp => + { + var opts = sp.GetRequiredService(); + return new DistributedCacheTaskStateStore(cacheFactory(sp), opts.KeyPrefix); + })); + return this; + } + + /// Replaces the backing store with a custom implementation. + public TaskTurnstileBuilder UseTaskStateStore() where T : class, ITaskStateStore + { + ReplaceStore(services => services.AddSingleton()); + return this; + } + + /// Replaces the backing store with a custom instance created by the provided factory. + public TaskTurnstileBuilder UseTaskStateStore(Func factory) + { + ReplaceStore(services => services.AddSingleton(factory)); + return this; + } + + private void ReplaceStore(Action register) + { + var existing = Services.FirstOrDefault(d => d.ServiceType == typeof(ITaskStateStore)); + if (existing is not null) + Services.Remove(existing); + register(Services); + } +} From 8a7f8fbe2703146afddfc2f18e82ffebe16def99 Mon Sep 17 00:00:00 2001 From: chunty <6099589+chunty@users.noreply.github.com> Date: Fri, 1 May 2026 23:30:14 +0100 Subject: [PATCH 2/3] Fix build: restore ProjectReference in TaskTurnstile.SqlServer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj b/TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj index 864226d..5f4335d 100644 --- a/TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj +++ b/TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj @@ -20,6 +20,10 @@ + + + + From 153db34d65eb92413113569e2cc0a193d5315e4d Mon Sep 17 00:00:00 2001 From: chunty <6099589+chunty@users.noreply.github.com> Date: Fri, 1 May 2026 23:31:05 +0100 Subject: [PATCH 3/3] Remove old ConcurrencyManager/TaskControlTower builder files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...s.cs => TaskTurnstileBuilderExtensions.cs} | 0 ...s.cs => TaskTurnstileBuilderExtensions.cs} | 0 .../TaskControlTowerBuilder.cs | 65 ------------------- .../TaskTurnstileBuilder.cs | 1 - 4 files changed, 66 deletions(-) rename TaskTurnstile.Redis/DependencyInjection/{ConcurrencyManagerBuilderExtensions.cs => TaskTurnstileBuilderExtensions.cs} (100%) rename TaskTurnstile.SqlServer/DependencyInjection/{ConcurrencyManagerBuilderExtensions.cs => TaskTurnstileBuilderExtensions.cs} (100%) delete mode 100644 TaskTurnstile/DependencyInjection/TaskControlTowerBuilder.cs diff --git a/TaskTurnstile.Redis/DependencyInjection/ConcurrencyManagerBuilderExtensions.cs b/TaskTurnstile.Redis/DependencyInjection/TaskTurnstileBuilderExtensions.cs similarity index 100% rename from TaskTurnstile.Redis/DependencyInjection/ConcurrencyManagerBuilderExtensions.cs rename to TaskTurnstile.Redis/DependencyInjection/TaskTurnstileBuilderExtensions.cs diff --git a/TaskTurnstile.SqlServer/DependencyInjection/ConcurrencyManagerBuilderExtensions.cs b/TaskTurnstile.SqlServer/DependencyInjection/TaskTurnstileBuilderExtensions.cs similarity index 100% rename from TaskTurnstile.SqlServer/DependencyInjection/ConcurrencyManagerBuilderExtensions.cs rename to TaskTurnstile.SqlServer/DependencyInjection/TaskTurnstileBuilderExtensions.cs diff --git a/TaskTurnstile/DependencyInjection/TaskControlTowerBuilder.cs b/TaskTurnstile/DependencyInjection/TaskControlTowerBuilder.cs deleted file mode 100644 index 7b21c90..0000000 --- a/TaskTurnstile/DependencyInjection/TaskControlTowerBuilder.cs +++ /dev/null @@ -1,65 +0,0 @@ -using TaskTurnstile.Stores; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace TaskTurnstile.DependencyInjection; - -public sealed class TaskTurnstileBuilder(IServiceCollection services) -{ - public IServiceCollection Services { get; } = services; - - /// - /// Uses the app's registered as the backing store. - /// The app must register a distributed cache (e.g. AddStackExchangeRedisCache) before building the container. - /// Task keys are prefixed with (default "cm:") to avoid collisions. - /// - public TaskTurnstileBuilder AddDistributedStore() - { - ReplaceStore(services => services.AddSingleton(sp => - { - var opts = sp.GetRequiredService(); - return new DistributedCacheTaskStateStore( - sp.GetRequiredService(), - opts.KeyPrefix); - })); - return this; - } - - /// - /// Uses a dedicated instance created by the provided factory, - /// completely isolated from the app's own cache. - /// Task keys are prefixed with (default "cm:"). - /// - public TaskTurnstileBuilder AddDistributedStore(Func cacheFactory) - { - ReplaceStore(services => services.AddSingleton(sp => - { - var opts = sp.GetRequiredService(); - return new DistributedCacheTaskStateStore(cacheFactory(sp), opts.KeyPrefix); - })); - return this; - } - - /// Replaces the backing store with a custom implementation. - public TaskTurnstileBuilder UseTaskStateStore() where T : class, ITaskStateStore - { - ReplaceStore(services => services.AddSingleton()); - return this; - } - - /// Replaces the backing store with a custom instance created by the provided factory. - public TaskTurnstileBuilder UseTaskStateStore(Func factory) - { - ReplaceStore(services => services.AddSingleton(factory)); - return this; - } - - private void ReplaceStore(Action register) - { - var existing = Services.FirstOrDefault(d => d.ServiceType == typeof(ITaskStateStore)); - if (existing is not null) - Services.Remove(existing); - register(Services); - } -} diff --git a/TaskTurnstile/DependencyInjection/TaskTurnstileBuilder.cs b/TaskTurnstile/DependencyInjection/TaskTurnstileBuilder.cs index 7b21c90..9aaf794 100644 --- a/TaskTurnstile/DependencyInjection/TaskTurnstileBuilder.cs +++ b/TaskTurnstile/DependencyInjection/TaskTurnstileBuilder.cs @@ -1,7 +1,6 @@ using TaskTurnstile.Stores; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; namespace TaskTurnstile.DependencyInjection;