feat(site-runtime): script scheduler gauges — queue depth, busy threads, oldest-busy age (S2/UA5)
This commit is contained in:
@@ -22,6 +22,12 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
|||||||
private readonly List<Thread> _threads;
|
private readonly List<Thread> _threads;
|
||||||
private int _disposed;
|
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 volatile ScriptExecutionScheduler? _shared;
|
||||||
private static readonly object SharedLock = new();
|
private static readonly object SharedLock = new();
|
||||||
|
|
||||||
@@ -52,10 +58,12 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
|||||||
if (threadCount < 1)
|
if (threadCount < 1)
|
||||||
threadCount = 1;
|
threadCount = 1;
|
||||||
|
|
||||||
|
_busySinceTicks = new long[threadCount];
|
||||||
_threads = new List<Thread>(threadCount);
|
_threads = new List<Thread>(threadCount);
|
||||||
for (var i = 0; i < threadCount; i++)
|
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,
|
IsBackground = true,
|
||||||
Name = $"script-execution-{i}"
|
Name = $"script-execution-{i}"
|
||||||
@@ -68,13 +76,57 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override int MaximumConcurrencyLevel => _threads.Count;
|
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
|
try
|
||||||
{
|
{
|
||||||
foreach (var task in _queue.GetConsumingEnumerable())
|
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)
|
catch (ObjectDisposedException)
|
||||||
|
|||||||
+30
@@ -44,4 +44,34 @@ public class ScriptExecutionSchedulerTests
|
|||||||
var b = ScriptExecutionScheduler.Shared(options);
|
var b = ScriptExecutionScheduler.Shared(options);
|
||||||
Assert.Same(a, b);
|
Assert.Same(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteRuntime-S2/UA5: the scheduler exposes observability gauges so a stuck /
|
||||||
|
// saturated script-execution pool is visible to operators via site health.
|
||||||
|
[Fact]
|
||||||
|
public async Task Gauges_ReflectBusyThreadAndQueueDepth()
|
||||||
|
{
|
||||||
|
using var scheduler = new ScriptExecutionScheduler(1);
|
||||||
|
using var gate = new ManualResetEventSlim(false);
|
||||||
|
|
||||||
|
var blocking = Task.Factory.StartNew(() => gate.Wait(),
|
||||||
|
CancellationToken.None, TaskCreationOptions.None, scheduler);
|
||||||
|
var queued = Task.Factory.StartNew(() => { },
|
||||||
|
CancellationToken.None, TaskCreationOptions.None, scheduler);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => scheduler.BusyThreadCount == 1);
|
||||||
|
Assert.Equal(1, scheduler.QueueDepth); // second task waiting
|
||||||
|
Assert.True(scheduler.OldestBusyAge > TimeSpan.Zero);
|
||||||
|
|
||||||
|
gate.Set();
|
||||||
|
await Task.WhenAll(blocking, queued);
|
||||||
|
await WaitUntilAsync(() => scheduler.BusyThreadCount == 0);
|
||||||
|
Assert.Null(scheduler.OldestBusyAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 100 && !condition(); i++)
|
||||||
|
await Task.Delay(50);
|
||||||
|
Assert.True(condition(), "condition not met within timeout");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user