using System; using System.Collections.Generic; 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); } /// 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); } // 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. 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; } }