perf(grpc): O(1) unconstrained bulk fast path, direct filtered-command build, cache eviction, capacity hints

This commit is contained in:
Joseph Doherty
2026-08-15 12:24:17 -04:00
parent 88d38bb900
commit 7171892984
6 changed files with 261 additions and 33 deletions
@@ -19,10 +19,35 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// </remarks>
public static class GatewayApiKeyIdentityMapper
{
private const int MaxCachedConstraintBlobs = 1024;
/// <summary>
/// Maximum number of parsed constraint blobs retained in <see cref="ConstraintCache"/>.
/// Blobs are admin-controlled (one per API key), so the cap is only a memory backstop for a
/// store with an unusually large number of distinct constrained keys.
/// </summary>
internal const int MaxCachedConstraintBlobs = 1024;
/// <summary>
/// Bounded parsed-constraints cache keyed by the raw constraints JSON. The blob is parsed
/// once per authenticated RPC otherwise, so this keeps the JSON parse off the hot path.
/// Beyond <see cref="MaxCachedConstraintBlobs"/> entries the oldest insertion is evicted
/// rather than the cache refusing new entries — a hard stop at the cap would leave every key
/// admitted after it re-parsing its blob on every RPC for the process lifetime. Eviction is
/// approximate (FIFO over insertion order, not true LRU) because only the bound matters.
/// </summary>
private static readonly ConcurrentDictionary<string, ApiKeyConstraints> ConstraintCache =
new(StringComparer.Ordinal);
/// <summary>
/// Insertion-order queue used to evict the oldest cache entry once the cache exceeds
/// <see cref="MaxCachedConstraintBlobs"/>. Keeping it separate leaves
/// <see cref="ConstraintCache"/> reads lock-free; the lock guards only the eviction path.
/// </summary>
private static readonly ConcurrentQueue<string> InsertionOrder = new();
private static readonly object EvictionLock = new();
/// <summary>Current cache size, exposed for tests asserting the cap is honoured.</summary>
internal static int CurrentCacheSize => ConstraintCache.Count;
private static ApiKeyConstraints DeserializeConstraints(string? constraintsJson)
{
if (string.IsNullOrWhiteSpace(constraintsJson))
@@ -36,12 +61,36 @@ public static class GatewayApiKeyIdentityMapper
}
ApiKeyConstraints parsed = ApiKeyConstraintSerializer.Deserialize(constraintsJson);
if (ConstraintCache.Count < MaxCachedConstraintBlobs)
// GetOrAdd returns whichever instance is in the cache after the call, so concurrent parsers
// of the same blob converge on one instance; it also avoids the TryAdd-then-read race where
// the key could be evicted between a failed TryAdd and the read back.
ApiKeyConstraints result = ConstraintCache.GetOrAdd(constraintsJson, parsed);
if (ReferenceEquals(result, parsed))
{
ConstraintCache.TryAdd(constraintsJson, parsed);
// We were the inserter — track for FIFO eviction and bound the cache.
InsertionOrder.Enqueue(constraintsJson);
EvictIfOverCapacity();
}
return parsed;
return result;
}
private static void EvictIfOverCapacity()
{
if (ConstraintCache.Count <= MaxCachedConstraintBlobs)
{
return;
}
// Serialize eviction so two threads do not race past the cap together.
lock (EvictionLock)
{
while (ConstraintCache.Count > MaxCachedConstraintBlobs && InsertionOrder.TryDequeue(out string? oldest))
{
ConstraintCache.TryRemove(oldest, out _);
}
}
}
/// <summary>