fix(inbound): prune expired ApiMethodCache entries; pin exact-name scopes (review 2026-10-21 05/M3)

This commit is contained in:
Joseph Doherty
2026-10-22 08:29:13 -04:00
parent d7e87c0688
commit 6c9dfaf700
3 changed files with 67 additions and 0 deletions
@@ -34,6 +34,8 @@ public sealed class ApiMethodCache
private sealed record Entry(ApiMethod? Method, DateTimeOffset ExpiresAtUtc);
private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private const int PruneThreshold = 256;
private const int HardCap = 4096;
private readonly TimeSpan _ttl;
private readonly TimeProvider _timeProvider;
@@ -73,10 +75,34 @@ public sealed class ApiMethodCache
}
var method = await fetch(cancellationToken).ConfigureAwait(false);
if (_entries.Count >= PruneThreshold)
PruneExpired(now);
_entries[methodName] = new Entry(method, now + _ttl);
return method;
}
/// <summary>
/// Opportunistically removes expired entries on a miss once the dictionary is large, and
/// clears it outright at the hard cap. Growth is bounded today by the invariant that
/// <c>EndpointExtensions</c> reaches this cache only after the in-memory scope check, and
/// a key's scopes are EXACT method names (no wildcard/prefix scope exists in
/// <c>ZB.MOM.WW.Auth.ApiKeys</c>; pinned by <c>EndpointExtensionsTests</c>). Should a
/// wildcard scope ever be added, every probed path would otherwise be a permanent
/// negative entry — this prune is the safety net.
/// </summary>
private void PruneExpired(DateTimeOffset now)
{
foreach (var kv in _entries)
{
if (kv.Value.ExpiresAtUtc <= now)
_entries.TryRemove(kv);
}
// Still at the cap with nothing expired: drop everything (entries are re-fetchable).
if (_entries.Count >= HardCap)
_entries.Clear();
}
/// <summary>Drops the cached entry for one method name. Safe to call for an unknown name.</summary>
/// <param name="methodName">The method name to invalidate.</param>
public void Invalidate(string methodName) => _entries.TryRemove(methodName, out _);