feat: add reply subject mapping cache with TTL (Gap 11.5)

Add ReplyMapCache to ReplyMapper.cs — an LRU cache with TTL expiration
for gateway reply subject mappings, avoiding repeated string parsing on
the hot path. Includes 10 unit tests covering LRU eviction, TTL expiry,
hit/miss counters, PurgeExpired, and Count.
This commit is contained in:
Joseph Doherty
2026-02-25 11:51:55 -05:00
parent 455a91579a
commit dc8d28c222
2 changed files with 280 additions and 0 deletions

View File

@@ -172,3 +172,119 @@ public static class ReplyMapper
return true;
}
}
/// <summary>
/// LRU cache with TTL expiration for gateway reply subject mappings.
/// Caches the result of MapToGateway/MapFromGateway to avoid repeated parsing.
/// Go reference: gateway.go — reply mapping cache.
/// </summary>
public sealed class ReplyMapCache
{
private readonly int _capacity;
private readonly int _ttlMs;
private readonly Dictionary<string, LinkedListNode<CacheEntry>> _map;
private readonly LinkedList<CacheEntry> _list = new();
private readonly Lock _lock = new();
private long _hits;
private long _misses;
public ReplyMapCache(int capacity = 4096, int ttlMs = 60_000)
{
_capacity = capacity;
_ttlMs = ttlMs;
_map = new Dictionary<string, LinkedListNode<CacheEntry>>(capacity, StringComparer.Ordinal);
}
public long Hits => Interlocked.Read(ref _hits);
public long Misses => Interlocked.Read(ref _misses);
public int Count { get { lock (_lock) return _map.Count; } }
public bool TryGet(string key, out string? value)
{
lock (_lock)
{
if (_map.TryGetValue(key, out var node))
{
// Check TTL
if ((DateTime.UtcNow - node.Value.CreatedUtc).TotalMilliseconds > _ttlMs)
{
// Expired
_list.Remove(node);
_map.Remove(key);
value = null;
Interlocked.Increment(ref _misses);
return false;
}
value = node.Value.Value;
_list.Remove(node);
_list.AddFirst(node);
Interlocked.Increment(ref _hits);
return true;
}
}
value = null;
Interlocked.Increment(ref _misses);
return false;
}
public void Set(string key, string value)
{
lock (_lock)
{
if (_map.TryGetValue(key, out var existing))
{
_list.Remove(existing);
existing.Value = new CacheEntry(key, value, DateTime.UtcNow);
_list.AddFirst(existing);
return;
}
if (_map.Count >= _capacity)
{
var last = _list.Last!;
_map.Remove(last.Value.Key);
_list.RemoveLast();
}
var entry = new CacheEntry(key, value, DateTime.UtcNow);
var node = new LinkedListNode<CacheEntry>(entry);
_list.AddFirst(node);
_map[key] = node;
}
}
public void Clear()
{
lock (_lock)
{
_map.Clear();
_list.Clear();
}
}
/// <summary>Removes expired entries.</summary>
public int PurgeExpired()
{
lock (_lock)
{
var now = DateTime.UtcNow;
var purged = 0;
var node = _list.Last;
while (node != null)
{
var prev = node.Previous;
if ((now - node.Value.CreatedUtc).TotalMilliseconds > _ttlMs)
{
_map.Remove(node.Value.Key);
_list.Remove(node);
purged++;
}
node = prev;
}
return purged;
}
}
private sealed record CacheEntry(string Key, string Value, DateTime CreatedUtc);
}