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>
344 lines
16 KiB
C#
344 lines
16 KiB
C#
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");
|
|
}
|
|
}
|