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:
Joseph Doherty
2026-07-09 15:52:49 -04:00
parent 73ce824f6c
commit f61c816acf
9 changed files with 303 additions and 28 deletions
@@ -29,6 +29,26 @@ public sealed class MxAccessEventQueueTests
Assert.False(queue.TryDequeue(out _));
}
/// <summary>
/// Verifies that Enqueue takes ownership of the passed event instead of
/// cloning it: the dequeued instance is the very reference passed in, and
/// the worker sequence/timestamp are stamped on that same instance
/// (WRK-11).
/// </summary>
[Fact]
public void Enqueue_TakesOwnershipOfPassedEventInstance()
{
MxAccessEventQueue queue = new(capacity: 4);
MxEvent original = CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10);
queue.Enqueue(original);
Assert.True(queue.TryDequeue(out WorkerEvent? dequeued));
Assert.Same(original, dequeued?.Event);
Assert.Equal(1UL, original.WorkerSequence);
Assert.NotNull(original.WorkerTimestamp);
}
/// <summary>Verifies that Drain removes at most the requested number of events.</summary>
[Fact]
public void Drain_RemovesAtMostRequestedEvents()
@@ -46,6 +46,37 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(999, other.Value.Int32Value);
}
/// <summary>
/// Verifies that Set stores an independent deep-copied snapshot: mutating
/// the source event's protobuf sub-messages after caching does not alter
/// the cached value. WRK-11 stopped the event sink cloning before enqueue,
/// so the same MxEvent instance now flows to the outbound queue; the cache
/// must own its own copy so the two never share mutable state.
/// </summary>
[Fact]
public void Set_StoresIndependentSnapshot_UnaffectedByLaterEventMutation()
{
MxAccessValueCache cache = new();
Timestamp sourceTimestamp = Timestamp.FromDateTime(new(2026, 5, 19, 9, 0, 0, DateTimeKind.Utc));
MxEvent mxEvent = BuildEvent(serverHandle: 7, itemHandle: 21, intValue: 100, quality: 192, sourceTimestamp);
cache.Set(7, 21, mxEvent);
// Mutate the event in place after it was cached — as if it kept flowing
// through the (unrelated) outbound path. None of this must reach the cache.
mxEvent.Value.Int32Value = 999;
mxEvent.Quality = 0;
mxEvent.SourceTimestamp = Timestamp.FromDateTime(new(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
mxEvent.Statuses[0].Category = MxStatusCategory.SecurityError;
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue cached));
Assert.Equal(100, cached.Value.Int32Value);
Assert.Equal(192, cached.Quality);
Assert.Equal(sourceTimestamp, cached.SourceTimestamp);
Assert.Single(cached.Statuses);
Assert.Equal(MxStatusCategory.Ok, cached.Statuses[0].Category);
}
/// <summary>Verifies that TryGet returns false for unknown handles.</summary>
[Fact]
public void TryGet_WithUnknownHandle_ReturnsFalse()