From 7cf6bb866e433fe05e15a62547e83137dbb2cb5b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 00:33:15 -0500 Subject: [PATCH] feat: add PermissionLruCache (128-entry LRU) and wire into ClientPermissions --- src/NATS.Server/Auth/ClientPermissions.cs | 10 ++- src/NATS.Server/Auth/PermissionLruCache.cs | 62 +++++++++++++++++++ .../PermissionLruCacheTests.cs | 52 ++++++++++++++++ 3 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 src/NATS.Server/Auth/PermissionLruCache.cs create mode 100644 tests/NATS.Server.Tests/PermissionLruCacheTests.cs diff --git a/src/NATS.Server/Auth/ClientPermissions.cs b/src/NATS.Server/Auth/ClientPermissions.cs index d773fb1..c32dcd0 100644 --- a/src/NATS.Server/Auth/ClientPermissions.cs +++ b/src/NATS.Server/Auth/ClientPermissions.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using NATS.Server.Subscriptions; namespace NATS.Server.Auth; @@ -7,7 +6,7 @@ public sealed class ClientPermissions : IDisposable { private readonly PermissionSet? _publish; private readonly PermissionSet? _subscribe; - private readonly ConcurrentDictionary _pubCache = new(StringComparer.Ordinal); + private readonly PermissionLruCache _pubCache = new(128); private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe) { @@ -34,7 +33,12 @@ public sealed class ClientPermissions : IDisposable if (_publish == null) return true; - return _pubCache.GetOrAdd(subject, _publish.IsAllowed); + if (_pubCache.TryGet(subject, out var cached)) + return cached; + + var allowed = _publish.IsAllowed(subject); + _pubCache.Set(subject, allowed); + return allowed; } public bool IsSubscribeAllowed(string subject, string? queue = null) diff --git a/src/NATS.Server/Auth/PermissionLruCache.cs b/src/NATS.Server/Auth/PermissionLruCache.cs new file mode 100644 index 0000000..dfe8f56 --- /dev/null +++ b/src/NATS.Server/Auth/PermissionLruCache.cs @@ -0,0 +1,62 @@ +namespace NATS.Server.Auth; + +/// +/// Fixed-capacity LRU cache for permission results. +/// Lock-protected (per-client, low contention). +/// Reference: Go client.go maxPermCacheSize=128. +/// +public sealed class PermissionLruCache +{ + private readonly int _capacity; + private readonly Dictionary> _map; + private readonly LinkedList<(string Key, bool Value)> _list = new(); + private readonly object _lock = new(); + + public PermissionLruCache(int capacity = 128) + { + _capacity = capacity; + _map = new Dictionary>(capacity, StringComparer.Ordinal); + } + + public bool TryGet(string key, out bool value) + { + lock (_lock) + { + if (_map.TryGetValue(key, out var node)) + { + value = node.Value.Value; + _list.Remove(node); + _list.AddFirst(node); + return true; + } + + value = default; + return false; + } + } + + public void Set(string key, bool value) + { + lock (_lock) + { + 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; + } + } +} diff --git a/tests/NATS.Server.Tests/PermissionLruCacheTests.cs b/tests/NATS.Server.Tests/PermissionLruCacheTests.cs new file mode 100644 index 0000000..03955f6 --- /dev/null +++ b/tests/NATS.Server.Tests/PermissionLruCacheTests.cs @@ -0,0 +1,52 @@ +using NATS.Server.Auth; + +namespace NATS.Server.Tests; + +public class PermissionLruCacheTests +{ + [Fact] + public void Get_returns_none_for_unknown_key() + { + var cache = new PermissionLruCache(128); + cache.TryGet("foo", out _).ShouldBeFalse(); + } + + [Fact] + public void Set_and_get_returns_value() + { + var cache = new PermissionLruCache(128); + cache.Set("foo", true); + cache.TryGet("foo", out var v).ShouldBeTrue(); + v.ShouldBeTrue(); + } + + [Fact] + public void Evicts_oldest_when_full() + { + var cache = new PermissionLruCache(3); + cache.Set("a", true); + cache.Set("b", true); + cache.Set("c", true); + cache.Set("d", true); // evicts "a" + + cache.TryGet("a", out _).ShouldBeFalse(); + cache.TryGet("b", out _).ShouldBeTrue(); + cache.TryGet("d", out _).ShouldBeTrue(); + } + + [Fact] + public void Get_promotes_to_front() + { + var cache = new PermissionLruCache(3); + cache.Set("a", true); + cache.Set("b", true); + cache.Set("c", true); + + // Access "a" to promote it + cache.TryGet("a", out _); + + cache.Set("d", true); // should evict "b" (oldest untouched) + cache.TryGet("a", out _).ShouldBeTrue(); + cache.TryGet("b", out _).ShouldBeFalse(); + } +}