feat(localdb): telemetry meters + ISyncStatus

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-18 00:03:39 -04:00
parent 8befebd476
commit be4ca76f5f
9 changed files with 765 additions and 8 deletions
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.LocalDb.Replication.Internal;
namespace ZB.MOM.WW.LocalDb.Replication; namespace ZB.MOM.WW.LocalDb.Replication;
@@ -32,6 +33,30 @@ public static class ReplicationServiceCollectionExtensions
services.TryAddEnumerable( services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<ReplicationOptions>, ReplicationOptionsValidator>()); ServiceDescriptor.Singleton<IValidateOptions<ReplicationOptions>, ReplicationOptionsValidator>());
// Telemetry is optional but always registered here; the sessions/adapters hold nullable
// references and null-guard every call, so a consumer that never collects metrics is
// unaffected. The oplog-depth / lag / backlog observers poll a cheap synchronous store SELECT.
services.TryAddSingleton<SyncStatus>(sp =>
{
var db = SyncSessionFactory.RequireSqlite(sp.GetRequiredService<ILocalDb>(), nameof(SyncStatus));
var options = sp.GetRequiredService<IOptions<ReplicationOptions>>().Value;
var store = new OplogStore(db, options);
return new SyncStatus { OplogBacklogProvider = store.GetOplogDepthSync };
});
services.TryAddSingleton<ISyncStatus>(sp => sp.GetRequiredService<SyncStatus>());
services.TryAddSingleton<LocalDbMetrics>(sp =>
{
var db = SyncSessionFactory.RequireSqlite(sp.GetRequiredService<ILocalDb>(), nameof(LocalDbMetrics));
var options = sp.GetRequiredService<IOptions<ReplicationOptions>>().Value;
var store = new OplogStore(db, options);
var status = sp.GetRequiredService<SyncStatus>();
return new LocalDbMetrics
{
OplogDepthProvider = store.GetOplogDepthSync,
LastSyncUtcProvider = () => status.LastSyncUtc,
};
});
services.AddSingleton<LocalDbSyncService>(); services.AddSingleton<LocalDbSyncService>();
services.AddHostedService<SyncBackgroundService>(); services.AddHostedService<SyncBackgroundService>();
@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.LocalDb.Replication;
/// <summary>
/// A live, read-only view of the replication engine's current state, registered as a singleton for
/// dashboards / health surfaces. Always available (empty/false on a passive or not-yet-connected
/// node); the adapters and the sync session update it as connections and batches come and go.
/// </summary>
public interface ISyncStatus
{
/// <summary>True while a sync session is actively running (in <c>RunAsync</c>).</summary>
bool Connected { get; }
/// <summary>The peer node's identity once the handshake has completed, else null.</summary>
string? PeerNodeId { get; }
/// <summary>When the last inbound batch was applied / acked, else null if no sync has occurred.</summary>
DateTimeOffset? LastSyncUtc { get; }
/// <summary>The current unacked oplog backlog (entries above the peer's last-acked watermark).</summary>
long OplogBacklog { get; }
/// <summary>Number of connection attempts the initiator has made this lifetime.</summary>
long ConnectionAttempts { get; }
}
/// <summary>
/// The mutable <see cref="ISyncStatus"/> backing store: thread-safe setters written from the sync
/// session and the adapters, read from arbitrary dashboard / scrape threads. <see cref="OplogBacklog"/>
/// polls <see cref="OplogBacklogProvider"/> on read (the same cheap SELECT the depth gauge uses).
/// </summary>
internal sealed class SyncStatus : ISyncStatus
{
private volatile int _connected;
private string? _peerNodeId;
private long _lastSyncUtcTicks = -1;
private long _connectionAttempts;
/// <summary>Supplies the live unacked backlog for <see cref="OplogBacklog"/>; null yields 0.</summary>
public Func<long>? OplogBacklogProvider { get; set; }
public bool Connected => _connected != 0;
public string? PeerNodeId => Volatile.Read(ref _peerNodeId);
public DateTimeOffset? LastSyncUtc
{
get
{
var ticks = Interlocked.Read(ref _lastSyncUtcTicks);
return ticks < 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero);
}
}
public long OplogBacklog
{
get
{
var provider = OplogBacklogProvider;
if (provider is null)
return 0;
try
{
return provider();
}
catch
{
return 0;
}
}
}
public long ConnectionAttempts => Interlocked.Read(ref _connectionAttempts);
public void SetConnected(bool value) => _connected = value ? 1 : 0;
public void SetPeerNodeId(string peerNodeId) => Volatile.Write(ref _peerNodeId, peerNodeId);
public void SetLastSyncUtc(DateTimeOffset value) =>
Interlocked.Exchange(ref _lastSyncUtcTicks, value.UtcTicks);
public void SetConnectionAttempts(long value) => Interlocked.Exchange(ref _connectionAttempts, value);
}
@@ -79,6 +79,21 @@ internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<D
return rows[0]; return rows[0];
} }
/// <summary>
/// The unacked backlog read synchronously on a dedicated short-lived connection, for the
/// metric-observation callbacks (gauge scrape / status poll). Cheap enough at that frequency;
/// mirrors <see cref="GetOplogDepthAsync"/>'s query. May throw if the db is disposing — callers
/// on the observation path swallow and drop the sample.
/// </summary>
public long GetOplogDepthSync()
{
using var conn = db.CreateConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT COUNT(*) FROM __localdb_oplog WHERE seq > (SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1)";
return (long)cmd.ExecuteScalar()!;
}
/// <summary>The highest oplog seq present, or 0 when the oplog is empty.</summary> /// <summary>The highest oplog seq present, or 0 when the oplog is empty.</summary>
public async Task<long> GetMaxSeqAsync(CancellationToken ct = default) public async Task<long> GetMaxSeqAsync(CancellationToken ct = default)
{ {
@@ -31,6 +31,8 @@ internal sealed class SyncSession
private readonly ReplicationOptions _options; private readonly ReplicationOptions _options;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Func<DateTimeOffset> _utcNow; private readonly Func<DateTimeOffset> _utcNow;
private readonly LocalDbMetrics? _metrics;
private readonly SyncStatus? _status;
// Highest oplog seq handed to the wire this session; the pump reads strictly above it so a batch // 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). // is never re-sent before its ack (the reliable ordered stream delivers exactly once in-session).
@@ -45,7 +47,7 @@ internal sealed class SyncSession
public SyncSession( public SyncSession(
SqliteLocalDb db, OplogStore store, LwwApplier applier, ReplicationOptions options, ILogger logger, SqliteLocalDb db, OplogStore store, LwwApplier applier, ReplicationOptions options, ILogger logger,
Func<DateTimeOffset>? utcNow = null) Func<DateTimeOffset>? utcNow = null, LocalDbMetrics? metrics = null, SyncStatus? status = null)
{ {
_db = db; _db = db;
_store = store; _store = store;
@@ -53,6 +55,8 @@ internal sealed class SyncSession
_options = options; _options = options;
_logger = logger; _logger = logger;
_utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
_metrics = metrics;
_status = status;
} }
/// <summary> /// <summary>
@@ -140,6 +144,7 @@ internal sealed class SyncSession
await _store.SetPeerNodeIdAsync(peerHandshake.NodeId, ct); await _store.SetPeerNodeIdAsync(peerHandshake.NodeId, ct);
PeerNodeId = peerHandshake.NodeId; PeerNodeId = peerHandshake.NodeId;
_status?.SetPeerNodeId(peerHandshake.NodeId);
_sentThruSeq = peerHandshake.LastAppliedRemoteSeq; _sentThruSeq = peerHandshake.LastAppliedRemoteSeq;
Interlocked.Exchange(ref _peerAckedSeq, peerHandshake.LastAppliedRemoteSeq); Interlocked.Exchange(ref _peerAckedSeq, peerHandshake.LastAppliedRemoteSeq);
@@ -164,6 +169,7 @@ internal sealed class SyncSession
if (SnapshotSender is null) if (SnapshotSender is null)
throw new NotSupportedException("snapshot required but no snapshot sender configured"); throw new NotSupportedException("snapshot required but no snapshot sender configured");
var snapshotAsOfSeq = await SnapshotSender(dataSend, ct); var snapshotAsOfSeq = await SnapshotSender(dataSend, ct);
_metrics?.RecordSnapshot("sent");
// The snapshot already carries every row state up to as-of; skipping the pump past it // The snapshot already carries every row state up to as-of; skipping the pump past it
// avoids re-sending tail deltas the peer would only discard via LWW. // avoids re-sending tail deltas the peer would only discard via LWW.
if (snapshotAsOfSeq > _sentThruSeq) if (snapshotAsOfSeq > _sentThruSeq)
@@ -269,6 +275,7 @@ internal sealed class SyncSession
"Replication protocol error: SnapshotComplete with no active snapshot (missing SnapshotBegin)."); "Replication protocol error: SnapshotComplete with no active snapshot (missing SnapshotBegin).");
await SnapshotApplier!.OnCompleteAsync(msg.SnapshotComplete, ct); await SnapshotApplier!.OnCompleteAsync(msg.SnapshotComplete, ct);
_snapshotReceiving = false; _snapshotReceiving = false;
_metrics?.RecordSnapshot("received");
break; break;
default: default:
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -288,6 +295,7 @@ internal sealed class SyncSession
var driftLimitMs = nowMs + (long)_options.MaxHlcDriftAhead.TotalMilliseconds; var driftLimitMs = nowMs + (long)_options.MaxHlcDriftAhead.TotalMilliseconds;
var toApply = new List<OplogEntryRecord>(batch.Entries.Count); var toApply = new List<OplogEntryRecord>(batch.Entries.Count);
var maxSeq = 0L; var maxSeq = 0L;
var driftDeadLettered = 0;
foreach (var proto in batch.Entries) foreach (var proto in batch.Entries)
{ {
@@ -310,6 +318,7 @@ internal sealed class SyncSession
if (_options.FailClosedOnDrift) if (_options.FailClosedOnDrift)
{ {
await DeadLetterDriftedAsync(entry, physicalMs, nowMs, now, ct); await DeadLetterDriftedAsync(entry, physicalMs, nowMs, now, ct);
driftDeadLettered++;
continue; continue;
} }
} }
@@ -317,6 +326,10 @@ internal sealed class SyncSession
} }
var result = await _applier.ApplyBatchAsync(toApply, ct); var result = await _applier.ApplyBatchAsync(toApply, ct);
_metrics?.RecordApplied(result.Applied);
_metrics?.RecordConflictsDiscarded(result.Discarded);
_metrics?.RecordDeadLettered(result.DeadLettered + driftDeadLettered);
_status?.SetLastSyncUtc(now);
var ackThru = Math.Max(result.AppliedThruSeq, maxSeq); var ackThru = Math.Max(result.AppliedThruSeq, maxSeq);
if (ackThru > result.AppliedThruSeq) if (ackThru > result.AppliedThruSeq)
// Dead-lettered entries carried the batch's top seqs; advance the durable remote // Dead-lettered entries carried the batch's top seqs; advance the durable remote
@@ -20,12 +20,14 @@ internal static class SyncSessionFactory
$"{consumer} requires the ILocalDb registered by AddZbLocalDb (a {nameof(SqliteLocalDb)}); " + $"{consumer} requires the ILocalDb registered by AddZbLocalDb (a {nameof(SqliteLocalDb)}); " +
$"got {db.GetType().FullName}."); $"got {db.GetType().FullName}.");
public static SyncSession Create(SqliteLocalDb db, ReplicationOptions options, ILoggerFactory loggerFactory) public static SyncSession Create(
SqliteLocalDb db, ReplicationOptions options, ILoggerFactory loggerFactory,
LocalDbMetrics? metrics = null, SyncStatus? status = null)
{ {
var store = new OplogStore(db, options); var store = new OplogStore(db, options);
var applier = new LwwApplier(db); var applier = new LwwApplier(db);
var logger = loggerFactory.CreateLogger<SyncSession>(); var logger = loggerFactory.CreateLogger<SyncSession>();
var session = new SyncSession(db, store, applier, options, logger); var session = new SyncSession(db, store, applier, options, logger, utcNow: null, metrics, status);
var streamer = new SnapshotStreamer(db, store, options, loggerFactory.CreateLogger<SnapshotStreamer>()); var streamer = new SnapshotStreamer(db, store, options, loggerFactory.CreateLogger<SnapshotStreamer>());
session.SnapshotSender = streamer.SendAsync; session.SnapshotSender = streamer.SendAsync;
@@ -0,0 +1,104 @@
using System.Diagnostics.Metrics;
namespace ZB.MOM.WW.LocalDb.Replication;
/// <summary>
/// The replication engine's OpenTelemetry instruments, under the single <c>zb.mom.ww.localdb</c>
/// meter. Optional throughout: the sessions and adapters hold a nullable reference and every call
/// is null-guarded, so replication runs identically whether or not this is registered. Instrument
/// names follow the family metric conventions (lower-case, dot-separated, seconds for durations).
/// </summary>
public sealed class LocalDbMetrics : IDisposable
{
/// <summary>The single meter name every replication instrument is published under.</summary>
public const string MeterName = "zb.mom.ww.localdb";
private readonly Meter _meter;
private readonly Counter<long> _applied;
private readonly Counter<long> _conflictsDiscarded;
private readonly Counter<long> _deadLettered;
private readonly Counter<long> _reconnects;
private readonly Counter<long> _snapshots;
private readonly Func<DateTimeOffset> _utcNow;
private bool _disposed;
public LocalDbMetrics(Func<DateTimeOffset>? utcNow = null)
{
_utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
_meter = new Meter(MeterName, "0.1.0");
_applied = _meter.CreateCounter<long>("localdb.sync.applied");
_conflictsDiscarded = _meter.CreateCounter<long>("localdb.sync.conflicts_discarded");
_deadLettered = _meter.CreateCounter<long>("localdb.sync.dead_lettered");
_reconnects = _meter.CreateCounter<long>("localdb.sync.reconnects");
_snapshots = _meter.CreateCounter<long>("localdb.sync.snapshots");
_meter.CreateObservableGauge("localdb.oplog.depth", ObserveOplogDepth);
_meter.CreateObservableGauge("localdb.sync.lag.seconds", ObserveSyncLag, "s");
}
/// <summary>
/// Supplies the current unacked oplog backlog for the <c>localdb.oplog.depth</c> gauge. Invoked
/// only at collection time; a cheap synchronous SELECT is acceptable at that frequency.
/// </summary>
public Func<long>? OplogDepthProvider { get; set; }
/// <summary>Supplies the last successful sync time for the <c>localdb.sync.lag.seconds</c> gauge; null means no sync yet, and the gauge then reports no measurement.</summary>
public Func<DateTimeOffset?>? LastSyncUtcProvider { get; set; }
/// <summary>The backing meter — exposed so a test <see cref="MeterListener"/> can filter by instance reference rather than the process-shared meter name.</summary>
internal Meter Meter => _meter;
public void RecordApplied(long count)
{
if (count > 0) _applied.Add(count);
}
public void RecordConflictsDiscarded(long count)
{
if (count > 0) _conflictsDiscarded.Add(count);
}
public void RecordDeadLettered(long count)
{
if (count > 0) _deadLettered.Add(count);
}
public void RecordReconnect() => _reconnects.Add(1);
public void RecordSnapshot(string direction) =>
_snapshots.Add(1, new KeyValuePair<string, object?>("direction", direction));
private IEnumerable<Measurement<long>> ObserveOplogDepth()
{
var provider = OplogDepthProvider;
if (provider is null)
return [];
try
{
return [new Measurement<long>(provider())];
}
catch
{
// The observation SELECT can race a disposed db / closing connection; a scrape must
// never throw, so drop this sample rather than surface a partial value.
return [];
}
}
private IEnumerable<Measurement<double>> ObserveSyncLag()
{
var last = LastSyncUtcProvider?.Invoke();
if (last is null)
return [];
var lag = (_utcNow() - last.Value).TotalSeconds;
if (lag < 0) lag = 0;
return [new Measurement<double>(lag)];
}
public void Dispose()
{
if (_disposed)
return;
_meter.Dispose();
_disposed = true;
}
}
@@ -18,16 +18,24 @@ public sealed class LocalDbSyncService : LocalDbSync.LocalDbSyncBase
private readonly ReplicationOptions _options; private readonly ReplicationOptions _options;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<LocalDbSyncService> _logger; private readonly ILogger<LocalDbSyncService> _logger;
private readonly LocalDbMetrics? _metrics;
private readonly SyncStatus? _status;
// 0 = idle, 1 = a stream is active. Interlocked-guarded across concurrent calls (singleton service). // 0 = idle, 1 = a stream is active. Interlocked-guarded across concurrent calls (singleton service).
private int _active; private int _active;
public LocalDbSyncService(ILocalDb db, IOptions<ReplicationOptions> options, ILoggerFactory loggerFactory) // status is taken as the public ISyncStatus (SyncStatus is internal and cannot appear in a
// public signature); DI always supplies the concrete SyncStatus this cast recovers.
public LocalDbSyncService(
ILocalDb db, IOptions<ReplicationOptions> options, ILoggerFactory loggerFactory,
LocalDbMetrics? metrics = null, ISyncStatus? status = null)
{ {
_db = SyncSessionFactory.RequireSqlite(db, nameof(LocalDbSyncService)); _db = SyncSessionFactory.RequireSqlite(db, nameof(LocalDbSyncService));
_options = options.Value; _options = options.Value;
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<LocalDbSyncService>(); _logger = loggerFactory.CreateLogger<LocalDbSyncService>();
_metrics = metrics;
_status = status as SyncStatus;
} }
public override async Task Sync( public override async Task Sync(
@@ -46,13 +54,15 @@ public sealed class LocalDbSyncService : LocalDbSync.LocalDbSyncBase
// handler has exited. // handler has exited.
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
var (duplex, readerTask) = GrpcSyncDuplex.Create(requestStream, responseStream, linkedCts.Token); var (duplex, readerTask) = GrpcSyncDuplex.Create(requestStream, responseStream, linkedCts.Token);
var session = SyncSessionFactory.Create(_db, _options, _loggerFactory); var session = SyncSessionFactory.Create(_db, _options, _loggerFactory, _metrics, _status);
_status?.SetConnected(true);
try try
{ {
await session.RunAsync(duplex, linkedCts.Token); await session.RunAsync(duplex, linkedCts.Token);
} }
finally finally
{ {
_status?.SetConnected(false);
linkedCts.Cancel(); linkedCts.Cancel();
try try
{ {
@@ -26,6 +26,8 @@ public sealed class SyncBackgroundService : BackgroundService
private readonly ReplicationOptions _options; private readonly ReplicationOptions _options;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<SyncBackgroundService> _logger; private readonly ILogger<SyncBackgroundService> _logger;
private readonly LocalDbMetrics? _metrics;
private readonly SyncStatus? _status;
/// <summary>Test seam: supplies the gRPC channel instead of dialing <see cref="ReplicationOptions.PeerAddress"/>. A channel it returns is owned by the caller and not disposed here.</summary> /// <summary>Test seam: supplies the gRPC channel instead of dialing <see cref="ReplicationOptions.PeerAddress"/>. A channel it returns is owned by the caller and not disposed here.</summary>
internal Func<GrpcChannel>? ChannelFactory { get; set; } internal Func<GrpcChannel>? ChannelFactory { get; set; }
@@ -33,12 +35,18 @@ public sealed class SyncBackgroundService : BackgroundService
/// <summary>Number of connection attempts made this lifetime (one per reconnect loop iteration).</summary> /// <summary>Number of connection attempts made this lifetime (one per reconnect loop iteration).</summary>
internal int ConnectionAttempts; internal int ConnectionAttempts;
public SyncBackgroundService(ILocalDb db, IOptions<ReplicationOptions> options, ILoggerFactory loggerFactory) // status is taken as the public ISyncStatus (SyncStatus is internal and cannot appear in a
// public signature); DI always supplies the concrete SyncStatus this cast recovers.
public SyncBackgroundService(
ILocalDb db, IOptions<ReplicationOptions> options, ILoggerFactory loggerFactory,
LocalDbMetrics? metrics = null, ISyncStatus? status = null)
{ {
_db = SyncSessionFactory.RequireSqlite(db, nameof(SyncBackgroundService)); _db = SyncSessionFactory.RequireSqlite(db, nameof(SyncBackgroundService));
_options = options.Value; _options = options.Value;
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SyncBackgroundService>(); _logger = loggerFactory.CreateLogger<SyncBackgroundService>();
_metrics = metrics;
_status = status as SyncStatus;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -52,7 +60,11 @@ public sealed class SyncBackgroundService : BackgroundService
var backoff = InitialBackoff; var backoff = InitialBackoff;
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
Interlocked.Increment(ref ConnectionAttempts); var attempt = Interlocked.Increment(ref ConnectionAttempts);
_status?.SetConnectionAttempts(attempt);
// Every attempt past the first is a reconnect (the initial dial is not).
if (attempt > 1)
_metrics?.RecordReconnect();
var lifetime = System.Diagnostics.Stopwatch.StartNew(); var lifetime = System.Diagnostics.Stopwatch.StartNew();
Exception? fault = null; Exception? fault = null;
GrpcChannel? ownedChannel = null; GrpcChannel? ownedChannel = null;
@@ -75,7 +87,8 @@ public sealed class SyncBackgroundService : BackgroundService
// stream this iteration has abandoned. // stream this iteration has abandoned.
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
var (duplex, readerTask) = GrpcSyncDuplex.Create(call.ResponseStream, call.RequestStream, linkedCts.Token); var (duplex, readerTask) = GrpcSyncDuplex.Create(call.ResponseStream, call.RequestStream, linkedCts.Token);
var session = SyncSessionFactory.Create(_db, _options, _loggerFactory); var session = SyncSessionFactory.Create(_db, _options, _loggerFactory, _metrics, _status);
_status?.SetConnected(true);
try try
{ {
await session.RunAsync(duplex, linkedCts.Token); await session.RunAsync(duplex, linkedCts.Token);
@@ -83,6 +96,7 @@ public sealed class SyncBackgroundService : BackgroundService
} }
finally finally
{ {
_status?.SetConnected(false);
linkedCts.Cancel(); linkedCts.Cancel();
try try
{ {
@@ -0,0 +1,492 @@
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 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>();
Assert.False(status.Connected);
using var cts = new CancellationTokenSource(RunTimeout);
await bg.StartAsync(cts.Token);
await WaitForAsync(() => Task.FromResult(status.Connected), RunTimeout);
await WaitForAsync(
async () => (await ReadOrders(clientDb)).Count == 2 && (await ReadOrders(serverDb)).Count == 2,
RunTimeout);
Assert.NotNull(status.PeerNodeId);
Assert.NotNull(status.LastSyncUtc);
await bg.StopAsync(CancellationToken.None);
await WaitForAsync(() => Task.FromResult(!status.Connected), RunTimeout);
Assert.False(status.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);
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();
}
}