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:
@@ -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