fix(dcl): discard in-flight subscribe results for unsubscribed tags; release the orphaned handle

This commit is contained in:
Joseph Doherty
2026-08-14 23:25:48 -04:00
parent b1de9dfdd4
commit 37f13e2eaa
3 changed files with 208 additions and 7 deletions
@@ -1152,6 +1152,10 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// request be re-stashed/retried after reconnect via ReSubscribeAll.
var connectionLevelFailure = msg.Results.Any(r => !r.Success && r.ConnectionLevelFailure);
// Handles this request created that turn out to be redundant; released in ONE
// round trip below, symmetric with HandleBatchSubscribeCompleted.
var redundantIds = new List<string>();
foreach (var result in msg.Results)
{
// A result with AlreadySubscribed: false means
@@ -1175,9 +1179,26 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// Re-check against current state: another subscribe may have resolved the
// same tag while this request's I/O was in flight.
if (result.AlreadySubscribed || _subscriptionIds.ContainsKey(result.TagPath))
// AlreadySubscribed rows are partitioned out in HandleSubscribe and carry NO
// SubscriptionId of their own (another caller owns the adapter handle), so
// there is nothing to release for them.
if (result.AlreadySubscribed)
continue;
if (_subscriptionIds.ContainsKey(result.TagPath))
{
// Another path (a resolution probe, a reconnect re-subscribe or a
// concurrent request for a different instance) stored a handle for this
// tag while this request's I/O was in flight. This request issued its OWN
// SubscribeAsync, so dropping the row without releasing its handle leaks a
// monitored item forever — _subscriptionIds holds a single id per tag, so
// no later unsubscribe can ever reference this one. Release it, exactly as
// HandleBatchSubscribeCompleted does for its duplicate rows.
if (result is { Success: true, SubscriptionId: not null })
redundantIds.Add(result.SubscriptionId);
continue;
}
if (result.Success)
{
_subscriptionIds[result.TagPath] = result.SubscriptionId!;
@@ -1239,6 +1260,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
}
}
// Fire-and-forget release of every handle this request created redundantly.
_ = UnsubscribeIdsAsync(_adapter, redundantIds);
// Now that every tag is registered in
// _subscriptionsByInstance, deliver the values captured by the initial read.
// Re-entering via Self reuses HandleTagValueReceived's generation guard, fan-out
@@ -1897,7 +1921,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
}
var anyResolved = false;
var duplicateIds = new List<string>();
var idsToRelease = new List<string>();
foreach (var row in msg.Results)
{
@@ -1906,12 +1930,37 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
if (row is { Success: true, SubscriptionId: not null })
{
var wasUnresolved = _unresolvedTags.Remove(row.TagPath);
// The tag lost its last subscriber while this batch was in flight — an
// instance unsubscribe (disable/undeploy/redeploy) raced the resolution
// probe or the reconnect re-subscribe. _instancesByTag is the authority:
// it is the inverse of _subscriptionsByInstance and, like it, is preserved
// across reconnect, so an empty entry means nobody wants this tag any more.
// Applying the row anyway would store a handle no future unsubscribe can
// ever release (HandleUnsubscribe already ran for this tag) AND push
// _resolvedTags above the _totalSubscribed that same unsubscribe just
// decremented — permanently corrupting the health counters and driving
// _totalSubscribed negative on the next redeploy round trip. So discard
// every state mutation for this row and release the adapter handle the
// batch just created. This restores the gate the pre-merge per-tag handler
// had (it applied only when the tag was still in _unresolvedTags) and
// additionally releases the handle that path leaked.
if (!_instancesByTag.ContainsKey(row.TagPath))
{
_log.Debug(
"[{0}] Discarding batch-subscribe result for {1} — the tag was " +
"unsubscribed while the subscribe was in flight; releasing its handle.",
_connectionName, row.TagPath);
idsToRelease.Add(row.SubscriptionId);
continue;
}
if (_subscriptionIds.ContainsKey(row.TagPath))
{
// Another path already stored a handle for this tag while this one was
// in flight — release the redundant handle instead of leaking it
// (mirrors the duplicate-alarm-feed guard).
duplicateIds.Add(row.SubscriptionId);
idsToRelease.Add(row.SubscriptionId);
}
else
{
@@ -1933,11 +1982,18 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
{
_log.Debug("[{0}] Tag resolution still failing for {1}: {2}",
_connectionName, row.TagPath, row.Error);
_unresolvedTags.Add(row.TagPath);
// Same in-flight-unsubscribe race as the success branch above: the tag was
// already dropped from _unresolvedTags AND from _totalSubscribed by
// HandleUnsubscribe, so re-adding it here would probe a tag nobody
// subscribes to forever, at a retry count TotalSubscribedTags no longer
// accounts for.
if (_instancesByTag.ContainsKey(row.TagPath))
_unresolvedTags.Add(row.TagPath);
}
}
_ = UnsubscribeIdsAsync(_adapter, duplicateIds);
_ = UnsubscribeIdsAsync(_adapter, idsToRelease);
_healthCollector.UpdateTagResolution(_connectionName, _totalSubscribed, _resolvedTags);
// Backoff bookkeeping belongs to the probe round only: a reconnect re-subscribe