Files
wwtools/mbproxy/tests/Mbproxy.Tests/Proxy/Cache/ResponseCacheTests.cs
T
Joseph Doherty 1db900edef mbproxy: add opt-in response cache (Phase 11)
Layers a per-PLC, per-tag response cache on top of Phase 10's coalescing.
Cache is OFF by default per tag (CacheTtlMs = 0); a fresh deployment with no
TTL config behaves identically to Phase 10. Operators opt tags in by setting
CacheTtlMs > 0 on a BcdTagOptions entry (or DefaultCacheTtlMs > 0 on a
PlcOptions entry), explicitly acknowledging the staleness window.

Cache lookup order: cache -> coalesce -> backend. A cache hit short-circuits
both Phase 10's coalescing path and Phase 9's backend send. Cache stores
POST-rewriter PDU bytes so hits never re-invoke the BCD rewriter. FC06/FC16
write responses invalidate every cached entry whose address range overlaps
the write (half-open interval math).

New types (Mbproxy.Proxy.Cache, all internal):
- CacheKey (record-struct, same shape as CoalescingKey but kept SEPARATE so
  the two phases evolve independently).
- CacheEntry, ResponseCache (IDisposable; LRU + PeriodicTimer eviction
  loop), CacheInvalidator (pure overlap matcher), CacheLogEvents (stable
  mbproxy.cache.* names).

Multi-tag range TTL = min(TTLs); any tag with TTL = 0 in the range disables
caching for the whole read (conservative-by-design).

Options surface:
- BcdTagOptions.CacheTtlMs (nullable int; null = fall through to PLC default)
- PlcOptions.DefaultCacheTtlMs
- MbproxyOptions.Cache.{AllowLongTtl, MaxEntriesPerPlc, EvictionIntervalMs}
- TTL > 60_000 ms requires Cache.AllowLongTtl = true (reload validation).

Admin counters (Tier 1.8 + Tier 2 cache-memory KPIs from docs/kpi.md):
- CacheHitCount, CacheMissCount, CacheInvalidations on ProxyCounters.
- CacheEntryCount, CacheBytes via a new ICacheStatsProvider snapshot path.
- /status.json and the HTML page surface a new Cache cell per PLC row.

Hot-reload: any tag-list change to a PLC reseats the per-PLC context with a
fresh cache; the old cache is disposed inside ReplaceContextAsync. Per-tag
flush granularity is intentionally not implemented in v1.

PLCs with no cache-eligible tags (every resolved tag has CacheTtlMs = 0)
get Cache = null on the context and skip the eviction timer entirely, so
the no-cache path is byte-identical to Phase 10.

Tests (32 new unit + 5 new E2E = 37 new; suite now 314 unit + 48 E2E):
- CacheKeyTests, CacheEntryTests (records + boundary semantics).
- CacheInvalidatorTests: full overlap, both partials, adjacent-not-
  overlapping, disjoint, different unit ID + auxiliary FC-filter / zero-qty.
- ResponseCacheTests: round-trip, lazy expiry, range invalidation,
  unit-id filter, LRU bound, LRU access tracking, concurrent get/set,
  dispose, clear, approximate-bytes accounting.
- ResponseCacheMultiplexerTests (stub-backend): hit short-circuits
  coalescing, BCD-decoded bytes are cached not raw, FC06 invalidates
  overlapping, non-overlapping write does not invalidate, multi-tag
  TTL=min rule, regression-cache-disabled-by-default-is-Phase-10, hit
  works even when backend unreachable.
- ResponseCacheE2ETests (pymodbus DL205 sim, sequential reads):
  * Headline: 10 reads with TTL=1000 ms -> 9 hits, 1 miss, 1 backend trip.
  * TTL expiry path with sleep > TTL.
  * Write invalidation through the proxy on a scratch register.
  * BCD-decoded bytes are cached, not raw BCD nibbles.
  * Regression: Cache disabled by default -> behaviour byte-identical to
    Phase 10.

Pre-existing flake hardened: BackendDisconnect_CascadesToAllUpstreams now
polls briefly for the cascade counter to absorb the inherent scheduling
gap between "upstream EOF observed" and "counter incremented inside
TearDownBackendAsync." Counter semantics unchanged.

