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>