a2dba4bd07
When two or more upstream clients send the same FC03/FC04 read while a matching request is already in flight on the same PLC's multiplexed backend socket, attach the late arrivals to the existing InFlightRequest .InterestedParties list instead of opening a second backend round-trip. The single backend response fans out to every attached party with each party's original MBAP TxId restored individually. Zero post-response staleness — coalescing operates entirely within the in-flight window (microseconds to ~10 ms typical); the proxy is NOT a cache layer. Headline mechanism: - New record struct CoalescingKey(UnitId, Fc, StartAddress, Qty) keys the per-PLC InFlightByKeyMap. FC03 and FC04 are separate Modbus tables and never share a key; different unit IDs never coalesce; writes (FC06/FC16) bypass the coalescing path entirely. - InFlightByKeyMap uses a simple lock around a Dictionary; atomic TryAttachOrCreate either appends a new party to the in-flight request's mutable List<InterestedParty> or invokes a factory to build a fresh entry. Per-entry MaxParties cap (default 32) bounds fan-out cost; past the cap, the next arrival opens a new entry. - PlcMultiplexer.OnUpstreamFrameAsync takes the coalescing path for FC03/FC04 when Mbproxy.Resilience.ReadCoalescing.Enabled. The factory closure does the Phase-9 work (allocate TxId, add to CorrelationMap); the channel send happens AFTER returning from TryAttachOrCreate so the map lock is not held across the async send. - Response fan-out in RunBackendReaderAsync removes the entry from InFlightByKeyMap before iterating InterestedParties, ensuring no concurrent attach can mutate the list during iteration. - Cascade + watchdog paths also drain the key map so a stale entry cannot outlive its backend round-trip. Counter accounting balance (per snapshot): CoalescedHitCount + CoalescedMissCount equals total FC03 + FC04 requests since startup. Even with coalescing disabled, every read still bumps Miss so dashboard math stays balanced. New surface (additive only): - src/Mbproxy/Proxy/Multiplexing/CoalescingKey.cs - src/Mbproxy/Proxy/Multiplexing/InFlightByKeyMap.cs - src/Mbproxy/Proxy/Multiplexing/CoalescingLogEvents.cs - ReadCoalescingOptions on ResilienceOptions - CoalescedHitCount / CoalescedMissCount / CoalescedResponseToDeadUpstream counters surfaced on /status.json per PLC and as a compact "Coal" cell on the HTML status page. Phase 9 test patch: TwoUpstreams_ProxyTxIds_AreDistinct_OnTheWire previously read the same register from both clients (which now coalesces). Patched to read two different addresses so the test still proves distinct backend TxIds without violating the coalescing contract. Tests added: 24 new (19 unit + 5 E2E): - CoalescingKeyTests (5) - InFlightByKeyMapTests (6, includes concurrent stress) - ReadCoalescingTests (8, stub-backend with deterministic delay) - ReadCoalescingE2ETests (5, pymodbus simulator; coalescing-active during overlap is proven against the stub, not the sim, due to pymodbus 3.13's known concurrent-frame bug) Total: 325 tests passing (282 unit + 43 E2E). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
3.4 KiB
C#
87 lines
3.4 KiB
C#
using Mbproxy.Proxy.Multiplexing;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
|
|
|
/// <summary>
|
|
/// Equality + hash-distribution coverage for the Phase-10 <see cref="CoalescingKey"/>
|
|
/// record struct. The key is the load-bearing primitive of read coalescing: bad equality
|
|
/// would either cause unrelated requests to share a backend round-trip (correctness loss)
|
|
/// or prevent legitimate same-key requests from coalescing (performance loss).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class CoalescingKeyTests
|
|
{
|
|
[Fact]
|
|
public void Equality_OnIdenticalKeys_ReturnsTrue()
|
|
{
|
|
var a = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
|
var b = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
|
|
|
a.ShouldBe(b, "identical keys must compare equal");
|
|
a.GetHashCode().ShouldBe(b.GetHashCode(), "identical keys must hash to the same bucket");
|
|
}
|
|
|
|
[Fact]
|
|
public void Equality_OnDifferentFc_ReturnsFalse()
|
|
{
|
|
// FC03 (Read Holding Registers) and FC04 (Read Input Registers) name DIFFERENT
|
|
// Modbus tables even for the same address. Coalescing them would deliver wrong
|
|
// data.
|
|
var fc03 = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
|
var fc04 = new CoalescingKey(UnitId: 1, Fc: 0x04, StartAddress: 100, Qty:4);
|
|
|
|
fc03.ShouldNotBe(fc04, "FC03 and FC04 keys must never coalesce");
|
|
}
|
|
|
|
[Fact]
|
|
public void Equality_OnDifferentUnitId_ReturnsFalse()
|
|
{
|
|
// Different unit IDs typically address different PLC personalities behind a shared
|
|
// socket (multi-drop / gateway-backed setups). Never coalesce across them.
|
|
var u1 = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
|
var u2 = new CoalescingKey(UnitId: 2, Fc: 0x03, StartAddress: 100, Qty:4);
|
|
|
|
u1.ShouldNotBe(u2, "different unit IDs must never coalesce");
|
|
}
|
|
|
|
[Fact]
|
|
public void Equality_OnDifferentQty_ReturnsFalse()
|
|
{
|
|
var read1 = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:1);
|
|
var read4 = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
|
|
|
read1.ShouldNotBe(read4, "different qty must not coalesce — response register count differs");
|
|
}
|
|
|
|
[Fact]
|
|
public void HashCode_DistributionSanity()
|
|
{
|
|
// Build 10,000 keys at random V-memory-ish addresses and bucket the low byte of
|
|
// GetHashCode. A reasonable hash should spread fairly evenly across 256 buckets.
|
|
// Threshold: no single bucket holds > 5% of total (well above ideal 1/256 = 0.4%).
|
|
const int Count = 10_000;
|
|
var rng = new Random(17);
|
|
var buckets = new int[256];
|
|
|
|
for (int i = 0; i < Count; i++)
|
|
{
|
|
ushort start = (ushort)rng.Next(0, 4096); // 12-bit V-memory space
|
|
ushort qty = (ushort)rng.Next(1, 128);
|
|
byte unit = (byte)rng.Next(0, 4);
|
|
byte fc = rng.Next(2) == 0 ? (byte)0x03 : (byte)0x04;
|
|
|
|
int bucket = new CoalescingKey(unit, fc, start, qty).GetHashCode() & 0xFF;
|
|
buckets[bucket]++;
|
|
}
|
|
|
|
int max = 0;
|
|
for (int i = 0; i < buckets.Length; i++) if (buckets[i] > max) max = buckets[i];
|
|
|
|
int ceiling = Count * 5 / 100;
|
|
max.ShouldBeLessThanOrEqualTo(ceiling,
|
|
$"hash distribution is uneven — the busiest bucket holds {max} > {ceiling} keys");
|
|
}
|
|
}
|