perf(acl): per-session decision-cache shards — a crawl no longer evicts the fleet
This commit is contained in:
@@ -31,30 +31,68 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
/// 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>Storage is sharded per session, under a two-level bound.</b> Each session key owns its own
|
||||
/// dictionary, capped at <see cref="MaxEntriesPerSession"/>; overflowing it clears <i>that shard
|
||||
/// only</i>. A second, global <see cref="MaxEntries"/> bound clears every shard, and exists purely
|
||||
/// so many sessions 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 session on the server — the sessions 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>
|
||||
/// Entry ceiling, past which the cache clears rather than growing. Large enough that a realistic
|
||||
/// address space browsed by a handful of machine consumers never reaches it, small enough that a
|
||||
/// pathological client cannot use it to exhaust memory.
|
||||
/// 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 sessions each staying well under
|
||||
/// <see cref="MaxEntriesPerSession"/> still cannot grow the cache without limit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Clearing beats evicting here: 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.
|
||||
/// 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 is approximate under concurrency by design, and is reset by every clear so
|
||||
/// drift cannot ratchet permanently.
|
||||
/// </remarks>
|
||||
public const int MaxEntries = 100_000;
|
||||
|
||||
private readonly ConcurrentDictionary<DecisionKey, bool> _entries = new();
|
||||
/// <summary>
|
||||
/// Per-session entry ceiling, past which that session's 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 of every other session on the server.
|
||||
/// </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 session that caused
|
||||
/// it, which is the part the old flat cache got wrong.
|
||||
/// </remarks>
|
||||
public const int MaxEntriesPerSession = 20_000;
|
||||
|
||||
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>Number of decisions currently memoized. Diagnostics and tests.</summary>
|
||||
public int Count => _entries.Count;
|
||||
/// <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>
|
||||
/// Returns the memoized decision for this key, computing and storing it when absent or when the
|
||||
@@ -79,8 +117,9 @@ public sealed class SessionAclDecisionCache
|
||||
|
||||
InvalidateIfStale(generation);
|
||||
|
||||
var key = new DecisionKey(sessionKey, operation, realm, nodeIdentifier);
|
||||
if (_entries.TryGetValue(key, out var cached)) return cached;
|
||||
var shard = _shards.GetOrAdd(sessionKey, static _ => new Shard());
|
||||
var key = new ShardKey(operation, realm, nodeIdentifier);
|
||||
if (shard.Entries.TryGetValue(key, out var cached)) return cached;
|
||||
|
||||
var decided = compute();
|
||||
|
||||
@@ -89,13 +128,32 @@ public sealed class SessionAclDecisionCache
|
||||
// trie never made. Dropping it costs one recomputation on the next call.
|
||||
if (Generation != generation) return decided;
|
||||
|
||||
if (_entries.Count >= MaxEntries) _entries.Clear();
|
||||
_entries[key] = decided;
|
||||
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.
|
||||
ClearAll();
|
||||
shard = _shards.GetOrAdd(sessionKey, static _ => new Shard());
|
||||
}
|
||||
else if (Volatile.Read(ref shard.ApproxCount) >= MaxEntriesPerSession)
|
||||
{
|
||||
shard.Entries.Clear();
|
||||
Interlocked.Add(ref _globalCount, -Interlocked.Exchange(ref shard.ApproxCount, 0));
|
||||
}
|
||||
|
||||
// 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(key, decided))
|
||||
{
|
||||
Interlocked.Increment(ref shard.ApproxCount);
|
||||
Interlocked.Increment(ref _globalCount);
|
||||
}
|
||||
|
||||
return decided;
|
||||
}
|
||||
|
||||
/// <summary>Drops every memoized decision. Called when the authorization generation advances.</summary>
|
||||
public void Clear() => _entries.Clear();
|
||||
public void Clear() => ClearAll();
|
||||
|
||||
private void InvalidateIfStale(long generation)
|
||||
{
|
||||
@@ -104,9 +162,31 @@ public sealed class SessionAclDecisionCache
|
||||
|
||||
// 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) _entries.Clear();
|
||||
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen) ClearAll();
|
||||
}
|
||||
|
||||
private readonly record struct DecisionKey(
|
||||
string SessionKey, OpcUaOperation Operation, AddressSpaceRealm Realm, string NodeIdentifier);
|
||||
/// <summary>
|
||||
/// Drops every shard and resets 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>
|
||||
private void ClearAll()
|
||||
{
|
||||
_shards.Clear();
|
||||
Interlocked.Exchange(ref _globalCount, 0);
|
||||
}
|
||||
|
||||
/// <summary>One session's decisions, plus the approximate entry count its own ceiling is enforced on.</summary>
|
||||
private sealed class Shard
|
||||
{
|
||||
public readonly ConcurrentDictionary<ShardKey, bool> Entries = new();
|
||||
|
||||
/// <summary>Approximate by design — read and written with <see cref="Interlocked"/>, never derived
|
||||
/// from <see cref="ConcurrentDictionary{TKey,TValue}.Count"/> on the store path.</summary>
|
||||
public int ApproxCount;
|
||||
}
|
||||
|
||||
private readonly record struct ShardKey(
|
||||
OpcUaOperation Operation, AddressSpaceRealm Realm, string NodeIdentifier);
|
||||
}
|
||||
|
||||
@@ -111,20 +111,58 @@ public sealed class SessionAclDecisionCacheTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cache is bounded. A client walking a huge address space must not be able to grow it without
|
||||
/// limit; clearing at the ceiling degrades to recomputation, which is the no-cache steady state.
|
||||
/// The global bound still holds when no single session is anywhere near its own ceiling. Spreading
|
||||
/// the entries over many sessions is the case the per-session cap cannot catch, so the global
|
||||
/// counter is the only thing standing between a large fleet and unbounded growth.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_cache_is_bounded()
|
||||
public void The_global_bound_still_holds()
|
||||
{
|
||||
var cache = new SessionAclDecisionCache();
|
||||
|
||||
for (var i = 0; i <= SessionAclDecisionCache.MaxEntries + 10; i++)
|
||||
Ask(cache, generation: 1, node: $"Plant/dev1/Tag{i}", compute: () => true);
|
||||
// Well under MaxEntriesPerSession, so every clear observed here is the global one.
|
||||
const int perSession = 5_000;
|
||||
var total = SessionAclDecisionCache.MaxEntries + perSession;
|
||||
|
||||
for (var i = 0; i < total; i++)
|
||||
Ask(
|
||||
cache, generation: 1, session: $"key-{i / perSession}", node: $"Plant/dev1/Tag{i}",
|
||||
compute: () => true);
|
||||
|
||||
// The global counter is approximate under concurrency, so the contract allows one shard's worth of
|
||||
// slack; single-threaded it is exact, which is the stronger fact pinned here.
|
||||
cache.Count.ShouldBeLessThanOrEqualTo(SessionAclDecisionCache.MaxEntries);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One session filling its own shard evicts only itself. This is the whole point of sharding: a
|
||||
/// machine consumer crawling a 50k-node tree used to blow away every other session's decisions on
|
||||
/// its way past the ceiling, so the sessions doing ordinary work paid for the crawl.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_full_session_shard_clears_only_that_session()
|
||||
{
|
||||
var cache = new SessionAclDecisionCache();
|
||||
const string crawler = "crawler-key";
|
||||
const string neighbour = "neighbour-key";
|
||||
var neighbourCalls = 0;
|
||||
|
||||
Ask(cache, generation: 1, session: neighbour, compute: () => { neighbourCalls++; return true; })
|
||||
.ShouldBeTrue();
|
||||
neighbourCalls.ShouldBe(1);
|
||||
|
||||
for (var i = 0; i <= SessionAclDecisionCache.MaxEntriesPerSession + 10; i++)
|
||||
Ask(cache, generation: 1, session: crawler, node: $"Plant/dev1/Tag{i}", compute: () => true);
|
||||
|
||||
// The crawler's own shard was cleared and restarted rather than growing without limit.
|
||||
cache.Count.ShouldBeLessThanOrEqualTo(SessionAclDecisionCache.MaxEntriesPerSession);
|
||||
|
||||
// The neighbour's single decision survived the crawl — its walk is not re-run.
|
||||
Ask(cache, generation: 1, session: neighbour, compute: () => { neighbourCalls++; return true; })
|
||||
.ShouldBeTrue();
|
||||
neighbourCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A decision taken while a deployment lands mid-walk is returned but NOT stored, so the new
|
||||
/// generation never inherits a verdict its own trie did not make.
|
||||
|
||||
Reference in New Issue
Block a user