perf(grpc): O(1) unconstrained bulk fast path, direct filtered-command build, cache eviction, capacity hints
This commit is contained in:
+53
-4
@@ -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>
|
||||
|
||||
@@ -16,6 +16,14 @@ public sealed class ConstraintEnforcer(
|
||||
IGalaxyHierarchyCache cache,
|
||||
IAuditWriter auditWriter) : IConstraintEnforcer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool HasReadConstraints(ApiKeyIdentity? identity) =>
|
||||
identity?.EffectiveConstraints.HasReadConstraints ?? false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasWriteConstraints(ApiKeyIdentity? identity) =>
|
||||
identity?.EffectiveConstraints.HasWriteConstraints ?? false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
@@ -211,7 +219,25 @@ public sealed class ConstraintEnforcer(
|
||||
return true;
|
||||
}
|
||||
|
||||
return subtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(containedPath, glob))
|
||||
|| tagGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(tagAddress, glob));
|
||||
// Plain index loops rather than Any(lambda): this runs once per item of every bulk
|
||||
// read/write, and the closures the lambdas capture (containedPath / tagAddress) allocate a
|
||||
// display class plus a delegate per call. Same short-circuit order, same result.
|
||||
for (int i = 0; i < subtreeGlobs.Count; i++)
|
||||
{
|
||||
if (GalaxyGlobMatcher.IsMatch(containedPath, subtreeGlobs[i]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < tagGlobs.Count; i++)
|
||||
{
|
||||
if (GalaxyGlobMatcher.IsMatch(tagAddress, tagGlobs[i]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,30 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
public interface IConstraintEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether any read constraint applies to an identity at all, so a
|
||||
/// bulk caller can hoist the question out of its per-item loop.
|
||||
/// </summary>
|
||||
/// <param name="identity">The API key identity.</param>
|
||||
/// <returns><see langword="true"/> when at least one read constraint applies; otherwise <see langword="false"/>.</returns>
|
||||
/// <remarks>
|
||||
/// Every per-item <see cref="CheckReadTagAsync"/> / <see cref="CheckReadHandleAsync"/> call for
|
||||
/// an unconstrained identity allows the item, so skipping the loop removes work without
|
||||
/// changing a decision. The default implementation answers <see langword="true"/> — an
|
||||
/// implementation that does not model constraints (test doubles, allow-all enforcers) keeps
|
||||
/// being consulted per item rather than being silently bypassed.
|
||||
/// </remarks>
|
||||
bool HasReadConstraints(ApiKeyIdentity? identity) => true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether any write constraint applies to an identity at all, the
|
||||
/// write-side counterpart of <see cref="HasReadConstraints"/>.
|
||||
/// </summary>
|
||||
/// <param name="identity">The API key identity.</param>
|
||||
/// <returns><see langword="true"/> when at least one write constraint applies; otherwise <see langword="false"/>.</returns>
|
||||
/// <remarks>The same conservative default as <see cref="HasReadConstraints"/> applies.</remarks>
|
||||
bool HasWriteConstraints(ApiKeyIdentity? identity) => true;
|
||||
|
||||
/// <summary>Checks whether a read constraint is satisfied for a tag address.</summary>
|
||||
/// <param name="identity">The API key identity.</param>
|
||||
/// <param name="tagAddress">Tag address to check.</param>
|
||||
|
||||
Reference in New Issue
Block a user