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();
}
}