158 lines
5.3 KiB
C#
158 lines
5.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace StellaOps.Scheduler.Models;
|
|
|
|
/// <summary>
|
|
/// Encapsulates allowed <see cref="RunState"/> transitions and invariants.
|
|
/// </summary>
|
|
public static class RunStateMachine
|
|
{
|
|
private static readonly IReadOnlyDictionary<RunState, RunState[]> Adjacency = new Dictionary<RunState, RunState[]>
|
|
{
|
|
[RunState.Planning] = new[] { RunState.Planning, RunState.Queued, RunState.Cancelled },
|
|
[RunState.Queued] = new[] { RunState.Queued, RunState.Running, RunState.Cancelled },
|
|
[RunState.Running] = new[] { RunState.Running, RunState.Completed, RunState.Error, RunState.Cancelled },
|
|
[RunState.Completed] = new[] { RunState.Completed },
|
|
[RunState.Error] = new[] { RunState.Error },
|
|
[RunState.Cancelled] = new[] { RunState.Cancelled },
|
|
};
|
|
|
|
public static bool CanTransition(RunState from, RunState to)
|
|
{
|
|
if (!Adjacency.TryGetValue(from, out var allowed))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return allowed.Contains(to);
|
|
}
|
|
|
|
public static bool IsTerminal(RunState state)
|
|
=> state is RunState.Completed or RunState.Error or RunState.Cancelled;
|
|
|
|
/// <summary>
|
|
/// Applies a state transition ensuring timestamps, stats, and error contracts stay consistent.
|
|
/// </summary>
|
|
public static Run EnsureTransition(
|
|
Run run,
|
|
RunState next,
|
|
DateTimeOffset timestamp,
|
|
Action<RunStatsBuilder>? mutateStats = null,
|
|
string? errorMessage = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(run);
|
|
|
|
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
|
|
var current = run.State;
|
|
|
|
if (!CanTransition(current, next))
|
|
{
|
|
throw new InvalidOperationException($"Run state transition from '{current}' to '{next}' is not allowed.");
|
|
}
|
|
|
|
var statsBuilder = new RunStatsBuilder(run.Stats);
|
|
mutateStats?.Invoke(statsBuilder);
|
|
var newStats = statsBuilder.Build();
|
|
|
|
var startedAt = run.StartedAt;
|
|
var finishedAt = run.FinishedAt;
|
|
|
|
if (current != RunState.Running && next == RunState.Running && startedAt is null)
|
|
{
|
|
startedAt = normalizedTimestamp;
|
|
}
|
|
|
|
if (IsTerminal(next))
|
|
{
|
|
finishedAt ??= normalizedTimestamp;
|
|
}
|
|
|
|
if (startedAt is { } start && start < run.CreatedAt)
|
|
{
|
|
throw new InvalidOperationException("Run started time cannot be earlier than created time.");
|
|
}
|
|
|
|
if (finishedAt is { } finish)
|
|
{
|
|
if (startedAt is { } startTime && finish < startTime)
|
|
{
|
|
throw new InvalidOperationException("Run finished time cannot be earlier than start time.");
|
|
}
|
|
|
|
if (!IsTerminal(next))
|
|
{
|
|
throw new InvalidOperationException("Finished time present but next state is not terminal.");
|
|
}
|
|
}
|
|
|
|
string? nextError = null;
|
|
if (next == RunState.Error)
|
|
{
|
|
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? run.Error : errorMessage.Trim();
|
|
if (string.IsNullOrWhiteSpace(effectiveError))
|
|
{
|
|
throw new InvalidOperationException("Transitioning to Error requires a non-empty error message.");
|
|
}
|
|
|
|
nextError = effectiveError;
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(errorMessage))
|
|
{
|
|
throw new InvalidOperationException("Error message can only be provided when transitioning to Error state.");
|
|
}
|
|
|
|
var updated = run with
|
|
{
|
|
State = next,
|
|
Stats = newStats,
|
|
StartedAt = startedAt,
|
|
FinishedAt = finishedAt,
|
|
Error = nextError,
|
|
};
|
|
|
|
Validate(updated);
|
|
return updated;
|
|
}
|
|
|
|
public static void Validate(Run run)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(run);
|
|
|
|
if (run.StartedAt is { } started && started < run.CreatedAt)
|
|
{
|
|
throw new InvalidOperationException("Run.StartedAt cannot be earlier than CreatedAt.");
|
|
}
|
|
|
|
if (run.FinishedAt is { } finished)
|
|
{
|
|
if (run.StartedAt is { } startedAt && finished < startedAt)
|
|
{
|
|
throw new InvalidOperationException("Run.FinishedAt cannot be earlier than StartedAt.");
|
|
}
|
|
|
|
if (!IsTerminal(run.State))
|
|
{
|
|
throw new InvalidOperationException("Run.FinishedAt set while state is not terminal.");
|
|
}
|
|
}
|
|
else if (IsTerminal(run.State))
|
|
{
|
|
throw new InvalidOperationException("Terminal run states must include FinishedAt.");
|
|
}
|
|
|
|
if (run.State == RunState.Error)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(run.Error))
|
|
{
|
|
throw new InvalidOperationException("Run.Error must be populated when state is Error.");
|
|
}
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(run.Error))
|
|
{
|
|
throw new InvalidOperationException("Run.Error must be null for non-error states.");
|
|
}
|
|
}
|
|
}
|