fix(scheduler): a firing could be lost between rescheduling and dispatch
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

`tick` advanced `next_run_at` before dispatching the work, with nothing
recording that the occurrence was owed. A process that died between the two
dropped it silently.

The window is narrower than it first looks — `claim_due` sets `last_run_at`
but does not clear `next_run_at`, so a crash *before* `set_next_run` leaves
the routine due and it re-fires on the next tick. The loss is specifically
between the reschedule and the dispatch. That is tolerable for a message
routine and not tolerable for a scheduled mission, which is why this lands
before mission scheduling does.

`routine_fires` holds one row per (routine, occurrence), claimed before
dispatch and settled after:

- Fresh   — nobody has it; fire.
- Retry   — claimed, never settled: a crash mid-fire. Safe to fire again, as
            no completion was recorded and nothing downstream saw a result.
- Settled — already dispatched; advance the clock and do not run the work.
            This is what keeps a scheduled mission to one container across
            restarts.

A failed dispatch settles terminally rather than staying retryable. Retrying
a persistently failing action every tick is how a broken routine becomes a
denial-of-service against whatever it talks to; the error is kept on the row.

The claim uses `xmax = 0` to distinguish a real insert from a no-op update in
a single statement — `ON CONFLICT DO NOTHING` returns no row at all, so two
schedulers racing one occurrence could both read it as unclaimed.

Also: fan-out capped at 25 per tick with the remainder logged and deferred (a
clock jump or an accidental every-minute cron would otherwise dispatch every
missed occurrence at once — one container each for topology routines), and
`spawn` no longer discards tick errors, so a scheduler that has stopped firing
no longer looks identical to one with nothing to do.

The pre-existing exactly-once test still passes: the claim changes
recoverability, not firing semantics.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-01 18:50:11 -07:00
co-authored by Claude Opus 5
parent 9f874bc06a
commit 2c7d619cf0
4 changed files with 303 additions and 7 deletions
+77
View File
@@ -104,3 +104,80 @@ pub async fn set_next_run(
.await?;
Ok(())
}
/// What claiming an occurrence found.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FireClaim {
/// Nobody has taken this occurrence. Fire it.
Fresh,
/// A previous attempt took it and never recorded an outcome — a crash
/// between claim and dispatch. Safe to fire again: no completion was ever
/// written, so nothing downstream saw a result.
Retry,
/// Already dispatched (or already failed). Do not fire; just advance the
/// clock. This is the branch that makes a scheduled mission cost one
/// container instead of one per restart.
Settled,
}
/// Take ownership of one occurrence before dispatching it.
///
/// `scheduled_at` is the occurrence's own timestamp — the `next_run_at` that
/// came due — not the wall clock at claim time. That is what makes the claim
/// idempotent across restarts: the same occurrence always maps to the same
/// row.
pub async fn claim_fire(
pool: &PgPool,
routine_id: Uuid,
scheduled_at: OffsetDateTime,
) -> Result<FireClaim, DbError> {
use sqlx::Row;
// Insert-or-look-at-what's-there in one statement, so two schedulers
// racing the same occurrence cannot both see "fresh".
let row = sqlx::query(
"INSERT INTO routine_fires (routine_id, scheduled_at)
VALUES ($1, $2)
ON CONFLICT (routine_id, scheduled_at) DO UPDATE
SET routine_id = routine_fires.routine_id
RETURNING status, (xmax = 0) AS inserted",
)
.bind(routine_id)
.bind(scheduled_at)
.fetch_one(pool)
.await?;
// `xmax = 0` distinguishes a genuine insert from a no-op update — the
// usual Postgres trick, and the reason for the otherwise pointless
// self-assignment in DO UPDATE (a bare DO NOTHING returns no row at all).
let inserted: bool = row.try_get("inserted").unwrap_or(false);
if inserted {
return Ok(FireClaim::Fresh);
}
let status: String = row.try_get("status").unwrap_or_default();
Ok(match status.as_str() {
"claimed" => FireClaim::Retry,
_ => FireClaim::Settled,
})
}
/// Record how a dispatched occurrence ended. Called after the work is handed
/// off, so a crash before this leaves the row `claimed` and retryable.
pub async fn complete_fire(
pool: &PgPool,
routine_id: Uuid,
scheduled_at: OffsetDateTime,
error: Option<&str>,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE routine_fires
SET status = $3, completed_at = now(), error = $4
WHERE routine_id = $1 AND scheduled_at = $2",
)
.bind(routine_id)
.bind(scheduled_at)
.bind(if error.is_some() { "failed" } else { "fired" })
.bind(error)
.execute(pool)
.await?;
Ok(())
}