Files
wwtools/mbproxy/src/Mbproxy/Options/MbproxyOptions.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

114 lines
4.7 KiB
C#

using Microsoft.Extensions.Options;
namespace Mbproxy.Options;
public sealed class MbproxyOptions
{
public BcdTagListOptions BcdTags { get; init; } = new();
public IReadOnlyList<PlcOptions> Plcs { get; init; } = [];
public int AdminPort { get; init; } = 8080;
public ConnectionOptions Connection { get; init; } = new();
public ResilienceOptions Resilience { get; init; } = new();
/// <summary>
/// Phase 11 — service-wide response-cache settings. The cache is opt-in
/// per-tag (<see cref="BcdTagOptions.CacheTtlMs"/>); this section configures the
/// safety knobs that gate / bound the cache.
/// </summary>
public CacheOptions Cache { get; init; } = new();
}
/// <summary>
/// Phase 11 — service-wide response-cache knobs. The cache is OFF by default for every
/// tag; this section governs the limits when an operator opts a tag in.
/// </summary>
public sealed class CacheOptions
{
/// <summary>
/// Gate for any <see cref="BcdTagOptions.CacheTtlMs"/> greater than 60_000 ms.
/// Defaults to <c>false</c> so accidentally-stale-for-an-hour deployments are caught
/// at reload validation. Set to <c>true</c> to explicitly allow long TTLs.
/// </summary>
public bool AllowLongTtl { get; init; } = false;
/// <summary>
/// LRU cap on the number of entries per-PLC. Past this cap, the next insert evicts
/// the least-recently-used entry. Defaults to 1000 — comfortable for a 54-PLC fleet
/// with short TTLs.
/// </summary>
public int MaxEntriesPerPlc { get; init; } = 1000;
/// <summary>
/// Background eviction loop tick in milliseconds. Each tick scans the cache and
/// removes entries past their <c>ExpiresAtUtc</c>. Defaults to 5000 ms; values below
/// 100 ms are clamped at 100 to avoid pathologically tight loops.
/// </summary>
public int EvictionIntervalMs { get; init; } = 5000;
}
/// <summary>
/// Schema-level validation for <see cref="MbproxyOptions"/>.
/// Business-rule validation (duplicate addresses, port conflicts) is deferred to phase 06.
/// </summary>
public sealed class MbproxyOptionsValidator : IValidateOptions<MbproxyOptions>
{
public ValidateOptionsResult Validate(string? name, MbproxyOptions options)
{
var errors = new List<string>();
bool allowLongTtl = options.Cache.AllowLongTtl;
foreach (var tag in options.BcdTags.Global)
{
if (tag.Width != 16 && tag.Width != 32)
errors.Add($"BcdTags.Global: Address {tag.Address} has invalid Width {tag.Width}; must be 16 or 32.");
ValidateCacheTtl(errors, $"BcdTags.Global Address {tag.Address}", tag.CacheTtlMs, allowLongTtl);
}
for (int i = 0; i < options.Plcs.Count; i++)
{
var plc = options.Plcs[i];
// Phase 11 — per-PLC default TTL bounds.
if (plc.DefaultCacheTtlMs < 0)
errors.Add($"Plcs[{i}] ({plc.Name}): DefaultCacheTtlMs must be >= 0.");
else if (plc.DefaultCacheTtlMs > 60_000 && !allowLongTtl)
errors.Add(
$"Plcs[{i}] ({plc.Name}): DefaultCacheTtlMs={plc.DefaultCacheTtlMs} exceeds the 60_000 ms safety cap; " +
$"set Cache.AllowLongTtl=true to opt in.");
if (plc.BcdTags is { } overrides)
{
foreach (var tag in overrides.Add)
{
if (tag.Width != 16 && tag.Width != 32)
errors.Add($"Plcs[{i}] ({plc.Name}): BcdTags.Add Address {tag.Address} has invalid Width {tag.Width}; must be 16 or 32.");
ValidateCacheTtl(errors, $"Plcs[{i}] ({plc.Name}) BcdTags.Add Address {tag.Address}",
tag.CacheTtlMs, allowLongTtl);
}
}
}
// Cache section ranges.
if (options.Cache.MaxEntriesPerPlc < 0)
errors.Add($"Cache.MaxEntriesPerPlc must be >= 0; got {options.Cache.MaxEntriesPerPlc}.");
if (options.Cache.EvictionIntervalMs < 0)
errors.Add($"Cache.EvictionIntervalMs must be >= 0; got {options.Cache.EvictionIntervalMs}.");
return errors.Count > 0
? ValidateOptionsResult.Fail(errors)
: ValidateOptionsResult.Success;
}
private static void ValidateCacheTtl(List<string> errors, string context, int? ttlMs, bool allowLongTtl)
{
if (ttlMs is null) return;
int value = ttlMs.Value;
if (value < 0)
errors.Add($"{context}: CacheTtlMs must be >= 0; got {value}.");
else if (value > 60_000 && !allowLongTtl)
errors.Add(
$"{context}: CacheTtlMs={value} exceeds the 60_000 ms safety cap; " +
$"set Cache.AllowLongTtl=true to opt in.");
}
}