fix(jobs): per-lease generation token closes the ack-from-dead-lease race (0.87.26)

0.87.20 narrowed but did not close the force-analyze race:
ack_done / ack_failed / renew matched only on (id, state='running'),
so a worker A whose lease was released mid-flight (force-analyze,
reclaim_orphaned) could still come back later — once a successor B
re-leased the same id and put it back to state='running' — and
clobber B's lease. A's stale result became the row's `done` payload;
B's in-flight work was silently lost. The pre-existing test even
documented this hole: "We can't make ack_done's lease_id distinguish
A from B today".

Add `lease_generation BIGINT NOT NULL DEFAULT 0` to crawler_jobs
(migration 0032). It is bumped by every lease, release / release_unowned,
and reclaim_orphaned, so lease identity is `(id, generation)` rather
than just `id`. Each Lease struct carries its generation. ack_done /
ack_failed / renew / release match on `(id, generation, state='running')`
so a stale ack from a prior generation finds zero rows and falls
back to the existing warn-and-skip branch.

Split the release surface in two:
  * release(lease_id, lease_generation) — owner-aware path for
    workers (graceful shutdown, session-expired), matches the
    caller's specific lease.
  * release_unowned(lease_id) — id-only path for force-analyze and
    ops tools that drop a running lease without holding a Lease
    struct; unconditionally bumps generation so any pending ack
    from the in-flight original becomes a no-op.

Force-analyze in repo::page_analysis now uses release_unowned; the
test in api_admin_analysis still passes unchanged (it only asserts
the steady-state outcome). The pre-existing `ack_done_no_ops_when_
lease_was_stolen` test is strengthened: it now mints both A and B
leases, asserts their generations differ, and verifies A's stale
ack does NOT clobber B's running row — the assertion the old test
explicitly couldn't make.

Five new tests in tests/crawler_jobs.rs pin every facet:
  * lease_assigns_strictly_increasing_generation_per_release
  * ack_done_from_dead_lease_after_release_unowned_is_a_no_op
  * ack_failed_from_dead_lease_after_release_unowned_is_a_no_op
  * renew_from_dead_lease_is_a_no_op
  * release_unowned_bumps_generation_even_when_lease_is_held

Mutation-confirmed: relaxing the ack_done guard to
`lease_generation >= $2` (bind 0) makes the race test fail with
state=done — the exact prior-bug shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 20:05:55 +02:00
parent ae708a72d0
commit 93b7e451bf
10 changed files with 419 additions and 82 deletions

View File

@@ -128,16 +128,18 @@ pub async fn enqueue_for_page(
// Even though we've flipped the row's payload to
// `force=true`, the worker's skip-if-done check uses its
// own pre-upgrade value and will ack done without
// re-analyzing. Release the lease so the row returns to
// `pending` with attempts refunded; the worker's later
// `ack_done` becomes a no-op (state guard `WHERE state =
// 'running'`) and a fresh lease picks the row up with the
// updated payload. May produce one wasted dispatch on the
// races where the worker had already started its vision
// call — strictly better than silently losing admin intent.
// re-analyzing. `release_unowned` returns the row to
// `pending` AND bumps `lease_generation` so the original
// worker's later `ack_done(id, old_generation)` finds no
// matching row and is a no-op — the successor's lease (a
// fresh generation) is preserved. The state-only guard
// that previously protected this (and was the 0.87.20
// narrowing) wasn't enough on its own: a successor that
// re-leased the id would re-enter `state='running'`, and
// the original's ack would clobber it.
for (id, state) in &upgraded {
if state == "running" {
let _ = jobs::release(pool, *id).await;
let _ = jobs::release_unowned(pool, *id).await;
}
}
Ok(EnqueueForPageOutcome::UpgradedToForce)