w4c-workflows-api/Services/WorkflowGraph.cs

89 lines
3.7 KiB
C#
Raw Permalink Normal View History

using w4c_workflows.Models;
namespace w4c_workflows.Services;
/// <summary>
/// Precomputes the execution graph from a parsed YAML definition so validation
/// and compilation share one deterministic interpretation:
/// - <see cref="HeadId"/> is the entry of the success chain (the top-level
/// task with no incoming `next` that is not purely a compensation target).
/// - <see cref="Chain"/> is the linear happy path root → head → … → terminal.
/// - <see cref="CompensationOnly"/> marks tasks that are only ever reached as
/// an `onError` target (they have no `next` of their own).
/// </summary>
internal sealed class WorkflowGraph
{
public required IReadOnlyList<TaskDefinition> Tasks { get; init; }
public required IReadOnlyDictionary<string, TaskDefinition> ById { get; init; }
public required IReadOnlyDictionary<string, int> IncomingNext { get; init; }
public required IReadOnlySet<string> OnErrorTargets { get; init; }
public required IReadOnlySet<string> CompensationOnly { get; init; }
public string? HeadId { get; init; }
public required IReadOnlyList<string> Chain { get; init; }
public static WorkflowGraph Compute(WorkflowDefinition def)
{
var tasks = def.Tasks ?? new List<TaskDefinition>();
var byId = new Dictionary<string, TaskDefinition>(StringComparer.Ordinal);
foreach (var task in tasks)
if (!string.IsNullOrEmpty(task.Id))
byId[task.Id] = task;
var incomingNext = new Dictionary<string, int>(StringComparer.Ordinal);
var onErrorTargets = new HashSet<string>(StringComparer.Ordinal);
foreach (var task in tasks)
{
if (!string.IsNullOrEmpty(task.Next) && byId.ContainsKey(task.Next))
incomingNext[task.Next] = incomingNext.GetValueOrDefault(task.Next) + 1;
if (!string.IsNullOrEmpty(task.OnError) && byId.ContainsKey(task.OnError))
onErrorTargets.Add(task.OnError);
}
// A compensation-only task is an onError target that does not continue
// the success chain (no `next`). It lives outside the happy path.
var compensationOnly = new HashSet<string>(StringComparer.Ordinal);
foreach (var task in tasks)
{
if (task.Id == null) continue;
if (onErrorTargets.Contains(task.Id) && string.IsNullOrEmpty(task.Next))
compensationOnly.Add(task.Id);
}
// Head: top-level (parent == root), no incoming `next`, not compensation-only.
string? headId = null;
foreach (var task in tasks)
{
if (task.Id == null) continue;
var parent = string.IsNullOrEmpty(task.Parent) ? "root" : task.Parent;
if (!string.Equals(parent, "root", StringComparison.Ordinal)) continue;
if (incomingNext.GetValueOrDefault(task.Id) > 0) continue;
if (compensationOnly.Contains(task.Id)) continue;
headId = task.Id;
break;
}
// Walk the success chain (each node has at most one `next` after validation).
var chain = new List<string>();
var visited = new HashSet<string>(StringComparer.Ordinal);
var cursor = headId;
while (cursor != null && visited.Add(cursor) && byId.TryGetValue(cursor, out var task))
{
chain.Add(cursor);
cursor = string.IsNullOrEmpty(task.Next) ? null : task.Next;
}
return new WorkflowGraph
{
Tasks = tasks,
ById = byId,
IncomingNext = incomingNext,
OnErrorTargets = onErrorTargets,
CompensationOnly = compensationOnly,
HeadId = headId,
Chain = chain,
};
}
}