Files
wwtools/mbproxy/tests/Mbproxy.Tests/Proxy/Cache/CacheInvalidatorTests.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

100 lines
4.0 KiB
C#

using Mbproxy.Proxy.Cache;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy.Cache;
/// <summary>
/// Six range-overlap unit tests required by the Phase-11 doc. Half-open interval math:
/// write [w, w+writeQty) overlaps entry [s, s+qty) iff w &lt; s+qty AND s &lt; w+writeQty.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CacheInvalidatorTests
{
private static CacheKey K(byte unit, ushort start, ushort qty, byte fc = 0x03)
=> new(unit, fc, start, qty);
[Fact]
public void FullOverlap_WriteCoversEntryRange_Invalidates()
{
// Entry [100..110), write [95..115) — write covers entry fully.
var entry = K(unit: 1, start: 100, qty: 10);
var hits = CacheInvalidator.FindOverlapping([entry], unitId: 1, writeStart: 95, writeQty: 20).ToList();
hits.ShouldContain(entry, "a write that fully contains the entry's range must invalidate it");
}
[Fact]
public void PartialOverlap_WriteStartsBeforeEntry_Invalidates()
{
// Entry [100..110), write [95..105) — overlaps low side.
var entry = K(unit: 1, start: 100, qty: 10);
var hits = CacheInvalidator.FindOverlapping([entry], unitId: 1, writeStart: 95, writeQty: 10).ToList();
hits.ShouldContain(entry, "low-side partial overlap must invalidate");
}
[Fact]
public void PartialOverlap_WriteEndsAfterEntry_Invalidates()
{
// Entry [100..110), write [105..115) — overlaps high side.
var entry = K(unit: 1, start: 100, qty: 10);
var hits = CacheInvalidator.FindOverlapping([entry], unitId: 1, writeStart: 105, writeQty: 10).ToList();
hits.ShouldContain(entry, "high-side partial overlap must invalidate");
}
[Fact]
public void Adjacent_NotOverlapping_DoesNotInvalidate()
{
// Half-open intervals: write [10..15) is adjacent to but NOT overlapping entry
// [15..20) — register 15 is in the entry but NOT in the write. Should not match.
var entry = K(unit: 1, start: 15, qty: 5);
var hits = CacheInvalidator.FindOverlapping([entry], unitId: 1, writeStart: 10, writeQty: 5).ToList();
hits.ShouldBeEmpty("adjacent-but-not-overlapping ranges must not invalidate (half-open semantics)");
}
[Fact]
public void NoOverlap_DoesNotInvalidate()
{
// Entry [100..110), write [200..210) — fully disjoint.
var entry = K(unit: 1, start: 100, qty: 10);
var hits = CacheInvalidator.FindOverlapping([entry], unitId: 1, writeStart: 200, writeQty: 10).ToList();
hits.ShouldBeEmpty("disjoint ranges must not invalidate");
}
[Fact]
public void DifferentUnitId_DoesNotInvalidate()
{
// Same address range, different unit ID — must not match.
var entry = K(unit: 1, start: 100, qty: 10);
var hits = CacheInvalidator.FindOverlapping([entry], unitId: 2, writeStart: 95, writeQty: 20).ToList();
hits.ShouldBeEmpty("writes on a different unit ID must not invalidate this entry");
}
// ── Auxiliary correctness checks ─────────────────────────────────────────────
[Fact]
public void FcOtherThan03Or04_NeverInvalidated()
{
// Defensive: only FC03/FC04 entries are ever stored, but if a non-read key
// somehow appeared the invalidator must skip it.
var nonRead = new CacheKey(UnitId: 1, Fc: 0x06, StartAddress: 100, Qty: 10);
var hits = CacheInvalidator.FindOverlapping([nonRead], unitId: 1, writeStart: 95, writeQty: 20).ToList();
hits.ShouldBeEmpty("only FC03/FC04 entries should ever be invalidated");
}
[Fact]
public void ZeroWriteQty_NeverInvalidates()
{
var entry = K(unit: 1, start: 100, qty: 10);
var hits = CacheInvalidator.FindOverlapping([entry], unitId: 1, writeStart: 100, writeQty: 0).ToList();
hits.ShouldBeEmpty("a degenerate write covering zero registers must not invalidate anything");
}
}