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>
This commit is contained in:
@@ -3,26 +3,39 @@ namespace Mbproxy.Bcd;
|
||||
/// <summary>
|
||||
/// Immutable description of a single BCD-encoded V-memory tag as seen on the Modbus wire.
|
||||
/// Width is 16 (one register) or 32 (two registers, CDAB low-word-first).
|
||||
///
|
||||
/// <para><b>Phase 11 — <see cref="CacheTtlMs"/></b> is the resolved per-tag response-cache
|
||||
/// TTL in milliseconds. 0 (the default) means caching is disabled for this tag. Positive
|
||||
/// values cap upstream staleness; the multi-tag-range read uses <c>min(TTLs)</c> across all
|
||||
/// matched tags and treats any 0 in the range as "uncached for the whole read."</para>
|
||||
/// </summary>
|
||||
public sealed record BcdTag(ushort Address, byte Width)
|
||||
public sealed record BcdTag(ushort Address, byte Width, int CacheTtlMs = 0)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="BcdTag"/> and validates that Width is 16 or 32.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Width is not 16 or 32.</exception>
|
||||
public static BcdTag Create(ushort address, byte width)
|
||||
public static BcdTag Create(ushort address, byte width, int cacheTtlMs = 0)
|
||||
{
|
||||
if (width != 16 && width != 32)
|
||||
throw new ArgumentException(
|
||||
$"BCD tag Width must be 16 or 32; got {width} at address {address}.",
|
||||
nameof(width));
|
||||
|
||||
return new BcdTag(address, width);
|
||||
if (cacheTtlMs < 0)
|
||||
throw new ArgumentException(
|
||||
$"BCD tag CacheTtlMs must be >= 0; got {cacheTtlMs} at address {address}.",
|
||||
nameof(cacheTtlMs));
|
||||
|
||||
return new BcdTag(address, width, cacheTtlMs);
|
||||
}
|
||||
|
||||
/// <summary>True when this tag occupies two registers (32-bit BCD).</summary>
|
||||
public bool IsThirtyTwoBit => Width == 32;
|
||||
|
||||
/// <summary>True when this tag opts into the Phase-11 response cache.</summary>
|
||||
public bool IsCacheable => CacheTtlMs > 0;
|
||||
|
||||
/// <summary>
|
||||
/// The address of the high-word register for a 32-bit tag (Address + 1).
|
||||
/// Only valid when <see cref="IsThirtyTwoBit"/> is true.
|
||||
|
||||
@@ -46,6 +46,37 @@ public sealed class BcdTagMap
|
||||
public bool TryGet(ushort address, out BcdTag tag)
|
||||
=> _map.TryGetValue(address, out tag!);
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — resolves the effective cache TTL for an FC03/FC04 read over the range
|
||||
/// [<paramref name="startAddress"/>, <paramref name="startAddress"/> + <paramref name="qty"/>).
|
||||
///
|
||||
/// <para>Returns 0 (uncached) when:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The range covers no configured BCD tags (nothing to cache for, conservatively).</description></item>
|
||||
/// <item><description>Any covered tag has <see cref="BcdTag.CacheTtlMs"/> = 0 (the
|
||||
/// conservative-by-design "if any tag is uncached, the whole read is uncached" rule).</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Otherwise returns the minimum non-zero TTL across all covered tags.</para>
|
||||
///
|
||||
/// <para>Allocation-free in every path (delegates to <see cref="TryGetForRange"/> which
|
||||
/// is allocation-free on no-hit and allocates only the hit list on hit).</para>
|
||||
/// </summary>
|
||||
public int ResolveCacheTtlMs(ushort startAddress, ushort qty)
|
||||
{
|
||||
if (!TryGetForRange(startAddress, qty, out var hits) || hits.Count == 0)
|
||||
return 0;
|
||||
|
||||
int min = int.MaxValue;
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
int ttl = hit.Tag.CacheTtlMs;
|
||||
if (ttl <= 0) return 0;
|
||||
if (ttl < min) min = ttl;
|
||||
}
|
||||
return min == int.MaxValue ? 0 : min;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns every BCD tag whose register footprint intersects
|
||||
/// [<paramref name="startAddress"/>, <paramref name="startAddress"/> + <paramref name="qty"/>).
|
||||
|
||||
@@ -31,6 +31,24 @@ public static class BcdTagMapBuilder
|
||||
/// <see cref="ValidationResult.Errors"/> as a fatal configuration problem.
|
||||
/// </returns>
|
||||
public static ValidationResult Build(BcdTagListOptions global, PlcBcdOverrides? perPlc)
|
||||
=> Build(global, perPlc, perPlcDefaultCacheTtlMs: 0);
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 overload — resolves the effective BCD tag list for one PLC and validates
|
||||
/// it, additionally folding the per-PLC <paramref name="perPlcDefaultCacheTtlMs"/> into
|
||||
/// any tag whose explicit <see cref="BcdTagOptions.CacheTtlMs"/> is null.
|
||||
///
|
||||
/// <para>Resolution order per tag:</para>
|
||||
/// <list type="number">
|
||||
/// <item><description>Explicit per-tag <c>CacheTtlMs</c> if set (including explicit 0).</description></item>
|
||||
/// <item><description>Otherwise the per-PLC default.</description></item>
|
||||
/// <item><description>Otherwise 0 (uncached).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static ValidationResult Build(
|
||||
BcdTagListOptions global,
|
||||
PlcBcdOverrides? perPlc,
|
||||
int perPlcDefaultCacheTtlMs)
|
||||
{
|
||||
var errors = new List<BcdError>();
|
||||
var warnings = new List<BcdWarning>();
|
||||
@@ -84,7 +102,12 @@ public static class BcdTagMapBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
validated[addr] = BcdTag.Create(addr, opt.Width);
|
||||
// Phase 11 — resolve the effective per-tag cache TTL:
|
||||
// explicit per-tag (including 0) wins; otherwise fall back to per-PLC default.
|
||||
int resolvedTtl = opt.CacheTtlMs ?? perPlcDefaultCacheTtlMs;
|
||||
if (resolvedTtl < 0) resolvedTtl = 0;
|
||||
|
||||
validated[addr] = BcdTag.Create(addr, opt.Width, resolvedTtl);
|
||||
}
|
||||
|
||||
// High-register collision check (only meaningful for 32-bit entries).
|
||||
|
||||
Reference in New Issue
Block a user