feat(ci): Add revdep4, sequential halves driven by a two-ended bash work queue - #2857
Open
krlmlr wants to merge 37 commits into
Open
feat(ci): Add revdep4, sequential halves driven by a two-ended bash work queue#2857krlmlr wants to merge 37 commits into
krlmlr wants to merge 37 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
…ap checks at 6g Three findings from the most/depth-2 live runs, one commit each way they bit: Bioconductor: 121 packages came back depmissing on dependencies (DESeq2, pwalign, ...) that pak would have installed happily -- pinned_repos() has carried the Bioconductor repositories all along -- because install_closure() intersected every dependency list with CRAN metadata alone, so the planner dropped the names before pak ever saw them. dep_db() now merges the Bioconductor repositories matching the running R version into the dependency metadata; enumeration stays on cran_db(), so the checked set is still CRAN's reverse dependencies. The install union grows 4406 to 4675 on the most/depth-2 plan, and all 77 previously missing dependencies are in it. Compile-failure diagnostics: the dmesg watch caught the kernel killing cc1plus at the per-check cap (anon-rss ~3.3 GiB against the derived ~3.4g) while the manifest said only "fails to install", and failures.md showed "<00install.out file does not exist>" because parse_check() looks for it under the container's path. read_side() now reads the real 00install.out next to the log (tail-capped at 200 lines), and flags compiler kills so the manifest message names the memory cap instead of the maintainer. Memory: the per-check cap defaults to 6g instead of the derived (RAM - 2 GiB) / workers ~ 3.4g. Four workers at 6g overcommit the 15.6 GiB runner deliberately: checks rarely peak together, and the tight cap was turning Stan/TMB compiles into "install failures". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…ment the backlog The check's own compile of the package under test now runs with -g0 appended through the container HOME's ~/.R/Makevars (GNU make reads it after Makeconf, so `+=` extends the image's flags and the last -g* wins) and MAKEFLAGS=-j1: debug info is where a template-heavy Stan/TMB translation unit spends most of its compiler memory -- the dmesg watch caught cc1plus OOM-killed at ~3.3 GiB anon-rss under the old derived cap -- and one compiler process per check keeps the 6g cap sized for one cc1plus rather than a package Makefile's idea of parallelism. Both halves get identical flags by construction, so the comparison stays fair; REVDEPX_CHECK_FLAGS='-g' restores CRAN's own flags, and REVDEPX_CHECK_MAKEFLAGS overrides the make parallelism. The README gains a measured backlog: the universe membership threshold (the install union is so long-tailed that 1696 of 4675 packages are needed by exactly one shard -- baking only packages ≥ 1/4 of shards need would shrink the image to ~1151 packages for a ~218-package per-shard delta install), the shard-count layer question (simulation over the measured durations says full waves beat slack counts, which is the rule the planner already implements), further compile-memory switches, and surfacing memory verdicts in the report summary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
packages=broken re-checks the 204 packages the committed run-9 report lists as not ok (49 failed, 121 depmissing, 21 newly broken, 6 timeouts, the deferred and errors), now under the Bioconductor-aware universe, the 6g per-check cap, -g0/-j1 check compiles, and the 00install.out/compiler-kill diagnostics. Revert this commit after the run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
…the base image Two findings from the broken-packages retest (run 32260705703): collect.R wrote manifest.json and README.md whole from this run's entries, so a subset run -- packages: broken, an explicit list, a part -- shrank the durable record from 3435 rows to its own 204. Rows for packages outside the run's plan are now kept from the committed manifest, marked carried; planned packages stay ineligible, so a dead shard still reads as `missing` instead of hiding behind a stale row. The 00install.out capture paid for itself on first contact: all six remaining install failures (a5R, caugi, osmnxr, RPesto, spopt, zoomerjoin) are "sh: 1: rustc: not found" -- one missing toolchain, not memory. The base image now carries Rust via rustup (current stable, frozen at build time; Ubuntu 24.04's apt rustc 1.75 is below caugi's 1.80 floor), system-wide under /opt/rust with the proxies symlinked into /usr/local/bin. The recipe hash rolls the base tag, so the next run rebuilds base and universe once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
Run 32260705703 (packages: broken) replaced the committed manifest with its own 204 rows -- the collect.R bug fixed in the previous commit. Restored by merging run 32158907637's full record with the retest's fresh rows: 3402 ok, 25 newly broken, 6 failed (all rustc), 1 error, 1 timeout. problems.md, failures.md and README.md were already correct -- they are assembled per package, and every non-retested package is ok and appears in neither. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
tcltk is a *base* R package: pak's sysreqs machinery never sees it as a dependency, so libtcl only ever reached the universe image through some other package's system requirements. Run 32281237129 built a universe for 33 packages, none of whose sysreqs pulled tcl in, and Boptbd, optbdmaeAT and optrcdmaeAT -- which lazy-load tcltk at install time -- failed to *install* with "libtcl8.6.so: cannot open shared object file", after installing fine on every full universe. The base image already carries Xvfb and X fonts precisely for Tk-using checks; now it carries the tcl/tk libraries those checks dyn.load too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UB7YutLzVWU7xCTYUvF3kF
This reverts commit f2323e4.
…re sections The subset-merge carry fed payload-less rows into comparison_of(), whose only shape for a missing payload was an rcmdcheck error -- so revdepcheck classified all 3402 carried ok rows as "failed to check", run 32281237129's README announced "Failed to check (3407)", and a 28-line "Not checked (ok)" failure section appeared for every one of them (failures.md grew by 95916 lines). Two guards in collect.R: a carried ok row without a payload becomes a clean two-sided comparison (status "+", zero rows -- counted by the summary, invisible to every table; verified against rcmdcheck locally), and the section writer skips payload-less carried rows entirely -- their committed sections, where they exist, are already the better evidence. Carried not-ok rows keep the error shim: "failed to check, not by this run" is the closest bucket the report vocabulary has. The committed report is scrubbed in the same commit: the 3400 phantom sections deleted, failures.md reassembled from the 7 real ones, README.md's failed table filtered back to the true 5 rows, cran.md's count and list corrected. manifest.json was correct throughout. 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). In the live runs the model self-corrected on its first opportunity: the second full run consumed the first's measured timings and cut 20 shards in one wave instead of 40 in two.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: a fresh old is the only result whose provenance the run fully controls. (Baselines from a different platform — another R version, another base image — are never offered; the key walls them off.)Pipeline (shared with revdep3):
plan+baseimage (parallel) →build(dev binary inside the container) →universeimage (whole dependency universe + sysreqs, delta-updated, age-bounded) →testshards (queue) →collect. Containers also bring per-check memory caps (default 6g; an OOM kills one container, not the runner — observed working live), low-memory check compiles (-g0,MAKEFLAGS=-j1inside the container; both halves identical, CRAN flags restorable viaREVDEPX_CHECK_FLAGS), and a pinned check platform (r-version, default 4.6.1 — at least one reverse dependency needs R-release) with qpdf/ghostscript/pandoc/TeX/Xvfb, the tcl/tk runtime (tcltk is a base R package, invisible to pak's sysreqs machinery) and Rust (six revdeps compile cargo crates). Dependency metadata spans CRAN and Bioconductor — the checked packages are CRAN reverse dependencies, but what they depend on may live in either repository.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 — a fallback the live runs exercised end-to-end, repeatedly).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, retries the other's runs, and starts from whichever universe image was refreshed last.Live-test results
The full validation run (32158907637,
most, depth 2, R 4.6.1) completed green end to end: 3435 reverse dependencies, 20 shards in one wave, ~6.5 h wall clock — 3231 ok, 21 newly broken, 49 fail to install, 121 with uninstallable dependencies, 6 new-half timeouts. revdep3's cross-run of the same parameters (32196879628) reproduced all 21 newly-broken on the other engine and resolved the residual buckets: five of the six "timeouts" were queue-worker core contention at the 1200 s floor, not dev slowdowns; the dmesg watch caught the kernel OOM-killingcc1plusat the derived ~3.4g per-check cap while the report said only "fails to install"; and the 121 uninstallable were all Bioconductor, dropped by the planner's CRAN-only metadata before pak (whose pinned repositories included Bioconductor all along) ever saw them.Follow-up commits closed each cause: dependency metadata now merges the Bioconductor repositories (install union 4406 → 4675), the per-check cap defaults to 6g, check compiles run with
-g0/-j1,failures.mdcarries the real tail of00install.out(a container-path mismatch had left it "<00install.out file does not exist>"), and a detected compiler kill is named in the manifest message.A targeted rerun of all 204 not-ok packages (32260705703,
packages: broken) validated the lot in one pass: 171 of 204 ok — all 121 uninstallable-dependency packages resolved, 42 of 49 install failures installed (blavaan, gllvm and multinma among them, confirming the memory diagnosis), all 21 newly-broken reproduced. The universe job took the delta path for the first time (145 new packages on top of the published:latest, pushed in 50 min), and the new install-log capture identified the entire remaining residue at a glance: all six failures weresh: 1: rustc: not found— one missing toolchain, not memory.A final rerun on the Rust-enabled base (32281237129) closed the loop: all six Rust packages now install and check ok, and the committed record survived a subset run intact at 3435 rows — validating the collector fix this rerun's predecessor exposed (a subset run used to replace the committed manifest with its own rows; rows outside a run's plan are now carried). It also surfaced one last sysreqs blind spot, fixed here:
tcltkis a base R package, invisible to pak's sysreqs, so a universe built for a small package set shipped no libtcl and the three tcltk-using packages failed to install — the base image now carries the tcl/tk runtime it already had Xvfb for. Current committed record: 3410 ok · 20 newly broken · 3 failed (the tcltk trio, cured by the next run's base) · 1 timeout (ctmm) · 1 deferred. Two earlier newly-broken (c3net, ggm) passed on re-check — flaky rather than broken.The stable newly-broken set (reproduced on both engines and on re-check): cfid, comato, cranly, dci, degreenet, ECoL, ggraph, glyrepr, GoodFitSBM, manynet, MetaNet, migraph, nat, netrics, R6causal, scistreer, SEMgraph, sfclust, tidygraph, vkR.
Notes for review
docker commitfilling the runner's single disk killed two runners with their logs (now: a measured guard, a docker-stop watchdog, and a pre-commit evidence artifact uploaded before the copy starts — a dead runner loses even its streamed log lines);file.rename()across bind mounts silently dropped the dev binary from its artifact (EXDEV; now a checked copy); a 755/tmpbind mount broke every apt call via apt-key's temp files (now 1777, reproduced and verified locally); each check slice truncated the shared manifest (file.create()truncates) and destroyed two thirds of a run's finished results (now create-if-absent; proven fixed by the final run's 4 deferred vs 2293 before); the runners' containerd image store made committed images unpushable OCI indexes anddocker savea 1.3 kB shell (now: the classic graphdriver store via the daemon.json the workflow already writes, plus a 100 MB save floor and shard-prep falling through to a local build on any bad image);pak::pkg_sysreqs()over all 3435 packages was OOM-killed at 14 GB (now chunked, per-chunk fault-tolerant, with memory caps on build containers); Tk-based packages died installing headless (now Xvfb in the base image, used for installs, load tests and checks); and the one-at-a-time install salvage became three-way divide-and-conquer (5–29× fewer pak calls in simulation).strong/depth-1 defaults restored and the check platform pinned at R 4.6.1.GITHUB_TOKENpackage writes degrades gracefully to artifact transport, and a missing image to shard-local builds.