Flow Studio
The Timeout Background Job
HILTimeoutJob is a recurring background job that scans the suspended executions table for expired tasks and dispatches them to the appropriate timeout handler.
Job Implementation
// HILTimeoutJob.cs
public class HILTimeoutJob : IHangfireJob
{
private readonly ISuspendedExecutionRepository _repo;
private readonly IHILTimeoutDispatcher _dispatcher;
private readonly HILTimeoutOptions _options;
public async Task ExecuteAsync(CancellationToken ct)
{
var expiredBatch = await _repo.GetExpiredAsync(
limit : _options.BatchSize,
asOfUtc : DateTimeOffset.UtcNow,
ct : ct);
foreach (var suspension in expiredBatch)
{
try
{
await _dispatcher.DispatchAsync(suspension, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process timeout for {ExecutionResId}",
suspension.ExecutionResId);
// Continue processing remaining items — one failure doesn't stop the batch
}
}
}
}
Expired Task Query
-- Query run by the job
SELECT TOP (@BatchSize) *
FROM Process_SuspendedExecutions
WHERE ExpiresAt < GETUTCDATE()
AND Status = 0 -- Pending
AND ResumedAt IS NULL
ORDER BY ExpiresAt ASC; -- oldest first
IHILTimeoutDispatcher
public interface IHILTimeoutDispatcher
{
Task DispatchAsync(SuspendedExecution suspension, CancellationToken ct);
}
// Implementation routes to the appropriate service based on TimeoutBehavior
public class HILTimeoutDispatcher : IHILTimeoutDispatcher
{
public async Task DispatchAsync(SuspendedExecution s, CancellationToken ct)
{
switch (s.TimeoutBehavior)
{
case "Escalate":
await _escalationService.EscalateAsync(s, ct);
break;
case "AutoApprove":
case "AutoReject":
await _autoDecisionService.DecideAsync(s, ct);
break;
default: // Fail
await _failureService.FailAsync(s, ct);
break;
}
// Publish HILExpired event regardless of behavior
await _eventBus.PublishAsync(new HILExpiredEvent
{
ExecutionResId = s.ExecutionResId,
ExecutionId = s.ExecutionId,
TenantId = s.TenantId,
NodeId = s.SuspendedNodeId,
ExpiredAt = DateTimeOffset.UtcNow
}, ct);
}
}
Idempotency
The job is idempotent — if the same expired suspension is processed twice (e.g., due to a crash mid-batch), the second processing attempt detects the Status is no longer Pending and skips it. This prevents double-timeout from triggering two escalations.