Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpReplayGapTests.cs
T
Joseph Doherty 4769044f88
v2-ci / build (push) Successful in 4m39s
v2-ci / unit-tests (push) Failing after 19m35s
feat(galaxy): handle the replay-gap sentinel and sparse-array values
Closes the two follow-ups left open by the MxGateway 0.2.0 bump. Reading the
gateway's own contract first changed what each of them is, and one of my earlier
claims was simply wrong -- both corrections are recorded below.

Replay gap
----------
A sentinel MxEvent carries family = UNSPECIFIED, so it fell through EventPump's
family switch into the default drop: we silently ignored a documented MUST ("you
missed events -- discard local state and re-snapshot"). It is now intercepted and
answered by re-issuing SubscribeBulk for every tracked subscription, since
MXAccess delivers an initial value per item on subscribe -- re-subscribing IS the
re-snapshot. It does not restart the event pump (the stream carrying the sentinel
is healthy, and the handler runs on that stream's own read loop, so disposing the
pump from there would deadlock on itself), it does not block the read loop, and
re-entry is gated so a burst collapses to one pass instead of a subscribe storm
per sentinel.

Handled on the READ loop, not in the dispatch loop, because everything past that
point is subject to newest-dropped channel pressure -- and "you missed events" is
the one signal whose loss is unrecoverable. Pinned by a test that fills the
channel and still expects the sentinel.

⚠️ This cannot fire in the shipped configuration, and I did not pretend otherwise
or paper over it by enabling resume. The gateway emits the sentinel only for a
stream opened with after_worker_sequence > 0; GatewayGalaxySubscriber passes 0,
which the gateway treats as "fresh stream, not a resume -- plain attach, no
replay, no sentinel". Enabling resume is a larger and genuinely delicate change:
the sequence is only meaningful within ONE gateway session, our reconnect
recreates the session, and replaying a remembered sequence against a fresh
session sets the server's live watermark ABOVE every event it is about to send --
silencing the stream instead of recovering it. That deserves its own change with
a live gate, which I cannot run from here.

Sparse arrays -- correcting the last commit message
---------------------------------------------------
I wrote that the bump "makes handling it possible" on the decode path. That is
wrong: MxSparseArray is documented write-only, the gateway expands it before
forwarding, and "the worker never receives or produces it". So the decode path
is unreachable by contract and the write path has no use for it either -- it is a
wire compaction for a WHOLE-array write, not a partial update (unmentioned
indices are RESET, deliberately, because the gateway cannot read-modify-write
without racing the provider). It is therefore the wrong primitive for an OPC UA
IndexRange write, which expects untouched elements preserved -- and WriteRequest
carries no IndexRange in the first place.

It is decoded anyway, as contract defence: the alternative if a future gateway
ever emits one is a silent null published under Good quality. Fill semantics
mirror the gateway's own SparseArrayExpander exactly (epoch for Time, empty
string, Integer widening to long[] when any element is int64) so a value that
round-trips through a write reads back as the same CLR array.

Also: the decoder is now total by contract, since it runs inside the dispatch
loop where an escaping exception kills dispatch for the life of the pump. A
malformed payload degrades instead of throwing.

A real defect found in the same code
------------------------------------
A stream the GATEWAY completes -- a closed or evicted session -- ends the RPC
without an error, so nothing threw, the supervisor was never notified, and the
pump exited silently. GetHealth() kept reporting Healthy, which also means the
#527 comms-loss watchdog could never trip, because it requires a non-Healthy
state. The driver went permanently deaf with no signal anywhere. This one is
live, unlike the two above. Now reported as a fault; the falsification control is
the existing CleanShutdown_DoesNotInvokeOnStreamFault, since our own disposal
also ends the stream and must NOT be reported.

Value kinds we cannot represent are now counted and warned once rather than
published as an indistinguishable null. The status is deliberately NOT downgraded
to Bad: nothing can reach that branch today, and changing what an OPC UA client
sees for a live shape is not a change to make without a gate. The counter exists
so the next person has evidence instead of a guess.

Verified: 330 Galaxy tests green (12 new), plus Galaxy.Browser 21, Runtime 558,
Core 351; full solution builds 0 errors with the warning census unchanged. Every
new test was falsified first -- disabling the gap interception reds 3 of 4 (the
throwing-callback test passes vacuously when the callback never runs, by
construction), disabling sparse decode plus the representability signal reds all
7 decoder tests, and both were restored and re-verified green.

Docs: the Galaxy.Performance invariant was stated as received = dispatched +
dropped + in-flight, which this breaks -- a sentinel is received but never enters
the channel -- so it now reads + replay_gaps. Galaxy.md gains the three
operational notes; DriverLifecycle.md records that "reaches a non-Healthy state"
is an obligation a driver can silently fail to meet, which is the general shape
of the clean-end bug.
2026-08-12 02:56:17 -04:00

