diff --git a/Directory.Packages.props b/Directory.Packages.props index ce7bcd4..737af3f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -151,6 +151,7 @@ + diff --git a/Elsa.sln b/Elsa.sln index 5baed45..89d4e38 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -419,6 +419,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Caspian.WorkflowFramework", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Caspian.WorkflowFramework.Sample", "src\apps\Caspian.WorkflowFramework.Sample\Caspian.WorkflowFramework.Sample.csproj", "{C260D75F-19EB-4982-A98B-D8E126776910}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Caching.Redis", "src\modules\Elsa.Caching.Redis\Elsa.Caching.Redis.csproj", "{CEB64AF4-A63C-4204-828A-53E6096095A0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Caching.UnitTests", "test\unit\Elsa.Caching.UnitTests\Elsa.Caching.UnitTests.csproj", "{86197750-6B7F-4F65-8456-E30E82F53F1C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Caching.ProcessTests", "test\integration\Elsa.Caching.ProcessTests\Elsa.Caching.ProcessTests.csproj", "{0368A4B4-FA10-489D-B390-F141A6225720}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -875,6 +881,18 @@ Global {C260D75F-19EB-4982-A98B-D8E126776910}.Debug|Any CPU.Build.0 = Debug|Any CPU {C260D75F-19EB-4982-A98B-D8E126776910}.Release|Any CPU.ActiveCfg = Release|Any CPU {C260D75F-19EB-4982-A98B-D8E126776910}.Release|Any CPU.Build.0 = Release|Any CPU + {CEB64AF4-A63C-4204-828A-53E6096095A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEB64AF4-A63C-4204-828A-53E6096095A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEB64AF4-A63C-4204-828A-53E6096095A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEB64AF4-A63C-4204-828A-53E6096095A0}.Release|Any CPU.Build.0 = Release|Any CPU + {86197750-6B7F-4F65-8456-E30E82F53F1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86197750-6B7F-4F65-8456-E30E82F53F1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86197750-6B7F-4F65-8456-E30E82F53F1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86197750-6B7F-4F65-8456-E30E82F53F1C}.Release|Any CPU.Build.0 = Release|Any CPU + {0368A4B4-FA10-489D-B390-F141A6225720}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0368A4B4-FA10-489D-B390-F141A6225720}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0368A4B4-FA10-489D-B390-F141A6225720}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0368A4B4-FA10-489D-B390-F141A6225720}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1030,6 +1048,9 @@ Global {C583AF05-D517-4B7F-8955-6B61500ED3D8} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} {A0F413B7-FA0F-472D-8BC2-6193DA440072} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} {C260D75F-19EB-4982-A98B-D8E126776910} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} + {CEB64AF4-A63C-4204-828A-53E6096095A0} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {86197750-6B7F-4F65-8456-E30E82F53F1C} = {18453B51-25EB-4317-A4B3-B10518252E92} + {0368A4B4-FA10-489D-B390-F141A6225720} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/NuGet.rar b/NuGet.rar deleted file mode 100644 index 228c610..0000000 Binary files a/NuGet.rar and /dev/null differ diff --git a/scripts/Test-WorkflowCache.ps1 b/scripts/Test-WorkflowCache.ps1 new file mode 100644 index 0000000..43aed32 --- /dev/null +++ b/scripts/Test-WorkflowCache.ps1 @@ -0,0 +1,31 @@ +param( + [Parameter(Mandatory = $true)] + [string] $RedisConnection +) + +$cacheRepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$cachePreviousConnection = $env:REDIS_TEST_CONNECTION +$cacheValidationRoot = Join-Path $cacheRepoRoot 'artifacts/cache-validation' +New-Item -ItemType Directory -Path $cacheValidationRoot -Force | Out-Null +$env:REDIS_TEST_CONNECTION = $RedisConnection + +try { + Push-Location $cacheRepoRoot + dotnet test test/unit/Elsa.Caching.UnitTests/Elsa.Caching.UnitTests.csproj -m:1 -p:BuildInParallel=false -p:GeneratePackageOnBuild=false + if ($LASTEXITCODE -ne 0) { throw 'Workflow cache tests failed.' } + dotnet build test/integration/Elsa.Caching.ProcessTests/Elsa.Caching.ProcessTests.csproj -m:1 -p:BuildInParallel=false -p:GeneratePackageOnBuild=false + if ($LASTEXITCODE -ne 0) { throw 'Workflow cache process test build failed.' } + + $cacheTestRun = [Guid]::NewGuid().ToString('N') + $cacheDatabasePath = Join-Path $cacheValidationRoot ($cacheTestRun + '.db') + $cacheKeyPrefix = 'workflow-process-tests:' + $cacheTestRun + ':' + $cacheProcessProgram = Join-Path $cacheRepoRoot 'test/integration/Elsa.Caching.ProcessTests/bin/Debug/net9.0/Elsa.Caching.ProcessTests.dll' + foreach ($cacheProcessMode in @('write', 'read', 'invalidate', 'read-updated', 'pending', 'bypass')) { + dotnet $cacheProcessProgram $cacheDatabasePath $cacheKeyPrefix $cacheProcessMode + if ($LASTEXITCODE -ne 0) { throw "Independent process test failed: $cacheProcessMode" } + } +} +finally { + Pop-Location + $env:REDIS_TEST_CONNECTION = $cachePreviousConnection +} diff --git a/src/apps/Elsa.Server.Web/Configuration/RedisSettings.cs b/src/apps/Elsa.Server.Web/Configuration/RedisSettings.cs new file mode 100644 index 0000000..e9891a5 --- /dev/null +++ b/src/apps/Elsa.Server.Web/Configuration/RedisSettings.cs @@ -0,0 +1,22 @@ +namespace Elsa.Server.Web.Configuration; + +/// Host connection settings, separate from the workflow cache policy. +public class RedisSettings +{ + public bool AllowAdmin { get; set; } + public bool Ssl { get; set; } + public int ConnectTimeout { get; set; } = 5000; + public int ConnectRetry { get; set; } = 2; + public int SyncTimeout { get; set; } = 5000; + public int Database { get; set; } = 1; + public string? ServiceName { get; set; } + public string? User { get; set; } + public string? Password { get; set; } + public List Hosts { get; set; } = new(); +} + +public class RedisHostSettings +{ + public string Host { get; set; } = string.Empty; + public int Port { get; set; } = 6379; +} diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj index c6414bd..acc6342 100644 --- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -4,6 +4,8 @@ + + diff --git a/src/apps/Elsa.Server.Web/Extensions/RedisServiceCollectionExtensions.cs b/src/apps/Elsa.Server.Web/Extensions/RedisServiceCollectionExtensions.cs new file mode 100644 index 0000000..2f6b9e2 --- /dev/null +++ b/src/apps/Elsa.Server.Web/Extensions/RedisServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +using Elsa.Server.Web.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StackExchange.Redis; + +namespace Elsa.Server.Web.Extensions; + +public static class RedisServiceCollectionExtensions +{ + public static IServiceCollection AddWorkflowRedis(this IServiceCollection services, IConfiguration configuration) + { + services.TryAddSingleton(_ => ConnectionMultiplexer.Connect( + CreateConfiguration(configuration.GetSection("Redis").Get() ?? new()))); + return services; + } + + public static ConfigurationOptions CreateConfiguration(RedisSettings settings) + { + var options = new ConfigurationOptions + { + AllowAdmin = settings.AllowAdmin, + Ssl = settings.Ssl, + AbortOnConnectFail = false, + ConnectTimeout = settings.ConnectTimeout, + ConnectRetry = settings.ConnectRetry, + SyncTimeout = settings.SyncTimeout, + DefaultDatabase = settings.Database, + User = string.IsNullOrWhiteSpace(settings.User) ? null : settings.User, + Password = string.IsNullOrWhiteSpace(settings.Password) ? null : settings.Password + }; + foreach (var host in settings.Hosts.Where(x => !string.IsNullOrWhiteSpace(x.Host))) + { + if (host.Port is < 1 or > 65535) throw new InvalidOperationException("Redis host port must be between 1 and 65535."); + options.EndPoints.Add(host.Host, host.Port); + } + if (options.EndPoints.Count == 0) throw new InvalidOperationException("Redis:Hosts must contain a Redis or Sentinel endpoint."); + if (!string.IsNullOrWhiteSpace(settings.ServiceName)) + { + options.ServiceName = settings.ServiceName; + options.TieBreaker = string.Empty; + } + return options; + } +} diff --git a/src/apps/Elsa.Server.Web/Extensions/WorkflowRuntimeFeatureExtensions.cs b/src/apps/Elsa.Server.Web/Extensions/WorkflowRuntimeFeatureExtensions.cs new file mode 100644 index 0000000..3755c64 --- /dev/null +++ b/src/apps/Elsa.Server.Web/Extensions/WorkflowRuntimeFeatureExtensions.cs @@ -0,0 +1,62 @@ +using Elsa.Common.DistributedHosting.DistributedLocks; +using Elsa.Workflows.Runtime.Features; +using Medallion.Threading; +using Medallion.Threading.FileSystem; +using Medallion.Threading.Postgres; +using Medallion.Threading.Redis; +using StackExchange.Redis; + +namespace Elsa.Server.Web.Extensions; + +public static class WorkflowRuntimeFeatureExtensions +{ + public static WorkflowRuntimeFeature UseConfiguredDistributedLocking(this WorkflowRuntimeFeature runtime, IConfiguration configuration) + { + var legacySettings = configuration.GetSection("Runtime:DistributedLocking"); + var settings = configuration.GetSection("Workflow:Runtime:DistributedLocking"); + + runtime.DistributedLockingOptions = options => + { + legacySettings.Bind(options); + settings.Bind(options); + }; + + var providerName = settings["Provider"] ?? legacySettings["Provider"]; + runtime.DistributedLockProvider = sp => + { + switch (providerName?.ToUpperInvariant()) + { + case "REDIS": + { + var database = settings.GetValue("Database") + ?? legacySettings.GetValue("Database") + ?? configuration.GetValue("Redis:Database") ?? -1; + var prefix = settings["KeyPrefix"] ?? legacySettings["KeyPrefix"] ?? "nextbank:workflow:locks:"; + if (database < -1) throw new InvalidOperationException("Workflow lock database must be -1 or a non-negative number."); + if (string.IsNullOrWhiteSpace(prefix)) throw new InvalidOperationException("Workflow lock KeyPrefix must not be empty."); + var connection = sp.GetRequiredService(); + return new PrefixedDistributedLockProvider(new RedisDistributedSynchronizationProvider(connection.GetDatabase(database)), prefix); + } + case "POSTGRES": + return new PostgresDistributedSynchronizationProvider(configuration.GetConnectionString("PostgreSql")!, options => + { + options.KeepaliveCadence(TimeSpan.FromMinutes(5)); + options.UseMultiplexing(); + }); + case "FILE": + return new FileDistributedSynchronizationProvider(new(Path.Combine(Directory.GetCurrentDirectory(), "App_Data", "locks"))); + case null: + case "NOOP": + return new NoopDistributedSynchronizationProvider(); + default: + throw new InvalidOperationException($"Unsupported workflow lock provider '{providerName}'. Configure Redis, File, Postgres or Noop."); + } + }; + return runtime; + } + + private sealed class PrefixedDistributedLockProvider(IDistributedLockProvider provider, string prefix) : IDistributedLockProvider + { + public IDistributedLock CreateLock(string name) => provider.CreateLock(prefix + name); + } +} diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index c97a88c..429bc7c 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -3,7 +3,6 @@ using Elsa.Alterations.Extensions; using Elsa.Alterations.MassTransit.Extensions; using Elsa.Caching.Options; using Elsa.Common.Codecs; -using Elsa.Common.DistributedHosting.DistributedLocks; using Elsa.Common.RecurringTasks; using Elsa.Common.Serialization; using Elsa.Dapper.Extensions; @@ -64,9 +63,6 @@ using Hangfire.PostgreSql.Factories; using Hangfire.SqlServer; using Hangfire.Storage.SQLite; using JetBrains.Annotations; -using Medallion.Threading.FileSystem; -using Medallion.Threading.Postgres; -using Medallion.Threading.Redis; using Microsoft.Extensions.Options; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -76,7 +72,6 @@ using Proto.Persistence.Sqlite; using Proto.Persistence.SqlServer; using Proto.Remote; using Proto.Remote.GrpcNet; -using StackExchange.Redis; // ReSharper disable RedundantAssignment const PersistenceProvider persistenceProvider = PersistenceProvider.EntityFrameworkCore; @@ -119,8 +114,6 @@ var cockroachDbConnectionString = configuration.GetConnectionString("CockroachDb var mongoDbConnectionString = configuration.GetConnectionString("MongoDb")!; var azureServiceBusConnectionString = configuration.GetConnectionString("AzureServiceBus")!; var rabbitMqConnectionString = configuration.GetConnectionString("RabbitMq")!; -var redisConnectionString = configuration.GetConnectionString("Redis")!; -var distributedLockProviderName = configuration.GetSection("Runtime:DistributedLocking")["Provider"]; var appRole = Enum.Parse(configuration["AppRole"] ?? "Default"); var sqlDatabaseProvider = Enum.Parse(configuration["DatabaseProvider"] ?? "Sqlite"); @@ -164,10 +157,13 @@ if (useManualOtelInstrumentation) } // Add Elsa services. +services.AddWorkflowRedis(configuration); services.AddScoped(); services .AddElsa(elsa => { + elsa.UseCaching(cache => cache.RegisterRedis()); + if (persistenceProvider == PersistenceProvider.MongoDb) elsa.UseMongoDb(mongoDbConnectionString); @@ -324,7 +320,7 @@ services management.UseMassTransitDispatcher(); if (useCaching) - management.UseCache(); + management.UseCache(configuration.GetSection("Workflow:Cache")); management.SetDefaultLogPersistenceMode(LogPersistenceMode.Inherit); management.UseReadOnlyMode(useReadOnlyMode); @@ -394,31 +390,7 @@ services if (useCaching) runtime.UseCache(); - runtime.DistributedLockingOptions = options => configuration.GetSection("Runtime:DistributedLocking").Bind(options); - - runtime.DistributedLockProvider = _ => - { - switch (distributedLockProviderName) - { - case "Postgres": - return new PostgresDistributedSynchronizationProvider(postgresConnectionString, options => - { - options.KeepaliveCadence(TimeSpan.FromMinutes(5)); - options.UseMultiplexing(); - }); - case "Redis": - { - var connectionMultiplexer = ConnectionMultiplexer.Connect(redisConnectionString); - var database = connectionMultiplexer.GetDatabase(); - return new RedisDistributedSynchronizationProvider(database); - } - case "File": - return new FileDistributedSynchronizationProvider(new(Path.Combine(Directory.GetCurrentDirectory(), "App_Data", "locks"))); - case "Noop": - default: - return new NoopDistributedSynchronizationProvider(); - } - }; + runtime.UseConfiguredDistributedLocking(configuration); }) .UseEnvironments(environments => environments.EnvironmentsOptions = options => configuration.GetSection("Environments").Bind(options)) .UseScheduling(scheduling => @@ -758,7 +730,6 @@ services.Configure(options => services.Configure(options => { options.InactivityThreshold = TimeSpan.FromSeconds(15); }); services.Configure(options => options.Ttl = TimeSpan.FromSeconds(10)); -services.Configure(options => options.CacheDuration = TimeSpan.FromDays(1)); services.Configure(options => options.DefaultIncidentStrategy = typeof(ContinueWithIncidentsStrategy)); services.AddHealthChecks(); services.AddControllers(); @@ -825,4 +796,4 @@ public partial class Program /// Set by the test runner to configure the module for testing. /// public static Action? ConfigureForTest { get; set; } -} \ No newline at end of file +} diff --git a/src/apps/Elsa.Server.Web/Properties/launchSettings.json b/src/apps/Elsa.Server.Web/Properties/launchSettings.json index 2bff847..65a64df 100644 --- a/src/apps/Elsa.Server.Web/Properties/launchSettings.json +++ b/src/apps/Elsa.Server.Web/Properties/launchSettings.json @@ -12,7 +12,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://localhost:5002;http://localhost:5072", + "applicationUrl": "https://localhost:5001;http://localhost:5072", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/apps/Elsa.Server.Web/appsettings.Development.json b/src/apps/Elsa.Server.Web/appsettings.Development.json new file mode 100644 index 0000000..728f986 --- /dev/null +++ b/src/apps/Elsa.Server.Web/appsettings.Development.json @@ -0,0 +1,31 @@ +{ + "Redis": { + "AllowAdmin": true, + "Ssl": false, + "ConnectTimeout": 5000, + "ConnectRetry": 2, + "Database": 1, + "ServiceName": "", + "Hosts": [ + { "Host": "localhost", "Port": 6379 } + ] + }, + "Workflow": { + "Cache": { + "Enabled": true, + "Provider": "Redis", + "Duration": "1.00:00:00", + "Redis": { + "Database": 1, + "KeyPrefix": "nextbank:workflow:cache:" + } + }, + "Runtime": { + "DistributedLocking": { + "Provider": "Redis", + "Database": 1, + "KeyPrefix": "nextbank:workflow:locks:" + } + } + } +} diff --git a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj index 82d63fa..3332e92 100644 --- a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj +++ b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Api.Client Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides HTTP clients for the Elsa Workflows REST API. diff --git a/src/common/Elsa.Api.Common/Elsa.Api.Common.csproj b/src/common/Elsa.Api.Common/Elsa.Api.Common.csproj index 5f193b5..495975b 100644 --- a/src/common/Elsa.Api.Common/Elsa.Api.Common.csproj +++ b/src/common/Elsa.Api.Common/Elsa.Api.Common.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Api.Common Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides common features to modules that expose API endpoints. diff --git a/src/common/Elsa.DropIns.Core/Elsa.DropIns.Core.csproj b/src/common/Elsa.DropIns.Core/Elsa.DropIns.Core.csproj index b828127..97b1672 100644 --- a/src/common/Elsa.DropIns.Core/Elsa.DropIns.Core.csproj +++ b/src/common/Elsa.DropIns.Core/Elsa.DropIns.Core.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.DropIns.Core Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Reference this package when developing drop-ins for Elsa Workflows. diff --git a/src/common/Elsa.DropIns/Elsa.DropIns.csproj b/src/common/Elsa.DropIns/Elsa.DropIns.csproj index ad755ae..71305e9 100644 --- a/src/common/Elsa.DropIns/Elsa.DropIns.csproj +++ b/src/common/Elsa.DropIns/Elsa.DropIns.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.DropIns Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides drop-in features for Elsa workflows. diff --git a/src/common/Elsa.Features/Elsa.Features.csproj b/src/common/Elsa.Features/Elsa.Features.csproj index 936cad7..acbe4c1 100644 --- a/src/common/Elsa.Features/Elsa.Features.csproj +++ b/src/common/Elsa.Features/Elsa.Features.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Features Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides utilities to turn your class library into a module that provides features. diff --git a/src/common/Elsa.Mediator/Elsa.Mediator.csproj b/src/common/Elsa.Mediator/Elsa.Mediator.csproj index 075676b..5df5b7a 100644 --- a/src/common/Elsa.Mediator/Elsa.Mediator.csproj +++ b/src/common/Elsa.Mediator/Elsa.Mediator.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Mediator Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a mediator service inspired by MediatR. diff --git a/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj b/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj index 3ecfae7..9f7bbf3 100644 --- a/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj +++ b/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Testing.Shared.Component Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides common utilities that are helpful when writing component tests against Elsa Workflows functionality. diff --git a/src/common/Elsa.Testing.Shared.Integration/Elsa.Testing.Shared.Integration.csproj b/src/common/Elsa.Testing.Shared.Integration/Elsa.Testing.Shared.Integration.csproj index 899d965..f9c65f1 100644 --- a/src/common/Elsa.Testing.Shared.Integration/Elsa.Testing.Shared.Integration.csproj +++ b/src/common/Elsa.Testing.Shared.Integration/Elsa.Testing.Shared.Integration.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Testing.Shared.Integration Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides common utilities that are helpful when writing integration tests against Elsa Workflows functionality. diff --git a/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj b/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj index 8371cfa..2f104d5 100644 --- a/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj +++ b/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Testing.Shared Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides common utilities that are helpful when writing tests against Elsa Workflows functionality. diff --git a/src/modules/Elsa.Alterations.Core/Elsa.Alterations.Core.csproj b/src/modules/Elsa.Alterations.Core/Elsa.Alterations.Core.csproj index ce7b7cd..4787134 100644 --- a/src/modules/Elsa.Alterations.Core/Elsa.Alterations.Core.csproj +++ b/src/modules/Elsa.Alterations.Core/Elsa.Alterations.Core.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Alterations.Core Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides core interfaces, models and services that support the alteration engine. diff --git a/src/modules/Elsa.Alterations.MassTransit/Elsa.Alterations.MassTransit.csproj b/src/modules/Elsa.Alterations.MassTransit/Elsa.Alterations.MassTransit.csproj index 51cfdb0..f563123 100644 --- a/src/modules/Elsa.Alterations.MassTransit/Elsa.Alterations.MassTransit.csproj +++ b/src/modules/Elsa.Alterations.MassTransit/Elsa.Alterations.MassTransit.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Alterations.MassTransit Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides alteration background job runners using MassTransit. diff --git a/src/modules/Elsa.Alterations/Elsa.Alterations.csproj b/src/modules/Elsa.Alterations/Elsa.Alterations.csproj index 9ad1870..6411851 100644 --- a/src/modules/Elsa.Alterations/Elsa.Alterations.csproj +++ b/src/modules/Elsa.Alterations/Elsa.Alterations.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Alterations Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides alterations for workflow instances. diff --git a/src/modules/Elsa.CSharp/Elsa.CSharp.csproj b/src/modules/Elsa.CSharp/Elsa.CSharp.csproj index d7e56a2..9cabcc3 100644 --- a/src/modules/Elsa.CSharp/Elsa.CSharp.csproj +++ b/src/modules/Elsa.CSharp/Elsa.CSharp.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.CSharp Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a CSharp expression provider. diff --git a/src/modules/Elsa.Caching.Distributed.MassTransit/Elsa.Caching.Distributed.MassTransit.csproj b/src/modules/Elsa.Caching.Distributed.MassTransit/Elsa.Caching.Distributed.MassTransit.csproj index 2aafb37..190440c 100644 --- a/src/modules/Elsa.Caching.Distributed.MassTransit/Elsa.Caching.Distributed.MassTransit.csproj +++ b/src/modules/Elsa.Caching.Distributed.MassTransit/Elsa.Caching.Distributed.MassTransit.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Caching.Distributed.MassTransit Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides distributed caching services leveraging MassTransit for transport of signals. diff --git a/src/modules/Elsa.Caching.Distributed.ProtoActor/Elsa.Caching.Distributed.ProtoActor.csproj b/src/modules/Elsa.Caching.Distributed.ProtoActor/Elsa.Caching.Distributed.ProtoActor.csproj index 81e6fa1..13f1ba9 100644 --- a/src/modules/Elsa.Caching.Distributed.ProtoActor/Elsa.Caching.Distributed.ProtoActor.csproj +++ b/src/modules/Elsa.Caching.Distributed.ProtoActor/Elsa.Caching.Distributed.ProtoActor.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Caching.Distributed.ProtoActor Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides distributed caching services leveraging Proto.Actor for transport of signals. diff --git a/src/modules/Elsa.Caching.Distributed/Elsa.Caching.Distributed.csproj b/src/modules/Elsa.Caching.Distributed/Elsa.Caching.Distributed.csproj index 49303a7..0246b27 100644 --- a/src/modules/Elsa.Caching.Distributed/Elsa.Caching.Distributed.csproj +++ b/src/modules/Elsa.Caching.Distributed/Elsa.Caching.Distributed.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Caching.Distributed Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides distributed caching services and utility functions common to Elsa modules. diff --git a/src/modules/Elsa.Caching.Redis/Elsa.Caching.Redis.csproj b/src/modules/Elsa.Caching.Redis/Elsa.Caching.Redis.csproj new file mode 100644 index 0000000..3a0ba76 --- /dev/null +++ b/src/modules/Elsa.Caching.Redis/Elsa.Caching.Redis.csproj @@ -0,0 +1,21 @@ + + + Caspian.Elsa.Caching.Redis + Caspian + Behzad Alizadeh + 1.1.0-preview.11 + True + + Bundles the most commonly-used packages when building an Elsa workflows application. + + Elsa + elsa bundle + + + + + + + + + diff --git a/src/modules/Elsa.Caching.Redis/Extensions/CacheFeatureExtensions.cs b/src/modules/Elsa.Caching.Redis/Extensions/CacheFeatureExtensions.cs new file mode 100644 index 0000000..a08f9d1 --- /dev/null +++ b/src/modules/Elsa.Caching.Redis/Extensions/CacheFeatureExtensions.cs @@ -0,0 +1,31 @@ +using Elsa.Caching.Features; +using Elsa.Caching.Redis.Features; +using Elsa.Caching.Redis.Options; +using Elsa.Caching.Redis.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Extensions; + +public static class RedisCacheFeatureExtensions +{ + public static CacheFeature RegisterRedis(this CacheFeature cache) + => cache.RegisterProvider("Redis", section => cache.UseRedis(options => { if (section != null) section.Bind(options); })); + + public static CacheFeature UseRedis(this CacheFeature cache, Action? configure = null) + { + cache.Module.Configure(redis => + { + // Bind first; explicit property assignments then override individual settings. + cache.Configuration?.GetSection("Redis").Bind(redis.Options); + configure?.Invoke(redis.Options); + }); + return cache.SelectProvider("Redis", sp => sp.GetRequiredService()); + } + + public static T UseRedis(this T feature, Action? configure = null) where T : CacheConsumerFeature + { + feature.Cache.UseRedis(configure); + return feature; + } +} diff --git a/src/modules/Elsa.Caching.Redis/Features/RedisCacheFeature.cs b/src/modules/Elsa.Caching.Redis/Features/RedisCacheFeature.cs new file mode 100644 index 0000000..58ba566 --- /dev/null +++ b/src/modules/Elsa.Caching.Redis/Features/RedisCacheFeature.cs @@ -0,0 +1,33 @@ +using Elsa.Caching.Features; +using Elsa.Caching.Redis.Options; +using Elsa.Caching.Redis.Services; +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +namespace Elsa.Caching.Redis.Features; + +[DependsOn(typeof(CacheFeature))] +public class RedisCacheFeature(IModule module) : FeatureBase(module) +{ + public RedisCacheOptions Options { get; } = new(); + public override void Apply() + { + if (Options.Database < -1) throw new InvalidOperationException("Redis cache database must be -1 or a non-negative number."); + if (string.IsNullOrWhiteSpace(Options.KeyPrefix)) throw new InvalidOperationException("Redis cache KeyPrefix must not be empty."); + Services.AddSingleton(Options); + if (!Options.HasExplicitConnectionFactory && !string.IsNullOrWhiteSpace(Options.ConnectionString)) + { + Services.AddSingleton(_ => new(ConnectionMultiplexer.Connect(Options.ConnectionString))); + Options.ConnectionFactory = sp => sp.GetRequiredService().Connection; + } + Services.AddSingleton(); + } + private sealed class OwnedRedisCacheConnection(IConnectionMultiplexer connection) : IDisposable + { + public IConnectionMultiplexer Connection => connection; + public void Dispose() => connection.Dispose(); + } +} diff --git a/src/modules/Elsa.Caching.Redis/FodyWeavers.xml b/src/modules/Elsa.Caching.Redis/FodyWeavers.xml new file mode 100644 index 0000000..00e1d9a --- /dev/null +++ b/src/modules/Elsa.Caching.Redis/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Caching.Redis/Options/RedisCacheOptions.cs b/src/modules/Elsa.Caching.Redis/Options/RedisCacheOptions.cs new file mode 100644 index 0000000..92f9a06 --- /dev/null +++ b/src/modules/Elsa.Caching.Redis/Options/RedisCacheOptions.cs @@ -0,0 +1,20 @@ +using StackExchange.Redis; + +namespace Elsa.Caching.Redis.Options; + +public class RedisCacheOptions +{ + /// Uses the host's singleton connection by default. The cache does not dispose host connections. + private Func _connectionFactory + = sp => (IConnectionMultiplexer)(sp.GetService(typeof(IConnectionMultiplexer)) + ?? throw new InvalidOperationException("Register IConnectionMultiplexer or configure Redis ConnectionFactory/ConnectionString.")); + internal bool HasExplicitConnectionFactory { get; private set; } + public Func ConnectionFactory + { + get => _connectionFactory; + set { _connectionFactory = value ?? throw new ArgumentNullException(nameof(value)); HasExplicitConnectionFactory = true; } + } + public string? ConnectionString { get; set; } + public int Database { get; set; } = -1; + public string KeyPrefix { get; set; } = "nextbank:workflow:cache:"; +} diff --git a/src/modules/Elsa.Caching.Redis/Services/RedisCacheProvider.cs b/src/modules/Elsa.Caching.Redis/Services/RedisCacheProvider.cs new file mode 100644 index 0000000..0204c5f --- /dev/null +++ b/src/modules/Elsa.Caching.Redis/Services/RedisCacheProvider.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using Elsa.Caching.Models; +using Elsa.Caching.Redis.Options; +using Elsa.Workflows; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Elsa.Caching.Redis.Services; + +/// Stores serializable payloads only. Redis failures bypass caching; never fall back to local payloads. +public class RedisCacheProvider(IServiceProvider services, RedisCacheOptions options, IPayloadSerializer serializer, + ILogger logger) : ICacheProvider +{ + public bool IsShared => true; + private IDatabase Database => options.ConnectionFactory(services).GetDatabase(options.Database); + private string Key(string key) => options.KeyPrefix + key; + + private const string ReadScript = """ + local payload = redis.call('HGET', KEYS[1], 'payload') + if not payload then return false end + local slide = tonumber(redis.call('HGET', KEYS[1], 'slide')) or 0 + local deadline = tonumber(redis.call('HGET', KEYS[1], 'deadline')) or 0 + if slide > 0 then + local ttl = slide + if deadline > 0 then + local t = redis.call('TIME') + ttl = math.min(ttl, deadline - (tonumber(t[1])*1000 + math.floor(tonumber(t[2])/1000))) + end + if ttl <= 0 then redis.call('DEL', KEYS[1]); return false end + redis.call('PEXPIRE', KEYS[1], ttl) + end + return payload + """; + private const string WriteScript = """ + local absolute = tonumber(ARGV[2]) + local slide = tonumber(ARGV[3]) + local ttl = absolute + if ttl <= 0 or (slide > 0 and slide < ttl) then ttl = slide end + local deadline = 0 + if absolute > 0 then + local t = redis.call('TIME') + deadline = tonumber(t[1])*1000 + math.floor(tonumber(t[2])/1000) + absolute + end + redis.call('HSET', KEYS[1], 'payload', ARGV[1], 'slide', slide, 'deadline', deadline) + redis.call('PEXPIRE', KEYS[1], ttl) + return 1 + """; + + public async Task> GetAsync(string key, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var data = await Database.ScriptEvaluateAsync(ReadScript, new RedisKey[] { Key(key) }).WaitAsync(cancellationToken); + if (data.IsNull) return new(false, default); + return new(true, JsonSerializer.Deserialize((string)data!, serializer.GetOptions())); + } + catch (RedisException ex) { logger.LogWarning(ex, "Redis workflow cache read failed; using the persistent store."); return new(false, default); } + catch (JsonException ex) { logger.LogWarning(ex, "Invalid Redis workflow cache payload; rebuilding the entry."); return new(false, default); } + } + + public async Task SetAsync(string key, T? value, CacheEntryOptions entryOptions, CancellationToken cancellationToken = default) + { + var absolute = Milliseconds(entryOptions.AbsoluteExpirationRelativeToNow); + var sliding = Milliseconds(entryOptions.SlidingExpiration); + if (absolute <= 0 && sliding <= 0) throw new ArgumentException("A Redis cache entry must have a positive lifetime."); + var data = JsonSerializer.Serialize(value, serializer.GetOptions()); + try + { + await Database.ScriptEvaluateAsync(WriteScript, new RedisKey[] { Key(key) }, new RedisValue[] { data, absolute, sliding }).WaitAsync(cancellationToken); + } + catch (RedisException ex) { logger.LogWarning(ex, "Redis workflow cache write failed; the SQL revision remains authoritative."); } + } + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + try { await Database.KeyDeleteAsync(Key(key)).WaitAsync(cancellationToken); } + catch (RedisException ex) { logger.LogWarning(ex, "Redis workflow cache removal failed."); } + } + private static long Milliseconds(TimeSpan? duration) => duration.HasValue ? Math.Max(1, (long)duration.Value.TotalMilliseconds) : 0; +} diff --git a/src/modules/Elsa.Caching/Contracts/ICacheConsistencyStore.cs b/src/modules/Elsa.Caching/Contracts/ICacheConsistencyStore.cs new file mode 100644 index 0000000..693fa39 --- /dev/null +++ b/src/modules/Elsa.Caching/Contracts/ICacheConsistencyStore.cs @@ -0,0 +1,15 @@ +namespace Elsa.Caching; + +/// +/// Authoritative cache revision and durable write fences. A pending mutation disables cache reads and writes. +/// Shared providers require a durable store shared by every node, independently of the cache payload storage. +/// +public interface ICacheConsistencyStore +{ + bool IsDurable { get; } + Task ReadAsync(CancellationToken cancellationToken = default); + Task BeginMutationAsync(CancellationToken cancellationToken = default, string scope = "workflow"); + Task CompleteMutationAsync(string mutationId, CancellationToken cancellationToken = default); +} + +public record CacheConsistencyState(string Revision, bool HasPendingMutations, string? RouteRevision = null, bool HasPendingRouteMutations = false); diff --git a/src/modules/Elsa.Caching/Contracts/ICacheManager.cs b/src/modules/Elsa.Caching/Contracts/ICacheManager.cs index 29deac5..2139b9e 100644 --- a/src/modules/Elsa.Caching/Contracts/ICacheManager.cs +++ b/src/modules/Elsa.Caching/Contracts/ICacheManager.cs @@ -10,6 +10,21 @@ namespace Elsa.Caching; /// public interface ICacheManager { + bool IsShared => false; + bool IsEnabled => true; + Task BeginMutationAsync(CancellationToken cancellationToken = default) => Task.FromResult(string.Empty); + Task BeginRouteMutationAsync(CancellationToken cancellationToken = default) => BeginMutationAsync(cancellationToken); + Task CompleteMutationAsync(string id, CancellationToken cancellationToken = default) => Task.CompletedTask; + Task GetConsistencyStateAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new CacheConsistencyState("0", false)); + Task GetOrCreateAsync(string key, Func> factory, Elsa.Caching.Models.CacheEntryOptions options, CancellationToken cancellationToken = default) + => GetOrCreateAsync(key, entry => + { + entry.AbsoluteExpirationRelativeToNow = options.AbsoluteExpirationRelativeToNow; + entry.SlidingExpiration = options.SlidingExpiration; + foreach (var tag in options.Tags) entry.ExpirationTokens.Add(GetToken(tag)); + return factory(); + }); /// /// Provides options for configuring caching. /// @@ -35,4 +50,4 @@ public interface ICacheManager /// /// void Remove(string key); -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Caching/Contracts/ICacheProvider.cs b/src/modules/Elsa.Caching/Contracts/ICacheProvider.cs new file mode 100644 index 0000000..2c6959d --- /dev/null +++ b/src/modules/Elsa.Caching/Contracts/ICacheProvider.cs @@ -0,0 +1,14 @@ +using Elsa.Caching.Models; + +namespace Elsa.Caching; + +/// Storage for cache entries. Implementations must distinguish a miss from a cached null. +public interface ICacheProvider +{ + bool IsShared { get; } + Task> GetAsync(string key, CancellationToken cancellationToken = default); + Task SetAsync(string key, T? value, CacheEntryOptions options, CancellationToken cancellationToken = default); + Task RemoveAsync(string key, CancellationToken cancellationToken = default); +} + +public record CacheResult(bool Found, T? Value); diff --git a/src/modules/Elsa.Caching/Elsa.Caching.csproj b/src/modules/Elsa.Caching/Elsa.Caching.csproj index 7271f74..4150689 100644 --- a/src/modules/Elsa.Caching/Elsa.Caching.csproj +++ b/src/modules/Elsa.Caching/Elsa.Caching.csproj @@ -1,10 +1,10 @@ - + Caspian.Elsa.Caching Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides caching services and utility functions common to Elsa modules. @@ -19,6 +19,8 @@ + + diff --git a/src/modules/Elsa.Caching/Extensions/CacheFeatureExtensions.cs b/src/modules/Elsa.Caching/Extensions/CacheFeatureExtensions.cs new file mode 100644 index 0000000..af2aeb2 --- /dev/null +++ b/src/modules/Elsa.Caching/Extensions/CacheFeatureExtensions.cs @@ -0,0 +1,13 @@ +using Elsa.Caching.Features; +using Elsa.Features.Services; + +namespace Elsa.Extensions; + +public static class CacheFeatureExtensions +{ + public static IModule UseCaching(this IModule module, Action? configure = null) + { + module.Configure(configure); + return module; + } +} diff --git a/src/modules/Elsa.Caching/Extensions/CacheMutationExtensions.cs b/src/modules/Elsa.Caching/Extensions/CacheMutationExtensions.cs new file mode 100644 index 0000000..516941a --- /dev/null +++ b/src/modules/Elsa.Caching/Extensions/CacheMutationExtensions.cs @@ -0,0 +1,34 @@ +using Elsa.Caching; + +namespace Elsa.Extensions; + +public static class CacheMutationExtensions +{ + public static async Task MutateRoutesAsync(this ICacheManager cache, Func operation, CancellationToken cancellationToken = default) + { + var id = await cache.BeginRouteMutationAsync(cancellationToken); + await operation(); + await cache.CompleteMutationAsync(id, CancellationToken.None); + } + public static async Task MutateRoutesAsync(this ICacheManager cache, Func> operation, CancellationToken cancellationToken = default) + { + var id = await cache.BeginRouteMutationAsync(cancellationToken); + var value = await operation(); + await cache.CompleteMutationAsync(id, CancellationToken.None); + return value; + } + public static async Task MutateAsync(this ICacheManager cache, Func operation, CancellationToken cancellationToken = default) + { + var id = await cache.BeginMutationAsync(cancellationToken); + await operation(); + // Once SQL has committed, request cancellation must not leave a fence unfinished. + await cache.CompleteMutationAsync(id, CancellationToken.None); + } + public static async Task MutateAsync(this ICacheManager cache, Func> operation, CancellationToken cancellationToken = default) + { + var id = await cache.BeginMutationAsync(cancellationToken); + var value = await operation(); + await cache.CompleteMutationAsync(id, CancellationToken.None); + return value; + } +} diff --git a/src/modules/Elsa.Caching/Features/CacheConsumerFeature.cs b/src/modules/Elsa.Caching/Features/CacheConsumerFeature.cs new file mode 100644 index 0000000..399333b --- /dev/null +++ b/src/modules/Elsa.Caching/Features/CacheConsumerFeature.cs @@ -0,0 +1,14 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Services; + +namespace Elsa.Caching.Features; + +/// Common configuration entry point for workflow cache consumers. +public abstract class CacheConsumerFeature(IModule module) : FeatureBase(module) +{ + public CacheFeature Cache => Module.Configure(); + public bool Enabled { get => Cache.Enabled; set => Cache.Enabled = value; } + public TimeSpan Duration { get => Cache.Duration; set => Cache.Duration = value; } + public void UseMemory() => Cache.UseMemory(); + public void UseConfiguration(Microsoft.Extensions.Configuration.IConfigurationSection section) => Cache.UseConfiguration(section); +} diff --git a/src/modules/Elsa.Caching/Features/CacheFeature.cs b/src/modules/Elsa.Caching/Features/CacheFeature.cs new file mode 100644 index 0000000..9a2ce3c --- /dev/null +++ b/src/modules/Elsa.Caching/Features/CacheFeature.cs @@ -0,0 +1,80 @@ +using Elsa.Caching.Services; +using Elsa.Caching.Options; +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Caching.Features; + +/// Configures one provider for the entire Elsa module. +[DependsOn(typeof(MemoryCacheFeature))] +public class CacheFeature(IModule module) : FeatureBase(module) +{ + private readonly Dictionary> _providers = new(StringComparer.OrdinalIgnoreCase); + private string? _explicitProvider; + private bool _selectingConfiguration; + public bool Enabled { get; set; } = true; + public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(1); + public Func Provider { get; private set; } = sp => sp.GetRequiredService(); + public Func ConsistencyStore { get; set; } = sp => sp.GetRequiredService(); + + public CacheFeature RegisterProvider(string name, Action configure) + { + _providers[name] = configure; + return this; + } + + public CacheFeature SelectProvider(string name, Func factory) + { + if (_explicitProvider != null && !string.Equals(_explicitProvider, name, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Conflicting Elsa cache providers: '{_explicitProvider}' and '{name}'. Select one provider per module."); + if (!_selectingConfiguration) _explicitProvider = name; + Provider = factory; + return this; + } + + public CacheFeature UseMemory() => SelectProvider("Memory", sp => sp.GetRequiredService()); + + public CacheFeature UseConfiguration(IConfigurationSection section) + { + if (section["Enabled"] is { } enabledValue) + Enabled = bool.TryParse(enabledValue, out var enabled) ? enabled + : throw new InvalidOperationException("Workflow cache Enabled must be true or false."); + if (section["Duration"] is { } durationValue) + Duration = TimeSpan.TryParse(durationValue, out var duration) ? duration + : throw new InvalidOperationException("Workflow cache Duration must be a valid TimeSpan."); + Configuration = section; + var name = section["Provider"] ?? "Memory"; + if (_explicitProvider == null) + { + _selectingConfiguration = true; + try + { + if (name.Equals("Memory", StringComparison.OrdinalIgnoreCase)) UseMemory(); + else if (_providers.TryGetValue(name, out var select)) select(section.GetSection(name)); + else throw new InvalidOperationException($"Elsa cache provider '{name}' is not registered."); + } + finally { _selectingConfiguration = false; } + } + Configuration = section; + return this; + } + + public IConfigurationSection? Configuration { get; private set; } + public override void Apply() + { + if (Duration <= TimeSpan.Zero) throw new InvalidOperationException("Elsa cache duration must be positive."); + Services.Configure(options => options.CacheDuration = Duration); + Services.AddSingleton(Provider); + Services.AddSingleton(ConsistencyStore); + Services.AddSingleton(this); + Services.AddSingleton(sp => + { + if (sp.GetRequiredService().IsShared && !sp.GetRequiredService().IsDurable) + throw new InvalidOperationException("Shared Elsa caching requires a durable ICacheConsistencyStore. Configure the EF Core consistency store."); + return ActivatorUtilities.CreateInstance(sp); + }); + } +} diff --git a/src/modules/Elsa.Caching/Features/MemoryCacheFeature.cs b/src/modules/Elsa.Caching/Features/MemoryCacheFeature.cs index 1a3ad07..f86c2fa 100644 --- a/src/modules/Elsa.Caching/Features/MemoryCacheFeature.cs +++ b/src/modules/Elsa.Caching/Features/MemoryCacheFeature.cs @@ -3,6 +3,7 @@ using Elsa.Caching.Services; using Elsa.Features.Abstractions; using Elsa.Features.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Elsa.Caching.Features; @@ -23,9 +24,12 @@ public class MemoryCacheFeature(IModule module) : FeatureBase(module) Services .AddMemoryCache() - .AddSingleton() .AddSingleton() .AddSingleton() ; + // Keep local script/template caches independent of the workflow provider. + Services.TryAddSingleton(); + Services.TryAddSingleton(); + Services.TryAddSingleton(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Caching/Models/CacheEntryOptions.cs b/src/modules/Elsa.Caching/Models/CacheEntryOptions.cs new file mode 100644 index 0000000..6ddd495 --- /dev/null +++ b/src/modules/Elsa.Caching/Models/CacheEntryOptions.cs @@ -0,0 +1,9 @@ +namespace Elsa.Caching.Models; + +/// Provider-independent lifetime and invalidation dependencies. +public class CacheEntryOptions +{ + public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; } + public TimeSpan? SlidingExpiration { get; set; } + public ICollection Tags { get; } = new List(); +} diff --git a/src/modules/Elsa.Caching/Services/MemoryCacheConsistencyStore.cs b/src/modules/Elsa.Caching/Services/MemoryCacheConsistencyStore.cs new file mode 100644 index 0000000..018913a --- /dev/null +++ b/src/modules/Elsa.Caching/Services/MemoryCacheConsistencyStore.cs @@ -0,0 +1,34 @@ +namespace Elsa.Caching.Services; + +public class MemoryCacheConsistencyStore : ICacheConsistencyStore +{ + private readonly object _sync = new(); + private readonly Dictionary _pending = new(); + private long _revision; + private long _routeRevision; + public bool IsDurable => false; + public Task ReadAsync(CancellationToken cancellationToken = default) + { + lock (_sync) return Task.FromResult(new CacheConsistencyState(_revision.ToString(), _pending.Values.Any(x => x == "workflow"), _routeRevision.ToString(), _pending.Count != 0)); + } + public Task BeginMutationAsync(CancellationToken cancellationToken = default, string scope = "workflow") + { + lock (_sync) + { + var id = Guid.NewGuid().ToString("N"); + _pending.Add(id, scope); + if (scope == "workflow") _revision++; + _routeRevision++; + return Task.FromResult(id); + } + } + public Task CompleteMutationAsync(string mutationId, CancellationToken cancellationToken = default) + { + lock (_sync) + { + if (_pending.Remove(mutationId, out var scope) && scope == "workflow") _revision++; + _routeRevision++; + } + return Task.CompletedTask; + } +} diff --git a/src/modules/Elsa.Caching/Services/MemoryCacheProvider.cs b/src/modules/Elsa.Caching/Services/MemoryCacheProvider.cs new file mode 100644 index 0000000..1ef47df --- /dev/null +++ b/src/modules/Elsa.Caching/Services/MemoryCacheProvider.cs @@ -0,0 +1,32 @@ +using Elsa.Caching.Models; +using Microsoft.Extensions.Caching.Memory; + +namespace Elsa.Caching.Services; + +public class MemoryCacheProvider(IMemoryCache cache, IChangeTokenSignaler signaler) : ICacheProvider +{ + public bool IsShared => false; + public Task> GetAsync(string key, CancellationToken cancellationToken = default) + { + var found = cache.TryGetValue(key, out T? value); + return Task.FromResult(new CacheResult(found, value)); + } + + public Task SetAsync(string key, T? value, CacheEntryOptions options, CancellationToken cancellationToken = default) + { + var memoryOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = options.AbsoluteExpirationRelativeToNow, + SlidingExpiration = options.SlidingExpiration + }; + foreach (var tag in options.Tags) memoryOptions.AddExpirationToken(signaler.GetToken(tag)); + cache.Set(key, value, memoryOptions); + return Task.CompletedTask; + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + cache.Remove(key); + return Task.CompletedTask; + } +} diff --git a/src/modules/Elsa.Caching/Services/ProviderCacheManager.cs b/src/modules/Elsa.Caching/Services/ProviderCacheManager.cs new file mode 100644 index 0000000..00c2628 --- /dev/null +++ b/src/modules/Elsa.Caching/Services/ProviderCacheManager.cs @@ -0,0 +1,122 @@ +using System.Security.Cryptography; +using System.Text; +using System.Transactions; +using Elsa.Caching.Features; +using Elsa.Caching.Models; +using Elsa.Caching.Options; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Extensions.Logging; + +namespace Elsa.Caching.Services; + +/// Coordinates cache payloads using an authoritative revision, independent of the provider. +public class ProviderCacheManager(ICacheProvider provider, ICacheConsistencyStore consistency, + IChangeTokenSignaler signaler, IOptions options, CacheFeature feature, IMemoryCache memoryCache, + ILogger logger) : ICacheManager +{ + public IOptions CachingOptions => options; + public bool IsShared => provider.IsShared; + public bool IsEnabled => feature.Enabled; + public IChangeToken GetToken(string key) => signaler.GetToken(key); + public Task GetConsistencyStateAsync(CancellationToken cancellationToken = default) => consistency.ReadAsync(cancellationToken); + + public async Task BeginMutationAsync(CancellationToken cancellationToken = default) + => await BeginMutationCoreAsync("workflow", cancellationToken); + public Task BeginRouteMutationAsync(CancellationToken cancellationToken = default) + => BeginMutationCoreAsync("routes", cancellationToken); + public async Task CompleteMutationAsync(string id, CancellationToken cancellationToken = default) + { + // Begin registered the transaction-completed callback. Do not close a fence before the outer commit. + if (Transaction.Current != null) return; + await consistency.CompleteMutationAsync(id, cancellationToken); + } + + private async Task BeginMutationCoreAsync(string scope, CancellationToken cancellationToken) + { + var transaction = Transaction.Current; + string id; + using (new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled)) + id = await consistency.BeginMutationAsync(cancellationToken, scope); + if (transaction != null) + { + void CompleteAfterTransaction(TransactionStatus status) + { + if (status is not (TransactionStatus.Committed or TransactionStatus.Aborted)) return; + try + { + using var suppressed = new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled); + consistency.CompleteMutationAsync(id, CancellationToken.None).GetAwaiter().GetResult(); + } + catch (Exception ex) { logger.LogError(ex, "Workflow cache mutation {MutationId} remains pending after transaction completion.", id); } + } + transaction.TransactionCompleted += (_, args) => CompleteAfterTransaction(args.Transaction!.TransactionInformation.Status); + if (transaction.TransactionInformation.Status != TransactionStatus.Active) + CompleteAfterTransaction(transaction.TransactionInformation.Status); + } + return id; + } + + public async ValueTask TriggerTokenAsync(string key, CancellationToken cancellationToken = default) + { + var id = await BeginMutationAsync(cancellationToken); + await CompleteMutationAsync(id, cancellationToken); + await signaler.TriggerTokenAsync(key, cancellationToken); + } + + public async Task GetOrCreateAsync(object key, Func> factory) + { + if (Transaction.Current != null) { using var transactionEntry = new CompatibilityCacheEntry(key); return await factory(transactionEntry); } + if (!feature.Enabled) { using var uncached = new CompatibilityCacheEntry(key); return await factory(uncached); } + if (!IsShared) return await memoryCache.GetOrCreateAsync(key, factory); + // Compatibility entry for older extensions. New consumers use CacheEntryOptions. + using var entry = new CompatibilityCacheEntry(key); + return await GetOrCreateAsync(key.ToString()!, () => factory(entry), entry.Options); + } + + public async Task GetOrCreateAsync(string key, Func> factory, CacheEntryOptions entryOptions, CancellationToken cancellationToken = default) + { + if (!feature.Enabled || Transaction.Current != null) return await factory(); + if (IsShared && !consistency.IsDurable) + throw new InvalidOperationException("Shared Elsa caching requires a durable ICacheConsistencyStore. Configure the EF Core consistency store."); + var before = await consistency.ReadAsync(cancellationToken); + if (before.HasPendingMutations) return await factory(); + var storageKey = CreateKey(key, before.Revision); + var cached = await provider.GetAsync(storageKey, cancellationToken); + if (cached.Found) return cached.Value; + var value = await factory(); + var after = await consistency.ReadAsync(cancellationToken); + if (!after.HasPendingMutations && after.Revision == before.Revision) + { + if (entryOptions.AbsoluteExpirationRelativeToNow == null && entryOptions.SlidingExpiration == null) + entryOptions.AbsoluteExpirationRelativeToNow = options.Value.CacheDuration; + await provider.SetAsync(storageKey, value, entryOptions, cancellationToken); + } + return value; + } + + public void Remove(string key) => TriggerTokenAsync(key).AsTask().GetAwaiter().GetResult(); + private static string CreateKey(string key, string revision) + => $"v1:{revision}:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{typeof(T).FullName}:{key}"))); + + private sealed class CompatibilityCacheEntry(object key) : ICacheEntry + { + public CacheEntryOptions Options { get; } = new(); + public object Key => key; + public object? Value { get; set; } + private DateTimeOffset? _absoluteExpiration; + public DateTimeOffset? AbsoluteExpiration + { + get => _absoluteExpiration; + set { _absoluteExpiration = value; Options.AbsoluteExpirationRelativeToNow = value - DateTimeOffset.UtcNow; } + } + public TimeSpan? AbsoluteExpirationRelativeToNow { get => Options.AbsoluteExpirationRelativeToNow; set => Options.AbsoluteExpirationRelativeToNow = value; } + public TimeSpan? SlidingExpiration { get => Options.SlidingExpiration; set => Options.SlidingExpiration = value; } + public IList ExpirationTokens { get; } = new List(); + public IList PostEvictionCallbacks { get; } = new List(); + public CacheItemPriority Priority { get; set; } + public long? Size { get; set; } + public void Dispose() { } + } +} diff --git a/src/modules/Elsa.Common/Elsa.Common.csproj b/src/modules/Elsa.Common/Elsa.Common.csproj index c883ffd..543a083 100644 --- a/src/modules/Elsa.Common/Elsa.Common.csproj +++ b/src/modules/Elsa.Common/Elsa.Common.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Common Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides services and utility functions common to Elsa modules. diff --git a/src/modules/Elsa.Connections.Api/Elsa.Connections.Api.csproj b/src/modules/Elsa.Connections.Api/Elsa.Connections.Api.csproj index f22b59b..452bb69 100644 --- a/src/modules/Elsa.Connections.Api/Elsa.Connections.Api.csproj +++ b/src/modules/Elsa.Connections.Api/Elsa.Connections.Api.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Connections.Api Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Activity Connection API endpoints elsa module activity connection abstraction api diff --git a/src/modules/Elsa.Connections.Core/Elsa.Connections.Core.csproj b/src/modules/Elsa.Connections.Core/Elsa.Connections.Core.csproj index f88a704..82b9185 100644 --- a/src/modules/Elsa.Connections.Core/Elsa.Connections.Core.csproj +++ b/src/modules/Elsa.Connections.Core/Elsa.Connections.Core.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Connections.Core Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides an connection framework to use connection abtraction in activities elsa module connection abstratction diff --git a/src/modules/Elsa.Connections.Models/Elsa.Connections.Models.csproj b/src/modules/Elsa.Connections.Models/Elsa.Connections.Models.csproj index 3d1bb3b..d774e46 100644 --- a/src/modules/Elsa.Connections.Models/Elsa.Connections.Models.csproj +++ b/src/modules/Elsa.Connections.Models/Elsa.Connections.Models.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Connections.Models Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides models that can be shared between the API and API clients elsa module connections api diff --git a/src/modules/Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite/Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite.csproj b/src/modules/Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite/Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite.csproj index 843aa4a..0bc8b11 100644 --- a/src/modules/Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite/Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite.csproj +++ b/src/modules/Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite/Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides an EF Core migrations for SQLite for the Connection Persistence module. elsa module persistence efcore entity framework core sqlite diff --git a/src/modules/Elsa.Connections.Persistence.EntityFrameworkCore/Elsa.Connections.Persistence.EntityFrameworkCore.csproj b/src/modules/Elsa.Connections.Persistence.EntityFrameworkCore/Elsa.Connections.Persistence.EntityFrameworkCore.csproj index cd3f8a8..92abf6b 100644 --- a/src/modules/Elsa.Connections.Persistence.EntityFrameworkCore/Elsa.Connections.Persistence.EntityFrameworkCore.csproj +++ b/src/modules/Elsa.Connections.Persistence.EntityFrameworkCore/Elsa.Connections.Persistence.EntityFrameworkCore.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Connections.Persistence.EntityFrameworkCore Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides an Entity Framework Core provider for the persistence layer of the Connection module elsa connection persistence efcore entity framework core diff --git a/src/modules/Elsa.Connections.Persistence/Elsa.Connections.Persistence.csproj b/src/modules/Elsa.Connections.Persistence/Elsa.Connections.Persistence.csproj index dff80d9..6e1dacd 100644 --- a/src/modules/Elsa.Connections.Persistence/Elsa.Connections.Persistence.csproj +++ b/src/modules/Elsa.Connections.Persistence/Elsa.Connections.Persistence.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Connections.Persistence Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Connections persistence services elsa module connections persistence diff --git a/src/modules/Elsa.Dapper.Migrations/Elsa.Dapper.Migrations.csproj b/src/modules/Elsa.Dapper.Migrations/Elsa.Dapper.Migrations.csproj index 72bc008..7eeb056 100644 --- a/src/modules/Elsa.Dapper.Migrations/Elsa.Dapper.Migrations.csproj +++ b/src/modules/Elsa.Dapper.Migrations/Elsa.Dapper.Migrations.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Dapper.Migrations Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Dapper migrations for Dapper perisstence providers from various modules. diff --git a/src/modules/Elsa.Dapper/Elsa.Dapper.csproj b/src/modules/Elsa.Dapper/Elsa.Dapper.csproj index 097e6f8..084be2d 100644 --- a/src/modules/Elsa.Dapper/Elsa.Dapper.csproj +++ b/src/modules/Elsa.Dapper/Elsa.Dapper.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Dapper Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Dapper implementations of various persistence abstractions from various modules. diff --git a/src/modules/Elsa.Dsl/Elsa.Dsl.csproj b/src/modules/Elsa.Dsl/Elsa.Dsl.csproj index 69401f5..6a7d716 100644 --- a/src/modules/Elsa.Dsl/Elsa.Dsl.csproj +++ b/src/modules/Elsa.Dsl/Elsa.Dsl.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Dsl Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a custom DSL to write workflows with in a syntax similar to JavaScript but with native keywords translating to workflow functionality. diff --git a/src/modules/Elsa.Elasticsearch/Elsa.Elasticsearch.csproj b/src/modules/Elsa.Elasticsearch/Elsa.Elasticsearch.csproj index 872115c..c0e779f 100644 --- a/src/modules/Elsa.Elasticsearch/Elsa.Elasticsearch.csproj +++ b/src/modules/Elsa.Elasticsearch/Elsa.Elasticsearch.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Elasticsearch Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Elasticsearch implementation for workflow state persistence. diff --git a/src/modules/Elsa.Email/Elsa.Email.csproj b/src/modules/Elsa.Email/Elsa.Email.csproj index eaaf040..64911ca 100644 --- a/src/modules/Elsa.Email/Elsa.Email.csproj +++ b/src/modules/Elsa.Email/Elsa.Email.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Email Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a SendEmail activity that lets you send emails from a workflow. diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj b/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj index d6c5658..b0ae971 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.EntityFrameworkCore.Common Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides common Entity Framework Core types for implementing EF Core persistence of varipus ELsa modules. diff --git a/src/modules/Elsa.EntityFrameworkCore.MySql/Elsa.EntityFrameworkCore.MySql.csproj b/src/modules/Elsa.EntityFrameworkCore.MySql/Elsa.EntityFrameworkCore.MySql.csproj index bd2c9ec..a2bfb9c 100644 --- a/src/modules/Elsa.EntityFrameworkCore.MySql/Elsa.EntityFrameworkCore.MySql.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.MySql/Elsa.EntityFrameworkCore.MySql.csproj @@ -5,7 +5,7 @@ Caspian.Elsa.EntityFrameworkCore.MySql Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides MySQL EF Core migrations for various modules. diff --git a/src/modules/Elsa.EntityFrameworkCore.Oracle/Elsa.EntityFrameworkCore.Oracle.csproj b/src/modules/Elsa.EntityFrameworkCore.Oracle/Elsa.EntityFrameworkCore.Oracle.csproj index 171ea61..b984ac1 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Oracle/Elsa.EntityFrameworkCore.Oracle.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.Oracle/Elsa.EntityFrameworkCore.Oracle.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.EntityFrameworkCore.Oracle Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Oracle EF Core migrations for various modules. diff --git a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Elsa.EntityFrameworkCore.PostgreSql.csproj b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Elsa.EntityFrameworkCore.PostgreSql.csproj index e4eb390..de7358a 100644 --- a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Elsa.EntityFrameworkCore.PostgreSql.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Elsa.EntityFrameworkCore.PostgreSql.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.EntityFrameworkCore.PostgreSql Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides PostgreSQL EF Core migrations for various modules. diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Elsa.EntityFrameworkCore.SqlServer.csproj b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Elsa.EntityFrameworkCore.SqlServer.csproj index 89aeaef..6c69a48 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Elsa.EntityFrameworkCore.SqlServer.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Elsa.EntityFrameworkCore.SqlServer.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.EntityFrameworkCore.SqlServer Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides SQL Server EF Core migrations for various modules. diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Management/20261007000000_AddCacheMutationFences.cs b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Management/20261007000000_AddCacheMutationFences.cs new file mode 100644 index 0000000..433c2e2 --- /dev/null +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Management/20261007000000_AddCacheMutationFences.cs @@ -0,0 +1,26 @@ +using Elsa.EntityFrameworkCore.Modules.Management; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Management; + +[DbContext(typeof(ManagementElsaDbContext))] +[Migration("20261007000000_AddCacheMutationFences")] +public class AddCacheMutationFences : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable("CacheMutations", schema: "Elsa", columns: table => new + { + Id = table.Column(nullable: false).Annotation("SqlServer:Identity", "1, 1"), + MutationId = table.Column(maxLength: 32, nullable: false), + Scope = table.Column(maxLength: 32, nullable: false), + OwnerInstance = table.Column(maxLength: 256, nullable: false), + IsCompleted = table.Column(nullable: false), + CreatedAt = table.Column(nullable: false) + }, constraints: table => table.PrimaryKey("PK_CacheMutations", x => x.Id)); + migrationBuilder.CreateIndex("IX_CacheMutations_MutationId", "CacheMutations", "MutationId", "Elsa", unique: true); + migrationBuilder.CreateIndex("IX_CacheMutations_IsCompleted", "CacheMutations", "IsCompleted", "Elsa"); + } + protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.DropTable("CacheMutations", "Elsa"); +} diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index 5a2f2fc..c5b2322 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Elsa.EntityFrameworkCore.Modules.Management; using Microsoft.EntityFrameworkCore; @@ -23,6 +23,21 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Management SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("Elsa.EntityFrameworkCore.Modules.Management.CacheMutation", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("bigint"); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("MutationId").IsRequired().HasMaxLength(32).HasColumnType("nvarchar(32)"); + b.Property("Scope").IsRequired().HasMaxLength(32).HasColumnType("nvarchar(32)"); + b.Property("OwnerInstance").IsRequired().HasMaxLength(256).HasColumnType("nvarchar(256)"); + b.Property("IsCompleted").HasColumnType("bit"); + b.Property("CreatedAt").HasColumnType("datetimeoffset"); + b.HasKey("Id"); + b.HasIndex("MutationId").IsUnique(); + b.HasIndex("IsCompleted"); + b.ToTable("CacheMutations", "Elsa"); + }); + modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowDefinition", b => { b.Property("Id") diff --git a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Elsa.EntityFrameworkCore.Sqlite.csproj b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Elsa.EntityFrameworkCore.Sqlite.csproj index e072969..4694d71 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Elsa.EntityFrameworkCore.Sqlite.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Elsa.EntityFrameworkCore.Sqlite.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.EntityFrameworkCore.Sqlite Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides SQLite EF Core migrations for various modules. diff --git a/src/modules/Elsa.EntityFrameworkCore/Elsa.EntityFrameworkCore.csproj b/src/modules/Elsa.EntityFrameworkCore/Elsa.EntityFrameworkCore.csproj index 23f4557..13a3aa4 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Elsa.EntityFrameworkCore.csproj +++ b/src/modules/Elsa.EntityFrameworkCore/Elsa.EntityFrameworkCore.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.EntityFrameworkCore Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Entity Framework Core implementations of various abstractions from various modules. diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/CacheMutation.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/CacheMutation.cs new file mode 100644 index 0000000..63e9275 --- /dev/null +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/CacheMutation.cs @@ -0,0 +1,13 @@ +namespace Elsa.EntityFrameworkCore.Modules.Management; + +/// Durable write fence. Pending records must never expire automatically. +public class CacheMutation +{ + public static string CurrentOwnerInstance { get; } = $"{Environment.MachineName}:{Environment.ProcessId}:{Guid.NewGuid():N}"; + public long Id { get; set; } + public string MutationId { get; set; } = Guid.NewGuid().ToString("N"); + public string Scope { get; set; } = "workflow"; + public string OwnerInstance { get; set; } = CurrentOwnerInstance; + public bool IsCompleted { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/DbContext.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/DbContext.cs index 79ab138..2fcb6ad 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/DbContext.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/DbContext.cs @@ -24,6 +24,7 @@ public class ManagementElsaDbContext : ElsaDbContextBase /// The workflow instances. /// public DbSet WorkflowInstances { get; set; } = default!; + public DbSet CacheMutations { get; set; } = default!; /// protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -34,6 +35,17 @@ public class ManagementElsaDbContext : ElsaDbContextBase var config = new Configurations(); modelBuilder.ApplyConfiguration(config); modelBuilder.ApplyConfiguration(config); + modelBuilder.Entity(b => + { + b.ToTable("CacheMutations"); + b.HasKey(x => x.Id); + b.Property(x => x.Id).ValueGeneratedOnAdd(); + b.Property(x => x.MutationId).HasMaxLength(32).IsRequired(); + b.HasIndex(x => x.MutationId).IsUnique(); + b.Property(x => x.Scope).HasMaxLength(32).IsRequired(); + b.Property(x => x.OwnerInstance).HasMaxLength(256).IsRequired(); + b.HasIndex(x => x.IsCompleted); + }); base.OnModelCreating(modelBuilder); } diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreCacheConsistencyStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreCacheConsistencyStore.cs new file mode 100644 index 0000000..1aced04 --- /dev/null +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreCacheConsistencyStore.cs @@ -0,0 +1,57 @@ +using Elsa.Caching; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Transactions; + +namespace Elsa.EntityFrameworkCore.Modules.Management; + +/// Reads SQL before cache access, so missed messages and Redis failover cannot revive old revisions. +public class EFCoreCacheConsistencyStore(IServiceScopeFactory scopes) : ICacheConsistencyStore +{ + public bool IsDurable => true; + public async Task ReadAsync(CancellationToken cancellationToken = default) + { + using var suppressed = new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled); + using var serviceScope = scopes.CreateScope(); + var factory = serviceScope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(cancellationToken); + // One SQL statement gives a consistent view of the revision and pending fences. + var state = await db.CacheMutations.AsNoTracking().GroupBy(_ => 1) + .Select(g => new { Revision = g.Max(x => x.Scope == "workflow" ? (long?)x.Id : null) ?? 0, + Pending = g.Any(x => !x.IsCompleted && x.Scope == "workflow"), + RouteRevision = g.Max(x => x.Id), PendingRoutes = g.Any(x => !x.IsCompleted) }) + .SingleOrDefaultAsync(cancellationToken); + return new CacheConsistencyState(state?.Revision.ToString() ?? "0", state?.Pending ?? false, state?.RouteRevision.ToString() ?? "0", state?.PendingRoutes ?? false); + } + public async Task BeginMutationAsync(CancellationToken cancellationToken = default, string scope = "workflow") + { + using var suppressed = new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled); + if (scope is not ("workflow" or "routes")) throw new ArgumentException("Unknown workflow cache mutation scope.", nameof(scope)); + using var serviceScope = scopes.CreateScope(); + var factory = serviceScope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(cancellationToken); + var mutation = new CacheMutation { Scope = scope }; + db.CacheMutations.Add(mutation); + await db.SaveChangesAsync(cancellationToken); + return mutation.MutationId; + } + public async Task CompleteMutationAsync(string mutationId, CancellationToken cancellationToken = default) + { + using var suppressed = new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled); + using var serviceScope = scopes.CreateScope(); + var factory = serviceScope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(cancellationToken); + var mutation = await db.CacheMutations.SingleOrDefaultAsync(x => x.MutationId == mutationId, cancellationToken); + if (mutation == null || mutation.IsCompleted) return; + mutation.IsCompleted = true; + // A begin marker can commit out of identity order. Always allocate a new revision AFTER the data write. + // Closing the fence and publishing that revision must commit in the same SQL transaction. + db.CacheMutations.Add(new CacheMutation { Scope = mutation.Scope, IsCompleted = true }); + await db.SaveChangesAsync(cancellationToken); + // Never delete a row newer than the maximum observed for its scope: another node may complete meanwhile. + var latest = await db.CacheMutations.GroupBy(x => x.Scope) + .Select(g => new { Scope = g.Key, Maximum = g.Max(x => x.Id) }).ToListAsync(cancellationToken); + foreach (var item in latest) + await db.CacheMutations.Where(x => x.IsCompleted && x.Scope == item.Scope && x.Id < item.Maximum).ExecuteDeleteAsync(cancellationToken); + } +} diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs index 3b808a9..b4c2425 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs @@ -3,6 +3,7 @@ using Elsa.Features.Services; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Features; using Microsoft.Extensions.DependencyInjection; +using Elsa.Caching.Features; namespace Elsa.EntityFrameworkCore.Modules.Management; @@ -16,6 +17,10 @@ public class EFCoreWorkflowDefinitionPersistenceFeature(IModule module) : Persis /// public override void Configure() { + Module.Configure(feature => feature.ConsistencyStore = sp => + sp.GetRequiredService().IsShared + ? ActivatorUtilities.CreateInstance(sp) + : sp.GetRequiredService()); Module.Configure(feature => { feature.WorkflowDefinitionStore = sp => sp.GetRequiredService(); @@ -28,4 +33,4 @@ public class EFCoreWorkflowDefinitionPersistenceFeature(IModule module) : Persis base.Apply(); AddEntityStore(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Environments/Elsa.Environments.csproj b/src/modules/Elsa.Environments/Elsa.Environments.csproj index 6a8b215..c606b56 100644 --- a/src/modules/Elsa.Environments/Elsa.Environments.csproj +++ b/src/modules/Elsa.Environments/Elsa.Environments.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Environments Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides API endpoints for client applications to enumerate available environments. diff --git a/src/modules/Elsa.Expressions/Elsa.Expressions.csproj b/src/modules/Elsa.Expressions/Elsa.Expressions.csproj index e930a3d..8a485c2 100644 --- a/src/modules/Elsa.Expressions/Elsa.Expressions.csproj +++ b/src/modules/Elsa.Expressions/Elsa.Expressions.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Expressions Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides infrastructure for registering and executing workflow expressions. diff --git a/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj b/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj index 1abaf72..2918bb1 100644 --- a/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj +++ b/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.FileStorage Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides activities to save and load files to and from a confogurable storage provider. diff --git a/src/modules/Elsa.Hangfire/Elsa.Hangfire.csproj b/src/modules/Elsa.Hangfire/Elsa.Hangfire.csproj index ba8611d..ee7fe79 100644 --- a/src/modules/Elsa.Hangfire/Elsa.Hangfire.csproj +++ b/src/modules/Elsa.Hangfire/Elsa.Hangfire.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Hangfire Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Hangfire integration and an implementation of Elsa's IJobQueue implementation. diff --git a/src/modules/Elsa.Hosting.Management/Elsa.Hosting.Management.csproj b/src/modules/Elsa.Hosting.Management/Elsa.Hosting.Management.csproj index 2c8f4ab..275c85e 100644 --- a/src/modules/Elsa.Hosting.Management/Elsa.Hosting.Management.csproj +++ b/src/modules/Elsa.Hosting.Management/Elsa.Hosting.Management.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Hosting.Management Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides hosting management functionality. diff --git a/src/modules/Elsa.Http/Contracts/IRouteTable.cs b/src/modules/Elsa.Http/Contracts/IRouteTable.cs index b82ce1c..f746681 100644 --- a/src/modules/Elsa.Http/Contracts/IRouteTable.cs +++ b/src/modules/Elsa.Http/Contracts/IRouteTable.cs @@ -5,6 +5,12 @@ namespace Elsa.Http; /// public interface IRouteTable : IEnumerable { + /// Replaces the local snapshot. Implementations should publish it atomically. + void Replace(IEnumerable routes) + { + RemoveRange(this.Select(x => x.Route).ToArray()); + foreach (var route in routes) Add(route); + } /// /// Adds a route to the table. /// @@ -34,4 +40,4 @@ public interface IRouteTable : IEnumerable /// /// The routes to remove. void RemoveRange(IEnumerable routes); -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Http/Elsa.Http.csproj b/src/modules/Elsa.Http/Elsa.Http.csproj index 4e7ad08..5d8815a 100644 --- a/src/modules/Elsa.Http/Elsa.Http.csproj +++ b/src/modules/Elsa.Http/Elsa.Http.csproj @@ -1,10 +1,10 @@ - + Caspian.Elsa.Http Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides HTTP activities to listen for and send out HTTP requests. diff --git a/src/modules/Elsa.Http/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Http/Extensions/ModuleExtensions.cs index 7898a48..1c7aa8c 100644 --- a/src/modules/Elsa.Http/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/ModuleExtensions.cs @@ -26,4 +26,13 @@ public static class ModuleExtensions feature.Module.Configure(configure); return feature; } -} \ No newline at end of file + public static HttpFeature UseCache(this HttpFeature feature, + Microsoft.Extensions.Configuration.IConfigurationSection section, Action? configure = null) + { + return feature.UseCache(cache => + { + cache.UseConfiguration(section); + configure?.Invoke(cache); + }); + } +} diff --git a/src/modules/Elsa.Http/Features/HttpCacheFeature.cs b/src/modules/Elsa.Http/Features/HttpCacheFeature.cs index 4dbc59b..9e087e9 100644 --- a/src/modules/Elsa.Http/Features/HttpCacheFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpCacheFeature.cs @@ -1,4 +1,5 @@ using Elsa.Features.Abstractions; +using Elsa.Caching.Features; using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.Http.Handlers; @@ -13,7 +14,7 @@ namespace Elsa.Http.Features; /// [DependsOn(typeof(HttpFeature))] [DependsOn(typeof(CachingWorkflowDefinitionsFeature))] -public class HttpCacheFeature : FeatureBase +public class HttpCacheFeature : CacheConsumerFeature { /// public HttpCacheFeature(IModule module) : base(module) @@ -28,4 +29,4 @@ public class HttpCacheFeature : FeatureBase .Decorate() .AddNotificationHandler(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 80e0e51..f3aec14 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -167,6 +167,8 @@ public class HttpFeature(IModule module) : FeatureBase(module) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddSingleton() .AddScoped() .AddScoped(ContentTypeProvider) .AddHttpContextAccessor() @@ -253,4 +255,4 @@ public class HttpFeature(IModule module) : FeatureBase(module) options.AddTypeAlias("Downloadable[]"); }); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs index 668d188..070aea8 100644 --- a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs @@ -36,6 +36,8 @@ public class HttpWorkflowsMiddleware(RequestDelegate next, IOptions() + .EnsureFreshAsync(httpContext.RequestAborted); var matchingPath = GetMatchingRoute(serviceProvider, path).Route; var basePath = options.Value.BasePath?.ToString().NormalizeRoute(); @@ -370,4 +372,4 @@ public class HttpWorkflowsMiddleware(RequestDelegate next, IOptions(); return bookmarkHasher.Hash(HttpStimulusNames.HttpEndpoint, bookmarkPayload); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Http/Services/CachingHttpWorkflowLookupService.cs b/src/modules/Elsa.Http/Services/CachingHttpWorkflowLookupService.cs index b4d2e63..ae5f82e 100644 --- a/src/modules/Elsa.Http/Services/CachingHttpWorkflowLookupService.cs +++ b/src/modules/Elsa.Http/Services/CachingHttpWorkflowLookupService.cs @@ -11,7 +11,8 @@ namespace Elsa.Http.Services; public class CachingHttpWorkflowLookupService( IHttpWorkflowLookupService decoratedService, IHttpWorkflowsCacheManager cacheManager, - ITenantAccessor tenantAccessor) : IHttpWorkflowLookupService + ITenantAccessor tenantAccessor, + Elsa.Workflows.Management.IWorkflowDefinitionService definitionService) : IHttpWorkflowLookupService { /// public async Task FindWorkflowAsync(string bookmarkHash, CancellationToken cancellationToken = default) @@ -21,6 +22,21 @@ public class CachingHttpWorkflowLookupService( var tenantIdPrefix = !string.IsNullOrEmpty(tenantId) ? $"{tenantId}:" : string.Empty; var key = $"{tenantIdPrefix}http-workflow:{bookmarkHash}"; var cache = cacheManager.Cache; + if (!cache.IsEnabled) return await decoratedService.FindWorkflowAsync(bookmarkHash, cancellationToken); + if (cache.IsShared) + { + // Redis stores the definition version reference and trigger data, never an executable graph. + var entryOptions = new Elsa.Caching.Models.CacheEntryOptions { SlidingExpiration = cache.CachingOptions.Value.CacheDuration }; + var cached = await cache.GetOrCreateAsync(key, async () => + { + var found = await decoratedService.FindWorkflowAsync(bookmarkHash, cancellationToken); + return found == null ? null : new CachedHttpLookup(found.WorkflowGraph?.Workflow.Identity.Id, found.Triggers); + }, entryOptions, cancellationToken); + if (cached == null) return null; + var graph = cached.DefinitionVersionId == null ? null + : await definitionService.FindWorkflowGraphAsync(cached.DefinitionVersionId, cancellationToken); + return new HttpWorkflowLookupResult(graph, cached.Triggers); + } return await cache.GetOrCreateAsync(key, async entry => { var cachingOptions = cache.CachingOptions.Value; @@ -43,4 +59,5 @@ public class CachingHttpWorkflowLookupService( return result; }); } -} \ No newline at end of file + public record CachedHttpLookup(string? DefinitionVersionId, ICollection Triggers); +} diff --git a/src/modules/Elsa.Http/Services/DefaultRouteTableUpdater.cs b/src/modules/Elsa.Http/Services/DefaultRouteTableUpdater.cs index bca5468..6765c5f 100644 --- a/src/modules/Elsa.Http/Services/DefaultRouteTableUpdater.cs +++ b/src/modules/Elsa.Http/Services/DefaultRouteTableUpdater.cs @@ -23,17 +23,29 @@ public class DefaultRouteTableUpdater( var stimulusName = HttpStimulusNames.HttpEndpoint; var triggerFilter = new TriggerFilter { - Name = stimulusName + Name = stimulusName, + TenantAgnostic = true }; var bookmarkFilter = new BookmarkFilter { - Name = stimulusName + Name = stimulusName, + TenantAgnostic = true }; var triggers = (await triggerStore.FindManyAsync(triggerFilter, cancellationToken)).ToList(); var bookmarks = (await bookmarkStore.FindManyAsync(bookmarkFilter, cancellationToken)).ToList(); - await AddRoutesAsync(triggers, cancellationToken); - await AddRoutesAsync(bookmarks, cancellationToken); + var routes = new List(); + foreach (var trigger in Filter(triggers)) + { + var context = new HttpEndpointRouteProviderContext(trigger.GetPayload(), trigger.TenantId, cancellationToken); + routes.AddRange(await httpEndpointRoutesProvider.GetRoutesAsync(context)); + } + foreach (var bookmark in Filter(bookmarks)) + { + var context = new HttpEndpointRouteProviderContext(bookmark.GetPayload(), bookmark.TenantId, cancellationToken); + routes.AddRange(await httpEndpointRoutesProvider.GetRoutesAsync(context)); + } + routeTable.Replace(routes); } public async Task AddRoutesAsync(IEnumerable triggers, CancellationToken cancellationToken = default) @@ -107,4 +119,4 @@ public class DefaultRouteTableUpdater( var stimulusName = HttpStimulusNames.HttpEndpoint; return bookmarks.Where(x => x.Name == stimulusName && x.Payload != null); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Http/Services/RouteTable.cs b/src/modules/Elsa.Http/Services/RouteTable.cs index 67260a7..687cb2b 100644 --- a/src/modules/Elsa.Http/Services/RouteTable.cs +++ b/src/modules/Elsa.Http/Services/RouteTable.cs @@ -12,6 +12,13 @@ public class RouteTable(IMemoryCache cache, ILogger logger) : IRoute private static readonly object Key = new(); private ConcurrentDictionary Routes => cache.GetOrCreate(Key, _ => new ConcurrentDictionary())!; + public void Replace(IEnumerable routes) + { + var snapshot = new ConcurrentDictionary(); + foreach (var route in routes.Where(x => !x.Route.Contains("//"))) + snapshot.TryAdd(route.Route.NormalizeRoute(), route); + cache.Set(Key, snapshot); + } /// public void Add(string route) @@ -56,4 +63,4 @@ public class RouteTable(IMemoryCache cache, ILogger logger) : IRoute public IEnumerator GetEnumerator() => Routes.Values.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Http/Services/RouteTableRevisionRefresher.cs b/src/modules/Elsa.Http/Services/RouteTableRevisionRefresher.cs new file mode 100644 index 0000000..7eb866a --- /dev/null +++ b/src/modules/Elsa.Http/Services/RouteTableRevisionRefresher.cs @@ -0,0 +1,34 @@ +using Elsa.Caching; + +namespace Elsa.Http.Services; + +/// A local route snapshot with a revision checked against SQL on each inbound workflow request. +public class RouteTableRevisionState : IDisposable +{ + public SemaphoreSlim Gate { get; } = new(1, 1); + public string? Revision { get; set; } + public void Dispose() => Gate.Dispose(); +} + +public class RouteTableRevisionRefresher(ICacheManager cache, IRouteTableUpdater updater, RouteTableRevisionState state) +{ + public async Task EnsureFreshAsync(CancellationToken cancellationToken) + { + if (!cache.IsShared) return; + // A process-wide route snapshot must not be built from a request's ambient transaction snapshot. + using var suppressed = new System.Transactions.TransactionScope(System.Transactions.TransactionScopeOption.Suppress, + System.Transactions.TransactionScopeAsyncFlowOption.Enabled); + var before = await cache.GetConsistencyStateAsync(cancellationToken); + if (!before.HasPendingRouteMutations && (before.RouteRevision ?? before.Revision) == state.Revision) return; + await state.Gate.WaitAsync(cancellationToken); + try + { + before = await cache.GetConsistencyStateAsync(cancellationToken); + if (!before.HasPendingRouteMutations && (before.RouteRevision ?? before.Revision) == state.Revision) return; + await updater.UpdateAsync(cancellationToken); + var after = await cache.GetConsistencyStateAsync(cancellationToken); + state.Revision = !after.HasPendingRouteMutations && (before.RouteRevision ?? before.Revision) == (after.RouteRevision ?? after.Revision) ? after.RouteRevision ?? after.Revision : null; + } + finally { state.Gate.Release(); } + } +} diff --git a/src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj b/src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj index 62000c5..950a1b1 100644 --- a/src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj +++ b/src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.IO.Compression Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides compression and archiving activities for Elsa Workflows. diff --git a/src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj b/src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj index 7426539..d7f84e2 100644 --- a/src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj +++ b/src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.IO.Http Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides http capabilities to IO modules in Elsa Workflows. diff --git a/src/modules/Elsa.IO/Elsa.IO.csproj b/src/modules/Elsa.IO/Elsa.IO.csproj index b187af2..6c606cb 100644 --- a/src/modules/Elsa.IO/Elsa.IO.csproj +++ b/src/modules/Elsa.IO/Elsa.IO.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.IO Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides IO services for resolving various content types to streams. diff --git a/src/modules/Elsa.Identity/Elsa.Identity.csproj b/src/modules/Elsa.Identity/Elsa.Identity.csproj index 2c0b2e2..3b0f818 100644 --- a/src/modules/Elsa.Identity/Elsa.Identity.csproj +++ b/src/modules/Elsa.Identity/Elsa.Identity.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Identity Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a default identity solution. diff --git a/src/modules/Elsa.JavaScript.Libraries/Elsa.JavaScript.Libraries.csproj b/src/modules/Elsa.JavaScript.Libraries/Elsa.JavaScript.Libraries.csproj index f039407..9d69d0d 100644 --- a/src/modules/Elsa.JavaScript.Libraries/Elsa.JavaScript.Libraries.csproj +++ b/src/modules/Elsa.JavaScript.Libraries/Elsa.JavaScript.Libraries.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.JavaScript.Libraries Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides the following JavaScript libraries for Elsa: diff --git a/src/modules/Elsa.JavaScript/Elsa.JavaScript.csproj b/src/modules/Elsa.JavaScript/Elsa.JavaScript.csproj index 964cf23..3c23e09 100644 --- a/src/modules/Elsa.JavaScript/Elsa.JavaScript.csproj +++ b/src/modules/Elsa.JavaScript/Elsa.JavaScript.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.JavaScript Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a JavaScript expression provider. diff --git a/src/modules/Elsa.Kafka/Elsa.Kafka.csproj b/src/modules/Elsa.Kafka/Elsa.Kafka.csproj index b1de00e..0dca7ee 100644 --- a/src/modules/Elsa.Kafka/Elsa.Kafka.csproj +++ b/src/modules/Elsa.Kafka/Elsa.Kafka.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Kafka Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Apache Kafka integration and activities. diff --git a/src/modules/Elsa.KeyValues/Elsa.KeyValues.csproj b/src/modules/Elsa.KeyValues/Elsa.KeyValues.csproj index 97975a8..b7ed2e4 100644 --- a/src/modules/Elsa.KeyValues/Elsa.KeyValues.csproj +++ b/src/modules/Elsa.KeyValues/Elsa.KeyValues.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.KeyValues Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a store for holding key value records. diff --git a/src/modules/Elsa.Labels/Elsa.Labels.csproj b/src/modules/Elsa.Labels/Elsa.Labels.csproj index a82ae2a..b0b966b 100644 --- a/src/modules/Elsa.Labels/Elsa.Labels.csproj +++ b/src/modules/Elsa.Labels/Elsa.Labels.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Labels Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides funtionality to tag workflows with labels and filter by them. diff --git a/src/modules/Elsa.Liquid/Elsa.Liquid.csproj b/src/modules/Elsa.Liquid/Elsa.Liquid.csproj index fd2a0e8..b717a0c 100644 --- a/src/modules/Elsa.Liquid/Elsa.Liquid.csproj +++ b/src/modules/Elsa.Liquid/Elsa.Liquid.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Liquid Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a Liquid expression provider. diff --git a/src/modules/Elsa.MassTransit.AzureServiceBus/Elsa.MassTransit.AzureServiceBus.csproj b/src/modules/Elsa.MassTransit.AzureServiceBus/Elsa.MassTransit.AzureServiceBus.csproj index 5596c82..4c0ae55 100644 --- a/src/modules/Elsa.MassTransit.AzureServiceBus/Elsa.MassTransit.AzureServiceBus.csproj +++ b/src/modules/Elsa.MassTransit.AzureServiceBus/Elsa.MassTransit.AzureServiceBus.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.MassTransit.AzureServiceBus Caspian. Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides integration of Azure Service Bus with MassTransit's integration of Elsa. diff --git a/src/modules/Elsa.MassTransit.RabbitMq/Elsa.MassTransit.RabbitMq.csproj b/src/modules/Elsa.MassTransit.RabbitMq/Elsa.MassTransit.RabbitMq.csproj index f2f7be4..04e9f5c 100644 --- a/src/modules/Elsa.MassTransit.RabbitMq/Elsa.MassTransit.RabbitMq.csproj +++ b/src/modules/Elsa.MassTransit.RabbitMq/Elsa.MassTransit.RabbitMq.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.MassTransit.RabbitMq Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides integration of RabbitMQ with MassTransit's integration of Elsa. diff --git a/src/modules/Elsa.MassTransit/Elsa.MassTransit.csproj b/src/modules/Elsa.MassTransit/Elsa.MassTransit.csproj index 0902853..18babef 100644 --- a/src/modules/Elsa.MassTransit/Elsa.MassTransit.csproj +++ b/src/modules/Elsa.MassTransit/Elsa.MassTransit.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.MassTransit Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides integration with MassTransit and comes with a MassTransit implementation of Elsa's IServiceBus abstraction. diff --git a/src/modules/Elsa.MongoDb/Elsa.MongoDb.csproj b/src/modules/Elsa.MongoDb/Elsa.MongoDb.csproj index db333eb..75406ef 100644 --- a/src/modules/Elsa.MongoDb/Elsa.MongoDb.csproj +++ b/src/modules/Elsa.MongoDb/Elsa.MongoDb.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.MongoDb Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides MongoDB implementations of various abstractions from various modules. diff --git a/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj b/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj index 0602c75..c24641c 100644 --- a/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj +++ b/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.OpenTelemetry Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides OpenTelemetry sources for tracing workflow and activity execution. diff --git a/src/modules/Elsa.ProtoActor.Core/Elsa.ProtoActor.Core.csproj b/src/modules/Elsa.ProtoActor.Core/Elsa.ProtoActor.Core.csproj index 10968ed..25cc775 100644 --- a/src/modules/Elsa.ProtoActor.Core/Elsa.ProtoActor.Core.csproj +++ b/src/modules/Elsa.ProtoActor.Core/Elsa.ProtoActor.Core.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.ProtoActor.Core Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides ProtoActor services to the system. Other modules can add actors to the ActorSystem. diff --git a/src/modules/Elsa.Python/Elsa.Python.csproj b/src/modules/Elsa.Python/Elsa.Python.csproj index f70e0c0..6581550 100644 --- a/src/modules/Elsa.Python/Elsa.Python.csproj +++ b/src/modules/Elsa.Python/Elsa.Python.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Python Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a Python expression provider. diff --git a/src/modules/Elsa.Quartz.EntityFrameworkCore.MySql/Elsa.Quartz.EntityFrameworkCore.MySql.csproj b/src/modules/Elsa.Quartz.EntityFrameworkCore.MySql/Elsa.Quartz.EntityFrameworkCore.MySql.csproj index 48e8844..4ccb29f 100644 --- a/src/modules/Elsa.Quartz.EntityFrameworkCore.MySql/Elsa.Quartz.EntityFrameworkCore.MySql.csproj +++ b/src/modules/Elsa.Quartz.EntityFrameworkCore.MySql/Elsa.Quartz.EntityFrameworkCore.MySql.csproj @@ -5,7 +5,7 @@ Caspian.Elsa.Quartz.EntityFrameworkCore.MySql Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides EF Core migrations for Quartz.NET. diff --git a/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj b/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj index 7ff97fe..5c3cb7d 100644 --- a/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj +++ b/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Quartz.EntityFrameworkCore.PostgreSql Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides EF Core migrations for Quartz.NET. diff --git a/src/modules/Elsa.Quartz.EntityFrameworkCore.SqlServer/Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj b/src/modules/Elsa.Quartz.EntityFrameworkCore.SqlServer/Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj index 15fdc73..687514d 100644 --- a/src/modules/Elsa.Quartz.EntityFrameworkCore.SqlServer/Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj +++ b/src/modules/Elsa.Quartz.EntityFrameworkCore.SqlServer/Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Quartz.EntityFrameworkCore.SqlServer Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides EF Core migrations for Quartz.NET. diff --git a/src/modules/Elsa.Quartz.EntityFrameworkCore.Sqlite/Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj b/src/modules/Elsa.Quartz.EntityFrameworkCore.Sqlite/Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj index 2fcfe88..3492e5e 100644 --- a/src/modules/Elsa.Quartz.EntityFrameworkCore.Sqlite/Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj +++ b/src/modules/Elsa.Quartz.EntityFrameworkCore.Sqlite/Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Quartz.EntityFrameworkCore.Sqlite Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides EF Core migrations for Quartz.NET. diff --git a/src/modules/Elsa.Quartz/Elsa.Quartz.csproj b/src/modules/Elsa.Quartz/Elsa.Quartz.csproj index 1c53c48..a63bdc5 100644 --- a/src/modules/Elsa.Quartz/Elsa.Quartz.csproj +++ b/src/modules/Elsa.Quartz/Elsa.Quartz.csproj @@ -5,7 +5,7 @@ Caspian.Elsa.Quartz Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides integration with the Quartz.NET library and provide am implementation of Elsa's IJobScheduler using Quartz.NET. diff --git a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj index f017bce..56838bd 100644 --- a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj +++ b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Resilience.Core Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides core services and types for implementing resilient services and activities. diff --git a/src/modules/Elsa.Resilience/Elsa.Resilience.csproj b/src/modules/Elsa.Resilience/Elsa.Resilience.csproj index 1011437..902a091 100644 --- a/src/modules/Elsa.Resilience/Elsa.Resilience.csproj +++ b/src/modules/Elsa.Resilience/Elsa.Resilience.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Resilience Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides features and services for implementing resilient services and activities. diff --git a/src/modules/Elsa.Retention/Elsa.Retention.csproj b/src/modules/Elsa.Retention/Elsa.Retention.csproj index bbac337..12dbfce 100644 --- a/src/modules/Elsa.Retention/Elsa.Retention.csproj +++ b/src/modules/Elsa.Retention/Elsa.Retention.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Retention Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides retention options for workflows. diff --git a/src/modules/Elsa.SasTokens/Elsa.SasTokens.csproj b/src/modules/Elsa.SasTokens/Elsa.SasTokens.csproj index 3549fc6..7798d2c 100644 --- a/src/modules/Elsa.SasTokens/Elsa.SasTokens.csproj +++ b/src/modules/Elsa.SasTokens/Elsa.SasTokens.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.SasTokens Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides services to generate SAS tokens. diff --git a/src/modules/Elsa.Scheduling/Elsa.Scheduling.csproj b/src/modules/Elsa.Scheduling/Elsa.Scheduling.csproj index 16f7285..e61ce5a 100644 --- a/src/modules/Elsa.Scheduling/Elsa.Scheduling.csproj +++ b/src/modules/Elsa.Scheduling/Elsa.Scheduling.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Scheduling Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides scheduling activities such as Timer, Delay and StartAt. diff --git a/src/modules/Elsa.Secrets.Api/Elsa.Secrets.Api.csproj b/src/modules/Elsa.Secrets.Api/Elsa.Secrets.Api.csproj index 5b570b9..ce4d65b 100644 --- a/src/modules/Elsa.Secrets.Api/Elsa.Secrets.Api.csproj +++ b/src/modules/Elsa.Secrets.Api/Elsa.Secrets.Api.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Secrets.Api Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides Secrets API endpoints elsa module secrets api diff --git a/src/modules/Elsa.Secrets.Core/Elsa.Secrets.Core.csproj b/src/modules/Elsa.Secrets.Core/Elsa.Secrets.Core.csproj index a1d8e1e..af902ff 100644 --- a/src/modules/Elsa.Secrets.Core/Elsa.Secrets.Core.csproj +++ b/src/modules/Elsa.Secrets.Core/Elsa.Secrets.Core.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Secrets.Core Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides services to get secrets. diff --git a/src/modules/Elsa.Secrets.Management/Elsa.Secrets.Management.csproj b/src/modules/Elsa.Secrets.Management/Elsa.Secrets.Management.csproj index 8d2a3b1..d50c103 100644 --- a/src/modules/Elsa.Secrets.Management/Elsa.Secrets.Management.csproj +++ b/src/modules/Elsa.Secrets.Management/Elsa.Secrets.Management.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Secrets.Management Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides services to manage secrets. diff --git a/src/modules/Elsa.Secrets.Models/Elsa.Secrets.Models.csproj b/src/modules/Elsa.Secrets.Models/Elsa.Secrets.Models.csproj index e0a590c..9977236 100644 --- a/src/modules/Elsa.Secrets.Models/Elsa.Secrets.Models.csproj +++ b/src/modules/Elsa.Secrets.Models/Elsa.Secrets.Models.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Secrets.Models Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides models that can be shared between the API and API clients elsa module secrets api diff --git a/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.PostgreSql/Elsa.Secrets.Persistence.EntityFrameworkCore.PostgreSql.csproj b/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.PostgreSql/Elsa.Secrets.Persistence.EntityFrameworkCore.PostgreSql.csproj index 1619ab7..a933ca9 100644 --- a/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.PostgreSql/Elsa.Secrets.Persistence.EntityFrameworkCore.PostgreSql.csproj +++ b/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.PostgreSql/Elsa.Secrets.Persistence.EntityFrameworkCore.PostgreSql.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Secrets.Persistence.EntityFrameworkCore.PostgreSql Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides PostgreSql persistence for the Elsa Secrets module using Entity Framework Core. elsa module secrets persistence efcore PostgreSql diff --git a/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.SqlServer/Elsa.Secrets.Persistence.EntityFrameworkCore.SqlServer.csproj b/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.SqlServer/Elsa.Secrets.Persistence.EntityFrameworkCore.SqlServer.csproj index 93d59ea..6aa3539 100644 --- a/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.SqlServer/Elsa.Secrets.Persistence.EntityFrameworkCore.SqlServer.csproj +++ b/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.SqlServer/Elsa.Secrets.Persistence.EntityFrameworkCore.SqlServer.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Secrets.Persistence.EntityFrameworkCore.SqlServer Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides SqlServer persistence for the Elsa Secrets module using Entity Framework Core. elsa module secrets persistence efcore SqlServer diff --git a/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.Sqlite/Elsa.Secrets.Persistence.EntityFrameworkCore.Sqlite.csproj b/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.Sqlite/Elsa.Secrets.Persistence.EntityFrameworkCore.Sqlite.csproj index 070dcb0..4fb9fe7 100644 --- a/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.Sqlite/Elsa.Secrets.Persistence.EntityFrameworkCore.Sqlite.csproj +++ b/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore.Sqlite/Elsa.Secrets.Persistence.EntityFrameworkCore.Sqlite.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Secrets.Persistence.EntityFrameworkCore.Sqlite Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides SQLite persistence for the Elsa Secrets module using Entity Framework Core. elsa module secrets persistence efcore sqlite diff --git a/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore/Elsa.Secrets.Persistence.EntityFrameworkCore.csproj b/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore/Elsa.Secrets.Persistence.EntityFrameworkCore.csproj index c7f5d79..f5dee21 100644 --- a/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore/Elsa.Secrets.Persistence.EntityFrameworkCore.csproj +++ b/src/modules/Elsa.Secrets.Persistence.EntityFrameworkCore/Elsa.Secrets.Persistence.EntityFrameworkCore.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Secrets.Persistence.EntityFrameworkCore Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides an Entity Framework Core provider for the persistence layer of the Secrets module elsa module security secrets persistence efcore entity framework core diff --git a/src/modules/Elsa.Secrets.Scripting/Elsa.Secrets.Scripting.csproj b/src/modules/Elsa.Secrets.Scripting/Elsa.Secrets.Scripting.csproj index 580bbf5..6547b73 100644 --- a/src/modules/Elsa.Secrets.Scripting/Elsa.Secrets.Scripting.csproj +++ b/src/modules/Elsa.Secrets.Scripting/Elsa.Secrets.Scripting.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Secrets.Scripting Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides scripting integration with various languages like JavaScript, C#, Python and Liquid. elsa module secrets scripting diff --git a/src/modules/Elsa.Sql.MySql/Elsa.Sql.MySql.csproj b/src/modules/Elsa.Sql.MySql/Elsa.Sql.MySql.csproj index cf7365d..0c1f4b9 100644 --- a/src/modules/Elsa.Sql.MySql/Elsa.Sql.MySql.csproj +++ b/src/modules/Elsa.Sql.MySql/Elsa.Sql.MySql.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Sql.MySql Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides client implementations for interacting with MySql databases. diff --git a/src/modules/Elsa.Sql.PostgreSql/Elsa.Sql.PostgreSql.csproj b/src/modules/Elsa.Sql.PostgreSql/Elsa.Sql.PostgreSql.csproj index 7ddfe6f..903a849 100644 --- a/src/modules/Elsa.Sql.PostgreSql/Elsa.Sql.PostgreSql.csproj +++ b/src/modules/Elsa.Sql.PostgreSql/Elsa.Sql.PostgreSql.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Sql.PostgreSql Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides client implementations for interacting with PostgreSql databases. diff --git a/src/modules/Elsa.Sql.SqlServer/Elsa.Sql.SqlServer.csproj b/src/modules/Elsa.Sql.SqlServer/Elsa.Sql.SqlServer.csproj index e1da09c..da34aeb 100644 --- a/src/modules/Elsa.Sql.SqlServer/Elsa.Sql.SqlServer.csproj +++ b/src/modules/Elsa.Sql.SqlServer/Elsa.Sql.SqlServer.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Sql.SqlServer Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides client implementations for interacting with Microsoft SQL Server databases. diff --git a/src/modules/Elsa.Sql.Sqlite/Elsa.Sql.Sqlite.csproj b/src/modules/Elsa.Sql.Sqlite/Elsa.Sql.Sqlite.csproj index f1385ba..cdb135d 100644 --- a/src/modules/Elsa.Sql.Sqlite/Elsa.Sql.Sqlite.csproj +++ b/src/modules/Elsa.Sql.Sqlite/Elsa.Sql.Sqlite.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Sql.Sqlite Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides client implementations for interacting with Sqlite databases. diff --git a/src/modules/Elsa.Sql/Elsa.Sql.csproj b/src/modules/Elsa.Sql/Elsa.Sql.csproj index 730eec0..2a1652e 100644 --- a/src/modules/Elsa.Sql/Elsa.Sql.csproj +++ b/src/modules/Elsa.Sql/Elsa.Sql.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Sql Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides activities to interact with sql databases. diff --git a/src/modules/Elsa.Tenants.AspNetCore/Elsa.Tenants.AspNetCore.csproj b/src/modules/Elsa.Tenants.AspNetCore/Elsa.Tenants.AspNetCore.csproj index 07f35d6..990f8ff 100644 --- a/src/modules/Elsa.Tenants.AspNetCore/Elsa.Tenants.AspNetCore.csproj +++ b/src/modules/Elsa.Tenants.AspNetCore/Elsa.Tenants.AspNetCore.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Tenants.AspNetCore Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides multitenancy middleware diff --git a/src/modules/Elsa.Tenants/Elsa.Tenants.csproj b/src/modules/Elsa.Tenants/Elsa.Tenants.csproj index 881f657..7483e7f 100644 --- a/src/modules/Elsa.Tenants/Elsa.Tenants.csproj +++ b/src/modules/Elsa.Tenants/Elsa.Tenants.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Tenants Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides multitenancy services. diff --git a/src/modules/Elsa.Webhooks/Elsa.Webhooks.csproj b/src/modules/Elsa.Webhooks/Elsa.Webhooks.csproj index c694f16..2d3a9a5 100644 --- a/src/modules/Elsa.Webhooks/Elsa.Webhooks.csproj +++ b/src/modules/Elsa.Webhooks/Elsa.Webhooks.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Webhooks Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a way to register webhook endpoints that should be invoked when certain event occur. diff --git a/src/modules/Elsa.WorkflowContexts/Elsa.WorkflowContexts.csproj b/src/modules/Elsa.WorkflowContexts/Elsa.WorkflowContexts.csproj index 4b9cfd9..e6c2e51 100644 --- a/src/modules/Elsa.WorkflowContexts/Elsa.WorkflowContexts.csproj +++ b/src/modules/Elsa.WorkflowContexts/Elsa.WorkflowContexts.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.WorkflowContexts Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides workflow context APIs to Elsa workflows. diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj b/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj index 0b49f32..90041c8 100644 --- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.WorkflowProviders.BlobStorage Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a workflow definition provider that loads workflow definitions from files. diff --git a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj index 1baee8f..b7a3285 100644 --- a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj +++ b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Workflows.Api Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides API endpoints for client applications such as the Elsa Designer web component. diff --git a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj index d3642c6..774eebd 100644 --- a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj +++ b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Workflows.Core Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Elsa.Workflows diff --git a/src/modules/Elsa.Workflows.Management/Elsa.Workflows.Management.csproj b/src/modules/Elsa.Workflows.Management/Elsa.Workflows.Management.csproj index a34b0e7..48586f1 100644 --- a/src/modules/Elsa.Workflows.Management/Elsa.Workflows.Management.csproj +++ b/src/modules/Elsa.Workflows.Management/Elsa.Workflows.Management.csproj @@ -1,10 +1,10 @@ - + Caspian.Elsa.Workflows.Management Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides workflow management functionality. diff --git a/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs index dc6a1dc..236731f 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs @@ -89,4 +89,13 @@ public static class ModuleExtensions feature.Module.Configure(configure); return feature; } -} \ No newline at end of file + public static WorkflowManagementFeature UseCache(this WorkflowManagementFeature feature, + Microsoft.Extensions.Configuration.IConfigurationSection section, Action? configure = null) + { + return feature.UseCache(cache => + { + cache.UseConfiguration(section); + configure?.Invoke(cache); + }); + } +} diff --git a/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs b/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs index df939d6..a5e7622 100644 --- a/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs @@ -1,4 +1,6 @@ using Elsa.Features.Abstractions; +using Elsa.Caching.Features; +using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.Workflows.Management.Handlers; using Elsa.Workflows.Management.Handlers.Notifications; @@ -11,7 +13,8 @@ namespace Elsa.Workflows.Management.Features; /// /// Configures workflow definition caching. /// -public class CachingWorkflowDefinitionsFeature : FeatureBase +[DependsOn(typeof(CacheFeature))] +public class CachingWorkflowDefinitionsFeature : CacheConsumerFeature { /// public CachingWorkflowDefinitionsFeature(IModule module) : base(module) @@ -26,4 +29,4 @@ public class CachingWorkflowDefinitionsFeature : FeatureBase Services.Decorate(); Services.AddNotificationHandler(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Management/Handlers/Notifications/EvictWorkflowDefinitionServiceCache.cs b/src/modules/Elsa.Workflows.Management/Handlers/Notifications/EvictWorkflowDefinitionServiceCache.cs index f3802e9..7de4bef 100644 --- a/src/modules/Elsa.Workflows.Management/Handlers/Notifications/EvictWorkflowDefinitionServiceCache.cs +++ b/src/modules/Elsa.Workflows.Management/Handlers/Notifications/EvictWorkflowDefinitionServiceCache.cs @@ -12,39 +12,39 @@ namespace Elsa.Workflows.Management.Handlers.Notifications; /// [UsedImplicitly] internal class EvictWorkflowDefinitionServiceCache(IWorkflowDefinitionCacheManager workflowDefinitionCacheManager) : - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler { /// - public async Task HandleAsync(WorkflowDefinitionPublishing notification, CancellationToken cancellationToken) + public async Task HandleAsync(WorkflowDefinitionPublished notification, CancellationToken cancellationToken) { await workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken); } /// - public async Task HandleAsync(WorkflowDefinitionRetracting notification, CancellationToken cancellationToken) + public async Task HandleAsync(WorkflowDefinitionRetracted notification, CancellationToken cancellationToken) { await workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken); } /// - public async Task HandleAsync(WorkflowDefinitionDeleting notification, CancellationToken cancellationToken) + public async Task HandleAsync(WorkflowDefinitionDeleted notification, CancellationToken cancellationToken) { await workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.DefinitionId, cancellationToken); } /// - public async Task HandleAsync(WorkflowDefinitionsDeleting notification, CancellationToken cancellationToken) + public async Task HandleAsync(WorkflowDefinitionsDeleted notification, CancellationToken cancellationToken) { foreach (var definitionId in notification.DefinitionIds) await workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(definitionId, cancellationToken); } /// - public async Task HandleAsync(WorkflowDefinitionVersionsUpdating notification, CancellationToken cancellationToken) + public async Task HandleAsync(WorkflowDefinitionVersionsUpdated notification, CancellationToken cancellationToken) { foreach (var definition in notification.WorkflowDefinitions) await workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(definition.DefinitionId, cancellationToken); diff --git a/src/modules/Elsa.Workflows.Management/Services/CachingWorkflowDefinitionService.cs b/src/modules/Elsa.Workflows.Management/Services/CachingWorkflowDefinitionService.cs index f8936d8..cd15f7d 100644 --- a/src/modules/Elsa.Workflows.Management/Services/CachingWorkflowDefinitionService.cs +++ b/src/modules/Elsa.Workflows.Management/Services/CachingWorkflowDefinitionService.cs @@ -3,7 +3,7 @@ using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Models; using JetBrains.Annotations; -using Microsoft.Extensions.Caching.Memory; +using Elsa.Caching.Models; namespace Elsa.Workflows.Management.Services; @@ -61,6 +61,7 @@ public class CachingWorkflowDefinitionService(IWorkflowDefinitionService decorat /// public async Task FindWorkflowGraphAsync(string definitionId, VersionOptions versionOptions, CancellationToken cancellationToken = default) { + if (cacheManager.Cache.IsShared) return await decoratedService.FindWorkflowGraphAsync(definitionId, versionOptions, cancellationToken); var cacheKey = cacheManager.CreateWorkflowVersionCacheKey(definitionId, versionOptions); return await GetFromCacheAsync(cacheKey, () => decoratedService.FindWorkflowGraphAsync(definitionId, versionOptions, cancellationToken), @@ -70,6 +71,7 @@ public class CachingWorkflowDefinitionService(IWorkflowDefinitionService decorat /// public async Task FindWorkflowGraphAsync(string definitionVersionId, CancellationToken cancellationToken = default) { + if (cacheManager.Cache.IsShared) return await decoratedService.FindWorkflowGraphAsync(definitionVersionId, cancellationToken); var cacheKey = cacheManager.CreateWorkflowVersionCacheKey(definitionVersionId); return await GetFromCacheAsync(cacheKey, () => decoratedService.FindWorkflowGraphAsync(definitionVersionId, cancellationToken), @@ -90,6 +92,7 @@ public class CachingWorkflowDefinitionService(IWorkflowDefinitionService decorat /// public async Task FindWorkflowGraphAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) { + if (cacheManager.Cache.IsShared) return await decoratedService.FindWorkflowGraphAsync(filter, cancellationToken); var cacheKey = cacheManager.CreateWorkflowFilterCacheKey(filter); return await GetFromCacheAsync(cacheKey, () => decoratedService.FindWorkflowGraphAsync(filter, cancellationToken), @@ -99,18 +102,15 @@ public class CachingWorkflowDefinitionService(IWorkflowDefinitionService decorat private async Task GetFromCacheAsync(string cacheKey, Func> getObjectFunc, Func getChangeTokenKeyFunc) { var cache = cacheManager.Cache; - return await cache.GetOrCreateAsync(cacheKey, async entry => + var options = new CacheEntryOptions { AbsoluteExpirationRelativeToNow = cache.CachingOptions.Value.CacheDuration }; + options.Tags.Add(typeof(Elsa.Workflows.Management.Stores.CachingWorkflowDefinitionStore).FullName!); + // The shared consistency revision also invalidates cached misses. + return await cache.GetOrCreateAsync(cacheKey, async () => { - entry.SetAbsoluteExpiration(cache.CachingOptions.Value.CacheDuration); var obj = await getObjectFunc(); - - if (obj == null) - return default; - - var changeTokenKeyInput = getChangeTokenKeyFunc(obj); - var changeTokenKey = cacheManager.CreateWorkflowDefinitionChangeTokenKey(changeTokenKeyInput); - entry.AddExpirationToken(cache.GetToken(changeTokenKey)); + if (obj != null) + options.Tags.Add(cacheManager.CreateWorkflowDefinitionChangeTokenKey(getChangeTokenKeyFunc(obj))); return obj; - }); + }, options); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionCacheManager.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionCacheManager.cs index 8fdbd6d..ee43835 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionCacheManager.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionCacheManager.cs @@ -5,36 +5,36 @@ using Elsa.Workflows.Management.Filters; namespace Elsa.Workflows.Management.Services; /// -public class WorkflowDefinitionCacheManager(ICacheManager cache, IHasher hasher) : IWorkflowDefinitionCacheManager +public class WorkflowDefinitionCacheManager(ICacheManager cache, IHasher hasher, Elsa.Common.Multitenancy.ITenantAccessor tenantAccessor) : IWorkflowDefinitionCacheManager { /// public ICacheManager Cache => cache; /// - public string CreateWorkflowDefinitionVersionCacheKey(string definitionId, VersionOptions versionOptions) => $"WorkflowDefinition:{definitionId}:{versionOptions}"; + public string CreateWorkflowDefinitionVersionCacheKey(string definitionId, VersionOptions versionOptions) => $"{tenantAccessor.Tenant?.Id}:WorkflowDefinition:{definitionId}:{versionOptions}"; /// public string CreateWorkflowDefinitionFilterCacheKey(WorkflowDefinitionFilter filter) { var hash = hasher.Hash(filter); - return $"WorkflowDefinition:{hash}"; + return $"{tenantAccessor.Tenant?.Id}:WorkflowDefinition:{hash}"; } /// - public string CreateWorkflowVersionCacheKey(string definitionId, VersionOptions versionOptions) => $"Workflow:{definitionId}:{versionOptions}"; + public string CreateWorkflowVersionCacheKey(string definitionId, VersionOptions versionOptions) => $"{tenantAccessor.Tenant?.Id}:Workflow:{definitionId}:{versionOptions}"; /// - public string CreateWorkflowVersionCacheKey(string definitionVersionId) => $"Workflow:{definitionVersionId}"; + public string CreateWorkflowVersionCacheKey(string definitionVersionId) => $"{tenantAccessor.Tenant?.Id}:Workflow:{definitionVersionId}"; /// public string CreateWorkflowFilterCacheKey(WorkflowDefinitionFilter filter) { var hash = hasher.Hash(filter); - return $"Workflow:{hash}"; + return $"{tenantAccessor.Tenant?.Id}:Workflow:{hash}"; } /// - public string CreateWorkflowDefinitionVersionCacheKey(string definitionVersionId) => $"WorkflowDefinition:{definitionVersionId}"; + public string CreateWorkflowDefinitionVersionCacheKey(string definitionVersionId) => $"{tenantAccessor.Tenant?.Id}:WorkflowDefinition:{definitionVersionId}"; /// public string CreateWorkflowDefinitionChangeTokenKey(string definitionId) => $"WorkflowChangeToken:{definitionId}"; diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs index 88640c6..464aca2 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs @@ -1,4 +1,7 @@ using Elsa.Common; +using Elsa.Caching; +using Medallion.Threading; +using Microsoft.Extensions.DependencyInjection; using Elsa.Common.Entities; using Elsa.Common.Models; using Elsa.Mediator.Contracts; @@ -20,7 +23,8 @@ public class WorkflowDefinitionPublisher( IIdentityGenerator identityGenerator, IActivitySerializer activitySerializer, IMediator mediator, - ISystemClock systemClock) + ISystemClock systemClock, + IServiceProvider? serviceProvider = null) : IWorkflowDefinitionPublisher { /// @@ -90,6 +94,11 @@ public class WorkflowDefinitionPublisher( if (validationErrors.Any()) return new(false, validationErrors, new([])); + var locks = serviceProvider?.GetService(); + await using var publishLock = locks == null ? null + : await locks.AcquireLockAsync($"workflow-definition:{definition.DefinitionId}", cancellationToken: cancellationToken); + var cache = serviceProvider?.GetService(); + var mutation = cache == null ? null : await cache.BeginMutationAsync(cancellationToken); await mediator.SendAsync(new WorkflowDefinitionPublishing(definition), cancellationToken); var definitionId = definition.DefinitionId; @@ -117,7 +126,8 @@ public class WorkflowDefinitionPublisher( await workflowDefinitionStore.SaveAsync(definition, cancellationToken); var affectedWorkflows = new AffectedWorkflows(new List()); - await mediator.SendAsync(new WorkflowDefinitionPublished(definition, affectedWorkflows), cancellationToken); + await mediator.SendAsync(new WorkflowDefinitionPublished(definition, affectedWorkflows), CancellationToken.None); + if (cache != null && mutation != null) await cache.CompleteMutationAsync(mutation, CancellationToken.None); return new(true, validationErrors, affectedWorkflows); } @@ -221,4 +231,4 @@ public class WorkflowDefinitionPublisher( return definition; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Management/Stores/CachingWorkflowDefinitionStore.cs b/src/modules/Elsa.Workflows.Management/Stores/CachingWorkflowDefinitionStore.cs index 796a557..8c86b54 100644 --- a/src/modules/Elsa.Workflows.Management/Stores/CachingWorkflowDefinitionStore.cs +++ b/src/modules/Elsa.Workflows.Management/Stores/CachingWorkflowDefinitionStore.cs @@ -4,7 +4,8 @@ using Elsa.Common.Multitenancy; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Management.Models; -using Microsoft.Extensions.Caching.Memory; +using Elsa.Caching.Models; +using Elsa.Extensions; namespace Elsa.Workflows.Management.Stores; @@ -18,7 +19,7 @@ public class CachingWorkflowDefinitionStore(IWorkflowDefinitionStore decoratedSt /// public async Task FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) { - var cacheKey = hasher.Hash(filter); + var cacheKey = hasher.Hash(nameof(FindAsync), filter); return await GetOrCreateAsync(cacheKey, () => decoratedStore.FindAsync(filter, cancellationToken)); } @@ -95,22 +96,22 @@ public class CachingWorkflowDefinitionStore(IWorkflowDefinitionStore decoratedSt /// public async Task SaveAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) { - await decoratedStore.SaveAsync(definition, cancellationToken); - await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, cancellationToken); + await cacheManager.MutateAsync(() => decoratedStore.SaveAsync(definition, cancellationToken), cancellationToken); + await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, CancellationToken.None); } /// public async Task SaveManyAsync(IEnumerable definitions, CancellationToken cancellationToken = default) { - await decoratedStore.SaveManyAsync(definitions, cancellationToken); - await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, cancellationToken); + await cacheManager.MutateAsync(() => decoratedStore.SaveManyAsync(definitions, cancellationToken), cancellationToken); + await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, CancellationToken.None); } /// public async Task DeleteAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) { - var result = await decoratedStore.DeleteAsync(filter, cancellationToken); - await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, cancellationToken); + var result = await cacheManager.MutateAsync(() => decoratedStore.DeleteAsync(filter, cancellationToken), cancellationToken); + await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, CancellationToken.None); return result; } @@ -139,13 +140,9 @@ public class CachingWorkflowDefinitionStore(IWorkflowDefinitionStore decoratedSt { var tenantId = tenantAccessor.Tenant?.Id; var tenantIdPrefix = !string.IsNullOrEmpty(tenantId) ? $"{tenantId}:" : string.Empty; - var internalKey = $"{tenantIdPrefix}{typeof(T).Name}:{key}"; - return await cacheManager.GetOrCreateAsync(internalKey, async entry => - { - var invalidationRequestToken = cacheManager.GetToken(CacheInvalidationTokenKey); - entry.AddExpirationToken(invalidationRequestToken); - entry.SetSlidingExpiration(cacheManager.CachingOptions.Value.CacheDuration); - return await factory(); - }); + var internalKey = $"{tenantIdPrefix}{typeof(T).FullName}:{key}"; + var options = new CacheEntryOptions { SlidingExpiration = cacheManager.CachingOptions.Value.CacheDuration }; + options.Tags.Add(CacheInvalidationTokenKey); + return await cacheManager.GetOrCreateAsync(internalKey, factory, options); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Runtime.Distributed/Elsa.Workflows.Runtime.Distributed.csproj b/src/modules/Elsa.Workflows.Runtime.Distributed/Elsa.Workflows.Runtime.Distributed.csproj index a989a60..99d377c 100644 --- a/src/modules/Elsa.Workflows.Runtime.Distributed/Elsa.Workflows.Runtime.Distributed.csproj +++ b/src/modules/Elsa.Workflows.Runtime.Distributed/Elsa.Workflows.Runtime.Distributed.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Workflows.Runtime.Distributed Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides distributed workflow runtime functionality. diff --git a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Elsa.Workflows.Runtime.ProtoActor.csproj b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Elsa.Workflows.Runtime.ProtoActor.csproj index 1aa0607..ce368bc 100644 --- a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Elsa.Workflows.Runtime.ProtoActor.csproj +++ b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Elsa.Workflows.Runtime.ProtoActor.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Workflows.Runtime.ProtoActor Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides a workflow runtime using ProtoActor and its out-of-the-box memory provider. diff --git a/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj b/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj index e261795..710974a 100644 --- a/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj +++ b/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj @@ -4,7 +4,7 @@ Caspian.Elsa.Workflows.Runtime Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Provides workflow runtime functionality. diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/ModuleExtensions.cs index c909010..4a8f791 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/ModuleExtensions.cs @@ -63,4 +63,13 @@ public static class ModuleExtensions feature.Module.Configure(configure); return feature; } -} \ No newline at end of file + public static WorkflowRuntimeFeature UseCache(this WorkflowRuntimeFeature feature, + Microsoft.Extensions.Configuration.IConfigurationSection section, Action? configure = null) + { + return feature.UseCache(cache => + { + cache.UseConfiguration(section); + configure?.Invoke(cache); + }); + } +} diff --git a/src/modules/Elsa.Workflows.Runtime/Features/CachingWorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/CachingWorkflowRuntimeFeature.cs index a4d2f58..dcbb0c1 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/CachingWorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/CachingWorkflowRuntimeFeature.cs @@ -11,8 +11,8 @@ namespace Elsa.Workflows.Runtime.Features; /// /// Installs and configures workflow runtime caching features. /// -[DependsOn(typeof(MemoryCacheFeature))] -public class CachingWorkflowRuntimeFeature : FeatureBase +[DependsOn(typeof(CacheFeature))] +public class CachingWorkflowRuntimeFeature : CacheConsumerFeature { /// public CachingWorkflowRuntimeFeature(IModule module) : base(module) @@ -25,9 +25,10 @@ public class CachingWorkflowRuntimeFeature : FeatureBase Services // Decorators. .Decorate() + .Decorate() // Handlers. .AddNotificationHandler() .AddNotificationHandler(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Runtime/Stores/BookmarkConsistencyStore.cs b/src/modules/Elsa.Workflows.Runtime/Stores/BookmarkConsistencyStore.cs new file mode 100644 index 0000000..37ba4c7 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Stores/BookmarkConsistencyStore.cs @@ -0,0 +1,21 @@ +using Elsa.Caching; +using Elsa.Extensions; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; + +namespace Elsa.Workflows.Runtime.Stores; + +/// Bookmarks stay in SQL; a mutation advances the shared revision for local HTTP route tables. +public class BookmarkConsistencyStore(IBookmarkStore decoratedStore, ICacheManager cache) : IBookmarkStore +{ + public async ValueTask SaveAsync(StoredBookmark record, CancellationToken cancellationToken = default) + => await cache.MutateRoutesAsync(async () => await decoratedStore.SaveAsync(record, cancellationToken), cancellationToken); + public async ValueTask SaveManyAsync(IEnumerable records, CancellationToken cancellationToken) + => await cache.MutateRoutesAsync(async () => await decoratedStore.SaveManyAsync(records, cancellationToken), cancellationToken); + public ValueTask FindAsync(BookmarkFilter filter, CancellationToken cancellationToken = default) + => decoratedStore.FindAsync(filter, cancellationToken); + public ValueTask> FindManyAsync(BookmarkFilter filter, CancellationToken cancellationToken = default) + => decoratedStore.FindManyAsync(filter, cancellationToken); + public async ValueTask DeleteAsync(BookmarkFilter filter, CancellationToken cancellationToken = default) + => await cache.MutateRoutesAsync(async () => await decoratedStore.DeleteAsync(filter, cancellationToken), cancellationToken); +} diff --git a/src/modules/Elsa.Workflows.Runtime/Stores/CachingTriggerStore.cs b/src/modules/Elsa.Workflows.Runtime/Stores/CachingTriggerStore.cs index 5fed21c..bec19df 100644 --- a/src/modules/Elsa.Workflows.Runtime/Stores/CachingTriggerStore.cs +++ b/src/modules/Elsa.Workflows.Runtime/Stores/CachingTriggerStore.cs @@ -4,7 +4,8 @@ using Elsa.Common.Multitenancy; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Filters; using Elsa.Workflows.Runtime.OrderDefinitions; -using Microsoft.Extensions.Caching.Memory; +using Elsa.Caching.Models; +using Elsa.Extensions; namespace Elsa.Workflows.Runtime.Stores; @@ -21,15 +22,15 @@ public class CachingTriggerStore(ITriggerStore decoratedStore, ICacheManager cac /// public async ValueTask SaveAsync(StoredTrigger record, CancellationToken cancellationToken = default) { - await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, cancellationToken); - await decoratedStore.SaveAsync(record, cancellationToken); + await cacheManager.MutateAsync(async () => await decoratedStore.SaveAsync(record, cancellationToken), cancellationToken); + await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, CancellationToken.None); } /// public async ValueTask SaveManyAsync(IEnumerable records, CancellationToken cancellationToken = default) { - await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, cancellationToken); - await decoratedStore.SaveManyAsync(records, cancellationToken); + await cacheManager.MutateAsync(async () => await decoratedStore.SaveManyAsync(records, cancellationToken), cancellationToken); + await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, CancellationToken.None); } /// @@ -48,41 +49,38 @@ public class CachingTriggerStore(ITriggerStore decoratedStore, ICacheManager cac public async ValueTask> FindManyAsync(TriggerFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default) { - var cacheKey = hasher.Hash(filter); + var cacheKey = hasher.Hash(filter, pageArgs); return (await GetOrCreateAsync(cacheKey, async () => await decoratedStore.FindManyAsync(filter, pageArgs, cancellationToken)))!; } public async ValueTask> FindManyAsync(TriggerFilter filter, PageArgs pageArgs, StoredTriggerOrder order, CancellationToken cancellationToken = default) { - var cacheKey = hasher.Hash(filter); + var cacheKey = hasher.Hash(filter, pageArgs, order); return (await GetOrCreateAsync(cacheKey, async () => await decoratedStore.FindManyAsync(filter, pageArgs, order, cancellationToken)))!; } /// public async ValueTask ReplaceAsync(IEnumerable removed, IEnumerable added, CancellationToken cancellationToken = default) { - await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, cancellationToken); - await decoratedStore.ReplaceAsync(removed, added, cancellationToken); + await cacheManager.MutateAsync(async () => await decoratedStore.ReplaceAsync(removed, added, cancellationToken), cancellationToken); + await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, CancellationToken.None); } /// public async ValueTask DeleteManyAsync(TriggerFilter filter, CancellationToken cancellationToken = default) { - await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, cancellationToken); - return await decoratedStore.DeleteManyAsync(filter, cancellationToken); + var result = await cacheManager.MutateAsync(async () => await decoratedStore.DeleteManyAsync(filter, cancellationToken), cancellationToken); + await cacheManager.TriggerTokenAsync(CacheInvalidationTokenKey, CancellationToken.None); + return result; } private async Task GetOrCreateAsync(string key, Func> factory) { var tenantId = tenantAccessor.Tenant?.Id; var tenantIdPrefix = !string.IsNullOrEmpty(tenantId) ? $"{tenantId}:" : string.Empty; - var internalKey = $"{tenantIdPrefix}{typeof(T).Name}:{key}"; - return await cacheManager.GetOrCreateAsync(internalKey, async entry => - { - var invalidationRequestToken = cacheManager.GetToken(CacheInvalidationTokenKey); - entry.AddExpirationToken(invalidationRequestToken); - entry.SetAbsoluteExpiration(cacheManager.CachingOptions.Value.CacheDuration); - return await factory(); - }); + var internalKey = $"{tenantIdPrefix}{typeof(T).FullName}:{key}"; + var options = new CacheEntryOptions { AbsoluteExpirationRelativeToNow = cacheManager.CachingOptions.Value.CacheDuration }; + options.Tags.Add(CacheInvalidationTokenKey); + return await cacheManager.GetOrCreateAsync(internalKey, factory, options); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa/Elsa.csproj b/src/modules/Elsa/Elsa.csproj index 31fc759..d6b7d7c 100644 --- a/src/modules/Elsa/Elsa.csproj +++ b/src/modules/Elsa/Elsa.csproj @@ -4,7 +4,7 @@ Caspian.Elsa Caspian Behzad Alizadeh - 1.1.0-preview.8 + 1.1.0-preview.11 True Bundles the most commonly-used packages when building an Elsa workflows application. diff --git a/test/integration/Elsa.Caching.ProcessTests/Elsa.Caching.ProcessTests.csproj b/test/integration/Elsa.Caching.ProcessTests/Elsa.Caching.ProcessTests.csproj new file mode 100644 index 0000000..9715238 --- /dev/null +++ b/test/integration/Elsa.Caching.ProcessTests/Elsa.Caching.ProcessTests.csproj @@ -0,0 +1,9 @@ + + Exefalse + + + + + + + diff --git a/test/integration/Elsa.Caching.ProcessTests/Program.cs b/test/integration/Elsa.Caching.ProcessTests/Program.cs new file mode 100644 index 0000000..90633ab --- /dev/null +++ b/test/integration/Elsa.Caching.ProcessTests/Program.cs @@ -0,0 +1,41 @@ +using Elsa.Caching; +using Elsa.Caching.Models; +using Elsa.Caching.UnitTests; +using Microsoft.Extensions.DependencyInjection; + +if (args.Length != 3) throw new ArgumentException("Usage: write|read|invalidate|read-updated|pending|bypass"); +using var host = CacheTestHost.Create(args[0], true, args[1]); +await CacheTestHost.EnsureCreatedAsync(host); +var cache = host.GetRequiredService(); +var options = new CacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) }; +switch (args[2]) +{ + case "write": + Console.WriteLine(await cache.GetOrCreateAsync("definition", () => Task.FromResult("from-first-process"), options)); + break; + case "read": + var value = await cache.GetOrCreateAsync("definition", () => throw new Exception("Second process did not share Redis"), options); + if (value != "from-first-process") throw new Exception("Unexpected cache payload"); + Console.WriteLine("PASS: payload shared across independent processes"); + break; + case "invalidate": + var mutation = await cache.BeginMutationAsync(); + await cache.CompleteMutationAsync(mutation); + Console.WriteLine(await cache.GetOrCreateAsync("definition", () => Task.FromResult("updated"), options)); + break; + case "pending": + await cache.BeginMutationAsync(); + Console.WriteLine("Pending durable fence recorded; process exiting"); + break; + case "read-updated": + var updated = await cache.GetOrCreateAsync("definition", () => throw new Exception("Updated payload was not shared"), options); + if (updated != "updated") throw new Exception("Old payload survived another process's mutation"); + Console.WriteLine("PASS: another process observes the updated revision"); + break; + case "bypass": + var fresh = await cache.GetOrCreateAsync("definition", () => Task.FromResult("sql-current"), options); + if (fresh != "sql-current") throw new Exception("Stale Redis payload was used after an unfinished mutation"); + Console.WriteLine("PASS: unfinished mutation forces SQL reads after process restart"); + break; + default: throw new ArgumentException("Unknown mode"); +} diff --git a/test/unit/Elsa.Caching.UnitTests/CacheProviderTests.cs b/test/unit/Elsa.Caching.UnitTests/CacheProviderTests.cs new file mode 100644 index 0000000..f3c4120 --- /dev/null +++ b/test/unit/Elsa.Caching.UnitTests/CacheProviderTests.cs @@ -0,0 +1,417 @@ +using Elsa.Caching.Features; +using Elsa.Caching.Models; +using Elsa.Caching.Redis.Options; +using Elsa.EntityFrameworkCore.Modules.Management; +using Elsa.Extensions; +using Elsa.Http.Services; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Common.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Elsa.Caching.UnitTests; + +public class RedisFactAttribute : FactAttribute +{ + public RedisFactAttribute() + { + if (Environment.GetEnvironmentVariable("REDIS_TEST_CONNECTION") == null) + Skip = "Set REDIS_TEST_CONNECTION to run integration tests against an isolated Redis server."; + } +} + +public class CacheProviderTests +{ + private static CacheEntryOptions Lifetime => new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) }; + private static IConfigurationSection Configuration(string provider) => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Workflow:Cache:Provider"] = provider, + ["Workflow:Cache:Duration"] = "00:03:00", + ["Workflow:Cache:Redis:Database"] = "2", + ["Workflow:Cache:Redis:KeyPrefix"] = "configured:" + }).Build().GetSection("Workflow:Cache"); + + [Fact] + public async Task Default_memory_caches_and_keeps_local_objects() + { + using var host = CacheTestHost.Create(); + var cache = host.GetRequiredService(); + var calls = 0; + Assert.Equal(1, await cache.GetOrCreateAsync("a", () => Task.FromResult(++calls), Lifetime)); + Assert.Equal(1, await cache.GetOrCreateAsync("a", () => Task.FromResult(++calls), Lifetime)); + var local = host.GetRequiredService(); + var runner = new Func(() => 3); + local.Set("compiled-script", runner); + await cache.TriggerTokenAsync("definition"); + Assert.Same(runner, local.Get>("compiled-script")); + Assert.Equal(2, await cache.GetOrCreateAsync("a", () => Task.FromResult(++calls), Lifetime)); + } + + [Fact] + public void Configuration_is_overridden_only_by_explicit_fluent_values() + { + using var host = CacheTestHost.Create(configure: cache => + { + cache.UseConfiguration(Configuration("Memory")); + cache.UseRedis(redis => redis.KeyPrefix = "fluent:"); + }); + var options = host.GetRequiredService(); + Assert.Equal(2, options.Database); + Assert.Equal("fluent:", options.KeyPrefix); + Assert.Equal(TimeSpan.FromMinutes(3), host.GetRequiredService().Duration); + Assert.True(host.GetRequiredService().IsShared); + } + + [Fact] + public void Fluent_memory_can_override_configured_redis() + { + using var host = CacheTestHost.Create(configure: cache => { cache.UseConfiguration(Configuration("Redis")); cache.UseMemory(); }); + Assert.False(host.GetRequiredService().IsShared); + } + + [Fact] + public void Explicit_connection_factory_overrides_configured_connection_string() + { + Func factory = _ => throw new Exception("Not used during registration"); + using var host = CacheTestHost.Create(configure: c => c.UseRedis(o => + { + o.ConnectionString = "configured-host:6379"; + o.ConnectionFactory = factory; + })); + Assert.Same(factory, host.GetRequiredService().ConnectionFactory); + } + + [Fact] + public void Unknown_or_conflicting_providers_fail_clearly() + { + Assert.Throws(() => CacheTestHost.Create(configure: c => c.UseConfiguration(Configuration("Missing")))); + Assert.Throws(() => CacheTestHost.Create(configure: c => { c.UseMemory(); c.UseRedis(); })); + } + + [Fact] + public async Task Shared_provider_requires_durable_consistency() + { + using var host = CacheTestHost.Create(configure: c => c.UseRedis()); + await Assert.ThrowsAsync(() => host.GetRequiredService() + .GetOrCreateAsync("a", () => Task.FromResult(1), Lifetime)); + } + + [Fact] + public async Task Disabled_cache_always_calls_factory() + { + using var host = CacheTestHost.Create(configure: c => c.Enabled = false); + var cache = host.GetRequiredService(); + var calls = 0; + await cache.GetOrCreateAsync("a", () => Task.FromResult(++calls), Lifetime); + await cache.GetOrCreateAsync("a", () => Task.FromResult(++calls), Lifetime); + Assert.Equal(2, calls); + } + + [Fact] + public async Task Filling_old_data_during_a_mutation_cannot_resurrect_it() + { + using var host = CacheTestHost.Create(); + var cache = host.GetRequiredService(); + var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var fill = cache.GetOrCreateAsync("definition", async () => { captured.SetResult(); await release.Task; return "old"; }, Lifetime); + await captured.Task; + var mutation = await cache.BeginMutationAsync(); + await cache.CompleteMutationAsync(mutation); + release.SetResult(); + Assert.Equal("old", await fill); // An already-running request may finish with its captured version. + Assert.Equal("new", await cache.GetOrCreateAsync("definition", () => Task.FromResult("new"), Lifetime)); + } + + [Fact] + public void Route_snapshot_replacement_removes_deleted_routes() + { + using var host = CacheTestHost.Create(); + var table = ActivatorUtilities.CreateInstance(host); + table.Add("/old"); + table.Replace(new[] { new Elsa.Http.HttpRouteData("/new") }); + Assert.Equal("/new", Assert.Single(table).Route); + } + + [Fact] + public void Sql_server_migration_generates_the_durable_fence_table() + { + var services = new ServiceCollection(); + services.AddDbContextFactory(options => options.UseSqlServer( + "Server=(local);Database=workflow-cache-script;Integrated Security=true;TrustServerCertificate=true", + sql => sql.MigrationsAssembly(typeof(Elsa.EntityFrameworkCore.SqlServer.Migrations.Management.AddCacheMutationFences).Assembly.FullName))); + using var host = services.BuildServiceProvider(); + using var context = host.GetRequiredService>().CreateDbContext(); + var migrator = ((Microsoft.EntityFrameworkCore.Infrastructure.IInfrastructure)context.Database).Instance.GetRequiredService(); + var script = migrator.GenerateScript("20250818170340_AddExternalUserId", "20261007000000_AddCacheMutationFences"); + Assert.Contains("CREATE TABLE [Elsa].[CacheMutations]", script); + Assert.Contains("[Scope] nvarchar(32)", script); + Assert.Contains("IDENTITY", script); + } + + [RedisFact] + public async Task Nodes_share_payload_and_cached_null_is_invalidated() + { + var path = Path.Combine(Path.GetTempPath(), "elsa-cache-" + Guid.NewGuid().ToString("N") + ".db"); + var prefix = "workflow-tests:" + Guid.NewGuid().ToString("N") + ":"; + using var first = CacheTestHost.Create(path, true, prefix); + using var second = CacheTestHost.Create(path, true, prefix); + await CacheTestHost.EnsureCreatedAsync(first); + var a = first.GetRequiredService(); + var b = second.GetRequiredService(); + Assert.Equal("node-one", await a.GetOrCreateAsync("definition", () => Task.FromResult("node-one"), Lifetime)); + Assert.Equal("node-one", await b.GetOrCreateAsync("definition", () => throw new Exception("Cache miss on second node"), Lifetime)); + Assert.Null(await a.GetOrCreateAsync("missing", () => Task.FromResult(null), Lifetime)); + Assert.Null(await b.GetOrCreateAsync("missing", () => throw new Exception("Cached null was lost"), Lifetime)); + var mutation = await a.BeginMutationAsync(); + await a.CompleteMutationAsync(mutation); + Assert.Equal("created", await b.GetOrCreateAsync("missing", () => Task.FromResult("created"), Lifetime)); + } + + [RedisFact] + public async Task Unfinished_durable_fence_bypasses_cache_after_node_restart() + { + var path = Path.Combine(Path.GetTempPath(), "elsa-cache-" + Guid.NewGuid().ToString("N") + ".db"); + var prefix = "workflow-tests:" + Guid.NewGuid().ToString("N") + ":"; + using (var first = CacheTestHost.Create(path, true, prefix)) + { + await CacheTestHost.EnsureCreatedAsync(first); + var cache = first.GetRequiredService(); + await cache.GetOrCreateAsync("definition", () => Task.FromResult("old"), Lifetime); + await cache.BeginMutationAsync(); // Simulates process death before completion. + } + using var restarted = CacheTestHost.Create(path, true, prefix); + var fresh = restarted.GetRequiredService(); + Assert.Equal("sql-current", await fresh.GetOrCreateAsync("definition", () => Task.FromResult("sql-current"), Lifetime)); + Assert.Equal("sql-next", await fresh.GetOrCreateAsync("definition", () => Task.FromResult("sql-next"), Lifetime)); + Assert.True((await fresh.GetConsistencyStateAsync()).HasPendingMutations); + } + + [RedisFact] + public async Task Redis_roundtrip_preserves_definition_and_polymorphic_trigger_payload() + { + var path = Path.Combine(Path.GetTempPath(), "elsa-cache-" + Guid.NewGuid().ToString("N") + ".db"); + using var host = CacheTestHost.Create(path, true); + await CacheTestHost.EnsureCreatedAsync(host); + var cache = host.GetRequiredService(); + var definition = new WorkflowDefinition { Id = "v1", DefinitionId = "wf", MaterializerName = "Json", StringData = "{}", Version = 1 }; + definition.Variables.Add(new Elsa.Workflows.Memory.Variable("message", "hello")); + definition.CustomProperties["payload"] = new Elsa.Http.Bookmarks.HttpEndpointBookmarkPayload("/custom", "GET"); + await cache.GetOrCreateAsync("definition", () => Task.FromResult(definition), Lifetime); + var roundtrip = await cache.GetOrCreateAsync("definition", () => throw new Exception("Miss"), Lifetime); + Assert.Equal("v1", roundtrip!.Id); + Assert.Equal("hello", Assert.Single(roundtrip.Variables).Value); + Assert.IsType(roundtrip.CustomProperties["payload"]); + var trigger = new StoredTrigger { Id = "t", WorkflowDefinitionId = "wf", WorkflowDefinitionVersionId = "v1", ActivityId = "a", Payload = new Elsa.Http.Bookmarks.HttpEndpointBookmarkPayload("/orders", "POST") }; + var page = new Page(new[] { trigger }, 1); + await cache.GetOrCreateAsync("triggers", () => Task.FromResult(page), Lifetime); + var result = await cache.GetOrCreateAsync>("triggers", () => throw new Exception("Miss"), Lifetime); + var payload = Assert.IsType(Assert.Single(result!.Items).Payload); + Assert.Equal("/orders", payload.Path); + } + + [RedisFact] + public async Task Disconnected_client_bypasses_cache_and_cannot_revive_old_data_on_reconnect() + { + var path = Path.Combine(Path.GetTempPath(), "elsa-cache-" + Guid.NewGuid().ToString("N") + ".db"); + using var host = CacheTestHost.Create(path, true); + await CacheTestHost.EnsureCreatedAsync(host); + var cache = host.GetRequiredService(); + var settings = host.GetRequiredService(); + var connected = host.GetRequiredService(); + using var disconnected = StackExchange.Redis.ConnectionMultiplexer.Connect("127.0.0.1:16380,abortConnect=false,connectTimeout=100,syncTimeout=100,asyncTimeout=100,connectRetry=0"); + await cache.GetOrCreateAsync("definition", () => Task.FromResult("old"), Lifetime); + settings.ConnectionFactory = _ => disconnected; + var mutation = await cache.BeginMutationAsync(); + await cache.CompleteMutationAsync(mutation); + Assert.Equal("sql-current", await cache.GetOrCreateAsync("definition", () => Task.FromResult("sql-current"), Lifetime)); + settings.ConnectionFactory = _ => connected; + Assert.Equal("sql-current", await cache.GetOrCreateAsync("definition", () => Task.FromResult("sql-current"), Lifetime)); + } + + [RedisFact] + public async Task Route_only_mutation_keeps_definition_payload_valid() + { + var path = Path.Combine(Path.GetTempPath(), "elsa-cache-" + Guid.NewGuid().ToString("N") + ".db"); + using var host = CacheTestHost.Create(path, true); + await CacheTestHost.EnsureCreatedAsync(host); + var cache = host.GetRequiredService(); + await cache.GetOrCreateAsync("definition", () => Task.FromResult("unchanged"), Lifetime); + var before = await cache.GetConsistencyStateAsync(); + var mutation = await cache.BeginRouteMutationAsync(); + await cache.CompleteMutationAsync(mutation); + var after = await cache.GetConsistencyStateAsync(); + Assert.Equal(before.Revision, after.Revision); + Assert.NotEqual(before.RouteRevision, after.RouteRevision); + Assert.Equal("unchanged", await cache.GetOrCreateAsync("definition", () => throw new Exception("Bookmark changes invalidated the definition"), Lifetime)); + } + + [RedisFact] + public async Task Actual_management_features_share_saved_definitions_and_refresh_after_update() + { + var path = Path.Combine(Path.GetTempPath(), "elsa-module-" + Guid.NewGuid().ToString("N") + ".db"); + var prefix = "workflow-module-tests:" + Guid.NewGuid().ToString("N") + ":"; + using var first = CreateManagementHost(path, prefix); + using var second = CreateManagementHost(path, prefix); + await CacheTestHost.EnsureCreatedAsync(first); + using var firstScope = first.CreateScope(); + using var secondScope = second.CreateScope(); + var writer = firstScope.ServiceProvider.GetRequiredService(); + var reader = secondScope.ServiceProvider.GetRequiredService(); + var definition = new WorkflowDefinition { Id = "version", DefinitionId = "definition", Version = 1, Name = "original", MaterializerName = "Json", StringData = "{}" }; + await writer.SaveAsync(definition); + var filter = new Elsa.Workflows.Management.Filters.WorkflowDefinitionFilter { Id = "version" }; + Assert.Equal("original", (await reader.FindAsync(filter, new Elsa.Workflows.Management.Filters.WorkflowDefinitionOrder(x => x.Id, Elsa.Common.Entities.OrderDirection.Ascending)))!.Name); + Assert.Equal("original", (await reader.FindAsync(filter, new Elsa.Workflows.Management.Filters.WorkflowDefinitionOrder(x => x.Id, Elsa.Common.Entities.OrderDirection.Ascending)))!.Name); + definition.Name = "updated"; + await writer.SaveAsync(definition); + Assert.Equal("updated", (await reader.FindAsync(filter, new Elsa.Workflows.Management.Filters.WorkflowDefinitionOrder(x => x.Id, Elsa.Common.Entities.OrderDirection.Ascending)))!.Name); + Assert.True(second.GetRequiredService().IsShared); + Assert.NotNull(second.GetRequiredService()); + } + + private static ServiceProvider CreateManagementHost(string path, string prefix) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(_ => StackExchange.Redis.ConnectionMultiplexer.Connect(Environment.GetEnvironmentVariable("REDIS_TEST_CONNECTION")!)); + var module = new Elsa.Features.Implementations.Module(services); + module.UseCaching(cache => cache.UseRedis(redis => { redis.Database = 1; redis.KeyPrefix = prefix; })); + module.UseWorkflowManagement(management => + { + Elsa.EntityFrameworkCore.Modules.Management.WorkflowManagementFeatureExtensions.UseEntityFrameworkCore(management, ef => + { + ef.RunMigrations = false; + ef.DbContextOptionsBuilder = (_, options) => options.UseSqlite($"Data Source={path}"); + }); + management.UseCache(); + }); + module.Apply(); + return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Ambient_transaction_bypasses_cache_and_keeps_fence_until_commit_or_rollback(bool commit) + { + using var host = CacheTestHost.Create(); + var cache = host.GetRequiredService(); + await cache.GetOrCreateAsync("definition", () => Task.FromResult("before"), Lifetime); + using (var transaction = new System.Transactions.TransactionScope(System.Transactions.TransactionScopeAsyncFlowOption.Enabled)) + { + var mutation = await cache.BeginMutationAsync(); + await cache.CompleteMutationAsync(mutation); + Assert.True((await cache.GetConsistencyStateAsync()).HasPendingMutations); + Assert.Equal("transaction-view", await cache.GetOrCreateAsync("definition", () => Task.FromResult("transaction-view"), Lifetime)); + if (commit) transaction.Complete(); + } + Assert.False((await cache.GetConsistencyStateAsync()).HasPendingMutations); + Assert.Equal("after", await cache.GetOrCreateAsync("definition", () => Task.FromResult("after"), Lifetime)); + } + + [RedisFact] + public async Task Completion_invalidates_a_cached_revision_even_if_begin_committed_out_of_identity_order() + { + var path = Path.Combine(Path.GetTempPath(), "elsa-late-fence-" + Guid.NewGuid().ToString("N") + ".db"); + using var host = CacheTestHost.Create(path, true); + await CacheTestHost.EnsureCreatedAsync(host); + using var scope = host.CreateScope(); + await using var db = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + db.CacheMutations.Add(new CacheMutation { Id = 100, IsCompleted = true }); + await db.SaveChangesAsync(); + var cache = host.GetRequiredService(); + await cache.GetOrCreateAsync("definition", () => Task.FromResult("old"), Lifetime); + // Simulate an earlier allocated identity becoming visible after identity 100 was cached. + var late = new CacheMutation { Id = 50 }; + db.CacheMutations.Add(late); + await db.SaveChangesAsync(); + await cache.CompleteMutationAsync(late.MutationId); + Assert.NotEqual("100", (await cache.GetConsistencyStateAsync()).Revision); + Assert.Equal("updated", await cache.GetOrCreateAsync("definition", () => Task.FromResult("updated"), Lifetime)); + } + + [RedisFact] + public async Task Pruning_on_one_node_cannot_delete_a_newer_revision_completed_by_another_node() + { + var path = Path.Combine(Path.GetTempPath(), "elsa-pruning-" + Guid.NewGuid().ToString("N") + ".db"); + var prefix = "workflow-prune-tests:" + Guid.NewGuid().ToString("N") + ":"; + var pause = new PauseFirstPruneInterceptor(); + using var first = CacheTestHost.Create(path, true, prefix, interceptor: pause); + using var second = CacheTestHost.Create(path, true, prefix); + await CacheTestHost.EnsureCreatedAsync(first); + var a = first.GetRequiredService(); + var b = second.GetRequiredService(); + var firstMutation = await a.BeginMutationAsync(); + var completing = a.CompleteMutationAsync(firstMutation); + await pause.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + string expected; + try + { + var secondMutation = await b.BeginMutationAsync(); + await b.CompleteMutationAsync(secondMutation); + expected = (await b.GetConsistencyStateAsync()).Revision; + } + finally { pause.Release.SetResult(); } + await completing; + Assert.Equal(expected, (await a.GetConsistencyStateAsync()).Revision); + } + + private sealed class PauseFirstPruneInterceptor : Microsoft.EntityFrameworkCore.Diagnostics.DbCommandInterceptor + { + private int _paused; + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public override async ValueTask> NonQueryExecutingAsync( + System.Data.Common.DbCommand command, Microsoft.EntityFrameworkCore.Diagnostics.CommandEventData eventData, + Microsoft.EntityFrameworkCore.Diagnostics.InterceptionResult result, CancellationToken cancellationToken = default) + { + if (command.CommandText.StartsWith("DELETE", StringComparison.OrdinalIgnoreCase) + && command.CommandText.Contains("CacheMutations") && Interlocked.Exchange(ref _paused, 1) == 0) + { + Entered.SetResult(); + await Release.Task.WaitAsync(cancellationToken); + } + return result; + } + } + + [RedisFact] + public async Task Disabled_redis_payload_cache_keeps_shared_coordination_active() + { + var path = Path.Combine(Path.GetTempPath(), "elsa-disabled-" + Guid.NewGuid().ToString("N") + ".db"); + using var host = CacheTestHost.Create(path, true, configure: cache => cache.Enabled = false); + await CacheTestHost.EnsureCreatedAsync(host); + var cache = host.GetRequiredService(); + Assert.True(cache.IsShared); + Assert.False(cache.IsEnabled); + var calls = 0; + await cache.GetOrCreateAsync("definition", () => Task.FromResult(++calls), Lifetime); + await cache.GetOrCreateAsync("definition", () => Task.FromResult(++calls), Lifetime); + Assert.Equal(2, calls); + var mutation = await cache.BeginMutationAsync(); + Assert.True((await cache.GetConsistencyStateAsync()).HasPendingMutations); + await cache.CompleteMutationAsync(mutation); + Assert.False((await cache.GetConsistencyStateAsync()).HasPendingMutations); + } + + [RedisFact] + public async Task Sliding_redis_lifetime_never_extends_the_absolute_deadline() + { + using var host = CacheTestHost.Create(redis: true); + var provider = host.GetRequiredService(); + var options = new CacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(1500), SlidingExpiration = TimeSpan.FromMilliseconds(700) }; + await provider.SetAsync("lifetime", "payload", options); + await Task.Delay(400); + Assert.True((await provider.GetAsync("lifetime")).Found); + await Task.Delay(400); + Assert.True((await provider.GetAsync("lifetime")).Found); + await Task.Delay(800); + Assert.False((await provider.GetAsync("lifetime")).Found); + } +} diff --git a/test/unit/Elsa.Caching.UnitTests/CacheTestHost.cs b/test/unit/Elsa.Caching.UnitTests/CacheTestHost.cs new file mode 100644 index 0000000..60f1243 --- /dev/null +++ b/test/unit/Elsa.Caching.UnitTests/CacheTestHost.cs @@ -0,0 +1,53 @@ +using Elsa.Caching; +using Elsa.Caching.Features; +using Elsa.EntityFrameworkCore.Modules.Management; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Services; +using Elsa.Extensions; +using Elsa.Features.Implementations; +using Elsa.Workflows; +using Elsa.Workflows.Serialization.Serializers; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +namespace Elsa.Caching.UnitTests; + +public static class CacheTestHost +{ + public static ServiceProvider Create(string? databasePath = null, bool redis = false, string? prefix = null, + Action? configure = null, Microsoft.EntityFrameworkCore.Diagnostics.DbCommandInterceptor? interceptor = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(WellKnownTypeRegistry.CreateDefault()); + services.AddSingleton(); + if (databasePath != null) + services.AddDbContextFactory(o => + { + o.UseSqlite($"Data Source={databasePath}"); + if (interceptor != null) o.AddInterceptors(interceptor); + }); + if (redis) + services.AddSingleton(_ => ConnectionMultiplexer.Connect( + Environment.GetEnvironmentVariable("REDIS_TEST_CONNECTION") ?? "127.0.0.1:16379,abortConnect=false,connectTimeout=1000,syncTimeout=1000")); + var module = new Module(services); + module.UseCaching(cache => + { + cache.RegisterRedis(); + if (databasePath != null) + cache.ConsistencyStore = sp => ActivatorUtilities.CreateInstance(sp); + if (redis) cache.UseRedis(o => { o.Database = 1; o.KeyPrefix = prefix ?? "workflow-tests:" + Guid.NewGuid().ToString("N") + ":"; }); + configure?.Invoke(cache); + }); + module.Apply(); + return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + } + + public static async Task EnsureCreatedAsync(IServiceProvider services) + { + using var scope = services.CreateScope(); + await using var db = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + await db.Database.EnsureCreatedAsync(); + } +} diff --git a/test/unit/Elsa.Caching.UnitTests/Elsa.Caching.UnitTests.csproj b/test/unit/Elsa.Caching.UnitTests/Elsa.Caching.UnitTests.csproj new file mode 100644 index 0000000..c627f35 --- /dev/null +++ b/test/unit/Elsa.Caching.UnitTests/Elsa.Caching.UnitTests.csproj @@ -0,0 +1,9 @@ + + + + + + + + +