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