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
@@ -271,6 +271,9 @@ public class SiteAuditTelemetryActor : ReceiveActor
var batch = new CachedTelemetryBatch();
var wireBytes = AuditWireBudget.InitialBytes;
var emittedEventIds = new List<Guid>(pending.Count);
// EventId → the tracking-row version its packet carried, so an accepted
// packet can be recorded as a central ack (review 2026-10-21 06/PERF-2).
var packetVersions = new Dictionary<Guid, (TrackedOperationId Id, DateTime UpdatedAtUtc)>(pending.Count);
// Rows whose operational half can never be built. They are marked
// PushAbandoned so they LEAVE this queue — see AbandonUnresolvableAsync
// for why that is safe and why leaving them Pending is not.
@@ -338,6 +341,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
var packet = BuildCachedPacket(auditRow, snapshot);
if (!AuditWireBudget.TryAppend(batch.Packets, packet, ref wireBytes, auditRow.EventId.ToString())) break;
emittedEventIds.Add(auditRow.EventId);
packetVersions[auditRow.EventId] = (snapshot.Id, snapshot.UpdatedAtUtc);
}
if (lookupFailures > 0)
@@ -389,6 +393,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
{
await _queue.MarkForwardedAsync(acceptedIds, ct)
.ConfigureAwait(false);
await RecordCentralAcksAsync(acceptedIds, packetVersions, ct).ConfigureAwait(false);
}
await RecordRefusalsAsync(
@@ -413,6 +418,51 @@ public class SiteAuditTelemetryActor : ReceiveActor
}
}
/// <summary>
/// Tells the tracking store which tracking-row versions central has just accepted, so
/// the SiteCalls reconciliation pull stops re-serving them (review 2026-10-21
/// 06/PERF-2). Accepted means central committed the packet's <c>SiteCalls</c> upsert
/// in the same transaction as its audit row, i.e. central now holds exactly what a pull
/// of that version would send. Several packets of one operation in a batch collapse to
/// the newest version. Best-effort: the store only enqueues, and a failure here just
/// leaves the rows eligible for one more pull.
/// </summary>
private async Task RecordCentralAcksAsync(
IReadOnlyList<Guid> acceptedIds,
Dictionary<Guid, (TrackedOperationId Id, DateTime UpdatedAtUtc)> packetVersions,
CancellationToken ct)
{
var versions = new Dictionary<TrackedOperationId, DateTime>();
foreach (var eventId in acceptedIds)
{
if (!packetVersions.TryGetValue(eventId, out var v))
{
continue;
}
if (!versions.TryGetValue(v.Id, out var existing) || v.UpdatedAtUtc > existing)
{
versions[v.Id] = v.UpdatedAtUtc;
}
}
if (versions.Count == 0 || _trackingStore is null)
{
return;
}
try
{
await _trackingStore.RecordCentralAcksAsync(versions, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex,
"Recording {Count} central ack(s) in the tracking store failed; those rows stay eligible for the reconciliation pull.",
versions.Count);
}
}
/// <summary>
/// Moves cached rows whose operational half can never be built out of the
/// cached-drain queue, as <see cref="SiteAuditForwardStates.PushAbandoned"/>.
@@ -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;
}
@@ -169,6 +169,84 @@ public class SiteCallAuditRepository : ISiteCallAuditRepository
}
}
/// <summary>
/// Rows per batched upsert command. Each row binds 15 parameters, so 128 rows stay
/// under SQL Server's 2 100-parameter limit (1 920) with headroom.
/// </summary>
internal const int UpsertManyChunkSize = 128;
/// <inheritdoc />
public async Task UpsertManyAsync(IReadOnlyList<SiteCall> siteCalls, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(siteCalls);
// The reconciliation pull's write path (review 2026-10-21 06/PERF-2): one command
// per chunk of UpsertManyChunkSize rows instead of one round trip per row — a
// 500-row page is 4 round trips, not 500. Each row runs the SAME two statements
// UpsertAsync runs (InsertIfAbsentSql then MonotonicUpdateSql), with its parameters
// suffixed per row, so the insert-first / monotonic / ownership semantics are
// byte-identical rather than re-derived set-based.
//
// Any SqlException from a chunk — an insert race's 2601/2627 (statement-level,
// the batch runs on) or an ownership THROW 51001 (batch-terminating) — re-runs that
// chunk row by row through UpsertAsync. Re-applying rows the batch already applied
// is inert (equal rank + equal UpdatedAtUtc changes nothing) and UpsertAsync handles
// the race exactly as before. A genuinely bad row does not stop the rest of the
// page: the remaining rows are still applied and the FIRST such failure is
// rethrown at the end, so the caller sees the same exception the old per-row
// loop surfaced (and keeps its cursor), minus the collateral of the rows behind it.
System.Runtime.ExceptionServices.ExceptionDispatchInfo? firstFailure = null;
for (var offset = 0; offset < siteCalls.Count; offset += UpsertManyChunkSize)
{
var chunk = siteCalls.Skip(offset).Take(UpsertManyChunkSize).ToList();
var sql = new System.Text.StringBuilder();
var parameters = new List<object>(chunk.Count * 15);
for (var i = 0; i < chunk.Count; i++)
{
var siteCall = chunk[i] ?? throw new ArgumentException("A null SiteCall row was supplied.", nameof(siteCalls));
var rank = GetRankOrThrow(siteCall.Status);
var suffix = "_" + i.ToString(System.Globalization.CultureInfo.InvariantCulture);
sql.AppendLine(SuffixParameters(InsertIfAbsentSql, suffix));
sql.AppendLine(SuffixParameters(MonotonicUpdateSql, suffix));
foreach (SqlParameter p in BuildUpsertParameters(siteCall, siteCall.TrackedOperationId.Value.ToString("D"), rank))
{
p.ParameterName += suffix;
parameters.Add(p);
}
}
try
{
await _context.Database.ExecuteSqlRawAsync(sql.ToString(), parameters, ct).ConfigureAwait(false);
}
catch (SqlException ex)
{
_logger.LogDebug(
ex,
"SiteCallAuditRepository.UpsertManyAsync chunk of {Count} rows faulted (error {SqlErrorNumber}); re-applying it row by row.",
chunk.Count,
ex.Number);
foreach (var siteCall in chunk)
{
try
{
await UpsertAsync(siteCall, ct).ConfigureAwait(false);
}
catch (Exception rowFailure) when (rowFailure is not OperationCanceledException)
{
firstFailure ??= System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(rowFailure);
}
}
}
}
firstFailure?.Throw();
}
/// <summary>Renames every <c>@Name</c> parameter reference in <paramref name="sql"/> to <c>@Name{suffix}</c>.</summary>
private static string SuffixParameters(string sql, string suffix) =>
System.Text.RegularExpressions.Regex.Replace(sql, @"@(\w+)", "@$1" + suffix);
// Leg 1 of the upsert: create the row when it does not exist yet. Runs FIRST
// so a concurrent first-write loser still has a row to update (see UpsertAsync).
private const string InsertIfAbsentSql = @"
@@ -499,6 +499,8 @@ public class SiteCallAuditActor : ReceiveActor
return;
}
PruneRemovedSites(sites);
if (sites.Count == 0)
{
return;
@@ -516,6 +518,30 @@ public class SiteCallAuditActor : ReceiveActor
await ReconcileSitesAsync(sites, client, repository).ConfigureAwait(false);
}
/// <summary>
/// Drops the per-site cursor and pinned latch of every site the enumerator no longer
/// returns (a deleted or renamed site), so the dictionaries do not grow with site
/// churn for the singleton's lifetime. A site that was pinned publishes its
/// <c>Pinned=false</c> transition first, so a health subscriber does not keep showing
/// a pinned site that no longer exists. Runs on the single-flight pass that owns the
/// dictionaries. A site that later reappears simply starts again from
/// <c>DateTime.MinValue</c>, which the idempotent upsert absorbs.
/// </summary>
private void PruneRemovedSites(IReadOnlyList<SiteEntry> sites)
{
var live = new HashSet<string>(sites.Select(s => s.SiteId), StringComparer.Ordinal);
foreach (var siteId in _reconciliationPinned.Keys.Where(k => !live.Contains(k)).ToList())
{
SetReconciliationPinned(siteId, pinned: false);
_reconciliationPinned.Remove(siteId);
}
foreach (var siteId in _reconciliationCursors.Keys.Where(k => !live.Contains(k)).ToList())
{
_reconciliationCursors.Remove(siteId);
}
}
/// <summary>
/// Reconciles every site in the tick against a single resolved repository,
/// isolating per-site faults so one bad site never sinks the rest of the
@@ -635,15 +661,19 @@ public class SiteCallAuditActor : ReceiveActor
var maxUpdated = since;
var maxAfterId = afterId;
var nowUtc = DateTime.UtcNow;
// IngestedAtUtc is the "central ingested (or last refreshed) this row" stamp —
// owned by the central actor, exactly as OnUpsertAsync does for the telemetry
// path. The whole page is written through ONE batched monotonic upsert (a few
// round trips per page instead of one per row — review 2026-10-21 06/PERF-2);
// a row already present from a prior push is a silent no-op. A fault propagates
// to the per-site catch BEFORE the cursor below moves, so the page is re-pulled.
await repository
.UpsertManyAsync(response.SiteCalls.Select(row => row with { IngestedAtUtc = nowUtc }).ToList())
.ConfigureAwait(false);
foreach (var row in response.SiteCalls)
{
// IngestedAtUtc is the "central ingested (or last refreshed) this
// row" stamp — owned by the central actor, exactly as OnUpsertAsync
// does for the telemetry path. Monotonic UpsertAsync makes a row
// already present (from a prior push) a silent no-op.
var siteCall = row with { IngestedAtUtc = nowUtc };
await repository.UpsertAsync(siteCall).ConfigureAwait(false);
// Advance the composite max: greater timestamp wins; on a tie the
// greater TrackedOperationId (ordinal, matching the site's SQLite
// BINARY text collation) wins.
@@ -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;
""";