Send-Jobs Claim Lease + Generation Fencing (double-send on rolling restart)
On this page
Status
| Unit | Description | Status |
|---|---|---|
U0 |
Choreography: label, issue amendment, #1195 filed (early-terminal gap), this plan |
Done (2026-07-28) — commit adf302b3 |
U1a |
Test seam: lib.rs exposes the store (visibility only, no behavior) |
Done (2026-07-28) — commit e79b5804; NARROW inline lib module (3 files only) so unrelated store surfaces don’t become lib dead code |
U1b |
Migration + lease claim + generation-fenced finalizers + transaction guard + test matrix (RED evidence) |
Done (2026-07-28) — commit 15236cf9; RED 5/6 against the pre-fix fns (only the FUSL single-winner pin passed); 8/8 green post-fix; J-review PASS with 4 minor flags remediated pre-commit (stale sweep comment, NULL-healing test added, refreshed-claimed_at assert strengthened, const-name doc) |
U2 |
Docs: ADR-022 amendment, data-model-exchange send-jobs section, CHANGELOG |
Done (2026-07-28) — send-jobs table + lease/generation semantics + index rows added to the previously-silent data-model page |
Ship |
Battery, MR, merge, close-out |
Done (2026-07-28) — MR !1097 merged (961317e8); battery attempt 2 green (attempt 1 fast-failed at the axis cheap gate — stale opt-out for the deleted recovery test, re-blessed da5de851); #1185 closed with AC walk; epic &74 ticked (6/15) |
Epic: &74
Issue: #1185 (Plan::SENDJOB-LEASE)
Follow-ups: #1195 (early terminal paths never stamp the transaction / stage exchange.failed — pre-existing, filed by U0; they DO get the generation fence here)
Review state: internal pass (Explore scout + Plan-agent verification, all anchors re-verified file:line). Approved 2026-07-28.
Context
exchange_send_jobs (craig-exchange’s partner HTTP send queue) kept the pre-#1128 claim pattern:
-
claim_pending(store/send_jobs.rs:61-82) stamps no claim time/owner — onlystatus='in_flight', attempts+1;next_attempt_atkeeps its scheduled-eligibility value. -
recover_stuck_in_flight(:153-168) flips ANY in_flight row withnext_attempt_at < now()-60sback to pending — the WRONG clock: a job claimed out of a >60s-overdue backlog (post-outage drain) is INSTANTLY "stuck", so a peer (re)boot re-claims it mid-send → two workers POST the same transmission. -
finalize_sent(:87-99) andfinalize_failed_terminal(:130-147) UPDATE by bare id — no status/owner guard (the retry path alone carriesAND status='in_flight', proving the pattern was known) — so whichever racing worker finishes LAST wins, and both stageexchange.sent/exchange.failedoutbox events. -
update_transaction_status(store/transactions.rs:107-130) has no transition guard: stalesuccess↔failedflips. -
Recovery runs once at bootstrap (
main.rs:223);drain_oncedeliberately abandons un-dispatched claims "for the recovery sweep" — which won’t run until the next boot. -
The worker module doc’s "harmless to apply twice" idempotency claim is exactly what this issue falsifies.
-
Design-verification correction: the two early terminal arms in
dispatch(partner-missing, resolve-miss) DO open a tx but skip the transaction stamp + events — pre-existing gap, now #1195; they get the fence here, not the missing stamp.
Design
| # | Decision | Substance |
|---|---|---|
D1 |
Migration |
|
D2 |
Claim absorbs recovery (single-statement UPDATE) |
Inner SELECT |
D3 |
|
Every adapter send is capped at 30s (per-adapter |
D4 |
Generation-fenced finalizers, typed outcome |
|
D5 |
|
|
D6 |
Test seam: lib exposes the store |
lib.rs |
D7 |
Recorded honesty |
Duplicate (NOT conflicting) |
Units
U0 — label Plan::SENDJOB-LEASE; issue amendment note; #1195 filed + /relate; this plan + nav; cargo xtask plan-lint.
U1a — the seam commit: lib.rs pub mod store;, visibility flips + rustdoc. No behavior change; compile-green both targets; mechanical carve-out.
U1b — one commit: migration (D1); store rework (D2/D4/D5); worker + main.rs (D2/D4 incl. terminal_fail_fenced, module-doc rewrite killing "harmless to apply twice", D5 annotations); new test root tests/send_jobs_lease.rs (scratch-DB per retention_sweep.rs; axis tags immediately above the test attributes): (1) conc — fresh claim with ancient next_attempt_at NOT re-claimable; (2) happy — expired lease reclaimed with generation+1/attempts+1/claimed_at refreshed; (3) fault — stale finalize_sent fenced, row stays in_flight under the new generation, new-generation finalize applies; (4) fault — stale finalize_failed_terminal cannot overwrite sent (the issue’s named defect); (5) fault — stale retry cannot yank an active claim (documents why the old status-only guard was insufficient); (6) sad — transaction terminal guard refuses conflicting flips; retry’s guarded failed→pending is the only legal terminal exit; (7) conc — two concurrent claimers, one winner (new coverage). In-module string pin extended (FUSL + both lease predicates + the generation increment). The old throwaway-DB recovery test DELETED (superseded; its inline copies die). Live e2e stage_then_async_finalize unchanged. RED procedure: the fix re-signs the fns under test, so stash won’t work — with U1a committed, write the tests against the OLD fns, RUN, record the failures (sweep-flips-fresh-claim; stale finalize stamps over the new claim; sent→failed overwrite; stale retry yank), then apply the fix + mechanically update call shapes (assertions unchanged); RED output quoted in the commit message. Worker-level stale-skip needs no harness — events stage after the Applied branch of the same tx (layering rationale in the module doc).
U2 — ADR-022 dated == Amendment — #1185 (after #1183): the lease (90s + sizing), the generation CAS and why the outbox’s unguarded stamp was NOT ported, the D7 honesty items, the spec-drift annotations, drain-first. data-model-exchange.adoc: the page omits exchange_send_jobs entirely — add the table section (columns incl. the new two, status vocabulary, lease/generation semantics, indexes). CHANGELOG === Fixed with the drain-first deploy note. Plan Status per unit.
Verification
-
cargo nextest run -p craig-exchange --profile integration --run-ignored=all -E 'binary(send_jobs_lease)'(devstack up); joinscargo xtask validate’s workspace `--run-ignored=allbattery automatically; no nextest test-group needed (unique scratch DBs). -
Unit pins:
cargo nextest run -p craig-exchange --lib(string pin) + the bin-module const pins. -
Targeted clippy
-p craig-exchange --all-targets --locked — -D warnings; budget hazards recorded (B3b: seed viajson!; axis tags; fn budgets —terminal_fail_fencedrelievesdispatch;genkeyword). -
Full battery pre-push. No devstack reseed (additive migration; NULL
claimed_atheals).
Out of scope (routed)
-
Early-terminal transaction stamp +
exchange.failedevent → #1195 (they DO get the fence here). -
Poison-job crash-loop reclaims → known limitation, ADR amendment.
-
can_transaction_transitionRetry-path spec drift → annotated only.