fix(ablegacy): prune per-key gates when an indirect index moves

Review 07 L7: each index value an indirect tag (N7:[N7:0]) swept through left a
per-runtime and a creation SemaphoreSlim in the device's gate tables until
shutdown. A moved index now drops the stale resolution's gates along with its
handle, and sweeps any other stale resolution of the same tag a concurrent
reader cached, so the tables stay bounded by the authored set.

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:17:16 -04:00
parent 92171a01ec
commit 7377daa9b0
2 changed files with 56 additions and 2 deletions
@@ -965,8 +965,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var concreteText = concrete.ToLibplctagName();
var resolvedName = $"{def.Name}=>{concreteText}";
// The index moved: drop the previous resolution's handle so a sweeping index does not accumulate one
// libplctag handle per value it ever took.
// The index moved: drop the previous resolution's handle AND its per-key gates (review 07 L7), so a sweeping
// index does not accumulate one libplctag handle plus two SemaphoreSlims per value it ever took.
var previous = device.IndirectLastName.TryGetValue(def.Name, out var prior) ? prior : null;
device.IndirectLastName[def.Name] = resolvedName;
if (previous is not null && !string.Equals(previous, resolvedName, StringComparison.OrdinalIgnoreCase))
@@ -974,6 +974,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var oldLock = device.GetRuntimeLock(previous);
await oldLock.WaitAsync(ct).ConfigureAwait(false);
try { EvictRuntime(device, previous); } finally { oldLock.Release(); }
device.ForgetGates(previous);
// A concurrent reader of the same indirect tag can have cached a resolution between our TryGetValue and
// the write above (a stale one nothing would ever evict). Sweep every OTHER resolution of this tag.
var prefix = def.Name + "=>";
foreach (var key in device.Runtimes.Keys)
{
if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|| string.Equals(key, resolvedName, StringComparison.OrdinalIgnoreCase)) continue;
EvictRuntime(device, key);
device.ForgetGates(key);
}
}
return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false);
}
@@ -1379,6 +1390,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// <summary>Tags already warned about for a malformed address (once-per-tag log).</summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, byte> MalformedAddressLogged { get; } = new();
/// <summary>
/// Drops the per-runtime and creation gates of a runtime key that is no longer in use (a moved indirect
/// resolution). The gates are removed, not disposed: a caller that already holds a reference finishes on
/// it, and the next user of the key simply gets a fresh gate.
/// </summary>
/// <param name="runtimeKey">The runtime key whose gates to drop.</param>
public void ForgetGates(string runtimeKey)
{
_runtimeLocks.TryRemove(runtimeKey, out _);
_creationLocks.TryRemove($"tag:{runtimeKey}", out _);
}
/// <summary>The number of per-key gates currently held (runtime + creation + RMW). Tests only.</summary>
internal int GateCount => _runtimeLocks.Count + _creationLocks.Count + _rmwLocks.Count;
private volatile bool _retired;
/// <summary>True once this state has been unpublished and released; nothing may be cached on it any more.</summary>