Skip to content

fix(cluster): cache the actions-runner tarball outside the dir the wipe deletes - #388

Merged
FumingPower3925 merged 1 commit into
mainfrom
fix/387-cache-runner-tarball
Sep 15, 2026
Merged

FumingPower3925 merged 1 commit into
mainfrom
fix/387-cache-runner-tarball

Conversation

@FumingPower3925

Copy link
Copy Markdown
Contributor

Closes #387.

The defect

ansible/runner-setup.yml wiped runner_root, then downloaded the actions-runner tarball into runner_root with force: false. That flag is supposed to skip a download when the file is already present — but the file it looks for lives in the directory the previous task just deleted, so it could never be there. Dead code, and 215 MiB re-fetched per host on every cluster run.

What it cost

run tier bootstrap
34918161309 nightly 1 m 40 s
34940523791 checkptr 2 m 58 s
34736980002 weekend 2 m 05 s
…9 more before today all under 3 m
34961642523 race, today 12 m 20 s
34971158913 nightly, today 15 m 17 s — job cancelled at its cap

The nightly's matrix validate was skipped and no cell ran. Nothing else was in the matrix-tier-cluster group, so this was a timeout, not an eviction. The download alone was 13 m 05 s of it; every other task in that playbook took 3–20 seconds.

225,628,509 bytes in 785 s is ≈287 KB/s.

Retrying was already tried — dc6e702 ci: retry actions-runner tarball download — and cannot help: get_url's timeout is the URL-open timeout, not a transfer cap, so retries: 5 never fires on a slow-but-progressing transfer. It just runs until the job is killed.

The change

Cache outside the wipe. runner_cache_dir, force: false against it, unarchive from there into the freshly wiped runner_root. After the first run per host per runner version the download disappears entirely.

The wipe stays exactly as it was. Its comment explains why a stale .runner is unsafe — it pins a registration GitHub has since deleted and run.sh exits with "the runner registration has been deleted from the server" — but that is an argument about the runner configuration, not about bytes GitHub hands back byte-identical for a filename that already contains the version.

The cache is /tmp/celeris-runner-tarballs, not /tmp/actions-runner-cache. The obvious name is runner_root on a host called cache, and the wipe would delete it. My first draft used the obvious name and the guard test rejected it — included below, because a guard catching its own author on the first run is the only evidence that it bites.

A bad cache self-heals. A cache turns one corrupt download into every future run's problem, and a skipped-because-present file looks exactly like success. The extract is therefore the integrity check: block/rescue discards the cached tarball and fetches it once more if it will not unpack.

timeout-minutes: 15 → 25 on the bootstrap job in all six cluster workflows. A cold cache still moves 215 MiB three times; 15 had no margin even on a good day.

Controls

Every injection was diffed against a backup before the test ran, and reverted with cp, confirmed by SHA-256 match (not just git status).

Control Injection Result
1 get_url dest back inside runner_root FAILa get_url writes to "{{ runner_root }}/{{ runner_tarball }}" instead of the cache
2 cache dir renamed to /tmp/actions-runner-cache FAILsits under runner_root … the wipe deletes it and force:false becomes dead code again
3 teardown given a task removing the cache FAILrunner-teardown.yml references runner_cache_dir
4 one workflow's bootstrap back to timeout-minutes: 15 FAILmatrix-race-tier.yml: bootstrap timeout-minutes=15, want >= 20

Vacuity: TestClusterBootstrapHasHeadroomForAColdCache logs checked 6 cluster workflow(s) and fails outright if it finds zero, so it cannot pass over an empty set.

runner-teardown.yml is byte-identical to HEAD — it only ever removed runner_root, and control 3 guards that it stays that way.

What I ran

gofmt -l . clean, go vet ./... clean, and the root package green both untagged and under -tags mage. I did not run the full go test ./... locally: another agent is working in this repo and the heavy validation package has exhausted this host's process table three times today. CI runs the full suite on this PR, including the tagged job added by #385.

Not addressed here

Whether the cluster's link to github.com was degraded today specifically, or whether 287 KB/s is its normal ceiling for a large object, is unmeasured — my own SSH sessions to the cluster hung repeatedly while investigating, and one 3-packet ping run showed 100 % loss between two runs that showed 0 %. That is worth its own look, but it is not what this PR claims to fix: the point is that the bootstrap should not be moving 645 MiB on a good day either.

