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
@@ -929,6 +929,120 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.DoesNotContain(perNode, n => n.SourceNode is null);
}
// --- UpsertManyAsync (review 2026-10-21 06/PERF-2) ----------------------
/// <summary>
/// Verifies that a batched upsert applies exactly the per-row monotonic semantics: fresh
/// rows insert, a higher rank advances, a lower rank is a no-op, and a page larger than
/// one command chunk is fully applied — in a handful of round trips, not one per row.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertManyAsync_AppliesMonotonicSemantics_InFewRoundTrips()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site = NewSiteId();
var now = DateTime.UtcNow;
var advance = TrackedOperationId.New();
var regress = TrackedOperationId.New();
await using (var seed = CreateContext())
{
var seedRepo = new SiteCallAuditRepository(seed);
await seedRepo.UpsertAsync(NewRow(advance, site, status: "Submitted", createdAtUtc: now));
await seedRepo.UpsertAsync(NewRow(regress, site, status: "Delivered", createdAtUtc: now,
terminal: true, terminalAtUtc: now));
}
var fresh = Enumerable.Range(0, 300).Select(_ => TrackedOperationId.New()).ToList();
var page = fresh.Select(id => NewRow(id, site, status: "Attempted", retryCount: 1, createdAtUtc: now))
.Append(NewRow(advance, site, status: "Delivered", createdAtUtc: now, terminal: true, terminalAtUtc: now))
.Append(NewRow(regress, site, status: "Attempted", retryCount: 9, createdAtUtc: now))
.ToList();
var counter = new NonQueryCounter();
await using (var context = CreateContext(counter))
{
await new SiteCallAuditRepository(context).UpsertManyAsync(page);
}
Assert.True(counter.Count <= 5, $"UpsertManyAsync issued {counter.Count} commands for {page.Count} rows");
await using var read = CreateContext();
var stored = await read.Set<SiteCall>().Where(s => s.SourceSite == site).ToListAsync();
Assert.Equal(302, stored.Count);
Assert.All(stored.Where(s => fresh.Contains(s.TrackedOperationId)), s => Assert.Equal("Attempted", s.Status));
Assert.Equal("Delivered", stored.Single(s => s.TrackedOperationId == advance).Status);
var kept = stored.Single(s => s.TrackedOperationId == regress);
Assert.Equal("Delivered", kept.Status);
Assert.Equal(0, kept.RetryCount);
}
/// <summary>
/// Verifies that a row another site owns still fails the batch with the ownership error,
/// while every legitimate row of the page is applied (the chunk falls back to per-row).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertManyAsync_OwnershipViolation_StillThrows_AfterApplyingTheRestOfThePage()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var owner = NewSiteId();
var attacker = NewSiteId();
var owned = TrackedOperationId.New();
await using (var seed = CreateContext())
{
await new SiteCallAuditRepository(seed).UpsertAsync(NewRow(owned, owner, status: "Submitted"));
}
var good1 = TrackedOperationId.New();
var good2 = TrackedOperationId.New();
var page = new List<SiteCall>
{
NewRow(good1, attacker, status: "Submitted"),
NewRow(owned, attacker, status: "Delivered", terminal: true),
NewRow(good2, attacker, status: "Submitted"),
};
await using (var context = CreateContext())
{
var failure = await Assert.ThrowsAsync<Microsoft.Data.SqlClient.SqlException>(
() => new SiteCallAuditRepository(context).UpsertManyAsync(page));
Assert.Equal(51001, failure.Number);
}
await using var read = CreateContext();
var repo = new SiteCallAuditRepository(read);
Assert.NotNull(await repo.GetAsync(good1));
Assert.NotNull(await repo.GetAsync(good2));
var stored = await repo.GetAsync(owned);
Assert.Equal(owner, stored!.SourceSite);
Assert.Equal("Submitted", stored.Status);
}
private ScadaBridgeDbContext CreateContext(DbCommandInterceptor interceptor)
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.AddInterceptors(interceptor)
.Options;
return new ScadaBridgeDbContext(options);
}
/// <summary>Counts the non-query commands a repository call issues.</summary>
private sealed class NonQueryCounter : DbCommandInterceptor
{
/// <summary>The number of non-query commands issued.</summary>
public int Count { get; private set; }
/// <inheritdoc />
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Count++;
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
}
}
// --- helpers ------------------------------------------------------------
private ScadaBridgeDbContext CreateContext()