feat: add SUB permission caching with generation invalidation (Gap 5.8)

Extend PermissionLruCache with SetSub/TryGetSub (internal key prefix "S:")
alongside existing PUB API ("P:" prefix, backward-compatible). Add Invalidate()
and Generation property for generation-based cache invalidation. Add
GenerationId/IncrementGeneration to Account for account-level change signalling.
10 new tests in SubPermissionCacheTests cover all paths.
This commit is contained in:
Joseph Doherty
2026-02-25 11:36:05 -05:00
parent a6e8088526
commit bd2504c8df
3 changed files with 272 additions and 22 deletions

View File

@@ -152,6 +152,16 @@ public sealed class Account : IDisposable
return true;
}
// Generation ID for permission cache invalidation.
// Callers hold a per-client copy; when it diverges from GenerationId they must flush their caches.
// Reference: Go server/accounts.go — account generation tracking for permission invalidation.
private long _generationId;
public long GenerationId => Interlocked.Read(ref _generationId);
/// <summary>Increments the generation counter, signalling that permission caches are stale.</summary>
public void IncrementGeneration() => Interlocked.Increment(ref _generationId);
// Slow consumer tracking
// Go reference: server/client.go — handleSlowConsumer, markConnAsSlow, server/accounts.go slowConsumerCount
private long _slowConsumerCount;

View File

@@ -1,7 +1,8 @@
namespace NATS.Server.Auth;
/// <summary>
/// Fixed-capacity LRU cache for permission results.
/// Fixed-capacity LRU cache for permission results, supporting both PUB and SUB entries
/// with generation-based invalidation.
/// Lock-protected (per-client, low contention).
/// Reference: Go client.go maxPermCacheSize=128.
/// </summary>
@@ -12,17 +13,51 @@ public sealed class PermissionLruCache
private readonly LinkedList<(string Key, bool Value)> _list = new();
private readonly object _lock = new();
// Generation tracking: _generation is the authoritative counter (bumped by Invalidate),
// _cacheGeneration is what the cache was last synced to. A mismatch on access triggers a clear.
private long _generation;
private long _cacheGeneration;
public PermissionLruCache(int capacity = 128)
{
_capacity = capacity;
_map = new Dictionary<string, LinkedListNode<(string Key, bool Value)>>(capacity, StringComparer.Ordinal);
}
/// <summary>
/// The current generation counter. Incremented on each <see cref="Invalidate"/> call.
/// </summary>
public long Generation => Interlocked.Read(ref _generation);
/// <summary>
/// Bumps the generation counter so that the next TryGet or TryGetSub call detects
/// the staleness and clears all cached entries.
/// </summary>
public void Invalidate() => Interlocked.Increment(ref _generation);
// Ensures the cache is cleared if Invalidate was called since the last access.
// Must be called inside _lock.
private void EnsureFresh()
{
var gen = Interlocked.Read(ref _generation);
if (gen != _cacheGeneration)
{
_map.Clear();
_list.Clear();
_cacheGeneration = gen;
}
}
// ── PUB API (backward-compatible) ────────────────────────────────────────
/// <summary>Looks up a PUB permission for <paramref name="key"/>.</summary>
public bool TryGet(string key, out bool value)
{
var internalKey = "P:" + key;
lock (_lock)
{
if (_map.TryGetValue(key, out var node))
EnsureFresh();
if (_map.TryGetValue(internalKey, out var node))
{
value = node.Value.Value;
_list.Remove(node);
@@ -35,6 +70,52 @@ public sealed class PermissionLruCache
}
}
/// <summary>Stores a PUB permission for <paramref name="key"/>.</summary>
public void Set(string key, bool value)
{
var internalKey = "P:" + key;
lock (_lock)
{
EnsureFresh();
SetInternal(internalKey, value);
}
}
// ── SUB API ───────────────────────────────────────────────────────────────
/// <summary>Looks up a SUB permission for <paramref name="subject"/>.</summary>
public bool TryGetSub(string subject, out bool value)
{
var internalKey = "S:" + subject;
lock (_lock)
{
EnsureFresh();
if (_map.TryGetValue(internalKey, out var node))
{
value = node.Value.Value;
_list.Remove(node);
_list.AddFirst(node);
return true;
}
value = default;
return false;
}
}
/// <summary>Stores a SUB permission for <paramref name="subject"/>.</summary>
public void SetSub(string subject, bool allowed)
{
var internalKey = "S:" + subject;
lock (_lock)
{
EnsureFresh();
SetInternal(internalKey, allowed);
}
}
// ── Shared ────────────────────────────────────────────────────────────────
public int Count
{
get
@@ -46,28 +127,26 @@ public sealed class PermissionLruCache
}
}
public void Set(string key, bool value)
// Must be called inside _lock.
private void SetInternal(string internalKey, bool value)
{
lock (_lock)
if (_map.TryGetValue(internalKey, out var existing))
{
if (_map.TryGetValue(key, out var existing))
{
_list.Remove(existing);
existing.Value = (key, value);
_list.AddFirst(existing);
return;
}
if (_map.Count >= _capacity)
{
var last = _list.Last!;
_map.Remove(last.Value.Key);
_list.RemoveLast();
}
var node = new LinkedListNode<(string Key, bool Value)>((key, value));
_list.AddFirst(node);
_map[key] = node;
_list.Remove(existing);
existing.Value = (internalKey, value);
_list.AddFirst(existing);
return;
}
if (_map.Count >= _capacity)
{
var last = _list.Last!;
_map.Remove(last.Value.Key);
_list.RemoveLast();
}
var node = new LinkedListNode<(string Key, bool Value)>((internalKey, value));
_list.AddFirst(node);
_map[internalKey] = node;
}
}