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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user