1db900edef
Layers a per-PLC, per-tag response cache on top of Phase 10's coalescing.
Cache is OFF by default per tag (CacheTtlMs = 0); a fresh deployment with no
TTL config behaves identically to Phase 10. Operators opt tags in by setting
CacheTtlMs > 0 on a BcdTagOptions entry (or DefaultCacheTtlMs > 0 on a
PlcOptions entry), explicitly acknowledging the staleness window.
Cache lookup order: cache -> coalesce -> backend. A cache hit short-circuits
both Phase 10's coalescing path and Phase 9's backend send. Cache stores
POST-rewriter PDU bytes so hits never re-invoke the BCD rewriter. FC06/FC16
write responses invalidate every cached entry whose address range overlaps
the write (half-open interval math).
New types (Mbproxy.Proxy.Cache, all internal):
- CacheKey (record-struct, same shape as CoalescingKey but kept SEPARATE so
the two phases evolve independently).
- CacheEntry, ResponseCache (IDisposable; LRU + PeriodicTimer eviction
loop), CacheInvalidator (pure overlap matcher), CacheLogEvents (stable
mbproxy.cache.* names).
Multi-tag range TTL = min(TTLs); any tag with TTL = 0 in the range disables
caching for the whole read (conservative-by-design).
Options surface:
- BcdTagOptions.CacheTtlMs (nullable int; null = fall through to PLC default)
- PlcOptions.DefaultCacheTtlMs
- MbproxyOptions.Cache.{AllowLongTtl, MaxEntriesPerPlc, EvictionIntervalMs}
- TTL > 60_000 ms requires Cache.AllowLongTtl = true (reload validation).
Admin counters (Tier 1.8 + Tier 2 cache-memory KPIs from docs/kpi.md):
- CacheHitCount, CacheMissCount, CacheInvalidations on ProxyCounters.
- CacheEntryCount, CacheBytes via a new ICacheStatsProvider snapshot path.
- /status.json and the HTML page surface a new Cache cell per PLC row.
Hot-reload: any tag-list change to a PLC reseats the per-PLC context with a
fresh cache; the old cache is disposed inside ReplaceContextAsync. Per-tag
flush granularity is intentionally not implemented in v1.
PLCs with no cache-eligible tags (every resolved tag has CacheTtlMs = 0)
get Cache = null on the context and skip the eviction timer entirely, so
the no-cache path is byte-identical to Phase 10.
Tests (32 new unit + 5 new E2E = 37 new; suite now 314 unit + 48 E2E):
- CacheKeyTests, CacheEntryTests (records + boundary semantics).
- CacheInvalidatorTests: full overlap, both partials, adjacent-not-
overlapping, disjoint, different unit ID + auxiliary FC-filter / zero-qty.
- ResponseCacheTests: round-trip, lazy expiry, range invalidation,
unit-id filter, LRU bound, LRU access tracking, concurrent get/set,
dispose, clear, approximate-bytes accounting.
- ResponseCacheMultiplexerTests (stub-backend): hit short-circuits
coalescing, BCD-decoded bytes are cached not raw, FC06 invalidates
overlapping, non-overlapping write does not invalidate, multi-tag
TTL=min rule, regression-cache-disabled-by-default-is-Phase-10, hit
works even when backend unreachable.
- ResponseCacheE2ETests (pymodbus DL205 sim, sequential reads):
* Headline: 10 reads with TTL=1000 ms -> 9 hits, 1 miss, 1 backend trip.
* TTL expiry path with sleep > TTL.
* Write invalidation through the proxy on a scratch register.
* BCD-decoded bytes are cached, not raw BCD nibbles.
* Regression: Cache disabled by default -> behaviour byte-identical to
Phase 10.
Pre-existing flake hardened: BackendDisconnect_CascadesToAllUpstreams now
polls briefly for the cascade counter to absorb the inherent scheduling
gap between "upstream EOF observed" and "counter incremented inside
TearDownBackendAsync." Counter semantics unchanged.
Phase doc updated with implementation clarifications discovered during
this work (CacheKey kept separate from CoalescingKey, LastUsedTick is
long, FC06/FC16 startAddr/qty parsing extension, cache-pre-connect
short-circuit, write-invalidation only on successful responses).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
545 lines
23 KiB
C#
545 lines
23 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|