perf(acl): authorizer hot path — cache lookup before scope build, lazy deny reasons

This commit is contained in:
Joseph Doherty
2026-08-16 04:19:47 -04:00
parent 7320586512
commit d8a17f2e34
3 changed files with 338 additions and 78 deletions
@@ -94,9 +94,101 @@ public sealed class SessionAclDecisionCache
}
}
/// <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, for callers that genuinely have nothing cheaper to offer.
/// </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.
return _shards.TryGetValue(sessionKey, out var shard)
&& 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 _ => new Shard());
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(new ShardKey(operation, realm, nodeIdentifier), decision))
{
Interlocked.Increment(ref shard.ApproxCount);
Interlocked.Increment(ref _globalCount);
}
}
/// <summary>
/// Returns the memoized decision for this key, computing and storing it when absent or when the
/// generation has moved on.
/// generation has moved on. A convenience over <see cref="TryGet"/> + <see cref="Set"/> for callers
/// that have no cheaper way to express the walk than a closure.
/// </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>
@@ -115,40 +207,10 @@ public sealed class SessionAclDecisionCache
{
ArgumentNullException.ThrowIfNull(compute);
InvalidateIfStale(generation);
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;
if (TryGet(generation, sessionKey, operation, realm, nodeIdentifier, out var cached)) return cached;
var decided = compute();
// Re-read the generation AFTER computing. 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 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);
}
Set(generation, sessionKey, operation, realm, nodeIdentifier, decided);
return decided;
}