w4c-workflows-api/Services/Execution/RunTerminalWriter.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

57 lines
2.3 KiB
C#

using Microsoft.EntityFrameworkCore;
using w4c_workflows.Data;
using w4c_workflows.Models;
namespace w4c_workflows.Services.Execution;
/// <summary>
/// Writes a run's terminal status under a non-terminal precondition.
///
/// <para>
/// The control-plane timeout sweep (<c>RunLifecycleEngine.TimeoutStaleRunsAsync</c>)
/// fails a run that has been <c>running</c> past <c>Workflows:RunTimeoutSeconds</c>
/// while a worker may still be executing it. A plain tracked write from the worker
/// would then resurrect the failed run as <c>succeeded</c>. Applying the update
/// with <c>ExecuteUpdate</c> plus a <c>Status IN (pending, running, compensating)</c>
/// guard makes the write atomic: if the sweep won the race the update affects zero
/// rows and the worker leaves the terminal status alone.
/// </para>
/// </summary>
public static class RunTerminalWriter
{
/// <summary>
/// Atomically sets the run's terminal status/error/output when it is still
/// non-terminal. Returns <c>false</c> when another writer (e.g. the timeout
/// sweep) already terminalized the run, so the caller must not report success.
/// </summary>
public static async Task<bool> TrySetTerminalAsync(
WorkflowsDbContext db,
WorkflowRun run,
string status,
string? error,
string? output,
CancellationToken ct)
{
var now = DateTime.UtcNow;
var affected = await db.WorkflowRuns
.Where(r => r.Id == run.Id
&& (r.Status == RunStatus.Pending
|| r.Status == RunStatus.Running
|| r.Status == RunStatus.Compensating))
.ExecuteUpdateAsync(setters => setters
.SetProperty(r => r.Status, status)
.SetProperty(r => r.Error, error)
.SetProperty(r => r.OutputJson, output)
.SetProperty(r => r.FinishedAt, now), ct);
// Refresh the tracked instance from the row we just wrote (or from the
// terminal state another writer already set), so in-memory callers see the
// truth and a later SaveChanges cannot flush the status unguarded.
var entry = db.Entry(run);
if (entry.State != EntityState.Detached)
await entry.ReloadAsync(ct);
return affected > 0;
}
}