feat(site-runtime): script scheduler gauges — queue depth, busy threads, oldest-busy age (S2/UA5)

This commit is contained in:
Joseph Doherty
2026-07-08 23:35:09 -04:00
parent b83d1ed19c
commit 474ec8a221
2 changed files with 85 additions and 3 deletions
@@ -22,6 +22,12 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
private readonly List<Thread> _threads;
private int _disposed;
// Per-worker "busy since" timestamp (Environment.TickCount64 ms) while a task
// is executing on that worker, 0 when idle. Written by the worker thread,
// read (lock-free) by the observability gauges below. S2/UA5: makes a
// saturated or stuck script-execution pool visible on the site health report.
private readonly long[] _busySinceTicks;
private static volatile ScriptExecutionScheduler? _shared;
private static readonly object SharedLock = new();
@@ -52,10 +58,12 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
if (threadCount < 1)
threadCount = 1;
_busySinceTicks = new long[threadCount];
_threads = new List<Thread>(threadCount);
for (var i = 0; i < threadCount; i++)
{
var thread = new Thread(WorkerLoop)
var index = i; // capture per-worker slot index
var thread = new Thread(() => WorkerLoop(index))
{
IsBackground = true,
Name = $"script-execution-{i}"
@@ -68,13 +76,57 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
/// <inheritdoc />
public override int MaximumConcurrencyLevel => _threads.Count;
private void WorkerLoop()
/// <summary>Number of tasks waiting in the queue (not counting those currently executing).</summary>
public int QueueDepth => _queue.Count;
/// <summary>Number of worker threads currently executing a task.</summary>
public int BusyThreadCount
{
get
{
var count = 0;
for (var i = 0; i < _busySinceTicks.Length; i++)
if (Volatile.Read(ref _busySinceTicks[i]) != 0) count++;
return count;
}
}
/// <summary>
/// Age of the longest-running in-flight task across all workers, or null when
/// the pool is idle. A large value with a full <see cref="BusyThreadCount"/>
/// indicates a stuck/blocked script holding a dedicated thread.
/// </summary>
public TimeSpan? OldestBusyAge
{
get
{
var now = Environment.TickCount64;
long oldestSince = 0;
for (var i = 0; i < _busySinceTicks.Length; i++)
{
var since = Volatile.Read(ref _busySinceTicks[i]);
if (since != 0 && (oldestSince == 0 || since < oldestSince))
oldestSince = since;
}
return oldestSince == 0 ? null : TimeSpan.FromMilliseconds(Math.Max(0, now - oldestSince));
}
}
private void WorkerLoop(int index)
{
try
{
foreach (var task in _queue.GetConsumingEnumerable())
{
TryExecuteTask(task);
Volatile.Write(ref _busySinceTicks[index], Environment.TickCount64);
try
{
TryExecuteTask(task);
}
finally
{
Volatile.Write(ref _busySinceTicks[index], 0);
}
}
}
catch (ObjectDisposedException)