using System; using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Google.Protobuf.WellKnownTypes; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.MxAccess; namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessEventQueueTests { /// Verifies that Enqueue assigns monotonic worker sequences and preserves event order. [Fact] public void Enqueue_AssignsMonotonicWorkerSequencesAndPreservesOrder() { MxAccessEventQueue queue = new(capacity: 4); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10)); queue.Enqueue(CreateEvent(MxEventFamily.OnWriteComplete, itemHandle: 11)); Assert.Equal(2, queue.Count); Assert.Equal(2UL, queue.LastEventSequence); Assert.True(queue.TryDequeue(out WorkerEvent? dequeuedFirst)); Assert.True(queue.TryDequeue(out WorkerEvent? dequeuedSecond)); Assert.Equal(1UL, dequeuedFirst?.Event.WorkerSequence); Assert.Equal(2UL, dequeuedSecond?.Event.WorkerSequence); Assert.NotNull(dequeuedFirst?.Event.WorkerTimestamp); Assert.Equal(10, dequeuedFirst?.Event.ItemHandle); Assert.Equal(11, dequeuedSecond?.Event.ItemHandle); Assert.False(queue.TryDequeue(out _)); } /// /// 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). /// [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); } /// Verifies that Drain removes at most the requested number of events. [Fact] public void Drain_RemovesAtMostRequestedEvents() { MxAccessEventQueue queue = new(capacity: 4); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10)); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11)); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12)); IReadOnlyList drained = queue.Drain(maxEvents: 2); Assert.Equal(2, drained.Count); Assert.Equal(10, drained[0].Event.ItemHandle); Assert.Equal(11, drained[1].Event.ItemHandle); Assert.Equal(1, queue.Count); } /// Verifies that Drain with maxEvents 0 drains every queued event. [Fact] public void Drain_WithZeroMaxEvents_DrainsAllEvents() { MxAccessEventQueue queue = new(capacity: 4); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10)); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11)); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12)); IReadOnlyList drained = queue.Drain(maxEvents: 0); Assert.Equal(3, drained.Count); Assert.Equal(new[] { 10, 11, 12 }, new[] { drained[0].Event.ItemHandle, drained[1].Event.ItemHandle, drained[2].Event.ItemHandle, }); Assert.Equal(0, queue.Count); } /// Verifies that draining an empty queue returns an empty list. [Fact] public void Drain_WhenQueueIsEmpty_ReturnsEmptyList() { MxAccessEventQueue queue = new(capacity: 4); Assert.Empty(queue.Drain(maxEvents: 0)); Assert.Empty(queue.Drain(maxEvents: 5)); Assert.Equal(0, queue.Count); } /// /// Verifies the byte-budgeted drain stops before the budget is exceeded, leaves the /// remainder queued in order, and reports the exact remaining count (WRK-21). Events that /// do not fit must never be dequeued — dequeuing them is how the pre-fix drain lost events /// when the reply frame was rejected. /// [Fact] public void Drain_ByteBudget_StopsBeforeBudgetAndLeavesRemainderQueued() { MxAccessEventQueue queue = new(capacity: 8); for (int itemHandle = 0; itemHandle < 5; itemHandle++) { queue.Enqueue(CreateEventWithPayload(itemHandle, payloadLength: 512)); } int perEventCost = MeasureDrainCost(payloadLength: 512); // Budget for exactly two events (plus a sliver too small for a third). IReadOnlyList drained = queue.Drain(maxEvents: 0, maxTotalBytes: (perEventCost * 2) + (perEventCost / 2)).Events; Assert.Equal(2, drained.Count); Assert.Equal(0, drained[0].Event.ItemHandle); Assert.Equal(1, drained[1].Event.ItemHandle); Assert.Equal(3, queue.Count); // The undrained remainder is still present, still in order. IReadOnlyList rest = queue.Drain(maxEvents: 0); Assert.Equal(new[] { 2, 3, 4 }, new[] { rest[0].Event.ItemHandle, rest[1].Event.ItemHandle, rest[2].Event.ItemHandle }); } /// /// Verifies the byte-budgeted drain reports truncation and the exact remaining count so the /// DrainEvents reply can tell the caller to drain again. /// [Fact] public void Drain_ByteBudget_ReportsTruncationAndRemainingCount() { MxAccessEventQueue queue = new(capacity: 8); for (int itemHandle = 0; itemHandle < 4; itemHandle++) { queue.Enqueue(CreateEventWithPayload(itemHandle, payloadLength: 256)); } // One-and-a-half events' worth of budget: the head fits, the next does not, and the next is // comfortably smaller than the whole budget so it is a plain truncation rather than the // oversized-head case. WorkerEventDrainResult result = queue.Drain( maxEvents: 0, maxTotalBytes: MeasureDrainCost(payloadLength: 256) * 3 / 2); Assert.Single(result.Events); Assert.True(result.TruncatedBySize); Assert.Equal(3, result.RemainingCount); Assert.Equal(0UL, result.OversizedHeadSequence); } /// /// Verifies the degenerate case: a head event whose own serialized size exceeds the whole /// budget is not drained (draining it would build an oversized reply or lose the event) and /// its worker sequence is reported so an operator can find the offending tag. /// [Fact] public void Drain_ByteBudget_OversizedHead_DrainsNothingAndReportsHeadSequence() { MxAccessEventQueue queue = new(capacity: 8); queue.Enqueue(CreateEventWithPayload(itemHandle: 0, payloadLength: 4096)); queue.Enqueue(CreateEventWithPayload(itemHandle: 1, payloadLength: 8)); WorkerEventDrainResult result = queue.Drain(maxEvents: 0, maxTotalBytes: 1024); Assert.Empty(result.Events); Assert.True(result.TruncatedBySize); Assert.Equal(2, result.RemainingCount); Assert.Equal(1UL, result.OversizedHeadSequence); // The blocked event is still queued — it was never removed. Assert.Equal(2, queue.Count); } /// /// The no-loss half of the WRK-21 acceptance criterion, at full scale. Draining the review's /// 10,000 byte-heavy events under a budget that fits only a fraction of them per call must /// return every event exactly once and in order: the pre-fix drain removed events from the /// queue before the reply was sized, so a rejected frame destroyed them. This runs at the /// queue layer because the property is the queue's, and because the pipe harness that covers /// the same walk end to end cannot sustain hundreds of large round trips. /// [Fact] public void Drain_ByteBudget_RepeatedCalls_RecoverAllEventsInOrderWithoutLoss() { const int eventCount = 10_000; const int payloadLength = 1_800; MxAccessEventQueue queue = new(eventCount); for (int index = 0; index < eventCount; index++) { queue.Enqueue(CreateEventWithPayload(index, payloadLength)); } // A budget that fits roughly 35 events, so the walk takes hundreds of calls. int budget = MeasureDrainCost(payloadLength) * 35; List recovered = new(); int calls = 0; while (true) { WorkerEventDrainResult result = queue.Drain(maxEvents: 0, maxTotalBytes: budget); calls++; if (result.Events.Count == 0) { break; } foreach (WorkerEvent drained in result.Events) { recovered.Add(drained.Event.WorkerSequence); } Assert.Equal(eventCount - recovered.Count, result.RemainingCount); Assert.True(calls < eventCount, "Drain made no progress."); } Assert.True(calls > 100, $"Expected the byte budget to split the drain, saw {calls} calls."); Assert.Equal(eventCount, recovered.Count); for (int index = 0; index < recovered.Count; index++) { Assert.Equal((ulong)(index + 1), recovered[index]); } Assert.Equal(0, queue.Count); } /// /// Verifies the count cap still binds when the byte budget is generous: the byte cap is an /// additional bound, not a replacement. /// [Fact] public void Drain_ByteBudget_CountCapStillBinds() { MxAccessEventQueue queue = new(capacity: 8); for (int itemHandle = 0; itemHandle < 5; itemHandle++) { queue.Enqueue(CreateEventWithPayload(itemHandle, payloadLength: 16)); } WorkerEventDrainResult result = queue.Drain(maxEvents: 2, maxTotalBytes: 1024 * 1024); Assert.Equal(2, result.Events.Count); Assert.False(result.TruncatedBySize); Assert.Equal(3, result.RemainingCount); } /// /// Brackets the byte budget's per-event charge to the exact serialized size of the event. /// The queue measures that size once at enqueue rather than during the drain, so this pins /// the two ends of the charge against the very instance the queue holds: a budget one byte /// short must refuse the head (and leave it queued, WRK-21), and a budget of exactly the /// charge must ship it. An undercharge — sizing the event before Enqueue stamps its worker /// sequence and timestamp, say — passes the first probe and breaks the frame guarantee; an /// overcharge of even one byte fails the second. The preStamped case covers an event /// that arrives with those two fields already filled, which Enqueue overwrites. /// /// Whether the event carries a stale sequence/timestamp on arrival. [Theory] [InlineData(false)] [InlineData(true)] public void Drain_ByteBudget_ChargesTheEventsExactSerializedSize(bool preStamped) { MxAccessEventQueue queue = new(capacity: 4); MxEvent mxEvent = CreateEventWithPayload(itemHandle: 7, payloadLength: 300); if (preStamped) { mxEvent.WorkerSequence = ulong.MaxValue; mxEvent.WorkerTimestamp = Timestamp.FromDateTime( new DateTime(1990, 1, 1, 0, 0, 0, DateTimeKind.Utc)); } queue.Enqueue(mxEvent); // Enqueue takes ownership but never mutates the event afterwards, so the retained reference // measures exactly what the queue holds — no second drain is needed to learn the cost. Assert.Equal(1UL, mxEvent.WorkerSequence); int exactCost = DrainCostOf(new WorkerEvent { Event = mxEvent }); WorkerEventDrainResult refused = queue.Drain(maxEvents: 0, maxTotalBytes: exactCost - 1); Assert.Empty(refused.Events); Assert.True(refused.TruncatedBySize); Assert.Equal(1UL, refused.OversizedHeadSequence); Assert.Equal(1, queue.Count); WorkerEventDrainResult drained = queue.Drain(maxEvents: 0, maxTotalBytes: exactCost); Assert.Single(drained.Events); Assert.Same(mxEvent, drained.Events[0].Event); Assert.False(drained.TruncatedBySize); Assert.Equal(0, queue.Count); } /// /// Pins the batch boundaries of a byte-budgeted walk over a mixed-size queue. Each batch is /// checked against the true serialized sizes of the events it returned: no batch may exceed /// the budget (an undercharge would build a reply past the frame maximum) and no batch may /// stop early — the next event, at its real size, must not have fit (an overcharge would /// ship frames smaller than the negotiated maximum allows). Both bounds come from the /// drained events themselves, so any drift between the size memoized at enqueue and the real /// one moves a boundary and fails here. /// [Fact] public void Drain_ByteBudget_MixedSizes_KeepsBatchBoundariesOnTheRealSizes() { int[] payloadLengths = { 8, 512, 40, 2_048, 96, 1_200, 16, 700 }; const int eventCount = 240; // Comfortably above the largest single event's cost, so the walk is never blocked by an // oversized head and every stop is a genuine budget boundary. const int budget = 4096; MxAccessEventQueue queue = new(eventCount); for (int index = 0; index < eventCount; index++) { queue.Enqueue(CreateEventWithPayload(index, payloadLengths[index % payloadLengths.Length])); } List> batches = new(); while (true) { WorkerEventDrainResult result = queue.Drain(maxEvents: 0, maxTotalBytes: budget); if (result.Events.Count == 0) { Assert.Equal(0UL, result.OversizedHeadSequence); break; } batches.Add(result.Events); Assert.True(batches.Count <= eventCount, "Drain made no progress."); } Assert.Equal(0, queue.Count); Assert.True( batches.Count > 5, $"Expected the byte budget to split the drain, saw {batches.Count} batches."); ulong expectedSequence = 0; for (int batchIndex = 0; batchIndex < batches.Count; batchIndex++) { IReadOnlyList batch = batches[batchIndex]; int charged = 0; foreach (WorkerEvent drained in batch) { charged += DrainCostOf(drained); Assert.Equal(++expectedSequence, drained.Event.WorkerSequence); } Assert.True( charged <= budget, $"Batch {batchIndex} shipped {charged} bytes against a {budget} byte budget."); if (batchIndex + 1 < batches.Count) { int nextCost = DrainCostOf(batches[batchIndex + 1][0]); Assert.True( charged + nextCost > budget, $"Batch {batchIndex} stopped at {charged} bytes although the next event's {nextCost} still fit the {budget} byte budget."); } } Assert.Equal((ulong)eventCount, expectedSequence); } /// /// The other half of the WRK-21 head guarantee: a head refused for its size is drained /// unchanged by a later call whose budget fits it. The size the queue charges lives with the /// event across calls, so a refused attempt must neither consume nor alter it — and the /// refusal itself is justified by the event's real serialized size. /// [Fact] public void Drain_ByteBudget_RefusedHead_IsDrainedUnchangedOnceTheBudgetFitsIt() { MxAccessEventQueue queue = new(capacity: 8); queue.Enqueue(CreateEventWithPayload(itemHandle: 0, payloadLength: 4096)); queue.Enqueue(CreateEventWithPayload(itemHandle: 1, payloadLength: 8)); WorkerEventDrainResult refused = queue.Drain(maxEvents: 0, maxTotalBytes: 1024); Assert.Empty(refused.Events); Assert.True(refused.TruncatedBySize); Assert.Equal(1UL, refused.OversizedHeadSequence); Assert.Equal(2, queue.Count); WorkerEventDrainResult retried = queue.Drain(maxEvents: 0, maxTotalBytes: 64 * 1024); Assert.Equal(2, retried.Events.Count); Assert.Equal(1UL, retried.Events[0].Event.WorkerSequence); Assert.Equal(2UL, retried.Events[1].Event.WorkerSequence); Assert.False(retried.TruncatedBySize); Assert.Equal(0, retried.RemainingCount); Assert.Equal(0, queue.Count); Assert.True( DrainCostOf(retried.Events[0]) > 1024, "The head was refused although its real cost fits the 1024-byte budget it was refused under."); Assert.True( DrainCostOf(retried.Events[0]) + DrainCostOf(retried.Events[1]) <= 64 * 1024, "The retried batch exceeded the budget it was drained under."); } /// Verifies that Enqueue is rejected after a fault is recorded manually. [Fact] public void Enqueue_AfterRecordFault_ThrowsInvalidOperationException() { MxAccessEventQueue queue = new(capacity: 4); queue.RecordFault(new WorkerFault { Category = WorkerFaultCategory.MxaccessEventConversionFailed, }); Assert.Throws( () => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10))); Assert.Equal(0, queue.Count); } /// Verifies that Enqueue records an overflow fault and rejects new events when capacity is exceeded. [Fact] public void Enqueue_WhenCapacityIsExceeded_RecordsOverflowFaultAndRejectsNewEvents() { MxAccessEventQueue queue = new(capacity: 1); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10)); MxAccessEventQueueOverflowException overflow = Assert.Throws( () => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11))); Assert.Equal(1, overflow.Capacity); Assert.True(queue.IsFaulted); Assert.Equal(WorkerFaultCategory.QueueOverflow, queue.Fault?.Category); Assert.Equal(ProtocolStatusCode.WorkerUnavailable, queue.Fault?.ProtocolStatus.Code); Assert.Throws( () => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12))); } /// Verifies that RecordFault keeps the first recorded fault. [Fact] public void RecordFault_KeepsFirstFault() { MxAccessEventQueue queue = new(capacity: 1); queue.RecordFault(new WorkerFault { Category = WorkerFaultCategory.MxaccessEventConversionFailed, }); queue.RecordFault(new WorkerFault { Category = WorkerFaultCategory.QueueOverflow, }); Assert.True(queue.IsFaulted); Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, queue.Fault?.Category); } // Wake-signal timings. The fallback is far longer than the patience deliberately: every wait // below asserts "the signal completed this", which is only a faithful claim while the fallback // timeout cannot have completed it within the patience window. The patience itself is generous // so a loaded CI box cannot fail a test that is not about latency. private static readonly TimeSpan WakeFallback = TimeSpan.FromSeconds(30); private static readonly TimeSpan WakePatience = TimeSpan.FromSeconds(5); /// /// Verifies the queue wakes a parked waiter as soon as an event is enqueued, rather than /// leaving it to time out. This is what removes the drain loop's latency floor: before the /// signal existed, an event arriving at an idle queue waited out the loop's whole poll tick. /// /// A task that represents the asynchronous operation. [Fact] public async Task WaitForEventsAsync_EnqueueAfterIdle_CompletesWithoutWaitingTheFallback() { MxAccessEventQueue queue = new(capacity: 4); Task wait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None); Assert.False(wait.IsCompleted); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10)); Assert.Same(wait, await Task.WhenAny(wait, Task.Delay(WakePatience))); await wait; Assert.Equal(1, queue.Count); } /// /// Verifies a fault recorded while the waiter is parked wakes it too. The drain loop /// discovers faults by calling DrainFault() at the top of each pass, so without this /// signal an overflow or conversion fault would not be reported until the loop's fallback /// tick expired. /// /// A task that represents the asynchronous operation. [Fact] public async Task WaitForEventsAsync_RecordFaultWhileParked_WakesTheWaiter() { MxAccessEventQueue queue = new(capacity: 4); Task wait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None); Assert.False(wait.IsCompleted); queue.RecordFault(new WorkerFault { Category = WorkerFaultCategory.MxaccessEventConversionFailed, }); Assert.Same(wait, await Task.WhenAny(wait, Task.Delay(WakePatience))); await wait; Assert.NotNull(queue.DrainFault()); } /// /// Verifies the one-permit cap loses no wakeups. A burst that lands while nobody is waiting /// leaves exactly one pending wake — the waiter that consumes it drains the whole burst, so /// coalescing costs nothing — and that consumed wake is not replayed: the next wait parks /// until a new enqueue signals it. /// /// A task that represents the asynchronous operation. [Fact] public async Task WaitForEventsAsync_BurstWhileNoWaiter_CoalescesToOneWakeThatLosesNothing() { MxAccessEventQueue queue = new(capacity: 16); for (int itemHandle = 0; itemHandle < 5; itemHandle++) { queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle)); } Task firstWait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None); Assert.Same(firstWait, await Task.WhenAny(firstWait, Task.Delay(WakePatience))); await firstWait; // One wake, the whole burst: the waiter re-drains everything queued, which is why capping // the signal at a single permit cannot drop an event. Assert.Equal(5, queue.Drain(maxEvents: 0).Count); Task secondWait = queue.WaitForEventsAsync(WakeFallback, CancellationToken.None); Assert.False(secondWait.IsCompleted); queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 99)); Assert.Same(secondWait, await Task.WhenAny(secondWait, Task.Delay(WakePatience))); await secondWait; } /// /// Verifies the timeout still bounds an unsignalled wait, from both sides: the wait ends /// without a signal (the fallback survives as a ceiling, so a state change reached by some /// future path that does not signal is still observed on the next pass rather than never) /// and it does not end early (the wait really is the timeout, not an already-armed permit /// completing it instantly). A longer-than-production timeout is used so the lower bound /// carries a wide margin over timer resolution. /// /// A task that represents the asynchronous operation. [Fact] public async Task WaitForEventsAsync_WithNoSignal_CompletesAtTheFallbackTimeout() { MxAccessEventQueue queue = new(capacity: 4); TimeSpan fallback = TimeSpan.FromMilliseconds(200); Stopwatch elapsed = Stopwatch.StartNew(); Task wait = queue.WaitForEventsAsync(fallback, CancellationToken.None); Assert.Same(wait, await Task.WhenAny(wait, Task.Delay(WakePatience))); await wait; elapsed.Stop(); // Half the timeout: far enough below it to be immune to timer resolution, far enough above // zero to fail an implementation that returned a completed task instead of waiting. Assert.True( elapsed.Elapsed >= TimeSpan.FromMilliseconds(100), $"Unsignalled wait returned after {elapsed.ElapsedMilliseconds} ms, well inside its {fallback.TotalMilliseconds} ms fallback."); } /// Verifies a cancelled wait unwinds instead of hanging until the fallback expires. /// A task that represents the asynchronous operation. [Fact] public async Task WaitForEventsAsync_WhenCancelled_Throws() { MxAccessEventQueue queue = new(capacity: 4); using CancellationTokenSource cancellation = new(); Task wait = queue.WaitForEventsAsync(WakeFallback, cancellation.Token); cancellation.Cancel(); await Assert.ThrowsAnyAsync(async () => await wait); } /// /// The queue capacity comes from the launcher-set environment variable; /// a missing, unparseable, below-floor, or above-ceiling value must fall /// back to the 10,000 default rather than throw. A bad environment value /// must never stop the worker's session from starting. /// /// Raw environment value under test. /// Expected resolved capacity. [Theory] [InlineData(null, MxAccessEventQueue.DefaultCapacity)] [InlineData("", MxAccessEventQueue.DefaultCapacity)] [InlineData("not-a-number", MxAccessEventQueue.DefaultCapacity)] [InlineData("0", MxAccessEventQueue.DefaultCapacity)] [InlineData("-5", MxAccessEventQueue.DefaultCapacity)] [InlineData("999", MxAccessEventQueue.DefaultCapacity)] [InlineData("1000001", MxAccessEventQueue.DefaultCapacity)] [InlineData("1000", 1000)] [InlineData("1000000", 1000000)] [InlineData("50000", 50000)] public void ResolveCapacity_WithEnvironmentValue_FallsBackToDefaultWhenUnusable( string? environmentValue, int expected) { string? original = Environment.GetEnvironmentVariable( MxAccessEventQueue.CapacityEnvironmentVariableName); try { Environment.SetEnvironmentVariable( MxAccessEventQueue.CapacityEnvironmentVariableName, environmentValue); Assert.Equal(expected, MxAccessEventQueue.ResolveCapacity()); } finally { Environment.SetEnvironmentVariable( MxAccessEventQueue.CapacityEnvironmentVariableName, original); } } /// /// A resolved capacity is what the queue is actually built with, so the /// configured headroom reaches the overflow check rather than only the /// resolver. /// [Fact] public void Constructor_WithResolvedCapacity_UsesEnvironmentValue() { string? original = Environment.GetEnvironmentVariable( MxAccessEventQueue.CapacityEnvironmentVariableName); try { Environment.SetEnvironmentVariable( MxAccessEventQueue.CapacityEnvironmentVariableName, "2500"); MxAccessEventQueue queue = new(MxAccessEventQueue.ResolveCapacity()); Assert.Equal(2500, queue.Capacity); } finally { Environment.SetEnvironmentVariable( MxAccessEventQueue.CapacityEnvironmentVariableName, original); } } // Mirrors MxAccessEventQueue's per-event repeated-field allowance. Kept local rather than made // public on the queue: the byte-budget tests state their budgets in units of that charge, so a // change to it should surface here as a failing bound instead of silently moving with the code. private const int RepeatedFieldOverheadBytes = 8; /// /// Measures what the queue charges one event of the given payload size against the byte budget: /// the serialized as it exists after Enqueue (sequence and timestamp /// stamped) plus the repeated-field allowance. The probe uses item handle 0, a proto3 default /// that is not serialized, so this is a lower bound on the fixtures' real per-event cost — the /// budgets above carry slack rather than assuming byte equality. /// /// Length of the event's raw-status payload string. /// The per-event byte cost. /// /// What the byte budget must charge for an already-stamped event: its true serialized size plus /// the repeated-field allowance. Measured from the generated CalculateSize() so the /// budget tests bound the queue's memoized size against the real one rather than against a /// second copy of the queue's own arithmetic. /// /// Event as the queue holds it, sequence and timestamp stamped. /// The per-event byte cost. private static int DrainCostOf(WorkerEvent workerEvent) { return workerEvent.CalculateSize() + RepeatedFieldOverheadBytes; } private static int MeasureDrainCost(int payloadLength) { MxAccessEventQueue probe = new(capacity: 1); probe.Enqueue(CreateEventWithPayload(0, payloadLength)); Assert.True(probe.TryDequeue(out WorkerEvent? probeEvent)); return probeEvent!.CalculateSize() + RepeatedFieldOverheadBytes; } /// /// Builds a byte-heavy event: a large string field is the cheapest stand-in for the array/string /// payloads that make a count-capped drain overshoot the frame maximum. /// /// Item handle identifying the event in assertions. /// Length of the raw-status payload string. /// The constructed event. private static MxEvent CreateEventWithPayload(int itemHandle, int payloadLength) { MxEvent mxEvent = CreateEvent(MxEventFamily.OnDataChange, itemHandle); mxEvent.RawStatus = new string('x', payloadLength); return mxEvent; } private static MxEvent CreateEvent( MxEventFamily family, int itemHandle) { MxEvent mxEvent = new() { Family = family, SessionId = "session-1", ServerHandle = 1, ItemHandle = itemHandle, }; switch (family) { case MxEventFamily.OnWriteComplete: mxEvent.OnWriteComplete = new OnWriteCompleteEvent(); break; default: mxEvent.OnDataChange = new OnDataChangeEvent(); break; } return mxEvent; } }