261 lines
13 KiB
C#

using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Coverage for the two <see cref="EventPump"/> signals that carry no item payload and so
/// have no natural home in the data-change fan-out: the gateway's replay-gap sentinel
/// (MxGateway 0.2.0) and a stream the gateway completes without an error.
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>The replay-gap sentinel is unreachable in the shipped configuration.</b>
/// <c>GatewayGalaxySubscriber</c> opens the stream with <c>after_worker_sequence = 0</c>,
/// which the gateway treats as "fresh stream, not a resume — plain attach, no replay, no
/// sentinel". These tests therefore pin a contract obligation rather than a live path: the
/// sentinel carries <c>family = UNSPECIFIED</c>, so before this work it fell straight
/// through the pump's family switch into the default drop, and enabling resume later would
/// have silently ignored a documented MUST.
/// </para>
/// <para>
/// The clean-completion half, by contrast, IS live: a gateway that closes or evicts a
/// session ends the RPC normally, which raises nothing at all.
/// </para>
/// </remarks>
public sealed class EventPumpReplayGapTests
{
private const int WaitMs = 2_000;
private static MxEvent GapSentinel(ulong requestedAfter, ulong oldestAvailable) => new()
{
// Shaped exactly as the gateway emits it: no family, no body, no per-item fields.
Family = MxEventFamily.Unspecified,
ReplayGap = new ReplayGap
{
RequestedAfterSequence = requestedAfter,
OldestAvailableSequence = oldestAvailable,
},
};
/// <summary>Verifies the sentinel reaches the callback carrying both sequence bounds.</summary>
[Fact]
public async Task A_replay_gap_sentinel_invokes_the_callback_with_both_sequences()
{
var subscriber = new ControllableSubscriber();
var observed = new TaskCompletionSource<ReplayGap>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, new SubscriptionRegistry(), channelCapacity: 8, clientName: "GapTest",
onReplayGap: gap => observed.TrySetResult(gap));
pump.Start();
await subscriber.EmitAsync(GapSentinel(requestedAfter: 100, oldestAvailable: 250));
(await Task.WhenAny(observed.Task, Task.Delay(WaitMs))).ShouldBe(observed.Task,
"a replay-gap sentinel must reach the driver so it can re-snapshot");
var gap = await observed.Task;
gap.RequestedAfterSequence.ShouldBe(100UL);
gap.OldestAvailableSequence.ShouldBe(250UL);
}
/// <summary>
/// Verifies the sentinel is consumed as a control signal and leaves the stream working.
/// </summary>
/// <remarks>
/// The "not dispatched" half is cheap insurance rather than the point: a sentinel carries
/// item handle 0, and <see cref="SubscriptionRegistry"/> never binds a non-positive handle
/// (that is its marker for a failed gateway subscribe), so it could not have reached a
/// subscriber even before it was intercepted. What this actually pins is that consuming
/// the sentinel off the read loop does not disturb the events around it — the load-bearing
/// assertion is the positive control that follows it.
/// </remarks>
[Fact]
public async Task A_replay_gap_sentinel_is_not_dispatched_as_a_data_change()
{
var subscriber = new ControllableSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
var dispatched = 0;
var gapSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var realValue = new TaskCompletionSource<DataChangeEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 8, clientName: "GapTest",
onReplayGap: _ => gapSeen.TrySetResult());
pump.OnDataChange += (_, args) => { Interlocked.Increment(ref dispatched); realValue.TrySetResult(args); };
pump.Start();
await subscriber.EmitAsync(GapSentinel(requestedAfter: 1, oldestAvailable: 2));
(await Task.WhenAny(gapSeen.Task, Task.Delay(WaitMs))).ShouldBe(gapSeen.Task);
// A positive control: follow the sentinel with a real event on the same handle. Without
// it, "nothing was dispatched" would also pass on a pump that dispatches nothing at all.
await subscriber.EmitAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = 7,
Value = new MxValue { DoubleValue = 12.5 },
Quality = 192,
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
});
(await Task.WhenAny(realValue.Task, Task.Delay(WaitMs))).ShouldBe(realValue.Task,
"the pump must keep dispatching real events after a sentinel");
(await realValue.Task).Snapshot.Value.ShouldBe(12.5);
Volatile.Read(ref dispatched).ShouldBe(1, "only the real event may be dispatched, never the sentinel");
}
/// <summary>
/// Verifies the sentinel survives a full event channel. This is the reason it is handled
/// on the read loop rather than in the dispatch loop: everything that goes through the
/// channel is subject to newest-dropped pressure, and "you missed events" is the one
/// signal whose loss cannot be recovered from later — the pump would carry on serving
/// stale values with no indication anything was wrong.
/// </summary>
[Fact]
public async Task A_replay_gap_sentinel_survives_a_full_event_channel()
{
var subscriber = new ControllableSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
using var blockDispatch = new ManualResetEventSlim(false);
var observed = new TaskCompletionSource<ReplayGap>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 1, clientName: "GapTest",
onReplayGap: gap => observed.TrySetResult(gap));
// Wedge the dispatch loop so the bounded channel fills and stays full.
pump.OnDataChange += (_, _) => blockDispatch.Wait(TimeSpan.FromSeconds(10));
pump.Start();
for (var i = 0; i < 20; i++)
{
await subscriber.EmitAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = 7,
Value = new MxValue { DoubleValue = i },
Quality = 192,
});
}
await subscriber.EmitAsync(GapSentinel(requestedAfter: 5, oldestAvailable: 900));
(await Task.WhenAny(observed.Task, Task.Delay(WaitMs))).ShouldBe(observed.Task,
"the sentinel must not be droppable by channel back-pressure");
(await observed.Task).OldestAvailableSequence.ShouldBe(900UL);
blockDispatch.Set();
}
/// <summary>
/// Verifies a throwing gap callback cannot take the stream down with it. The callback
/// runs on the read loop, so an escaping exception would land in RunAsync's catch and be
/// reported as a transport fault — tearing down a stream that is perfectly healthy.
/// </summary>
[Fact]
public async Task A_throwing_replay_gap_callback_does_not_fault_the_stream()
{
var subscriber = new ControllableSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
var faulted = false;
var resumed = new TaskCompletionSource<DataChangeEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 8, clientName: "GapTest",
onStreamFault: _ => faulted = true,
onReplayGap: _ => throw new InvalidOperationException("re-snapshot could not be scheduled"));
pump.OnDataChange += (_, args) => resumed.TrySetResult(args);
pump.Start();
await subscriber.EmitAsync(GapSentinel(requestedAfter: 1, oldestAvailable: 2));
await subscriber.EmitAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = 7,
Value = new MxValue { DoubleValue = 99.0 },
Quality = 192,
});
(await Task.WhenAny(resumed.Task, Task.Delay(WaitMs))).ShouldBe(resumed.Task,
"a failed re-snapshot must not stop the stream that is still delivering");
faulted.ShouldBeFalse("a throwing gap callback is not a transport fault");
}
/// <summary>
/// Verifies a stream the GATEWAY completes is reported as a fault. A closed or evicted
/// session ends the RPC normally, so nothing throws: before this, the pump exited
/// silently, the supervisor was never told, and <c>GetHealth()</c> kept reporting
/// Healthy — which also means the #527 comms-loss watchdog never tripped, since it
/// requires a non-Healthy state. The driver went permanently deaf with no signal at all.
/// </summary>
/// <remarks>
/// The falsification control for this lives in
/// <see cref="EventPumpStreamFaultTests.CleanShutdown_DoesNotInvokeOnStreamFault"/>: our
/// own disposal also ends the stream, and must NOT be reported.
/// </remarks>
[Fact]
public async Task A_stream_the_gateway_completes_without_an_error_is_reported_as_a_fault()
{
var subscriber = new ControllableSubscriber();
var faultObserved = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, new SubscriptionRegistry(), channelCapacity: 4, clientName: "GapTest",
onStreamFault: ex => faultObserved.TrySetResult(ex));
pump.Start();
subscriber.CompleteStream();
(await Task.WhenAny(faultObserved.Task, Task.Delay(WaitMs))).ShouldBe(faultObserved.Task,
"a server-completed stream is as fatal as a faulted one and must reach the supervisor");
(await faultObserved.Task).Message.ShouldContain("closed or evicted");
}
/// <summary>
/// <see cref="IGalaxySubscriber"/> fake whose stream can be fed arbitrary events and
/// completed cleanly, so both control-signal paths can be driven directly.
/// </summary>
private sealed class ControllableSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
/// <summary>Subscribes to multiple tags (test stub).</summary>
/// <param name="fullReferences">The tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
/// <summary>Unsubscribes from multiple tags (test stub).</summary>
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams events asynchronously (test stub).</summary>
/// <param name="cancellationToken">The cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
/// <summary>Pushes one event onto the stream.</summary>
/// <param name="ev">The event to emit.</param>
public ValueTask EmitAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
/// <summary>Ends the stream the way a gateway closing the session does — with no error.</summary>
public void CompleteStream() => _stream.Writer.TryComplete();
}
}