perf(worker): memoize event frame size at enqueue; drain stops sizing under the STA's lock
This commit is contained in:
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
@@ -21,6 +22,11 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
/// MxAccessAlarmEventSink, AlarmCommandHandler) build a new event per
|
||||
/// Enqueue via the mapper and satisfy this; the value cache stores its own
|
||||
/// independent snapshot (see <see cref="MxAccessValueCache.Set"/>).
|
||||
/// <para>
|
||||
/// The byte-budgeted <see cref="Drain(uint, int)"/> relies on the same invariant: each
|
||||
/// event's serialized size is measured once at enqueue and stored beside it, which is
|
||||
/// only sound because an enqueued event is never mutated again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MxAccessEventQueue
|
||||
{
|
||||
@@ -63,8 +69,16 @@ public sealed class MxAccessEventQueue
|
||||
// edge can never push the packed reply past the frame maximum.
|
||||
private const int RepeatedFieldOverheadBytes = 8;
|
||||
|
||||
// Every field the enqueue-time size reconstruction charges by hand — WorkerEvent.event (field
|
||||
// 1), MxEvent.worker_sequence (9) and MxEvent.worker_timestamp (10) — has a field number below
|
||||
// 16, so protobuf encodes each of their tags in a single byte. Renumbering any of them above 15
|
||||
// would widen its tag and make this estimate one byte light per field, which the
|
||||
// RepeatedFieldOverheadBytes slack absorbs — so the frame stays safe — but the constant should
|
||||
// be revisited with the .proto.
|
||||
private const int SingleByteFieldTagBytes = 1;
|
||||
|
||||
private readonly int capacity;
|
||||
private readonly Queue<WorkerEvent> events;
|
||||
private readonly Queue<QueuedEvent> events;
|
||||
private readonly object syncRoot = new();
|
||||
|
||||
// Wake signal for the worker's event drain loop (see WorkerPipeSession.RunEventDrainLoopAsync).
|
||||
@@ -110,7 +124,7 @@ public sealed class MxAccessEventQueue
|
||||
}
|
||||
|
||||
this.capacity = capacity;
|
||||
events = new Queue<WorkerEvent>(capacity);
|
||||
events = new Queue<QueuedEvent>(capacity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -210,6 +224,28 @@ public sealed class MxAccessEventQueue
|
||||
throw new ArgumentNullException(nameof(mxEvent));
|
||||
}
|
||||
|
||||
// Size the event here, outside syncRoot, and carry the result into the queue. The
|
||||
// byte-budgeted Drain used to call CalculateSize() on every candidate while holding the
|
||||
// lock, so a large gateway-pulled drain kept the STA's next Enqueue blocked for the whole
|
||||
// sizing walk — exactly when a burst of COM callbacks needs the lock. Measuring on the
|
||||
// enqueuing thread is safe on both counts: that thread owns this event exclusively at this
|
||||
// point, and WRK-11 (see the class remarks) makes the hand-off final — nothing mutates an
|
||||
// event after Enqueue, so a memoized size can never go stale.
|
||||
//
|
||||
// Enqueue itself stamps the worker sequence and worker timestamp, and those must stay under
|
||||
// the lock (the sequence has to be assigned in queue order), so they are excluded from this
|
||||
// measurement and charged back by CalculateWorkerEventSize with O(1) arithmetic instead of a
|
||||
// second walk of the message. Clearing them first is observationally a no-op — both are
|
||||
// overwritten unconditionally below — and it makes this measurement exactly "the event minus
|
||||
// the two stamped fields" even if a caller pre-filled them.
|
||||
//
|
||||
// CalculateSize() on a valid protobuf message does not throw, so moving it ahead of the
|
||||
// fault and overflow checks introduces no new failure before them; an event those checks
|
||||
// reject simply discards the size computed here.
|
||||
mxEvent.WorkerSequence = 0UL;
|
||||
mxEvent.WorkerTimestamp = null;
|
||||
int unstampedEventSize = mxEvent.CalculateSize();
|
||||
|
||||
try
|
||||
{
|
||||
lock (syncRoot)
|
||||
@@ -237,7 +273,9 @@ public sealed class MxAccessEventQueue
|
||||
{
|
||||
Event = mxEvent,
|
||||
};
|
||||
events.Enqueue(workerEvent);
|
||||
events.Enqueue(new QueuedEvent(
|
||||
workerEvent,
|
||||
CalculateWorkerEventSize(mxEvent, unstampedEventSize)));
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -295,7 +333,7 @@ public sealed class MxAccessEventQueue
|
||||
return false;
|
||||
}
|
||||
|
||||
workerEvent = events.Dequeue();
|
||||
workerEvent = events.Dequeue().Event;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -320,7 +358,7 @@ public sealed class MxAccessEventQueue
|
||||
List<WorkerEvent> drained = new(drainCount);
|
||||
for (int index = 0; index < drainCount; index++)
|
||||
{
|
||||
drained.Add(events.Dequeue());
|
||||
drained.Add(events.Dequeue().Event);
|
||||
}
|
||||
|
||||
return drained;
|
||||
@@ -334,11 +372,13 @@ public sealed class MxAccessEventQueue
|
||||
/// <remarks>
|
||||
/// The size decision happens inside the queue lock, so an event is dequeued only once it is
|
||||
/// known to fit: an event that does not fit stays at the head for the next call and is never
|
||||
/// lost (WRK-21). Per-event cost is <c>WorkerEvent.CalculateSize()</c> — which already
|
||||
/// includes the event's own tag and length prefix, the same shape the reply's
|
||||
/// <c>events</c> repeated field packs it into — plus <see cref="RepeatedFieldOverheadBytes"/>
|
||||
/// of pure slack, so the running total stays strictly ahead of the true serialized size and
|
||||
/// the estimate errs on the safe side.
|
||||
/// lost (WRK-21). Per-event cost is the serialized <c>WorkerEvent</c> size measured once at
|
||||
/// enqueue time — which already includes the event's own tag and length prefix, the same
|
||||
/// shape the reply's <c>events</c> repeated field packs it into — plus
|
||||
/// <see cref="RepeatedFieldOverheadBytes"/> of pure slack, so the running total stays
|
||||
/// strictly ahead of the true serialized size and the estimate errs on the safe side. Only
|
||||
/// the memoized number is read here: this loop no longer walks a message under the lock the
|
||||
/// STA needs for <see cref="Enqueue"/>, so a large drain cannot stall COM callbacks.
|
||||
/// </remarks>
|
||||
/// <param name="maxEvents">Maximum number of events to drain; 0 means "no count limit".</param>
|
||||
/// <param name="maxTotalBytes">Byte budget for the drained events' estimated serialized size.</param>
|
||||
@@ -357,8 +397,8 @@ public sealed class MxAccessEventQueue
|
||||
|
||||
while (drained.Count < countLimit && events.Count > 0)
|
||||
{
|
||||
WorkerEvent head = events.Peek();
|
||||
int cost = head.CalculateSize() + RepeatedFieldOverheadBytes;
|
||||
QueuedEvent head = events.Peek();
|
||||
int cost = head.Size + RepeatedFieldOverheadBytes;
|
||||
if (cost > remainingBudget)
|
||||
{
|
||||
truncatedBySize = true;
|
||||
@@ -367,14 +407,14 @@ public sealed class MxAccessEventQueue
|
||||
// The head alone cannot fit this budget, so repeating the call will not
|
||||
// move it either. Report its sequence instead of silently stalling; the
|
||||
// events that did fit are still returned.
|
||||
oversizedHeadSequence = head.Event?.WorkerSequence ?? 0;
|
||||
oversizedHeadSequence = head.Event.Event?.WorkerSequence ?? 0;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
remainingBudget -= cost;
|
||||
drained.Add(events.Dequeue());
|
||||
drained.Add(events.Dequeue().Event);
|
||||
}
|
||||
|
||||
return new WorkerEventDrainResult(
|
||||
@@ -447,6 +487,24 @@ public sealed class MxAccessEventQueue
|
||||
}
|
||||
}
|
||||
|
||||
// Serialized size of the WorkerEvent wrapping mxEvent, rebuilt from the size measured before the
|
||||
// lock plus the exact cost of the two fields Enqueue stamps inside it. It mirrors the generated
|
||||
// CalculateSize() instead of calling it, because calling it again under the lock is the cost
|
||||
// this memoization exists to remove: WorkerEvent carries the single length-delimited `event`
|
||||
// field, MxEvent charges worker_sequence as tag + varint and worker_timestamp as tag +
|
||||
// length-delimited message, and both are always serialized here — the sequence is
|
||||
// pre-incremented, so it is never the proto3 default that generated code would skip, and the
|
||||
// timestamp is never null. Exactness matters: Drain must charge the same bytes it charged when
|
||||
// it sized events itself, so an unchanged budget still yields unchanged batches.
|
||||
private static int CalculateWorkerEventSize(MxEvent mxEvent, int unstampedEventSize)
|
||||
{
|
||||
int eventSize = unstampedEventSize
|
||||
+ SingleByteFieldTagBytes + CodedOutputStream.ComputeUInt64Size(mxEvent.WorkerSequence)
|
||||
+ SingleByteFieldTagBytes + CodedOutputStream.ComputeMessageSize(mxEvent.WorkerTimestamp);
|
||||
|
||||
return SingleByteFieldTagBytes + CodedOutputStream.ComputeLengthSize(eventSize) + eventSize;
|
||||
}
|
||||
|
||||
private WorkerFault CreateOverflowFault()
|
||||
{
|
||||
string message = $"MXAccess outbound event queue reached capacity {capacity}.";
|
||||
@@ -461,4 +519,24 @@ public sealed class MxAccessEventQueue
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A queued event and the serialized size measured for it at enqueue time. A struct, so carrying
|
||||
// the size costs the queue one extra field per slot rather than an allocation per event on the
|
||||
// STA's path. Nothing outside the queue sees it: every drain hands back the WorkerEvent alone,
|
||||
// exactly as before.
|
||||
private readonly struct QueuedEvent
|
||||
{
|
||||
public QueuedEvent(WorkerEvent workerEvent, int size)
|
||||
{
|
||||
Event = workerEvent;
|
||||
Size = size;
|
||||
}
|
||||
|
||||
// The queued event, returned unchanged by TryDequeue and both Drain overloads.
|
||||
public WorkerEvent Event { get; }
|
||||
|
||||
// Serialized size of Event, valid for the queue's whole lifetime because an enqueued event
|
||||
// is never mutated again (WRK-11).
|
||||
public int Size { get; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user