fix(core): resolve Medium code-review finding (Core-005)

Change ClusterEntry from sealed record to sealed class so TryUpdate
uses reference equality for the CAS comparison.  Prune now uses a
read-compute-TryUpdate retry loop that restarts when a concurrent
Install updates the entry between the read and the write, preventing
a race that could silently drop the just-installed newest generation.
Two regression tests added to PermissionTrieCacheTests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 08:23:52 -04:00
parent 09cd579220
commit debe163f4d
2 changed files with 69 additions and 9 deletions

View File

@@ -101,4 +101,39 @@ public sealed class PermissionTrieCacheTests
cache.CurrentGenerationId("c1").ShouldBe(1);
cache.CurrentGenerationId("c2").ShouldBe(9);
}
/// <summary>
/// Core-005 regression: a concurrent Install that adds a new generation between a Prune
/// read and write must not be silently overwritten. We can't deterministically reproduce
/// a true data race in a unit test, but we can verify that Prune performed after Install
/// honours the newly-installed generation rather than clobbering it.
/// </summary>
[Fact]
public void Prune_AfterConcurrentInstall_PreservesLatestGeneration()
{
var cache = new PermissionTrieCache();
for (var g = 1L; g <= 5; g++) cache.Install(Trie("c1", g));
// Simulate: Install of generation 6 races with a pending Prune(keepLatest:2).
// After Prune runs, generation 6 (the "just installed" one) must still be current.
cache.Install(Trie("c1", 6));
cache.Prune("c1", keepLatest: 2);
cache.CurrentGenerationId("c1").ShouldBe(6, "current must remain the newest installed generation");
cache.GetTrie("c1", 6).ShouldNotBeNull("generation 6 must be retained as current");
cache.GetTrie("c1", 5).ShouldNotBeNull("generation 5 retained (keepLatest=2)");
cache.GetTrie("c1", 4).ShouldBeNull("generation 4 was pruned");
}
[Fact]
public void Prune_Current_Pointer_Survives_Pruning()
{
// Current is always the highest generation; verify it is not lost after prune.
var cache = new PermissionTrieCache();
for (var g = 1L; g <= 6; g++) cache.Install(Trie("c1", g));
cache.Prune("c1", keepLatest: 3);
cache.GetTrie("c1")!.GenerationId.ShouldBe(6, "current must still be the highest generation after pruning");
}
}