fix(sitecallaudit): push StuckOnly filter into SQL; doc accuracy fixes

This commit is contained in:
Joseph Doherty
2026-05-21 04:24:16 -04:00
parent e3519fdb39
commit ac1f73cf8a
8 changed files with 168 additions and 20 deletions

View File

@@ -271,6 +271,67 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(5, allIds.Count);
}
[SkippableFact]
public async Task QueryAsync_StuckCutoff_ComposesWithKeysetPaging_NoEmptyPages()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// Three stuck rows (non-terminal, created before the cutoff) interleaved
// by CreatedAtUtc with non-stuck rows: recent non-terminal rows and an
// old-but-terminal row. The stuck predicate is pushed into the SQL WHERE
// alongside the keyset cursor, so each page must come back full of stuck
// rows — never under-filled by a post-filter.
var t0 = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc);
var cutoff = t0.AddMinutes(10);
var stuckIds = new List<TrackedOperationId>();
for (var i = 0; i < 3; i++)
{
var stuckId = TrackedOperationId.New();
stuckIds.Add(stuckId);
// Stuck: non-terminal, created before the cutoff.
await repo.UpsertAsync(NewRow(
stuckId, sourceSite: site, status: "Attempted",
createdAtUtc: t0.AddMinutes(i)));
// Not stuck: non-terminal but created after the cutoff.
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), sourceSite: site, status: "Attempted",
createdAtUtc: cutoff.AddMinutes(i + 1)));
// Not stuck: created before the cutoff but terminal.
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), sourceSite: site, status: "Delivered",
createdAtUtc: t0.AddMinutes(i), terminal: true,
terminalAtUtc: t0.AddMinutes(i + 1)));
}
var filter = new SiteCallQueryFilter(SourceSite: site, StuckCutoffUtc: cutoff);
var page1 = await repo.QueryAsync(filter, new SiteCallPaging(PageSize: 2));
Assert.Equal(2, page1.Count);
Assert.All(page1, r => Assert.Null(r.TerminalAtUtc));
Assert.All(page1, r => Assert.True(r.CreatedAtUtc < cutoff));
var cursor1 = page1[^1];
var page2 = await repo.QueryAsync(
filter,
new SiteCallPaging(
PageSize: 2,
AfterCreatedAtUtc: cursor1.CreatedAtUtc,
AfterId: cursor1.TrackedOperationId));
// Only the third stuck row remains — no empty trailing page.
Assert.Single(page2);
Assert.Null(page2[0].TerminalAtUtc);
Assert.True(page2[0].CreatedAtUtc < cutoff);
// Exactly the three stuck rows, no overlap, no non-stuck leakage.
var returned = page1.Concat(page2).Select(r => r.TrackedOperationId).ToHashSet();
Assert.Equal(stuckIds.ToHashSet(), returned);
}
[SkippableFact]
public async Task PurgeTerminalAsync_RemovesTerminalAndOld()
{

View File

@@ -293,6 +293,65 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.True(response.SiteCalls[0].IsStuck);
}
[SkippableFact]
public async Task SiteCallQueryRequest_StuckOnly_PagesAreFull_NoEmptyPagesWithCursor()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var actor = CreateActor(repo, new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.FromMinutes(10) });
var now = DateTime.UtcNow;
// Three stuck rows interleaved (by CreatedAtUtc) with three non-stuck
// rows: recent non-terminal and old-but-terminal. With the StuckOnly
// filter pushed into SQL, a page-size-2 query must return exactly the
// stuck rows two-per-page — never an under-filled page with a non-null
// next cursor caused by post-filtering.
for (var i = 0; i < 3; i++)
{
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-30 - i)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-2 - i)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-40 - i), terminal: true));
}
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-p1", null, siteId, null, null, StuckOnly: true,
null, null, null, null, PageSize: 2),
TestActor);
var page1 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page1.Success);
// Page is full — two stuck rows, both honestly stuck.
Assert.Equal(2, page1.SiteCalls.Count);
Assert.All(page1.SiteCalls, s => Assert.True(s.IsStuck));
Assert.NotNull(page1.NextAfterCreatedAtUtc);
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-p2", null, siteId, null, null, StuckOnly: true,
null, null, page1.NextAfterCreatedAtUtc, page1.NextAfterId,
PageSize: 2),
TestActor);
var page2 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page2.Success);
// Final page — the third stuck row, the only remaining match.
Assert.Single(page2.SiteCalls);
Assert.All(page2.SiteCalls, s => Assert.True(s.IsStuck));
// No overlap, exactly the three stuck rows across both pages.
var allIds = page1.SiteCalls.Concat(page2.SiteCalls)
.Select(s => s.TrackedOperationId).ToHashSet();
Assert.Equal(3, allIds.Count);
}
[SkippableFact]
public async Task SiteCallDetailRequest_KnownId_ReturnsFullDetail()
{