Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Services/Configuration/OrchestratorSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,18 @@ public class OrchestratorSettings

/// <summary>Maximum number of times a task can be interrupted before being marked Failed.</summary>
public int MaxRetries { get; set; } = 3;

/// <summary>
/// How long a run's rows outlive it (hours, default 48). A run that finished — or that nothing is
/// driving and that last wrote to storage — longer ago than this is removed from all three tables,
/// together with any Tasks/Results partition whose Run row is already gone. Craft itself needs the
/// rows only while a run is live; they stay this long for operators reading recent history.
/// </summary>
public int RetentionHours { get; set; } = 48;

/// <summary>
/// How often the retention sweep runs after the one at startup (hours, default 4). 0 disables the
/// periodic sweep; the startup pass, which follows crash recovery, still runs.
/// </summary>
public int CleanupIntervalHours { get; set; } = 4;
}
77 changes: 74 additions & 3 deletions Services/Orchestration/OrchestratorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -538,14 +538,85 @@ public async Task ResumeInterruptedRunsAsync(CancellationToken ct)
}
}

// Cleanup old runs (older than 7 days)
// First retention pass, now that every run that could be resumed is back in _activeRuns and so
// exempt from the abandoned-run rule. The scheduler keeps it going on an interval from here.
try
{
await _store.CleanupOldRunsAsync(TimeSpan.FromDays(7));
await RunRetentionSweepAsync(ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Scheduler] Failed to cleanup old runs");
_logger.LogWarning(ex, "[Scheduler] Startup retention sweep failed");
}
}

/// <summary>
/// One retention pass over the orchestrator tables: finished runs past
/// <c>Orchestrator:RetentionHours</c>, runs nobody is driving that have not been written to for that
/// long, and Tasks/Results partitions whose Run row is already gone. Runs at the end of startup
/// recovery and then every <c>Orchestrator:CleanupIntervalHours</c> via <see cref="RunRetentionLoopAsync"/>.
/// </summary>
public async Task<OrchestratorCleanupResult> RunRetentionSweepAsync(CancellationToken ct)
{
var retention = TimeSpan.FromHours(Math.Max(1, _settings.Orchestrator.RetentionHours));
var active = _activeRuns.Keys.ToHashSet(StringComparer.Ordinal);
var result = await _store.CleanupOldRunsAsync(retention, active, ct);

// An abandoned run can still have rows in the durable queue. The pump would drop each as a
// stale descriptor when it came to claim it — but only after paying for the claim.
foreach (var name in result.AbandonedRuns)
{
try
{
await _queue.RemoveRunAsync(name, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Scheduler] Could not remove queue rows for abandoned run {Name}", name);
}
}

return result;
}

/// <summary>
/// Periodic retention sweeps for the life of the host, started by the scheduler once recovery has
/// run. A sweep that fails is logged and tried again next interval; <c>CleanupIntervalHours</c> of 0
/// leaves only the startup pass.
/// </summary>
public async Task RunRetentionLoopAsync(CancellationToken ct)
{
var hours = _settings.Orchestrator.CleanupIntervalHours;
if (hours <= 0)
{
_logger.LogInformation(
"[Scheduler] Periodic retention sweep disabled (CleanupIntervalHours={Hours}); only the startup pass runs", hours);
return;
}

var interval = TimeSpan.FromHours(hours);
using var timer = new PeriodicTimer(interval);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
try
{
await RunRetentionSweepAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Scheduler] Retention sweep failed; next attempt in {Interval}", interval);
}
}
}
catch (OperationCanceledException)
{
// Host shutdown.
}
}

Expand Down
4 changes: 4 additions & 0 deletions Services/Orchestration/SchedulerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
_logger.LogError(ex, "[Scheduler] Failed to resume interrupted orchestrator runs");
}

// Retention sweeps for the rest of the process lifetime; recovery ran the first one. Fire and
// forget is deliberate: the loop handles its own failures and ends with the stopping token.
_ = _orchestrator.RunRetentionLoopAsync(stoppingToken);

while (!stoppingToken.IsCancellationRequested)
{
var now = DateTimeOffset.UtcNow;
Expand Down
Loading
Loading