Retry the job image pull so a registry reset cannot kill the whole job - #113611
Retry the job image pull so a registry reset cannot kill the whole job#113611groeneai wants to merge 6 commits into
Conversation
`docker run` pulls the job image implicitly, inside the same invocation that
runs the job, and praktika executes that invocation exactly once (via
`TeePopen`, which has no retry facility). So when the registry transfer is reset
mid-stream the job dies before any test executes and is reported as a plain
job-level error, with the docker error as its only output:
docker: failed to copy: read tcp <runner>:<port>-><registry>:443: read: connection reset by peer
praktika already retries transient registry errors everywhere else it owns a
registry interaction as a distinct step: the image build in `docker.py`, GH auth
in `gh_auth.py`, and the integration suite's pre-pull in
`prefetch-integration-test-images`. The job image pull was not a distinct step,
so it inherited no retry.
`Runner._run` now pulls the image explicitly before `TeePopen`, retried on
transport-class errors only. Three properties make that narrow rather than a
blanket retry, and each is measured (docker 29.5.1):
* The pulled command contains no job command, so nothing a job prints can
reach the matcher; and `retry_errors` is matched against stderr while a
successful pull writes its progress to stdout (measured: 335 bytes stdout,
0 stderr), so progress cannot trigger a retry either.
* The allowlist holds transport phrases only. A permanent failure
(`manifest unknown`, `pull access denied`, `no matching manifest`) still
fails on the first attempt.
* The pull is skipped when the image is already present. A bare `docker run`
uses a local image without contacting the registry, so pulling
unconditionally would re-resolve a mutable tag. With the guard there is no
extra registry interaction at all in the warm case.
A pull failure is deliberately not fatal: `docker run` then behaves exactly as
before. This is required, not defensive - an image built locally by this
workflow cannot be pulled at all (measured: `docker pull` fails with
`pull access denied` while `docker run` on the same tag succeeds).
Each attempt is bounded, because `job.timeout` only starts with `TeePopen`, so a
stalled pull would otherwise sit outside every job-level bound. The bound is
written as `timeout --verbose` rather than `Shell.run(timeout=...)`: the latter
does bound the attempt, but its SIGTERMed child writes nothing to stderr, so
`retry_errors` matches nothing and the loop stops after one attempt (measured:
1 attempt vs 3). That is also why the allowlist carries the phrase `timeout
--verbose` emits; plain `timeout` writes nothing and the stall protection would
be silently dead.
Retrying `docker run` itself is not an option: `{job.command}` is inside that
command string, so a retry would re-run a whole test job, and its exit code is
ambiguous by construction - a container command exiting 125 gives docker rc=125,
exactly what a pull failure returns.
Reproduced end to end against a local registry behind a proxy that resets the
downstream direction, which yields rc=125, the same error text, and no container
residue. Verified in both directions through praktika's real retry loop: a
transport reset is retried and succeeds, a permanent error is not retried.
The arm feeding pull progress on stdout was a vacuous oracle for the property two docstrings claimed it pinned. Pull progress contains no allowlisted phrase, so Shell.run's guard stops after one attempt whether retry_errors is matched against stderr alone or against both streams: the arm read one attempt either way and could not redden if the split were lost. Add an arm that sends the verbatim production error line to stdout with an empty stderr and requires a single attempt. It differs from the retried arm in exactly one variable, the stream, so the pair is the demonstration. Mutating Shell.run's stdout thread to feed err_output reddens only the new arm, with the progress arm still passing; the ten pre-existing mutants keep their verdicts and none of them trips the new arm. Reword the progress arm to claim only what it shows, and condense the module docstring from 39 lines to 15 content lines: the incident narrative, the rejected docker-run-retry design and the mutant rationale already live in the pull request description. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The retried pull's safety rests on two claims the suite did not assert. The pull command carries no job command, so a retry can never re-run the job. That was stated in the module docstring and the PR body and pinned by nothing: appending the job command to the pull left every arm green. The ordering arm now also asserts the command ends at the image, which forbids any suffix rather than just this fixture's command string. Asserting the absence alone would not have been enough, because the arm's existing substring check survives that regression unchanged. Four allowlist entries had no witness at all: connection refused, TLS handshake timeout, i/o timeout and unexpected EOF. Deleting any one of them left the suite green, so nothing distinguished a load-bearing entry from decoration. Each now has a case that requires the retry to happen. The two remaining entries were already covered, connection reset by peer by the stdout/stderr pair and the timeout TERM line behaviourally, so they get no duplicate arm. Two of the three permanent errors the allowlist comment names were likewise untested; both now assert a single attempt, using the strings already in tree at docker.py and prefetch-integration-test-images. The docstring no longer enumerates which arms drive the real retry loop. That list was already wrong for one section holding two functions on opposite sides, and it rots whenever an arm is added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three arms compared the pull command against a hardcoded clickhouse/test-base:0abcdef123456_amd, but _run derives that suffix from the host architecture via Utils.is_arm and Utils.is_amd. Nothing in the fixture neutralized it: none of the seven monkeypatch calls touched the architecture, and there is no skip marker and no conftest. The only job running this file is ci_tests, which runs on arm, so the suite would have failed there while passing on every amd dev box. The shared helper that drives _run now pins the architecture, and the expected image stays a literal a reader can check. Deriving the suffix in the test instead would duplicate the branch under test, and skipping on architecture would delete the coverage on the one platform CI uses. Reverting just the two pinning lines is green on amd and red on arm, which is what shows they carry weight; a pass on amd alone is equally consistent with them doing nothing. Swapping the two suffixes in the runner turns the suite red on both architectures, so the expectation is pinned to a constant rather than mirroring the code under test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Internal second-model review (click to expand)Four review rounds, each with an independent cold pass plus a second-model gate against ❌ Blockers found and fixed during review
|
| Claim | Correction |
|---|---|
| "praktika already retries transient registry errors everywhere else it owns a registry interaction" | A false universal, refuted inside praktika's own docker.py: docker login there (:146) is a distinct, praktika-owned, strict=True registry step with no retry, and DockerImage.pull_image() (docker_image.py:27) is another. Narrowed to what the code supports: the docker.py image build does retry. |
"The three sibling docker run invocations" |
Two. The post-job chown and "the chown after a root job" are the same line, since its guard is ... and from_root. |
| "A mutation matrix pins each arm" | Three arms are reddened by no mutant, and all three are controls rather than gaps: one is the retried counterpart of the stdout arm, one is the progress arm that by construction carries no allowlisted phrase, and one extends the permanent-error arm with two phrases no mutant in the matrix touches. Changed to "covers them". |
| Test/mutant/suite counts in the PR body | Every round that added arms falsified them again. Rephrased so the body states the properties rather than digits that go stale. |
"five jobs pull their own image through DockerImage.pull_image()" |
Five is the call-site count. The job count is unstable across derivations (23 by config, 22 distinct names across four workflows). Restated with the grep that reproduces it: five call sites across four job scripts. |
💡 Reviewed and deliberately not changed
- Add
--kill-afterto the per-attempt bound. Raised as a blocker:timeoutsends only
SIGTERM, so a stalled pull might not be hard-bounded. Refuted on docker 29.5.1 with a
positive control. A stalled pull undertimeout --verbose 5returns 124 at 5.01s, and a
genuinely mid-transfer one under--verbose 6at 6.01s, while a child that really ignores
SIGTERM (trap "" TERM; sleep 30) does holdtimeoutfor the full 30.01s. The transfer runs
indockerd, a system service rather than a CLI child, so there is no unbounded descendant
to orphan. The in-tree precedent uses plaintimeout, so this form is already stronger. - The worst-case pull time versus
job.timeout. Three bounded attempts plus backoff sit
outsidejob.timeout, and the generated workflow grants little slack above 360 minutes. Not
reachable: over 30 days every docker job with a timeout at or above 345 minutes keeps more
headroom than the worst case consumes. - Locale sensitivity of the
sending signal TERM to commandentry. That message is a
translatable gettext string, so a translated locale would break the match. coreutils ships no
translation catalogues on the runner image, and under theen_US.UTF-8the base image sets,
only the quote characters change, outside the matched substring. Verified across five locales. - One mutation is knowingly uncaught. Widening the allowlist with a bare lowercase
timeoutreddens no arm. That is measured behaviour-neutral, not a gap: matching is
case-sensitive substring matching and no permanent docker error reaching this matcher
contains it (docker writesClient.Timeout). The guard against an over-broad allowlist is
the permanent-error arm, which does redden when the list is replaced by["docker"].
Pre-PR validation gate (click to expand)
One mutation is knowingly uncaught: widening the allowlist with a bare Session id: cron:clickhouse-impl-slot-5:20260805-183400 |
|
cc @maxknv @leshikus, could you review this? |
|
@groenai
|
A retried pull was silent: the job recovered and nothing on the report said the registry had failed, so the transient loss stayed invisible. Shell.run gains an optional on_retry callback, invoked only where a retry is actually about to be issued -- after the allowlist has matched, so a permanent error never reaches it. The runner passes a hook that records the matched phrase and the image as a workflow warning. The hook writes through _run's own _Environment object rather than Info(). Info() reads a second copy from disk, and a later dump of the in-frame object silently discards the message; measured, the fresh-from-disk warning list goes back to empty. runner.py already uses env.add_workflow_error at five sites. Shell.run's pre-existing except Exception around the loop body reports an exception-terminated attempt as exit code 1, so an unguarded raising hook would replace the real exit code -- 124 for a stalled pull -- with 1 and hide the failure. The callback is therefore wrapped, and the regression test asserts the code is preserved rather than merely that the pull survived. Five test cases cover it: the warning fires on a retry, is absent for a clean pull and for an image already present, fires once per retry issued and never for a permanent error, and a raising hook changes neither the exit code nor the retry outcome.
The callback also ran after the final allowed attempt, where the allowlist still matches but nothing is retried, so a terminal failure could be reported as "retried (3/3)" when only two retries had happened. Fire it only when another attempt follows, and pass the retry budget rather than the attempt budget so the warning counts retries. A new case drives an all-attempts-fail command and asserts the callback count equals the retries issued, not the attempts made.
|
Both done in c7c4c55. 1. Run examples. Added to the PR body. All three occurrences between 2026-07-01 and
The first, verbatim after 17 Three different job families, one on master. Worth noting for anyone re-running the census: 2. Two things I measured while wiring it, both of which changed the implementation:
It also fires only when another attempt follows, and counts retries rather than attempts: Five test cases cover it: the warning fires on a retry, is absent for a clean pull and for |
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Retry the praktika job's Docker image pull on transport-class registry errors, so a connection reset during the image transfer no longer kills the whole job before any test runs.
Description
docker runpulls the job image implicitly, inside the same invocation that runs the job, and praktika executes that invocation exactly once (TeePopenhas no retry facility). So a registry transfer reset there kills the job with zero tests executed, reported as a bare job-level error:praktika can retry a registry interaction it owns as a distinct step, and the
docker.pyimage build does. The job image pull was not a step at all, so nothing could carry a retry.Runner._runnow pulls the image explicitly beforeTeePopen, retried on transport-class errors only. Retryingdocker runitself is not an option:{job.command}sits inside that command string, so a retry would re-run a whole test job, and rc=125 is ambiguous anyway (a container command exiting 125 returns a pull failure's rc).Three properties keep this narrow, each measured on docker 29.5.1:
retry_errorsmatches stderr, while a successful pull writes its progress to stdout (335 bytes stdout, 0 stderr).manifest unknown/pull access denied/no matching manifeststill fail on the first attempt.docker runthen contacts no registry: the warm case is unchanged.Fail-open is required rather than defensive: an image built locally by this workflow cannot be pulled at all (
docker pullgivespull access deniedwhiledocker runon that tag succeeds).Each attempt is bounded, since
job.timeoutonly starts withTeePopen. The bound istimeout --verbose, notShell.run(timeout=...), whose SIGTERMed child writes nothing to stderr, so the loop would stop after one attempt (measured 1 vs 3).Reproduction, validation, and why this is not covered by
retry_infra_failures.ymlReproduced end to end against a local
registry:2behind a proxy resetting the downstream direction mid-transfer: rc=125, same error text, no container residue.A cut-point sweep showed the same fault produces three different messages depending on where the transfer dies; only
connection reset by peermatches all three.Validated in both directions through praktika's real retry loop: a transport reset is retried and succeeds; a permanent error is attempted exactly once. The tests need no docker daemon, and a mutation matrix covers them: dropping
--verboseor its allowlist entry each makes the stall protection dead and reddens its own arm.retry_infra_failures.ymlnames "Docker image pull failures" and does decideshould_rerun=truehere, but it is complementary, and three gaps are measured: it reruns the whole workflow; it only selectsattempt == 1(:37), and the motivating sighting was already on attempt 2, so a reset there is terminal; and it is PR-only (:33), so the master occurrence had no retry path. This fixes the pull where it happens, on master too, on any attempt.All three occurrences, 2026-07-01 to 2026-08-06 (CIDB, each
check_status = errorwith an emptytest_name, i.e. no test row):Stateless tests (amd_asan_ubsan, db disk, distributed plan, sequential, 1/3)ca19728c9e86Build (amd_release)Build (arm_binary)The first, verbatim after 17
Pull completelayers:Rare, but each costs an entire job and no PR diff can influence it.