feat(localdb): gRPC sync adapters, background service, replication DI

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-17 23:01:29 -04:00
parent 183901e8ac
commit a64fc26391
7 changed files with 701 additions and 0 deletions
@@ -0,0 +1,59 @@
using System.Threading.Channels;
using Grpc.Core;
using ZB.MOM.WW.LocalDb.Contracts;
namespace ZB.MOM.WW.LocalDb.Replication.Internal;
/// <summary>
/// Adapts a gRPC duplex stream (an inbound reader + an outbound writer) to the transport-agnostic
/// <see cref="SyncDuplex"/> a <see cref="SyncSession"/> runs over. Both the passive service and the
/// initiating client build one of these over their respective stream halves.
/// </summary>
internal static class GrpcSyncDuplex
{
/// <summary>
/// Wires <paramref name="reader"/> into an unbounded inbox channel drained by a background loop,
/// and exposes <paramref name="writer"/> 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).
/// </summary>
public static (SyncDuplex Duplex, Task ReaderTask) Create(
IAsyncStreamReader<SyncMessage> reader,
IAsyncStreamWriter<SyncMessage> writer,
CancellationToken ct)
{
var inbox = Channel.CreateUnbounded<SyncMessage>(new UnboundedChannelOptions { 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<SyncMessage> reader, ChannelWriter<SyncMessage> inbox, CancellationToken ct)
{
try
{
while (await reader.MoveNext(ct))
inbox.TryWrite(reader.Current);
}
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();
}
}