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:
@@ -152,6 +152,16 @@ public sealed class Account : IDisposable
|
|||||||
return true;
|
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
|
// Slow consumer tracking
|
||||||
// Go reference: server/client.go — handleSlowConsumer, markConnAsSlow, server/accounts.go slowConsumerCount
|
// Go reference: server/client.go — handleSlowConsumer, markConnAsSlow, server/accounts.go slowConsumerCount
|
||||||
private long _slowConsumerCount;
|
private long _slowConsumerCount;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
namespace NATS.Server.Auth;
|
namespace NATS.Server.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <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).
|
/// Lock-protected (per-client, low contention).
|
||||||
/// Reference: Go client.go maxPermCacheSize=128.
|
/// Reference: Go client.go maxPermCacheSize=128.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -12,17 +13,51 @@ public sealed class PermissionLruCache
|
|||||||
private readonly LinkedList<(string Key, bool Value)> _list = new();
|
private readonly LinkedList<(string Key, bool Value)> _list = new();
|
||||||
private readonly object _lock = 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)
|
public PermissionLruCache(int capacity = 128)
|
||||||
{
|
{
|
||||||
_capacity = capacity;
|
_capacity = capacity;
|
||||||
_map = new Dictionary<string, LinkedListNode<(string Key, bool Value)>>(capacity, StringComparer.Ordinal);
|
_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)
|
public bool TryGet(string key, out bool value)
|
||||||
{
|
{
|
||||||
|
var internalKey = "P:" + key;
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
if (_map.TryGetValue(key, out var node))
|
EnsureFresh();
|
||||||
|
if (_map.TryGetValue(internalKey, out var node))
|
||||||
{
|
{
|
||||||
value = node.Value.Value;
|
value = node.Value.Value;
|
||||||
_list.Remove(node);
|
_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
|
public int Count
|
||||||
{
|
{
|
||||||
get
|
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 = (internalKey, value);
|
||||||
_list.Remove(existing);
|
_list.AddFirst(existing);
|
||||||
existing.Value = (key, value);
|
return;
|
||||||
_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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
161
tests/NATS.Server.Tests/Auth/SubPermissionCacheTests.cs
Normal file
161
tests/NATS.Server.Tests/Auth/SubPermissionCacheTests.cs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
using NATS.Server.Auth;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for SUB permission caching and generation-based invalidation.
|
||||||
|
/// Reference: Go server/client.go — subPermCache, pubPermCache, perm cache invalidation on account update.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SubPermissionCacheTests
|
||||||
|
{
|
||||||
|
// ── SUB API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetSub_and_TryGetSub_round_trips()
|
||||||
|
{
|
||||||
|
var cache = new PermissionLruCache();
|
||||||
|
|
||||||
|
cache.SetSub("foo.bar", true);
|
||||||
|
|
||||||
|
cache.TryGetSub("foo.bar", out var result).ShouldBeTrue();
|
||||||
|
result.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetSub_returns_false_for_unknown()
|
||||||
|
{
|
||||||
|
var cache = new PermissionLruCache();
|
||||||
|
|
||||||
|
cache.TryGetSub("unknown.subject", out var result).ShouldBeFalse();
|
||||||
|
result.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PUB_and_SUB_stored_independently()
|
||||||
|
{
|
||||||
|
var cache = new PermissionLruCache();
|
||||||
|
|
||||||
|
// Same logical subject, different PUB/SUB outcomes
|
||||||
|
cache.Set("orders.>", false); // PUB denied
|
||||||
|
cache.SetSub("orders.>", true); // SUB allowed
|
||||||
|
|
||||||
|
cache.TryGet("orders.>", out var pubAllowed).ShouldBeTrue();
|
||||||
|
pubAllowed.ShouldBeFalse();
|
||||||
|
|
||||||
|
cache.TryGetSub("orders.>", out var subAllowed).ShouldBeTrue();
|
||||||
|
subAllowed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invalidation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Invalidate_clears_on_next_access()
|
||||||
|
{
|
||||||
|
var cache = new PermissionLruCache();
|
||||||
|
|
||||||
|
cache.Set("pub.subject", true);
|
||||||
|
cache.SetSub("sub.subject", true);
|
||||||
|
|
||||||
|
cache.Invalidate();
|
||||||
|
|
||||||
|
// Both PUB and SUB lookups should miss after invalidation
|
||||||
|
cache.TryGet("pub.subject", out _).ShouldBeFalse();
|
||||||
|
cache.TryGetSub("sub.subject", out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generation_increments_on_invalidate()
|
||||||
|
{
|
||||||
|
var cache = new PermissionLruCache();
|
||||||
|
|
||||||
|
var before = cache.Generation;
|
||||||
|
cache.Invalidate();
|
||||||
|
var afterOne = cache.Generation;
|
||||||
|
cache.Invalidate();
|
||||||
|
var afterTwo = cache.Generation;
|
||||||
|
|
||||||
|
afterOne.ShouldBe(before + 1);
|
||||||
|
afterTwo.ShouldBe(before + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LRU eviction ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LRU_eviction_applies_to_SUB_entries()
|
||||||
|
{
|
||||||
|
// capacity = 4: fill with 4 SUB entries then add a 5th; the oldest should be evicted
|
||||||
|
var cache = new PermissionLruCache(capacity: 4);
|
||||||
|
|
||||||
|
cache.SetSub("a", true);
|
||||||
|
cache.SetSub("b", true);
|
||||||
|
cache.SetSub("c", true);
|
||||||
|
cache.SetSub("d", true);
|
||||||
|
|
||||||
|
// Touch "a" so it becomes MRU; "b" becomes LRU
|
||||||
|
cache.TryGetSub("a", out _);
|
||||||
|
|
||||||
|
// Adding "e" should evict "b" (LRU)
|
||||||
|
cache.SetSub("e", true);
|
||||||
|
|
||||||
|
cache.Count.ShouldBe(4);
|
||||||
|
cache.TryGetSub("b", out _).ShouldBeFalse("b should have been evicted");
|
||||||
|
cache.TryGetSub("e", out _).ShouldBeTrue("e was just added");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backward compatibility ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Existing_PUB_API_still_works()
|
||||||
|
{
|
||||||
|
var cache = new PermissionLruCache();
|
||||||
|
|
||||||
|
cache.Set("pub.only", true);
|
||||||
|
|
||||||
|
cache.TryGet("pub.only", out var value).ShouldBeTrue();
|
||||||
|
value.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Overwrite with false
|
||||||
|
cache.Set("pub.only", false);
|
||||||
|
cache.TryGet("pub.only", out value).ShouldBeTrue();
|
||||||
|
value.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account.GenerationId ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Account_GenerationId_starts_at_zero()
|
||||||
|
{
|
||||||
|
var account = new Account("test");
|
||||||
|
|
||||||
|
account.GenerationId.ShouldBe(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Account_IncrementGeneration_increments()
|
||||||
|
{
|
||||||
|
var account = new Account("test");
|
||||||
|
|
||||||
|
account.IncrementGeneration();
|
||||||
|
account.GenerationId.ShouldBe(1L);
|
||||||
|
|
||||||
|
account.IncrementGeneration();
|
||||||
|
account.GenerationId.ShouldBe(2L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mixed PUB + SUB count ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mixed_PUB_SUB_count_includes_both()
|
||||||
|
{
|
||||||
|
var cache = new PermissionLruCache();
|
||||||
|
|
||||||
|
cache.Set("pub.a", true);
|
||||||
|
cache.Set("pub.b", false);
|
||||||
|
cache.SetSub("sub.a", true);
|
||||||
|
cache.SetSub("sub.b", false);
|
||||||
|
|
||||||
|
// All four entries (stored under different internal keys) contribute to Count
|
||||||
|
cache.Count.ShouldBe(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user