fix(outbox): never resurrect a synchronous row on reclaim

The stale-claim reclaimer added earlier in this branch un-claimed every timed-out
row, including SYNCHRONOUS ones. `reply_to.is_some()` is the schema's "never
retry" signal (0009_outbox.sql) and the dispatcher honours it: those rows are an
HTTP request someone is blocked on, and re-running one means re-running its side
effects — a charge, an email, an external POST — for a caller that is long gone.

So a dispatcher dying mid-sync-dispatch would have its row resurrected 10 minutes
later and executed a second time, for nobody. Before the reclaimer those rows were
merely stranded; the reclaimer turned inert into harmful.

The reclaim now gates on `reply_to IS NULL`. Stale sync rows are DELETED instead:
they can never be dispatched again and nobody is waiting on them, so leaving them
would just accumulate garbage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-14 21:12:01 +02:00
parent fa79b4ee45
commit 1fc4080800

View File

@@ -133,6 +133,11 @@ pub trait OutboxRepo: Send + Sync {
/// Returns how many rows were reclaimed. `attempt_count` is deliberately NOT
/// bumped: the handler never ran, so a reclaim must not consume the row's
/// retry budget (same reasoning as the transient queue `release`).
///
/// SYNCHRONOUS rows (`reply_to IS NOT NULL`) are NOT reclaimed — the schema
/// makes `reply_to.is_some()` the "never retry" signal, and their caller is
/// long gone. They are deleted instead: nobody is waiting, they can never be
/// dispatched, and leaving them would leak a row per crashed sync dispatch.
async fn reclaim_stale_claims(&self, timeout_secs: u32) -> Result<u64, OutboxRepoError>;
/// Remove a row after a terminal outcome (success or dead-letter).
@@ -231,14 +236,34 @@ impl OutboxRepo for PostgresOutboxRepo {
}
async fn reclaim_stale_claims(&self, timeout_secs: u32) -> Result<u64, OutboxRepoError> {
// `reply_to IS NULL` is load-bearing: a row with a reply_to is a SYNCHRONOUS
// HTTP dispatch, and the schema (0009_outbox.sql) makes reply_to.is_some()
// the "never retry" signal. Returning one to the queue would re-execute a
// handler whose caller left minutes ago and whose inbox oneshot no longer
// exists — running its side effects a second time for nobody.
let res = sqlx::query(
"UPDATE outbox SET claimed_at = NULL, claimed_by = NULL \
WHERE claimed_at IS NOT NULL \
AND reply_to IS NULL \
AND claimed_at < NOW() - ($1 || ' seconds')::INTERVAL",
)
.bind(i64::from(timeout_secs))
.execute(&self.pool)
.await?;
// A stale SYNC row can never be dispatched again (never-retry) and nobody
// is waiting on it, so it is pure garbage — drop it rather than leak a row
// per crashed sync dispatch forever.
sqlx::query(
"DELETE FROM outbox \
WHERE claimed_at IS NOT NULL \
AND reply_to IS NOT NULL \
AND claimed_at < NOW() - ($1 || ' seconds')::INTERVAL",
)
.bind(i64::from(timeout_secs))
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}