1db900edef
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>
88 lines
3.5 KiB
C#
88 lines
3.5 KiB
C#
using Mbproxy.Proxy.Cache;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace Mbproxy.Tests.Proxy.Cache;
|
|
|
|
/// <summary>
|
|
/// Cover invariants of the <see cref="CacheEntry"/> record: TTL boundary expiry, byte
|
|
/// independence (caller-supplied <c>PduBytes</c> are not mutated by the cache itself —
|
|
/// the cache copies on store), and monotonic <see cref="CacheEntry.LastUsedTick"/>
|
|
/// semantics when the cache stamps it.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class CacheEntryTests
|
|
{
|
|
[Fact]
|
|
public void Expired_When_NowEqualsOrExceedsExpiresAtUtc()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var entry = new CacheEntry(
|
|
PduBytes: [0x03, 0x02, 0x04, 0xD2],
|
|
CachedAtUtc: now,
|
|
ExpiresAtUtc: now.AddMilliseconds(50),
|
|
Length: 4,
|
|
LastUsedTick: 1);
|
|
|
|
// The entry exposes ExpiresAtUtc for the cache to compare against UtcNow. Sanity:
|
|
// an entry whose ExpiresAtUtc is in the past is expired.
|
|
var past = entry with { ExpiresAtUtc = now.AddMilliseconds(-1) };
|
|
(past.ExpiresAtUtc <= now).ShouldBeTrue("an entry whose expiry is in the past must be expired");
|
|
|
|
var future = entry with { ExpiresAtUtc = now.AddMilliseconds(100) };
|
|
(future.ExpiresAtUtc > now).ShouldBeTrue("an entry whose expiry is in the future must be live");
|
|
}
|
|
|
|
[Fact]
|
|
public void Record_With_Expression_DoesNotMutate_OriginalArrayContents()
|
|
{
|
|
var bytes = new byte[] { 0x03, 0x02, 0x04, 0xD2 };
|
|
var entry = new CacheEntry(
|
|
PduBytes: bytes,
|
|
CachedAtUtc: DateTimeOffset.UtcNow,
|
|
ExpiresAtUtc: DateTimeOffset.UtcNow.AddSeconds(1),
|
|
Length: 4,
|
|
LastUsedTick: 1);
|
|
|
|
// Sanity: the entry holds a reference to the supplied array. The cache's hot path
|
|
// never mutates PduBytes; this test pins that contract by mutating the original
|
|
// array and confirming the entry sees the change (i.e. it doesn't copy on store,
|
|
// but the cache wraps Set with a snapshot — verified separately in the multiplexer
|
|
// path).
|
|
bytes[0] = 0xFF;
|
|
entry.PduBytes[0].ShouldBe((byte)0xFF, "CacheEntry holds the supplied reference; defensive copies live in the multiplexer");
|
|
}
|
|
|
|
[Fact]
|
|
public void LastUsedTick_Stamped_By_ResponseCache_OnSet_AndOnHit()
|
|
{
|
|
// The CacheEntry itself doesn't compute LastUsedTick — the cache assigns the next
|
|
// tick on every Set/Get. Verified here in conjunction with the cache: two inserts
|
|
// produce strictly-increasing ticks; a hit refreshes the tick.
|
|
using var cache = new ResponseCache(maxEntriesPerPlc: 10, evictionIntervalMs: 5000);
|
|
var k1 = new CacheKey(1, 0x03, 100, 1);
|
|
var k2 = new CacheKey(1, 0x03, 200, 1);
|
|
|
|
cache.Set(k1, MakeEntry(ttlMs: 1000));
|
|
cache.Set(k2, MakeEntry(ttlMs: 1000));
|
|
|
|
cache.TryGet(k1, out var e1).ShouldBeTrue();
|
|
cache.TryGet(k2, out var e2).ShouldBeTrue();
|
|
|
|
// Whichever was touched last has the larger LastUsedTick (k2 was the most recent
|
|
// touch via TryGet).
|
|
e2.LastUsedTick.ShouldBeGreaterThan(e1.LastUsedTick, "the more-recently-touched entry must carry the larger tick");
|
|
}
|
|
|
|
private static CacheEntry MakeEntry(int ttlMs)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
return new CacheEntry(
|
|
PduBytes: [0x03, 0x02, 0x04, 0xD2],
|
|
CachedAtUtc: now,
|
|
ExpiresAtUtc: now.AddMilliseconds(ttlMs),
|
|
Length: 4,
|
|
LastUsedTick: 0);
|
|
}
|
|
}
|