| | | 1 | | using Elsa.Common; |
| | | 2 | | using Elsa.Workflows.Management; |
| | | 3 | | using Elsa.Workflows.Management.Filters; |
| | | 4 | | using Elsa.Workflows.Runtime.Filters; |
| | | 5 | | using Elsa.Workflows.Runtime.Options; |
| | | 6 | | using Microsoft.Extensions.DependencyInjection; |
| | | 7 | | using Microsoft.Extensions.Diagnostics.HealthChecks; |
| | | 8 | | using Microsoft.Extensions.Logging; |
| | | 9 | | using Microsoft.Extensions.Options; |
| | | 10 | | |
| | | 11 | | namespace Elsa.Workflows.Runtime.HealthChecks; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Performs small read-only probes against the workflow management and runtime stores. |
| | | 15 | | /// </summary> |
| | 7 | 16 | | public class ElsaWorkflowPersistenceHealthCheck( |
| | 7 | 17 | | IServiceProvider serviceProvider, |
| | 7 | 18 | | IOptions<ElsaReadinessHealthCheckOptions> options, |
| | 7 | 19 | | ILogger<ElsaWorkflowPersistenceHealthCheck> logger) : IHealthCheck |
| | | 20 | | { |
| | | 21 | | private const string ProbeId = "00000000-0000-0000-0000-000000000000"; |
| | | 22 | | |
| | | 23 | | /// <inheritdoc /> |
| | | 24 | | public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToke |
| | | 25 | | { |
| | | 26 | | // These probes verify store reachability only; returned entities and counts are intentionally ignored. |
| | 6 | 27 | | var probeResults = new List<ProbeResult>(); |
| | | 28 | | |
| | 6 | 29 | | if (!await AddProbeAsync("workflow-definitions", serviceProvider.GetService<IWorkflowDefinitionStore>(), async ( |
| | 6 | 30 | | { |
| | 4 | 31 | | await store.FindAsync(new WorkflowDefinitionFilter { Id = ProbeId }, ct); |
| | 10 | 32 | | })) |
| | 0 | 33 | | return CreateResult(); |
| | | 34 | | |
| | 6 | 35 | | if (!await AddProbeAsync("workflow-instances", serviceProvider.GetService<IWorkflowInstanceStore>(), async (stor |
| | 6 | 36 | | { |
| | 4 | 37 | | await store.CountAsync(new WorkflowInstanceFilter { Id = ProbeId }, ct); |
| | 10 | 38 | | })) |
| | 0 | 39 | | return CreateResult(); |
| | | 40 | | |
| | 6 | 41 | | if (!await AddProbeAsync("triggers", serviceProvider.GetService<ITriggerStore>(), async (store, ct) => |
| | 6 | 42 | | { |
| | 5 | 43 | | await store.FindAsync(new TriggerFilter { Id = ProbeId }, ct); |
| | 9 | 44 | | })) |
| | 1 | 45 | | return CreateResult(); |
| | | 46 | | |
| | 5 | 47 | | if (!await AddProbeAsync("bookmark-queue", serviceProvider.GetService<IBookmarkQueueStore>(), async (store, ct) |
| | 5 | 48 | | { |
| | 4 | 49 | | await store.FindAsync(new BookmarkQueueFilter { Id = ProbeId }, ct); |
| | 9 | 50 | | })) |
| | 5 | 51 | | return CreateResult(); |
| | | 52 | | |
| | | 53 | | return CreateResult(); |
| | | 54 | | |
| | | 55 | | async Task<bool> AddProbeAsync<TStore>(string storeName, TStore? store, Func<TStore, CancellationToken, Task> pr |
| | | 56 | | { |
| | 23 | 57 | | var result = await ProbeAsync(storeName, store, probe); |
| | 23 | 58 | | probeResults.Add(result); |
| | 23 | 59 | | return result.Exception == null || options.Value.ContinuePersistenceProbesAfterFailure; |
| | 23 | 60 | | } |
| | | 61 | | |
| | | 62 | | HealthCheckResult CreateResult() |
| | | 63 | | { |
| | 46 | 64 | | var attemptedProbes = probeResults.Where(x => !x.Skipped).Select(x => x.StoreName).ToList(); |
| | 44 | 65 | | var successfulProbes = probeResults.Where(x => !x.Skipped && x.Exception == null).Select(x => x.StoreName).T |
| | 35 | 66 | | var skippedProbes = probeResults.Where(x => x.Skipped).Select(x => x.StoreName).ToList(); |
| | 31 | 67 | | var failedProbes = probeResults.Where(x => x.Exception != null).Select(x => x.StoreName).ToList(); |
| | 28 | 68 | | var failedProbe = probeResults.FirstOrDefault(x => x.Exception != null); |
| | 6 | 69 | | if (failedProbe != null) |
| | | 70 | | { |
| | 2 | 71 | | var data = CreateData(); |
| | 2 | 72 | | data["failedStore"] = failedProbe.StoreName; |
| | 2 | 73 | | data["failedProbe"] = failedProbe.StoreName; |
| | | 74 | | |
| | 2 | 75 | | logger.LogWarning(failedProbe.Exception, "Elsa workflow store {StoreName} is not reachable.", failedProb |
| | 2 | 76 | | return HealthCheckResult.Unhealthy($"Elsa workflow store '{failedProbe.StoreName}' is not reachable.", d |
| | | 77 | | } |
| | | 78 | | |
| | 4 | 79 | | var healthyData = CreateData(); |
| | 4 | 80 | | return attemptedProbes.Count == 0 |
| | 4 | 81 | | ? HealthCheckResult.Degraded("No Elsa workflow persistence stores are registered.", data: healthyData) |
| | 4 | 82 | | : HealthCheckResult.Healthy("Elsa workflow stores are reachable.", healthyData); |
| | | 83 | | |
| | | 84 | | Dictionary<string, object> CreateData() |
| | | 85 | | { |
| | 6 | 86 | | var data = new Dictionary<string, object> |
| | 6 | 87 | | { |
| | 6 | 88 | | ["category"] = "persistence" |
| | 6 | 89 | | }; |
| | | 90 | | |
| | 6 | 91 | | if (successfulProbes.Count > 0) |
| | 5 | 92 | | data["successfulProbes"] = string.Join(",", successfulProbes); |
| | | 93 | | |
| | 6 | 94 | | if (attemptedProbes.Count > 0) |
| | 5 | 95 | | data["attemptedProbes"] = string.Join(",", attemptedProbes); |
| | | 96 | | |
| | 6 | 97 | | if (failedProbes.Count > 0) |
| | 2 | 98 | | data["failedProbes"] = string.Join(",", failedProbes); |
| | | 99 | | |
| | 6 | 100 | | if (skippedProbes.Count > 0) |
| | 2 | 101 | | data["skippedProbes"] = string.Join(",", skippedProbes); |
| | | 102 | | |
| | 6 | 103 | | return data; |
| | | 104 | | } |
| | | 105 | | } |
| | | 106 | | |
| | | 107 | | async Task<ProbeResult> ProbeAsync<TStore>(string storeName, TStore? store, Func<TStore, CancellationToken, Task |
| | | 108 | | { |
| | 23 | 109 | | if (store == null) |
| | | 110 | | { |
| | 6 | 111 | | return new ProbeResult(storeName, true, null); |
| | | 112 | | } |
| | | 113 | | |
| | | 114 | | try |
| | | 115 | | { |
| | 17 | 116 | | await probe(store, cancellationToken); |
| | 15 | 117 | | return new ProbeResult(storeName, false, null); |
| | | 118 | | } |
| | 0 | 119 | | catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) |
| | | 120 | | { |
| | 0 | 121 | | throw; |
| | | 122 | | } |
| | 2 | 123 | | catch (Exception e) when (!e.IsFatal()) |
| | | 124 | | { |
| | 2 | 125 | | return new ProbeResult(storeName, false, e); |
| | | 126 | | } |
| | 23 | 127 | | } |
| | 6 | 128 | | } |
| | | 129 | | |
| | 227 | 130 | | private sealed record ProbeResult(string StoreName, bool Skipped, Exception? Exception); |
| | | 131 | | } |