perf(metrics): pull-model worker queue gauge (fixes last-writer-wins), Interlocked command counters

This commit is contained in:
Joseph Doherty
2026-08-15 12:21:51 -04:00
parent 77c5731b7b
commit 9735ac3b7c
4 changed files with 178 additions and 77 deletions
@@ -19,7 +19,10 @@ public sealed class GatewayMetricsTests
metrics.CommandFailed("WriteSecured", "AuthorizationFailed", TimeSpan.FromMilliseconds(12));
metrics.EventReceived("session-1", "OnDataChange");
metrics.EventReceived("session-1", "OnDataChange");
metrics.SetWorkerEventQueueDepth(7);
// GWC-30: the worker queue-depth gauge sums one live source per worker client, so the two
// registrations below stand in for two concurrent sessions holding 3 and 4 events.
using IDisposable workerDepthSourceA = metrics.RegisterWorkerEventQueueDepthSource(static () => 3);
using IDisposable workerDepthSourceB = metrics.RegisterWorkerEventQueueDepthSource(static () => 4);
// GWC-15: the gRPC stream queue-depth gauge sums live backlog sources at collection time
// rather than tracking a pushed running total. Register a source reporting 3.
using IDisposable backlogSource = metrics.RegisterEventStreamBacklogSource(static () => 3);
@@ -54,16 +57,111 @@ public sealed class GatewayMetricsTests
Assert.Equal(2, snapshot.EventsBySession["session-1"]);
}
/// <summary>Verifies that negative queue depth is rejected.</summary>
/// <summary>
/// GWC-30: the worker queue-depth gauge sums every registered source rather than holding a
/// single pushed scalar, so concurrent sessions add up instead of overwriting one another,
/// and a disposed registration (a worker client going away) drops out of the sum. Disposal
/// is idempotent because a client's dispose path can run twice.
/// </summary>
[Fact]
public void SetEventQueueDepth_RejectsNegativeDepth()
public void WorkerEventQueueDepthSources_SumAcrossRegistrationsAndDropOnDispose()
{
using GatewayMetrics metrics = new();
ArgumentOutOfRangeException exception = Assert.Throws<ArgumentOutOfRangeException>(
() => metrics.SetWorkerEventQueueDepth(-1));
IDisposable firstSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 3);
using IDisposable secondSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 4);
Assert.Equal("depth", exception.ParamName);
Assert.Equal(7, metrics.GetSnapshot().WorkerEventQueueDepth);
firstSource.Dispose();
Assert.Equal(4, metrics.GetSnapshot().WorkerEventQueueDepth);
firstSource.Dispose();
Assert.Equal(4, metrics.GetSnapshot().WorkerEventQueueDepth);
}
/// <summary>
/// A depth source reads a lock-free counter that a racing decrement can momentarily push
/// below zero, so the sum clamps each reading instead of rejecting it — the pull model has
/// no caller to throw back at.
/// </summary>
[Fact]
public void WorkerEventQueueDepthSources_ClampNegativeReadingsToZero()
{
using GatewayMetrics metrics = new();
using IDisposable negativeSource = metrics.RegisterWorkerEventQueueDepthSource(static () => -5);
using IDisposable positiveSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 4);
Assert.Equal(4, metrics.GetSnapshot().WorkerEventQueueDepth);
}
/// <summary>
/// Verifies the exported gauge keeps the name <c>mxgateway.events.worker_queue.depth</c> and
/// reports the summed sources, so the pull-model rework is invisible to exporters.
/// </summary>
[Fact]
public void WorkerEventQueueDepthGauge_ReportsSummedSources()
{
using GatewayMetrics metrics = new();
using MeterListener listener = new();
int? capturedDepth = null;
listener.InstrumentPublished = (instrument, meterListener) =>
{
if (ReferenceEquals(instrument.Meter, metrics.Meter)
&& instrument.Name == "mxgateway.events.worker_queue.depth")
{
meterListener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<int>(
(instrument, measurement, _, _) =>
{
if (ReferenceEquals(instrument.Meter, metrics.Meter)
&& instrument.Name == "mxgateway.events.worker_queue.depth")
{
capturedDepth = measurement;
}
});
listener.Start();
using IDisposable firstSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 5);
using IDisposable secondSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 6);
listener.RecordObservableInstruments();
Assert.Equal(11, capturedDepth);
}
/// <summary>
/// The command counters are incremented with <see cref="Interlocked"/> rather than under the
/// process-wide metrics lock, so this asserts no increment is lost when every gRPC thread
/// records at once.
/// </summary>
[Fact]
public void CommandCounters_CountEveryConcurrentInvocation()
{
const int workers = 8;
const int perWorker = 500;
using GatewayMetrics metrics = new();
Parallel.For(0, workers, _ =>
{
for (int index = 0; index < perWorker; index++)
{
metrics.CommandStarted("Register");
metrics.CommandSucceeded("Register", TimeSpan.FromMilliseconds(1));
metrics.CommandFailed("WriteSecured", "AuthorizationFailed", TimeSpan.FromMilliseconds(1));
}
});
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(workers * perWorker, snapshot.CommandsStarted);
Assert.Equal(workers * perWorker, snapshot.CommandsSucceeded);
Assert.Equal(workers * perWorker, snapshot.CommandsFailed);
Assert.Equal(workers * perWorker, snapshot.CommandFailuresByMethod["WriteSecured"]);
}
/// <summary>