feat(ci): Add revdep4, sequential halves driven by a two-ended bash work queue - #2857
Open
krlmlr wants to merge 27 commits into
Open
feat(ci): Add revdep4, sequential halves driven by a two-ended bash work queue#2857krlmlr wants to merge 27 commits into
krlmlr wants to merge 27 commits into
Conversation
…ork queue revdep2 checks each reverse dependency's CRAN and dev halves as two simultaneous `R CMD check` processes on one host. Simultaneously checking the same package against two libraries is not a supported mode of operation for the packages being checked: the PSOCK port collision that needed the `R_PARALLEL_PORT` split was one failure class of an open-ended family. revdep4 removes the simultaneity instead of isolating it: per package, the old half runs, then the new half, never both at once. The lost concurrency is won back across packages -- a custom bash work queue (revdep4/queue.sh) checks REVDEPX_WORKERS packages at a time, each half in its own container, which isolates different packages from each other too. The queue consumes the shard's heaviest-first list from both ends: one worker takes from the heavy end, so the longest checks start first and are never the straggler discovered last, while the rest drain the cheap tail from the other end. Sequential halves make per-half durations real measurements, and give baseline reuse teeth again: a valid old-version result from an earlier run of either workflow skips the old half outright -- sound now, unlike revdep2's abandoned cross-run comparison, because both eras check inside the same pinned universe image and are parsed by the same code. The engine-agnostic core lands in .github/workflows/revdepx/, shared byte-for-byte with the sibling revdep3 proposal: one artifact family, one manifest, baseline and timings schema, one comparison code path, one universe-image lineage -- either workflow reuses the other's baselines, timings, images and reports, and `retry-run` accepts a run of either. revdep2 stays untouched; this is a proposal beside it, not a replacement of it yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
1 task
…nion only The queue engine briefly revived what revdep2 had retired: substituting a stored old-version result for the old check when the plan judged it comparable. The container platform makes that far safer than it was, but a fresh old check is the only result whose provenance the run fully controls -- so the old half now always runs, in both engines, and a comparable stored result rides along purely as a second opinion: `baseline_agrees` records whether the fresh check reproduced it, and a disagreement is printed as drift. Concretely: the queue engine's per-package price is always two halves (no baseline discount in the plan), the queue file loses its skip_old column, queue.sh always runs both halves, compare-one.R always reads a fresh old side, and compare_halves() loses its baseline-substitution mode. The baseline artifact, its validity conditions and the cross-workflow lineage are unchanged -- only what a valid row is *for* has narrowed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…licy Two sentences still described the retired skip-old design: the timings bullet halved the queue plan's bill where a baseline covered the old half, and the baseline bullet spoke of reusing rows. Both halves always run fresh now, and a stored row is only ever a second opinion. (This push also re-triggers the checks that failed during the GitHub outage.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
An independent review of both revdepx branches confirmed four bugs and a handful of gaps; this applies everything that lives in the shared core or on this branch. - The check-slice cut no longer dies on shards with fewer runnable packages than slices: `seq(index, n, by = of)` is an R error when index exceeds n, which crashed slices of 1-package retry shards and -- with an empty runnable set -- erased recorded depfail diagnoses into `missing`. The slice is also cut before the source downloads now, so each slice fetches only its own tarballs instead of the whole shard's three times over. The same seq() trap is fixed in plan.R's `part` split. - The base image now installs callr: util.R's run_with_timeout() silently degrades to an unbounded inline call without it, and every "bounded" pak call of the in-container universe build ran with no clock -- the exact hang class revdep2 added callr for on the host. A denied base-image push is now a hard error too: unlike the universe image it has no artifact fallback, and everything downstream pulls it. - timing.json's `script_seconds` accumulates across check slices; before, the final slice's overwrite dropped the earlier slices' driver time and the calibration charged it to per-shard setup, inflating every later plan. - The `compared` commit gate counts only this run's own comparisons: carried-over retry results could pass it and let a run that learnt nothing overwrite the committed report. - ensure_check_sysreqs() runs `apt-get update` before its direct install -- the base image deletes the apt lists, so the install otherwise failed quietly. - The shared concurrency group is built from `github.ref_name`, so a dispatch with the `ref` input filled in serializes against one without it on the same branch. - check-half.sh tells its outer safety-net timeout apart from a real check timeout (both exit 124) by when the axe fell, and reports the outer case as a runner failure, not a package one. - The queue's claimed.log and queue-state.json are copied into the results artifact per slice, and queue.sh refuses loudly to run without flock instead of racing its claim cursors in silence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
The previous run passed every substantive step -- build, check, pkgdown -- and failed only on its final `gh api` call posting the success status, which caught a stray HTTP 503 from the outage's tail. The sibling branch's identical run posted its status a minute later and went green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…h 2) A workflow_dispatch workflow is only registered once its file exists on the default branch, so a branch-only workflow cannot be dispatched by API or UI. Fire this first run from the branch itself instead -- the same device the old revdep.yaml uses -- with `which: most` and `depth: 2` hardcoded, since a push event carries no inputs. Revert after the run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
The first live run failed in the build container: "vignette builder 'knitr' not found". The pak bootstrap installs the package's hard dependencies, and building vignettes needs the Suggests tree on top -- which revdep2's host build inherited from setup-r-dependencies without anyone deciding it. The binary exists to be installed into the checks' new-half library, and no check ever builds or reads the package-under-test's own vignettes, so `--no-build-vignettes` removes the requirement instead of importing it. Also forward GITHUB_SHA into the container, so meta.json records the real commit when git cannot answer inside the mount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
The first live run's universe job died in `docker commit`: these runners have one disk (there is no /mnt volume), `docker commit` writes a full copy of the container's rw layer -- the entire installed universe -- onto it, and when the copy filled the disk the runner agent itself could no longer write. The job was killed with its logs, artifacts and cache saves; the step froze as `in_progress` and the `always()` steps never ran. So: measure the rw layer (`du` over the overlay upper dir) and the free space first, and skip the commit with a loud warning when it cannot fit -- the shards' shard-local build fallback carries the run, which is the same path a failed universe job already takes, and which the dying run demonstrated works. After a successful commit, drop the build container before the push, so the original copy of the delta is freed before the push wants scratch space. The artifact fallback gets its own space check, and a new `saved` output gates the artifact upload and download instead of overloading `pushed`. The resource sampler turned out to stream nothing after its own step ended: a backgrounded sampler keeps appending to its RESOURCE_LOG file, but its stdout stops reaching the job log -- and the streamed lines are all that survives a dying runner, which is exactly when they are needed. The long steps (universe build, commit, shard image prep) now run their own in-step samplers on top of the file-writing one. Also rename the "Move docker onto the big disk" steps to tell the truth -- there is no big disk to move to, only room to make -- revert the temporary push trigger and its hardcoded inputs now that the live run is under way (removing the trigger in the same push means this push starts no run), and bump docker/login-action to v4 for the Node.js 24 runtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Every shard of the first live run failed installing the dev binary:
the revdepx-pkg artifact carried meta.json and the source tarball,
but not the binary meta.json named.
build.R moved the binary with file.rename(),
and in the build container the working directory and OUT_DIR are
two different bind mounts --
rename(2) across mounts fails with EXDEV,
which file.rename() reports only as a FALSE nobody read.
Copy with file.copy(), check the result, and fail loudly,
for the source tarball too.
Also chown the pak cache back to the runner user
after the build and universe containers wrote it as root:
their 600 lock files made the cache post-step's tar fail
("Permission denied"), so the cache never saved --
and a warm cache is exactly what makes a retry cheap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…th 2) The first live run died on the two bugs the previous commits fix (the universe commit filling the one disk; the dev binary lost to a cross-mount file.rename()), so the test run runs again. Same device as before -- push trigger plus hardcoded inputs, because a workflow_dispatch workflow is only registered once its file exists on the default branch -- and this time `run-name` is hardcoded too, so the run is titled what it actually checks. Revert this commit after the test run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…dence first The guarded commit died again -- run 32084560474, ~34 minutes in, same as the run before it, runner and all -- and a dead runner turns out to lose even the log lines it already streamed, so the guard's own measurements died with it. Three consequences, all in the universe job: - A new step measures the delta (bytes AND inodes -- `df` can show free gigabytes while millions of small package files run the inode table dry) and uploads the numbers as an artifact BEFORE the commit runs. Whatever kills the copy, the post-mortem now starts with data. - The guard reads those numbers, refuses an unmeasured delta (after two dead runners the burden of proof sits on the commit), and checks inodes as well as bytes. - The commit runs under a watchdog that stops the docker daemon when disk, inodes or memory approach zero -- killing the client would not abort the daemon-side copy -- then restarts it so the report, artifact, cache save and logout still work. A wrong measurement can cost the commit, never a third runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…ost, depth 2)" This reverts commit 0a2c237. The live test run (32084560474) is under way pinned to its own commit, so the branch no longer needs the push trigger -- and reverting it in the same push as the universe hardening means that push starts no spurious sibling run. The workflow is dispatch-only again, with the run title and the which/depth defaults back on the dispatch expressions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
… before retrying singly Two answers to what the universe build log shows. The one-at-a-time installs at the end are the salvage pass: a pak transaction is all-or-nothing, so one broken package strands its whole 400-package chunk, and the retry then paid pak's per-call resolution overhead once per stranded innocent -- hundreds of calls, hours of tail, and the install deadline cutting off packages that were never broken at all. A middle rung now re-tries the missing set in chunks of 50 first; only what still refuses gets the one-at-a-time treatment that names each failure on its own. And the rw layer was carrying temporary files into `docker commit`: every pak install that hit its timeout died mid-build and left its extracted sources and objects in the container's /tmp, and apt's package lists stayed behind from the sysreqs runs -- all of it copied by the commit, and on the delta path inherited by every descendant image. The build containers (universe job and shard-local fallback alike) now bind-mount /tmp from the host, so build residue never enters the layer at all, and image.R sweeps apt lists and any remaining /tmp leftovers before the library is indexed. The pak download cache was already a host bind mount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…a flat scan The one-at-a-time salvage pass paid pak's per-call overhead once per stranded innocent -- and that overhead is irreducible from the driver's side: the resolver runs per call, in pak's own private subprocess, so the only lever is the number of calls. Divide and conquer, in the driving R process (which risks nothing: every pak call already runs in its own clocked subprocess). Retry the missing set whole; a failing set of more than one package splits into three, down to single-package leaves where a genuine failure names itself. Subsets without a culprit succeed as one call, so d culprits hiding in n packages cost about 3 * d * log3(n) calls instead of n. Simulated with pak's prefix-install behavior modelled: for n = 400 and d = 1..10, trisection takes 14-78 calls where the flat scan took 400; fan-outs 3, 4 and 5 are within noise of each other (k/ln k is minimal at 3), and 2 is ~15% worse. This supersedes the chunks-of-50 middle rung from the previous commit; missing_from() re-measures before every call, so nothing a failing transaction did install is ever asked for twice, and a big retry that merely times out splits and continues instead of starting over. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…th 2) The first live run died on the two bugs the previous commits fix (the universe commit filling the one disk; the dev binary lost to a cross-mount file.rename()), so the test run runs again. Same device as before -- push trigger plus hardcoded inputs, because a workflow_dispatch workflow is only registered once its file exists on the default branch -- and this time `run-name` is hardcoded too, so the run is titled what it actually checks. Revert this commit after the test run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32106802120 showed every apt-get that pak executed failing.
Not privileges -- everything that runs apt runs as root -- but the
/tmp bind mount the previous hygiene commit introduced: a plain
mkdir hands the container a 755 directory where /tmp semantics
demand 1777, apt-key cannot create its temporary config there
("Couldn't create temporary file /tmp/apt.conf.XXXX"), and from
that moment every repository fails signature verification and every
apt-get fails with it.
Reproduced locally against rocker/r-ver:4.5.3:
a 755 host directory mounted at /tmp fails apt-get update on all
four repositories exactly as the run did;
chmod 1777 on the same mount and the same update runs clean.
chmod 1777 on every host directory that becomes a container's /tmp:
the universe build, the shard-local fallback build, and -- for
strict /tmp semantics even single-user -- the check containers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…hardening
Four changes, each answering something the live runs surfaced.
R 4.6.1, hard-coded: at least one reverse dependency needs R-release,
so the pinned default moves from oldrel to 4.6.1
(dispatch can still pass 'oldrel', 'release' or any version).
The base image tag follows the recipe hash, so the new platform
builds itself on the next run.
Xvfb in the base image, used everywhere R runs: Tk-based packages
(gWidgets2tcltk) initialise Tk while lazy-loading AT INSTALL TIME
and die headless with `[tcl] invalid command name "font"`.
CRAN's own machines check under X; ours now start a virtual
framebuffer -- image.R for installs and load tests, check-half.sh
for the checks themselves.
The universe delta is measured even under the containerd image
store: GraphDriver is empty there ("upper: <none>"), which made the
guard skip a commit that would have fit five times over
(run 32114635495: ~10G delta, 94G free).
`docker ps -as` prices the rw layer instead when du cannot.
And the sharp edges from the same run: the check-sysreqs survey runs
pak::pkg_sysreqs in chunks of 300 (one call over 3435 packages grew
past 14 GB and was OOM-killed), the build containers get memory caps
so a future kill stays inside the container instead of gambling the
runner, the load test computes its roots from Depends+Imports only
(LinkingTo-only packages -- BH, cpp11 -- were counted as covered yet
never actually loaded; now they are roots and load-tested),
and the check slices run in-step resource samplers,
so the minute-by-minute load lines reach the log
from the phase where the minutes actually go.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…ost, depth 2)" This reverts commit bf9ac26e2517889113c98d270ff7fa15b40a54cd. The live test has its completed run (32114635495: 3435 packages, 20 shards, collect green, report committed), so the branch goes back to dispatch-only. Removing the trigger in its own push means this push starts no run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32114635495's manifest recorded 2293 of 3435 packages as `deferred` with empty messages -- yet every shard's three check slices each ran for a real hour. The slices did the work; the bookkeeping destroyed it: the check phase opened with `file.create(manifest_path)`, and file.create() TRUNCATES an existing file. Every slice wiped its predecessors' manifest lines at startup, the account-for-everything sweep then faithfully re-wrote those packages as `deferred`, and the per-slice artifact upload (same name, overwrite) propagated the loss. The sweep at the end was already written to leave existing lines alone -- it just never got to see them. Create the manifest only when it does not exist yet. The interim semantics stay exactly as designed: earlier slices' lines survive, later lines win per package in the collector, and the last upload carries the whole shard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
The reports committed by runs 32084560474 (cancelled mid-run) and 32114635495 (the slice-truncation bug, fixed in the previous commit) carried entries that describe harness artifacts as package results: shard-cancellation "deadline" failures for packages that were never reached, and `deferred` rows for 2293 checks whose finished results the truncation destroyed. Rather than hand-prune fabrications, the report returns to the last trustworthy state (main's), and the next complete run rewrites it from scratch with real data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…th 2) The first live run died on the two bugs the previous commits fix (the universe commit filling the one disk; the dev binary lost to a cross-mount file.rename()), so the test run runs again. Same device as before -- push trigger plus hardcoded inputs, because a workflow_dispatch workflow is only registered once its file exists on the default branch -- and this time `run-name` is hardcoded too, so the run is titled what it actually checks. Revert this commit after the test run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32148999976 got everything right up to the handoff:
delta measured via docker ps -as (14.9G -- the SizeRw fallback
works), guarded commit succeeded in ten minutes, runner healthy.
Then three dominoes:
- `docker push` refused the commit-created image outright:
the containerd store commits an OCI manifest INDEX, and pushing
one is rejected ("trying to push a manifest list/index").
Push `--platform linux/amd64` -- the fix the error message itself
prescribes -- with the plain push kept as fallback for daemons
without the flag.
- The artifact fallback then `docker save`d a 1336-BYTE
manifest-only shell of the same index -- no layers -- and shipped
it. Save with `--platform` too, and refuse to upload anything
under 100 MB: a universe image compresses to gigabytes, and an
empty shell costs every shard its run.
- shard-prep docker-loaded the shell, correctly recognised it
("no readable /opt/revdepx/lib-index.json") -- and exited instead
of falling back. A bad image from the pull or the artifact now
falls through to the shard-local build; only the locally built
image still fails the shard on that test, because there is no
further rung.
Also: one failing chunk of the sysreqs survey no longer costs the
whole survey (per-chunk tolerance; the same run lost it to a single
pak subprocess error).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32158907637 characterised the containerd image store's commit
completely: the committed image is an OCI index whose children the
daemon never pulled, so a plain push is refused ("not all of them
are available locally"), `--platform linux/amd64` cannot select a
platform the commit never labelled ("does not provide the specified
platform"), and a plain save reduces to a manifest-only shell --
which the new 100 MB floor correctly refused, and the shards fell
through to local builds as designed.
The daemon.json this workflow already writes now also disables the
containerd snapshotter, restoring the classic store whose commit is
a plain single-manifest image that pushes, saves and du-measures
exactly as everything here assumes. The --platform fallbacks and
the save floor stay as belt and braces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…ost, depth 2)" This reverts commit db4fde9. Run 32158907637 (most, depth 2, R 4.6.1) is under way pinned to its own commit and needs no trigger; reverting it here lets the held classic-store fix ride the same push without spawning a sibling run. Re-add the trigger only if another push-driven revdep4 test run is wanted before the PR merges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
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.
Proposes revdep4, the queue engine: old and new halves of each reverse dependency checked sequentially, with the lost concurrency won back across packages by a custom bash work queue — one of two sibling proposals (the other is revdep3, #2856). Both reuse the good bits of revdep2 through a shared, engine-agnostic core, and are fully interoperable with each other.
The problem
revdep2 checks each reverse dependency against the released and the dev igraph simultaneously on one host. The PSOCK port collision (patched with
R_PARALLEL_PORT) was one member of an open-ended interference class — shared/tmp, caches, locks, any singleton a check assumes it owns. Simultaneously checking the same package with two libraries is not a supported mode of operation.What revdep4 does
Removes the simultaneity instead of isolating it: per package, the old half runs, then the new half — no overlap, ever. Parallelism moves to the package level: a shard runs
workerspackages at once (default 4, the runner's cores), each check in its own container, so different packages cannot collide either (stronger than revdep2, which only separated the two halves' ports).The queue (
revdep4/queue.sh, bash, flock-guarded cursors) consumes the shard's heaviest-first package list from both ends: one heavy lane takes the heaviest remaining package — the longest checks start first and are never the straggler discovered last — while the remaining light lanes drain the long tail of cheap packages from the other end; the two meet in the middle. Deadline-aware claiming defers what no longer fits; a crashed claim still writes an error manifest line through a four-rung fallback ladder (compare-one →--errorrerun → printf-JSON template → supervisor sweep), so no claimed package can ever vanish silently; three check slices with an upload after each bound what a reclaimed runner can lose.Heaviness — answering the design question directly: the queue key is expected check seconds — measured on this infrastructure by earlier revdepx runs (either workflow, youngest wins), else CRAN's
T_total× a self-calibrating scale, else the cohort median. Package size and dependency count/size are deliberately not in the key: they price installation, which the shared universe image amortizes to zero (dependency count still gates the depfail screen). This is revdep2's proven cost model, re-priced for sequential halves (×2 per package, ÷workers per shard, with amax(heaviest, sum/workers)bound so a giant-dominated shard is not flattered).Sequential halves make per-half timings real:
t_old/t_newbecome true measurements (revdep2 could only record the pair's wall clock for both), which sharpens the cost model over time.The old half always runs; a stored old result is a second opinion only. Where the plan certifies an earlier run's old-version result as comparable — same revdep version, our CRAN version, container R, base-image tag, dependency fingerprint, within age — the fresh old check is compared against it and
baseline_agreesrecords whether they match, with disagreements reported as drift. It never substitutes for the check, however tempting the saved wall clock: a fresh old is the only result whose provenance the run fully controls. (revdep2-era baselines lack the base-image key and are never offered — they were measured on a different platform entirely.)Pipeline (shared with revdep3):
plan+baseimage (parallel) →build(dev binary inside the container) →universeimage (whole dependency universe + sysreqs on GHCR, delta-updated, age-bounded) →testshards (queue) →collect. Containers also bring per-check memory caps (an OOM kills one container, not the runner) and a pinned check platform (r-version, default oldrel — a fixed target).Kept from revdep2 / dropped
Kept (via
.github/workflows/revdepx/): the planner with all its inputs (packages/broken/retry-run/part/dry-run), the manifest schema and result vocabulary, baseline lineage, timings + calibration, three-slice uploads, resource sampler, chunked deadline-bounded installs, load test, comparison/diff/salvage, report/collect and the committedrevdep/record. Dropped: the port hack, the preflight job and all prebuilt-library artifacts/donor walks/tar machinery, host toolchain setup on shards, per-shard host installs (kept only as disaster-recovery fallback when the universe image is unavailable).revdep2itself is untouched by this PR.Compatibility with revdep3
Both PRs ship
.github/workflows/revdepx/byte-for-byte identical (this PR addsrevdep4.yaml+revdep4/; #2856 addsrevdep3.yaml+revdep3/; merging both leaves one copy, no conflicts). One artifact family (revdepx-*), one manifest/baseline/timings schema, one comparison code path, one universe-image lineage, one concurrency group per ref. Either workflow reads the other's baselines as second opinions, consumes the other's timings (canonical per-half seconds; each plan prices its own engine from them), retries the other's runs, and starts from whichever universe image was refreshed last.Notes for review
GITHUB_TOKENpackage writes degrades gracefully to artifact transport.plan.Rdry runs against live CRAN metadata for both engines (775 revdeps → 20 shards; queue weights verified at 2× pair weights, worker-aware shard walls, the giantmultinmaisolated by the LPT bound); the queue exercised standalone with stub checks (single-claim guarantees, two-ended consumption, deadline defer, all fallback rungs, the final sweep) and end-to-end against a local Docker daemon withrocker/r-ver:4.5.3— two real CRAN packages checked sequentially in containers with real per-half timings, and the second-opinion path verified (fresh old check ran, stored result consulted,baseline_agreesrecorded on the manifest line);bash -n/parse()clean; actionlint findings identical to what revdep2.yaml already carries.HTTP 503fromghone second into the job); the follow-up push re-triggers CI cleanly.docker commitof the universe image filled the runner's single disk (there is no/mntonubuntu-26.04runners) and killed the runner agent mid-copy — the commit step now measures the delta first and skips with a loud warning when the copy cannot fit; the dev binary silently vanished from its artifact becausefile.rename()across two bind mounts fails with EXDEV and itsFALSEwent unread — now a checkedfile.copy(); a resource sampler backgrounded in its own step stops reaching the job log once that step ends, so the long steps now run in-step samplers (a dying runner leaves streamed evidence); and the pak cache never saved because root-in-container wrote it — now chowned back. The shard-local fallback got a production validation for free: with no universe image, every shard built its own from the base image in ~12 minutes and carried on.workflow_dispatchregistration requires the file on the default branch; it will be reverted once the test run is green.