From 481c1aaa817f0c462adad617e2ffe718ec1e6907 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 20 May 2026 13:51:43 +0200 Subject: [PATCH] Guard persistence health check store probes --- .../ElsaWorkflowPersistenceHealthCheck.cs | 58 +++++++++++++------ ...ElsaWorkflowPersistenceHealthCheckTests.cs | 22 ++++++- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaWorkflowPersistenceHealthCheck.cs b/src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaWorkflowPersistenceHealthCheck.cs index 0a735c9b5..6d0b1a52e 100644 --- a/src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaWorkflowPersistenceHealthCheck.cs +++ b/src/modules/Elsa.Workflows.Runtime/HealthChecks/ElsaWorkflowPersistenceHealthCheck.cs @@ -2,6 +2,7 @@ using Elsa.Common; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Runtime.Filters; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Elsa.Workflows.Runtime.HealthChecks; @@ -9,11 +10,7 @@ namespace Elsa.Workflows.Runtime.HealthChecks; /// /// Performs small read-only probes against the workflow management and runtime stores. /// -public class ElsaWorkflowPersistenceHealthCheck( - IWorkflowDefinitionStore workflowDefinitionStore, - IWorkflowInstanceStore workflowInstanceStore, - ITriggerStore triggerStore, - IBookmarkQueueStore bookmarkQueueStore) : IHealthCheck +public class ElsaWorkflowPersistenceHealthCheck(IServiceProvider serviceProvider) : IHealthCheck { private const string ProbeId = "__elsa_health_check_probe__"; @@ -21,33 +18,58 @@ public class ElsaWorkflowPersistenceHealthCheck( public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { var failedStore = ""; + var probes = new List(); + var skippedProbes = new List(); try { - await ProbeAsync("workflow-definitions", async ct => await workflowDefinitionStore.FindAsync(new WorkflowDefinitionFilter { Id = ProbeId }, ct)); - await ProbeAsync("workflow-instances", async ct => await workflowInstanceStore.CountAsync(new WorkflowInstanceFilter { Id = ProbeId }, ct)); - await ProbeAsync("triggers", async ct => await triggerStore.FindAsync(new TriggerFilter { Id = ProbeId }, ct)); - await ProbeAsync("bookmark-queue", async ct => await bookmarkQueueStore.FindAsync(new BookmarkQueueFilter { Id = ProbeId }, ct)); + 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)); - return HealthCheckResult.Healthy("Elsa workflow stores are reachable.", new Dictionary - { - ["category"] = "persistence", - ["probes"] = "workflow-definitions,workflow-instances,triggers,bookmark-queue" - }); + var data = CreateData(); + return probes.Count == 0 + ? HealthCheckResult.Degraded("No Elsa workflow persistence stores are registered.", data: data) + : HealthCheckResult.Healthy("Elsa workflow stores are reachable.", data); } catch (Exception e) when (!e.IsFatal()) { return HealthCheckResult.Unhealthy($"Elsa workflow store '{failedStore}' is not reachable.", e, new Dictionary { ["category"] = "persistence", - ["failedStore"] = failedStore + ["failedStore"] = failedStore, + ["failedProbe"] = failedStore }); } - async Task ProbeAsync(string store, Func probe) + async Task ProbeAsync(string storeName, TStore? store, Func probe) where TStore : class { - failedStore = store; - await probe(cancellationToken); + if (store == null) + { + skippedProbes.Add(storeName); + return; + } + + failedStore = storeName; + probes.Add(storeName); + await probe(store, cancellationToken); + } + + Dictionary CreateData() + { + var data = new Dictionary + { + ["category"] = "persistence" + }; + + if (probes.Count > 0) + data["probes"] = string.Join(",", probes); + + if (skippedProbes.Count > 0) + data["skippedProbes"] = string.Join(",", skippedProbes); + + return data; } } } diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/HealthChecks/ElsaWorkflowPersistenceHealthCheckTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/HealthChecks/ElsaWorkflowPersistenceHealthCheckTests.cs index 893bb3c5a..705bb7f5b 100644 --- a/test/unit/Elsa.Workflows.Runtime.UnitTests/HealthChecks/ElsaWorkflowPersistenceHealthCheckTests.cs +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/HealthChecks/ElsaWorkflowPersistenceHealthCheckTests.cs @@ -9,6 +9,7 @@ namespace Elsa.Workflows.Runtime.UnitTests.HealthChecks; public class ElsaWorkflowPersistenceHealthCheckTests { + private readonly IServiceProvider _serviceProvider = Substitute.For(); private readonly IWorkflowDefinitionStore _workflowDefinitionStore = Substitute.For(); private readonly IWorkflowInstanceStore _workflowInstanceStore = Substitute.For(); private readonly ITriggerStore _triggerStore = Substitute.For(); @@ -21,7 +22,11 @@ public class ElsaWorkflowPersistenceHealthCheckTests _workflowInstanceStore.CountAsync(Arg.Any(), Arg.Any()).Returns(new ValueTask(0)); _triggerStore.FindAsync(Arg.Any(), Arg.Any()).Returns(new ValueTask((Elsa.Workflows.Runtime.Entities.StoredTrigger?)null)); _bookmarkQueueStore.FindAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(null)); - _sut = new ElsaWorkflowPersistenceHealthCheck(_workflowDefinitionStore, _workflowInstanceStore, _triggerStore, _bookmarkQueueStore); + _serviceProvider.GetService(typeof(IWorkflowDefinitionStore)).Returns(_workflowDefinitionStore); + _serviceProvider.GetService(typeof(IWorkflowInstanceStore)).Returns(_workflowInstanceStore); + _serviceProvider.GetService(typeof(ITriggerStore)).Returns(_triggerStore); + _serviceProvider.GetService(typeof(IBookmarkQueueStore)).Returns(_bookmarkQueueStore); + _sut = new ElsaWorkflowPersistenceHealthCheck(_serviceProvider); } [Fact] @@ -44,5 +49,20 @@ public class ElsaWorkflowPersistenceHealthCheckTests Assert.Equal("Elsa workflow store 'triggers' is not reachable.", result.Description); Assert.Equal("persistence", result.Data["category"]); Assert.Equal("triggers", result.Data["failedStore"]); + Assert.Equal("triggers", result.Data["failedProbe"]); + } + + [Fact] + public async Task ReturnsHealthyWithSkippedProbesWhenOptionalManagementStoresAreMissing() + { + _serviceProvider.GetService(typeof(IWorkflowDefinitionStore)).Returns((object?)null); + _serviceProvider.GetService(typeof(IWorkflowInstanceStore)).Returns((object?)null); + + var result = await _sut.CheckHealthAsync(new HealthCheckContext()); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal("persistence", result.Data["category"]); + Assert.Equal("triggers,bookmark-queue", result.Data["probes"]); + Assert.Equal("workflow-definitions,workflow-instances", result.Data["skippedProbes"]); } }