Repository navigation
fix(submit): release the singleflight claim when a submit fails - #11
Merged
Merged
Conversation
`submit_recipe_inner` takes the per-`cache_key` singleflight claim at
`try_claim_or_follow` but doesn't reach `sbatch` until ~120 lines later,
after output-marker checks, source copy, context write and
`render_script`. Any error in that window returned straight to the
caller and left the claim row at `created` with `job_id = NULL` — which
is exactly the predicate `runs_cache_key_inflight_unique` treats as
in-flight. Nothing ages that row out: every later submission of the same
recipe found it and attached as a follower of a leader that will never
run, and no CLI command clears it (it took a hand-written
`UPDATE runs SET status='failed'` to unblock a real recipe today, whose
`command` contained `${LABCTL_RUN_ID}` and failed at render).
The post-claim stretch now runs inside an async block whose error arm
calls `release_claim_on_failed_submit`: it flips the row to `failed`
(via `update_status`, which supplies the `finished_at` that
`runs_terminal_finished_at` requires for a terminal status) and returns
the original error untouched. The row is kept — it's the record of what
went wrong — but it no longer owns the key. A cleanup that itself fails
is logged with `tracing::error!` and swallowed, so it can never mask the
failure that triggered it. Matches the isolation `agent.rs` and
`try_submit_pending_children` already apply.
The follower / cache-hit paths return before the claim window and are
untouched.
Test: `runner::claim_tests::failed_submit_releases_singleflight_claim`
drives a real `submit_recipe_inner` against the live registry with every
filesystem path redirected into a tempdir, trips the marker-collision
`bail!` (the first failure after the claim, so sbatch is never reached),
and asserts the row is terminal with `finished_at` set and
`find_inflight_for_cache_key` no longer returns it. It observes, cleans
up, then asserts, so a failure never strands rows in the shared
registry. The `pg_store` test fixtures it reuses are now `pub(crate)`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
submit_recipe_innerclaims the per-cache_keysingleflight slot attry_claim_or_follow(src/runner.rs:442) but doesn't reachsbatchuntil ~120 lines later — after the output-marker check, the outputmkdirs,copy_dir_filtered, thecontext.jsonwrite andrender_script. Every error in that window returned straight to the caller and left the claim row sitting atstatus = 'created',job_id = NULL, which is exactly the predicateruns_cache_key_inflight_unique(migration 0003) treats as in-flight.Nothing ages that row out. Every later submission of the same recipe finds it, attaches as a follower of a leader that will never run, and returns
follower_of: run_...with no job. No CLI command clears it.Hit for real today: a recipe whose
commandcontained${LABCTL_RUN_ID}failed atrender_script; the next two submissions came back as followers and it took a hand-writtenUPDATE runs SET status='failed'to unblock.The fix
The post-claim stretch now runs inside an async block; its error arm calls a new
release_claim_on_failed_submit, whichfailedvia the existingupdate_statushelper (which supplies thefinished_atthat theruns_terminal_finished_atbiconditional requires for any terminal status),tracing::error!and swallows it, for the same reason.Same per-run failure isolation
agent.rsandtry_submit_pending_childrenalready apply. Thefollowerandcache_hitpaths both return before the claim window and are untouched; so is the pending-pipeline-children sweep, whose ownupdate_status(child, "failed")on a failedcomplete_pending_submissionis now redundant but harmless.The runner.rs diff looks large because the wrapped body is re-indented;
git diff -wis 167 insertions, 1 deletion.Test
runner::claim_tests::failed_submit_releases_singleflight_claim(PG-gated,#[ignore]d like the rest) drives a realsubmit_recipe_inneragainst the live registry withruns_base, the output root and the repo mapping all redirected into a tempdir, so nothing lands in the shared filesystem. The injected failure is the marker-collisionbail!— the first thing that can fail after the claim — so the test never reaches source-copy or sbatch. It then asserts the row isfailedwithfinished_atset andjob_idstill NULL, and thatfind_inflight_for_cache_keyno longer returns it.It observes, then cleans up, then asserts, so a failing assertion can't strand rows in the shared registry (which matters for the negative control below).
The
pg_store::testsfixtures it reuses (live_cluster,dummy_recipe,insert_test_user,cleanup_user) are nowpub(crate).Run on hai-login2 (where the PG socket lives)
Negative control — same test with the production fix neutered
release_claim_on_failed_submitreduced toerr(noupdate_status), everything else identical:i.e. the stranded
createdclaim reproduces exactly, and the test catches it.Full suite (local, non-PG)
cargo fmt --checkandcargo clippy --all-features --all-targetsare clean. No test rows remain in the registry after either run.🤖 Generated with Claude Code