Guard persistence health check store probes

This commit is contained in:
Sipke Schoorstra 2026-05-20 13:51:43 +02:00
parent a8390d8d64
commit 481c1aaa81
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
2 changed files with 61 additions and 19 deletions

View file

@ -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;
/// <summary>
/// Performs small read-only probes against the workflow management and runtime stores.
/// </summary>
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<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
var failedStore = "";
var probes = new List<string>();
var skippedProbes = new List<string>();
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<IWorkflowDefinitionStore>(), async (store, ct) => await store.FindAsync(new WorkflowDefinitionFilter { Id = ProbeId }, ct));
await ProbeAsync("workflow-instances", serviceProvider.GetService<IWorkflowInstanceStore>(), async (store, ct) => await store.CountAsync(new WorkflowInstanceFilter { Id = ProbeId }, ct));
await ProbeAsync("triggers", serviceProvider.GetService<ITriggerStore>(), async (store, ct) => await store.FindAsync(new TriggerFilter { Id = ProbeId }, ct));
await ProbeAsync("bookmark-queue", serviceProvider.GetService<IBookmarkQueueStore>(), async (store, ct) => await store.FindAsync(new BookmarkQueueFilter { Id = ProbeId }, ct));
return HealthCheckResult.Healthy("Elsa workflow stores are reachable.", new Dictionary<string, object>
{
["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<string, object>
{
["category"] = "persistence",
["failedStore"] = failedStore
["failedStore"] = failedStore,
["failedProbe"] = failedStore
});
}
async Task ProbeAsync(string store, Func<CancellationToken, Task> probe)
async Task ProbeAsync<TStore>(string storeName, TStore? store, Func<TStore, CancellationToken, Task> 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<string, object> CreateData()
{
var data = new Dictionary<string, object>
{
["category"] = "persistence"
};
if (probes.Count > 0)
data["probes"] = string.Join(",", probes);
if (skippedProbes.Count > 0)
data["skippedProbes"] = string.Join(",", skippedProbes);
return data;
}
}
}

View file

@ -9,6 +9,7 @@ namespace Elsa.Workflows.Runtime.UnitTests.HealthChecks;
public class ElsaWorkflowPersistenceHealthCheckTests
{
private readonly IServiceProvider _serviceProvider = Substitute.For<IServiceProvider>();
private readonly IWorkflowDefinitionStore _workflowDefinitionStore = Substitute.For<IWorkflowDefinitionStore>();
private readonly IWorkflowInstanceStore _workflowInstanceStore = Substitute.For<IWorkflowInstanceStore>();
private readonly ITriggerStore _triggerStore = Substitute.For<ITriggerStore>();
@ -21,7 +22,11 @@ public class ElsaWorkflowPersistenceHealthCheckTests
_workflowInstanceStore.CountAsync(Arg.Any<WorkflowInstanceFilter>(), Arg.Any<CancellationToken>()).Returns(new ValueTask<long>(0));
_triggerStore.FindAsync(Arg.Any<TriggerFilter>(), Arg.Any<CancellationToken>()).Returns(new ValueTask<Elsa.Workflows.Runtime.Entities.StoredTrigger?>((Elsa.Workflows.Runtime.Entities.StoredTrigger?)null));
_bookmarkQueueStore.FindAsync(Arg.Any<BookmarkQueueFilter>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult<Elsa.Workflows.Runtime.Entities.BookmarkQueueItem?>(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"]);
}
}