From a0d6f6b24b55010edf14b2a7903334fc68a33a82 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 21 May 2026 01:16:36 +0200 Subject: [PATCH] Address health check review feedback --- src/apps/Elsa.Server.Web/Program.cs | 6 +- .../ElsaWorkflowPersistenceHealthCheck.cs | 12 ++-- ...ElsaWorkflowPersistenceHealthCheckTests.cs | 59 +++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index 3990bf81d..dddf8ea85 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -186,7 +186,11 @@ app.MapHealthChecks("/health/ready", new() }); app.MapHealthChecks("/", new() { - Predicate = _ => false + ResultStatusCodes = + { + [HealthStatus.Degraded] = StatusCodes.Status503ServiceUnavailable, + [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable + } }); // Routing used for SignalR. diff --git a/src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaWorkflowPersistenceHealthCheck.cs b/src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaWorkflowPersistenceHealthCheck.cs index 55d5478fb..3c6dca7fb 100644 --- a/src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaWorkflowPersistenceHealthCheck.cs +++ b/src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaWorkflowPersistenceHealthCheck.cs @@ -18,11 +18,13 @@ public class ElsaWorkflowPersistenceHealthCheck(IServiceProvider serviceProvider /// public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { - var probeResults = await Task.WhenAll( - ProbeAsync("workflow-definitions", serviceProvider.GetService(), async (store, ct) => await store.FindAsync(new WorkflowDefinitionFilter { Id = ProbeId }, ct)), - ProbeAsync("workflow-instances", serviceProvider.GetService(), async (store, ct) => await store.CountAsync(new WorkflowInstanceFilter { Id = ProbeId }, ct)), - ProbeAsync("triggers", serviceProvider.GetService(), async (store, ct) => await store.FindAsync(new TriggerFilter { Id = ProbeId }, ct)), - ProbeAsync("bookmark-queue", serviceProvider.GetService(), async (store, ct) => await store.FindAsync(new BookmarkQueueFilter { Id = ProbeId }, ct))); + var probeResults = new List + { + await ProbeAsync("workflow-definitions", serviceProvider.GetService(), async (store, ct) => await store.FindAsync(new WorkflowDefinitionFilter { Id = ProbeId }, ct)), + await ProbeAsync("workflow-instances", serviceProvider.GetService(), async (store, ct) => await store.CountAsync(new WorkflowInstanceFilter { Id = ProbeId }, ct)), + await ProbeAsync("triggers", serviceProvider.GetService(), async (store, ct) => await store.FindAsync(new TriggerFilter { Id = ProbeId }, ct)), + await ProbeAsync("bookmark-queue", serviceProvider.GetService(), async (store, ct) => await store.FindAsync(new BookmarkQueueFilter { Id = ProbeId }, ct)) + }; var attemptedProbes = probeResults.Where(x => !x.Skipped).Select(x => x.StoreName).ToList(); var successfulProbes = probeResults.Where(x => !x.Skipped && x.Exception == null).Select(x => x.StoreName).ToList(); diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/HealthChecks/ElsaWorkflowPersistenceHealthCheckTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/HealthChecks/ElsaWorkflowPersistenceHealthCheckTests.cs index 02fcd8f11..26c37cc95 100644 --- a/test/unit/Elsa.Workflows.Runtime.UnitTests/HealthChecks/ElsaWorkflowPersistenceHealthCheckTests.cs +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/HealthChecks/ElsaWorkflowPersistenceHealthCheckTests.cs @@ -44,6 +44,25 @@ public class ElsaWorkflowPersistenceHealthCheckTests Arg.Any()); } + [Fact] + public async Task ProbesStoresSequentially() + { + var tracker = new ProbeConcurrencyTracker(); + _workflowDefinitionStore.FindAsync(Arg.Any(), Arg.Any()) + .Returns(_ => TrackProbeAsync(tracker, null)); + _workflowInstanceStore.CountAsync(Arg.Any(), Arg.Any()) + .Returns(_ => new ValueTask(TrackProbeAsync(tracker, 0L))); + _triggerStore.FindAsync(Arg.Any(), Arg.Any()) + .Returns(_ => new ValueTask(TrackProbeAsync(tracker, null))); + _bookmarkQueueStore.FindAsync(Arg.Any(), Arg.Any()) + .Returns(_ => TrackProbeAsync(tracker, null)); + + var result = await _sut.CheckHealthAsync(new HealthCheckContext()); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(1, tracker.MaxConcurrentProbes); + } + [Fact] public async Task ReturnsUnhealthyWithFailedStoreWhenAStoreProbeFails() { @@ -92,4 +111,44 @@ public class ElsaWorkflowPersistenceHealthCheckTests Assert.Equal("workflow-definitions,workflow-instances,triggers,bookmark-queue", result.Data["skippedProbes"]); Assert.False(result.Data.ContainsKey("probes")); } + + private static async Task TrackProbeAsync(ProbeConcurrencyTracker tracker, T result) + { + tracker.Enter(); + + try + { + await Task.Delay(10); + return result; + } + finally + { + tracker.Exit(); + } + } + + private sealed class ProbeConcurrencyTracker + { + private int _currentProbes; + private int _maxConcurrentProbes; + + public int MaxConcurrentProbes => Volatile.Read(ref _maxConcurrentProbes); + + public void Enter() + { + var currentProbes = Interlocked.Increment(ref _currentProbes); + + while (true) + { + var maxConcurrentProbes = MaxConcurrentProbes; + if (currentProbes <= maxConcurrentProbes) + return; + + if (Interlocked.CompareExchange(ref _maxConcurrentProbes, currentProbes, maxConcurrentProbes) == maxConcurrentProbes) + return; + } + } + + public void Exit() => Interlocked.Decrement(ref _currentProbes); + } }