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
@@ -128,4 +128,32 @@ public sealed class AbLegacyIndirectAddressingTests
w.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
}
/// <summary>
/// Review 07 L7: every index value an indirect tag sweeps through used to leave one per-runtime semaphore and
/// one creation semaphore behind until shutdown. They are pruned with the stale resolution's handle, so the
/// per-key gate tables stay bounded by the authored set however far the index sweeps.
/// </summary>
[Fact]
public async Task Sweeping_index_does_not_accumulate_per_key_gates()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)10));
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var state = drv.GetDeviceState(Host)!;
var baseline = state.GateCount;
for (short i = 11; i < 60; i++)
{
f.Tags["N7:0"].Value = i;
f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)(p.TagName == "N7:0" ? i : 1000 + i) };
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: every resolution really read
r.Value.ShouldBe((short)(1000 + i));
}
state.GateCount.ShouldBe(baseline);
// Only the live resolution's handle (plus the index word's parent runtime) remains cached.
state.Runtimes.Keys.Count(k => k.StartsWith("Ind=>", StringComparison.Ordinal)).ShouldBe(1);
}
}