fix(acl): a stale-generation straggler can no longer roll the cache stamp backward and thrash ClearAll
This commit is contained in:
@@ -252,9 +252,16 @@ public sealed class SessionAclDecisionCache
|
|||||||
// before, and it is accepted — the loss window is a single compare-and-swap wide, and it is
|
// before, and it is accepted — the loss window is a single compare-and-swap wide, and it is
|
||||||
// paid only by threads that overflowed the same shard simultaneously, against a refill cost
|
// paid only by threads that overflowed the same shard simultaneously, against a refill cost
|
||||||
// paid by every eviction.
|
// paid by every eviction.
|
||||||
|
//
|
||||||
|
// A shard from a PREVIOUS generation contributes no hint: the fill level it reached belongs to a
|
||||||
|
// set of grants that no longer exists, and it is about to be displaced by the generation check
|
||||||
|
// below in any case. Sizing this generation's storage from the last one's crawl would be a guess
|
||||||
|
// dressed as evidence.
|
||||||
shard = Displace(
|
shard = Displace(
|
||||||
sessionKey, shard, generation,
|
sessionKey, shard, generation,
|
||||||
capacityHint: Math.Clamp(Volatile.Read(ref shard.ApproxCount), 0, MaxEntriesPerSession));
|
capacityHint: shard.Generation == generation
|
||||||
|
? Math.Clamp(Volatile.Read(ref shard.ApproxCount), 0, MaxEntriesPerSession)
|
||||||
|
: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shard must belong to THIS generation before anything is filed in it. Reached in two ways: a
|
// The shard must belong to THIS generation before anything is filed in it. Reached in two ways: a
|
||||||
@@ -420,10 +427,33 @@ public sealed class SessionAclDecisionCache
|
|||||||
internal void AdvanceGenerationForTest(long generation) =>
|
internal void AdvanceGenerationForTest(long generation) =>
|
||||||
Interlocked.Exchange(ref _generation, generation);
|
Interlocked.Exchange(ref _generation, generation);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances the cache-wide stamp when this call presents a newer generation, and purges on the way.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>The comparison is <c><=</c>, not <c>!=</c>, and that is a straggler guard.</b> The
|
||||||
|
/// generation only ever increases (<c>DataPlaneAuthorityState</c> counts it up rather than
|
||||||
|
/// deriving it from the revision hash, precisely so it cannot go backwards), but a <i>request</i>
|
||||||
|
/// can still present an older one: it captured its authority snapshot before a deploy and then
|
||||||
|
/// lost the scheduling race to a request that captured after. An inequality test treats that
|
||||||
|
/// straggler as a deploy, rolls the stamp <i>backward</i>, and purges the new generation's
|
||||||
|
/// freshly-populated shards — so the post-deploy window, when the cache is coldest and doing the
|
||||||
|
/// most work, is exactly when it thrashes. Verdicts are safe either way now that each shard
|
||||||
|
/// carries its own generation; what is at stake here is only whether the cache is any use.
|
||||||
|
/// </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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="generation">The generation the calling request is being decided under.</param>
|
||||||
private void InvalidateIfStale(long generation)
|
private void InvalidateIfStale(long generation)
|
||||||
{
|
{
|
||||||
var seen = Interlocked.Read(ref _generation);
|
var seen = Interlocked.Read(ref _generation);
|
||||||
if (seen == generation) return;
|
if (generation <= seen) return;
|
||||||
|
|
||||||
// Only the thread that actually moves the stamp clears, so a burst of concurrent requests arriving
|
// 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.
|
// just after a deploy repopulates once rather than each clearing the others' fresh entries.
|
||||||
|
|||||||
@@ -263,6 +263,48 @@ public sealed class SessionAclDecisionCacheTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A request presenting an OLDER generation — one whose authority snapshot was captured before a
|
||||||
|
/// deploy and which then lost the scheduling race — must not roll the cache stamp backward, and must
|
||||||
|
/// not purge the new generation's entries on its way past.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The generation itself is monotonic by construction; what is not monotonic is the order
|
||||||
|
/// requests <i>arrive</i> holding it. Treating a straggler as a deploy inverts the stamp and
|
||||||
|
/// wipes shards that were populated correctly, so the post-deploy window — the coldest the cache
|
||||||
|
/// ever is, and the busiest repopulating — is precisely when it would thrash.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Nothing here uses <c>AdvanceGenerationForTest</c>: this is the ordinary lookup path,
|
||||||
|
/// observed. The straggler must miss (its snapshot is superseded, so recomputing is right) AND
|
||||||
|
/// leave everything else standing — a test asserting only the miss passes against the defect,
|
||||||
|
/// because the straggler misses either way.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void A_straggler_from_an_older_generation_neither_clears_nor_rolls_the_stamp_back()
|
||||||
|
{
|
||||||
|
var cache = new SessionAclDecisionCache();
|
||||||
|
|
||||||
|
Ask(cache, generation: 2, compute: () => true).ShouldBeTrue();
|
||||||
|
cache.Generation.ShouldBe(2);
|
||||||
|
cache.Count.ShouldBe(1);
|
||||||
|
|
||||||
|
// The straggler: same question, superseded snapshot.
|
||||||
|
cache.TryGet(generation: 1, Session, OpcUaOperation.Read, AddressSpaceRealm.Raw, Node, out _)
|
||||||
|
.ShouldBeFalse("a superseded snapshot must recompute rather than read the newer generation");
|
||||||
|
|
||||||
|
// Asserted BEFORE the generation-2 lookup below, which would itself restore a rolled-back stamp and
|
||||||
|
// hide the defect.
|
||||||
|
cache.Generation.ShouldBe(2, "the straggler must not have rolled the stamp backward");
|
||||||
|
cache.Count.ShouldBe(1, "and must not have purged the current generation's entries");
|
||||||
|
|
||||||
|
cache.TryGet(generation: 2, Session, OpcUaOperation.Read, AddressSpaceRealm.Raw, Node, out var decision)
|
||||||
|
.ShouldBeTrue("the current generation's decision survived the straggler");
|
||||||
|
decision.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The store half of the same window: a <c>Set</c> that meets the previous generation's shard must
|
/// The store half of the same window: a <c>Set</c> that meets the previous generation's shard must
|
||||||
/// displace it, not fill it.
|
/// displace it, not fill it.
|
||||||
|
|||||||
Reference in New Issue
Block a user