perf(worker): event hot-path allocation + flush cuts (WRK-06/11/12, IPC-15)
WRK-06: MxStatusProxyConverter caches the four resolved FieldInfo per status type in a static ConcurrentDictionary (the GetField metadata scan ran 4x per status per event on the STA path). GetValue+Convert.ToInt32 still run per event (late-bound RCW). Exceptions byte-identical: missing-field message unchanged (ResolveField, not cached on throw via GetOrAdd); null-value message unchanged. WRK-11: MxAccessEventQueue.Enqueue takes ownership of the passed MxEvent - stamps WorkerSequence/WorkerTimestamp on it in place and enqueues it, no Clone(). Audited all 3 callers (base/alarm event sinks, provider-mode handler): each builds a fresh event per Enqueue, none reuse it. MxAccessValueCache.Set now deep-copies its retained Value/SourceTimestamp/Statuses so the cache snapshot never aliases the queue-owned (later serialized) event. Net: alarm/other events clone nothing (was full clone); data-change clones payload-only. WRK-12: WorkerFrameWriter coalesces the flush across a drained batch - each frame is written but not flushed individually; one FlushAsync after the batch, then all written frames complete. Preserves the written+flushed completion contract; a burst of N events costs 1 flush, not N. On write failure the whole in-flight batch + queue fail so no caller hangs. IPC-15 (doc): the multi-event WorkerEnvelope body remains unimplemented (wire still carries one event per worker_event frame); gateway.md Performance section now distinguishes the shipped flush-coalescing from that deferred proto change. net48-safe (no init/records; readonly struct cache entry). Worker builds x86 only - verification on windev. Tests added: converter cache-reuse, queue ownership-transfer, value-cache snapshot independence, writer batch-flush-once. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
@@ -112,36 +112,77 @@ public sealed class WorkerFrameWriter
|
||||
// Runs only under _writeLock. Drains control frames before event frames, stamping and writing each.
|
||||
// The stream write itself is not cancellable: a frame is written atomically or fails, never left
|
||||
// half-written on the pipe because a caller gave up waiting.
|
||||
//
|
||||
// Flushes are coalesced across the whole drained batch (WRK-12 / IPC-15): each frame is written to
|
||||
// the stream but not flushed individually; a single FlushAsync runs after the batch, then every
|
||||
// successfully-written frame is completed. A caller's Completion therefore still signals only after
|
||||
// its bytes have been written AND flushed, so the "written and flushed" contract is unchanged — but
|
||||
// a burst of N events now costs one flush syscall instead of N.
|
||||
private async Task DrainQueuedFramesAsync()
|
||||
{
|
||||
List<PendingFrame> written = new List<PendingFrame>();
|
||||
while (true)
|
||||
{
|
||||
PendingFrame? frame = DequeueNext();
|
||||
if (frame is null)
|
||||
{
|
||||
return;
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await WriteFrameAsync(frame.Envelope).ConfigureAwait(false);
|
||||
frame.Completion.TrySetResult(true);
|
||||
written.Add(frame);
|
||||
}
|
||||
catch (WorkerFrameProtocolException exception) when (IsPerFrameRejection(exception))
|
||||
{
|
||||
// Validation, empty-payload, and oversized-frame errors are specific to this frame and
|
||||
// do not damage the stream; fail only this frame and keep draining the rest.
|
||||
// do not damage the stream; fail only this frame and keep draining the rest. Nothing was
|
||||
// written for it, so it needs no flush.
|
||||
frame.Completion.TrySetException(exception);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A stream write/flush failure means the pipe is broken; fail this frame and every frame
|
||||
// still queued so no caller awaits forever, then stop draining.
|
||||
// A stream write failure means the pipe is broken; fail this frame, every frame already
|
||||
// written this batch but not yet flushed, and every frame still queued so no caller
|
||||
// awaits forever, then stop draining.
|
||||
frame.Completion.TrySetException(exception);
|
||||
FailFrames(written, exception);
|
||||
FailAllQueued(exception);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (written.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _stream.FlushAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// The batch reached the stream but the flush that guarantees delivery failed: the pipe is
|
||||
// broken. Fail every frame in the batch (the queue was already drained) so no caller treats
|
||||
// an unflushed write as delivered.
|
||||
FailFrames(written, exception);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (PendingFrame frame in written)
|
||||
{
|
||||
frame.Completion.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void FailFrames(List<PendingFrame> frames, Exception exception)
|
||||
{
|
||||
foreach (PendingFrame frame in frames)
|
||||
{
|
||||
frame.Completion.TrySetException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPerFrameRejection(WorkerFrameProtocolException exception)
|
||||
@@ -211,14 +252,14 @@ public sealed class WorkerFrameWriter
|
||||
|
||||
// Serialize once into a single 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, and a separate prefix write.
|
||||
// prefix array, and a separate prefix write. The flush is deferred to the end of the drained
|
||||
// batch (see DrainQueuedFramesAsync) so a burst of frames shares one flush.
|
||||
int frameLength = sizeof(uint) + payloadLength;
|
||||
byte[] frame = new byte[frameLength];
|
||||
WriteUInt32LittleEndian(frame, (uint)payloadLength);
|
||||
envelope.WriteTo(new Span<byte>(frame, sizeof(uint), payloadLength));
|
||||
|
||||
await _stream.WriteAsync(frame, 0, frameLength, CancellationToken.None).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void WriteUInt32LittleEndian(
|
||||
|
||||
Reference in New Issue
Block a user