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:
+114
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user