Merge branch 'worktree-agent-a7084b23177344196' into arch-review-remediation

This commit is contained in:
Joseph Doherty
2026-08-14 21:15:31 -04:00
30 changed files with 1941 additions and 482 deletions
@@ -89,28 +89,25 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
{
CallCount++;
var rows = await _siteQueue
.ReadPendingSinceAsync(sinceUtc, batchSize, ct)
.ConfigureAwait(false);
// Commit immediately on the site side — once the actor has the
// batch in hand it will InsertIfNotExistsAsync centrally; if the
// central insert later throws on a specific row, idempotency
// guarantees the next pull cycle does NOT re-fetch the row (it's
// already Reconciled on the site) but also does not surface the
// failure here. The brief calls this "ack-after-persist" — the
// production gRPC server will flip to Reconciled inside its
// PullAuditEvents handler after the central side has acknowledged
// (per Bundle A's race-fix, central is idempotent on EventId).
//
// MoreAvailable is true iff the read filled the batch — the actor
// uses this to decide whether to follow up on the next tick.
if (rows.Count > 0)
// Mirrors SiteStreamGrpcServer.PullAuditEvents exactly (WP2.3): the
// INCOMING cursor is central's receipt, so everything at or before it
// is retired FIRST; the rows this call serves are NOT retired, because
// nothing yet proves central consumed them. A fault between here and
// central's commit therefore re-serves them on the next tick instead of
// losing them. The actor sends no after_id, so the cursor is a bare
// timestamp under the inclusive >= read contract and only rows strictly
// older than it are provably received.
if (sinceUtc > DateTime.MinValue)
{
var ids = rows.Select(e => e.EventId).ToList();
await _siteQueue.MarkReconciledAsync(ids, ct).ConfigureAwait(false);
await _siteQueue.MarkReconciledUpToAsync(sinceUtc, null, ct).ConfigureAwait(false);
}
var rows = await _siteQueue
.ReadPendingSinceAsync(sinceUtc, batchSize, afterId: null, ct)
.ConfigureAwait(false);
// MoreAvailable is true iff the read filled the batch — the actor
// uses this to decide whether to follow up on the next tick.
return new PullAuditEventsResponse(rows, MoreAvailable: rows.Count >= batchSize);
}
}
@@ -251,13 +248,19 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
duration: TimeSpan.FromSeconds(30),
interval: TimeSpan.FromMilliseconds(200));
// Step 4: assert site rows flipped to Reconciled.
// ReadPendingAsync only returns Pending rows; after a full drain
// it must be empty.
// Step 4: assert site rows flipped to Reconciled once central's cursor
// proved receipt. Exactly ONE row can legitimately remain Pending: the
// newest, which sits AT the cursor instant. Central sends only a
// timestamp cursor (no after_id yet — see the central-side follow-up),
// and under the inclusive >= read contract a bare timestamp cannot
// prove the rows AT that instant were consumed, so the site keeps
// serving that row until a newer one advances the cursor. Re-serving is
// harmless: central dedups on EventId (asserted in step 5).
await AwaitAssertAsync(async () =>
{
var stillPending = await sqliteWriter.ReadPendingAsync(totalEvents + 10);
Assert.Empty(stillPending);
Assert.True(stillPending.Count <= 1,
$"expected at most the boundary row to remain Pending, found {stillPending.Count}");
},
duration: TimeSpan.FromSeconds(10),
interval: TimeSpan.FromMilliseconds(100));