36583ee6f4
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
309 lines
14 KiB
C#
309 lines
14 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #870: <see cref="RingEventMonitoredItemQueue"/> must be observably identical to the SDK's
|
|
/// <see cref="EventMonitoredItemQueue"/>. Every test drives the SDK queue and the ring with the same operations and
|
|
/// compares every result — directly at the queue, and through the SDK's own <see cref="EventQueueHandler"/> (which owns
|
|
/// DiscardOldest, the overflow-event positions and the discarded/overflow counters) built over each factory.
|
|
/// </summary>
|
|
public sealed class RingEventMonitoredItemQueueParityTests
|
|
{
|
|
private static readonly ITelemetryContext Telemetry = DefaultTelemetry.Create(_ => { });
|
|
|
|
private sealed class Target : IFilterTarget
|
|
{
|
|
public bool IsTypeOf(IFilterContext context, NodeId typeDefinitionId) => false;
|
|
|
|
public Variant GetAttributeValue(IFilterContext context, NodeId typeDefinitionId,
|
|
ArrayOf<QualifiedName> relativePath, uint attributeId, NumericRange indexRange) => Variant.Null;
|
|
}
|
|
|
|
private static readonly Target[] Targets = Enumerable.Range(0, 6).Select(_ => new Target()).ToArray();
|
|
|
|
private static EventFieldList NewEvent(Random random) => new() { Handle = Targets[random.Next(Targets.Length)] };
|
|
|
|
/// <summary>Runs <paramref name="action"/> on both, asserting the same outcome (value or exception type + status).</summary>
|
|
private static T Both<T>(Func<IEventMonitoredItemQueue, T> action, IEventMonitoredItemQueue sdk,
|
|
IEventMonitoredItemQueue ring, string step)
|
|
{
|
|
Exception? sdkError = null, ringError = null;
|
|
T sdkResult = default!, ringResult = default!;
|
|
try { sdkResult = action(sdk); } catch (Exception e) { sdkError = e; }
|
|
try { ringResult = action(ring); } catch (Exception e) { ringError = e; }
|
|
|
|
(ringError?.GetType()).ShouldBe(sdkError?.GetType(), step);
|
|
if (sdkError is ServiceResultException s)
|
|
((ServiceResultException)ringError!).StatusCode.ShouldBe(s.StatusCode, step);
|
|
if (sdkError is null) ringResult.ShouldBe(sdkResult, step);
|
|
ring.ItemsInQueue.ShouldBe(sdk.ItemsInQueue, step);
|
|
ring.QueueSize.ShouldBe(sdk.QueueSize, step);
|
|
return sdkResult;
|
|
}
|
|
|
|
[Fact]
|
|
public void Initial_state_matches()
|
|
{
|
|
using var sdk = new EventMonitoredItemQueue(false, 7, Telemetry);
|
|
using var ring = new RingEventMonitoredItemQueue(7);
|
|
ring.MonitoredItemId.ShouldBe(sdk.MonitoredItemId);
|
|
ring.IsDurable.ShouldBe(sdk.IsDurable);
|
|
ring.QueueSize.ShouldBe(sdk.QueueSize);
|
|
ring.ItemsInQueue.ShouldBe(sdk.ItemsInQueue);
|
|
ring.Dequeue(out var value).ShouldBeFalse();
|
|
value.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Enqueue_at_queue_size_zero_throws_the_same_BadInternalError()
|
|
{
|
|
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
|
var ring = new RingEventMonitoredItemQueue(1);
|
|
Both(q => { q.Enqueue(new EventFieldList()); return 0; }, sdk, ring, "enqueue at 0");
|
|
Should.Throw<ServiceResultException>(() => ring.Enqueue(new EventFieldList())).StatusCode
|
|
.ShouldBe(StatusCodes.BadInternalError);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void Shrink_keeps_newest_or_oldest_and_dequeue_order_is_fifo(bool discardOldest)
|
|
{
|
|
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
|
var ring = new RingEventMonitoredItemQueue(1);
|
|
var events = Enumerable.Range(0, 10).Select(_ => new EventFieldList()).ToArray();
|
|
sdk.SetQueueSize(10, true);
|
|
ring.SetQueueSize(10, true);
|
|
// Wrap the ring first so the shrink crosses the buffer end.
|
|
for (var i = 0; i < 6; i++) { sdk.Enqueue(new EventFieldList()); ring.Enqueue(new EventFieldList()); }
|
|
for (var i = 0; i < 6; i++) { sdk.Dequeue(out _); ring.Dequeue(out _); }
|
|
foreach (var e in events) { sdk.Enqueue(e); ring.Enqueue(e); }
|
|
|
|
sdk.SetQueueSize(4, discardOldest);
|
|
ring.SetQueueSize(4, discardOldest);
|
|
|
|
var expected = discardOldest ? events[6..] : events[..4];
|
|
foreach (var e in expected)
|
|
{
|
|
sdk.Dequeue(out var a).ShouldBeTrue();
|
|
ring.Dequeue(out var b).ShouldBeTrue();
|
|
a.ShouldBeSameAs(e);
|
|
b.ShouldBeSameAs(e);
|
|
}
|
|
|
|
ring.Dequeue(out _).ShouldBeFalse();
|
|
sdk.Dequeue(out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Enqueue_at_a_full_queue_drops_the_oldest()
|
|
{
|
|
var ring = new RingEventMonitoredItemQueue(1);
|
|
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
|
ring.SetQueueSize(3, false);
|
|
sdk.SetQueueSize(3, false);
|
|
var events = Enumerable.Range(0, 5).Select(_ => new EventFieldList()).ToArray();
|
|
foreach (var e in events) { ring.Enqueue(e); sdk.Enqueue(e); }
|
|
foreach (var e in events[2..])
|
|
{
|
|
ring.Dequeue(out var b).ShouldBeTrue();
|
|
sdk.Dequeue(out var a).ShouldBeTrue();
|
|
b.ShouldBeSameAs(e);
|
|
a.ShouldBeSameAs(e);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Duplicate_check_covers_exactly_the_newest_1000_entries()
|
|
{
|
|
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
|
var ring = new RingEventMonitoredItemQueue(1);
|
|
sdk.SetQueueSize(3000, true);
|
|
ring.SetQueueSize(3000, true);
|
|
var old = new Target();
|
|
var edge = new Target();
|
|
void Add(EventFieldList e) { sdk.Enqueue(e); ring.Enqueue(e); }
|
|
Add(new EventFieldList { Handle = old });
|
|
Add(new EventFieldList { Handle = edge });
|
|
for (var i = 0; i < 999; i++) Add(new EventFieldList { Handle = Targets[0] });
|
|
Add(null!); // a null entry is skipped, as the SDK skips it
|
|
|
|
// 1002 entries: `old` is the 1002nd newest, `edge` the 1001st — both outside the 1000-entry window.
|
|
Both(q => q.IsEventContainedInQueue(old), sdk, ring, "old").ShouldBeFalse();
|
|
Both(q => q.IsEventContainedInQueue(edge), sdk, ring, "edge").ShouldBeFalse();
|
|
sdk.Dequeue(out _); ring.Dequeue(out _);
|
|
sdk.Dequeue(out _); ring.Dequeue(out _);
|
|
Add(new EventFieldList { Handle = edge });
|
|
Both(q => q.IsEventContainedInQueue(edge), sdk, ring, "newest").ShouldBeTrue();
|
|
Both(q => q.IsEventContainedInQueue(Targets[0]), sdk, ring, "middle").ShouldBeTrue();
|
|
Both(q => q.IsEventContainedInQueue(Targets[1]), sdk, ring, "absent").ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Upper boundary of the window: the entry exactly 1000th from newest is still found; 1001st is not.</summary>
|
|
[Fact]
|
|
public void Duplicate_check_includes_the_1000th_newest_entry()
|
|
{
|
|
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
|
var ring = new RingEventMonitoredItemQueue(1);
|
|
sdk.SetQueueSize(3000, true);
|
|
ring.SetQueueSize(3000, true);
|
|
var outside = new Target();
|
|
var boundary = new Target();
|
|
void Add(EventFieldList e) { sdk.Enqueue(e); ring.Enqueue(e); }
|
|
Add(new EventFieldList { Handle = outside });
|
|
Add(new EventFieldList { Handle = boundary });
|
|
for (var i = 0; i < 999; i++) Add(new EventFieldList { Handle = Targets[0] });
|
|
|
|
// 1001 entries: `boundary` is the 1000th newest (inside), `outside` the 1001st.
|
|
Both(q => q.IsEventContainedInQueue(boundary), sdk, ring, "boundary").ShouldBeTrue();
|
|
Both(q => q.IsEventContainedInQueue(outside), sdk, ring, "outside").ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Random interleavings of every queue operation, at small sizes (wrap-around, grow, shrink, size 0).</summary>
|
|
[Theory]
|
|
[MemberData(nameof(Seeds))]
|
|
public void Random_operation_sequences_match_the_sdk_queue(int seed)
|
|
{
|
|
var random = new Random(seed);
|
|
var sdk = new EventMonitoredItemQueue(false, 3, Telemetry);
|
|
var ring = new RingEventMonitoredItemQueue(3);
|
|
for (var step = 0; step < 2000; step++)
|
|
{
|
|
var label = $"seed {seed} step {step}";
|
|
switch (random.Next(10))
|
|
{
|
|
case < 5:
|
|
var e = random.Next(50) == 0 ? null! : NewEvent(random);
|
|
Both(q => { q.Enqueue(e); return 0; }, sdk, ring, label);
|
|
break;
|
|
case < 8:
|
|
var (sdkHad, sdkValue) = (sdk.Dequeue(out var a), a);
|
|
var (ringHad, ringValue) = (ring.Dequeue(out var b), b);
|
|
ringHad.ShouldBe(sdkHad, label);
|
|
ringValue.ShouldBeSameAs(sdkValue, label);
|
|
break;
|
|
case 8:
|
|
var size = (uint)(random.Next(8) == 0 ? 0 : random.Next(1, 40));
|
|
var discardOldest = random.Next(2) == 0;
|
|
Both(q => { q.SetQueueSize(size, discardOldest); return 0; }, sdk, ring, label);
|
|
break;
|
|
default:
|
|
var t = Targets[random.Next(Targets.Length)];
|
|
Both(q => q.IsEventContainedInQueue(t), sdk, ring, label);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Drain: the same remaining contents in the same order.
|
|
while (sdk.Dequeue(out var a))
|
|
{
|
|
ring.Dequeue(out var b).ShouldBeTrue();
|
|
b.ShouldBeSameAs(a);
|
|
}
|
|
|
|
ring.Dequeue(out _).ShouldBeFalse();
|
|
}
|
|
|
|
public static TheoryData<int> Seeds() => new(Enumerable.Range(1, 40));
|
|
|
|
/// <summary>
|
|
/// The SDK's <see cref="EventQueueHandler"/> over the SDK factory vs over <see cref="RingEventQueueFactory"/>:
|
|
/// QueueEvent (incl. the DiscardOldest=false refusal), SetQueueOverflowIfFull, SetQueueSize with DiscardOldest
|
|
/// flips, Publish with small batches (dequeue mid-stream) — the published events, the overflow-event-due flags,
|
|
/// the Overflow property and the discarded/overflow callbacks must all agree.
|
|
/// </summary>
|
|
[Theory]
|
|
[MemberData(nameof(Seeds))]
|
|
public void The_sdk_event_queue_handler_behaves_identically_over_either_queue(int seed)
|
|
{
|
|
var random = new Random(seed);
|
|
var counts = new int[4]; // sdk discarded, sdk overflow, ring discarded, ring overflow
|
|
using var sdkFactory = new MonitoredItemQueueFactory(Telemetry);
|
|
using var ringFactory = new RingEventQueueFactory(new MonitoredItemQueueFactory(Telemetry));
|
|
using var sdk = new EventQueueHandler(false, sdkFactory, 9, Telemetry, () => counts[0]++, () => counts[1]++);
|
|
using var ring = new EventQueueHandler(false, ringFactory, 9, Telemetry, () => counts[2]++, () => counts[3]++);
|
|
var context = new OperationContext(new RequestHeader(), null, RequestType.Publish, RequestLifetime.None);
|
|
|
|
var initialOldest = random.Next(2) == 0;
|
|
sdk.SetQueueSize(10, initialOldest);
|
|
ring.SetQueueSize(10, initialOldest);
|
|
|
|
for (var step = 0; step < 1500; step++)
|
|
{
|
|
var label = $"seed {seed} step {step}";
|
|
switch (random.Next(12))
|
|
{
|
|
case < 6:
|
|
var e = NewEvent(random);
|
|
Exception? a = null, b = null;
|
|
try { sdk.QueueEvent(e); } catch (Exception x) { a = x; }
|
|
try { ring.QueueEvent(e); } catch (Exception x) { b = x; }
|
|
(b?.GetType()).ShouldBe(a?.GetType(), label);
|
|
break;
|
|
case 6:
|
|
ring.SetQueueOverflowIfFull().ShouldBe(sdk.SetQueueOverflowIfFull(), label);
|
|
break;
|
|
case 7:
|
|
var size = (uint)random.Next(1, 25);
|
|
var discardOldest = random.Next(2) == 0;
|
|
sdk.SetQueueSize(size, discardOldest);
|
|
ring.SetQueueSize(size, discardOldest);
|
|
break;
|
|
case < 10:
|
|
var max = (uint)random.Next(1, 6);
|
|
var sdkOut = new Queue<EventFieldList>();
|
|
var ringOut = new Queue<EventFieldList>();
|
|
var sdkN = sdk.Publish(context, sdkOut, max);
|
|
var ringN = ring.Publish(context, ringOut, max);
|
|
ringN.ShouldBe(sdkN, label);
|
|
ringOut.Count.ShouldBe(sdkOut.Count, label);
|
|
while (sdkOut.Count > 0) ringOut.Dequeue().ShouldBeSameAs(sdkOut.Dequeue(), label);
|
|
break;
|
|
default:
|
|
var t = Targets[random.Next(Targets.Length)];
|
|
ring.IsEventContainedInQueue(t).ShouldBe(sdk.IsEventContainedInQueue(t), label);
|
|
break;
|
|
}
|
|
|
|
ring.ItemsInQueue.ShouldBe(sdk.ItemsInQueue, label);
|
|
ring.Overflow.ShouldBe(sdk.Overflow, label);
|
|
counts[2].ShouldBe(counts[0], label);
|
|
counts[3].ShouldBe(counts[1], label);
|
|
}
|
|
|
|
// Positive control: the sequence really overflowed (discards and overflow events happened).
|
|
counts[0].ShouldBeGreaterThan(0);
|
|
counts[1].ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Factory_supplies_ring_event_queues_and_delegates_everything_else()
|
|
{
|
|
using var factory = new RingEventQueueFactory(new MonitoredItemQueueFactory(Telemetry));
|
|
factory.SupportsDurableQueues.ShouldBeFalse();
|
|
factory.CreateEventQueue(false, 4).ShouldBeOfType<RingEventMonitoredItemQueue>().MonitoredItemId.ShouldBe(4u);
|
|
factory.CreateDataChangeQueue(false, 5).ShouldBeOfType<DataChangeMonitoredItemQueue>();
|
|
// A durable request reaches the SDK factory, which refuses it as before.
|
|
Should.Throw<ArgumentException>(() => factory.CreateEventQueue(true, 6));
|
|
Should.Throw<ArgumentException>(() => new MonitoredItemQueueFactory(Telemetry).CreateEventQueue(true, 6));
|
|
}
|
|
|
|
[Fact]
|
|
public void Buffer_grows_lazily_and_is_released_on_shrink()
|
|
{
|
|
var ring = new RingEventMonitoredItemQueue(1);
|
|
ring.SetQueueSize(10000, true);
|
|
ring.Capacity.ShouldBe(0);
|
|
for (var i = 0; i < 20000; i++) ring.Enqueue(new EventFieldList());
|
|
ring.Capacity.ShouldBe(10000);
|
|
ring.ItemsInQueue.ShouldBe(10000);
|
|
ring.SetQueueSize(10, true);
|
|
ring.ItemsInQueue.ShouldBe(10);
|
|
ring.Capacity.ShouldBeLessThanOrEqualTo(20);
|
|
}
|
|
}
|