Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SessionAclDecisionCache.cs
T

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>&lt;=</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);
}