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
@@ -26,6 +26,8 @@ public sealed class SyncBackgroundService : BackgroundService
private readonly ReplicationOptions _options;
private readonly ILoggerFactory _loggerFactory;
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>
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>
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));
_options = options.Value;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SyncBackgroundService>();
_metrics = metrics;
_status = status as SyncStatus;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -52,7 +60,11 @@ public sealed class SyncBackgroundService : BackgroundService
var backoff = InitialBackoff;
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();
Exception? fault = null;
GrpcChannel? ownedChannel = null;
@@ -75,7 +87,8 @@ public sealed class SyncBackgroundService : BackgroundService
// stream this iteration has abandoned.
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
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
{
await session.RunAsync(duplex, linkedCts.Token);
@@ -83,6 +96,7 @@ public sealed class SyncBackgroundService : BackgroundService
}
finally
{
_status?.SetConnected(false);
linkedCts.Cancel();
try
{