Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StrandedSubscriptionTests.cs
T
Joseph Doherty 33e10c2266 fix(galaxy): stranded subscriptions after a no-replay reconnect go Bad + Degraded (#862)
With Reconnect.ReplayOnSessionLost=false a recreated gateway session carries none of
the old advises, so every pre-reconnect subscription went silently deaf: nodes kept
their last value as Good and health returned to Healthy (only a Warning log).

ReplayAsync now records the stranded subscriptions and publishes BadNoCommunication
(StatusCodeMap.BadNoCommunication, 0x80310000; value null — no driver value cache)
for every tag of each, straight to OnDataChange so LastSuccessfulRead is not stamped.
GetHealth reports Degraded naming the setting while any stranded subscription is
still registered; it clears on UnsubscribeAsync (the host's re-subscribe path) or
runtime teardown, and pruned ids never pin it. A subscribe after the reconnect is an
ordinary new-session subscription. With nothing else flowing, the comms-loss
watchdog re-initializes the driver after 90 s (a delayed replay).

Tests: Runtime/StrandedSubscriptionTests (6). Docs: drivers/Galaxy.md, which also
no longer claims the replay uses the gateway's ReplaySubscriptions RPC.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 11:13:19 -05:00

249 lines
12 KiB
C#

using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
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.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// #862 — with <c>Reconnect.ReplayOnSessionLost=false</c> a gateway reconnect keeps the driver's
/// subscriptions but the recreated session carries none of their advises. That used to be invisible:
/// every node kept its last value with Good quality and health went back to Healthy. Now each tag of
/// each stranded subscription is published <c>BadNoCommunication</c> and the driver reports Degraded
/// until those subscriptions are unsubscribed (or the runtime is torn down).
/// </summary>
public sealed class StrandedSubscriptionTests
{
private const uint BadNoCommunication = 0x80310000u;
private static GalaxyDriverOptions Opts(bool replay) => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A"),
new GalaxyRepositoryOptions(WatchDeployEvents: false),
new GalaxyReconnectOptions(ReplayOnSessionLost: replay));
private static MxEvent DataChange(int handle, double value) => new()
{
Family = MxEventFamily.OnDataChange,
ItemHandle = handle,
Value = new MxValue { DoubleValue = value },
Quality = 192,
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
};
private static GalaxyDriver NewDriver(bool replay, HandleSubscriber subscriber) =>
new("g", Opts(replay), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber)
{
RecreateOverrideForTests = _ => Task.CompletedTask,
};
private static async Task ReconnectAsync(GalaxyDriver driver)
{
await driver.InvokeReopenForTestAsync(TestContext.Current.CancellationToken);
await driver.InvokeReplayForTestAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// The reconnect publishes BadNoCommunication for every tag of every stranded subscription, under
/// that subscription's own handle, and degrades health with a message naming the setting.
/// </summary>
[Fact]
public async Task Replay_disabled_reconnect_publishes_BadNoCommunication_and_degrades()
{
var subscriber = new HandleSubscriber();
using var driver = NewDriver(replay: false, subscriber);
var events = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => events.Enqueue(e);
var ab = (GalaxySubscriptionHandle)await driver.SubscribeAsync(["Tank.A", "Tank.B"], TimeSpan.Zero, TestContext.Current.CancellationToken);
var c = (GalaxySubscriptionHandle)await driver.SubscribeAsync(["Tank.C"], TimeSpan.Zero, TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldNotBe(DriverState.Degraded);
var callsBefore = Volatile.Read(ref subscriber.SubscribeCalls);
var lastReadBefore = driver.GetHealth().LastSuccessfulRead;
await ReconnectAsync(driver);
Volatile.Read(ref subscriber.SubscribeCalls).ShouldBe(callsBefore, "replay is disabled — nothing is re-advised");
var published = events.ToArray();
published.Select(e => (e.FullReference, ((GalaxySubscriptionHandle)e.SubscriptionHandle).SubscriptionId))
.OrderBy(x => x.FullReference)
.ShouldBe([("Tank.A", ab.SubscriptionId), ("Tank.B", ab.SubscriptionId), ("Tank.C", c.SubscriptionId)]);
published.ShouldAllBe(e => e.Snapshot.StatusCode == BadNoCommunication && e.Snapshot.Value == null);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastError.ShouldNotBeNull().ShouldContain("ReplayOnSessionLost");
health.LastError.ShouldContain("2 Galaxy subscription(s)");
health.LastSuccessfulRead.ShouldBe(lastReadBefore, "a synthetic Bad publish is not evidence of a live gateway");
driver.StrandedSubscriptionCountForTests.ShouldBe(2);
}
/// <summary>
/// Degraded clears exactly when the last stranded subscription is unsubscribed (what the host does on
/// every re-subscribe), and the stranded handles' dead numbers are never sent to the new session.
/// </summary>
[Fact]
public async Task Degraded_clears_once_every_stranded_subscription_is_unsubscribed()
{
var subscriber = new HandleSubscriber();
using var driver = NewDriver(replay: false, subscriber);
var a = await driver.SubscribeAsync(["Tank.A"], TimeSpan.Zero, TestContext.Current.CancellationToken);
var b = await driver.SubscribeAsync(["Tank.B"], TimeSpan.Zero, TestContext.Current.CancellationToken);
await ReconnectAsync(driver);
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
await driver.UnsubscribeAsync(a, TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldBe(DriverState.Degraded, "Tank.B is still stranded");
driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("1 Galaxy subscription(s)");
await driver.UnsubscribeAsync(b, TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldNotBe(DriverState.Degraded);
driver.StrandedSubscriptionCountForTests.ShouldBe(0);
subscriber.Unsubscribed.ShouldBeEmpty("old-session handle numbers must never reach the recreated session");
}
/// <summary>
/// A subscribe after the reconnect is an ordinary new-session subscription: it receives data, is
/// not counted as stranded, and keeps working once the stranded ones are gone.
/// </summary>
[Fact]
public async Task A_new_subscribe_after_the_reconnect_works_and_is_not_stranded()
{
var subscriber = new HandleSubscriber();
using var driver = NewDriver(replay: false, subscriber);
var delivered = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => delivered.Enqueue(e);
var old = await driver.SubscribeAsync(["Tank.A"], TimeSpan.Zero, TestContext.Current.CancellationToken);
await ReconnectAsync(driver);
driver.StrandedSubscriptionCountForTests.ShouldBe(1);
var fresh = (GalaxySubscriptionHandle)await driver.SubscribeAsync(["Tank.A"], TimeSpan.Zero, TestContext.Current.CancellationToken);
driver.StrandedSubscriptionCountForTests.ShouldBe(1, "the new subscription holds live new-session handles");
var freshHandle = subscriber.LastHandle;
subscriber.Emit(DataChange(freshHandle, 7.5));
await TestWait.UntilAsync(
() => delivered.Any(e => e.Snapshot.StatusCode == 0 && (double?)e.Snapshot.Value == 7.5),
"a subscribe after the reconnect must receive data");
delivered.Last().SubscriptionHandle.ShouldBe(fresh);
await driver.UnsubscribeAsync(old, TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldNotBe(DriverState.Degraded);
subscriber.Unsubscribed.ShouldBeEmpty();
await driver.UnsubscribeAsync(fresh, TestContext.Current.CancellationToken);
subscriber.Unsubscribed.ToArray().ShouldBe(new[] { freshHandle }, "the live subscription unadvises normally");
}
/// <summary>Default replay re-advises everything: no Bad publish, no Degraded.</summary>
[Fact]
public async Task Replay_enabled_reconnect_publishes_no_Bad_and_stays_undegraded()
{
var subscriber = new HandleSubscriber();
using var driver = NewDriver(replay: true, subscriber);
var events = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => events.Enqueue(e);
await driver.SubscribeAsync(["Tank.A"], TimeSpan.Zero, TestContext.Current.CancellationToken);
await ReconnectAsync(driver);
// Positive control: the replayed handle delivers, so the publish path was live while we looked.
subscriber.Emit(DataChange(subscriber.LastHandle, 1.0));
await TestWait.UntilAsync(() => !events.IsEmpty, "the replayed subscription must deliver");
events.ShouldAllBe(e => e.Snapshot.StatusCode != BadNoCommunication);
driver.GetHealth().State.ShouldNotBe(DriverState.Degraded);
driver.StrandedSubscriptionCountForTests.ShouldBe(0);
}
/// <summary>A second reconnect re-asserts the stranded state (still one entry per subscription).</summary>
[Fact]
public async Task A_second_reconnect_restates_the_same_stranded_subscriptions()
{
var subscriber = new HandleSubscriber();
using var driver = NewDriver(replay: false, subscriber);
var events = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => events.Enqueue(e);
await driver.SubscribeAsync(["Tank.A"], TimeSpan.Zero, TestContext.Current.CancellationToken);
await ReconnectAsync(driver);
await ReconnectAsync(driver);
events.Count.ShouldBe(2);
events.ShouldAllBe(e => e.Snapshot.StatusCode == BadNoCommunication);
driver.StrandedSubscriptionCountForTests.ShouldBe(1);
}
/// <summary>The runtime teardown forgets the stranded set along with the registry.</summary>
[Fact]
public async Task Shutdown_clears_the_stranded_state()
{
var subscriber = new HandleSubscriber();
using var driver = NewDriver(replay: false, subscriber);
await driver.SubscribeAsync(["Tank.A"], TimeSpan.Zero, TestContext.Current.CancellationToken);
await ReconnectAsync(driver);
driver.StrandedSubscriptionCountForTests.ShouldBe(1);
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
driver.StrandedSubscriptionCountForTests.ShouldBe(0);
driver.GetHealth().State.ShouldNotBe(DriverState.Degraded);
}
/// <summary>Hands out monotonic item handles, records unsubscribes, and streams emitted events.</summary>
private sealed class HandleSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
private int _nextHandle = 100;
/// <summary>Number of SubscribeBulk calls.</summary>
public int SubscribeCalls;
/// <summary>Gets the most recently issued item handle.</summary>
public int LastHandle => Volatile.Read(ref _nextHandle);
/// <summary>Gets every item handle passed to UnsubscribeBulk.</summary>
public ConcurrentQueue<int> Unsubscribed { get; } = new();
/// <summary>Pushes an event onto the stream.</summary>
/// <param name="ev">The event.</param>
public void Emit(MxEvent ev) => _events.Writer.TryWrite(ev).ShouldBeTrue();
/// <inheritdoc />
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
Interlocked.Increment(ref SubscribeCalls);
IReadOnlyList<SubscribeResult> results = [.. fullReferences.Select(r => new SubscribeResult
{
TagAddress = r,
ItemHandle = Interlocked.Increment(ref _nextHandle),
WasSuccessful = true,
})];
return Task.FromResult(results);
}
/// <inheritdoc />
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
foreach (var h in itemHandles) Unsubscribed.Enqueue(h);
return Task.CompletedTask;
}
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var ev in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return ev;
}
}
}
}