fix(site-call-audit): reconciliation pull serves only un-acked tracking rows (node-local ack ledger), batched page upsert, prune removed sites' cursors (review 2026-10-21 06/PERF-2 + lane H follow-up)

This commit is contained in:
Joseph Doherty
2026-10-22 09:35:30 -04:00
parent e69be1654f
commit 88273693f9
12 changed files with 789 additions and 10 deletions
@@ -141,7 +141,34 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
// which is strictly before this constructor can receive it. It stays because it
// keeps a directly-constructed store (tests, tooling) self-sufficient, and the
// DDL is idempotent.
private void InitializeSchema() => OperationTrackingSchema.Apply(_writeConnection);
private void InitializeSchema()
{
OperationTrackingSchema.Apply(_writeConnection);
using var cmd = _writeConnection.CreateCommand();
cmd.CommandText = CentralAckSchemaSql;
cmd.ExecuteNonQuery();
}
/// <summary>
/// Node-local ledger of the tracking-row version central last acknowledged per
/// operation (review 2026-10-21 06/PERF-2). Deliberately a SEPARATE table, never a
/// column on <c>OperationTracking</c>: that table is LocalDb-replicated and its
/// capture triggers are built from its column list, while acks are per-node fact —
/// only the node that pushed the telemetry saw central accept it. Like
/// <c>mirrored_secrets</c>, it is not in <c>SiteLocalDbSetup.ReplicatedTables</c>.
/// <para>
/// Failover: the standby holds no ack rows, so after it takes over its
/// reconciliation read serves every row once (bounded by central's cursor), and
/// central's idempotent monotonic upsert absorbs the duplicates. Pushes from the new
/// active node rebuild the ledger from then on.
/// </para>
/// </summary>
private const string CentralAckSchemaSql = """
CREATE TABLE IF NOT EXISTS OperationTrackingCentralAck (
TrackedOperationId TEXT NOT NULL PRIMARY KEY,
AckedUpdatedAtUtc TEXT NOT NULL
);
""";
/// <inheritdoc/>
public async Task RecordEnqueueAsync(
@@ -271,6 +298,51 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
}, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task RecordCentralAcksAsync(
IReadOnlyDictionary<TrackedOperationId, DateTime> ackedVersions,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(ackedVersions);
if (ackedVersions.Count == 0)
{
return;
}
// Rendered exactly as the write path stamps UpdatedAtUtc ("o", UTC 'Z'), so the
// read filter's text equality compares like with like.
var acks = ackedVersions
.Select(a => (Id: a.Key.ToString(), Version: DateTime
.SpecifyKind(a.Value, DateTimeKind.Utc)
.ToString("o", CultureInfo.InvariantCulture)))
.ToList();
// Rides the single-writer queue like a lifecycle write (enqueue and return) —
// the ack is best-effort, and FIFO keeps it behind the writes it describes.
await EnqueueAsync($"central-ack x{acks.Count}", (conn, tx) =>
{
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
// Monotonic: an older ack never rolls a newer one back. ISO-8601 "o" text
// orders lexicographically.
cmd.CommandText = """
INSERT INTO OperationTrackingCentralAck (TrackedOperationId, AckedUpdatedAtUtc)
VALUES ($id, $version)
ON CONFLICT (TrackedOperationId) DO UPDATE
SET AckedUpdatedAtUtc = excluded.AckedUpdatedAtUtc
WHERE excluded.AckedUpdatedAtUtc > AckedUpdatedAtUtc;
""";
var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
var versionParam = cmd.Parameters.Add("$version", SqliteType.Text);
foreach (var (id, version) in acks)
{
idParam.Value = id;
versionParam.Value = version;
cmd.ExecuteNonQuery();
}
}, ct).ConfigureAwait(false);
}
// ── Queue plumbing ─────────────────────────────────────────────────────
/// <summary>
@@ -494,10 +566,17 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
cmd.Transaction = tx;
// Non-terminal rows (TerminalAtUtc IS NULL) are kept regardless of
// age — the operation is still in flight.
// The node-local ack ledger is pruned in the same transaction so it never
// outlives the rows it describes (it also drops acks for rows the peer's
// purge removed through replication).
cmd.CommandText = """
DELETE FROM OperationTracking
WHERE TerminalAtUtc IS NOT NULL
AND TerminalAtUtc < $threshold;
DELETE FROM OperationTrackingCentralAck
WHERE NOT EXISTS (
SELECT 1 FROM OperationTracking t
WHERE t.TrackedOperationId = OperationTrackingCentralAck.TrackedOperationId);
""";
cmd.Parameters.AddWithValue("$threshold", threshold);
cmd.ExecuteNonQuery();
@@ -543,12 +622,23 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
var predicate = useKeyset
? "UpdatedAtUtc > $since OR (UpdatedAtUtc = $since AND TrackedOperationId > $afterId)"
: "UpdatedAtUtc >= $since";
// Rows whose CURRENT version central already acknowledged through the
// cached-telemetry push are skipped (review 2026-10-21 06/PERF-2): central holds
// exactly what this read would send, so serving them only replays every
// lifecycle step a second time — and, after a central failover reset the
// cursor, the whole retention window. Equality (not >=) on the version is the
// safe direction: any later change, even one stamped by a clock that stepped
// backwards, no longer matches and is served.
cmd.CommandText = $"""
SELECT TrackedOperationId, Kind, TargetSummary, Status,
RetryCount, LastError, HttpStatus,
CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, SourceNode
FROM OperationTracking
WHERE {predicate}
WHERE ({predicate})
AND NOT EXISTS (
SELECT 1 FROM OperationTrackingCentralAck a
WHERE a.TrackedOperationId = OperationTracking.TrackedOperationId
AND a.AckedUpdatedAtUtc = OperationTracking.UpdatedAtUtc)
ORDER BY UpdatedAtUtc ASC, TrackedOperationId ASC
LIMIT $batchSize;
""";