diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index 115bb61a..4e829286 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -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(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); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs index 6d263a47..f6a2c6a7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs @@ -1018,6 +1018,40 @@ public class SiteCallAuditRepositoryTests : IClassFixture Assert.Equal("Submitted", stored.Status); } + /// + /// 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). + /// + /// A task that represents the asynchronous operation. + [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 + { + NewRow(good1, site, status: "Submitted"), + NewRow(bad, site, status: "NotAStatus"), + NewRow(good2, site, status: "Submitted"), + }; + + await using (var context = CreateContext()) + { + await Assert.ThrowsAsync( + () => 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()