…pe deletes (#387)

The bootstrap fetched 215 MiB per host on every cluster run. The
`force: false` that was meant to skip an existing download pointed at a
path inside the directory the preceding "Wipe stale runner dir" task had
just removed, so the file could never be found and the guard was dead
code.

On 2026-09-15 that download took 13 minutes 5 seconds and GitHub
cancelled the nightly's 15-minute bootstrap job in the task after it;
the matrix never ran. The same day's race tier spent 12 minutes 20
seconds there and survived on 2 minutes 40 seconds of margin. Every
bootstrap recorded before that finished in under three minutes, so the
download is the variable and nothing else in the playbook is.

Retrying does not address it. get_url's `timeout` is the URL-open
timeout, not a transfer cap, so `retries: 5` never fires on a
slow-but-progressing transfer; it runs until the job is killed. Caching
removes the transfer instead of trying to bound it.

The wipe stays. Its comment explains why a stale .runner is unsafe -- it
pins a registration GitHub has deleted -- but that argument is about the
runner configuration, not about bytes GitHub returns byte-identical for
a version-addressed filename.

The cache is /tmp/celeris-runner-tarballs, not the obvious
/tmp/actions-runner-cache: the latter IS runner_root on a host named
"cache", and the wipe would take the cache with it. The guard test
caught that in the first draft.

A cache turns one bad download into every later run's problem, so the
extract doubles as the integrity check: if the cached tarball will not
unpack, it is discarded and fetched once more rather than skipped
forever as a successful no-op.

Bootstrap timeout-minutes goes 15 to 25 across all six cluster
workflows. A cold cache still has to move 215 MiB three times, and 15
had no margin for that even on a good day.
@FumingPower3925
FumingPower3925 merged commit 693e938 into main Sep 15, 2026
19 checks passed
@FumingPower3925
FumingPower3925 deleted the fix/387-cache-runner-tarball branch September 15, 2026 13:50
@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Following up on this PR's "Not addressed here" section, which said the link question was unmeasured. It is measured now, and one piece of what I wrote there was wrong.

Wrong: I cited my SSH sessions hanging as a signal. They were not one. Tailscale SSH was waiting on an interactive re-authentication check (login.tailscale.com/a/...); once it cleared, SSH to all three hosts was immediate. That symptom had nothing to do with the link.

Right, but for reasons I had not established: the link is degraded. Tracked as #390.

The decisive comparison is the same task on the same hosts twelve hours apart: TASK [Download actions-runner tarball] ran 01:40:34 → 01:40:48Z in nightly 34918161309 — 14 seconds for the same 645 MiB that took 785 seconds at 12:50Z. A factor of 56 on the same day.

Measured live on an idle cluster: 1.1 MB/s single-stream from GitHub on each of two hosts, but 867 KB/s aggregate when both run at once — two streams total less than one, so it is a single contended pipe, which predicts the 785 s almost exactly. speed.cloudflare.com came back at 193 KB/s, five times slower again, so it is not GitHub.

That does not change anything in this PR. Moving 645 MiB per bootstrap was wasteful at 46 MB/s too; it just was not visible until the pipe narrowed. But it does mean the cache is not a fix for #390, and I am holding the 24-hour soak and the benchmark until the link recovers.

FumingPower3925 added a commit that referenced this pull request Sep 15, 2026
…he wipe deletes (#392)

#388 cached the actions-runner tarball, but the same playbook fetched four
more tools from the internet on every run into the directory it wipes:
uv, a python build, ansible-core and ansible.posix. Each sat behind a
skip-if-present guard that could therefore never skip, none retried, and
ansible-core and ansible.posix were unpinned. On 2026-09-15 a Galaxy
timeout on one host (API time-to-first-byte 23s while GitHub on the same
host did 48 MB/s) failed nightly 34981852841 although the other two
hosts had already registered their runners.

Pinning comes first. Caching an unpinned install silently freezes
whichever version installed first, with nothing recording which. uv
0.12.15, python 3.13, ansible-core 2.21.4 and ansible.posix 2.2.2 are
pinned, and each cache directory is keyed by its pin so a bump installs
fresh. All four were field-proven by the 14:29Z bootstrap on all three
hosts.

Six cluster workflows reach the venv and the collections through paths
they hardcode under runner_root, not through anything this playbook
exports, so moving the tools would have broken every tier. The two
legacy paths stay alive as symlinks into the cache. Teardown's wipe is
ansible.builtin.file state=absent, which for a directory is
shutil.rmtree; that unlinks a symlink and never follows it -- measured
on Linux CPython 3.13 -- so the wipe drops the links and keeps the cache.

uv venv now runs with --managed-python. Without it uv prefers any
matching python already on PATH over downloading into
UV_PYTHON_INSTALL_DIR; an end-to-end run caught the "cached" venv linked
to the test controller's python, and a fresh container could not
execute it. The cluster only escaped because its system python is 3.14.
UV_PYTHON_INSTALL_DIR and UV_CACHE_DIR also move uv's python and cache
under /tmp, which the playbook's own pristine rule always required and
uv's defaults under $HOME quietly broke.

ansible.posix is trusted from the cache only if its MANIFEST.json names
the pinned version; otherwise it is fetched from Galaxy, and if Galaxy
will not serve it, built from the collection's GitHub source tag. It
publishes no release assets, so its git source is the only non-Galaxy
route, and git is present on every host.

TestBootstrapToolchainIsPinnedAndCachedOutsideTheWipedDir asserts the
pins, the cache locations, the manifest check, --managed-python, and the
six-workflow symlink contract from both ends.
FumingPower3925 added a commit that referenced this pull request Sep 15, 2026
…he wipe deletes (#392)

#388 cached the actions-runner tarball, but the same playbook fetched four
more tools from the internet on every run into the directory it wipes:
uv, a python build, ansible-core and ansible.posix. Each sat behind a
skip-if-present guard that could therefore never skip, none retried, and
ansible-core and ansible.posix were unpinned. On 2026-09-15 a Galaxy
timeout on one host (API time-to-first-byte 23s while GitHub on the same
host did 48 MB/s) failed nightly 34981852841 although the other two
hosts had already registered their runners.

Pinning comes first. Caching an unpinned install silently freezes
whichever version installed first, with nothing recording which. uv
0.12.15, python 3.13, ansible-core 2.21.4 and ansible.posix 2.2.2 are
pinned, and each cache directory is keyed by its pin so a bump installs
fresh. All four were field-proven by the 14:29Z bootstrap on all three
hosts.

Six cluster workflows reach the venv and the collections through paths
they hardcode under runner_root, not through anything this playbook
exports, so moving the tools would have broken every tier. The two
legacy paths stay alive as symlinks into the cache. Teardown's wipe is
ansible.builtin.file state=absent, which for a directory is
shutil.rmtree; that unlinks a symlink and never follows it -- measured
on Linux CPython 3.13 -- so the wipe drops the links and keeps the cache.

uv venv now runs with --managed-python. Without it uv prefers any
matching python already on PATH over downloading into
UV_PYTHON_INSTALL_DIR; an end-to-end run caught the "cached" venv linked
to the test controller's python, and a fresh container could not
execute it. The cluster only escaped because its system python is 3.14.
UV_PYTHON_INSTALL_DIR and UV_CACHE_DIR also move uv's python and cache
under /tmp, which the playbook's own pristine rule always required and
uv's defaults under $HOME quietly broke.

ansible.posix is trusted from the cache only if its MANIFEST.json names
the pinned version; otherwise it is fetched from Galaxy, and if Galaxy
will not serve it, built from the collection's GitHub source tag. It
publishes no release assets, so its git source is the only non-Galaxy
route, and git is present on every host.

TestBootstrapToolchainIsPinnedAndCachedOutsideTheWipedDir asserts the
pins, the cache locations, the manifest check, --managed-python, and the
six-workflow symlink contract from both ends.
FumingPower3925 added a commit that referenced this pull request Sep 15, 2026
…he wipe deletes (#392) (#393)

* fix(cluster): pin and cache the bootstrap toolchain outside the dir the wipe deletes (#392)

#388 cached the actions-runner tarball, but the same playbook fetched four
more tools from the internet on every run into the directory it wipes:
uv, a python build, ansible-core and ansible.posix. Each sat behind a
skip-if-present guard that could therefore never skip, none retried, and
ansible-core and ansible.posix were unpinned. On 2026-09-15 a Galaxy
timeout on one host (API time-to-first-byte 23s while GitHub on the same
host did 48 MB/s) failed nightly 34981852841 although the other two
hosts had already registered their runners.

Pinning comes first. Caching an unpinned install silently freezes
whichever version installed first, with nothing recording which. uv
0.12.15, python 3.13, ansible-core 2.21.4 and ansible.posix 2.2.2 are
pinned, and each cache directory is keyed by its pin so a bump installs
fresh. All four were field-proven by the 14:29Z bootstrap on all three
hosts.

Six cluster workflows reach the venv and the collections through paths
they hardcode under runner_root, not through anything this playbook
exports, so moving the tools would have broken every tier. The two
legacy paths stay alive as symlinks into the cache. Teardown's wipe is
ansible.builtin.file state=absent, which for a directory is
shutil.rmtree; that unlinks a symlink and never follows it -- measured
on Linux CPython 3.13 -- so the wipe drops the links and keeps the cache.

uv venv now runs with --managed-python. Without it uv prefers any
matching python already on PATH over downloading into
UV_PYTHON_INSTALL_DIR; an end-to-end run caught the "cached" venv linked
to the test controller's python, and a fresh container could not
execute it. The cluster only escaped because its system python is 3.14.
UV_PYTHON_INSTALL_DIR and UV_CACHE_DIR also move uv's python and cache
under /tmp, which the playbook's own pristine rule always required and
uv's defaults under $HOME quietly broke.

ansible.posix is trusted from the cache only if its MANIFEST.json names
the pinned version; otherwise it is fetched from Galaxy, and if Galaxy
will not serve it, built from the collection's GitHub source tag. It
publishes no release assets, so its git source is the only non-Galaxy
route, and git is present on every host.

TestBootstrapToolchainIsPinnedAndCachedOutsideTheWipedDir asserts the
pins, the cache locations, the manifest check, --managed-python, and the
six-workflow symlink contract from both ends.

* fix(cluster): trust the cached toolchain only when verified, never on presence

Review of this branch found that the first cut still trusted three
cached states on presence alone, which is the exact failure the change
exists to remove, and each of them survives the wipe now that the cache
outlives the run that left it:

- Ansible's `creates:` checks with glob.glob, which counts a DANGLING
  symlink as present. A venv whose python was deleted was skipped and
  failed later inside a job instead of being rebuilt. The self-heal the
  comment promised never ran.
- uv writes a wheel's entry-point scripts before its package data,
  metadata and RECORD, so bin/ansible-playbook can exist over an install
  the bootstrap timeout killed half way.
- ansible-galaxy writes MANIFEST.json before any other collection file,
  so a manifest naming the pinned version proves nothing about the files
  after it.

The venv and ansible-core are now one cached unit, trusted only when a
completion stamp written last exists AND ansible-playbook actually runs
and reports the pinned core; a miss rebuilds it from scratch.
ansible.posix is trusted only with a completion stamp AND
`ansible-galaxy collection verify --offline`, which checks every file
against its recorded hash, AND a MANIFEST.json version equal to the
pin: verify does not hash MANIFEST.json itself, and the end-to-end run
caught a cached manifest altered to 0.0.0 passing it.

ansible-core's dependencies were unpinned and would have been frozen by
the cache at whatever resolved first. They are now resolved with
`--exclude-newer 2026-09-15T00:00:00Z`, and the cutoff is part of the
venv's directory name, so moving it builds a fresh venv.

Each Galaxy attempt is bounded by coreutils timeout with one retry.
retries alone never bound a stalled transfer: at the 2m29s stall that
failed nightly 34981852841, four unbounded attempts would have used ~11
minutes of a 25-minute bootstrap before the fallback ran. The fallback
has its own timeout and installs commit e98d9a07 rather than the 2.2.2
tag, which upstream could move.

Smaller fixes from the same review: ANSIBLE_HOME keeps ansible-galaxy's
temp dirs and API cache inside the tool cache, so "nothing lands outside
/tmp" is true for it too; the cache directories carry an explicit owner;
the link task drops force, which switched off ansible's only check that
a link target exists, and a stat now fails the play if a target is
missing; changed_when no longer masks a module failure.
RUNNER_BOOTSTRAP.md documents the persistent tool cache instead of
claiming nothing persists, and the teardown check is renamed to say it
confirms the runner dir only.

TestBootstrapToolchainCacheIsTrustedOnlyWhenVerified and
TestCachedAnsiblePosixCheckComparesTheManifestVersion guard all of it.
Its first version failed on the good tree: a substring check for
"force: true" matched the comment explaining why there is no force.
It now matches force as a YAML key in any truthy spelling.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant