feat(localdb): oplog store (batch read, ack watermark, pruning caps, tombstone retention)

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-17 21:45:20 -04:00
parent c061ba733f
commit 529585163c
3 changed files with 344 additions and 0 deletions
@@ -0,0 +1,116 @@
using System.Globalization;
using ZB.MOM.WW.LocalDb.Hlc;
namespace ZB.MOM.WW.LocalDb.Replication.Internal;
/// <summary>A single oplog delta as read for outbound replication. <see cref="RowJson"/> is null for tombstones.</summary>
internal sealed record OplogEntryRecord(
long Seq, string TableName, string PkJson, string? RowJson, long Hlc, string NodeId, bool IsTombstone);
/// <summary>The persisted peer-sync watermarks and flags from <c>__localdb_peer_state</c>.</summary>
internal sealed record PeerState(
string? PeerNodeId, long LastAckedSeq, long LastAppliedRemoteSeq, long LastSeenHlc, bool NeedsSnapshot, string? LastSyncUtc);
/// <summary>
/// DB-side operations over the replication bookkeeping tables: batched oplog reads above a
/// watermark, peer ack + prune, backlog/age cap enforcement, tombstone retention, and peer state.
/// </summary>
internal sealed class OplogStore(ILocalDb db, ReplicationOptions options, Func<DateTimeOffset>? utcNow = null)
{
// Must match the delete trigger's strftime('%Y-%m-%dT%H:%M:%fZ') output so string comparison is chronological.
private const string TombstoneUtcFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
private readonly Func<DateTimeOffset> _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
public Task<IReadOnlyList<OplogEntryRecord>> ReadBatchAboveAsync(long afterSeq, int maxBatch, CancellationToken ct = default) =>
db.QueryAsync(
"SELECT seq, table_name, pk_json, row_json, hlc, node_id, is_tombstone FROM __localdb_oplog " +
"WHERE seq > @afterSeq ORDER BY seq LIMIT @maxBatch",
static r => new OplogEntryRecord(
r.GetInt64(0), r.GetString(1), r.GetString(2), r.IsDBNull(3) ? null : r.GetString(3),
r.GetInt64(4), r.GetString(5), r.GetInt64(6) != 0),
new { afterSeq, maxBatch }, ct);
public async Task RecordPeerAckAsync(long ackedSeq, CancellationToken ct = default)
{
await using var tx = await db.BeginTransactionAsync(ct);
await tx.ExecuteAsync(
"UPDATE __localdb_peer_state SET last_acked_seq = MAX(last_acked_seq, @acked), last_sync_utc = @now WHERE id = 1",
new { acked = ackedSeq, now = FormatUtc(_utcNow()) }, ct);
await tx.ExecuteAsync(
"DELETE FROM __localdb_oplog WHERE seq <= (SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1)",
null, ct);
await tx.CommitAsync(ct);
}
public Task PruneTombstonesAsync(CancellationToken ct = default) =>
db.ExecuteAsync(
"DELETE FROM __localdb_row_version WHERE is_tombstone = 1 AND tombstone_utc < @cutoff",
new { cutoff = FormatUtc(_utcNow() - options.TombstoneRetention) }, ct);
public async Task<long> GetOplogDepthAsync(CancellationToken ct = default)
{
var rows = await db.QueryAsync(
"SELECT COUNT(*) FROM __localdb_oplog WHERE seq > (SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1)",
static r => r.GetInt64(0), null, ct);
return rows[0];
}
public async Task<long> GetMaxSeqAsync(CancellationToken ct = default)
{
var rows = await db.QueryAsync(
"SELECT COALESCE(MAX(seq), 0) FROM __localdb_oplog", static r => r.GetInt64(0), null, ct);
return rows[0];
}
public async Task<bool> EnforceCapsAsync(CancellationToken ct = default)
{
var overRows = await GetOplogDepthAsync(ct) > options.MaxOplogRows;
var overAge = false;
var oldest = await db.QueryAsync(
"SELECT hlc FROM __localdb_oplog ORDER BY seq LIMIT 1", static r => r.GetInt64(0), null, ct);
if (oldest.Count > 0)
{
var cutoffMs = (_utcNow() - options.MaxOplogAge).ToUnixTimeMilliseconds();
overAge = HybridLogicalClock.PhysicalMs(oldest[0]) < cutoffMs;
}
if (!overRows && !overAge)
return false;
await using var tx = await db.BeginTransactionAsync(ct);
await tx.ExecuteAsync("UPDATE __localdb_peer_state SET needs_snapshot = 1 WHERE id = 1", null, ct);
await tx.ExecuteAsync(
"DELETE FROM __localdb_oplog WHERE seq < ((SELECT MAX(seq) FROM __localdb_oplog) - @keep + 1)",
new { keep = options.MaxOplogRows }, ct);
await tx.CommitAsync(ct);
return true;
}
public async Task<PeerState> GetPeerStateAsync(CancellationToken ct = default)
{
var rows = await db.QueryAsync(
"SELECT peer_node_id, last_acked_seq, last_applied_remote_seq, last_seen_hlc, needs_snapshot, last_sync_utc " +
"FROM __localdb_peer_state WHERE id = 1",
static r => new PeerState(
r.IsDBNull(0) ? null : r.GetString(0), r.GetInt64(1), r.GetInt64(2), r.GetInt64(3),
r.GetInt64(4) != 0, r.IsDBNull(5) ? null : r.GetString(5)),
null, ct);
return rows[0];
}
public Task SetPeerNodeIdAsync(string peerNodeId, CancellationToken ct = default) =>
db.ExecuteAsync("UPDATE __localdb_peer_state SET peer_node_id = @v WHERE id = 1", new { v = peerNodeId }, ct);
public Task SetNeedsSnapshotAsync(bool value, CancellationToken ct = default) =>
db.ExecuteAsync("UPDATE __localdb_peer_state SET needs_snapshot = @v WHERE id = 1", new { v = value ? 1 : 0 }, ct);
public Task SetLastAppliedRemoteSeqAsync(long seq, long lastSeenHlc, CancellationToken ct = default) =>
db.ExecuteAsync(
"UPDATE __localdb_peer_state SET last_applied_remote_seq = @seq, last_seen_hlc = @hlc, last_sync_utc = @now WHERE id = 1",
new { seq, hlc = lastSeenHlc, now = FormatUtc(_utcNow()) }, ct);
private static string FormatUtc(DateTimeOffset value) =>
value.UtcDateTime.ToString(TombstoneUtcFormat, CultureInfo.InvariantCulture);
}
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.LocalDb.Replication;
/// <summary>
/// Configuration for the bidirectional 2-node replication engine. All durations are wall-clock UTC.
/// </summary>
public sealed class ReplicationOptions
{
/// <summary>gRPC address of the peer to dial. Required for the initiator role (validated in the sync host).</summary>
public string PeerAddress { get; set; } = "";
/// <summary>API key presented to / expected from the peer; null disables key auth.</summary>
public string? ApiKey { get; set; }
/// <summary>How often the change pump flushes pending oplog deltas to the peer.</summary>
public TimeSpan FlushInterval { get; set; } = TimeSpan.FromMilliseconds(250);
/// <summary>Maximum oplog entries sent per outbound batch.</summary>
public int MaxBatchSize { get; set; } = 500;
/// <summary>Backlog ceiling: exceeding it flags a snapshot resync and prunes the oldest oplog rows.</summary>
public long MaxOplogRows { get; set; } = 1_000_000;
/// <summary>Age ceiling: an unpruned oplog entry older than this flags a snapshot resync.</summary>
public TimeSpan MaxOplogAge { get; set; } = TimeSpan.FromDays(7);
/// <summary>How long deleted-row tombstones are retained before pruning.</summary>
public TimeSpan TombstoneRetention { get; set; } = TimeSpan.FromDays(7);
/// <summary>Upper bound on the exponential reconnect backoff.</summary>
public TimeSpan ReconnectBackoffMax { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Maximum tolerated HLC drift where a peer's clock runs ahead of ours.</summary>
public TimeSpan MaxHlcDriftAhead { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>When true, drift beyond <see cref="MaxHlcDriftAhead"/> rejects the batch instead of clamping.</summary>
public bool FailClosedOnDrift { get; set; }
}