Repository navigation
fix(task run): --wait must not return a previous attempt's completion (#608) - #612
Merged
Merged
Conversation
…#608) Re-running the same task_id overwrote the same tasks/<task_id>/completion.json key, and the record carried no attempt identity — so `task run --wait` could return the PREVIOUS attempt's verdict (old timestamps and all) while the new instance was still running and going on to succeed. The natural read is "my fix didn't work", which is how the reporter came to re-debug a task that was succeeding as they watched. Re-running after a fix is the most common reason to use --wait, so the stale record is most likely to exist exactly when it is most likely to mislead. Two layers, both needed: 1. Structural — clear the previous attempt's completion.json AND the sibling .exitcode before launch, so there is nothing stale to mis-read. Best-effort: a delete failure logs and launches anyway (deleting an absent key is a no-op success in S3, which is the normal first-run case). Adds aws.Client.DeleteS3Object, which normalizes NoSuchKey/NotFound to nil for that reason. 2. Verifiable — CompletionRecord gains an additive `run_id`, the wrapper stamps the launcher's freshly minted id into the record, and --wait ignores any record whose run_id isn't its own, saying so and continuing to poll. An empty run_id (pre-fix wrapper) is accepted with a loud warning rather than polled through: hanging to TTL on every legitimate old-wrapper run would be worse than an announced unverifiable answer, and layer 1 makes leftovers unlikely. The S3 key is deliberately NOT renamed per run: tasks/<task_id>/completion.json is the published adapter contract six repos poll (nf-spawn, miniwdl-spawn, cwl-spawn, snakemake-executor-plugin-spawn, airflow-spawn, pegasus-spawn), and an additive JSON field is invisible to them. The pooled per-job path stamps a run id too (minted per execution in pkg/taskpool), so "run_id empty" keeps its single meaning of "written by a pre-fix wrapper". Tests are unit-only, no AWS: a narrow taskResultStore seam lets pollCompletion/clearStaleCompletion be driven by a fake, plus wrapper text + exec-based JSON-validity coverage and the additive-field guard on CompletionRecord. Fixes #608
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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 symptom, and why it is severe
spawn task run --spec <f> --waitcould report the previous attempt's verdictwhen a task was re-run with the same
task_id. The reporter fixed a spec, re-ran,and was told
failed 141with the old timestamps while the new instance wasstill booting — and that instance went on to exit
0. It happened twice in onesession, and both times the natural read was "my fix didn't work", so working code
got re-debugged.
This is a false negative in the one place that is supposed to be authoritative,
and the failure mode is quiet: the stale record is a real record of a real
earlier failure, so nothing looks corrupt — it just describes the wrong run.
Re-running after a fix is the single most common reason to invoke
--wait, whichis precisely when a leftover record exists and is most likely to mislead.
Root cause
Two facts combine:
$RESULTS_PREFIX/completion.json=s3://<bucket>/tasks/<task_id>/completion.json— the same key for every runof the same task_id (and the same for the sibling
.exitcode).CompletionRecordhadtask_id/started_at/ended_atbut no runidentity, so attempt N was indistinguishable from attempt N−1.
pollCompletionfetched the key and returned whatever was there.
The fix — both layers
1. Clear the stale record at launch (structural).
runTaskRealdeletestasks/<task_id>/completion.jsonandtasks/<task_id>/.exitcodebeforelauncher.Provision, so there is no stale record to mis-read in the first place.Best-effort by design: deleting an absent key is a no-op success in S3 (the normal
first-run case), and a delete failure logs a warning to stderr and launches
anyway — the run-id check is the suspenders. New
aws.Client.DeleteS3Objectnormalizes
NoSuchKey/NotFoundto nil for the same reason.2. Stamp a run id and verify it (detectable).
CompletionRecordgains anadditive
run_id;runTaskRealmints one per launch (github.com/google/uuid,already in the tree) and passes it to the wrapper generator;
--waitignoresany record whose
run_idis not the one it just launched and keeps polling,announcing the skip. If the only record that ever appears belongs to an earlier
attempt,
--waitnow times out with an error that says so instead of returningit. The launched run id is printed in the launch summary and in
printCompletion, so "which attempt is this?" is answerable from the output.Explicitly NOT renaming the completion key
The issue floats
completion-<runid>.json. Rejected:tasks/<task_id>/completion.jsonis the published workflow-adapter contractpolled by six adapter repos — nf-spawn, miniwdl-spawn, cwl-spawn,
snakemake-executor-plugin-spawn, airflow-spawn, pegasus-spawn. Renaming it is a
cross-repo breaking change for no gain, whereas an additive JSON field is
invisible to them (unknown fields are ignored in every one of those parsers).
Back-compat: what an empty
run_idmeansAn empty
run_idcan only come from a pre-fix wrapper (the wrapper now emitsthe field unconditionally, and the pooled path mints its own id). Such a record is
accepted, with a loud warning that it predates run-id stamping and cannot be
attributed to this run. Rationale: treating "unverifiable" as "keep polling" would
hang every legitimate old-wrapper run until its TTL and then fail it — turning a
rare misattribution risk into a guaranteed outage for anyone on a cached
wrapper/AMI. Layer 1 already deleted both keys moments earlier, so a record
present now is overwhelmingly likely to be this run's. An announced unverifiable
answer beats an unexplained timeout. The warning is asserted in a test so it can't
quietly disappear.
Pooled path
GeneratePooledJobScript(the #70 per-job path) stamps a run id too, minted perexecution in
pkg/taskpool'sScriptExecer— a pooled worker overwrites thesame key on every execution of a
task_id, so its records need attempt identityfor the same reason. No pooled submitter verifies it today (it never learns the
id, because the dispatcher has no launch step to mint it in); what it buys now is
that "
run_idis empty" keeps its single meaning of "written by a pre-fixwrapper". When the dispatch path grows a submitter-side identity, threading it in
gets verification for free.
Adapter blast radius (note, not a blocker)
The adapters poll the same key, so any adapter that reuses a
task_idinherits the same false negative — they do not check
run_id. In practice theymint a unique id per run (per-task/per-attempt work-dir naming), so this is a note
rather than a blocker. The new field is available to them whenever they want to
harden the same way.
Tests (unit only, zero AWS, zero launches)
A narrow
taskResultStoreinterface (GetS3Object/DeleteS3Object, satisfied by*aws.Client) is the only new seam;runTaskRealis untouched in shape.run_idis not accepted — polling continues, theskip is announced exactly once, and the matching record is what gets returned
run_idis accepted, with no warningrun_idrecord is accepted and warns (asserted)runID == ""caller (no run identity) keeps the pre-fix accept-anything behaviorreported (for logging) without skipping the second key
run_idinto the completion heredoc (one-instance andpooled), before the upload; an exec-based test runs the generated script with a
stand-in
awsand asserts the record it writes is valid JSON and parses with theright
run_idCompletionRecordadditive guard: a record with norun_idstill parses(
RunID""), a set one round-trips, and unset stays omittedGates:
gofmtclean,go vet ./...,go build ./...,go test ./...all green;all 11
lambda/*modules still build+vet.staticcheck ./...finding list isbyte-identical to the pre-change baseline (61 findings, all pre-existing).
make gen-docsre-run anddocs-gen/task.mdcommitted (thetask runlong helpgained a paragraph on the re-run guarantee).
Notes
s3:DeleteObjecton the per-account results bucket. Without it the pre-launchclear prints a warning and the launch proceeds, with the run-id check covering
correctness.
CHANGELOG.mdupdated under## [Unreleased]→Fixed. No version promoted;recommended bump is PATCH.
Fixes #608