fix(mqtt): prune LastValueCache to the authored set on Register (R06:M-2)

Both ingest paths now drop cache entries whose RawPath is no longer authored when a
deploy re-registers, so a removed tag's value and every non-authored subscribe
reference's BadNodeIdUnknown marker no longer accumulate for the driver's lifetime.
Still-authored tags keep their observed values.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:28:33 -04:00
parent 43f45bb978
commit 57ffbf79e5
5 changed files with 83 additions and 0 deletions
@@ -45,6 +45,30 @@ public sealed class LastValueCache
_values[rawPath] = snapshot;
}
/// <summary>How many RawPaths currently hold an entry. Diagnostics and the bounded-growth tests.</summary>
internal int Count => _values.Count;
/// <summary>
/// Drops every entry whose RawPath <paramref name="keep"/> rejects — called by each ingest path's
/// <c>Register</c> with "is this still an authored tag", so the cache is bounded by the CURRENT
/// authored set rather than by the union of every RawPath (and every non-authored reference ever
/// subscribed) across the driver's lifetime (R06:M-2).
/// </summary>
/// <param name="keep">Returns <see langword="true"/> for a RawPath whose entry must survive.</param>
/// <returns>How many entries were dropped.</returns>
public int RetainOnly(Func<string, bool> keep)
{
ArgumentNullException.ThrowIfNull(keep);
var dropped = 0;
foreach (var rawPath in _values.Keys)
{
if (!keep(rawPath) && _values.TryRemove(rawPath, out _))
dropped++;
}
return dropped;
}
/// <summary>
/// Read the last observed value for <paramref name="rawPath"/>. Never throws: a
/// <c>null</c>/empty <paramref name="rawPath"/> or a RawPath never observed both return a
@@ -325,6 +325,11 @@ public sealed class MqttSubscriptionManager
_authored = table;
_warnedRefs.Clear();
// Bound the shared last-value cache by the CURRENT authored set (R06:M-2): a dropped tag's value
// and every non-authored reference's BadNodeIdUnknown marker would otherwise live forever. A
// still-authored tag keeps its value, so a redeploy never blanks what has been observed.
Values.RetainOnly(table.ByRawPath.ContainsKey);
// Drop state belonging to tags this deploy removed. Without this, a topic whose last tag was
// deleted stays in _subscribedTopics and is dutifully re-SUBSCRIBEd on every future reconnect,
// forever — and its RawPaths keep a handle mapping that can never fire again.
@@ -416,6 +416,11 @@ public sealed class SparkplugIngestor
_authored = table;
_warned.Clear();
// Bound the shared last-value cache by the CURRENT authored set (R06:M-2): a dropped tag's value
// and every non-authored reference's BadNodeIdUnknown marker would otherwise live forever. A
// still-authored tag keeps its value, so a redeploy never blanks what has been observed.
Values.RetainOnly(table.ByRawPath.ContainsKey);
// Drop state belonging to edge nodes this deploy removed. Without this a tracker (and its
// rebirth debounce slot) for a node nobody reads survives every future redeploy.
foreach (var key in _trackers.Keys)