perf(acl): authorizer hot path — cache lookup before scope build, lazy deny reasons
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user