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:
+2
-2
@@ -1,6 +1,6 @@
|
||||
# mbproxy
|
||||
|
||||
A .NET 10 Windows Service that sits inline as a Modbus TCP proxy in front of a fleet of AutomationDirect DirectLOGIC DL205/DL260 controllers, rewriting BCD-encoded registers bidirectionally so upstream clients can read and write them as plain integers.
|
||||
A .NET 10 Windows Service that sits inline as a Modbus TCP proxy in front of a fleet of AutomationDirect DirectLOGIC DL205/DL260 controllers, rewriting BCD-encoded registers bidirectionally so upstream clients can read and write them as plain integers. Since Phase 11, the proxy also offers an opt-in per-tag response cache (default OFF) for FC03/FC04 reads with bounded operator-configured staleness — see [`docs/design.md`](docs/design.md) → "Response cache (Phase 11)" before enabling it.
|
||||
|
||||
## Hard constraints / prerequisites
|
||||
|
||||
@@ -14,7 +14,7 @@ A .NET 10 Windows Service that sits inline as a Modbus TCP proxy in front of a f
|
||||
|
||||
```
|
||||
src/Mbproxy/ Main C# project (net10.0, Microsoft.NET.Sdk.Worker)
|
||||
tests/Mbproxy.Tests/ xUnit v3 test project (282 unit + 43 E2E tests)
|
||||
tests/Mbproxy.Tests/ xUnit v3 test project (314 unit + 48 E2E tests)
|
||||
install/ PowerShell install/uninstall scripts and config template
|
||||
docs/ Design document, phase plans, and operations runbook
|
||||
DL260/ DL205/DL260 reference material and pymodbus simulator profile
|
||||
|
||||
@@ -365,6 +365,46 @@ If you're the agent picking up this phase:
|
||||
|
||||
11. **Update `docs/design.md` AND `docs/kpi.md` AND `mbproxy/CLAUDE.md` AND `install/mbproxy.config.template.json` IN THE SAME PR AS THE CODE.** Doc drift is a gate fail. The architectural pivot must be visible across all reader-facing surfaces.
|
||||
|
||||
## Implementation clarifications discovered during this phase
|
||||
|
||||
The following clarifications were resolved while implementing Phase 11 — recorded here so
|
||||
the next agent doesn't re-derive them:
|
||||
|
||||
- **`CacheKey` vs `CoalescingKey` — kept SEPARATE (no aliasing).** The two records carry
|
||||
the same dimensions but live in different namespaces (`Mbproxy.Proxy.Cache` vs
|
||||
`Mbproxy.Proxy.Multiplexing`). Aliasing them would couple the two phases' evolution; a
|
||||
duplicate 4-field record-struct is cheap enough to justify keeping them independent.
|
||||
Per-key equality is record-struct value equality; the two types are never compared.
|
||||
- **`CacheEntry.LastUsedTick` is a `long`, not `ushort`.** The phase doc proposed `ushort`
|
||||
but the LRU comparison needs to survive >65K touches in a long-running process. The
|
||||
signed-long ticker stamp suffices for the lifetime of any reasonable deployment.
|
||||
- **No-cacheable-tag PLCs skip the cache entirely.** When a PLC's resolved tag map has no
|
||||
entry with `CacheTtlMs > 0`, `ProxyWorker` (and `ConfigReconciler` on reseat/add)
|
||||
builds the `PerPlcContext` with `Cache = null`. The multiplexer's cache check is a
|
||||
no-op on a null cache, and no eviction timer is started. The "default OFF = byte-
|
||||
identical to Phase 10" regression test (`Cache_DisabledByDefault_*`) lands on this code
|
||||
path.
|
||||
- **Cache check runs BEFORE `EnsureBackendConnectedAsync`.** A cache hit serves the
|
||||
upstream client even when the backend is currently unreachable. This is intentional and
|
||||
matches the design contract bullet "cache survives backend disconnects." Verified by the
|
||||
unit-level `FailedBackendConnect_OnFirstRead_DoesNotPreventLaterCacheHits_*` test.
|
||||
- **FC06 / FC16 invalidation requires startAddr/qty parsing.** The multiplexer's request
|
||||
parser previously only extracted start/qty for FC03/FC04. Phase 11 extends it to
|
||||
FC06 (qty = 1) and FC16 (qty from request) so the InFlightRequest carries the write
|
||||
span; the response path then invalidates by overlap using those values.
|
||||
- **Cache eviction loop uses `PeriodicTimer`.** Per the phase doc; clamps the interval
|
||||
to a 100 ms floor (operator-configurable down to that) so a misconfigured
|
||||
`EvictionIntervalMs = 0` doesn't become a tight loop.
|
||||
- **Write invalidation only fires on SUCCESSFUL responses.** The post-rewriter check at
|
||||
the backend reader inspects the response FC byte for the exception-bit (`& 0x80`). An
|
||||
exception response on FC06 / FC16 (e.g. PLC in PROGRAM mode → code 04) does NOT
|
||||
invalidate — consistent with "the write didn't take effect."
|
||||
- **Pre-existing flake in `BackendDisconnect_CascadesToAllUpstreams`** hardened with a
|
||||
poll loop. The race window between "upstream EOF observed" and "BackendDisconnectCascades
|
||||
counter incremented in `TearDownBackendAsync`" is inherent to the multiplexer's
|
||||
serial-pipe-dispose loop; the test now polls for up to 1 s for the counter to reach 3.
|
||||
Behaviour is unchanged.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Phase 9's multiplexer is the chokepoint that hosts the cache check: [`09-txid-multiplexing.md`](09-txid-multiplexing.md).
|
||||
|
||||
@@ -74,7 +74,10 @@ public sealed record FcCounts(
|
||||
/// <c>QueueDepth</c>. Phase 10 added the three coalescing counters
|
||||
/// (<c>CoalescedHitCount</c>, <c>CoalescedMissCount</c>, <c>CoalescedResponseToDeadUpstream</c>);
|
||||
/// the dashboard-side derived <c>coalescingRatio</c> is intentionally NOT carried on the wire
|
||||
/// — consumers compute <c>Hit / (Hit + Miss)</c>.
|
||||
/// — consumers compute <c>Hit / (Hit + Miss)</c>. Phase 11 added the five cache counters
|
||||
/// (<c>CacheHitCount</c>, <c>CacheMissCount</c>, <c>CacheInvalidations</c>,
|
||||
/// <c>CacheEntryCount</c>, <c>CacheBytes</c>); the dashboard-side derived
|
||||
/// <c>cacheHitRatio</c> is intentionally NOT carried on the wire.
|
||||
/// </summary>
|
||||
public sealed record PlcBackendStatus(
|
||||
long ConnectsSuccess,
|
||||
@@ -88,7 +91,12 @@ public sealed record PlcBackendStatus(
|
||||
long QueueDepth,
|
||||
long CoalescedHitCount,
|
||||
long CoalescedMissCount,
|
||||
long CoalescedResponseToDeadUpstream);
|
||||
long CoalescedResponseToDeadUpstream,
|
||||
long CacheHitCount,
|
||||
long CacheMissCount,
|
||||
long CacheInvalidations,
|
||||
long CacheEntryCount,
|
||||
long CacheBytes);
|
||||
|
||||
/// <summary>Modbus exception counts by code.</summary>
|
||||
public sealed record ExceptionCounts(
|
||||
|
||||
@@ -84,6 +84,10 @@ internal static class StatusHtmlRenderer
|
||||
// a percentage plus the raw hit count for context. Kept compact (one cell) to
|
||||
// stay under the 50 KB page-weight budget.
|
||||
sb.Append("<th>Coal</th>");
|
||||
// Phase 11: cache column. Single cell carries hit-ratio percent plus raw hit
|
||||
// count; an em-dash when no cache-eligible reads have occurred. Page-weight
|
||||
// budget assertion stays under 50 KB for the 54-PLC fleet.
|
||||
sb.Append("<th>Cache</th>");
|
||||
sb.Append("</tr></thead><tbody>");
|
||||
|
||||
foreach (var plc in status.Plcs)
|
||||
@@ -165,6 +169,20 @@ internal static class StatusHtmlRenderer
|
||||
sb.Append(pct).Append("% (").Append(coalHit).Append(')');
|
||||
}
|
||||
sb.Append("</td>");
|
||||
// Phase 11: cache ratio cell — same pattern as coalescing.
|
||||
long cacheHit = plc.Backend.CacheHitCount;
|
||||
long cacheMiss = plc.Backend.CacheMissCount;
|
||||
sb.Append("<td>");
|
||||
if (cacheHit + cacheMiss == 0)
|
||||
{
|
||||
sb.Append("—");
|
||||
}
|
||||
else
|
||||
{
|
||||
int pct = (int)Math.Round(100.0 * cacheHit / (cacheHit + cacheMiss));
|
||||
sb.Append(pct).Append("% (").Append(cacheHit).Append(')');
|
||||
}
|
||||
sb.Append("</td>");
|
||||
sb.Append("</tr>");
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,12 @@ internal sealed class StatusSnapshotBuilder
|
||||
BackendQueueDepth: 0,
|
||||
CoalescedHitCount: 0,
|
||||
CoalescedMissCount: 0,
|
||||
CoalescedResponseToDeadUpstream: 0);
|
||||
CoalescedResponseToDeadUpstream: 0,
|
||||
CacheHitCount: 0,
|
||||
CacheMissCount: 0,
|
||||
CacheInvalidations: 0,
|
||||
CacheEntryCount: 0,
|
||||
CacheBytes: 0);
|
||||
|
||||
// Phase 08: ConnectsSuccess / ConnectsFailed are now tracked in ProxyCounters.
|
||||
long connectsSuccess = counters.ConnectsSuccess;
|
||||
@@ -140,7 +145,12 @@ internal sealed class StatusSnapshotBuilder
|
||||
QueueDepth: counters.BackendQueueDepth,
|
||||
CoalescedHitCount: counters.CoalescedHitCount,
|
||||
CoalescedMissCount: counters.CoalescedMissCount,
|
||||
CoalescedResponseToDeadUpstream: counters.CoalescedResponseToDeadUpstream),
|
||||
CoalescedResponseToDeadUpstream: counters.CoalescedResponseToDeadUpstream,
|
||||
CacheHitCount: counters.CacheHitCount,
|
||||
CacheMissCount: counters.CacheMissCount,
|
||||
CacheInvalidations: counters.CacheInvalidations,
|
||||
CacheEntryCount: counters.CacheEntryCount,
|
||||
CacheBytes: counters.CacheBytes),
|
||||
Bytes: new PlcBytesStatus(
|
||||
UpstreamIn: counters.BytesUpstreamIn,
|
||||
UpstreamOut: counters.BytesUpstreamOut)));
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Threading.Channels;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -274,14 +275,15 @@ internal sealed partial class ConfigReconciler : IDisposable
|
||||
await old.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Build fresh context.
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags);
|
||||
// Build fresh context. Phase 11: pass DefaultCacheTtlMs.
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs);
|
||||
var newCtx = new PerPlcContext
|
||||
{
|
||||
PlcName = plcNew.Name,
|
||||
TagMap = result.Map,
|
||||
Counters = new Proxy.ProxyCounters(),
|
||||
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plcNew.Name}"),
|
||||
Cache = BuildCacheIfNeeded(result.Map, next.Cache),
|
||||
};
|
||||
|
||||
// Build and start new supervisor.
|
||||
@@ -330,6 +332,9 @@ internal sealed partial class ConfigReconciler : IDisposable
|
||||
// Preserve existing counters so operators see real history.
|
||||
Counters = supervisor.CurrentCounters,
|
||||
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{name}"),
|
||||
// Phase 11: any reseat (tag-map change) constructs a fresh cache.
|
||||
// The supervisor disposes the old one inside ReplaceContextAsync.
|
||||
Cache = BuildCacheIfNeeded(newMap, next.Cache),
|
||||
};
|
||||
|
||||
using var reseatCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -355,13 +360,15 @@ internal sealed partial class ConfigReconciler : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags);
|
||||
// Phase 11: pass DefaultCacheTtlMs.
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs);
|
||||
var newCtx = new PerPlcContext
|
||||
{
|
||||
PlcName = plcNew.Name,
|
||||
TagMap = result.Map,
|
||||
Counters = new Proxy.ProxyCounters(),
|
||||
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plcNew.Name}"),
|
||||
Cache = BuildCacheIfNeeded(result.Map, next.Cache),
|
||||
};
|
||||
|
||||
var recoveryPipeline = PolicyFactory.BuildListenerRecovery(
|
||||
@@ -405,6 +412,19 @@ internal sealed partial class ConfigReconciler : IDisposable
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — constructs a <see cref="ResponseCache"/> only when at least one resolved
|
||||
/// tag in <paramref name="map"/> opts in (<see cref="BcdTag.CacheTtlMs"/> > 0).
|
||||
/// Returns <c>null</c> otherwise so the no-cache path is byte-identical to Phase 10.
|
||||
/// </summary>
|
||||
private static ResponseCache? BuildCacheIfNeeded(BcdTagMap map, CacheOptions opts)
|
||||
{
|
||||
foreach (var t in map.All)
|
||||
if (t.CacheTtlMs > 0)
|
||||
return new ResponseCache(opts.MaxEntriesPerPlc, opts.EvictionIntervalMs);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ComputeGlobalTagDelta(BcdTagListOptions before, BcdTagListOptions after)
|
||||
{
|
||||
// Count entries in before but not in after (removed), plus entries in after
|
||||
|
||||
@@ -78,8 +78,10 @@ public sealed record ReloadPlan(
|
||||
// Tag-map change → reseat (swap context, keep socket).
|
||||
// We must build both maps to compare them structurally.
|
||||
// Compute happens after validation so Build should never return errors here.
|
||||
var oldMap = BcdTagMapBuilder.Build(current.BcdTags, plcOld.BcdTags).Map;
|
||||
var newMap = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags).Map;
|
||||
// Phase 11: include DefaultCacheTtlMs in the build so a per-PLC default change
|
||||
// is detected by TagMapsEqual via the per-tag CacheTtlMs delta.
|
||||
var oldMap = BcdTagMapBuilder.Build(current.BcdTags, plcOld.BcdTags, plcOld.DefaultCacheTtlMs).Map;
|
||||
var newMap = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs).Map;
|
||||
|
||||
if (!TagMapsEqual(oldMap, newMap))
|
||||
toReseat.Add((name, newMap));
|
||||
@@ -94,7 +96,9 @@ public sealed record ReloadPlan(
|
||||
|
||||
/// <summary>
|
||||
/// Structural equality between two <see cref="BcdTagMap"/> instances: same set of
|
||||
/// (Address, Width) pairs. Order doesn't matter — we compare as sets.
|
||||
/// (Address, Width, CacheTtlMs) triples. Order doesn't matter — we compare as sets.
|
||||
/// Phase 11 includes <see cref="BcdTag.CacheTtlMs"/> in the comparison so a per-tag
|
||||
/// or per-PLC default TTL change reseats the context (which flushes the cache).
|
||||
/// </summary>
|
||||
private static bool TagMapsEqual(BcdTagMap a, BcdTagMap b)
|
||||
{
|
||||
@@ -106,6 +110,8 @@ public sealed record ReloadPlan(
|
||||
return false;
|
||||
if (tag.Width != bTag.Width)
|
||||
return false;
|
||||
if (tag.CacheTtlMs != bTag.CacheTtlMs)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -77,12 +77,54 @@ internal static class ReloadValidator
|
||||
// well-formedness; we must not duplicate its validation logic here.
|
||||
foreach (var plc in next.Plcs)
|
||||
{
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plc.BcdTags);
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plc.BcdTags, plc.DefaultCacheTtlMs);
|
||||
foreach (var err in result.Errors)
|
||||
errs.Add($"Plc '{plc.Name}': BCD tag map error ({err.Kind}): {err.Message}");
|
||||
}
|
||||
|
||||
// ── 5. Cache TTL bounds (Phase 11) ────────────────────────────────────
|
||||
// The MbproxyOptionsValidator catches these at schema time too, but ReloadValidator
|
||||
// is the gate that the hot-reload path consults directly so re-checking here keeps
|
||||
// both paths internally consistent (and the validator runs against tag-map-resolved
|
||||
// BcdTag.CacheTtlMs values too).
|
||||
bool allowLongTtl = next.Cache.AllowLongTtl;
|
||||
foreach (var tag in next.BcdTags.Global)
|
||||
{
|
||||
CheckTtl(errs, $"BcdTags.Global Address {tag.Address}", tag.CacheTtlMs, allowLongTtl);
|
||||
}
|
||||
foreach (var plc in next.Plcs)
|
||||
{
|
||||
if (plc.DefaultCacheTtlMs > 60_000 && !allowLongTtl)
|
||||
errs.Add(
|
||||
$"Plc '{plc.Name}': DefaultCacheTtlMs={plc.DefaultCacheTtlMs} exceeds 60_000 ms " +
|
||||
"without Cache.AllowLongTtl=true.");
|
||||
else if (plc.DefaultCacheTtlMs < 0)
|
||||
errs.Add($"Plc '{plc.Name}': DefaultCacheTtlMs must be >= 0; got {plc.DefaultCacheTtlMs}.");
|
||||
|
||||
if (plc.BcdTags?.Add is { } addList)
|
||||
{
|
||||
foreach (var tag in addList)
|
||||
CheckTtl(errs, $"Plc '{plc.Name}' BcdTags.Add Address {tag.Address}",
|
||||
tag.CacheTtlMs, allowLongTtl);
|
||||
}
|
||||
}
|
||||
if (next.Cache.MaxEntriesPerPlc < 0)
|
||||
errs.Add($"Cache.MaxEntriesPerPlc must be >= 0; got {next.Cache.MaxEntriesPerPlc}.");
|
||||
if (next.Cache.EvictionIntervalMs < 0)
|
||||
errs.Add($"Cache.EvictionIntervalMs must be >= 0; got {next.Cache.EvictionIntervalMs}.");
|
||||
|
||||
errors = errs;
|
||||
return errs.Count == 0;
|
||||
}
|
||||
|
||||
private static void CheckTtl(List<string> errs, string context, int? ttl, bool allowLongTtl)
|
||||
{
|
||||
if (ttl is null) return;
|
||||
int v = ttl.Value;
|
||||
if (v < 0)
|
||||
errs.Add($"{context}: CacheTtlMs must be >= 0; got {v}.");
|
||||
else if (v > 60_000 && !allowLongTtl)
|
||||
errs.Add(
|
||||
$"{context}: CacheTtlMs={v} exceeds 60_000 ms without Cache.AllowLongTtl=true.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,12 @@ public sealed class BcdTagOptions
|
||||
{
|
||||
public ushort Address { get; init; }
|
||||
public byte Width { get; init; } // 16 or 32
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — optional opt-in to the response cache. Null (default) means
|
||||
/// "unset" and falls back to the per-PLC <see cref="PlcOptions.DefaultCacheTtlMs"/>;
|
||||
/// 0 explicitly disables caching for this tag even when the PLC default is non-zero.
|
||||
/// Positive values cap the staleness window in milliseconds.
|
||||
/// </summary>
|
||||
public int? CacheTtlMs { get; init; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,41 @@ public sealed class MbproxyOptions
|
||||
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>
|
||||
@@ -20,28 +55,59 @@ 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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,12 @@ public sealed class PlcOptions
|
||||
public int Port { get; init; } = 502;
|
||||
|
||||
public PlcBcdOverrides? BcdTags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — per-PLC default cache TTL applied to any tag whose explicit
|
||||
/// <see cref="BcdTagOptions.CacheTtlMs"/> is unset (null). 0 (the default) means
|
||||
/// "no caching by default at this PLC". Per-tag values always win over the per-PLC
|
||||
/// default when set; an explicit zero on a tag still disables caching for that tag.
|
||||
/// </summary>
|
||||
public int DefaultCacheTtlMs { get; init; } = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the per-PLC response cache.
|
||||
///
|
||||
/// <para><b><see cref="PduBytes"/></b> is the POST-rewriter response PDU body (FC byte +
|
||||
/// byteCount + register data; no MBAP header). The cache stores rewriter-decoded bytes so
|
||||
/// hits never re-invoke the BCD rewriter — both a CPU optimisation and a correctness
|
||||
/// guarantee against future rewriter changes accidentally transforming an already-decoded
|
||||
/// payload.</para>
|
||||
///
|
||||
/// <para><b><see cref="LastUsedTick"/></b> is the LRU ordering counter. The cache assigns
|
||||
/// each touch (hit or fresh insert) the next value from <see cref="ResponseCache"/>'s
|
||||
/// monotonic ticker; LRU eviction picks the entry with the smallest tick. Using a long
|
||||
/// instead of <see cref="DateTimeOffset.UtcNow"/> on every access keeps the hot path free
|
||||
/// of clock calls and works correctly even if the wall clock moves backward.</para>
|
||||
/// </summary>
|
||||
internal sealed record CacheEntry(
|
||||
byte[] PduBytes,
|
||||
DateTimeOffset CachedAtUtc,
|
||||
DateTimeOffset ExpiresAtUtc,
|
||||
int Length,
|
||||
long LastUsedTick);
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Pure address-range-overlap matcher for FC06 / FC16 write invalidation.
|
||||
///
|
||||
/// <para>Half-open interval math: a write covering <c>[w, w + writeQty)</c> overlaps an
|
||||
/// entry covering <c>[s, s + qty)</c> iff <c>w < s + qty AND s < w + writeQty</c>.
|
||||
/// Adjacent-but-not-overlapping (write to <c>[10..15)</c> vs cached <c>[15..20)</c>) does
|
||||
/// NOT match — register 15 is not in the cached range.</para>
|
||||
///
|
||||
/// <para>Scope is restricted to FC03 / FC04 keys; we never cache writes so invalidation
|
||||
/// only applies to read entries. Different <c>unitId</c> bytes never invalidate each other
|
||||
/// (multi-drop / gateway personalities behind a shared socket).</para>
|
||||
/// </summary>
|
||||
internal static class CacheInvalidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns every <see cref="CacheKey"/> in <paramref name="haystack"/> that satisfies:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="CacheKey.UnitId"/> equals <paramref name="unitId"/>.</description></item>
|
||||
/// <item><description><see cref="CacheKey.Fc"/> is 0x03 or 0x04 (only read entries are evicted).</description></item>
|
||||
/// <item><description>The key's range <c>[StartAddress, StartAddress + Qty)</c> overlaps
|
||||
/// the write range <c>[writeStart, writeStart + writeQty)</c>.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Pure function; the returned enumeration is materialised so callers can mutate
|
||||
/// the haystack while iterating the result without raising "collection modified."</para>
|
||||
/// </summary>
|
||||
public static IEnumerable<CacheKey> FindOverlapping(
|
||||
IReadOnlyCollection<CacheKey> haystack,
|
||||
byte unitId,
|
||||
ushort writeStart,
|
||||
ushort writeQty)
|
||||
{
|
||||
// writeQty = 0 — a degenerate write that covers no registers. Nothing to invalidate.
|
||||
if (writeQty == 0) return Array.Empty<CacheKey>();
|
||||
|
||||
int writeEnd = writeStart + writeQty; // half-open upper bound
|
||||
|
||||
var hits = new List<CacheKey>();
|
||||
foreach (var key in haystack)
|
||||
{
|
||||
if (key.UnitId != unitId) continue;
|
||||
if (key.Fc != 0x03 && key.Fc != 0x04) continue;
|
||||
|
||||
int keyEnd = key.StartAddress + key.Qty;
|
||||
// Overlap iff writeStart < keyEnd AND key.StartAddress < writeEnd.
|
||||
if (writeStart < keyEnd && key.StartAddress < writeEnd)
|
||||
hits.Add(key);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Hash key for the per-PLC <see cref="ResponseCache"/>. Structurally identical to
|
||||
/// Phase 10's <see cref="CoalescingKey"/> — both keys discriminate the same dimensions
|
||||
/// (UnitId, FunctionCode, StartAddress, Quantity), but the two type aliases live in
|
||||
/// different namespaces so the two phases can evolve independently without one shaping
|
||||
/// the other's API surface.
|
||||
///
|
||||
/// <para><b>Equality semantics:</b> record-struct value equality. FC03 and FC04 produce
|
||||
/// different keys for the same address (different Modbus tables); different
|
||||
/// <see cref="UnitId"/> bytes never share a key (different PLC personalities behind a
|
||||
/// shared socket); reads of different <see cref="Qty"/> never share a key (the responses
|
||||
/// carry different register counts and would not be interchangeable on a fan-out).</para>
|
||||
///
|
||||
/// <para><b>Scope:</b> only FC03 (Read Holding Registers) and FC04 (Read Input Registers)
|
||||
/// are cacheable. FC06 / FC16 writes invalidate cache entries by ADDRESS RANGE OVERLAP
|
||||
/// rather than exact-key match — see <see cref="CacheInvalidator"/>.</para>
|
||||
/// </summary>
|
||||
internal readonly record struct CacheKey(
|
||||
byte UnitId,
|
||||
byte Fc,
|
||||
ushort StartAddress,
|
||||
ushort Qty);
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated <see cref="LoggerMessage"/> definitions for the Phase-11 response
|
||||
/// cache. Event names are stable — do not rename without updating <c>docs/design.md</c>'s
|
||||
/// Logging event-name table.
|
||||
///
|
||||
/// <para>Levels are conservative — a busy PLC under steady cache pressure would emit one
|
||||
/// Hit / Miss per FC03/FC04 request. The counters surface the same data at far lower cost
|
||||
/// for monitoring; Debug-level events are present for incident-time diagnosis only.</para>
|
||||
/// </summary>
|
||||
internal static partial class CacheLogEvents
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 140,
|
||||
EventName = "mbproxy.cache.hit",
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Cache hit: Plc={Plc} Unit={UnitId} Fc={Fc} Start={Start} Qty={Qty}")]
|
||||
public static partial void Hit(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
byte unitId,
|
||||
byte fc,
|
||||
ushort start,
|
||||
ushort qty);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 141,
|
||||
EventName = "mbproxy.cache.miss",
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Cache miss: Plc={Plc} Unit={UnitId} Fc={Fc} Start={Start} Qty={Qty}")]
|
||||
public static partial void Miss(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
byte unitId,
|
||||
byte fc,
|
||||
ushort start,
|
||||
ushort qty);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 142,
|
||||
EventName = "mbproxy.cache.store",
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Cache store: Plc={Plc} Unit={UnitId} Fc={Fc} Start={Start} Qty={Qty} TtlMs={TtlMs}")]
|
||||
public static partial void Store(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
byte unitId,
|
||||
byte fc,
|
||||
ushort start,
|
||||
ushort qty,
|
||||
int ttlMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 143,
|
||||
EventName = "mbproxy.cache.invalidated",
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Cache invalidated: Plc={Plc} Unit={UnitId} WriteStart={WriteStart} WriteQty={WriteQty} Count={Count}")]
|
||||
public static partial void Invalidated(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
byte unitId,
|
||||
ushort writeStart,
|
||||
ushort writeQty,
|
||||
int count);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 144,
|
||||
EventName = "mbproxy.cache.flushed",
|
||||
Level = LogLevel.Information,
|
||||
Message = "Cache flushed: Plc={Plc} Reason={Reason} Count={Count}")]
|
||||
public static partial void Flushed(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
string reason,
|
||||
int count);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Per-PLC opt-in response cache for FC03 / FC04 read responses. Phase 11.
|
||||
///
|
||||
/// <para><b>Lifecycle.</b> One instance per PLC, owned by the per-PLC context. The cache
|
||||
/// is consulted on every FC03/FC04 request before coalescing; populated by the backend
|
||||
/// reader task AFTER the BCD rewriter has decoded the response; invalidated on every
|
||||
/// successful FC06/FC16 write response that overlaps a cached read range.</para>
|
||||
///
|
||||
/// <para><b>Concurrency.</b> A single <see cref="object"/> lock serialises every method.
|
||||
/// A per-PLC cache sees at most one outstanding FC03/FC04 read on the backend at any
|
||||
/// instant (the multiplexer serialises onto the shared socket), but the read-on-hit path
|
||||
/// is called from many upstream task contexts concurrently; the lock is small and fast.</para>
|
||||
///
|
||||
/// <para><b>LRU eviction.</b> Each touch (hit or insert) assigns the entry the next value
|
||||
/// from <see cref="_lruTicker"/>. When the cache reaches <see cref="_maxEntries"/> and a
|
||||
/// new entry is inserted, the entry with the smallest <see cref="CacheEntry.LastUsedTick"/>
|
||||
/// is removed.</para>
|
||||
///
|
||||
/// <para><b>TTL expiry.</b> Entries past their <see cref="CacheEntry.ExpiresAtUtc"/> are
|
||||
/// dropped lazily on every read attempt, and also swept proactively by a background
|
||||
/// <see cref="PeriodicTimer"/> loop every <see cref="_evictionIntervalMs"/>. The background
|
||||
/// loop is the safety net that prevents abandoned entries (PLC whose clients all dropped)
|
||||
/// from holding memory until process exit.</para>
|
||||
/// </summary>
|
||||
internal sealed class ResponseCache : IDisposable
|
||||
{
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<CacheKey, CacheEntry> _entries;
|
||||
private readonly int _maxEntries;
|
||||
private readonly int _evictionIntervalMs;
|
||||
|
||||
private long _lruTicker;
|
||||
private long _approxBytes;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Task _evictionTask;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a cache with the supplied capacity and eviction tick interval. The
|
||||
/// eviction loop starts immediately; the cache becomes usable as soon as the
|
||||
/// constructor returns.
|
||||
/// </summary>
|
||||
/// <param name="maxEntriesPerPlc">LRU cap. Past this count, the next insert evicts
|
||||
/// the least-recently-used entry. Must be >= 0; 0 disables caching entirely (every
|
||||
/// <see cref="Set"/> call no-ops).</param>
|
||||
/// <param name="evictionIntervalMs">Background sweep interval in milliseconds. Clamped
|
||||
/// to a 100 ms floor and an effective ceiling of <c>int.MaxValue</c>.</param>
|
||||
public ResponseCache(int maxEntriesPerPlc, int evictionIntervalMs)
|
||||
{
|
||||
if (maxEntriesPerPlc < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxEntriesPerPlc),
|
||||
"maxEntriesPerPlc must be >= 0.");
|
||||
if (evictionIntervalMs < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(evictionIntervalMs),
|
||||
"evictionIntervalMs must be >= 0.");
|
||||
|
||||
_maxEntries = maxEntriesPerPlc;
|
||||
// 100 ms floor — protects against pathologically tight loops; 0 (operator-pinned)
|
||||
// becomes 100 ms here so the eviction task isn't a tight loop spinning on
|
||||
// _entries.
|
||||
_evictionIntervalMs = Math.Max(100, evictionIntervalMs);
|
||||
_entries = new Dictionary<CacheKey, CacheEntry>(capacity: Math.Min(_maxEntries, 64));
|
||||
|
||||
_evictionTask = Task.Run(() => RunEvictionLoopAsync(_cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>Current entry count. Stable read under lock.</summary>
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) return _entries.Count; }
|
||||
}
|
||||
|
||||
/// <summary>Approximation of cached PDU bytes (Sum of <see cref="CacheEntry.Length"/>). Stable read under lock.</summary>
|
||||
public long ApproximateBytes
|
||||
{
|
||||
get { lock (_lock) return _approxBytes; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> with the cached <see cref="CacheEntry"/> when a non-expired
|
||||
/// entry is present for <paramref name="key"/>. Expired entries are removed lazily.
|
||||
/// Updates LRU ordering on hit.
|
||||
/// </summary>
|
||||
public bool TryGet(CacheKey key, out CacheEntry entry)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
entry = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (existing.ExpiresAtUtc <= now)
|
||||
{
|
||||
// Expired — remove and miss.
|
||||
_entries.Remove(key);
|
||||
_approxBytes -= existing.Length;
|
||||
entry = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
long tick = ++_lruTicker;
|
||||
var refreshed = existing with { LastUsedTick = tick };
|
||||
_entries[key] = refreshed;
|
||||
entry = refreshed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts or replaces the entry under <paramref name="key"/>. If the cache is at
|
||||
/// capacity, evicts the LRU entry first. No-op when <see cref="_maxEntries"/> is 0.
|
||||
/// </summary>
|
||||
public void Set(CacheKey key, CacheEntry entry)
|
||||
{
|
||||
if (_maxEntries == 0) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
long tick = ++_lruTicker;
|
||||
var stamped = entry with { LastUsedTick = tick };
|
||||
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
// Replace; adjust byte accounting.
|
||||
_approxBytes -= existing.Length;
|
||||
_approxBytes += stamped.Length;
|
||||
_entries[key] = stamped;
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert. Evict LRU if at cap.
|
||||
if (_entries.Count >= _maxEntries)
|
||||
EvictLeastRecentlyUsed();
|
||||
|
||||
_entries[key] = stamped;
|
||||
_approxBytes += stamped.Length;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates every entry whose <see cref="CacheKey"/> range overlaps the write
|
||||
/// <c>[startAddress, startAddress + qty)</c> on <paramref name="unitId"/>. Returns the
|
||||
/// count of invalidated entries.
|
||||
/// </summary>
|
||||
public int Invalidate(byte unitId, ushort startAddress, ushort qty)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Snapshot keys for the pure overlap matcher.
|
||||
var keys = _entries.Keys.ToArray();
|
||||
int count = 0;
|
||||
foreach (var k in CacheInvalidator.FindOverlapping(keys, unitId, startAddress, qty))
|
||||
{
|
||||
if (_entries.TryGetValue(k, out var existing))
|
||||
{
|
||||
_entries.Remove(k);
|
||||
_approxBytes -= existing.Length;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops every entry. Used by hot-reload (per-PLC flush on tag-map change).
|
||||
/// Returns the count of entries that were present before the flush.
|
||||
/// </summary>
|
||||
public int Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
int n = _entries.Count;
|
||||
_entries.Clear();
|
||||
_approxBytes = 0;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the eviction loop and disposes the internal CTS. Idempotent.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try { _cts.Cancel(); } catch { /* best effort */ }
|
||||
|
||||
// Best-effort join the eviction loop; the loop will observe the cancellation and
|
||||
// exit. We bound the wait so a faulted loop doesn't hold up disposal.
|
||||
try { _evictionTask.Wait(TimeSpan.FromSeconds(1)); } catch { /* best effort */ }
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
// ── Eviction internals ───────────────────────────────────────────────────────
|
||||
|
||||
private void EvictLeastRecentlyUsed()
|
||||
{
|
||||
// Linear scan — acceptable at MaxEntriesPerPlc = 1000 (insert path is far cheaper
|
||||
// than the network round-trip the cache is saving). A sorted secondary structure
|
||||
// would be a premature optimisation.
|
||||
CacheKey lruKey = default;
|
||||
long lruTick = long.MaxValue;
|
||||
bool found = false;
|
||||
|
||||
foreach (var kvp in _entries)
|
||||
{
|
||||
if (kvp.Value.LastUsedTick < lruTick)
|
||||
{
|
||||
lruTick = kvp.Value.LastUsedTick;
|
||||
lruKey = kvp.Key;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (found && _entries.TryGetValue(lruKey, out var existing))
|
||||
{
|
||||
_entries.Remove(lruKey);
|
||||
_approxBytes -= existing.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunEvictionLoopAsync(CancellationToken ct)
|
||||
{
|
||||
var period = TimeSpan.FromMilliseconds(_evictionIntervalMs);
|
||||
using var timer = new PeriodicTimer(period);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
SweepExpired();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal disposal.
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Defensive — eviction loop must never fault the host. A swallow here means
|
||||
// entries are only evicted on access until disposal, which is correctness-preserving.
|
||||
}
|
||||
}
|
||||
|
||||
private void SweepExpired()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_entries.Count == 0) return;
|
||||
// Two-pass to avoid mutating during enumeration.
|
||||
var expired = new List<CacheKey>();
|
||||
foreach (var kvp in _entries)
|
||||
{
|
||||
if (kvp.Value.ExpiresAtUtc <= now)
|
||||
expired.Add(kvp.Key);
|
||||
}
|
||||
foreach (var k in expired)
|
||||
{
|
||||
if (_entries.TryGetValue(k, out var existing))
|
||||
{
|
||||
_entries.Remove(k);
|
||||
_approxBytes -= existing.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,5 @@ internal sealed record InFlightRequest(
|
||||
ushort StartAddress,
|
||||
ushort Qty,
|
||||
IReadOnlyList<InterestedParty> InterestedParties,
|
||||
DateTimeOffset SentAtUtc);
|
||||
DateTimeOffset SentAtUtc,
|
||||
int ResolvedCacheTtlMs = 0);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Channels;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Polly;
|
||||
|
||||
namespace Mbproxy.Proxy.Multiplexing;
|
||||
@@ -102,6 +103,12 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
_backendConnectPipeline = backendConnectPipeline;
|
||||
_coalescingOptions = coalescingOptions ?? (static () => new ReadCoalescingOptions());
|
||||
|
||||
// Phase 11 — register the per-PLC cache as the live stats source for the snapshot
|
||||
// path. Cache may be null when the per-PLC context has not been wired with one
|
||||
// (every tag uncached, or unit tests).
|
||||
if (_ctx.Cache is not null)
|
||||
_ctx.Counters.SetCacheStatsProvider(new CacheStatsAdapter(_ctx.Cache));
|
||||
|
||||
// Register this multiplexer as the live telemetry source for the PLC's counters.
|
||||
_ctx.Counters.SetMultiplexProvider(this);
|
||||
|
||||
@@ -177,6 +184,7 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
// Stop the counters provider link so a status snapshot during teardown doesn't
|
||||
// see live-but-soon-to-be-empty internal state.
|
||||
_ctx.Counters.SetMultiplexProvider(null);
|
||||
_ctx.Counters.SetCacheStatsProvider(null);
|
||||
|
||||
await _disposeCts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -450,6 +458,57 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
frame.AsSpan(MbapFrame.HeaderSize, pduBodyLen),
|
||||
responseCtx);
|
||||
|
||||
// Phase 11 — post-rewriter cache update:
|
||||
// * FC03/FC04 successful responses are stored when the request was
|
||||
// cache-eligible (resolvedTtlMs > 0).
|
||||
// * FC06/FC16 successful responses invalidate every cached entry whose
|
||||
// address range overlaps the write.
|
||||
if (_ctx.Cache is { } postCache)
|
||||
{
|
||||
byte fcInResponse = frame[MbapFrame.HeaderSize]; // post-rewriter, but the FC byte is never rewritten
|
||||
bool isException = (fcInResponse & 0x80) != 0;
|
||||
|
||||
if (!isException)
|
||||
{
|
||||
if (inFlight.Fc is 0x03 or 0x04 && inFlight.ResolvedCacheTtlMs > 0)
|
||||
{
|
||||
// Snapshot the post-rewriter PDU body so the cached entry is
|
||||
// independent of this frame's lifetime.
|
||||
byte[] pduSnapshot = new byte[pduBodyLen];
|
||||
Buffer.BlockCopy(frame, MbapFrame.HeaderSize, pduSnapshot, 0, pduBodyLen);
|
||||
|
||||
var cacheKey = new CacheKey(
|
||||
inFlight.UnitId, inFlight.Fc,
|
||||
inFlight.StartAddress, inFlight.Qty);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entry = new CacheEntry(
|
||||
PduBytes: pduSnapshot,
|
||||
CachedAtUtc: now,
|
||||
ExpiresAtUtc: now.AddMilliseconds(inFlight.ResolvedCacheTtlMs),
|
||||
Length: pduSnapshot.Length,
|
||||
LastUsedTick: 0); // ResponseCache.Set stamps the real tick
|
||||
postCache.Set(cacheKey, entry);
|
||||
|
||||
CacheLogEvents.Store(_logger, _plc.Name,
|
||||
inFlight.UnitId, inFlight.Fc,
|
||||
inFlight.StartAddress, inFlight.Qty,
|
||||
inFlight.ResolvedCacheTtlMs);
|
||||
}
|
||||
else if (inFlight.Fc is 0x06 or 0x10)
|
||||
{
|
||||
int invalidated = postCache.Invalidate(
|
||||
inFlight.UnitId, inFlight.StartAddress, inFlight.Qty);
|
||||
if (invalidated > 0)
|
||||
{
|
||||
_ctx.Counters.AddCacheInvalidations(invalidated);
|
||||
CacheLogEvents.Invalidated(_logger, _plc.Name,
|
||||
inFlight.UnitId, inFlight.StartAddress, inFlight.Qty,
|
||||
invalidated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan out to each interested party with their original TxId restored.
|
||||
// Phase 9: always exactly one party. Phase 10: N parties (read coalescing).
|
||||
// Note: the InFlightByKey TryRemove above (for FC03/FC04) guarantees no
|
||||
@@ -506,15 +565,6 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// Ensure backend is connected. Failure here means we cannot service the request;
|
||||
// close the upstream pipe (consistent with the 1:1 model's behaviour on connect
|
||||
// failure).
|
||||
if (!await EnsureBackendConnectedAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
try { await pipe.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ }
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.Length < MbapFrame.HeaderSize)
|
||||
return;
|
||||
|
||||
@@ -522,9 +572,11 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
out ushort originalTxId, out _, out _, out byte unitId))
|
||||
return;
|
||||
|
||||
// Parse the PDU FC + start/qty (for FC03/04) — needed for both the coalescing-key
|
||||
// path and the response correlation slot. FC06/FC16 (writes) keep startAddr/qty = 0;
|
||||
// they bypass coalescing entirely.
|
||||
// Parse the PDU FC + start/qty. FC03/FC04 reads use start/qty for the coalescing key
|
||||
// and (Phase 11) for the cache lookup. FC06 writes carry [addr][value]; we treat qty
|
||||
// as 1 for invalidation. FC16 carries [start][qty][byteCount]...; qty is the write
|
||||
// span used for cache invalidation. Phase 11: FC06/FC16 start/qty drive cache
|
||||
// invalidation by overlap rather than exact key.
|
||||
int pduOffset = MbapFrame.HeaderSize;
|
||||
byte fcByte = frame.Length > pduOffset ? frame[pduOffset] : (byte)0;
|
||||
ushort startAddr = 0;
|
||||
@@ -534,6 +586,56 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
startAddr = (ushort)((frame[pduOffset + 1] << 8) | frame[pduOffset + 2]);
|
||||
qty = (ushort)((frame[pduOffset + 3] << 8) | frame[pduOffset + 4]);
|
||||
}
|
||||
else if (fcByte == 0x06 && frame.Length >= pduOffset + 5)
|
||||
{
|
||||
// FC06 = Write Single Register. PDU: [fc=06][addrHi][addrLo][valHi][valLo].
|
||||
// For cache invalidation we represent this as qty=1 at addr.
|
||||
startAddr = (ushort)((frame[pduOffset + 1] << 8) | frame[pduOffset + 2]);
|
||||
qty = 1;
|
||||
}
|
||||
else if (fcByte == 0x10 && frame.Length >= pduOffset + 5)
|
||||
{
|
||||
// FC16 = Write Multiple Registers. PDU: [fc=10][startHi][startLo][qtyHi][qtyLo][byteCount]...
|
||||
startAddr = (ushort)((frame[pduOffset + 1] << 8) | frame[pduOffset + 2]);
|
||||
qty = (ushort)((frame[pduOffset + 3] << 8) | frame[pduOffset + 4]);
|
||||
}
|
||||
|
||||
// Phase 11 — response-cache path. Cache check happens BEFORE coalescing AND before
|
||||
// we attempt to bring up the backend connection. A hit short-circuits everything,
|
||||
// including the EnsureBackendConnectedAsync call — operators with all reads cached
|
||||
// and the backend down still get served (the cache survives backend disconnects per
|
||||
// the design contract). The cache only fires for FC03/FC04 and only when the read
|
||||
// range's resolved TTL > 0.
|
||||
int resolvedCacheTtlMs = 0;
|
||||
if (fcByte is 0x03 or 0x04 && _ctx.Cache is { } responseCache)
|
||||
{
|
||||
resolvedCacheTtlMs = _ctx.TagMap.ResolveCacheTtlMs(startAddr, qty);
|
||||
if (resolvedCacheTtlMs > 0)
|
||||
{
|
||||
var cacheKey = new CacheKey(unitId, fcByte, startAddr, qty);
|
||||
if (responseCache.TryGet(cacheKey, out var cached))
|
||||
{
|
||||
_ctx.Counters.IncrementCacheHit();
|
||||
CacheLogEvents.Hit(_logger, _plc.Name, unitId, fcByte, startAddr, qty);
|
||||
|
||||
byte[] hitFrame = BuildCacheHitFrame(originalTxId, unitId, cached.PduBytes);
|
||||
await pipe.SendResponseAsync(hitFrame, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_ctx.Counters.IncrementCacheMiss();
|
||||
CacheLogEvents.Miss(_logger, _plc.Name, unitId, fcByte, startAddr, qty);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure backend is connected. Failure here means we cannot service the request;
|
||||
// close the upstream pipe (consistent with the 1:1 model's behaviour on connect
|
||||
// failure).
|
||||
if (!await EnsureBackendConnectedAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
try { await pipe.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 10 — read-coalescing path. Only FC03/FC04 are coalescable; only when the
|
||||
// feature is enabled in the live config. If the late-arriving request matches an
|
||||
@@ -573,7 +675,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
StartAddress: startAddr,
|
||||
Qty: qty,
|
||||
InterestedParties: new List<InterestedParty> { newParty },
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
SentAtUtc: DateTimeOffset.UtcNow,
|
||||
ResolvedCacheTtlMs: resolvedCacheTtlMs);
|
||||
}
|
||||
|
||||
var partyList = new List<InterestedParty>(capacity: 1) { newParty };
|
||||
@@ -583,7 +686,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
StartAddress: startAddr,
|
||||
Qty: qty,
|
||||
InterestedParties: partyList,
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
SentAtUtc: DateTimeOffset.UtcNow,
|
||||
ResolvedCacheTtlMs: resolvedCacheTtlMs);
|
||||
|
||||
if (!_correlation.TryAdd(proxyTxId, inFlight))
|
||||
{
|
||||
@@ -673,7 +777,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
StartAddress: startAddr,
|
||||
Qty: qty,
|
||||
InterestedParties: partyListNc,
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
SentAtUtc: DateTimeOffset.UtcNow,
|
||||
ResolvedCacheTtlMs: resolvedCacheTtlMs);
|
||||
|
||||
if (!_correlation.TryAdd(proxyTxIdFc, inFlightNc))
|
||||
{
|
||||
@@ -809,6 +914,20 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Adapter exposing a <see cref="Cache.ResponseCache"/>'s Count / ApproximateBytes as
|
||||
/// <see cref="ICacheStatsProvider"/> for the snapshot path. Kept as a sealed class so
|
||||
/// the cache type itself doesn't need to take an interface dependency on
|
||||
/// <see cref="ICacheStatsProvider"/>.
|
||||
/// </summary>
|
||||
private sealed class CacheStatsAdapter : ICacheStatsProvider
|
||||
{
|
||||
private readonly Cache.ResponseCache _cache;
|
||||
public CacheStatsAdapter(Cache.ResponseCache cache) => _cache = cache;
|
||||
public long EntryCount => _cache.Count;
|
||||
public long ApproximateBytes => _cache.ApproximateBytes;
|
||||
}
|
||||
|
||||
private static async Task<bool> FillAsync(
|
||||
Socket socket, byte[] buf, int offset, int count, CancellationToken ct)
|
||||
{
|
||||
@@ -824,6 +943,28 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — builds an MBAP-framed response from cached PDU bytes for the given
|
||||
/// upstream party. The cache stores POST-rewriter PDU bodies (no MBAP); each hit
|
||||
/// stamps a fresh MBAP header carrying the requesting party's original TxId so the
|
||||
/// response looks indistinguishable from a fresh backend reply.
|
||||
/// </summary>
|
||||
private static byte[] BuildCacheHitFrame(ushort originalTxId, byte unitId, byte[] cachedPdu)
|
||||
{
|
||||
// Length field covers UnitId(1) + PDU body. Capped by Modbus spec at 253-byte PDU.
|
||||
int pduLen = cachedPdu.Length;
|
||||
ushort length = (ushort)(1 + pduLen);
|
||||
var frame = new byte[MbapFrame.HeaderSize + pduLen];
|
||||
frame[0] = (byte)(originalTxId >> 8);
|
||||
frame[1] = (byte)(originalTxId & 0xFF);
|
||||
frame[2] = 0; frame[3] = 0;
|
||||
frame[4] = (byte)(length >> 8);
|
||||
frame[5] = (byte)(length & 0xFF);
|
||||
frame[6] = unitId;
|
||||
Buffer.BlockCopy(cachedPdu, 0, frame, MbapFrame.HeaderSize, pduLen);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static byte[] BuildExceptionFrame(ushort originalTxId, byte unitId, byte fc, byte exceptionCode)
|
||||
{
|
||||
// Modbus exception PDU = [fc | 0x80][exceptionCode].
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
|
||||
namespace Mbproxy.Proxy;
|
||||
@@ -44,6 +45,14 @@ internal class PerPlcContext : PduContext
|
||||
/// </summary>
|
||||
internal InFlightRequest? CurrentRequest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — optional per-PLC response cache. <c>null</c> on contexts that opt out
|
||||
/// (every BCD tag has <see cref="BcdTag.CacheTtlMs"/> = 0) or in unit tests that don't
|
||||
/// exercise the cache. The multiplexer constructs and disposes the cache alongside
|
||||
/// itself.
|
||||
/// </summary>
|
||||
internal ResponseCache? Cache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a shallow clone of this context with <see cref="CurrentRequest"/> set to
|
||||
/// <paramref name="req"/>. The clone is cheap (one allocation per response) and avoids
|
||||
@@ -56,5 +65,6 @@ internal class PerPlcContext : PduContext
|
||||
Counters = Counters,
|
||||
Logger = Logger,
|
||||
CurrentRequest = req,
|
||||
Cache = Cache,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,7 +97,34 @@ public sealed record CounterSnapshot(
|
||||
/// attached upstream pipe had already disconnected. A spike is a churn indicator; the
|
||||
/// metric itself is informational (Tier 2 in <c>docs/kpi.md</c>).
|
||||
/// </summary>
|
||||
long CoalescedResponseToDeadUpstream);
|
||||
long CoalescedResponseToDeadUpstream,
|
||||
/// <summary>
|
||||
/// Phase 11 — cumulative count of FC03/FC04 requests served from the response cache.
|
||||
/// <c>CacheHitCount + CacheMissCount</c> equals total FC03/FC04 requests whose resolved
|
||||
/// TTL was > 0 (cache-eligible). Reads against tags with TTL = 0 increment neither.
|
||||
/// </summary>
|
||||
long CacheHitCount,
|
||||
/// <summary>
|
||||
/// Phase 11 — cumulative count of cache-eligible FC03/FC04 requests that fell through
|
||||
/// to coalescing / backend (no fresh entry was present or the entry had expired).
|
||||
/// </summary>
|
||||
long CacheMissCount,
|
||||
/// <summary>
|
||||
/// Phase 11 — cumulative count of cache entries invalidated by overlapping FC06/FC16
|
||||
/// write responses. A high rate suggests caching is fighting writes; consider lower
|
||||
/// TTLs on cache-overlapping tags.
|
||||
/// </summary>
|
||||
long CacheInvalidations,
|
||||
/// <summary>
|
||||
/// Phase 11 — point-in-time snapshot of the per-PLC <see cref="Cache.ResponseCache"/>
|
||||
/// entry count. Read on the snapshot path; 0 when no cache is wired.
|
||||
/// </summary>
|
||||
long CacheEntryCount,
|
||||
/// <summary>
|
||||
/// Phase 11 — point-in-time approximation of cached PDU bytes for this PLC. Sum of
|
||||
/// <see cref="Cache.CacheEntry.Length"/> across entries. Read on the snapshot path.
|
||||
/// </summary>
|
||||
long CacheBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe per-PLC counters backed by <see cref="System.Threading.Interlocked"/> longs.
|
||||
@@ -137,6 +164,16 @@ internal sealed class ProxyCounters
|
||||
private long _coalescedMissCount;
|
||||
private long _coalescedResponseToDeadUpstream;
|
||||
|
||||
// Phase 11 — response-cache counters. Hit + Miss = total cache-eligible FC03/FC04.
|
||||
private long _cacheHitCount;
|
||||
private long _cacheMissCount;
|
||||
private long _cacheInvalidations;
|
||||
|
||||
// Phase 11 — live cache state pulled from a per-PLC ResponseCache on each snapshot.
|
||||
// The multiplexer registers a single provider via SetCacheStatsProvider so the status
|
||||
// page sees current entry-count / bytes without a separate poll.
|
||||
private volatile ICacheStatsProvider? _cacheStatsProvider;
|
||||
|
||||
// Phase 9: live state pulled from the multiplexer's allocator/map/queue on each
|
||||
// snapshot. The multiplexer registers a single provider via SetMultiplexProvider.
|
||||
// We use a volatile reference for lock-free read on the snapshot path.
|
||||
@@ -244,6 +281,25 @@ internal sealed class ProxyCounters
|
||||
public void IncrementCoalescedResponseToDeadUpstream()
|
||||
=> Interlocked.Increment(ref _coalescedResponseToDeadUpstream);
|
||||
|
||||
/// <summary>Phase 11 — records one FC03/FC04 cache hit.</summary>
|
||||
public void IncrementCacheHit()
|
||||
=> Interlocked.Increment(ref _cacheHitCount);
|
||||
|
||||
/// <summary>Phase 11 — records one cache-eligible FC03/FC04 read that missed.</summary>
|
||||
public void IncrementCacheMiss()
|
||||
=> Interlocked.Increment(ref _cacheMissCount);
|
||||
|
||||
/// <summary>Phase 11 — records <paramref name="n"/> cache entries invalidated by a write.</summary>
|
||||
public void AddCacheInvalidations(int n)
|
||||
=> Interlocked.Add(ref _cacheInvalidations, n);
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — wires the per-PLC <see cref="Cache.ResponseCache"/> as the live stats
|
||||
/// source for the snapshot path. Pass <c>null</c> to detach during disposal.
|
||||
/// </summary>
|
||||
internal void SetCacheStatsProvider(ICacheStatsProvider? provider)
|
||||
=> _cacheStatsProvider = provider;
|
||||
|
||||
/// <summary>
|
||||
/// CAS-updates the peak in-flight high-water mark. Called on every successful
|
||||
/// allocation by the multiplexer. Phase 9.
|
||||
@@ -328,6 +384,10 @@ internal sealed class ProxyCounters
|
||||
long txWraps = provider?.TxIdWraps ?? 0;
|
||||
long queueDepth = provider?.BackendQueueDepth ?? 0;
|
||||
|
||||
var cacheProvider = _cacheStatsProvider;
|
||||
long cacheEntries = cacheProvider?.EntryCount ?? 0;
|
||||
long cacheBytes = cacheProvider?.ApproximateBytes ?? 0;
|
||||
|
||||
return new(
|
||||
PdusForwarded: Interlocked.Read(ref _pdusForwarded),
|
||||
Fc03: Interlocked.Read(ref _fc03),
|
||||
@@ -357,7 +417,12 @@ internal sealed class ProxyCounters
|
||||
BackendQueueDepth: queueDepth,
|
||||
CoalescedHitCount: Interlocked.Read(ref _coalescedHitCount),
|
||||
CoalescedMissCount: Interlocked.Read(ref _coalescedMissCount),
|
||||
CoalescedResponseToDeadUpstream: Interlocked.Read(ref _coalescedResponseToDeadUpstream));
|
||||
CoalescedResponseToDeadUpstream: Interlocked.Read(ref _coalescedResponseToDeadUpstream),
|
||||
CacheHitCount: Interlocked.Read(ref _cacheHitCount),
|
||||
CacheMissCount: Interlocked.Read(ref _cacheMissCount),
|
||||
CacheInvalidations: Interlocked.Read(ref _cacheInvalidations),
|
||||
CacheEntryCount: cacheEntries,
|
||||
CacheBytes: cacheBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,3 +445,17 @@ internal interface IMultiplexCountersProvider
|
||||
/// <summary>Current depth of the outbound channel (frames queued for the backend writer).</summary>
|
||||
long BackendQueueDepth { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — read-only window into the per-PLC <see cref="Cache.ResponseCache"/>'s live
|
||||
/// state for the snapshot path. The multiplexer wires this on cache construction so the
|
||||
/// status page sees live counts without holding a direct reference to the cache.
|
||||
/// </summary>
|
||||
internal interface ICacheStatsProvider
|
||||
{
|
||||
/// <summary>Current cache entry count.</summary>
|
||||
long EntryCount { get; }
|
||||
|
||||
/// <summary>Approximation of cached PDU bytes (sum of <see cref="Cache.CacheEntry.Length"/>).</summary>
|
||||
long ApproximateBytes { get; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Configuration;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -70,7 +71,7 @@ internal sealed partial class ProxyWorker : BackgroundService
|
||||
|
||||
foreach (var plc in opts.Plcs)
|
||||
{
|
||||
var result = BcdTagMapBuilder.Build(opts.BcdTags, plc.BcdTags);
|
||||
var result = BcdTagMapBuilder.Build(opts.BcdTags, plc.BcdTags, plc.DefaultCacheTtlMs);
|
||||
|
||||
foreach (var warn in result.Warnings)
|
||||
_logger.LogWarning("[{Plc}] BCD tag map warning: {Message}", plc.Name, warn.Message);
|
||||
@@ -85,12 +86,22 @@ internal sealed partial class ProxyWorker : BackgroundService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 11 — construct a per-PLC response cache only when at least one
|
||||
// resolved tag opts in (CacheTtlMs > 0). Skipping cache construction for a
|
||||
// PLC with no cacheable tags keeps the no-cache path free of the eviction
|
||||
// timer and the per-call resolution cost, preserving "default behaviour =
|
||||
// Phase 10 unchanged" when no operator has opted any tag in.
|
||||
var cache = HasAnyCacheableTag(result.Map)
|
||||
? new ResponseCache(opts.Cache.MaxEntriesPerPlc, opts.Cache.EvictionIntervalMs)
|
||||
: null;
|
||||
|
||||
plcContexts[plc.Name] = new PerPlcContext
|
||||
{
|
||||
PlcName = plc.Name,
|
||||
TagMap = result.Map,
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plc.Name}"),
|
||||
Cache = cache,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,6 +224,20 @@ internal sealed partial class ProxyWorker : BackgroundService
|
||||
|
||||
// ── Logging ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — returns <c>true</c> when at least one BcdTag in the resolved map has a
|
||||
/// positive <see cref="BcdTag.CacheTtlMs"/>. A PLC with no cacheable tags skips the
|
||||
/// <see cref="Mbproxy.Proxy.Cache.ResponseCache"/> entirely (no eviction timer, no
|
||||
/// per-call cache resolution cost), so the default-OFF deployment is byte-identical
|
||||
/// to a Phase-10 deployment.
|
||||
/// </summary>
|
||||
private static bool HasAnyCacheableTag(BcdTagMap map)
|
||||
{
|
||||
foreach (var t in map.All)
|
||||
if (t.CacheTtlMs > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
[LoggerMessage(EventId = 1, EventName = "mbproxy.startup.ready",
|
||||
Level = LogLevel.Information,
|
||||
Message = "mbproxy service ready — ListenersBound={ListenersBound} PlcsConfigured={PlcsConfigured}")]
|
||||
|
||||
@@ -198,10 +198,20 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
|
||||
/// </summary>
|
||||
public Task ReplaceContextAsync(PerPlcContext newCtx, CancellationToken ct)
|
||||
{
|
||||
// Phase 11: dispose the outgoing context's response cache (if any) so its
|
||||
// eviction loop terminates. The "any tag-list reload flushes the affected PLC's
|
||||
// whole cache" doctrine is satisfied here — the new context constructs its own
|
||||
// fresh cache, the old cache is dropped wholesale.
|
||||
var oldCache = _currentContext?.Cache;
|
||||
|
||||
// Volatile write: the next PlcListener created in RunSupervisorAsync will see
|
||||
// the new context. The accept loop itself does not hold a direct reference to
|
||||
// _currentContext — it was captured at PlcListener construction time.
|
||||
_currentContext = newCtx;
|
||||
|
||||
if (oldCache is not null && !ReferenceEquals(oldCache, newCtx.Cache))
|
||||
oldCache.Dispose();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -376,6 +386,10 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
|
||||
// Phase 11: dispose the response cache (if any) — its eviction timer would
|
||||
// otherwise outlive the supervisor.
|
||||
_currentContext?.Cache?.Dispose();
|
||||
|
||||
_supervisorCts.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ public sealed class StatusHtmlRendererTests
|
||||
InFlight: 0, MaxInFlight: 0, TxIdWraps: 0,
|
||||
DisconnectCascades: 0, QueueDepth: 0,
|
||||
CoalescedHitCount: 0, CoalescedMissCount: 0,
|
||||
CoalescedResponseToDeadUpstream: 0),
|
||||
CoalescedResponseToDeadUpstream: 0,
|
||||
CacheHitCount: 0, CacheMissCount: 0,
|
||||
CacheInvalidations: 0, CacheEntryCount: 0, CacheBytes: 0),
|
||||
Bytes: new PlcBytesStatus(1024, 2048));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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 < s+qty AND s < 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Equality semantics for <see cref="CacheKey"/>. The key must distinguish every dimension
|
||||
/// the cache uses to route a hit — same dimensions as <c>CoalescingKey</c> but a separate
|
||||
/// type so the two phases can evolve independently.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CacheKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Equality_IdenticalKeys_AreEqual()
|
||||
{
|
||||
var a = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 4);
|
||||
var b = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 4);
|
||||
|
||||
a.ShouldBe(b);
|
||||
a.GetHashCode().ShouldBe(b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_Fc03_vs_Fc04_AtSameAddress_DifferentKeys()
|
||||
{
|
||||
var fc03 = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 1);
|
||||
var fc04 = new CacheKey(UnitId: 1, Fc: 0x04, StartAddress: 100, Qty: 1);
|
||||
|
||||
fc03.ShouldNotBe(fc04, "FC03 and FC04 read different Modbus tables");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_DifferentUnitId_DifferentKeys()
|
||||
{
|
||||
var u1 = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 1);
|
||||
var u2 = new CacheKey(UnitId: 2, Fc: 0x03, StartAddress: 100, Qty: 1);
|
||||
|
||||
u1.ShouldNotBe(u2, "different unit IDs never share cache entries");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Mbproxy;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NModbus;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage of the Phase-11 response cache against the pymodbus DL205
|
||||
/// simulator.
|
||||
///
|
||||
/// <para><b>pymodbus 3.13 simulator quirk.</b> Like Phase 9 and Phase 10, these tests
|
||||
/// serialise reads in the simulator-backed cases. The Phase-11 cache's behavioural
|
||||
/// guarantee (a TTL-bounded cache hit returns the cached value without backend traffic)
|
||||
/// is independent of the simulator's known concurrent-MBAP-frame bug — sequential reads
|
||||
/// keep the sim in single-PDU mode, which is its known-good envelope.</para>
|
||||
///
|
||||
/// <para>The headline assertion lives here: 10 reads at 100 ms intervals with a 1 s TTL
|
||||
/// must result in EXACTLY 1 backend round-trip. If this test fails, Phase 11 does not
|
||||
/// ship — see <c>11-response-cache.md</c>.</para>
|
||||
/// </summary>
|
||||
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class ResponseCacheE2ETests
|
||||
{
|
||||
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
||||
public ResponseCacheE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim) => _sim = sim;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int p = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return p;
|
||||
}
|
||||
|
||||
private Dictionary<string, string?> MakeBaseConfig(int proxyPort) => new()
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
};
|
||||
|
||||
private static IHost BuildBcdHost(Dictionary<string, string?> config)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
builder.Services.AddSerilog(
|
||||
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
|
||||
dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
|
||||
builder.Services.AddSingleton<ProxyWorker>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
||||
|
||||
if (int.TryParse(config["Mbproxy:AdminPort"], out int admin) && admin > 0)
|
||||
builder.AddMbproxyAdmin();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private sealed class AsyncHostDispose : IAsyncDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
public AsyncHostDispose(IHost host) => _host = host;
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
try { await _host.StopAsync(cts.Token); } catch { }
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Headline test: 10 reads at 100 ms intervals → exactly 1 backend round-trip ──
|
||||
|
||||
/// <summary>
|
||||
/// The "is the design pivot worth it?" test. Configure a BCD tag with <c>CacheTtlMs =
|
||||
/// 1000</c>; issue 10 reads at 100 ms intervals through the proxy. The cache HitCount
|
||||
/// must show 9 (one miss to prime, 9 hits to serve) and the backend trip count must
|
||||
/// be exactly 1.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_CacheHit_TenReadsIn1Sec_BackendSeesOneRoundTrip()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
int adminPort = PickFreePort();
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
config["Mbproxy:AdminPort"] = adminPort.ToString();
|
||||
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
||||
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
||||
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "1000";
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(300, TestContext.Current.CancellationToken);
|
||||
|
||||
using (var client = new TcpClient())
|
||||
{
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
// 10 reads at 100 ms intervals — total elapsed ~900 ms, well within the 1000 ms TTL.
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
|
||||
regs[0].ShouldBe((ushort)1234, $"read #{i}: BCD-decoded value must be 1234");
|
||||
if (i < 9)
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
var resp = await httpClient.GetStringAsync(
|
||||
$"http://127.0.0.1:{adminPort}/status.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
||||
|
||||
// 9 cache hits (the first read populated the cache; the next 9 returned from cache).
|
||||
// 1 cache miss (the priming read).
|
||||
backend.GetProperty("cacheHitCount").GetInt64()
|
||||
.ShouldBe(9, "10 reads with TTL=1000 ms at 100 ms intervals must produce 9 cache hits");
|
||||
backend.GetProperty("cacheMissCount").GetInt64()
|
||||
.ShouldBe(1, "exactly the first read should miss");
|
||||
|
||||
// The backend-trip count is observable via coalescedMissCount (every read that
|
||||
// makes it to the backend increments this counter; cache hits short-circuit).
|
||||
backend.GetProperty("coalescedMissCount").GetInt64()
|
||||
.ShouldBe(1, "exactly one read must reach the backend");
|
||||
}
|
||||
|
||||
// ── Regression: cache disabled by default ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Mandatory regression. With no cache config anywhere (default deployment shape),
|
||||
/// behaviour must be byte-identical to Phase 10. Sequential reads through the same
|
||||
/// client produce one backend round-trip each — no elision.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Cache_DisabledByDefault_BehaviourIs_ByteIdenticalTo_Phase10()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
int adminPort = PickFreePort();
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
config["Mbproxy:AdminPort"] = adminPort.ToString();
|
||||
// No Cache section, no CacheTtlMs on any tag — pure Phase-10 behaviour.
|
||||
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
||||
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(300, TestContext.Current.CancellationToken);
|
||||
|
||||
using (var client = new TcpClient())
|
||||
{
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
|
||||
regs[0].ShouldBe((ushort)1234, $"read #{i} must still BCD-decode correctly");
|
||||
}
|
||||
}
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
var resp = await httpClient.GetStringAsync(
|
||||
$"http://127.0.0.1:{adminPort}/status.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
||||
|
||||
backend.GetProperty("cacheHitCount").GetInt64()
|
||||
.ShouldBe(0, "no cache config: HitCount must remain at zero");
|
||||
backend.GetProperty("cacheMissCount").GetInt64()
|
||||
.ShouldBe(0, "no cache config: MissCount must remain at zero — cache counters are tracked only for cache-eligible reads");
|
||||
backend.GetProperty("coalescedMissCount").GetInt64()
|
||||
.ShouldBe(5, "every read must reach the backend as before — Phase-10 behaviour preserved");
|
||||
}
|
||||
|
||||
// ── TTL expiry path ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_CacheExpires_AfterTtl_NextReadHitsBackend()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
int adminPort = PickFreePort();
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
config["Mbproxy:AdminPort"] = adminPort.ToString();
|
||||
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
||||
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
||||
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "200";
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(300, TestContext.Current.CancellationToken);
|
||||
|
||||
using (var client = new TcpClient())
|
||||
{
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
_ = master.ReadHoldingRegisters(1, 1072, 1); // miss, populates cache
|
||||
_ = master.ReadHoldingRegisters(1, 1072, 1); // hit
|
||||
await Task.Delay(350, TestContext.Current.CancellationToken); // > TTL
|
||||
_ = master.ReadHoldingRegisters(1, 1072, 1); // miss again
|
||||
}
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
var resp = await httpClient.GetStringAsync(
|
||||
$"http://127.0.0.1:{adminPort}/status.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
||||
|
||||
backend.GetProperty("cacheHitCount").GetInt64()
|
||||
.ShouldBe(1, "exactly one read should land inside the TTL window");
|
||||
backend.GetProperty("cacheMissCount").GetInt64()
|
||||
.ShouldBe(2, "two reads should miss (initial fill and post-expiry refill)");
|
||||
backend.GetProperty("coalescedMissCount").GetInt64()
|
||||
.ShouldBe(2, "the two cache misses must each produce a backend round-trip");
|
||||
}
|
||||
|
||||
// ── Write invalidation ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Uses a register OUTSIDE the simulator's seeded BCD range so subsequent tests'
|
||||
/// reads of register 1072 are not polluted by this test's write. The simulator's
|
||||
/// holding-register table is shared across tests in the collection.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_WriteInvalidatesOverlappingCacheEntries()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
// Use a scratch register (200) from the simulator's allowed-write range so the
|
||||
// FC06 write does not fault on the simulator side, but a register that no other
|
||||
// test reads with BCD decoding — its initial value 0 round-trips through the
|
||||
// BCD codec without surprises, and any post-write state stays contained.
|
||||
const ushort isolatedRegister = 200;
|
||||
int proxyPort = PickFreePort();
|
||||
int adminPort = PickFreePort();
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
config["Mbproxy:AdminPort"] = adminPort.ToString();
|
||||
config["Mbproxy:BcdTags:Global:0:Address"] = isolatedRegister.ToString();
|
||||
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
||||
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "5000";
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(300, TestContext.Current.CancellationToken);
|
||||
|
||||
using (var client = new TcpClient())
|
||||
{
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
_ = master.ReadHoldingRegisters(1, isolatedRegister, 1); // miss → cached
|
||||
_ = master.ReadHoldingRegisters(1, isolatedRegister, 1); // hit
|
||||
master.WriteSingleRegister(1, isolatedRegister, 4321); // invalidates the cached entry
|
||||
_ = master.ReadHoldingRegisters(1, isolatedRegister, 1); // must miss again
|
||||
}
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
var resp = await httpClient.GetStringAsync(
|
||||
$"http://127.0.0.1:{adminPort}/status.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
||||
|
||||
backend.GetProperty("cacheHitCount").GetInt64()
|
||||
.ShouldBe(1, "the second read should hit the cache");
|
||||
backend.GetProperty("cacheMissCount").GetInt64()
|
||||
.ShouldBe(2, "first read primes the cache; third read misses because the write invalidated the entry");
|
||||
backend.GetProperty("cacheInvalidations").GetInt64()
|
||||
.ShouldBe(1, "the FC06 write must invalidate exactly one cache entry");
|
||||
}
|
||||
|
||||
// ── BCD-decoded bytes are cached ─────────────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_BcdDecodedBytesAreCached_NotRawBcd()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
||||
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
||||
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "5000";
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(300, TestContext.Current.CancellationToken);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] r1 = master.ReadHoldingRegisters(1, 1072, 1);
|
||||
ushort[] r2 = master.ReadHoldingRegisters(1, 1072, 1);
|
||||
ushort[] r3 = master.ReadHoldingRegisters(1, 1072, 1);
|
||||
|
||||
r1[0].ShouldBe((ushort)1234, "first read must be BCD-decoded");
|
||||
r2[0].ShouldBe((ushort)1234, "second read (cache hit) must return decoded 1234, not raw BCD 0x1234");
|
||||
r3[0].ShouldBe((ushort)1234, "third read (cache hit) must return decoded 1234");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Frozen;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Phase-11 cache wiring inside the multiplexer, exercised against a stub backend with
|
||||
/// deterministic response timing. Stub-backend tests are the "is the cache wired correctly"
|
||||
/// proof — they cover behaviour the simulator-backed E2E suite cannot exercise reliably
|
||||
/// (true concurrent reads through the cache; cross-PLC isolation).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ResponseCacheMultiplexerTests
|
||||
{
|
||||
// ── Frame helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadExactAsync(Socket s, int count, CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[count];
|
||||
int read = 0;
|
||||
while (read < count)
|
||||
{
|
||||
int n = await s.ReceiveAsync(buf.AsMemory(read, count - read), SocketFlags.None, ct);
|
||||
if (n == 0) throw new IOException("EOF");
|
||||
read += n;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadOneFrameAsync(Socket s, CancellationToken ct)
|
||||
{
|
||||
var header = await ReadExactAsync(s, 7, ct);
|
||||
ushort length = (ushort)((header[4] << 8) | header[5]);
|
||||
int bodyLen = length - 1;
|
||||
var body = bodyLen > 0 ? await ReadExactAsync(s, bodyLen, ct) : Array.Empty<byte>();
|
||||
var frame = new byte[7 + bodyLen];
|
||||
Buffer.BlockCopy(header, 0, frame, 0, 7);
|
||||
if (bodyLen > 0) Buffer.BlockCopy(body, 0, frame, 7, bodyLen);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static byte[] BuildFc03(ushort txId, ushort start, ushort qty, byte unit = 1)
|
||||
=> [
|
||||
(byte)(txId >> 8), (byte)(txId & 0xFF),
|
||||
0x00, 0x00,
|
||||
0x00, 0x06,
|
||||
unit, 0x03,
|
||||
(byte)(start >> 8), (byte)(start & 0xFF),
|
||||
(byte)(qty >> 8), (byte)(qty & 0xFF),
|
||||
];
|
||||
|
||||
private static byte[] BuildFc06(ushort txId, ushort addr, ushort value, byte unit = 1)
|
||||
=> [
|
||||
(byte)(txId >> 8), (byte)(txId & 0xFF),
|
||||
0x00, 0x00,
|
||||
0x00, 0x06,
|
||||
unit, 0x06,
|
||||
(byte)(addr >> 8), (byte)(addr & 0xFF),
|
||||
(byte)(value >> 8), (byte)(value & 0xFF),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Stub backend that responds immediately with a configurable register value. Records
|
||||
/// every backend request it receives so the test can count round-trips.
|
||||
/// </summary>
|
||||
private sealed class StubBackend : IAsyncDisposable
|
||||
{
|
||||
public int Port { get; }
|
||||
public int RequestCount => _requestCount;
|
||||
public ushort RegisterValue { get; set; } = 0x1234;
|
||||
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _tasks = new();
|
||||
private int _requestCount;
|
||||
|
||||
public StubBackend(int port)
|
||||
{
|
||||
Port = port;
|
||||
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||
_listener.Start();
|
||||
_ = AcceptLoop();
|
||||
}
|
||||
|
||||
private async Task AcceptLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var s = await _listener.AcceptSocketAsync(_cts.Token);
|
||||
var t = Task.Run(() => HandleAsync(s));
|
||||
lock (_tasks) _tasks.Add(t);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async Task HandleAsync(Socket s)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var req = await ReadOneFrameAsync(s, _cts.Token);
|
||||
if (req.Length < 8) break;
|
||||
Interlocked.Increment(ref _requestCount);
|
||||
|
||||
ushort txId = (ushort)((req[0] << 8) | req[1]);
|
||||
byte unit = req[6];
|
||||
byte fc = req[7];
|
||||
|
||||
byte[] response;
|
||||
if (fc == 0x03 || fc == 0x04)
|
||||
{
|
||||
ushort qty = (ushort)((req[10] << 8) | req[11]);
|
||||
int byteCount = qty * 2;
|
||||
response = new byte[7 + 2 + byteCount];
|
||||
response[0] = (byte)(txId >> 8);
|
||||
response[1] = (byte)(txId & 0xFF);
|
||||
response[2] = 0; response[3] = 0;
|
||||
ushort len = (ushort)(1 + 2 + byteCount);
|
||||
response[4] = (byte)(len >> 8);
|
||||
response[5] = (byte)(len & 0xFF);
|
||||
response[6] = unit;
|
||||
response[7] = fc;
|
||||
response[8] = (byte)byteCount;
|
||||
for (int i = 0; i < qty; i++)
|
||||
{
|
||||
response[9 + i * 2] = (byte)(RegisterValue >> 8);
|
||||
response[9 + i * 2 + 1] = (byte)(RegisterValue & 0xFF);
|
||||
}
|
||||
}
|
||||
else if (fc == 0x06)
|
||||
{
|
||||
ushort addr = (ushort)((req[8] << 8) | req[9]);
|
||||
ushort val = (ushort)((req[10] << 8) | req[11]);
|
||||
response = new byte[12];
|
||||
response[0] = (byte)(txId >> 8);
|
||||
response[1] = (byte)(txId & 0xFF);
|
||||
response[2] = 0; response[3] = 0;
|
||||
response[4] = 0; response[5] = 6;
|
||||
response[6] = unit; response[7] = 0x06;
|
||||
response[8] = (byte)(addr >> 8); response[9] = (byte)(addr & 0xFF);
|
||||
response[10] = (byte)(val >> 8); response[11] = (byte)(val & 0xFF);
|
||||
}
|
||||
else { break; }
|
||||
|
||||
await s.SendAsync(response, SocketFlags.None, _cts.Token);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally { try { s.Dispose(); } catch { } }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
try { _listener.Stop(); } catch { }
|
||||
Task[] snap;
|
||||
lock (_tasks) snap = _tasks.ToArray();
|
||||
try { await Task.WhenAll(snap).WaitAsync(TimeSpan.FromSeconds(2)); } catch { }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static PerPlcContext MakeContext(string name, ResponseCache? cache, params BcdTag[] tags)
|
||||
{
|
||||
var frozen = tags.ToDictionary(t => t.Address).ToFrozenDictionary();
|
||||
var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
|
||||
return new PerPlcContext
|
||||
{
|
||||
PlcName = name,
|
||||
TagMap = map,
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = NullLogger.Instance,
|
||||
Cache = cache,
|
||||
};
|
||||
}
|
||||
|
||||
private static PlcMultiplexer BuildMux(PlcOptions plc, PerPlcContext ctx, bool coalescingEnabled = true)
|
||||
{
|
||||
return new PlcMultiplexer(
|
||||
plc, new ConnectionOptions(),
|
||||
new BcdPduPipeline(),
|
||||
ctx,
|
||||
NullLogger<PlcMultiplexer>.Instance,
|
||||
backendConnectPipeline: null,
|
||||
coalescingOptions: () => new ReadCoalescingOptions { Enabled = coalescingEnabled, MaxParties = 32 });
|
||||
}
|
||||
|
||||
private static async Task<(Socket client, UpstreamPipe pipe, TcpListener proxyListener)>
|
||||
ConnectClientAsync(PlcMultiplexer mux, string plcName)
|
||||
{
|
||||
int proxyPort = PickFreePort();
|
||||
var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
|
||||
proxyListener.Start();
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
|
||||
{ NoDelay = true };
|
||||
await client.ConnectAsync(IPAddress.Loopback, proxyPort);
|
||||
var upstream = await proxyListener.AcceptSocketAsync();
|
||||
var pipe = new UpstreamPipe(upstream, plcName, NullLogger.Instance);
|
||||
_ = Task.Run(() => mux.StartPipeAsync(pipe, CancellationToken.None));
|
||||
return (client, pipe, proxyListener);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SecondRead_OfSameKey_WithinTtl_HitsCache_NoSecondBackendRoundTrip()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
|
||||
// 16-bit BCD tag at address 100 with 5 s TTL.
|
||||
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, ctx);
|
||||
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// First read — miss, hits backend.
|
||||
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
var r1 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
((ushort)((r1[0] << 8) | r1[1])).ShouldBe((ushort)0x0001);
|
||||
|
||||
// Second read same key — hit, no second round-trip.
|
||||
await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
|
||||
var r2 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
((ushort)((r2[0] << 8) | r2[1])).ShouldBe((ushort)0x0002,
|
||||
"the cache hit must restore the requesting client's original TxId");
|
||||
|
||||
backend.RequestCount.ShouldBe(1, "the second read must be served from the cache");
|
||||
var snap = ctx.Counters.Snapshot();
|
||||
snap.CacheHitCount.ShouldBe(1);
|
||||
snap.CacheMissCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
c.Dispose();
|
||||
await p.DisposeAsync();
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BcdDecodedBytes_AreCached_NotRawBcd()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort) { RegisterValue = 0x1234 }; // raw BCD nibbles
|
||||
|
||||
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
|
||||
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, ctx);
|
||||
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
var r1 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
// Response: [..mbap..][0x03][byteCount=2][hi][lo]
|
||||
ushort decoded1 = (ushort)((r1[9] << 8) | r1[10]);
|
||||
decoded1.ShouldBe((ushort)1234, "first read must be BCD-decoded by the rewriter");
|
||||
|
||||
// Now read again — must be served from cache and still show 1234 (not 0x1234).
|
||||
await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
|
||||
var r2 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
ushort decoded2 = (ushort)((r2[9] << 8) | r2[10]);
|
||||
decoded2.ShouldBe((ushort)1234,
|
||||
"cache must store POST-rewriter bytes — hits must not re-decode and must not return raw BCD");
|
||||
backend.RequestCount.ShouldBe(1, "the second read must be served from the cache");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c.Dispose();
|
||||
await p.DisposeAsync();
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CacheHit_ShortCircuits_Coalescing()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
|
||||
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, ctx, coalescingEnabled: true);
|
||||
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Prime the cache.
|
||||
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
|
||||
// Subsequent read must hit cache; coalescing miss-counter must NOT increment
|
||||
// (cache short-circuited before the coalescing path).
|
||||
long missBefore = ctx.Counters.Snapshot().CoalescedMissCount;
|
||||
long hitBefore = ctx.Counters.Snapshot().CoalescedHitCount;
|
||||
|
||||
await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
|
||||
var snap = ctx.Counters.Snapshot();
|
||||
snap.CacheHitCount.ShouldBe(1, "second read must hit cache");
|
||||
(snap.CoalescedMissCount - missBefore).ShouldBe(0,
|
||||
"cache hit must short-circuit the coalescing path entirely — no Miss recorded");
|
||||
(snap.CoalescedHitCount - hitBefore).ShouldBe(0,
|
||||
"cache hit must short-circuit the coalescing path entirely — no Hit recorded");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c.Dispose();
|
||||
await p.DisposeAsync();
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fc06Write_InvalidatesOverlappingCachedRead()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
|
||||
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, ctx);
|
||||
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Cache the read.
|
||||
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
cache.Count.ShouldBe(1, "first read must populate the cache");
|
||||
|
||||
// Write to address 100 — must invalidate the cached read.
|
||||
await c.SendAsync(BuildFc06(0x0002, 100, 1234), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
|
||||
// Cache must be empty now.
|
||||
cache.Count.ShouldBe(0, "write to a cached address must invalidate the entry");
|
||||
ctx.Counters.Snapshot().CacheInvalidations.ShouldBe(1);
|
||||
|
||||
// A subsequent read must miss the cache.
|
||||
await c.SendAsync(BuildFc03(0x0003, 100, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
backend.RequestCount.ShouldBe(3, "two reads (one miss, one post-invalidate) + one write = 3 backend round-trips");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c.Dispose();
|
||||
await p.DisposeAsync();
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonOverlappingWrite_DoesNotInvalidate()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
|
||||
var ctx = MakeContext("PLC1", cache,
|
||||
BcdTag.Create(100, 16, cacheTtlMs: 5000),
|
||||
BcdTag.Create(200, 16, cacheTtlMs: 5000));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, ctx);
|
||||
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Cache the read at 100.
|
||||
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
|
||||
// Write to address 200 — distinct register; the cached [100..101) must remain.
|
||||
await c.SendAsync(BuildFc06(0x0002, 200, 7), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
|
||||
cache.Count.ShouldBe(1, "a disjoint write must not invalidate the cached read");
|
||||
|
||||
// Second read on 100 must hit cache.
|
||||
await c.SendAsync(BuildFc03(0x0003, 100, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
ctx.Counters.Snapshot().CacheHitCount.ShouldBe(1);
|
||||
backend.RequestCount.ShouldBe(2, "first read + write — second read served from cache");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c.Dispose();
|
||||
await p.DisposeAsync();
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiTagRange_AnyZeroTtl_DisablesCachingForWholeRead()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
|
||||
// Two tags in the read range [100..102): tag 100 has a TTL, tag 101 does not.
|
||||
var ctx = MakeContext("PLC1", cache,
|
||||
BcdTag.Create(100, 16, cacheTtlMs: 1000),
|
||||
BcdTag.Create(101, 16, cacheTtlMs: 0));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, ctx);
|
||||
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Two identical reads — both should hit the backend because tag 101 disables
|
||||
// caching for the whole [100..102) range.
|
||||
await c.SendAsync(BuildFc03(0x0001, 100, 2), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
|
||||
await c.SendAsync(BuildFc03(0x0002, 100, 2), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
|
||||
backend.RequestCount.ShouldBe(2,
|
||||
"any TTL=0 in a multi-tag range must disable caching for the whole read");
|
||||
ctx.Counters.Snapshot().CacheHitCount.ShouldBe(0);
|
||||
ctx.Counters.Snapshot().CacheMissCount.ShouldBe(0,
|
||||
"reads with effective TTL = 0 must not increment either cache counter");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c.Dispose();
|
||||
await p.DisposeAsync();
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UncachedReads_BehaveIdentically_ToPhase10()
|
||||
{
|
||||
// Regression guard: PerPlcContext with Cache = null must behave byte-identically
|
||||
// to Phase 10 — every FC03 read produces a backend round-trip (coalescing aside).
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
// No cache on the context — Cache = null.
|
||||
var ctx = MakeContext("PLC1", cache: null);
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, ctx);
|
||||
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Three sequential identical reads — each hits the backend (no coalescing
|
||||
// window with sequential reads, no cache wired).
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await c.SendAsync(BuildFc03((ushort)(i + 1), 100, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
backend.RequestCount.ShouldBe(3,
|
||||
"without a cache, every read must hit the backend (Phase-10 behaviour)");
|
||||
var snap = ctx.Counters.Snapshot();
|
||||
snap.CacheHitCount.ShouldBe(0);
|
||||
snap.CacheMissCount.ShouldBe(0,
|
||||
"cache counters must remain at zero when no cache is wired");
|
||||
snap.CoalescedMissCount.ShouldBe(3,
|
||||
"every FC03 read must increment CoalescedMissCount per the Phase-10 contract");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c.Dispose();
|
||||
await p.DisposeAsync();
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedBackendConnect_OnFirstRead_DoesNotPreventLaterCacheHits_IfCachePrePopulated()
|
||||
{
|
||||
// Edge case from the design contract: a cache hit short-circuits backend
|
||||
// connection establishment. We pre-populate the cache by direct Set, then probe a
|
||||
// cache hit while the backend is unreachable.
|
||||
int unreachable = PickFreePort(); // listener never started on this port
|
||||
|
||||
using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
|
||||
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 60_000));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = unreachable };
|
||||
|
||||
// Pre-populate the cache with a synthesised response PDU body. This is what the
|
||||
// backend reader would have stored after BCD-decoding.
|
||||
var key = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 1);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
byte[] cachedPdu = [0x03, 0x02, 0x04, 0xD2]; // FC=03, byteCount=2, regValue=0x04D2 (decimal 1234)
|
||||
cache.Set(key, new CacheEntry(cachedPdu, now, now.AddSeconds(60), cachedPdu.Length, 0));
|
||||
|
||||
await using var mux = BuildMux(plc, ctx);
|
||||
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// The cache check runs BEFORE EnsureBackendConnectedAsync, so we should get a
|
||||
// response even though the backend is unreachable.
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(800));
|
||||
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None, deadline.Token);
|
||||
var r1 = await ReadOneFrameAsync(c, deadline.Token);
|
||||
((ushort)((r1[0] << 8) | r1[1])).ShouldBe((ushort)0x0001,
|
||||
"cache hits must serve even when the backend is unreachable");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c.Dispose();
|
||||
await p.DisposeAsync();
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -464,7 +464,20 @@ public sealed class PlcMultiplexerTests
|
||||
sw.Stop();
|
||||
sw.ElapsedMilliseconds.ShouldBeLessThan(2000, "cascade should propagate quickly");
|
||||
|
||||
ctx.Counters.Snapshot().BackendDisconnectCascades.ShouldBeGreaterThanOrEqualTo(3);
|
||||
// Poll briefly for the cascade counter — there is an inherent scheduling gap
|
||||
// between "upstream socket EOF observed" (WaitForCloseAsync returns) and "the
|
||||
// multiplexer's TearDownBackendAsync increments the counter after awaiting
|
||||
// every pipe.DisposeAsync". This poll absorbs that scheduling jitter without
|
||||
// weakening the assertion's semantics — the counter MUST reach 3 (or more)
|
||||
// because all three upstream pipes were attached when the cascade fired.
|
||||
long cascades = 0;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
cascades = ctx.Counters.Snapshot().BackendDisconnectCascades;
|
||||
if (cascades >= 3) break;
|
||||
await Task.Delay(20, TestContext.Current.CancellationToken);
|
||||
}
|
||||
cascades.ShouldBeGreaterThanOrEqualTo(3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user