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
This commit is contained in:
Joseph Doherty
2026-11-11 11:13:19 -05:00
parent 793935614f
commit 33e10c2266
5 changed files with 365 additions and 11 deletions
@@ -107,6 +107,13 @@ public sealed class GalaxyDriver
// GatewayGalaxySubscriber over a connected GalaxyMxSession.
private IGalaxySubscriber? _subscriber;
private readonly SubscriptionRegistry _subscriptions = new();
// #862: subscriptions a reconnect left without advises because Reconnect.ReplayOnSessionLost=false.
// Their tags were published BadNoCommunication and the driver reports Degraded while any of them is
// still registered. Cleared by UnsubscribeAsync (the host re-subscribes on every deploy / re-init) and
// by the runtime teardown; GetHealth also prunes ids the registry no longer holds, so an unsubscribe
// racing the replay can never pin Degraded.
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, byte> _strandedSubscriptions = new();
private EventPump? _eventPump;
private readonly Lock _pumpLock = new();
@@ -693,6 +700,7 @@ public sealed class GalaxyDriver
lock (_registryGate)
{
_subscriptions.Clear();
_strandedSubscriptions.Clear();
// A new session too: a late PurgeParked from a call the teardown orphaned (captured the old
// session) must never reach the next runtime's parked values.
_subscriptions.StartNewSession();
@@ -1055,15 +1063,11 @@ public sealed class GalaxyDriver
// Honor ReplayOnSessionLost. When operators opt out (false) the per-tag SubscribeBulk fan-out
// is skipped, and the recreated gateway session carries NONE of the old advises — those
// subscriptions stay deaf (their handles are dead and no longer resolve) until re-subscribed.
// #862: deaf must not look healthy. Every tag of every stranded subscription is published
// BadNoCommunication and the driver reports Degraded until the stranded subscriptions are gone.
if (!_options.Reconnect.ReplayOnSessionLost)
{
if (entries.Length > 0)
{
_logger.LogWarning(
"GalaxyDriver {InstanceId} reconnect replay skipped — ReplayOnSessionLost=false; {SubCount} subscription(s) " +
"receive no further data until they are re-subscribed (the recreated gateway session has none of their advises).",
_driverInstanceId, entries.Length);
}
if (entries.Length > 0) MarkSubscriptionsStranded(entries);
return;
}
@@ -1087,6 +1091,77 @@ public sealed class GalaxyDriver
_driverInstanceId, entries.Length, tagCount);
}
/// <summary>
/// #862 — a reconnect with <see cref="GalaxyReconnectOptions.ReplayOnSessionLost"/> = false left
/// <paramref name="entries"/> without advises on the recreated gateway session. Records them as
/// stranded (so <see cref="GetHealth"/> reports Degraded) and publishes every one of their tags
/// <c>BadNoCommunication</c> — the quality contract's comms-loss status — so no node keeps serving
/// its last value as Good. The value is null: the driver keeps no value cache, and a Bad sample's
/// value is not client-observable anyway (the SDK nulls it on read); the node is restated by the
/// initial value MXAccess delivers when the tag is next advised (a re-subscribe).
/// Published straight to <see cref="OnDataChange"/>, never through the pump path: that would
/// stamp <c>LastSuccessfulRead</c> for data that never arrived.
/// </summary>
/// <param name="entries">The subscriptions the reconnect stranded.</param>
private void MarkSubscriptionsStranded(
IReadOnlyList<(long SubscriptionId, IReadOnlyList<TagBinding> Bindings)> entries)
{
var tagCount = 0;
foreach (var (subscriptionId, _) in entries) _strandedSubscriptions[subscriptionId] = 0;
_logger.LogWarning(
"GalaxyDriver {InstanceId} reconnect replay skipped — Reconnect.ReplayOnSessionLost=false; {SubCount} subscription(s) " +
"receive no further data until they are re-subscribed (the recreated gateway session has none of their advises). " +
"Their tags are published BadNoCommunication and the driver reports Degraded until then.",
_driverInstanceId, entries.Count);
var now = DateTime.UtcNow;
var snapshot = new DataValueSnapshot(
Value: null, StatusCode: StatusCodeMap.BadNoCommunication, SourceTimestampUtc: now, ServerTimestampUtc: now);
foreach (var (subscriptionId, bindings) in entries)
{
// Unsubscribed while the reconnect ran: the host no longer holds this handle.
if (!_subscriptions.Contains(subscriptionId)) continue;
var handle = new GalaxySubscriptionHandle(subscriptionId);
foreach (var binding in bindings)
{
tagCount++;
try
{
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, ToRawPath(binding.FullReference), snapshot));
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"GalaxyDriver {InstanceId} OnDataChange handler threw publishing a stranded tag {FullRef} — continuing.",
_driverInstanceId, binding.FullReference);
}
}
}
_logger.LogDebug(
"GalaxyDriver {InstanceId} published BadNoCommunication for {TagCount} stranded tag(s).", _driverInstanceId, tagCount);
}
/// <summary>
/// #862 — the number of subscriptions a reconnect stranded (replay disabled) that are still registered.
/// Prunes ids the registry no longer holds.
/// </summary>
/// <returns>The live stranded-subscription count.</returns>
private int CountStrandedSubscriptions()
{
if (_strandedSubscriptions.IsEmpty) return 0;
var count = 0;
foreach (var id in _strandedSubscriptions.Keys)
{
if (_subscriptions.Contains(id)) count++;
else _strandedSubscriptions.TryRemove(id, out _);
}
return count;
}
/// <summary>Test seam — the live stranded-subscription count (#862).</summary>
internal int StrandedSubscriptionCountForTests => CountStrandedSubscriptions();
/// <summary>
/// Re-issues <c>SubscribeBulk</c> for every tracked subscription and rebinds the registry
/// with the item handles the gateway returned. Shared by the reconnect replay path and
@@ -1460,6 +1535,21 @@ public sealed class GalaxyDriver
{
return new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, _supervisor.LastError);
}
// #862: the transport is back but a reconnect with ReplayOnSessionLost=false left subscriptions
// without advises. LastSuccessfulRead is NOT advanced here — it only moves on real stream data, so
// if nothing else flows the host's comms-loss watchdog re-initializes the driver (which
// re-subscribes everything) exactly as for any other sustained loss.
var stranded = CountStrandedSubscriptions();
if (stranded > 0)
{
return new DriverHealth(
DriverState.Degraded,
_health.LastSuccessfulRead,
$"{stranded} Galaxy subscription(s) stranded by a gateway reconnect: Reconnect.ReplayOnSessionLost=false, " +
"so the recreated gateway session carries none of their advises — their tags are BadNoCommunication " +
"until they are re-subscribed. Set ReplayOnSessionLost=true to replay them on reconnect.");
}
return _health;
}
@@ -1957,6 +2047,7 @@ public sealed class GalaxyDriver
}
var bindings = _subscriptions.Remove(gsh.SubscriptionId, out var liveInCurrentSession);
_strandedSubscriptions.TryRemove(gsh.SubscriptionId, out _); // #862: gone ⇒ no longer degrades health
if (bindings is null) return; // already removed or never registered
// Not yet rebound after a reconnect: its handles died with the old session and the new one may
@@ -49,6 +49,11 @@ internal static class StatusCodeMap
public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadSensorFailure = 0x808C0000u;
public const uint BadCommunicationError = 0x80050000u;
/// <summary>
/// The quality contract's comms-loss status (<c>docs/DriverLifecycle.md</c>) — published for the tags
/// of a subscription a reconnect left without advises (#862).
/// </summary>
public const uint BadNoCommunication = 0x80310000u;
public const uint BadOutOfService = 0x808D0000u;
public const uint BadWaitingForInitialData = 0x80320000u;
public const uint BadInternalError = 0x80020000u;