feat(localdb): SyncSession protocol state machine (handshake fail-closed, bidirectional delta pump)
This commit is contained in:
@@ -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<string> _paths = [];
|
||||
private readonly List<IDisposable> _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<Side> NewSideAsync(
|
||||
string createSql = OrdersSql,
|
||||
string table = "orders",
|
||||
ReplicationOptions? options = null,
|
||||
ILogger? logger = null,
|
||||
Func<DateTimeOffset>? 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<SyncMessage>();
|
||||
var bToA = Channel.CreateUnbounded<SyncMessage>();
|
||||
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<SyncMessage> ToSession, ChannelReader<SyncMessage> FromSession) ScriptedPeer()
|
||||
{
|
||||
var toSession = Channel.CreateUnbounded<SyncMessage>();
|
||||
var fromSession = Channel.CreateUnbounded<SyncMessage>();
|
||||
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<SyncMessage> NextAsync(ChannelReader<SyncMessage> 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<Task<bool>> 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<List<(long Id, string? Sku, long? Qty)>> 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<long> OplogCount(SqliteLocalDb db)
|
||||
{
|
||||
var r = await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", x => x.GetInt64(0));
|
||||
return r[0];
|
||||
}
|
||||
|
||||
private static async Task<long> 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<LocalDbSchemaMismatchException>(() => runA);
|
||||
var exB = await Assert.ThrowsAsync<LocalDbSchemaMismatchException>(() => 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<LocalDbSchemaMismatchException>(() => 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<bool> 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<InvalidOperationException>(() => 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<NotSupportedException>(() => 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<NotSupportedException>(() => 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<InvalidOperationException>(() => 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<SyncMessage> toSession, ChannelReader<SyncMessage> 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>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries)
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user