271 lines
10 KiB
C#
271 lines
10 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Linq;
|
|
|
|
namespace Unity.Pipeline
|
|
{
|
|
/// <summary>
|
|
/// Lifecycle states of a detached command job (CLI-335).
|
|
/// Serialized to the wire as lower-case strings (see BasePipelineServer).
|
|
/// </summary>
|
|
internal enum PipelineJobState
|
|
{
|
|
Queued,
|
|
Running,
|
|
Completed,
|
|
Failed,
|
|
Canceled
|
|
}
|
|
|
|
/// <summary>One detached command execution tracked by <see cref="PipelineJobRegistry"/>.</summary>
|
|
internal sealed class PipelineJobRecord
|
|
{
|
|
public string Id;
|
|
public string Command;
|
|
public PipelineJobState State;
|
|
public object Result;
|
|
public string Error;
|
|
public string ErrorDetails;
|
|
public DateTime EnqueuedAt;
|
|
public DateTime? StartedAt;
|
|
public DateTime? CompletedAt;
|
|
public volatile bool CancellationRequested;
|
|
|
|
/// <summary>Guards State/Result/Error/timestamps; reads and writes cross threads.</summary>
|
|
public readonly object Gate = new object();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registry of detached command jobs (CLI-335): a client can submit a command as a job
|
|
/// (`/api/exec` with `"job": true`), get a job id back immediately, and later poll
|
|
/// `/api/job?id=…` to reattach and collect the result — surviving the client's own HTTP
|
|
/// timeout. Results are retained (bounded, see below) so a client that timed out or
|
|
/// disconnected can still fetch the outcome.
|
|
///
|
|
/// Owned one-per-<see cref="BasePipelineServer"/> (the same pattern as <c>Dispatcher</c>), so
|
|
/// a test server's jobs can never cross-attribute state with the live server's.
|
|
///
|
|
/// In-memory by design: a domain reload (script recompilation) discards pending jobs the
|
|
/// same way it discards every other C# static. That limitation is inherent to in-Editor
|
|
/// state and is tracked separately.
|
|
/// </summary>
|
|
internal sealed class PipelineJobRegistry
|
|
{
|
|
/// <summary>Completed/failed/canceled jobs retained beyond this count are pruned oldest-first.</summary>
|
|
private const int MaxRetainedTerminalJobs = 100;
|
|
|
|
/// <summary>
|
|
/// Jobs allowed queued or running at once. Jobs execute strictly serially through the
|
|
/// exec gate, so a deep backlog beyond this is pure queued work with no throughput
|
|
/// benefit — an authorized client loop (or a retrying script) submitting faster than jobs
|
|
/// drain would otherwise grow this without bound.
|
|
/// </summary>
|
|
private const int MaxQueuedJobs = 100;
|
|
|
|
/// <summary>Terminal jobs older than this are pruned regardless of count.</summary>
|
|
private static readonly TimeSpan m_RetentionTtl = TimeSpan.FromHours(1);
|
|
|
|
private readonly ConcurrentDictionary<string, PipelineJobRecord> m_Jobs =
|
|
new ConcurrentDictionary<string, PipelineJobRecord>();
|
|
|
|
/// <summary>Guards the queued-job cap check against a concurrent create racing past it.</summary>
|
|
private readonly object m_CreateGate = new object();
|
|
|
|
/// <summary>
|
|
/// The record currently executing. At most one job runs at a time (jobs share the
|
|
/// server's exec gate), so a single reference suffices; read by
|
|
/// <see cref="PipelineCancellation"/> from whatever thread the command runs on.
|
|
/// </summary>
|
|
private volatile PipelineJobRecord m_CurrentRunning;
|
|
|
|
internal PipelineJobRecord CurrentRunning => m_CurrentRunning;
|
|
|
|
/// <summary>
|
|
/// Create a queued job for <paramref name="command"/>, unless <see cref="MaxQueuedJobs"/>
|
|
/// queued-or-running jobs already exist (returns false; the caller should reject the
|
|
/// submission rather than grow an unbounded backlog).
|
|
/// </summary>
|
|
internal bool TryCreate(string command, out PipelineJobRecord record)
|
|
{
|
|
Prune();
|
|
|
|
lock (m_CreateGate)
|
|
{
|
|
var nonTerminal = m_Jobs.Values.Count(job =>
|
|
{
|
|
lock (job.Gate)
|
|
{
|
|
return job.State == PipelineJobState.Queued || job.State == PipelineJobState.Running;
|
|
}
|
|
});
|
|
if (nonTerminal >= MaxQueuedJobs)
|
|
{
|
|
record = null;
|
|
return false;
|
|
}
|
|
|
|
record = new PipelineJobRecord
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
Command = command,
|
|
State = PipelineJobState.Queued,
|
|
EnqueuedAt = DateTime.UtcNow
|
|
};
|
|
m_Jobs[record.Id] = record;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
internal bool TryGet(string id, out PipelineJobRecord record)
|
|
{
|
|
Prune();
|
|
return m_Jobs.TryGetValue(id ?? string.Empty, out record);
|
|
}
|
|
|
|
internal void MarkRunning(PipelineJobRecord record)
|
|
{
|
|
lock (record.Gate)
|
|
{
|
|
record.State = PipelineJobState.Running;
|
|
record.StartedAt = DateTime.UtcNow;
|
|
}
|
|
m_CurrentRunning = record;
|
|
}
|
|
|
|
internal void MarkCompleted(PipelineJobRecord record, object result)
|
|
{
|
|
lock (record.Gate)
|
|
{
|
|
record.State = PipelineJobState.Completed;
|
|
record.Result = result;
|
|
record.CompletedAt = DateTime.UtcNow;
|
|
}
|
|
ClearCurrent(record);
|
|
}
|
|
|
|
internal void MarkFailed(PipelineJobRecord record, string error, string details)
|
|
{
|
|
lock (record.Gate)
|
|
{
|
|
record.State = PipelineJobState.Failed;
|
|
record.Error = error;
|
|
record.ErrorDetails = details;
|
|
record.CompletedAt = DateTime.UtcNow;
|
|
}
|
|
ClearCurrent(record);
|
|
}
|
|
|
|
internal void MarkCanceled(PipelineJobRecord record)
|
|
{
|
|
lock (record.Gate)
|
|
{
|
|
record.State = PipelineJobState.Canceled;
|
|
record.CompletedAt = DateTime.UtcNow;
|
|
}
|
|
ClearCurrent(record);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request cancellation. A queued job is canceled before it ever starts (the runner
|
|
/// checks the flag under the exec gate); a running job gets a cooperative flag that
|
|
/// command/eval code can observe via <see cref="PipelineCancellation"/> — arbitrary
|
|
/// synchronous main-thread code cannot be aborted safely, so cancellation of a running
|
|
/// job takes effect only when the code cooperates (mirroring how test cancellation
|
|
/// requires the runner's cooperation).
|
|
/// </summary>
|
|
internal bool RequestCancel(string id, out PipelineJobRecord record)
|
|
{
|
|
if (!TryGet(id, out record))
|
|
{
|
|
return false;
|
|
}
|
|
record.CancellationRequested = true;
|
|
return true;
|
|
}
|
|
|
|
private void ClearCurrent(PipelineJobRecord record)
|
|
{
|
|
if (ReferenceEquals(m_CurrentRunning, record))
|
|
{
|
|
m_CurrentRunning = null;
|
|
}
|
|
}
|
|
|
|
private void Prune()
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var terminal = m_Jobs.Values
|
|
.Where(job =>
|
|
{
|
|
lock (job.Gate)
|
|
{
|
|
return job.State == PipelineJobState.Completed
|
|
|| job.State == PipelineJobState.Failed
|
|
|| job.State == PipelineJobState.Canceled;
|
|
}
|
|
})
|
|
.OrderBy(job => job.CompletedAt ?? job.EnqueuedAt)
|
|
.ToList();
|
|
|
|
// Oldest-first: drop everything beyond the retained count, plus anything expired.
|
|
var excess = terminal.Count - MaxRetainedTerminalJobs;
|
|
for (var i = 0; i < terminal.Count; i++)
|
|
{
|
|
var job = terminal[i];
|
|
var expired = now - (job.CompletedAt ?? job.EnqueuedAt) > m_RetentionTtl;
|
|
if (i < excess || expired)
|
|
{
|
|
m_Jobs.TryRemove(job.Id, out _);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Test hook: forget every job.</summary>
|
|
internal void Reset()
|
|
{
|
|
m_Jobs.Clear();
|
|
m_CurrentRunning = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <para>Cooperative cancellation for detached jobs (CLI-335). Long-running command or
|
|
/// <c>eval</c> code checks this periodically; when a client has requested cancellation
|
|
/// (<c>POST /api/job/cancel</c>), the code can wind down early:</para>
|
|
/// <code>
|
|
/// for (int i = 0; i < n; i++)
|
|
/// {
|
|
/// Unity.Pipeline.PipelineCancellation.ThrowIfCancellationRequested();
|
|
/// // … chunk of work …
|
|
/// }
|
|
/// </code>
|
|
/// <para>Arbitrary synchronous code cannot be aborted safely from outside, so a running job
|
|
/// that never checks this flag runs to completion (its state still reports canceled).</para>
|
|
/// <para>This is a thin static façade over the executing server's own
|
|
/// <see cref="PipelineJobRegistry"/> (resolved via <see cref="BasePipelineServer.CurrentServer"/>)
|
|
/// so command code — which has no reference to "which server is running me" — can keep
|
|
/// calling this the same way regardless of which server dispatched it.</para>
|
|
/// </summary>
|
|
public static class PipelineCancellation
|
|
{
|
|
/// <summary>True when the currently executing job has been asked to cancel.</summary>
|
|
public static bool IsCancellationRequested
|
|
{
|
|
get
|
|
{
|
|
var current = BasePipelineServer.CurrentServer?.JobRegistry.CurrentRunning;
|
|
return current != null && current.CancellationRequested;
|
|
}
|
|
}
|
|
|
|
/// <summary>Throw <see cref="OperationCanceledException"/> when cancellation was requested.</summary>
|
|
public static void ThrowIfCancellationRequested()
|
|
{
|
|
if (IsCancellationRequested)
|
|
{
|
|
throw new OperationCanceledException("Pipeline job cancellation was requested");
|
|
}
|
|
}
|
|
}
|
|
}
|