557 lines
33 KiB
C#
557 lines
33 KiB
C#
using System.Collections.Concurrent;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
/// <summary>
|
|
/// Memoizes the <b>grant-walk</b> half of an ACL decision per <c>(session, node, operation)</c> for one
|
|
/// authorization generation (#520 stage 6).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why this exists.</b> Stage 5 gated write, alarm commands and HistoryRead — all of which are
|
|
/// request-rate operations where rebuilding the decision each time costs nothing. Stage 6 gates
|
|
/// Read, Browse and subscription creation, which are not: one Browse of a device folder asks the
|
|
/// same question once per child, and a client that reads a thousand nodes asks it a thousand times.
|
|
/// The trie walk is cheap individually and the repetition is what makes it worth avoiding.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>Only the grant walk is cached — never the key check.</b> A cached verdict is valid because
|
|
/// grants, principals and the trie are all fixed for the life of a generation, so nothing the walk
|
|
/// reads can change without <see cref="Generation"/> advancing. That reasoning does <b>not</b>
|
|
/// extend to the calling key's usability: a key's <c>ExpiresUtc</c> passes with the clock, not with
|
|
/// a deployment, so a cached allow would keep an expired key working until the next deploy —
|
|
/// re-introducing, in a subtler form, exactly the open-session hole stage 5 closed.
|
|
/// <see cref="DataPlaneAuthorizer"/> therefore runs revocation and expiry on every call and consults
|
|
/// this only afterwards.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Invalidation is wholesale, by generation.</b> Per-entry invalidation would need to know which
|
|
/// nodes a changed grant covers, which is the trie walk run backwards; dropping everything on a
|
|
/// deploy costs one repopulation and cannot be wrong. Deployments are rare relative to reads.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>The invariant that makes that safe is per-shard, not global: a shard can only ever answer for
|
|
/// the generation it was built under.</b> The wholesale drop is necessarily two steps — publish the new
|
|
/// generation, then purge the shards — because the publish <i>is</i> the compare-exchange that elects
|
|
/// the single thread allowed to purge. Between them the stamp already matches while the previous
|
|
/// generation's shards are still in the table, so comparing the stamp alone would let a caller arriving
|
|
/// in that window read a verdict the new trie never made. Each shard therefore carries its own
|
|
/// generation: a mismatched shard is a miss on the read path and is displaced on the store path, which
|
|
/// makes the window inert rather than merely narrow.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Storage is sharded per calling KEY, under a two-level bound.</b> The shard key is
|
|
/// <c>ApiKeyId ?? Principal</c>, so <i>sessions sharing one API key share one shard and one
|
|
/// <see cref="MaxEntriesPerSession"/> cap</i> — the isolation is across distinct keys, not across
|
|
/// sessions. Overflowing a shard clears <i>that shard only</i>, so a crawl by one key cannot evict
|
|
/// another key's entries. A second, global <see cref="MaxEntries"/> bound clears every shard, and
|
|
/// exists purely so many keys each staying under their own cap cannot add up to unbounded growth.
|
|
/// The flat cache this replaced had only the global bound, which made one machine consumer
|
|
/// crawling a large tree an eviction event for every other caller on the server — the callers
|
|
/// doing ordinary work paid for the crawl. Both caps are enforced against
|
|
/// <see cref="Interlocked"/> counters, never
|
|
/// <see cref="ConcurrentDictionary{TKey,TValue}.Count"/>, which locks the whole table.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class SessionAclDecisionCache
|
|
{
|
|
/// <summary>
|
|
/// Process-wide entry ceiling, past which <b>every</b> shard clears. The outer of the two bounds,
|
|
/// and a rare fallback: it exists so a large fleet of distinct keys each staying well under
|
|
/// <see cref="MaxEntriesPerSession"/> still cannot grow the cache without limit.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Enforced against an <see cref="Interlocked"/> counter rather than
|
|
/// <see cref="ConcurrentDictionary{TKey,TValue}.Count"/>, which takes every lock in the table — a
|
|
/// price the store path would pay on every miss to answer a question that only matters at the
|
|
/// ceiling. The counter lags — an insert is in its shard's dictionary a moment before it is booked
|
|
/// — but it does not drift: every <b>booked</b> entry is unbooked exactly once, when the shard
|
|
/// holding it is sealed. (Not every entry is booked: one whose <see cref="TryBookInsert"/> loses
|
|
/// the compare-exchange to a seal is never booked at all, because it is already unreachable in a
|
|
/// displaced shard.) See <see cref="SealedShard"/> for how that survives an eviction racing an
|
|
/// insert.
|
|
/// </remarks>
|
|
public const int MaxEntries = 100_000;
|
|
|
|
/// <summary>
|
|
/// Per-<b>key</b> entry ceiling (the shard key is <c>ApiKeyId ?? Principal</c>, so every session
|
|
/// using the same API key shares this one budget), past which that shard alone clears. The inner
|
|
/// and normally the only bound reached: it is what keeps one machine consumer crawling a 50k-node
|
|
/// tree from evicting the decisions taken for every OTHER key on the server. The name predates the
|
|
/// shard key and is kept for source compatibility.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Clearing beats evicting <i>within a shard</i>: an LRU needs per-entry bookkeeping on the hot path
|
|
/// to save a trie walk that is itself cheap, so the eviction policy would cost more than the miss it
|
|
/// prevents. The failure mode of clearing is a burst of recomputation, which is exactly the steady
|
|
/// state of not having a cache at all — and sharding confines that burst to the key that caused it,
|
|
/// which is the part the old flat cache got wrong.
|
|
/// </remarks>
|
|
public const int MaxEntriesPerSession = 20_000;
|
|
|
|
/// <summary>
|
|
/// Sentinel written over a shard's <see cref="Shard.ApproxCount"/> the instant it stops being the
|
|
/// live shard for its key. It is the whole of the eviction race fix: it is set by the same
|
|
/// <see cref="Interlocked.Exchange(ref int, int)"/> that captures the count being subtracted from
|
|
/// <see cref="_globalCount"/>, so an insert either books its increment <i>before</i> that capture
|
|
/// (and is therefore included in the subtraction) or sees the sentinel and books nothing at all.
|
|
/// There is no third outcome, which is what makes the counter exact rather than approximate.
|
|
/// Negative so it can never be mistaken for a real count, and far enough from zero that the
|
|
/// increment path cannot walk it back to a positive value.
|
|
/// </summary>
|
|
private const int SealedShard = int.MinValue;
|
|
|
|
private readonly ConcurrentDictionary<string, Shard> _shards = new(StringComparer.Ordinal);
|
|
private long _generation = -1;
|
|
private int _globalCount;
|
|
|
|
/// <summary>The authorization generation the cached decisions were taken under.</summary>
|
|
public long Generation => Interlocked.Read(ref _generation);
|
|
|
|
/// <summary>
|
|
/// Exact number of decisions currently memoized, across every shard. Diagnostics and tests only —
|
|
/// it enumerates the shards and takes each one's locks, so it must stay off the store path (which
|
|
/// uses the approximate counters above instead).
|
|
/// </summary>
|
|
public int Count
|
|
{
|
|
get
|
|
{
|
|
var total = 0;
|
|
foreach (var shard in _shards.Values) total += shard.Entries.Count;
|
|
return total;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The running counter both ceilings are enforced against. Exposed for the concurrency audit only:
|
|
/// the property a test wants is almost always <see cref="Count"/>, and the single interesting fact
|
|
/// about this one is that the two agree.
|
|
/// </summary>
|
|
internal int TrackedCount => Volatile.Read(ref _globalCount);
|
|
|
|
/// <summary>
|
|
/// Sum of the live shards' own counters — the middle term of the invariant
|
|
/// <c>TrackedCount == LiveShardCount == Count</c>. Reading the three separately is what tells a
|
|
/// failing audit whether a shard's counter drifted from its dictionary or the global counter
|
|
/// drifted from the shards.
|
|
/// </summary>
|
|
internal int LiveShardCount
|
|
{
|
|
get
|
|
{
|
|
var total = 0;
|
|
foreach (var shard in _shards.Values) total += Volatile.Read(ref shard.ApproxCount);
|
|
return total;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Looks a decision up without allocating anything — the hot half, and the reason the store half is
|
|
/// a separate method rather than a <see cref="Func{TResult}"/> parameter.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why split from <see cref="GetOrAdd"/>.</b> A closure-taking API forces its caller to
|
|
/// build the closure — and everything the closure captures — <i>before</i> it knows whether the
|
|
/// answer is already memoized, so the allocation the cache exists to avoid is paid on the hit
|
|
/// path too. Read and Browse ask this question once per node, which is precisely where paying
|
|
/// for a walk that never runs is worth removing. <see cref="GetOrAdd"/> remains, delegating
|
|
/// here, but has no production caller — it is exercised by the cache's own tests.
|
|
/// </para>
|
|
/// <para>
|
|
/// The generation is checked (and a stale cache dropped) here rather than in
|
|
/// <see cref="Set"/> alone, so a lookup landing first after a deploy cannot read the previous
|
|
/// generation's verdicts.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="generation">The authorization generation the decision is being taken under.</param>
|
|
/// <param name="sessionKey">Stable identity of the calling session — its key id, else its principal.</param>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="realm">The realm the node was resolved in.</param>
|
|
/// <param name="nodeIdentifier">The node's <c>s=</c> identifier.</param>
|
|
/// <param name="decision">The memoized verdict, when one exists.</param>
|
|
/// <returns><see langword="true"/> when a decision was memoized for this key.</returns>
|
|
public bool TryGet(
|
|
long generation,
|
|
string sessionKey,
|
|
OpcUaOperation operation,
|
|
AddressSpaceRealm realm,
|
|
string nodeIdentifier,
|
|
out bool decision)
|
|
{
|
|
InvalidateIfStale(generation);
|
|
|
|
decision = false;
|
|
|
|
// TryGetValue rather than GetOrAdd: a session that only ever hits must not mint a shard, and a
|
|
// session whose shard was just cleared must not resurrect one until it actually stores something.
|
|
//
|
|
// The shard's OWN generation decides, not the stamp InvalidateIfStale just compared: inside the
|
|
// rollover window the stamp already matches and the purge has not run, so the stamp cannot
|
|
// distinguish this generation's shard from the last one's. A mismatch is treated as a plain miss and
|
|
// is NOT displaced from here — this path stays allocation-free and non-mutating, and the shard is
|
|
// reclaimed a moment later by the purging thread or by the next Set on that key.
|
|
return _shards.TryGetValue(sessionKey, out var shard)
|
|
&& shard.Generation == generation
|
|
&& shard.Entries.TryGetValue(new ShardKey(operation, realm, nodeIdentifier), out decision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Memoizes a decision, subject to the two entry bounds and the overtaken-generation drop rule.
|
|
/// </summary>
|
|
/// <param name="generation">The generation the decision was taken under — <b>not</b> re-read from the
|
|
/// caller, so a deploy landing mid-walk is detected here.</param>
|
|
/// <param name="sessionKey">Stable identity of the calling session — its key id, else its principal.</param>
|
|
/// <param name="operation">The operation that was attempted.</param>
|
|
/// <param name="realm">The realm the node was resolved in.</param>
|
|
/// <param name="nodeIdentifier">The node's <c>s=</c> identifier.</param>
|
|
/// <param name="decision">The verdict the grant walk produced.</param>
|
|
public void Set(
|
|
long generation,
|
|
string sessionKey,
|
|
OpcUaOperation operation,
|
|
AddressSpaceRealm realm,
|
|
string nodeIdentifier,
|
|
bool decision)
|
|
{
|
|
// Re-read the generation AFTER the walk. A deploy landing mid-walk means the verdict may have been
|
|
// taken against either generation, and storing it under the new one would pin a decision the new
|
|
// trie never made. Dropping it costs one recomputation on the next call.
|
|
if (Generation != generation) return;
|
|
|
|
var shard = _shards.GetOrAdd(sessionKey, static (_, gen) => new Shard(gen), generation);
|
|
|
|
if (Volatile.Read(ref _globalCount) >= MaxEntries)
|
|
{
|
|
// The outer bound: no single session is over its own ceiling, so there is nothing narrower to
|
|
// drop. Re-acquire the shard — ClearAll drops the shard objects themselves.
|
|
//
|
|
// Deliberately NOT elected: concurrent callers crossing the ceiling together all run ClearAll,
|
|
// and that is safe rather than merely tolerated. ClearAll displaces each shard with a per-shard
|
|
// TryRemove, so exactly one thread wins each shard and seals it exactly once; the losers just
|
|
// re-enumerate an emptying dictionary. The redundancy costs an enumeration, not correctness,
|
|
// and a CAS-elected clear would add hot-path state for a branch that is a rare fallback.
|
|
ClearAll();
|
|
shard = _shards.GetOrAdd(sessionKey, static (_, gen) => new Shard(gen), generation);
|
|
}
|
|
else if (Volatile.Read(ref shard.ApproxCount) >= MaxEntriesPerSession)
|
|
{
|
|
// The inner bound. The replacement is pre-sized to what the shard it displaces was holding: this
|
|
// key has just demonstrated it fills its whole budget, and it is about to do so again, so the
|
|
// refill would otherwise walk the dictionary's whole growth cascade — a rehash under every one of
|
|
// the table's locks at each doubling — to arrive back at a size already known. Clamped to the
|
|
// ceiling because the count read here is the pre-seal one — a thread that lost the swap reads
|
|
// the sentinel and clamps to no hint at all, which is the right answer for it. Only this site
|
|
// hints; a shard minted by GetOrAdd has no such evidence and stays at the default capacity.
|
|
//
|
|
// The trade: the replacement is built before the swap is attempted, so a thread that loses
|
|
// discards a pre-sized dictionary rather than an empty one. That is a bigger throwaway than
|
|
// 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 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 this very Displace replaces it with a fresh shard
|
|
// stamped to the current generation regardless. Sizing this generation's storage from the last
|
|
// one's crawl would be a guess dressed as evidence.
|
|
shard = Displace(
|
|
sessionKey, shard, generation,
|
|
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
|
|
// deploy landed since the shard was minted (the common case — one displacement per key per deploy,
|
|
// and the read path has been missing on it since the stamp moved), or the rollover window closed
|
|
// between the check at the top of this method and here.
|
|
if (shard.Generation != generation)
|
|
{
|
|
shard = Displace(sessionKey, shard, generation, capacityHint: 0);
|
|
|
|
// Still mismatched means a racer re-minted the shard under a generation that has since moved
|
|
// again. Drop the decision rather than loop: filing this generation's verdict in another
|
|
// generation's shard is the same error in the other direction, and the line above already
|
|
// dropped a verdict for the identical reason.
|
|
if (shard.Generation != generation) return;
|
|
}
|
|
|
|
// TryAdd, not an indexer assignment: only a genuine insert may move the counters, or a racing pair
|
|
// of walks over the same key would inflate them and pull the ceilings in for good.
|
|
if (shard.Entries.TryAdd(new ShardKey(operation, realm, nodeIdentifier), decision))
|
|
TryBookInsert(shard);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces a session's live shard with a fresh one for <paramref name="generation"/> and returns
|
|
/// whatever shard is live afterwards.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This REPLACES the shard object rather than emptying the dictionary in place — the move
|
|
/// <see cref="ClearAll"/> makes, and for the same reason. Emptying in place left the clear and the
|
|
/// counter reset as two steps over storage a racing <see cref="Set"/> was still inserting into: the
|
|
/// racer could land in the just-cleared dictionary and increment both counters between them, and the
|
|
/// <see cref="Interlocked.Exchange(ref int, int)"/> then subtracted an amount that included the
|
|
/// racer's fresh entry, under-counting <see cref="_globalCount"/> for good. Swapping the object makes
|
|
/// the displaced shard unreachable in one atomic step, after which <see cref="Seal"/> settles its
|
|
/// count exactly — and because only the thread that wins the compare-and-swap seals, no shard is
|
|
/// sealed twice however many threads arrive here at once. The returned shard is re-read from the
|
|
/// table rather than assumed to be the one just built: whoever won the swap, the live shard is now a
|
|
/// different object than the caller held.
|
|
/// </remarks>
|
|
/// <param name="sessionKey">The key whose shard is being displaced.</param>
|
|
/// <param name="shard">The shard the caller found live.</param>
|
|
/// <param name="generation">The generation the replacement is built for.</param>
|
|
/// <param name="capacityHint">Initial entry capacity for the replacement; zero leaves it at the default.</param>
|
|
/// <returns>The live shard after the swap.</returns>
|
|
private Shard Displace(string sessionKey, Shard shard, long generation, int capacityHint)
|
|
{
|
|
if (_shards.TryUpdate(sessionKey, new Shard(generation, capacityHint), shard))
|
|
Interlocked.Add(ref _globalCount, -Seal(shard));
|
|
|
|
return _shards.GetOrAdd(sessionKey, static (_, gen) => new Shard(gen), generation);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Books a just-completed insert against the shard's own counter and the global one, unless the
|
|
/// shard was displaced first — in which case the entry lives in storage nothing can reach any more
|
|
/// and must not be counted.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A plain <see cref="Interlocked.Increment(ref int)"/> here would race the seal in the other
|
|
/// direction: an increment landing after the displacing thread captured the count would leave
|
|
/// <see cref="_globalCount"/> carrying an entry no live shard holds. The compare-exchange is what
|
|
/// linearises the two — either it wins while the shard is still live (and the seal that follows
|
|
/// captures and subtracts this increment along with the rest), or it observes
|
|
/// <see cref="SealedShard"/> and books nothing. The loop retries only against other <i>inserts</i>
|
|
/// on the same shard; the sealed case exits.
|
|
/// <para>
|
|
/// The shard's counter is moved <b>before</b> the global one, and that order matters: a seal
|
|
/// landing between the two subtracts an increment the global counter has not received yet, so
|
|
/// <see cref="_globalCount"/> can dip transiently below the live total. That is self-healing —
|
|
/// the thread in the gap always completes its increment — and it errs towards <i>not</i>
|
|
/// clearing, which is the harmless direction. The reverse order cannot self-heal: it would book
|
|
/// globally before the seal could see the entry, leaving a permanent over-count.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="shard">The shard the entry was added to.</param>
|
|
private void TryBookInsert(Shard shard)
|
|
{
|
|
while (true)
|
|
{
|
|
var seen = Volatile.Read(ref shard.ApproxCount);
|
|
|
|
// Displaced between the TryAdd and here: the entry is lost with the shard, and counting it
|
|
// would be exactly the over-count this method exists to prevent.
|
|
if (seen < 0) return;
|
|
|
|
if (Interlocked.CompareExchange(ref shard.ApproxCount, seen + 1, seen) != seen) continue;
|
|
|
|
Interlocked.Increment(ref _globalCount);
|
|
return;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retires a shard that has just been removed from (or replaced in) <see cref="_shards"/> and
|
|
/// returns the number of entries it was still accounted for, for the caller to subtract from
|
|
/// <see cref="_globalCount"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Only the thread that actually displaced the shard may call this — <see cref="_shards"/>'
|
|
/// compare-and-swap removal/replacement elects exactly one, so no shard is ever sealed twice. The
|
|
/// already-sealed branch is defence in depth: it returns zero rather than subtracting the
|
|
/// sentinel, which would drive the global counter deeply negative and disable both ceilings.
|
|
/// </remarks>
|
|
/// <param name="shard">The displaced shard.</param>
|
|
/// <returns>The count to subtract from <see cref="_globalCount"/>.</returns>
|
|
private static int Seal(Shard shard)
|
|
{
|
|
var captured = Interlocked.Exchange(ref shard.ApproxCount, SealedShard);
|
|
return captured < 0 ? 0 : captured;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the memoized decision for this key, computing and storing it when absent or when the
|
|
/// generation has moved on. A convenience over <see cref="TryGet"/> + <see cref="Set"/>.
|
|
/// <para>
|
|
/// <b>Retained for tests; production has no caller.</b> <see cref="DataPlaneAuthorizer"/> uses
|
|
/// the split <see cref="TryGet"/>/<see cref="Set"/> pair precisely so a hit pays for no closure,
|
|
/// and no other caller has appeared since. Kept because it is the readable single-expression
|
|
/// form of the same protocol and is how the cache's tests drive it — not because a production
|
|
/// caller is expected back.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="generation">The authorization generation the decision is being taken under.</param>
|
|
/// <param name="sessionKey">Stable identity of the calling session — its key id, else its principal.</param>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="realm">The realm the node was resolved in.</param>
|
|
/// <param name="nodeIdentifier">The node's <c>s=</c> identifier.</param>
|
|
/// <param name="compute">The grant walk to run on a miss.</param>
|
|
/// <returns><see langword="true"/> when the operation is permitted.</returns>
|
|
public bool GetOrAdd(
|
|
long generation,
|
|
string sessionKey,
|
|
OpcUaOperation operation,
|
|
AddressSpaceRealm realm,
|
|
string nodeIdentifier,
|
|
Func<bool> compute)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(compute);
|
|
|
|
if (TryGet(generation, sessionKey, operation, realm, nodeIdentifier, out var cached)) return cached;
|
|
|
|
var decided = compute();
|
|
Set(generation, sessionKey, operation, realm, nodeIdentifier, decided);
|
|
return decided;
|
|
}
|
|
|
|
/// <summary>Drops every memoized decision. Called when the authorization generation advances.</summary>
|
|
public void Clear() => ClearAll();
|
|
|
|
/// <summary>
|
|
/// Moves the generation stamp <b>without</b> clearing — reproducing, deterministically, the exact
|
|
/// view a racer gets inside <see cref="InvalidateIfStale"/>'s rollover window, where the new
|
|
/// generation is already published but the winner's <see cref="ClearAll"/> has not run yet.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Tests only. That window is a few instructions wide, so a concurrency test can only make it
|
|
/// probable; this makes it certain. The invariant it exercises — <i>a shard can only serve the
|
|
/// generation it was built under</i> — is what makes the window harmless, so the pin has to be able
|
|
/// to stand inside it.
|
|
/// </remarks>
|
|
/// <param name="generation">The generation to publish.</param>
|
|
internal void AdvanceGenerationForTest(long generation) =>
|
|
Interlocked.Exchange(ref _generation, generation);
|
|
|
|
/// <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>
|
|
/// ⚠️ <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>
|
|
/// ⚠️ <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 — 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)
|
|
{
|
|
while (true)
|
|
{
|
|
var seen = Interlocked.Read(ref _generation);
|
|
if (generation <= seen) return;
|
|
|
|
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen)
|
|
{
|
|
ClearAll();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drops every shard and settles the global counter. Dropping the shard objects rather than emptying
|
|
/// them also sheds the shards of sessions that have since closed. A walk racing this holds a shard
|
|
/// that is no longer in the table; its entry is simply lost, which is the same outcome the clear
|
|
/// itself produces.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each shard is removed and then <see cref="Seal"/>ed, and only its sealed count is subtracted —
|
|
/// rather than the whole counter being slammed to zero. Zeroing is wrong in the other direction
|
|
/// from the eviction race: a walk that mints a <i>fresh</i> shard while this is running is a live
|
|
/// entry, and resetting the counter after it books its increment would lose it. Subtracting exactly
|
|
/// what is being dropped leaves the counter equal to what remains, whatever arrives mid-clear.
|
|
/// </remarks>
|
|
private void ClearAll()
|
|
{
|
|
foreach (var key in _shards.Keys)
|
|
if (_shards.TryRemove(key, out var shard))
|
|
Interlocked.Add(ref _globalCount, -Seal(shard));
|
|
}
|
|
|
|
/// <summary>
|
|
/// One session's decisions, plus the entry count its own ceiling is enforced on. A shard is
|
|
/// immutable in identity: it is never emptied, only replaced, so a reference obtained from
|
|
/// <see cref="_shards"/> either still is the live shard or has been sealed — it can never turn
|
|
/// into a <i>different</i> generation of the same session's entries under its holder.
|
|
/// </summary>
|
|
/// <param name="generation">The authorization generation this shard's entries were decided under.</param>
|
|
/// <param name="capacity">
|
|
/// Initial entry capacity. Zero leaves <see cref="Entries"/> at the default — the right choice
|
|
/// everywhere except the per-shard eviction swap, which alone has evidence of the size the
|
|
/// replacement is about to grow back to.
|
|
/// </param>
|
|
private sealed class Shard(long generation, int capacity = 0)
|
|
{
|
|
public readonly ConcurrentDictionary<ShardKey, bool> Entries = capacity > 0
|
|
? new ConcurrentDictionary<ShardKey, bool>(Environment.ProcessorCount, capacity)
|
|
: new ConcurrentDictionary<ShardKey, bool>();
|
|
|
|
/// <summary>
|
|
/// The generation this shard's entries were decided under, fixed for its lifetime. It is what the
|
|
/// read path compares — the cache-wide stamp cannot be trusted alone, because it is published one
|
|
/// step before the shards it invalidates are purged.
|
|
/// </summary>
|
|
public readonly long Generation = generation;
|
|
|
|
/// <summary>
|
|
/// Read and written with <see cref="Interlocked"/>, never derived from
|
|
/// <see cref="ConcurrentDictionary{TKey,TValue}.Count"/> on the store path. The name is
|
|
/// historical: while the shard is live this tracks <see cref="Entries"/> exactly (entries are
|
|
/// only ever added, and every add books here), and once displaced it holds
|
|
/// <see cref="SealedShard"/>. It is <i>lagging</i> — an insert is visible in
|
|
/// <see cref="Entries"/> a moment before it is booked here — but never wrong.
|
|
/// </summary>
|
|
public int ApproxCount;
|
|
}
|
|
|
|
private readonly record struct ShardKey(
|
|
OpcUaOperation Operation, AddressSpaceRealm Realm, string NodeIdentifier);
|
|
}
|