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:
@@ -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;
|
||||
""";
|
||||
|
||||
Reference in New Issue
Block a user