c9925688f5
Change-log row for 2026-08-07: what landed for WRK-21/WRK-28/WRK-23/IPC-30, why
IPC-23 stays In progress (proto-comment/doc wave pending), and the verification
evidence — macOS NonWindows build + validator tests, and the documented windev
path (scripts/ci/windev-worker-ci.ps1 -Mode test) at a256560: x86 Worker build
clean, Worker.Tests 367 passed / 0 failed / 11 skipped.
365 lines
15 KiB
C#
365 lines
15 KiB
C#
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
|
|
{
|
|
/// <summary>Verifies that Enqueue assigns monotonic worker sequences and preserves event order.</summary>
|
|
[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 _));
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
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<WorkerEvent> 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);
|
|
}
|
|
|
|
/// <summary>Verifies that Drain with maxEvents 0 drains every queued event.</summary>
|
|
[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<WorkerEvent> 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);
|
|
}
|
|
|
|
/// <summary>Verifies that draining an empty queue returns an empty list.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<WorkerEvent> 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<WorkerEvent> rest = queue.Drain(maxEvents: 0);
|
|
Assert.Equal(new[] { 2, 3, 4 }, new[] { rest[0].Event.ItemHandle, rest[1].Event.ItemHandle, rest[2].Event.ItemHandle });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the byte-budgeted drain reports truncation and the exact remaining count so the
|
|
/// DrainEvents reply can tell the caller to drain again.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<ulong> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the count cap still binds when the byte budget is generous: the byte cap is an
|
|
/// additional bound, not a replacement.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that Enqueue is rejected after a fault is recorded manually.</summary>
|
|
[Fact]
|
|
public void Enqueue_AfterRecordFault_ThrowsInvalidOperationException()
|
|
{
|
|
MxAccessEventQueue queue = new(capacity: 4);
|
|
queue.RecordFault(new WorkerFault
|
|
{
|
|
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
|
|
});
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10)));
|
|
Assert.Equal(0, queue.Count);
|
|
}
|
|
|
|
/// <summary>Verifies that Enqueue records an overflow fault and rejects new events when capacity is exceeded.</summary>
|
|
[Fact]
|
|
public void Enqueue_WhenCapacityIsExceeded_RecordsOverflowFaultAndRejectsNewEvents()
|
|
{
|
|
MxAccessEventQueue queue = new(capacity: 1);
|
|
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
|
|
|
|
MxAccessEventQueueOverflowException overflow = Assert.Throws<MxAccessEventQueueOverflowException>(
|
|
() => 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<InvalidOperationException>(
|
|
() => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12)));
|
|
}
|
|
|
|
/// <summary>Verifies that RecordFault keeps the first recorded fault.</summary>
|
|
[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;
|
|
|
|
/// <summary>
|
|
/// Measures what the queue charges one event of the given payload size against the byte budget:
|
|
/// the serialized <see cref="WorkerEvent"/> 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.
|
|
/// </summary>
|
|
/// <param name="payloadLength">Length of the event's raw-status payload string.</param>
|
|
/// <returns>The per-event byte cost.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a byte-heavy event: a large string field is the cheapest stand-in for the array/string
|
|
/// <see cref="MxValue"/> payloads that make a count-capped drain overshoot the frame maximum.
|
|
/// </summary>
|
|
/// <param name="itemHandle">Item handle identifying the event in assertions.</param>
|
|
/// <param name="payloadLength">Length of the raw-status payload string.</param>
|
|
/// <returns>The constructed event.</returns>
|
|
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;
|
|
}
|
|
}
|