Phase doc updated with implementation clarifications discovered during
this work (CacheKey kept separate from CoalescingKey, LastUsedTick is
long, FC06/FC16 startAddr/qty parsing extension, cache-pre-connect
short-circuit, write-invalidation only on successful responses).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:08:51 -04:00

221 lines
8.2 KiB
C#

using Mbproxy.Proxy.Cache;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy.Cache;
/// <summary>
/// Phase-11 unit tests for <see cref="ResponseCache"/>. Cover the load-bearing
/// behaviours: set/get round-trip, TTL expiry, write-range invalidation, LRU bounds, LRU
/// access ordering, concurrent safety, and disposal semantics.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ResponseCacheTests
{
private static CacheEntry MakeEntry(int ttlMs, byte[]? bytes = null)
{
var now = DateTimeOffset.UtcNow;
bytes ??= [0x03, 0x02, 0x04, 0xD2];
return new CacheEntry(
PduBytes: bytes,
CachedAtUtc: now,
ExpiresAtUtc: now.AddMilliseconds(ttlMs),
Length: bytes.Length,
LastUsedTick: 0);
}
[Fact]
public void SetThenGet_RoundTrips()
{
using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
var key = new CacheKey(1, 0x03, 100, 1);
var entry = MakeEntry(ttlMs: 5000);
cache.Set(key, entry);
cache.TryGet(key, out var got).ShouldBeTrue();
got.PduBytes.ShouldBe(entry.PduBytes);
cache.Count.ShouldBe(1);
}
[Fact]
public async Task GetExpiredEntry_ReturnsFalse_AndRemoves()
{
using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
var key = new CacheKey(1, 0x03, 100, 1);
// 50 ms TTL; sleep past it and read.
cache.Set(key, MakeEntry(ttlMs: 50));
cache.Count.ShouldBe(1);
await Task.Delay(120, TestContext.Current.CancellationToken);
cache.TryGet(key, out _).ShouldBeFalse("expired entries must report miss");
cache.Count.ShouldBe(0, "TryGet on an expired entry must remove it lazily");
}
[Fact]
public void Invalidate_OverlappingRange_RemovesMatching()
{
using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
// Three entries: two overlap a write [105..115), one does not.
var overlapA = new CacheKey(1, 0x03, 100, 10); // [100..110) — overlaps low
var overlapB = new CacheKey(1, 0x03, 110, 10); // [110..120) — overlaps high
var disjoint = new CacheKey(1, 0x03, 200, 10); // [200..210) — disjoint
cache.Set(overlapA, MakeEntry(ttlMs: 5000));
cache.Set(overlapB, MakeEntry(ttlMs: 5000));
cache.Set(disjoint, MakeEntry(ttlMs: 5000));
cache.Count.ShouldBe(3);
int invalidated = cache.Invalidate(unitId: 1, startAddress: 105, qty: 10);
invalidated.ShouldBe(2, "the two overlapping entries must be invalidated");
cache.Count.ShouldBe(1, "the disjoint entry must remain");
cache.TryGet(disjoint, out _).ShouldBeTrue("the disjoint entry must still be retrievable");
}
[Fact]
public void Invalidate_DifferentUnitId_DoesNotTouch()
{
using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
var key = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 10);
cache.Set(key, MakeEntry(ttlMs: 5000));
int invalidated = cache.Invalidate(unitId: 2, startAddress: 100, qty: 10);
invalidated.ShouldBe(0, "writes on a different unit ID must not invalidate this entry");
cache.Count.ShouldBe(1);
}
[Fact]
public void Set_AtMaxEntries_EvictsLRU()
{
using var cache = new ResponseCache(maxEntriesPerPlc: 3, evictionIntervalMs: 5000);
// Insert 3 entries at distinct keys.
var k1 = new CacheKey(1, 0x03, 100, 1);
var k2 = new CacheKey(1, 0x03, 200, 1);
var k3 = new CacheKey(1, 0x03, 300, 1);
cache.Set(k1, MakeEntry(5000));
cache.Set(k2, MakeEntry(5000));
cache.Set(k3, MakeEntry(5000));
cache.Count.ShouldBe(3);
// 4th insert must evict the LRU — which is k1 (the earliest insert without a hit).
var k4 = new CacheKey(1, 0x03, 400, 1);
cache.Set(k4, MakeEntry(5000));
cache.Count.ShouldBe(3, "cap held at 3");
cache.TryGet(k1, out _).ShouldBeFalse("k1 was the LRU and must have been evicted");
cache.TryGet(k4, out _).ShouldBeTrue();
}
[Fact]
public void LRU_TracksAccessOrder_AcrossGetAndSet()
{
using var cache = new ResponseCache(maxEntriesPerPlc: 3, evictionIntervalMs: 5000);
var k1 = new CacheKey(1, 0x03, 100, 1);
var k2 = new CacheKey(1, 0x03, 200, 1);
var k3 = new CacheKey(1, 0x03, 300, 1);
cache.Set(k1, MakeEntry(5000));
cache.Set(k2, MakeEntry(5000));
cache.Set(k3, MakeEntry(5000));
// Touch k1 — it becomes the most-recently-used.
cache.TryGet(k1, out _).ShouldBeTrue();
// The LRU should now be k2 (k3 is fresher than k2 by insertion; k1 was just touched).
var k4 = new CacheKey(1, 0x03, 400, 1);
cache.Set(k4, MakeEntry(5000));
cache.TryGet(k1, out _).ShouldBeTrue("k1 was just touched — must survive");
cache.TryGet(k2, out _).ShouldBeFalse("k2 was the LRU and must have been evicted");
cache.TryGet(k3, out _).ShouldBeTrue();
cache.TryGet(k4, out _).ShouldBeTrue();
}
[Fact]
public async Task Concurrent_GetSet_NoDataRace()
{
using var cache = new ResponseCache(maxEntriesPerPlc: 256, evictionIntervalMs: 5000);
// 8 tasks, 500 ops each — overlapping reads and writes on the same key space.
// Verifies the cache survives concurrency without exceptions and remains coherent.
const int Tasks = 8;
const int Ops = 500;
var ct = TestContext.Current.CancellationToken;
long opsCompleted = 0;
await Task.WhenAll(Enumerable.Range(0, Tasks).Select(t => Task.Run(() =>
{
for (int i = 0; i < Ops; i++)
{
if (ct.IsCancellationRequested) return;
var key = new CacheKey(1, 0x03, (ushort)(i & 0xFF), 1);
if ((i & 1) == 0)
cache.Set(key, MakeEntry(2000));
else
cache.TryGet(key, out _);
Interlocked.Increment(ref opsCompleted);
}
}, ct)));
opsCompleted.ShouldBe((long)(Tasks * Ops), "every concurrent op must complete without exception");
cache.Count.ShouldBeLessThanOrEqualTo(256, "cap must never be exceeded under concurrent insertion");
}
[Fact]
public async Task Dispose_StopsEvictionLoop_AndDoesNotThrowOnSubsequentCalls()
{
var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 100);
cache.Set(new CacheKey(1, 0x03, 100, 1), MakeEntry(ttlMs: 50));
await Task.Delay(80, TestContext.Current.CancellationToken);
cache.Dispose();
cache.Dispose(); // idempotent
// After dispose, no exception on a synchronous probe — but operations are
// best-effort; we don't promise correct results post-dispose. The contract is:
// disposal must not corrupt state or leak the eviction task.
}
[Fact]
public void Clear_DropsAllEntries_AndReturnsCount()
{
using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
cache.Set(new CacheKey(1, 0x03, 100, 1), MakeEntry(5000));
cache.Set(new CacheKey(1, 0x03, 200, 1), MakeEntry(5000));
int dropped = cache.Clear();
dropped.ShouldBe(2);
cache.Count.ShouldBe(0);
cache.ApproximateBytes.ShouldBe(0);
}
[Fact]
public void ApproximateBytes_TracksSetReplaceAndInvalidate()
{
using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
var k1 = new CacheKey(1, 0x03, 100, 1);
var k2 = new CacheKey(1, 0x03, 200, 1);
cache.Set(k1, MakeEntry(5000, bytes: new byte[10]));
cache.Set(k2, MakeEntry(5000, bytes: new byte[20]));
cache.ApproximateBytes.ShouldBe(30L);
// Replace k1 with a bigger entry.
cache.Set(k1, MakeEntry(5000, bytes: new byte[15]));
cache.ApproximateBytes.ShouldBe(35L);
// Invalidate k1.
cache.Invalidate(unitId: 1, startAddress: 100, qty: 1).ShouldBe(1);
cache.ApproximateBytes.ShouldBe(20L, "approx-bytes must decrease on invalidate");
}
}