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); } }