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
@@ -42,6 +42,27 @@ public interface ISiteCallAuditRepository
/// <returns>A task that represents the asynchronous operation.</returns>
Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default);
/// <summary>
/// Applies <see cref="UpsertAsync"/>'s exact per-row semantics (insert-if-absent, then
/// the monotonic rank-guarded update, ownership-checked) to a whole page of rows in as
/// few round trips as the implementation can manage — the reconciliation pull's write
/// path (review 2026-10-21 06/PERF-2). Rows are applied in order; a row that faults
/// (e.g. an ownership violation) surfaces as the same exception <see cref="UpsertAsync"/>
/// would throw — the SQL Server implementation applies the rest of the page first. The
/// default implementation simply upserts row by row and stops at the first fault.
/// </summary>
/// <param name="siteCalls">The rows to insert or monotonically update.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
async Task UpsertManyAsync(IReadOnlyList<SiteCall> siteCalls, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(siteCalls);
foreach (var siteCall in siteCalls)
{
await UpsertAsync(siteCall, ct).ConfigureAwait(false);
}
}
/// <summary>
/// Returns the row for the given id, or <c>null</c> if none exists.
/// </summary>
@@ -150,6 +150,15 @@ public interface IOperationTrackingStore
/// deterministic — <c>UpdatedAtUtc ASC, TrackedOperationId ASC</c>.
/// </para>
/// <para>
/// <b>Only rows central has not already acknowledged.</b> A row whose
/// current version (<c>UpdatedAtUtc</c>) central acknowledged through the
/// cached-telemetry push — recorded via <see cref="RecordCentralAcksAsync"/> — is
/// skipped: central already holds exactly what this read would send. The pull
/// therefore carries only lost telemetry, not a replay of every lifecycle step
/// (review 2026-10-21 06/PERF-2). The ack ledger is node-local, so a node that
/// has just become active serves every row once.
/// </para>
/// <para>
/// <see cref="SiteCallOperational.SourceSite"/> is left as the empty string:
/// the site id is not a tracking-store column, and the central client re-stamps
/// it from the <c>siteId</c> it dialed (the only authority that knows which
@@ -174,4 +183,24 @@ public interface IOperationTrackingStore
int batchSize,
string? afterId = null,
CancellationToken ct = default);
/// <summary>
/// Records that central accepted the cached-telemetry push of each operation at the
/// given tracking-row version (the snapshot's <c>UpdatedAtUtc</c> the push carried),
/// so <see cref="ReadChangedSinceAsync"/> stops serving that version to the
/// reconciliation pull. Monotonic per operation (an older version never replaces a
/// newer one) and best-effort: a lost ack only means the row is pulled once more,
/// which central's idempotent upsert absorbs.
/// </summary>
/// <remarks>
/// The ledger lives in a NODE-LOCAL side table, never a column on the replicated
/// tracking table. The default implementation records nothing (every row stays
/// eligible for the pull — the pre-ack behaviour).
/// </remarks>
/// <param name="ackedVersions">Operation id → the <c>UpdatedAtUtc</c> of the snapshot central accepted.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that completes once the acks are queued.</returns>
Task RecordCentralAcksAsync(
IReadOnlyDictionary<TrackedOperationId, DateTime> ackedVersions,
CancellationToken ct = default) => Task.CompletedTask;
}