feat: add per-account subscription result cache with LRU (Gap 5.4)
Implements RouteResultCache with fixed-capacity LRU eviction and atomic generation-based invalidation (Go ref: client.go routeCache, maxResultCacheSize=8192). Fixes AccountGoParityTests namespace ambiguity introduced by new test file.
This commit is contained in:
125
src/NATS.Server/Subscriptions/RouteResultCache.cs
Normal file
125
src/NATS.Server/Subscriptions/RouteResultCache.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
namespace NATS.Server.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Per-account LRU cache for subscription match results.
|
||||
/// Avoids repeated SubList.Match() calls for the same subject.
|
||||
/// Uses atomic generation ID for bulk invalidation when subscriptions change.
|
||||
/// Go reference: server/client.go routeCache, maxResultCacheSize=8192.
|
||||
/// </summary>
|
||||
public sealed class RouteResultCache
|
||||
{
|
||||
private readonly int _capacity;
|
||||
private readonly Dictionary<string, LinkedListNode<(string Key, SubListResult Value)>> _map;
|
||||
private readonly LinkedList<(string Key, SubListResult Value)> _list = new();
|
||||
private readonly Lock _lock = new();
|
||||
private long _generation;
|
||||
private long _cacheGeneration;
|
||||
|
||||
// Stats
|
||||
private long _hits;
|
||||
private long _misses;
|
||||
|
||||
public RouteResultCache(int capacity = 8192)
|
||||
{
|
||||
_capacity = capacity;
|
||||
_map = new Dictionary<string, LinkedListNode<(string Key, SubListResult Value)>>(capacity, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public long Generation => Interlocked.Read(ref _generation);
|
||||
public long Hits => Interlocked.Read(ref _hits);
|
||||
public long Misses => Interlocked.Read(ref _misses);
|
||||
public int Count { get { lock (_lock) return _map.Count; } }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a cached result for the given subject.
|
||||
/// On generation mismatch (subscriptions changed since last cache fill),
|
||||
/// clears all entries and returns false.
|
||||
/// Updates LRU order on a hit.
|
||||
/// </summary>
|
||||
public bool TryGet(string subject, out SubListResult? result)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var currentGeneration = Interlocked.Read(ref _generation);
|
||||
if (_cacheGeneration != currentGeneration)
|
||||
{
|
||||
ClearUnderLock();
|
||||
_cacheGeneration = currentGeneration;
|
||||
result = null;
|
||||
Interlocked.Increment(ref _misses);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_map.TryGetValue(subject, out var node))
|
||||
{
|
||||
// Move to front (most recently used)
|
||||
_list.Remove(node);
|
||||
_list.AddFirst(node);
|
||||
result = node.Value.Value;
|
||||
Interlocked.Increment(ref _hits);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
result = null;
|
||||
Interlocked.Increment(ref _misses);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a subject's cached result.
|
||||
/// If at capacity, evicts the least recently used entry (tail of list).
|
||||
/// </summary>
|
||||
public void Set(string subject, SubListResult result)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_map.TryGetValue(subject, out var existing))
|
||||
{
|
||||
// Update value in place and move to front
|
||||
_list.Remove(existing);
|
||||
var updated = new LinkedListNode<(string Key, SubListResult Value)>((subject, result));
|
||||
_list.AddFirst(updated);
|
||||
_map[subject] = updated;
|
||||
return;
|
||||
}
|
||||
|
||||
// Evict LRU tail if at capacity
|
||||
if (_map.Count >= _capacity && _list.Last is { } tail)
|
||||
{
|
||||
_map.Remove(tail.Value.Key);
|
||||
_list.RemoveLast();
|
||||
}
|
||||
|
||||
var node = new LinkedListNode<(string Key, SubListResult Value)>((subject, result));
|
||||
_list.AddFirst(node);
|
||||
_map[subject] = node;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments the generation counter so that the next TryGet detects a mismatch
|
||||
/// and clears stale entries. This is called whenever subscriptions change.
|
||||
/// </summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
Interlocked.Increment(ref _generation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immediately clears all cached entries under the lock.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
ClearUnderLock();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearUnderLock()
|
||||
{
|
||||
_map.Clear();
|
||||
_list.Clear();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class AccountGoParityTests
|
||||
using var accB = new Account("B");
|
||||
|
||||
// Add subscriptions to account A's SubList
|
||||
var subA = new Subscriptions.Subscription { Subject = "foo", Sid = "1" };
|
||||
var subA = new Subscription { Subject = "foo", Sid = "1" };
|
||||
accA.SubList.Insert(subA);
|
||||
|
||||
// Account B should not see account A's subscriptions
|
||||
@@ -51,8 +52,8 @@ public class AccountGoParityTests
|
||||
// Go: TestAccountWildcardRouteMapping — wildcards work per-account.
|
||||
using var acc = new Account("TEST");
|
||||
|
||||
var sub1 = new Subscriptions.Subscription { Subject = "orders.*", Sid = "1" };
|
||||
var sub2 = new Subscriptions.Subscription { Subject = "orders.>", Sid = "2" };
|
||||
var sub1 = new Subscription { Subject = "orders.*", Sid = "1" };
|
||||
var sub2 = new Subscription { Subject = "orders.>", Sid = "2" };
|
||||
acc.SubList.Insert(sub1);
|
||||
acc.SubList.Insert(sub2);
|
||||
|
||||
|
||||
223
tests/NATS.Server.Tests/Subscriptions/RouteResultCacheTests.cs
Normal file
223
tests/NATS.Server.Tests/Subscriptions/RouteResultCacheTests.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
// Go reference: server/client.go routeCache, maxResultCacheSize=8192.
|
||||
|
||||
using NATS.Server.Subscriptions;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Tests.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for RouteResultCache — the per-account LRU cache that stores
|
||||
/// SubListResult objects keyed by subject string to avoid repeated SubList.Match() calls.
|
||||
/// Go reference: server/client.go routeCache field, maxResultCacheSize=8192.
|
||||
/// </summary>
|
||||
public class RouteResultCacheTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static SubListResult MakeResult(string subject)
|
||||
{
|
||||
var sub = new Subscription { Subject = subject, Sid = "1" };
|
||||
return new SubListResult([sub], []);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Basic lookup behaviour
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// An empty cache must return false for any subject.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryGet_returns_false_on_empty_cache()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
|
||||
var found = cache.TryGet("foo.bar", out var result);
|
||||
|
||||
found.ShouldBeFalse();
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A result placed with Set must be retrievable via TryGet.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Set_and_TryGet_round_trips_result()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
var expected = MakeResult("foo");
|
||||
cache.Set("foo", expected);
|
||||
|
||||
var found = cache.TryGet("foo", out var actual);
|
||||
|
||||
found.ShouldBeTrue();
|
||||
actual.ShouldBeSameAs(expected);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LRU eviction
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// When a capacity+1 entry is added, the oldest (LRU) entry must be evicted.
|
||||
/// Go reference: maxResultCacheSize=8192 with LRU eviction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LRU_eviction_at_capacity()
|
||||
{
|
||||
const int cap = 4;
|
||||
var cache = new RouteResultCache(capacity: cap);
|
||||
|
||||
// Fill to capacity — "first" is the oldest (LRU)
|
||||
cache.Set("first", MakeResult("first"));
|
||||
cache.Set("second", MakeResult("second"));
|
||||
cache.Set("third", MakeResult("third"));
|
||||
cache.Set("fourth", MakeResult("fourth"));
|
||||
|
||||
// Adding a 5th entry must evict "first"
|
||||
cache.Set("fifth", MakeResult("fifth"));
|
||||
|
||||
cache.Count.ShouldBe(cap);
|
||||
cache.TryGet("first", out _).ShouldBeFalse();
|
||||
cache.TryGet("fifth", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After accessing A, B, C (capacity=3), accessing A promotes it to MRU.
|
||||
/// The next Set evicts B (now the true LRU) rather than A.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LRU_order_updated_on_access()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 3);
|
||||
cache.Set("A", MakeResult("A"));
|
||||
cache.Set("B", MakeResult("B"));
|
||||
cache.Set("C", MakeResult("C"));
|
||||
|
||||
// Access A so it becomes MRU; now LRU order is: B, A, C (front=C, back=B)
|
||||
cache.TryGet("A", out _);
|
||||
|
||||
// D evicts the LRU entry — should be B, not A
|
||||
cache.Set("D", MakeResult("D"));
|
||||
|
||||
cache.TryGet("A", out _).ShouldBeTrue();
|
||||
cache.TryGet("B", out _).ShouldBeFalse();
|
||||
cache.TryGet("C", out _).ShouldBeTrue();
|
||||
cache.TryGet("D", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Generation / invalidation
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// After Invalidate(), the next TryGet must detect the generation mismatch,
|
||||
/// clear all entries, and return false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Invalidate_clears_on_next_access()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
cache.Set("a", MakeResult("a"));
|
||||
cache.Set("b", MakeResult("b"));
|
||||
|
||||
cache.Invalidate();
|
||||
|
||||
cache.TryGet("a", out var result).ShouldBeFalse();
|
||||
result.ShouldBeNull();
|
||||
cache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Each call to Invalidate() must increment the generation counter by 1.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Generation_increments_on_invalidate()
|
||||
{
|
||||
var cache = new RouteResultCache();
|
||||
var gen0 = cache.Generation;
|
||||
|
||||
cache.Invalidate();
|
||||
cache.Generation.ShouldBe(gen0 + 1);
|
||||
|
||||
cache.Invalidate();
|
||||
cache.Generation.ShouldBe(gen0 + 2);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Statistics
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Hits counter increments on each successful TryGet.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Hits_incremented_on_cache_hit()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
cache.Set("subject", MakeResult("subject"));
|
||||
|
||||
cache.TryGet("subject", out _);
|
||||
cache.TryGet("subject", out _);
|
||||
|
||||
cache.Hits.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Misses counter increments on each failed TryGet.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Misses_incremented_on_cache_miss()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
|
||||
cache.TryGet("nope", out _);
|
||||
cache.TryGet("also.nope", out _);
|
||||
|
||||
cache.Misses.ShouldBe(2);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Clear
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Clear() must remove all entries immediately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Clear_removes_all_entries()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
cache.Set("x", MakeResult("x"));
|
||||
cache.Set("y", MakeResult("y"));
|
||||
|
||||
cache.Clear();
|
||||
|
||||
cache.Count.ShouldBe(0);
|
||||
cache.TryGet("x", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Update existing entry
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Setting the same key twice must replace the value; TryGet returns the latest result.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Set_updates_existing_entry()
|
||||
{
|
||||
var cache = new RouteResultCache(capacity: 16);
|
||||
var first = MakeResult("v1");
|
||||
var second = MakeResult("v2");
|
||||
|
||||
cache.Set("key", first);
|
||||
cache.Set("key", second);
|
||||
|
||||
cache.Count.ShouldBe(1);
|
||||
cache.TryGet("key", out var returned).ShouldBeTrue();
|
||||
returned.ShouldBeSameAs(second);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user