perf(worker): control-frame completions resolve at the class-transition flush, not after the event batch
The two-class writer already got control bytes out ahead of a queued event backlog, but a frame counts as delivered only once flushed, and the drain deferred its single FlushAsync — and every TrySetResult — to the end of the pass. A heartbeat, command reply, fault, or shutdown ack was therefore written first and completed last, behind up to a full 128-frame event batch. The drain now records each frame's priority class on PendingFrame and flushes at every control-to-event boundary, completing and clearing the written set there. Cost stays bounded: a pure-event pass still pays exactly one flush, a run of control frames still pays one for the run, and only a pass that mixes both classes pays a second — never one flush per control frame, the syscall-per-heartbeat cost WRK-12 removed. A boundary flush that itself fails is a new failure window and is handled like the end-of-pass flush failure, additionally failing the event frame the drain had already claimed off its queue and every frame still queued. Frames a boundary flush completed leave the written set, so a later failure in the same pass can no longer reach back and fail an already-delivered control frame. The awaited task of a caller that lost the write-lock race is still bounded by the winning drainer's pass — that enqueue-then-contend parking is unchanged and now documented on WriteAsync and in docs/WorkerFrameProtocol.md.
This commit is contained in:
+46
-11
@@ -88,7 +88,9 @@ priority order. A caller enqueues its frame into the control or event queue
|
||||
under a lock, then contends for a single write lock; whichever caller wins
|
||||
drains every frame queued at that moment, control frames first and each class
|
||||
in FIFO order, so a command reply, fault, heartbeat, or shutdown
|
||||
acknowledgement is never delayed behind a backlog of queued events. Priority
|
||||
acknowledgement is never delayed behind a backlog of queued events — neither
|
||||
in the bytes written nor in the flush that marks them delivered (see the
|
||||
class-boundary flush under flush coalescing below). Priority
|
||||
only reorders *which frame writes next* — it does not affect the sequence
|
||||
value a frame receives (see below), so a caller cannot infer priority class
|
||||
from the wire sequence.
|
||||
@@ -117,13 +119,36 @@ Two failure shapes are distinguished during a drain pass:
|
||||
and every frame still queued, then stops draining entirely so no caller
|
||||
waits forever on a stream that will not recover.
|
||||
|
||||
Flushes are coalesced across a drained batch: each frame in the batch is
|
||||
written to the stream without an individual flush, then one `FlushAsync`
|
||||
runs after the whole batch, and only then does every successfully-written
|
||||
frame's completion resolve — so a caller's `WriteAsync` still does not
|
||||
complete until its bytes are both written *and* flushed, but a batch that
|
||||
happened to contain several queued frames pays one flush instead of one per
|
||||
frame. Note the ordering this implies at the peer: the frames reach the pipe
|
||||
Flushes are coalesced across a *run of same-class frames* inside a drain
|
||||
pass: each frame in the run is written to the stream without an individual
|
||||
flush, then one `FlushAsync` runs — at the end of the pass, and additionally
|
||||
at every control-to-event boundary — and only then does every
|
||||
successfully-written frame of that run resolve its completion. A caller's
|
||||
`WriteAsync` therefore still does not complete until its bytes are both
|
||||
written *and* flushed; what changed is *when* that moment arrives
|
||||
for a control frame that a pass writes ahead of queued events. It used to be
|
||||
the end of the pass, so a heartbeat, command reply, fault, or shutdown
|
||||
acknowledgement was written first but only counted as delivered after up to a
|
||||
full event batch had been written and flushed behind it. The boundary flush
|
||||
closes the control run out before the events are written, so the priority
|
||||
class governs the frame's delivery point and not just its byte order. The
|
||||
cost stays bounded: a pure-event pass — the event hot path — still pays
|
||||
exactly one flush however many frames drain together, a run of control
|
||||
frames still pays one for the whole run (never one per heartbeat, the
|
||||
syscall-per-frame cost the coalescing removed), and only a pass that actually
|
||||
mixes both classes pays a second.
|
||||
|
||||
One consequence of the boundary flush is worth stating: a control frame whose
|
||||
run has already been flushed and completed is out of the drain's
|
||||
written-but-unflushed set, so a *later* failure in the same pass — a broken
|
||||
write, or a failed end-of-pass flush — no longer reaches back and fails it.
|
||||
That is the honest outcome: its bytes were flushed, so it was delivered. A
|
||||
failure of the boundary flush itself is treated exactly like a failed
|
||||
end-of-pass flush, and additionally fails the event frame the drain had
|
||||
already claimed off its queue (nothing else would ever complete it) along
|
||||
with every frame still queued.
|
||||
|
||||
Note the ordering all of this implies at the peer: the frames reach the pipe
|
||||
before the flush that follows them, so the gateway can read a whole batch
|
||||
while the writer has not yet flushed it. Anything observing the flush itself
|
||||
(a test counting flushes, for instance) must wait for the flush, not infer it
|
||||
@@ -134,12 +159,22 @@ drains them together, so a burst of N events costs one flush rather than N —
|
||||
the coalescing the batch machinery was built for now engages on the event hot
|
||||
path, not only when independent producers happen to queue behind a blocked
|
||||
write. Intra-batch order is preserved (FIFO enqueue under one lock), and a
|
||||
concurrently queued control frame is still drained ahead of the batch. A
|
||||
per-frame rejection inside a batch (for example one oversized event) surfaces
|
||||
from the batch's awaited completions as that frame's
|
||||
concurrently queued control frame is still drained — and now flushed and
|
||||
completed — ahead of the batch's remaining events, which is why a batch a
|
||||
control frame cuts into pays one extra flush while an uninterrupted batch
|
||||
still pays exactly one. A per-frame rejection inside a batch (for example one
|
||||
oversized event) surfaces from the batch's awaited completions as that frame's
|
||||
`WorkerFrameProtocolException`; the remaining completions are still observed
|
||||
so none faults unobserved.
|
||||
|
||||
The completion is the frame's delivery point, not necessarily the instant its
|
||||
caller returns. A caller that loses the race for the write lock only observes
|
||||
its own completion after the winning drainer releases the lock, so its return
|
||||
remains bounded by that drain pass even though its control frame was flushed
|
||||
and completed at the class boundary inside it. The boundary flush is what
|
||||
makes the delivery point honest; unparking a lock-race loser from the winner's
|
||||
pass would be a separate change to the enqueue-then-contend shape.
|
||||
|
||||
Cancellation of a `WriteAsync`/`WriteBatchAsync` call that is still waiting
|
||||
for the write lock when its token fires tombstones the queued frame: the
|
||||
cancelled caller marks its frame under `_gate`, and the draining lock-holder's
|
||||
|
||||
Reference in New Issue
Block a user