chore(GWC-31): relabel perf-pass GWC-3x comments to PERF-nn; tracker housekeeping

Sweep code/doc comments citing unregistered GWC-30/GWC-31 (2026-08-15 perf
pass) to PERF-02/PERF-07/PERF-22, keyed to task numbers in
docs/plans/2026-08-15-perf-review-remediation.md; add a PERF-ID register
table to that plan. Genuine cycle-2 GWC-30 citations (gateway-side frame-
reader 4-byte prefix fix in Server/Workers/WorkerFrameReader.cs and its
test) are left untouched.

Add a line to REVIEW-PROCESS.md reserving the GWC/WRK/IPC/SEC/CLI/TST
prefixes for the archreview domain registers.

Tracker housekeeping per archreview/2026-08-19/00-overall.md:
- GWC-16 -> Won't fix (rationale: PERF(followup) comment documents the
  double session lookup as measured-negligible, deliberately not collapsed)
- IPC-08 -> Done (closed by 7c1ea12/a1a38b5; residual tracked as IPC-34)
- IPC-31 -> Done (superseded by GWC-28's write-time sequence stamping)
- TST-06, TST-17 rows refreshed per cycle-3 60-testing-docs-gaps.md
- WRK-31 (and defensively WRK-29/30) registered as reserved/do-not-reuse
  interim IDs burned by e913dab, in both the cycle-2 tracker and the
  cycle-2 worker remediation doc

Flip Batch C to Done in archreview/2026-08-19/remediation/00-tracking.md.

Worker-side files (Worker/Ipc/WorkerFrameReader.cs, WorkerFrameWriter.cs)
are comment-only edits, unverified here (net48 x86, windev-only build).
This commit is contained in:
Joseph Doherty
2026-08-20 09:23:36 -04:00
parent 7c5eb062e3
commit 3619152b9f
18 changed files with 65 additions and 27 deletions
@@ -43,7 +43,7 @@ public sealed class GatewayMetrics : IDisposable
private readonly ConcurrentDictionary<long, Func<int>> _eventStreamBacklogSources = new();
private long _nextEventStreamBacklogSourceId;
// GWC-30: the same pull model for the worker event queue depth. It replaces a pushed scalar that
// PERF-02: the same pull model for the worker event queue depth. It replaces a pushed scalar that
// every WorkerClient wrote twice per event (staged, consumed) under _syncRoot — a process-wide
// lock on the hottest path, and last-writer-wins across sessions, so the gauge reported one
// arbitrary session's backlog instead of the gateway's. Each client registers a source returning
@@ -210,7 +210,7 @@ public sealed class GatewayMetrics : IDisposable
/// <param name="method">Name of the command method.</param>
public void CommandStarted(string method)
{
// GWC-30: the three command counters run two-to-three times per gRPC call, so they use
// PERF-02: the three command counters run two-to-three times per gRPC call, so they use
// Interlocked rather than _syncRoot — the same idiom as EventReceived. Nothing here needs a
// consistent multi-field view; GetSnapshot reads each with Interlocked.Read.
Interlocked.Increment(ref _commandsStarted);
@@ -281,7 +281,7 @@ public sealed class GatewayMetrics : IDisposable
/// Registers a live depth source for the worker event queue-depth gauge and returns a handle
/// that removes it when disposed. Each <c>WorkerClient</c> registers once and reports its own
/// undelivered (staged + queued) event count, so the gauge is the gateway-wide sum instead of
/// the last value any one session happened to push (GWC-30).
/// the last value any one session happened to push (PERF-02).
/// </summary>
/// <param name="depth">
/// Returns this worker client's current undelivered event count. Invoked only at collection
@@ -460,7 +460,7 @@ public sealed class GatewayMetrics : IDisposable
{
// Compute the live queue depths outside _syncRoot: the sources are the subscriber channels'
// Count (their own locks) and the worker clients' interlocked counters, neither of which may
// run under this lock. GWC-15, GWC-30.
// run under this lock. GWC-15, PERF-02.
int workerEventQueueDepth = GetWorkerEventQueueDepth();
int grpcEventStreamQueueDepth = GetGrpcEventStreamQueueDepth();
lock (_syncRoot)
@@ -521,7 +521,7 @@ public sealed class GatewayMetrics : IDisposable
}
}
// Sums the undelivered event backlog across every live worker client (GWC-30).
// Sums the undelivered event backlog across every live worker client (PERF-02).
private int GetWorkerEventQueueDepth() => SumSources(_workerEventQueueDepthSources);
// Sums the live backlog across every registered event-stream subscriber.
@@ -41,7 +41,7 @@ public sealed class WorkerClient : IWorkerClient
private readonly SemaphoreSlim _pendingCommandSlots;
private readonly CancellationTokenSource _stopCts = new();
// GWC-30: this client's contribution to the gateway-wide worker queue-depth gauge. Registered
// PERF-02: this client's contribution to the gateway-wide worker queue-depth gauge. Registered
// once here and read only when the gauge is scraped, so staging and consuming an event cost an
// Interlocked on _eventQueueDepth and nothing else — previously each of those two hot-path steps
// called into GatewayMetrics and took its process-wide lock. Null when metrics are disabled.
@@ -233,7 +233,7 @@ public sealed class WorkerClient : IWorkerClient
// size the caller controls; checking here keeps a MessageTooLarge in the write loop a
// genuine desync signal.
//
// PERF(GWC-31): this size cannot be handed to WorkerFrameWriter to spare its own
// PERF-07: this size cannot be handed to WorkerFrameWriter to spare its own
// CalculateSize. WriteLoopAsync stamps envelope.Sequence immediately before the write
// (GWC-28), and a non-zero varint field grows the encoding — so the number computed here
// is a lower bound on the frame the writer actually emits, never the frame length. Passing
@@ -250,7 +250,7 @@ public sealed class WorkerClient : IWorkerClient
await EnqueueAsync(commandEnvelope, cancellationToken).ConfigureAwait(false);
// GWC-31: one pooled timer instead of a linked CTS + Task.Delay + WhenAny per command.
// PERF-07: one pooled timer instead of a linked CTS + Task.Delay + WhenAny per command.
// Task.WaitAsync arms a TimerQueueTimer on the shared timer queue and cancels it when the
// reply lands, so the steady-state cost of a command that replies in time is a single
// continuation — the old shape allocated a linked CancellationTokenSource, its
@@ -352,7 +352,7 @@ public sealed class WorkerClient : IWorkerClient
{
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
// No metrics call on the hot path: the gauge pulls _eventQueueDepth when scraped (GWC-30).
// No metrics call on the hot path: the gauge pulls _eventQueueDepth when scraped (PERF-02).
Interlocked.Decrement(ref _eventQueueDepth);
yield return workerEvent;
}
@@ -160,7 +160,7 @@ public sealed class WorkerClientTests
TestTimeout,
CancellationToken.None);
// The timeout also emits a WorkerCancel for the abandoned correlation (GWC-31), which sits
// The timeout also emits a WorkerCancel for the abandoned correlation (PERF-07), which sits
// ahead of the second command on the FIFO pipe; skip it rather than mistaking it for the
// command this assertion is about.
WorkerEnvelope secondCommand = await ReadNextCommandAsync(pipePair, timedOutCommand.CorrelationId);
@@ -176,7 +176,7 @@ public sealed class WorkerClientTests
/// <summary>
/// A command timeout abandons the gateway-side wait, but the worker keeps the correlation on
/// its single STA queue and would still run it — so the gateway forwards a <c>WorkerCancel</c>
/// for the abandoned correlation id (GWC-31). Without it, a client that retries after a
/// for the abandoned correlation id (PERF-07). Without it, a client that retries after a
/// timeout stacks work the worker still intends to execute. Asserted on the wire because the
/// cancel is protocol behavior the worker depends on, not an internal detail.
/// </summary>
@@ -19,7 +19,7 @@ public sealed class GatewayMetricsTests
metrics.CommandFailed("WriteSecured", "AuthorizationFailed", TimeSpan.FromMilliseconds(12));
metrics.EventReceived("session-1", "OnDataChange");
metrics.EventReceived("session-1", "OnDataChange");
// GWC-30: the worker queue-depth gauge sums one live source per worker client, so the two
// PERF-02: 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);
@@ -58,7 +58,7 @@ public sealed class GatewayMetricsTests
}
/// <summary>
/// GWC-30: the worker queue-depth gauge sums every registered source rather than holding a
/// PERF-02: 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.
@@ -20,7 +20,7 @@ public sealed class WorkerFrameReader
private readonly WorkerFrameProtocolOptions _options;
private readonly Stream _stream;
// Reused across frames rather than allocated per read (GWC-30). Safe because ReadAsync is
// Reused across frames rather than allocated per read (PERF-22). Safe because ReadAsync is
// single-consumer by construction; the prefix is fully overwritten by every read.
//
// The single-consumer invariant has to survive teardown as well as steady state, because a read
@@ -668,10 +668,10 @@ public sealed class WorkerFrameWriter
// Serialize once into a single pooled buffer that carries the 4-byte length prefix followed
// by the payload, then issue one stream write. This avoids a second serialization pass, a
// separate prefix array, a separate prefix write, and any per-frame heap allocation — the
// gateway-side writer's GWC-30 shape, now matched on the net48 side. The rented buffer may
// be larger than requested, so only the first frameLength bytes are ever written. The buffer
// is returned only after this frame's write has completed; the batch flush deferred to the
// end of the drain (see DrainQueuedFramesAsync) does not read from it.
// gateway-side writer's pooled-buffer shape (PERF-22), now matched on the net48 side. The
// rented buffer may be larger than requested, so only the first frameLength bytes are ever
// written. The buffer is returned only after this frame's write has completed; the batch
// flush deferred to the end of the drain (see DrainQueuedFramesAsync) does not read from it.
int frameLength = sizeof(uint) + payloadLength;
byte[] frame = ArrayPool<byte>.Shared.Rent(frameLength);
try