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