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:
@@ -373,6 +373,43 @@ public sealed class WorkerFrameProtocolTests
|
||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerEvent, frame3.BodyCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the writer coalesces the flush across a batch of frames drained together: four frames
|
||||
/// queued behind an in-progress write drain in a single pass and share one FlushAsync, not four
|
||||
/// (WRK-12 / IPC-15). Every frame still reaches the wire intact.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_WhenBatchDrainedTogether_FlushesOnce()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using GatedWriteStream stream = new();
|
||||
WorkerFrameWriter writer = new(stream, options);
|
||||
|
||||
// A blocked first write occupies the writer and holds the lock while more frames queue behind it.
|
||||
Task firstWrite = writer.WriteAsync(CreateGatewayHelloEnvelope(), WorkerFrameWritePriority.Control);
|
||||
await AwaitWithTimeoutAsync(stream.FirstWriteStarted);
|
||||
|
||||
Task eventWrite1 = writer.WriteAsync(CreateEventEnvelope(), WorkerFrameWritePriority.Event);
|
||||
Task eventWrite2 = writer.WriteAsync(CreateEventEnvelope(), WorkerFrameWritePriority.Event);
|
||||
Task eventWrite3 = writer.WriteAsync(CreateEventEnvelope(), WorkerFrameWritePriority.Event);
|
||||
await Task.Delay(50);
|
||||
|
||||
stream.ReleaseFirstWrite();
|
||||
await AwaitWithTimeoutAsync(Task.WhenAll(firstWrite, eventWrite1, eventWrite2, eventWrite3));
|
||||
|
||||
// Four frames written in one drain pass => exactly one flush.
|
||||
Assert.Equal(1, stream.FlushCount);
|
||||
|
||||
stream.Position = 0;
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
for (int index = 0; index < 4; index++)
|
||||
{
|
||||
WorkerEnvelope frame = await reader.ReadAsync();
|
||||
Assert.NotEqual(WorkerEnvelope.BodyOneofCase.None, frame.BodyCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies a zero negotiated frame maximum keeps the constructor default (IPC-02).</summary>
|
||||
[Fact]
|
||||
public void AdoptNegotiatedMaxMessageBytes_WithZero_KeepsDefault()
|
||||
@@ -451,9 +488,12 @@ public sealed class WorkerFrameProtocolTests
|
||||
private readonly TaskCompletionSource<bool> _firstWriteStarted =
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _writeCount;
|
||||
private int _flushCount;
|
||||
|
||||
public Task FirstWriteStarted => _firstWriteStarted.Task;
|
||||
|
||||
public int FlushCount => Volatile.Read(ref _flushCount);
|
||||
|
||||
public void ReleaseFirstWrite() => _release.Release();
|
||||
|
||||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
@@ -467,6 +507,12 @@ public sealed class WorkerFrameProtocolTests
|
||||
await base.WriteAsync(buffer, offset, count, cancellationToken);
|
||||
}
|
||||
|
||||
public override Task FlushAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _flushCount);
|
||||
return base.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
|
||||
Reference in New Issue
Block a user