Files

561 lines
21 KiB
C#

using System.Diagnostics.Metrics;
using System.Threading.Channels;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
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 MetricsTests : IAsyncLifetime
{
private const string OrdersSql = "CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)";
private static readonly TimeSpan RunTimeout = TimeSpan.FromSeconds(30);
private readonly List<string> _paths = [];
private readonly List<IDisposable> _disposables = [];
private readonly List<IHost> _hosts = [];
private readonly List<ServiceProvider> _providers = [];
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
foreach (var provider in _providers)
await provider.DisposeAsync();
foreach (var host in _hosts)
{
try { await host.StopAsync(TimeSpan.FromSeconds(5)); } catch { /* teardown */ }
host.Dispose();
}
foreach (var d in _disposables)
d.Dispose();
Microsoft.Data.Sqlite.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");
}
}
// ---- in-memory duplex tests -----------------------------------------------------------
[Fact]
public async Task Counters_IncrementOnApply()
{
using var metricsB = new LocalDbMetrics();
using var collector = new Collector(metricsB.Meter);
var a = await NewSideAsync();
var b = await NewSideAsync(metrics: metricsB);
await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'FROM_A', 10)");
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(async () => (await ReadOrders(b.Db)).Count == 1, RunTimeout);
Assert.True(collector.Sum("localdb.sync.applied") > 0);
cts.Cancel();
await SwallowAsync(runA);
await SwallowAsync(runB);
}
[Fact]
public async Task DeadLetter_Counter_Increments()
{
using var metrics = new LocalDbMetrics();
using var collector = new Collector(metrics.Meter);
var a = await NewSideAsync(metrics: metrics);
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);
// A non-tombstone entry with null row_json is poison: the applier dead-letters it.
await toSession.WriteAsync(new SyncMessage
{
DeltaBatch = new DeltaBatch
{
Entries = { new OplogEntry
{
Seq = 1, TableName = "orders", PkJson = "{\"id\":1}",
Hlc = 5_000_000, NodeId = "peer", IsTombstone = false,
} },
},
}, cts.Token);
await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token);
Assert.Equal(1, collector.Sum("localdb.sync.dead_lettered"));
cts.Cancel();
await SwallowAsync(run);
}
[Fact]
public async Task OplogDepth_Gauge_ReportsBacklog()
{
using var metrics = new LocalDbMetrics();
using var collector = new Collector(metrics.Meter);
var a = await NewSideAsync();
var b = await NewSideAsync();
metrics.OplogDepthProvider = a.Store.GetOplogDepthSync;
const int rows = 4;
for (var i = 1; i <= rows; i++)
await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'R', @id)", new { id = i });
// No session running yet: the gauge reflects the unacked oplog backlog.
collector.Clear();
collector.Observe();
Assert.Equal(rows, collector.LastLong("localdb.oplog.depth"));
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(async () => await OplogCount(a.Db) == 0, RunTimeout);
collector.Clear();
collector.Observe();
Assert.Equal(0, collector.LastLong("localdb.oplog.depth"));
cts.Cancel();
await SwallowAsync(runA);
await SwallowAsync(runB);
}
[Fact]
public async Task SyncLag_Gauge_EmptyUntilFirstSync_ThenReports()
{
using var metrics = new LocalDbMetrics();
using var collector = new Collector(metrics.Meter);
DateTimeOffset? lastSync = null;
metrics.LastSyncUtcProvider = () => lastSync;
// Before any sync: the gauge yields no measurement (last_sync unknown).
collector.Observe();
Assert.Empty(collector.Doubles("localdb.sync.lag.seconds"));
lastSync = DateTimeOffset.UtcNow;
collector.Observe();
var lags = collector.Doubles("localdb.sync.lag.seconds");
Assert.NotEmpty(lags);
Assert.True(lags[^1] >= 0, $"lag {lags[^1]} should be non-negative");
Assert.True(lags[^1] < 60, $"lag {lags[^1]} should be small");
}
[Fact]
public async Task SnapshotOnlySync_UpdatesLastSyncAndLag()
{
var statusA = new SyncStatus();
var statusB = new SyncStatus();
using var metricsB = new LocalDbMetrics { LastSyncUtcProvider = () => statusB.LastSyncUtc };
using var collector = new Collector(metricsB.Meter);
var a = await NewSideAsync(status: statusA);
var b = await NewSideAsync(metrics: metricsB, status: statusB);
await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'SNAP', 10)");
// A owes B a full snapshot; the pump then skips the tail deltas the snapshot covers, so
// B converges via the snapshot path alone (no delta batch ever reaches it).
await a.Store.SetNeedsSnapshotAsync(true, default);
// Before any sync the lag gauge yields no measurement.
collector.Observe();
Assert.Empty(collector.Doubles("localdb.sync.lag.seconds"));
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(async () => (await ReadOrders(b.Db)).Count == 1, RunTimeout);
await WaitForAsync(() => Task.FromResult(statusB.LastSyncUtc is not null), RunTimeout);
Assert.NotNull(statusB.LastSyncUtc); // receive path: SnapshotComplete
Assert.NotNull(statusA.LastSyncUtc); // send path: SnapshotSender returned
collector.Observe();
var lags = collector.Doubles("localdb.sync.lag.seconds");
Assert.NotEmpty(lags);
Assert.True(lags[^1] >= 0, $"lag {lags[^1]} should be non-negative");
cts.Cancel();
await SwallowAsync(runA);
await SwallowAsync(runB);
}
[Fact]
public void BothRoles_ActiveSessions_ConnectedUntilLastEnds()
{
// A node running both roles holds two concurrent sessions; one ending must not clobber
// the other's Connected signal.
var status = new SyncStatus();
Assert.False(status.Connected);
status.SessionStarted();
Assert.True(status.Connected);
status.SessionStarted();
Assert.True(status.Connected);
status.SessionEnded();
Assert.True(status.Connected);
status.SessionEnded();
Assert.False(status.Connected);
}
[Fact]
public async Task Metrics_NullSafe_NoMetricsRegistered()
{
// A session constructed with metrics=null and status=null must sync without an NRE.
var a = await NewSideAsync();
var b = await NewSideAsync();
await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'A', 10)");
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(async () => (await ReadOrders(b.Db)).Count == 1, RunTimeout);
cts.Cancel();
await SwallowAsync(runA);
await SwallowAsync(runB);
}
// ---- gRPC host tests ------------------------------------------------------------------
[Fact]
public async Task Reconnects_Counter_Increments()
{
var serverHost = await BuildServerAsync(NewDbPath());
var handler = serverHost.GetTestServer().CreateHandler();
var calls = 0;
GrpcChannel Factory()
{
if (Interlocked.Increment(ref calls) == 1)
throw new InvalidOperationException("transient dial failure");
return ChannelOver(handler);
}
var (clientProvider, bg) = BuildClient(NewDbPath(), "http://localhost", Factory);
var clientDb = clientProvider.GetRequiredService<ILocalDb>();
await clientDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'C', 10)");
var metrics = clientProvider.GetRequiredService<LocalDbMetrics>();
using var collector = new Collector(metrics.Meter);
using var cts = new CancellationTokenSource(RunTimeout);
await bg.StartAsync(cts.Token);
var serverDb = serverHost.Services.GetRequiredService<ILocalDb>();
await WaitForAsync(async () => (await ReadOrders(serverDb)).Count == 1, RunTimeout);
Assert.True(bg.ConnectionAttempts >= 2, $"attempts {bg.ConnectionAttempts}");
Assert.True(collector.Sum("localdb.sync.reconnects") >= 1, $"reconnects {collector.Sum("localdb.sync.reconnects")}");
await bg.StopAsync(CancellationToken.None);
}
[Fact]
public async Task SyncStatus_ReflectsSessionLifecycle()
{
var serverHost = await BuildServerAsync(NewDbPath());
var serverDb = serverHost.Services.GetRequiredService<ILocalDb>();
await serverDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'S', 20)");
var handler = serverHost.GetTestServer().CreateHandler();
var (clientProvider, bg) = BuildClient(NewDbPath(), "http://localhost", () => ChannelOver(handler));
var clientDb = clientProvider.GetRequiredService<ILocalDb>();
await clientDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'C', 10)");
var status = clientProvider.GetRequiredService<ISyncStatus>();
var serverStatus = serverHost.Services.GetRequiredService<ISyncStatus>();
Assert.False(status.Connected);
Assert.False(serverStatus.Connected);
using var cts = new CancellationTokenSource(RunTimeout);
await bg.StartAsync(cts.Token);
await WaitForAsync(() => Task.FromResult(status.Connected), RunTimeout);
await WaitForAsync(() => Task.FromResult(serverStatus.Connected), RunTimeout);
await WaitForAsync(
async () => (await ReadOrders(clientDb)).Count == 2 && (await ReadOrders(serverDb)).Count == 2,
RunTimeout);
Assert.NotNull(status.PeerNodeId);
Assert.NotNull(status.LastSyncUtc);
Assert.NotNull(serverStatus.PeerNodeId);
await bg.StopAsync(CancellationToken.None);
await WaitForAsync(() => Task.FromResult(!status.Connected), RunTimeout);
Assert.False(status.Connected);
await WaitForAsync(() => Task.FromResult(!serverStatus.Connected), RunTimeout);
Assert.False(serverStatus.Connected);
}
// ---- in-memory harness ----------------------------------------------------------------
private sealed record Side(SqliteLocalDb Db, OplogStore Store, LwwApplier Applier, SyncSession Session);
private async Task<Side> NewSideAsync(LocalDbMetrics? metrics = null, SyncStatus? status = 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(OrdersSql);
db.RegisterReplicated("orders");
var options = new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20) };
var store = new OplogStore(db, options);
var applier = new LwwApplier(db);
var session = new SyncSession(db, store, applier, options, NullLogger.Instance, null, metrics, status);
var streamer = new SnapshotStreamer(db, store, options, NullLogger.Instance);
session.SnapshotSender = streamer.SendAsync;
session.SnapshotApplier = new SnapshotApplier(db, applier, store, NullLogger.Instance);
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)
{
var h = new Handshake { NodeId = peerNodeId, LibSchemaVersion = 1, LastAppliedRemoteSeq = 0 };
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 DriveHandshakeAsync(
SqliteLocalDb sessionDb, ChannelWriter<SyncMessage> toSession, ChannelReader<SyncMessage> fromSession, CancellationToken ct)
{
await NextAsync(fromSession, ct);
await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(sessionDb, "peer") }, ct);
await NextAsync(fromSession, ct);
await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, ct);
}
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<SyncMessage> NextOfCaseAsync(
ChannelReader<SyncMessage> reader, SyncMessage.MsgOneofCase msgCase, CancellationToken ct)
{
SyncMessage msg;
do { msg = await NextAsync(reader, ct); } while (msg.MsgCase != msgCase);
return msg;
}
private static async Task SwallowAsync(Task task)
{
try { await task; }
catch (OperationCanceledException) { }
}
// ---- gRPC host harness ----------------------------------------------------------------
private string NewDbPath()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
_paths.Add(path);
return path;
}
private static void OnReady(ILocalDb db)
{
using var conn = db.CreateConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = OrdersSql;
cmd.ExecuteNonQuery();
db.RegisterReplicated("orders");
}
private static IConfiguration ConfigFor(string path, string? peerAddress)
{
var dict = new Dictionary<string, string?>
{
["LocalDb:Path"] = path,
["LocalDb:Replication:FlushInterval"] = "00:00:00.020",
};
if (peerAddress is not null) dict["LocalDb:Replication:PeerAddress"] = peerAddress;
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
}
private static GrpcChannel ChannelOver(HttpMessageHandler handler) =>
GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions { HttpHandler = handler });
private async Task<IHost> BuildServerAsync(string path)
{
var config = ConfigFor(path, peerAddress: null);
var host = await new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
services.AddGrpc();
services.AddZbLocalDb(config, OnReady);
services.AddZbLocalDbReplication(config);
});
web.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => e.MapZbLocalDbSync());
});
})
.StartAsync();
_hosts.Add(host);
return host;
}
private (ServiceProvider Provider, SyncBackgroundService Bg) BuildClient(
string path, string peerAddress, Func<GrpcChannel> channelFactory)
{
var config = ConfigFor(path, peerAddress);
var services = new ServiceCollection();
services.AddLogging();
services.AddZbLocalDb(config, OnReady);
services.AddZbLocalDbReplication(config);
var provider = services.BuildServiceProvider();
_providers.Add(provider);
var bg = provider.GetServices<IHostedService>().OfType<SyncBackgroundService>().Single();
bg.ChannelFactory = channelFactory;
return (provider, bg);
}
private static async Task<List<(long Id, string? Sku, long? Qty)>> ReadOrders(ILocalDb 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(ILocalDb db)
{
var r = await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", x => x.GetInt64(0));
return r[0];
}
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);
}
// ---- metric collector -----------------------------------------------------------------
private sealed class Collector : IDisposable
{
private readonly MeterListener _listener;
private readonly object _lock = new();
private readonly Dictionary<string, List<long>> _longs = new(StringComparer.Ordinal);
private readonly Dictionary<string, List<double>> _doubles = new(StringComparer.Ordinal);
public Collector(Meter meter)
{
_listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (ReferenceEquals(inst.Meter, meter))
l.EnableMeasurementEvents(inst);
},
};
_listener.SetMeasurementEventCallback<long>((inst, val, _, _) =>
{
lock (_lock) Append(_longs, inst.Name, val);
});
_listener.SetMeasurementEventCallback<double>((inst, val, _, _) =>
{
lock (_lock) Append(_doubles, inst.Name, val);
});
_listener.Start();
}
private static void Append<T>(Dictionary<string, List<T>> map, string name, T value)
{
if (!map.TryGetValue(name, out var list))
{
list = [];
map[name] = list;
}
list.Add(value);
}
public void Observe() => _listener.RecordObservableInstruments();
public long Sum(string name)
{
lock (_lock) return _longs.TryGetValue(name, out var l) ? l.Sum() : 0;
}
public long? LastLong(string name)
{
lock (_lock) return _longs.TryGetValue(name, out var l) && l.Count > 0 ? l[^1] : null;
}
public IReadOnlyList<double> Doubles(string name)
{
lock (_lock) return _doubles.TryGetValue(name, out var l) ? l.ToList() : [];
}
public void Clear()
{
lock (_lock)
{
_longs.Clear();
_doubles.Clear();
}
}
public void Dispose() => _listener.Dispose();
}
}