perf(worker): message-driven completion waits — the STA pumps continuously while waiting

This commit is contained in:
Joseph Doherty
2026-08-15 16:58:54 -04:00
parent afec56d03b
commit dc9424d3bd
6 changed files with 396 additions and 19 deletions
+72 -10
View File
@@ -261,6 +261,54 @@ is still responsive. Shutdown marks the runtime as closing, wakes the pump,
rejects new commands, cancels queued work, uninitializes COM on the STA, and
waits for the thread to exit.
### Inner Completion Waits
Two commands hold the STA while waiting for a COM event they just provoked: the
unary write path waits for its `OnWriteComplete`
(`MxAccessWriteCompletionCache.TryWaitForCompletion`), and `ReadBulk` waits per
tag for the first `OnDataChange` (`MxAccessValueCache.TryWaitForUpdate`). Both
run the same loop shape as the outer pump, for the same reason — the event they
are waiting for *is* a Windows message, so the thread must keep dispatching to
receive it:
```text
loop:
pumpStep() # PeekMessage / TranslateMessage / DispatchMessage
if cache entry newer than baseline: return it
if now >= deadline: return the timed-out shape
MsgWaitForMultipleObjectsEx(
cache_update_event,
min(remaining, 50 ms),
QS_ALLINPUT,
MWMO_INPUTAVAILABLE)
```
The idle slice is a Win32 wait (`StaWaitHelper.WaitForSignalOrMessages`), never
`Thread.Sleep`. A sleeping STA pumps no messages, so a sleep-polled loop could
only dispatch the awaited COM event at poll-tick granularity while stalling
*every other* event for the same tick — up to 1.5 s for a write completion and
up to `timeout_ms` per tag for `ReadBulk`. The Win32 wait returns the instant a
message needs pumping, so the apartment dispatches continuously for the whole
wait. Each cache also sets an `AutoResetEvent` from its update path (outside the
cache lock) so a cross-thread producer wakes the waiter immediately; in the live
worker the update arrives on the STA from inside `pumpStep` itself, and the
message wake is what carries it.
The wait slice is capped at 50 ms so `pumpStep` runs periodically even when
nothing wakes the wait — a process with no STA message queue (unit tests drive
these caches from ordinary threads, standing in for the STA by updating the
cache from a fake `pumpStep`) must not block for a full poll interval. Timeouts,
deadline math, and return values are unchanged by the wait mechanism: an expired
write wait still yields the empty-`statuses` unconfirmed reply, and an expired
per-tag `ReadBulk` wait still reports its own timeout.
The write wait's budget is `MxGateway:Worker:WriteCompletionWaitMilliseconds`
(default 1500). It is a bounded hold on the STA per unary write, so deployments
whose write workload is effectively fire-and-forget — no consumer reads the
reply's `statuses` — can lower it, or set `0` to skip the wait entirely and
reply on acceptance alone.
## COM Creation
The MXAccess analysis source at `C:\Users\dohertj2\Desktop\mxaccess` identifies
@@ -378,16 +426,30 @@ If event conversion throws, catch it inside the event handler, record a
structured `WorkerFault`, and keep the worker alive only if the fault policy
allows it.
The event drain loop streams queued events as `WorkerEvent` frames. A single
event whose envelope exceeds the negotiated frame maximum is **undeliverable end
to end** — the pipe maximum sits only the envelope-overhead reserve above the
public gRPC cap, so a frame the pipe rejects would also be rejected on the
client-facing stream. The session therefore faults on it rather than dropping it
(a silent drop makes the event stream unfaithful, and a synthesized placeholder
is barred by the no-synthesized-events rule), but the death is structured: the
worker logs the event's identity — family, handles, worker sequence, and sizes,
never the value — writes a `WorkerFault` with category `ProtocolViolation` and
command method `EventDrain` carrying the same identity, and only then exits.
The event drain loop streams queued events as `WorkerEvent` frames. It is
**signal-driven, not polled**: `MxAccessEventQueue` carries a wake signal that
`Enqueue` and `RecordFault` release (outside the queue lock, so the STA's enqueue
stays a lock acquire plus a non-blocking release), and a drain that comes back
empty waits on that signal rather than sleeping. The signal is capped at one
pending wake, so a burst coalesces into a single wake and the waiter re-drains
everything that arrived the loop must therefore re-check `DrainFault()` and
re-drain after every wait, never treat a wake as "exactly one event". The 25 ms
`EventDrainInterval` survives as the **fallback ceiling** on an unsignalled wait,
not as a latency floor: an event arriving at an idle worker is framed at signal
latency instead of waiting out a tick, an idle worker parks instead of waking 40
times a second, and the interval only bounds how long the loop may sleep if some
future path mutates the queue without signalling.
A single event whose envelope exceeds the negotiated frame maximum is
**undeliverable end to end** — the pipe maximum sits only the envelope-overhead
reserve above the public gRPC cap, so a frame the pipe rejects would also be
rejected on the client-facing stream. The session therefore faults on it rather
than dropping it (a silent drop makes the event stream unfaithful, and a
synthesized placeholder is barred by the no-synthesized-events rule), but the
death is structured: the worker logs the event's identity — family, handles,
worker sequence, and sizes, never the value — writes a `WorkerFault` with
category `ProtocolViolation` and command method `EventDrain` carrying the same
identity, and only then exits.
Operator remediation is configuration: raise `MxGateway:Worker:MaxMessageBytes`
for that workload. Other per-frame rejection codes keep their previous behavior
because they indicate worker bugs, not workload size.