fix(site-call-audit): an unknown-status row no longer aborts the rest of a batched page (review 2026-10-21 06/PERF-2 follow-up)

This commit is contained in:
Joseph Doherty
2026-10-22 09:49:54 -04:00
parent 5f7f889a96
commit 46ab5594ec
2 changed files with 60 additions and 5 deletions
@@ -196,19 +196,40 @@ public class SiteCallAuditRepository : ISiteCallAuditRepository
// 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)
// Rows UpsertAsync would reject before touching the database (a null row, an
// unknown status) are validated up front and SKIPPED with their failure captured,
// so one malformed row cannot stop the rest of the page from applying.
var valid = new List<(SiteCall Row, int Rank)>(siteCalls.Count);
foreach (var siteCall in siteCalls)
{
var chunk = siteCalls.Skip(offset).Take(UpsertManyChunkSize).ToList();
try
{
if (siteCall is null)
{
throw new ArgumentException("A null SiteCall row was supplied.", nameof(siteCalls));
}
valid.Add((siteCall, GetRankOrThrow(siteCall.Status)));
}
catch (ArgumentException invalid)
{
firstFailure ??= System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(invalid);
}
}
for (var offset = 0; offset < valid.Count; offset += UpsertManyChunkSize)
{
var chunk = valid.Skip(offset).Take(UpsertManyChunkSize).Select(v => v.Row).ToList();
var ranks = valid.Skip(offset).Take(UpsertManyChunkSize).Select(v => v.Rank).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 siteCall = chunk[i];
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))
foreach (SqlParameter p in BuildUpsertParameters(siteCall, siteCall.TrackedOperationId.Value.ToString("D"), ranks[i]))
{
p.ParameterName += suffix;
parameters.Add(p);
@@ -1018,6 +1018,40 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("Submitted", stored.Status);
}
/// <summary>
/// Verifies that a row with an unknown status does not stop the rest of the page: every
/// valid row is applied and the unknown-status failure is rethrown at the end (adversarial
/// review of 06/PERF-2).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertManyAsync_UnknownStatus_StillAppliesTheRestOfThePage()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site = NewSiteId();
var good1 = TrackedOperationId.New();
var bad = TrackedOperationId.New();
var good2 = TrackedOperationId.New();
var page = new List<SiteCall>
{
NewRow(good1, site, status: "Submitted"),
NewRow(bad, site, status: "NotAStatus"),
NewRow(good2, site, status: "Submitted"),
};
await using (var context = CreateContext())
{
await Assert.ThrowsAsync<ArgumentException>(
() => new SiteCallAuditRepository(context).UpsertManyAsync(page));
}
await using var read = CreateContext();
var repo = new SiteCallAuditRepository(read);
Assert.NotNull(await repo.GetAsync(good1));
Assert.NotNull(await repo.GetAsync(good2));
Assert.Null(await repo.GetAsync(bad));
}
private ScadaBridgeDbContext CreateContext(DbCommandInterceptor interceptor)
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()