perf(worker): message-driven completion waits — the STA pumps continuously while waiting
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user