fix(acl): make InvalidateIfStale a monotonic-max CAS loop — stamp publication is never deferred

This commit is contained in:
Joseph Doherty
2026-08-17 07:15:30 -04:00
parent 4c16118105
commit 441b34a391
2 changed files with 151 additions and 6 deletions
@@ -429,6 +429,8 @@ public sealed class SessionAclDecisionCache
/// <summary>
/// Advances the cache-wide stamp when this call presents a newer generation, and purges on the way.
/// The stamp ends up holding the highest generation any caller has presented — never a lower one,
/// and never one call late.
/// </summary>
/// <remarks>
/// <para>
@@ -443,21 +445,54 @@ public sealed class SessionAclDecisionCache
/// carries its own generation; what is at stake here is only whether the cache is any use.
/// </para>
/// <para>
/// ⚠️ <b>That guard lives INSIDE the loop, against a freshly re-read stamp</b> — hoisting it to a
/// single check before the loop reintroduces exactly the backward roll it exists to prevent: a
/// straggler that lost its exchange would spin back round and re-attempt against the newer stamp
/// it just lost to, and eventually succeed in rolling it back. Re-reading means the straggler
/// sees the newer value on its second pass and leaves.
/// </para>
/// <para>
/// <b>Why a loop rather than one exchange.</b> A single shot defers publication by a call: two
/// callers read the same baseline, the older one's exchange lands first, and the newer one's
/// fails and falls out having published nothing. Its own lookup then misses and the
/// <see cref="Set"/> that follows is dropped by the <c>Generation != generation</c> guard, which
/// costs a store in the post-deploy window where the cache is coldest. Retrying against the moved
/// stamp closes that: the higher generation is published on the call that presents it.
/// </para>
/// <para>
/// <b>The elected clear survives the loop.</b> Only the thread whose exchange actually moved the
/// stamp clears, so a burst of concurrent requests arriving just after a deploy still repopulates
/// once rather than each clearing the others' fresh entries. A retry can now clear too, but only
/// by winning a move to a strictly higher generation — so clears stay bounded by the number of
/// distinct generations presented, not by request volume.
/// </para>
/// <para>
/// <b>It terminates.</b> Each iteration either returns through the guard or exchanges against a
/// <c>seen</c> strictly larger than the last iteration's (a failed exchange means someone raised
/// the stamp), and <c>_generation</c> only ever rises — so the guard is reached within a bounded
/// number of passes.
/// </para>
/// <para>
/// The straggler's own lookup is unaffected by returning early: its shard-generation check misses
/// against the newer shards regardless, so it recomputes, which is the correct outcome for a
/// request holding a superseded snapshot. <c>_generation</c> starts at <c>-1</c>, below any real
/// generation, so the first one still passes.
/// generation, so the first one — generation <c>0</c> included — still passes.
/// </para>
/// </remarks>
/// <param name="generation">The generation the calling request is being decided under.</param>
private void InvalidateIfStale(long generation)
{
var seen = Interlocked.Read(ref _generation);
if (generation <= seen) return;
while (true)
{
var seen = Interlocked.Read(ref _generation);
if (generation <= seen) return;
// Only the thread that actually moves the stamp clears, so a burst of concurrent requests arriving
// just after a deploy repopulates once rather than each clearing the others' fresh entries.
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen) ClearAll();
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen)
{
ClearAll();
return;
}
}
}
/// <summary>