using System.Threading.Channels; using Grpc.Core; using ZB.MOM.WW.LocalDb.Contracts; namespace ZB.MOM.WW.LocalDb.Replication.Internal; /// /// Adapts a gRPC duplex stream (an inbound reader + an outbound writer) to the transport-agnostic /// a runs over. Both the passive service and the /// initiating client build one of these over their respective stream halves. /// internal static class GrpcSyncDuplex { /// /// Inbound messages buffered between the transport and the session's receive loop. /// /// /// /// Why bounded at all. An unbounded inbox makes this process's memory a function of what /// the PEER decides to send. The worst case is not hypothetical: a full snapshot streams every /// row of every registered table, and both sides of a pair that enabled replication late /// snapshot each other simultaneously — so an unbounded inbox holds an entire peer dataset (an /// event log at its storage cap, say) in memory on both nodes at once. Bounding it converts that /// into backpressure: the pump stops draining the gRPC stream, the HTTP/2 flow-control window /// closes, and the SENDER slows down. /// /// /// Why 64. Memory here is bounded by roughly capacity x /// (the sender's own per-message budget), so the /// ceiling is a configured number rather than a peer-controlled one. 64 is four times the /// sender's outbound data-lane depth, so a healthy stream never reaches the bound and pays /// nothing; it engages only when this node is genuinely slower than the peer for a sustained /// burst, which is exactly when backpressure is wanted. Lower MaxBatchBytes to lower the /// ceiling. /// /// /// What makes bounding safe. Backpressure on the inbox deadlocks any design where both /// peers can stop draining their inbox at the same time — mutual snapshots were exactly that /// case while the send ran inside the handshake. therefore starts its /// receive loop BEFORE streaming a snapshot it owes, so the inbox is drained continuously for /// the whole life of the session; see the remarks on its snapshot phase. /// /// internal const int InboxCapacity = 64; /// /// Wires into a bounded inbox channel (see /// ) drained by a background loop, and exposes /// as the duplex send. Returns the duplex plus the reader loop's task /// so the caller can observe it. On clean stream end the inbox channel is completed; on a reader /// fault it is completed WITH the exception — the session's handshake and receive phases both /// await the inbox, so terminating it is what unblocks them when the call dies (they would /// otherwise hang on a dead stream). /// public static (SyncDuplex Duplex, Task ReaderTask) Create( IAsyncStreamReader reader, IAsyncStreamWriter writer, CancellationToken ct) { var inbox = Channel.CreateBounded(new BoundedChannelOptions(InboxCapacity) { // Wait, never Drop: every inbound message is protocol state (a delta the peer will not // resend in-session, a snapshot frame, an ack). Dropping one is silent divergence. FullMode = BoundedChannelFullMode.Wait, SingleWriter = true, }); var readerTask = PumpInboundAsync(reader, inbox.Writer, ct); var duplex = new SyncDuplex { // A plain delegate is safe: the session's single writer task is the only caller of Send, // so this stream writer never sees the concurrent writes IServerStreamWriter / // IClientStreamWriter both prohibit. The gRPC WriteAsync(message, ct) overload honors the // token even when flow-control blocked, satisfying SyncDuplex.Send's cancellation contract. Send = (message, token) => writer.WriteAsync(message, token), Inbox = inbox.Reader, }; return (duplex, readerTask); } private static async Task PumpInboundAsync( IAsyncStreamReader reader, ChannelWriter inbox, CancellationToken ct) { try { while (await reader.MoveNext(ct)) // WriteAsync, not TryWrite: on a FULL bounded channel TryWrite returns false and the // message is silently lost. Awaiting here is the backpressure — it stops draining the // gRPC stream, which closes the peer's flow-control window. await inbox.WriteAsync(reader.Current, ct); } catch (Exception ex) { // Fault the inbox so the session's inbox awaiters observe the transport failure (a // cancelled call surfaces here as OperationCanceledException). Swallowed on this task: // the failure travels to the session through the channel, not by faulting the reader task. inbox.TryComplete(ex); return; } inbox.TryComplete(); } }