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
@@ -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 = @"