fix(site-call-audit): same-rank freshness tiebreaker — retrying calls no longer freeze RetryCount/LastError at first write

Within an equal NON-terminal rank the newest UpdatedAtUtc wins, unfreezing the
Attempted-phase RetryCount/LastError/HttpStatus. Terminal ranks (>=3) are
excluded from the tiebreaker so terminal immutability is preserved (Delivered
never overwrites Parked); equal stamps stay idempotent, lower rank stays a no-op.
This commit is contained in:
Joseph Doherty
2026-07-09 07:56:39 -04:00
parent 1b53de1933
commit 6b06d1efcb
3 changed files with 126 additions and 18 deletions
@@ -95,19 +95,23 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
[SkippableFact]
public async Task UpsertAsync_SameStatus_IsNoOp()
public async Task UpsertAsync_SameStatus_EqualUpdatedAt_IsNoOp()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
var stamp = new DateTime(2026, 6, 1, 9, 0, 0, DateTimeKind.Utc);
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "first"));
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "first", updatedAtUtc: stamp));
var snapshot = await repo.GetAsync(id);
// Same rank (2) — repository must treat this as a no-op (no fields move).
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second"));
// Same rank (2) AND an EQUAL UpdatedAtUtc — an idempotent replay of the
// same telemetry packet. The same-rank freshness tiebreaker only fires
// for a STRICTLY-newer stamp, so a true duplicate stays a no-op (no
// fields move). Task 11: newest-within-rank wins, equal stamps are inert.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second", updatedAtUtc: stamp));
var afterDuplicate = await repo.GetAsync(id);
Assert.NotNull(afterDuplicate);
@@ -117,21 +121,92 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(snapshot!.UpdatedAtUtc, afterDuplicate.UpdatedAtUtc);
}
[SkippableFact]
public async Task UpsertAsync_SameRank_NewerUpdatedAt_RefreshesProgressFields()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
var t0 = new DateTime(2026, 6, 1, 10, 0, 0, DateTimeKind.Utc);
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// Two Attempted packets (rank 2) from one retrying call; the second
// carries a strictly-newer UpdatedAtUtc, so its live RetryCount/LastError
// must land — the row no longer freezes at first-Attempted (Task 11).
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "timeout #1", updatedAtUtc: t0));
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5)));
var row = await repo.GetAsync(id);
Assert.NotNull(row);
Assert.Equal(5, row!.RetryCount);
Assert.Equal("timeout #5", row.LastError);
Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc);
}
[SkippableFact]
public async Task UpsertAsync_SameRank_OlderUpdatedAt_IsNoOp()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
var t0 = new DateTime(2026, 6, 1, 11, 0, 0, DateTimeKind.Utc);
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// Land retry 5 (stamp t0+5), then an out-of-order retry-2 packet with an
// OLDER stamp (t0). Same rank, older UpdatedAtUtc → rejected; stays at 5.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5)));
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "timeout #2", updatedAtUtc: t0));
var row = await repo.GetAsync(id);
Assert.NotNull(row);
Assert.Equal(5, row!.RetryCount);
Assert.Equal("timeout #5", row.LastError);
Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc);
}
[SkippableFact]
public async Task UpsertAsync_LowerRank_NewerUpdatedAt_IsStillNoOp()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
var t0 = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// Terminal Delivered (rank 3), then a late lower-rank Attempted (rank 2)
// with a NEWER stamp. Rank monotonicity dominates the freshness
// tiebreaker — the row stays Delivered (regression-proof).
await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 0, updatedAtUtc: t0, terminal: true, terminalAtUtc: t0));
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 9, lastError: "late", updatedAtUtc: t0.AddMinutes(5)));
var row = await repo.GetAsync(id);
Assert.NotNull(row);
Assert.Equal("Delivered", row!.Status);
Assert.Equal(0, row.RetryCount);
}
[SkippableFact]
public async Task UpsertAsync_TerminalOverTerminal_IsNoOp()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Bundle B3 plan: terminal statuses share rank 3 and are mutually
// exclusive — Delivered cannot overwrite Parked.
// exclusive — Delivered cannot overwrite Parked. Task 11 note: the
// Delivered packet carries a STRICTLY-NEWER UpdatedAtUtc, yet the row
// stays Parked — the same-rank freshness tiebreaker is scoped to
// NON-terminal ranks (< 3), so terminal immutability is preserved.
var id = TrackedOperationId.New();
var t0 = new DateTime(2026, 6, 1, 13, 0, 0, DateTimeKind.Utc);
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 3, lastError: "parked-reason", terminal: true));
await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 3, lastError: "parked-reason", updatedAtUtc: t0, terminal: true, terminalAtUtc: t0));
var afterPark = await repo.GetAsync(id);
await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 4, lastError: null, terminal: true));
await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 4, lastError: null, updatedAtUtc: t0.AddMinutes(5), terminal: true, terminalAtUtc: t0.AddMinutes(5)));
var afterDeliveredAttempt = await repo.GetAsync(id);
Assert.NotNull(afterDeliveredAttempt);