# Read Coalescing In-flight read coalescing collapses identical FC03/FC04 requests that arrive while a backend response is still in flight onto a single backend round-trip, then fans the single response out to every attached upstream client with each client's original MBAP transaction ID restored. ## What Coalescing Does When two upstream clients each send `(unitId=1, FC=3, start=100, qty=10)` within the in-flight window of a previously-routed request, the second arrival attaches to the existing `InFlightRequest` instead of opening a new proxy transaction ID and a second backend round-trip. The PLC's reply is delivered to both upstream pipes; each pipe sees its own MBAP `TxId` restored on its copy of the response. The value each upstream sees is the same value an uncoalesced request would have returned within the PLC's own scan-time precision (microseconds to ~10 ms typical window). Coalescing is not a cache layer — once the response fans out, the in-flight entry dies, and a subsequent identical read opens a fresh round-trip. Bounded-staleness caching is a separate feature; see [`./ResponseCache.md`](./ResponseCache.md). ## The Coalescing Key The lookup tuple is defined in `CoalescingKey.cs`: ```csharp internal readonly record struct CoalescingKey( byte UnitId, byte Fc, ushort StartAddress, ushort Qty); ``` Record-struct value equality drives the dictionary lookup in `InFlightByKeyMap`. Several axes never coalesce, by design: - **Function code.** FC03 (Read Holding Registers) and FC04 (Read Input Registers) read different Modbus tables on the device. Their responses are not interchangeable, so they do not share a key even at the same address. - **Unit ID.** Distinct unit IDs behind a shared socket address different Modbus personalities — coalescing never crosses a unit boundary. - **Start address and quantity.** Two reads with overlapping but non-identical ranges never coalesce. Range-overlap logic exists for cache invalidation, not for coalescing. ## Eligibility Only FC03 and FC04 enter the coalescing path. The multiplexer's request handler parses the function code from the inbound PDU and gates on `fcByte is 0x03 or 0x04` before consulting `_inFlightByKey`. - FC06 (Write Single Register) and FC16 (Write Multiple Registers) are non-idempotent on BCD tags — a second send would write the value twice. Writes bypass coalescing entirely and always take the one-round-trip path. - Exception responses do not coalesce. Each upstream sees an exception delivered against its own MBAP `TxId` through the normal correlation map fan-out; there is no special exception-deduplication path. ## The InterestedParties Seam The data shape that powers fan-out lives on `InFlightRequest`: ```csharp internal sealed record InFlightRequest( byte UnitId, byte Fc, ushort StartAddress, ushort Qty, IReadOnlyList InterestedParties, DateTimeOffset SentAtUtc, int ResolvedCacheTtlMs = 0); internal sealed record InterestedParty(UpstreamPipe Pipe, ushort OriginalTxId); ``` Each `InterestedParty` records the upstream pipe to deliver the response to and the original MBAP `TxId` that pipe sent. The backend reader iterates this list, patches each party's `OriginalTxId` into a per-party copy of the response frame, and hands the frame to `party.Pipe.SendResponseAsync`. ### Multi-writer multi-reader safety The list typed as `IReadOnlyList` on the public surface is in fact a mutable `List` underneath. `InFlightByKeyMap` serialises every state mutation under a single `object` lock: - `TryAttachOrCreate` looks up the key, casts the existing `InterestedParties` back to `List`, and appends the new party — all under the lock. - The backend reader calls `TryRemove(coalKey, out _)` **before** it iterates the parties list during fan-out. Once the key is gone from the map, no future attach can find it, so no further appends can occur. The reader's removal-before-iteration ordering is the load-bearing invariant. By the time fan-out reads the list, the list is effectively frozen — there is no other writer that can reach it. The watchdog timeout path observes the same protocol: it removes the coalescing key before it walks `req.InterestedParties` to deliver exception 0x0B. The reverse race (reader removes first, then a late attach arrives) is impossible by construction — `TryRemove` and `TryAttachOrCreate` both take the same map lock, so any late attach is serialised either entirely before the removal (and is part of the fan-out) or entirely after (and opens a fresh entry under a new factory call). ## MaxParties Cap `ResilienceOptions.cs` exposes the load-shedding cap: ```csharp public sealed class ReadCoalescingOptions { public bool Enabled { get; init; } = true; public int MaxParties { get; init; } = 32; } ``` `Mbproxy.Resilience.ReadCoalescing.MaxParties` defaults to 32. Inside `TryAttachOrCreate`, an existing entry is only extended when `existingList.Count < maxParties`; once the cap is hit, the next identical arrival falls through to the factory branch and opens a fresh in-flight entry (which means a fresh backend round-trip). The cap bounds two costs: - **Fan-out cost per entry** at O(MaxParties). The backend reader's per-party copy-and-patch loop runs at most `MaxParties` times for any single response. - **Backend reader latency under pile-on.** A single pathologically popular read (every HMI hitting the same tag at the same second) cannot stretch one fan-out arbitrarily long. ## Hot-Reloadable On/Off `Mbproxy.Resilience.ReadCoalescing.Enabled` defaults to `true`. The multiplexer holds a `Func` accessor that production binds to `() => optionsMonitor.CurrentValue.Resilience.ReadCoalescing`, so a hot-reload of `appsettings.json` propagates immediately on the next inbound PDU. Flipping `Enabled` to `false` at runtime does not disturb already-coalesced entries: existing fan-outs drain through the backend reader naturally. Subsequent FC03/FC04 requests skip the coalescing branch entirely and take the one-proxy-TxId-per-upstream-request path verbatim. The same accessor reads `MaxParties` per PDU, so an operator can raise or lower the cap without restarting the service. ## Lookup Order in the Multiplexer's Read Path `OnUpstreamFrameAsync` consults three tiers in fixed order for FC03/FC04: 1. **Cache** — if `_ctx.Cache` is wired and `_ctx.TagMap.ResolveCacheTtlMs` returns a positive TTL for the read range, the response cache is checked first. A hit short-circuits everything, including the `EnsureBackendConnectedAsync` call. See [`./ResponseCache.md`](./ResponseCache.md). 2. **Coalesce** — on a cache miss (or no cache configured), the request consults `_inFlightByKey` via `TryAttachOrCreate`. A hit attaches the new party to an in-flight peer and emits no backend traffic. 3. **Backend** — on a coalescing miss, the factory branch allocates a proxy `TxId` through `TxIdAllocator`, registers the entry in `CorrelationMap`, runs the BCD rewriter on the request PDU, and queues the frame onto the outbound channel. The order is load-bearing. Cache hits avoid both backend traffic **and** any coalescing-entry housekeeping. Coalescing hits avoid the backend but still incur a list-append and a fan-out. Backend round-trips are the most expensive of the three. ## Counter Accounting `PerPlcContext.Counters` exposes three coalescing-specific counters, all surfaced on the status page: - **`coalescedHitCount`** — increments inside `OnUpstreamFrameAsync` when `TryAttachOrCreate` returns `wasNew == false` (the request attached to an existing in-flight entry). - **`coalescedMissCount`** — increments when `wasNew == true`. The non-coalescing FC03/FC04 path also increments this counter when coalescing is disabled, so the identity `coalescedHitCount + coalescedMissCount == total FC03+FC04 requests since multiplexer construction` holds regardless of `Enabled`. - **`coalescedResponseToDeadUpstream`** — increments inside the backend reader's fan-out loop when a coalesced party's pipe has gone dead (`party.Pipe.IsAlive == false`) before the response landed. Only counted when the in-flight entry had more than one party — single-party dead-upstream skips are the normal Phase-9 behaviour and are silent. When `ReadCoalescing.Enabled == false`, `coalescedHitCount` remains zero and every FC03/FC04 read increments `coalescedMissCount`. Aggregate fleet metrics (hit ratio, requests per second) read directly from these counters; see [`../Operations/StatusPage.md`](../Operations/StatusPage.md). The Debug-level log events `mbproxy.coalesce.hit`, `mbproxy.coalesce.miss`, and `mbproxy.coalesce.dead_upstream` mirror each counter increment; see [`../Reference/LogEvents.md`](../Reference/LogEvents.md). ## Transparency Contract Preserved Each upstream client receives the same response shape it would have received from a one-to-one proxy: - **Original MBAP `TxId` restored.** The backend reader patches `outFrame[0..2]` with `party.OriginalTxId` for each party in the `InterestedParties` list. The proxy's internal TxId never reaches an upstream socket. - **BCD rewriter runs once.** `_pipeline.Process(ResponseToClient, ...)` fires exactly once against the shared backend response buffer. Cached rewriter context (start address, quantity) comes from the `InFlightRequest` that opened the round-trip. - **One-party fan-out reuses the buffer.** When `inFlight.InterestedParties.Count == 1`, the backend reader assigns the original `frame` reference to `outFrame` instead of cloning, saving the allocation. Multi-party fan-outs clone the frame per party so each can carry a distinct `TxId` without trampling its peers. Coalescing is invisible at the wire-protocol layer. An upstream client cannot tell whether its read was served by a fresh backend round-trip or by attaching to a peer's in-flight request — only the timing distribution changes. ## Related Documentation - [`./ConnectionModel.md`](./ConnectionModel.md) — multiplexer overview; the `InterestedParties` seam, `CorrelationMap`, and `TxIdAllocator` live here. - [`./ResponseCache.md`](./ResponseCache.md) — bounded-staleness cache that sits above coalescing in the lookup order; cache hits short-circuit coalescing entirely. - [`../Operations/StatusPage.md`](../Operations/StatusPage.md) — exposes `coalescedHitCount`, `coalescedMissCount`, and `coalescedResponseToDeadUpstream` per PLC and as fleet aggregates. - [`../Reference/LogEvents.md`](../Reference/LogEvents.md) — full `mbproxy.coalesce.*` event catalogue with event IDs. - [`../Operations/Configuration.md`](../Operations/Configuration.md) — binding for `Mbproxy.Resilience.ReadCoalescing.Enabled` and `MaxParties`, hot-reload semantics. - [`../Features/BcdRewriting.md`](../Features/BcdRewriting.md) — the rewriter that runs once on the shared response buffer before fan-out.