From 925623b878cf36f74e089edcad8ac49ca54d6d3e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 17 Jul 2026 22:26:03 -0400 Subject: [PATCH] feat(localdb): SyncSession protocol state machine (handshake fail-closed, bidirectional delta pump) --- .../Internal/SyncDuplex.cs | 18 + .../Internal/SyncSession.cs | 286 +++++++++++ .../SyncSessionTests.cs | 446 ++++++++++++++++++ 3 files changed, 750 insertions(+) create mode 100644 ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncDuplex.cs create mode 100644 ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSession.cs create mode 100644 ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SyncSessionTests.cs diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncDuplex.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncDuplex.cs new file mode 100644 index 0000000..a2a9043 --- /dev/null +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncDuplex.cs @@ -0,0 +1,18 @@ +using System.Threading.Channels; +using ZB.MOM.WW.LocalDb.Contracts; + +namespace ZB.MOM.WW.LocalDb.Replication.Internal; + +/// +/// The transport seam a runs over: an outbound send delegate plus an +/// inbound message reader. Task 11 adapts a gRPC duplex stream to this; tests wire two in-memory +/// pairs crosswise. may be called concurrently by the +/// session's pump and receive loops — the session serializes those calls, so an adapter's writer +/// only ever sees one send at a time. +/// +internal sealed class SyncDuplex +{ + public required Func Send { get; init; } + + public required ChannelReader Inbox { get; init; } +} diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSession.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSession.cs new file mode 100644 index 0000000..0aa796d --- /dev/null +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSession.cs @@ -0,0 +1,286 @@ +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.LocalDb.Contracts; +using ZB.MOM.WW.LocalDb.Hlc; +using ZB.MOM.WW.LocalDb.Internal; + +namespace ZB.MOM.WW.LocalDb.Replication.Internal; + +/// +/// The symmetric 2-node sync protocol as one state machine: both the gRPC service and the gRPC +/// client run this identical logic over a . It performs the fail-closed +/// handshake, then runs a bidirectional steady state — an outbound change pump and an inbound +/// apply/ack loop — until the token is cancelled or the stream ends. Local writes never block on +/// the peer; every failure is a typed exception that tears down both loops and propagates. +/// +internal sealed class SyncSession +{ + private const uint LibSchemaVersion = 1; + + private readonly SqliteLocalDb _db; + private readonly OplogStore _store; + private readonly LwwApplier _applier; + private readonly ReplicationOptions _options; + private readonly ILogger _logger; + private readonly Func _utcNow; + + // gRPC stream writers are not safe for concurrent writes; the pump and receive loops both send, + // so every outbound message goes through this gate to serialize them into one writer. + private readonly SemaphoreSlim _sendGate = new(1, 1); + + // Highest oplog seq handed to the wire this session; the pump reads strictly above it so a batch + // is never re-sent before its ack (the reliable ordered stream delivers exactly once in-session). + private long _sentThruSeq; + private long _peerAckedSeq; + + public SyncSession( + SqliteLocalDb db, OplogStore store, LwwApplier applier, ReplicationOptions options, ILogger logger, + Func? utcNow = null) + { + _db = db; + _store = store; + _applier = applier; + _options = options; + _logger = logger; + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); + } + + /// Invoked when the peer's HandshakeAck requests a snapshot before deltas. Task 12 fills this in. + public Func? SnapshotSender { get; set; } + + /// Invoked on an inbound SnapshotBegin. Task 12 fills this in. + public Func? SnapshotReceiver { get; set; } + + /// Highest seq the peer has acknowledged applying this session. + public long PeerAckedSeq => Interlocked.Read(ref _peerAckedSeq); + + /// The peer node's identity, once the handshake has completed. + public string? PeerNodeId { get; private set; } + + public async Task RunAsync(SyncDuplex duplex, CancellationToken ct) + { + var peerState = await _store.GetPeerStateAsync(ct); + + var localHandshake = BuildHandshake(peerState); + await SendAsync(duplex, new SyncMessage { Handshake = localHandshake }, ct); + + var firstInbound = await ReceiveOneAsync(duplex, ct); + if (firstInbound.MsgCase != SyncMessage.MsgOneofCase.Handshake) + throw new InvalidOperationException( + $"Replication protocol error: expected a Handshake as the first message, got {firstInbound.MsgCase}."); + var peerHandshake = firstInbound.Handshake; + + ValidateHandshake(localHandshake, peerHandshake); + + await _store.SetPeerNodeIdAsync(peerHandshake.NodeId, ct); + PeerNodeId = peerHandshake.NodeId; + _sentThruSeq = peerHandshake.LastAppliedRemoteSeq; + Interlocked.Exchange(ref _peerAckedSeq, peerHandshake.LastAppliedRemoteSeq); + + var snapshotRequired = await ComputeSnapshotRequiredAsync(peerHandshake, peerState, ct); + await SendAsync(duplex, new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = _db.NodeId, SnapshotRequired = snapshotRequired } }, ct); + + var peerAckMsg = await ReceiveOneAsync(duplex, ct); + if (peerAckMsg.MsgCase != SyncMessage.MsgOneofCase.HandshakeAck) + throw new InvalidOperationException( + $"Replication protocol error: expected a HandshakeAck, got {peerAckMsg.MsgCase}."); + + if (peerAckMsg.HandshakeAck.SnapshotRequired) + { + if (SnapshotSender is null) + throw new NotSupportedException("snapshot required but no snapshot sender configured"); + await SnapshotSender(duplex, ct); + } + + await RunSteadyStateAsync(duplex, ct); + } + + private async Task RunSteadyStateAsync(SyncDuplex duplex, CancellationToken ct) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); + var pump = PumpLoopAsync(duplex, linked.Token); + var receive = ReceiveLoopAsync(duplex, linked.Token); + + // Whichever loop completes first (fault or stream-end) drives teardown: cancel the sibling, + // drain it (swallowing only the cancellation we caused), then surface the primary outcome. + var finished = await Task.WhenAny(pump, receive); + linked.Cancel(); + var other = finished == pump ? receive : pump; + try { await other; } + catch (OperationCanceledException) { } + await finished; + } + + private async Task PumpLoopAsync(SyncDuplex duplex, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + var batch = await _store.ReadBatchAboveAsync(_sentThruSeq, _options.MaxBatchSize, ct); + if (batch.Count > 0) + { + var delta = new DeltaBatch(); + foreach (var entry in batch) + delta.Entries.Add(ToProto(entry)); + await SendAsync(duplex, new SyncMessage { DeltaBatch = delta }, ct); + _sentThruSeq = batch[^1].Seq; + continue; + } + + await Task.Delay(_options.FlushInterval, ct); + } + } + + private async Task ReceiveLoopAsync(SyncDuplex duplex, CancellationToken ct) + { + while (await duplex.Inbox.WaitToReadAsync(ct)) + { + while (duplex.Inbox.TryRead(out var msg)) + { + switch (msg.MsgCase) + { + case SyncMessage.MsgOneofCase.DeltaBatch: + await HandleDeltaBatchAsync(duplex, msg.DeltaBatch, ct); + break; + case SyncMessage.MsgOneofCase.DeltaAck: + var acked = msg.DeltaAck.AppliedThruSeq; + InterlockedMax(ref _peerAckedSeq, acked); + await _store.RecordPeerAckAsync(acked, ct); + break; + case SyncMessage.MsgOneofCase.SnapshotBegin: + if (SnapshotReceiver is null) + throw new NotSupportedException("snapshot received but no snapshot receiver configured"); + await SnapshotReceiver(msg.SnapshotBegin, duplex, ct); + break; + default: + throw new InvalidOperationException( + $"Replication protocol error: unexpected {msg.MsgCase} after the handshake."); + } + } + } + } + + private async Task HandleDeltaBatchAsync(SyncDuplex duplex, DeltaBatch batch, CancellationToken ct) + { + var entries = new List(batch.Entries.Count); + var maxHlc = 0L; + foreach (var e in batch.Entries) + { + entries.Add(FromProto(e)); + if (e.Hlc > maxHlc) maxHlc = e.Hlc; + } + + if (entries.Count > 0) + { + var maxHlcMs = HybridLogicalClock.PhysicalMs(maxHlc); + var driftLimitMs = _utcNow().Add(_options.MaxHlcDriftAhead).ToUnixTimeMilliseconds(); + if (maxHlcMs > driftLimitMs) + { + _logger.LogWarning( + "Inbound delta HLC physical time {HlcMs} ms exceeds local clock + MaxHlcDriftAhead ({LimitMs} ms).", + maxHlcMs, driftLimitMs); + if (_options.FailClosedOnDrift) + throw new InvalidOperationException( + $"Inbound HLC physical time {maxHlcMs} ms exceeds MaxHlcDriftAhead limit {driftLimitMs} ms; rejecting batch (fail-closed)."); + } + } + + var result = await _applier.ApplyBatchAsync(entries, ct); + await SendAsync(duplex, new SyncMessage { DeltaAck = new DeltaAck { AppliedThruSeq = result.AppliedThruSeq } }, ct); + } + + private Handshake BuildHandshake(PeerState peerState) + { + var handshake = new Handshake + { + NodeId = _db.NodeId, + LibSchemaVersion = LibSchemaVersion, + LastAppliedRemoteSeq = peerState.LastAppliedRemoteSeq, + }; + foreach (var table in _db.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal)) + handshake.Tables.Add(new TableDigest { TableName = table.Name, Digest = table.Digest }); + return handshake; + } + + private static void ValidateHandshake(Handshake local, Handshake peer) + { + var versionMismatch = local.LibSchemaVersion != peer.LibSchemaVersion; + + var localByName = local.Tables.ToDictionary(t => t.TableName, t => t.Digest, StringComparer.Ordinal); + var peerByName = peer.Tables.ToDictionary(t => t.TableName, t => t.Digest, StringComparer.Ordinal); + var mismatched = new SortedSet(StringComparer.Ordinal); + foreach (var name in localByName.Keys.Union(peerByName.Keys, StringComparer.Ordinal)) + if (!localByName.TryGetValue(name, out var ld) || !peerByName.TryGetValue(name, out var pd) || ld != pd) + mismatched.Add(name); + + if (!versionMismatch && mismatched.Count == 0) + return; + + var parts = new List(); + if (versionMismatch) + parts.Add($"lib_schema_version {local.LibSchemaVersion} != peer {peer.LibSchemaVersion}"); + if (mismatched.Count > 0) + parts.Add($"table schema mismatch on: {string.Join(", ", mismatched)}"); + + throw new LocalDbSchemaMismatchException( + "Replication handshake rejected (fail-closed): " + string.Join("; ", parts) + "."); + } + + private async Task ComputeSnapshotRequiredAsync(Handshake peerHandshake, PeerState peerState, CancellationToken ct) + { + if (peerState.NeedsSnapshot) + return true; + + var minRows = await _db.QueryAsync( + "SELECT COALESCE(MIN(seq), 0) FROM __localdb_oplog", static r => r.GetInt64(0), null, ct); + var minSeq = minRows[0]; + // minSeq 0 => oplog empty. A gap exists when the peer's applied watermark falls below the + // oldest seq we can still stream (pruning discarded everything at or below its horizon). + return minSeq > 0 && peerHandshake.LastAppliedRemoteSeq < minSeq - 1; + } + + private async Task SendAsync(SyncDuplex duplex, SyncMessage message, CancellationToken ct) + { + await _sendGate.WaitAsync(ct); + try { await duplex.Send(message, ct); } + finally { _sendGate.Release(); } + } + + private static async Task ReceiveOneAsync(SyncDuplex duplex, CancellationToken ct) + { + if (await duplex.Inbox.WaitToReadAsync(ct) && duplex.Inbox.TryRead(out var message)) + return message; + throw new InvalidOperationException( + "Replication protocol error: the peer closed the stream before completing the handshake."); + } + + private static OplogEntry ToProto(OplogEntryRecord entry) + { + var proto = new OplogEntry + { + Seq = entry.Seq, + TableName = entry.TableName, + PkJson = entry.PkJson, + Hlc = entry.Hlc, + NodeId = entry.NodeId, + IsTombstone = entry.IsTombstone, + }; + if (entry.RowJson is not null) + proto.RowJson = entry.RowJson; + return proto; + } + + private static OplogEntryRecord FromProto(OplogEntry entry) => + new(entry.Seq, entry.TableName, entry.PkJson, entry.HasRowJson ? entry.RowJson : null, + entry.Hlc, entry.NodeId, entry.IsTombstone); + + private static void InterlockedMax(ref long target, long value) + { + long current; + do + { + current = Interlocked.Read(ref target); + if (value <= current) + return; + } + while (Interlocked.CompareExchange(ref target, value, current) != current); + } +} diff --git a/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SyncSessionTests.cs b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SyncSessionTests.cs new file mode 100644 index 0000000..5ed8dd1 --- /dev/null +++ b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SyncSessionTests.cs @@ -0,0 +1,446 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.LocalDb.Contracts; +using ZB.MOM.WW.LocalDb.Internal; +using ZB.MOM.WW.LocalDb.Replication; +using ZB.MOM.WW.LocalDb.Replication.Internal; + +namespace ZB.MOM.WW.LocalDb.Tests; + +public sealed class SyncSessionTests : IDisposable +{ + private const string OrdersSql = "CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)"; + private static readonly TimeSpan RunTimeout = TimeSpan.FromSeconds(15); + + private readonly List _paths = []; + private readonly List _disposables = []; + + public void Dispose() + { + foreach (var d in _disposables) + d.Dispose(); + SqliteConnection_ClearAllPools(); + foreach (var p in _paths) + { + if (File.Exists(p)) File.Delete(p); + if (File.Exists(p + "-wal")) File.Delete(p + "-wal"); + if (File.Exists(p + "-shm")) File.Delete(p + "-shm"); + } + } + + private static void SqliteConnection_ClearAllPools() => Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + + private sealed record Side( + SqliteLocalDb Db, OplogStore Store, LwwApplier Applier, SyncSession Session); + + private async Task NewSideAsync( + string createSql = OrdersSql, + string table = "orders", + ReplicationOptions? options = null, + ILogger? logger = null, + Func? utcNow = null) + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db"); + _paths.Add(path); + var db = new SqliteLocalDb(new LocalDbOptions { Path = path }); + _disposables.Add(db); + await db.ExecuteAsync(createSql); + db.RegisterReplicated(table); + + options ??= new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20) }; + var store = new OplogStore(db, options, utcNow); + var applier = new LwwApplier(db, utcNow); + var session = new SyncSession(db, store, applier, options, logger ?? NullLogger.Instance, utcNow); + return new Side(db, store, applier, session); + } + + private static (SyncDuplex A, SyncDuplex B) DuplexPair() + { + var aToB = Channel.CreateUnbounded(); + var bToA = Channel.CreateUnbounded(); + var a = new SyncDuplex + { + Send = (m, ct) => aToB.Writer.WriteAsync(m, ct).AsTask(), + Inbox = bToA.Reader, + }; + var b = new SyncDuplex + { + Send = (m, ct) => bToA.Writer.WriteAsync(m, ct).AsTask(), + Inbox = aToB.Reader, + }; + return (a, b); + } + + private static (SyncDuplex Duplex, ChannelWriter ToSession, ChannelReader FromSession) ScriptedPeer() + { + var toSession = Channel.CreateUnbounded(); + var fromSession = Channel.CreateUnbounded(); + var duplex = new SyncDuplex + { + Send = (m, ct) => fromSession.Writer.WriteAsync(m, ct).AsTask(), + Inbox = toSession.Reader, + }; + return (duplex, toSession.Writer, fromSession.Reader); + } + + private static Handshake MatchingHandshake(SqliteLocalDb sessionDb, string peerNodeId, uint version = 1, long lastAppliedRemoteSeq = 0) + { + var h = new Handshake { NodeId = peerNodeId, LibSchemaVersion = version, LastAppliedRemoteSeq = lastAppliedRemoteSeq }; + foreach (var t in sessionDb.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal)) + h.Tables.Add(new TableDigest { TableName = t.Name, Digest = t.Digest }); + return h; + } + + private static async Task NextAsync(ChannelReader reader, CancellationToken ct) + { + await reader.WaitToReadAsync(ct); + reader.TryRead(out var msg); + return msg!; + } + + private static async Task SwallowAsync(Task task) + { + try { await task; } + catch (OperationCanceledException) { } + } + + private static async Task WaitForAsync(Func> predicate, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (await predicate()) return; + await Task.Delay(20); + } + throw new TimeoutException("Condition not reached within " + timeout); + } + + private static async Task> ReadOrders(SqliteLocalDb db) + { + var rows = await db.QueryAsync( + "SELECT id, sku, qty FROM orders ORDER BY id", + x => (x.GetInt64(0), x.IsDBNull(1) ? null : x.GetString(1), (long?)(x.IsDBNull(2) ? null : x.GetInt64(2)))); + return rows.ToList(); + } + + private static async Task OplogCount(SqliteLocalDb db) + { + var r = await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", x => x.GetInt64(0)); + return r[0]; + } + + private static async Task LastAckedSeq(SqliteLocalDb db) + { + var r = await db.QueryAsync("SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1", x => x.GetInt64(0)); + return r[0]; + } + + [Fact] + public async Task Handshake_Exchanged_BothProceed() + { + var a = await NewSideAsync(); + var b = await NewSideAsync(); + var (da, db) = DuplexPair(); + + using var cts = new CancellationTokenSource(RunTimeout); + var runA = a.Session.RunAsync(da, cts.Token); + var runB = b.Session.RunAsync(db, cts.Token); + + await WaitForAsync(() => Task.FromResult(a.Session.PeerNodeId is not null && b.Session.PeerNodeId is not null), RunTimeout); + + Assert.Equal(b.Db.NodeId, a.Session.PeerNodeId); + Assert.Equal(a.Db.NodeId, b.Session.PeerNodeId); + + cts.Cancel(); + await SwallowAsync(runA); + await SwallowAsync(runB); + } + + [Fact] + public async Task Handshake_DigestMismatch_FailsClosed() + { + var a = await NewSideAsync(); + var b = await NewSideAsync("CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER, extra TEXT)"); + await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'A', 5)"); + var (da, db) = DuplexPair(); + + using var cts = new CancellationTokenSource(RunTimeout); + var runA = a.Session.RunAsync(da, cts.Token); + var runB = b.Session.RunAsync(db, cts.Token); + + var exA = await Assert.ThrowsAsync(() => runA); + var exB = await Assert.ThrowsAsync(() => runB); + Assert.Contains("orders", exA.Message); + Assert.Contains("orders", exB.Message); + + // No delta crossed the handshake: B never received A's row. + Assert.Empty(await ReadOrders(b.Db)); + } + + [Fact] + public async Task Handshake_SchemaVersionMismatch_FailsClosed() + { + var a = await NewSideAsync(); + var (duplex, toSession, _) = ScriptedPeer(); + + using var cts = new CancellationTokenSource(RunTimeout); + var run = a.Session.RunAsync(duplex, cts.Token); + + await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer", version: 2) }, cts.Token); + + await Assert.ThrowsAsync(() => run); + } + + [Fact] + public async Task Deltas_FlowBothDirections_AcksAdvanceWatermarks() + { + var a = await NewSideAsync(); + var b = await NewSideAsync(); + await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'FROM_A', 10)"); + await b.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'FROM_B', 20)"); + var (da, db) = DuplexPair(); + + using var cts = new CancellationTokenSource(RunTimeout); + var runA = a.Session.RunAsync(da, cts.Token); + var runB = b.Session.RunAsync(db, cts.Token); + + async Task Converged() + { + var ra = await ReadOrders(a.Db); + var rb = await ReadOrders(b.Db); + return ra.Count == 2 && rb.Count == 2 + && await OplogCount(a.Db) == 0 && await OplogCount(b.Db) == 0; + } + + await WaitForAsync(Converged, RunTimeout); + + var rowsA = await ReadOrders(a.Db); + var rowsB = await ReadOrders(b.Db); + Assert.Equal(rowsA, rowsB); + Assert.Equal([(1L, "FROM_A", (long?)10), (2L, "FROM_B", (long?)20)], rowsA); + Assert.True(await LastAckedSeq(a.Db) >= 1); + Assert.True(await LastAckedSeq(b.Db) >= 1); + + cts.Cancel(); + await SwallowAsync(runA); + await SwallowAsync(runB); + } + + [Fact] + public async Task Backlog_DrainsWithoutWaitingFlushInterval() + { + var options = new ReplicationOptions { MaxBatchSize = 5, FlushInterval = TimeSpan.FromSeconds(30) }; + var a = await NewSideAsync(options: options); + var b = await NewSideAsync(options: options); + + const int rows = 15; // 3 x MaxBatchSize + for (var i = 1; i <= rows; i++) + await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'R', @id)", new { id = i }); + + var (da, db) = DuplexPair(); + using var cts = new CancellationTokenSource(RunTimeout); + var runA = a.Session.RunAsync(da, cts.Token); + var runB = b.Session.RunAsync(db, cts.Token); + + // FlushInterval is 30 s: reaching convergence in a couple seconds proves the pump loops + // immediately on backlog instead of waiting a flush tick between batches. + var sw = System.Diagnostics.Stopwatch.StartNew(); + await WaitForAsync(async () => (await ReadOrders(b.Db)).Count == rows, TimeSpan.FromSeconds(5)); + sw.Stop(); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"drain took {sw.Elapsed}"); + + cts.Cancel(); + await SwallowAsync(runA); + await SwallowAsync(runB); + } + + [Fact] + public async Task HlcDriftAhead_Warns() + { + var logger = new ListLogger(); + var a = await NewSideAsync( + options: new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20), FailClosedOnDrift = false }, + logger: logger); + var (duplex, toSession, fromSession) = ScriptedPeer(); + + using var cts = new CancellationTokenSource(RunTimeout); + var run = a.Session.RunAsync(duplex, cts.Token); + + await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); + + var aheadHlc = (DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeMilliseconds() << 16); + await toSession.WriteAsync(new SyncMessage + { + DeltaBatch = new DeltaBatch + { + Entries = { new OplogEntry + { + Seq = 1, TableName = "orders", PkJson = "{\"id\":1}", + RowJson = "{\"id\":1,\"sku\":\"AHEAD\",\"qty\":9}", Hlc = aheadHlc, NodeId = "peer", IsTombstone = false, + } }, + }, + }, cts.Token); + + // Drain the DeltaAck the session emits after applying. + SyncMessage ack; + do { ack = await NextAsync(fromSession, cts.Token); } while (ack.MsgCase != SyncMessage.MsgOneofCase.DeltaAck); + Assert.Equal(1, ack.DeltaAck.AppliedThruSeq); + + Assert.Single(await ReadOrders(a.Db)); // applied despite drift + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning); + + cts.Cancel(); + await SwallowAsync(run); + } + + [Fact] + public async Task HlcDriftAhead_FailClosed_Throws() + { + var a = await NewSideAsync( + options: new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20), FailClosedOnDrift = true }); + var (duplex, toSession, fromSession) = ScriptedPeer(); + + using var cts = new CancellationTokenSource(RunTimeout); + var run = a.Session.RunAsync(duplex, cts.Token); + + await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); + + var aheadHlc = (DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeMilliseconds() << 16); + await toSession.WriteAsync(new SyncMessage + { + DeltaBatch = new DeltaBatch + { + Entries = { new OplogEntry + { + Seq = 1, TableName = "orders", PkJson = "{\"id\":1}", + RowJson = "{\"id\":1,\"sku\":\"AHEAD\",\"qty\":9}", Hlc = aheadHlc, NodeId = "peer", IsTombstone = false, + } }, + }, + }, cts.Token); + + await Assert.ThrowsAsync(() => run); + Assert.Empty(await ReadOrders(a.Db)); // rejected, not applied + } + + [Fact] + public async Task PeerNeedsSnapshot_InvokesSnapshotHooks() + { + // (a) peer's HandshakeAck.snapshot_required = true -> our SnapshotSender fires. + { + var a = await NewSideAsync(); + var (duplex, toSession, fromSession) = ScriptedPeer(); + var sent = new TaskCompletionSource(); + a.Session.SnapshotSender = (_, _) => { sent.TrySetResult(); return Task.CompletedTask; }; + + using var cts = new CancellationTokenSource(RunTimeout); + var run = a.Session.RunAsync(duplex, cts.Token); + + await NextAsync(fromSession, cts.Token); // session Handshake + await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer") }, cts.Token); + await NextAsync(fromSession, cts.Token); // session HandshakeAck + await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = true } }, cts.Token); + + await sent.Task.WaitAsync(RunTimeout); + cts.Cancel(); + await SwallowAsync(run); + } + + // (b) inbound SnapshotBegin -> our SnapshotReceiver fires. + { + var a = await NewSideAsync(); + var (duplex, toSession, fromSession) = ScriptedPeer(); + var received = new TaskCompletionSource(); + a.Session.SnapshotReceiver = (_, _, _) => { received.TrySetResult(); return Task.CompletedTask; }; + + using var cts = new CancellationTokenSource(RunTimeout); + var run = a.Session.RunAsync(duplex, cts.Token); + + await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); + await toSession.WriteAsync(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = 0 } }, cts.Token); + + await received.Task.WaitAsync(RunTimeout); + cts.Cancel(); + await SwallowAsync(run); + } + + // (c) snapshot required but no sender hook -> NotSupportedException. + { + var a = await NewSideAsync(); + var (duplex, toSession, fromSession) = ScriptedPeer(); + + using var cts = new CancellationTokenSource(RunTimeout); + var run = a.Session.RunAsync(duplex, cts.Token); + + await NextAsync(fromSession, cts.Token); + await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer") }, cts.Token); + await NextAsync(fromSession, cts.Token); + await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = true } }, cts.Token); + + await Assert.ThrowsAsync(() => run); + } + + // (d) inbound SnapshotBegin but no receiver hook -> NotSupportedException. + { + var a = await NewSideAsync(); + var (duplex, toSession, fromSession) = ScriptedPeer(); + + using var cts = new CancellationTokenSource(RunTimeout); + var run = a.Session.RunAsync(duplex, cts.Token); + + await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); + await toSession.WriteAsync(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = 0 } }, cts.Token); + + await Assert.ThrowsAsync(() => run); + } + } + + [Fact] + public async Task SecondHandshake_MidStream_IsProtocolError() + { + var a = await NewSideAsync(); + var (duplex, toSession, fromSession) = ScriptedPeer(); + + using var cts = new CancellationTokenSource(RunTimeout); + var run = a.Session.RunAsync(duplex, cts.Token); + + await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); + await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer") }, cts.Token); + + await Assert.ThrowsAsync(() => run); + } + + // Completes the two-phase handshake against a real session: consumes its Handshake + HandshakeAck + // and replies with a matching Handshake + a no-snapshot HandshakeAck, leaving the session in steady state. + private static async Task DriveHandshakeAsync( + SqliteLocalDb sessionDb, ChannelWriter toSession, ChannelReader fromSession, CancellationToken ct) + { + var hs = await NextAsync(fromSession, ct); + Assert.Equal(SyncMessage.MsgOneofCase.Handshake, hs.MsgCase); + await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(sessionDb, "peer") }, ct); + var ack = await NextAsync(fromSession, ct); + Assert.Equal(SyncMessage.MsgOneofCase.HandshakeAck, ack.MsgCase); + await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, ct); + } + + private sealed class ListLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Entries) + Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } + } +}