diff --git a/.crow/build-all-versions-install-deps.yaml b/.crow/build-all-versions-install-deps.yaml index 8649ca0..e0cca86 100644 --- a/.crow/build-all-versions-install-deps.yaml +++ b/.crow/build-all-versions-install-deps.yaml @@ -4,7 +4,7 @@ # they are merged with build-all-versions' identical declarations. variables: target_arch: - description: "Architecture to build." + description: 'Architecture to build.' options: - amd64 - arm64 @@ -27,7 +27,7 @@ variables: - "noble" default: "3.24" R_VERSION: - description: "Primary R version under /opt/R." + description: 'Primary R version under /opt/R.' options: - 4.5.3 - 4.4.3 @@ -74,6 +74,10 @@ steps: commands: # one-time full wipe to fix corrupted .so files from previous failed builds # - rm -rf /mnt/cache/R-pkgs + # Clear churny pkgcache metadata left by a prior crashed run (the "patched" + # repo mints a new hash per PACKAGES change -> unbounded pkgs-*.rds/patched-*). + # Keep pkg/ downloads and the stable CRAN/BioC/INLA repo dirs. + - rm -rf /mnt/cache/pkgcache/R/pkgcache/_metadata/patched-* /mnt/cache/pkgcache/R/pkgcache/_metadata/pkgs-*.rds || true - mkdir -p /mnt/cache/pkgcache /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . # Pin the same bincraft version the build steps use, so the precomputed diff --git a/.crow/build-all-versions.yaml b/.crow/build-all-versions.yaml index 615d273..3932811 100644 --- a/.crow/build-all-versions.yaml +++ b/.crow/build-all-versions.yaml @@ -5,7 +5,7 @@ # Skip list lives in local/excluded-packages.json (read by local/build-all.R). variables: target_arch: - description: "Architecture to build." + description: 'Architecture to build.' options: - amd64 - arm64 @@ -31,7 +31,7 @@ variables: - "resolute" default: "3.24" R_VERSION: - description: "Primary R version under /opt/R." + description: 'Primary R version under /opt/R.' options: - 4.5.3 - 4.4.3 @@ -116,6 +116,11 @@ steps: - ${ARCH}-binaries-r-dep-cache-${OS}-${OS_VERSION//./}:/mnt/cache commands: - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . + # Clear churny pkgcache metadata left by a prior crashed run (the "patched" + # repo mints a new hash per PACKAGES change -> unbounded pkgs-*.rds/patched-*). + # Keep pkg/ downloads and the stable CRAN/BioC/INLA repo dirs. Within-run + # growth is bounded separately by trim_pkgcache_metadata() in build-all.R. + - rm -rf /mnt/cache/pkgcache/R/pkgcache/_metadata/patched-* /mnt/cache/pkgcache/R/pkgcache/_metadata/pkgs-*.rds || true - mkdir -p /mnt/cache/pkgcache /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages # The primary pass must not rely on build-all-versions-install-deps having # run on *this* agent: depends_on only orders the steps, but the cache diff --git a/docker/buildkitd.toml b/docker/buildkitd.toml new file mode 100644 index 0000000..ebbeb38 --- /dev/null +++ b/docker/buildkitd.toml @@ -0,0 +1,41 @@ +# BuildKit GC config for the remote buildx builders (artemis/amd64, gaia/arm64). +# +# Apply when creating the docker-container builders: +# docker buildx create --name artemis --driver docker-container \ +# --config docker/buildkitd.toml ssh:// +# docker buildx create --name gaia --driver docker-container \ +# --config docker/buildkitd.toml ssh:// +# +# Why: BuildKit's default GC caps the ephemeral cache tier — RUN +# --mount=type=cache mounts, local build context, git checkouts — at a +# hardcoded 512 MB (shown as "488.3 MiB" in `buildx inspect`). Across our +# 7-distro build matrix that fills instantly and forces re-downloads of +# system + R packages every rebuild. The first rule below raises that tier. +# +# Limits are absolute (not %) on purpose: artemis and gaia have very +# different free space (Hetzner ~42 GiB free vs Mac mini ~279 GiB), so a +# percentage would mean wildly different real budgets. minFreeSpace = 20 GB +# keeps the tight Hetzner host safe while staying modest on the Mac mini. + +[worker.oci] + gc = true + + # Tier 1 — ephemeral caches (cache mounts, local context, git checkouts). + # Raised from the 512 MB default to 8 GB, retained for 7 days so weekly + # rebuilds reuse downloaded packages instead of re-fetching them. + [[worker.oci.gcpolicy]] + filters = [ + "type==source.local", + "type==exec.cachemount", + "type==source.git.checkout", + ] + keepDuration = "168h" + maxUsedSpace = "8GB" + + # Tier 2 — everything else (image layers, RUN exec results). Bounds the + # whole buildkit cache and always leaves 20 GB free on the host disk. + [[worker.oci.gcpolicy]] + all = true + reservedSpace = "2GB" + maxUsedSpace = "40GB" + minFreeSpace = "20GB" diff --git a/justfile b/justfile index 2d9388d..a9a705e 100644 --- a/justfile +++ b/justfile @@ -10,8 +10,10 @@ # - buildx builders named `artemis` (amd64) and `gaia` (arm64), created with the # docker-container driver (runs BuildKit on the remote host's docker daemon over # SSH). The default `remote` driver does NOT work with an ssh:// docker host. -# docker buildx create --name artemis --driver docker-container ssh:// -# docker buildx create --name gaia --driver docker-container ssh:// +# Pass --config docker/buildkitd.toml so BuildKit's GC keeps a usable cache +# (the default caps the cache-mount tier at 512 MB, forcing re-downloads). +# docker buildx create --name artemis --driver docker-container --config docker/buildkitd.toml ssh:// +# docker buildx create --name gaia --driver docker-container --config docker/buildkitd.toml ssh:// # - exported secrets: B2_S3_ACCESS_KEY, B2_S3_SECRET_KEY, PGPASS (GITHUB_PAT optional) # # Overridable (env or `just VAR=… rebuild …`): diff --git a/local/build-all.R b/local/build-all.R index c0fe7f7..1df1f83 100644 --- a/local/build-all.R +++ b/local/build-all.R @@ -64,10 +64,13 @@ sprintf("# of package versions for this job: %s", nrow(chunk)) exclude <- jsonlite::fromJSON("local/excluded-packages.json")[["package"]] chunk <- chunk[!chunk$Package %in% exclude, ] -# Skip package versions already built in a previous run. +# Skip package versions already attempted in a previous run (built or errored). # pkgs_to_build.rds is a static snapshot from the install-deps step, so on a # restart it still lists everything an interrupted run already produced. The # metadata DB reflects that progress, so we re-derive the remaining set here. +# We exclude *all* attempted versions, not just successful ones: a previously +# errored version is skipped by build_binary_package() anyway, so leaving it in +# the chunk only makes the job cycle through it one-by-one for no benefit. # Derive platform + arch from the running container, mirroring the codename -> # platform mapping bincraft uses internally. The OS/OS_VERSION selectors are # workflow-level CI variables that are not injected into the container @@ -104,13 +107,13 @@ con <- DBI::dbConnect( ) built <- DBI::dbGetQuery( con, - "SELECT name, tag FROM single_builds WHERE platform = $1 AND arch = $2 AND error_occurred = FALSE", + "SELECT name, tag FROM single_builds WHERE platform = $1 AND arch = $2", params = list(platform, arch) ) DBI::dbDisconnect(con) before <- nrow(chunk) chunk <- chunk[!paste(chunk$Package, chunk$Version) %in% paste(built$name, built$tag), ] -sprintf("Skipped %d already-built package versions; %d remaining for this job", before - nrow(chunk), nrow(chunk)) +sprintf("Skipped %d already-attempted package versions; %d remaining for this job", before - nrow(chunk), nrow(chunk)) # Read pre-computed S3 listing from install-deps step # This avoids loading s3fs/reticulate/Python in the build container, @@ -119,6 +122,10 @@ s3_cache <- readRDS("/mnt/cache/packages/s3_cache.rds") sprintf("S3 cache: %s files", length(s3_cache)) n <- nrow(chunk) +# Every `trim_every` packages, bound the pkgcache _metadata dir so a full-platform +# run does not accumulate thousands of ~70 MB snapshots and fill the host disk. +# No-op on amd64 (R_PKG_CACHE_DIR is empty / cache not persisted). +trim_every <- 25L mapply( function(pkg, ver, sens, i) { cat(sprintf("[%d/%d] %s_%s (r_minor_sensitive=%s)\n", i, n, pkg, ver, sens)) @@ -144,6 +151,12 @@ mapply( upload = TRUE, store_build_metadata = TRUE ) + if (i %% trim_every == 0L) { + removed <- trim_pkgcache_metadata() + if (removed > 0L) { + cat(sprintf(" [pkgcache trim] removed %d stale _metadata entries\n", removed)) + } + } }, chunk$Package, chunk$Version, diff --git a/local/patches/fs/force-vendored-libuv.patch b/local/patches/fs/force-vendored-libuv.patch new file mode 100644 index 0000000..c69e8b9 --- /dev/null +++ b/local/patches/fs/force-vendored-libuv.patch @@ -0,0 +1,19 @@ +diff --git a/configure b/configure +--- a/configure ++++ b/configure +@@ -11,6 +11,15 @@ + PKG_TEST_HEADER="" + PKG_LIBS="-luv" + ++# bincraft patch: force the vendored static libuv so the resulting binary ++# is self-contained. fs configure otherwise links system libuv whenever ++# pkg-config finds libuv-devel (installed as a build-time sysreq), yielding ++# an fs.so with NEEDED libuv.so.1 that fails to dyn.load on machines lacking ++# runtime libuv (install.packages/renv do not install SystemRequirements). ++echo "Building static libuv (bincraft: forced vendored)" 1>&2 ++cp -f src/Makevars.vendor src/Makevars ++exit 0 ++ + # Use pkg-config if available + if [ `command -v pkg-config` ]; then + PKGCONFIG_CFLAGS=`pkg-config --cflags --silence-errors ${PKG_CONFIG_NAME}` diff --git a/local/patches/registry.json b/local/patches/registry.json index cdeef99..e53f1c9 100644 --- a/local/patches/registry.json +++ b/local/patches/registry.json @@ -8,5 +8,27 @@ "makevars": {}, "patch": "RcppParallel/disable-tbb.patch", "reason": "bundled Intel TBB build hangs/fails on musl (Alpine) and newer toolchains (g++ 15 on ubuntu-2604); patch unsets USE_TBB and forces -DRCPP_PARALLEL_USE_TBB=0 so RcppParallel skips the bundled build and uses the TinyThread backend" + }, + { + "package": "fs", + "versions": "*", + "platforms": ["*"], + "env": {}, + "configure_args": [], + "makevars": {}, + "patch": "fs/force-vendored-libuv.patch", + "reason": "fs 2.x configure links system libuv whenever pkg-config finds libuv-devel (installed as a build-time sysreq), producing an fs.so with NEEDED libuv.so.1. That binary fails to dyn.load on consumer machines lacking runtime libuv, because install.packages/renv do not install SystemRequirements (only pak does, and only in the build container). The patch short-circuits configure to copy src/Makevars.vendor and build the bundled static libuv (needs cmake) so the binary is self-contained on every platform. An env/pkg-config override was tried first but the rebuilt binary still linked libuv.so.1, so a source patch is used instead." + }, + { + "package": "rstan", + "versions": "*", + "platforms": ["*"], + "env": {}, + "configure_args": [], + "makevars": { + "CPPFLAGS": "-DTBB_INTERFACE_NEW -I/usr/local/include" + }, + "patch": null, + "reason": "StanHeaders' init_threadpool_tbb.hpp unconditionally includes the legacy (removed in oneTBB 2021+) for version detection, breaking compilation of Module.cpp against the bundled oneTBB. Pre-defining TBB_INTERFACE_NEW skips that include and selects the modern tbb/global_control.h + tbb/task_arena.h path that the bundled TBB provides (-I/usr/local/include preserves the default CPPFLAGS the override replaces)" } ] diff --git a/local/r-minor-helpers.R b/local/r-minor-helpers.R index 65aafdf..6f01c29 100644 --- a/local/r-minor-helpers.R +++ b/local/r-minor-helpers.R @@ -31,3 +31,42 @@ parse_build_args <- function(args) { ncpus = as.integer(pos[3L]) ) } + +# Bound the {pkgcache} metadata dir, which otherwise grows without limit: the +# "patched" repo mints a new content hash on every PACKAGES change, so each build +# writes a fresh ~70 MB _metadata/pkgs-.rds (+ patched-/) that is +# never reused. Keep the `keep` newest entries by mtime; only remove entries +# older than `min_age_secs`, so a concurrent split-job's in-flight files are +# never deleted (each build uses a unique hash, so aged entries are +# unreferenced). Stable repo dirs (CRAN-*, BioC*, INLA-*) and pkg/ downloads are +# not matched and thus preserved. Returns the number of entries removed. +trim_pkgcache_metadata <- function(cache_dir = Sys.getenv("R_PKG_CACHE_DIR"), + keep = 20L, + min_age_secs = 600) { + meta <- file.path(cache_dir, "R", "pkgcache", "_metadata") + if (!nzchar(cache_dir) || !dir.exists(meta)) { + return(0L) + } + entries <- c( + Sys.glob(file.path(meta, "patched-*")), + Sys.glob(file.path(meta, "pkgs-*.rds")) + ) + if (length(entries) == 0L) { + return(0L) + } + info <- file.info(entries) + order_new_first <- order(info$mtime, decreasing = TRUE) + ranked <- entries[order_new_first] + ranked_mtime <- info$mtime[order_new_first] + if (length(ranked) <= keep) { + return(0L) + } + candidates <- ranked[(keep + 1L):length(ranked)] + candidate_age <- as.numeric(Sys.time()) - as.numeric(ranked_mtime[(keep + 1L):length(ranked)]) + removable <- candidates[candidate_age >= min_age_secs] + if (length(removable) == 0L) { + return(0L) + } + unlink(removable, recursive = TRUE, force = TRUE) + length(removable) +} diff --git a/local/tests/test-trim-pkgcache.R b/local/tests/test-trim-pkgcache.R new file mode 100644 index 0000000..1a5b418 --- /dev/null +++ b/local/tests/test-trim-pkgcache.R @@ -0,0 +1,68 @@ +source(file.path("..", "r-minor-helpers.R")) + +# Build a fake _metadata dir under a temp R_PKG_CACHE_DIR. Each entry's mtime is +# set to `age_secs` in the past so we can exercise the age gate deterministically. +make_meta <- function(patched = 0L, pkgs = 0L, keep_repos = TRUE, age_secs = 3600) { + root <- tempfile("pkgcache-") + meta <- file.path(root, "R", "pkgcache", "_metadata") + dir.create(meta, recursive = TRUE) + old <- Sys.time() - age_secs + mk_dir <- function(p) { dir.create(p); Sys.setFileTime(p, old); p } + mk_file <- function(p) { writeLines("x", p); Sys.setFileTime(p, old); p } + for (i in seq_len(patched)) mk_dir(file.path(meta, sprintf("patched-%03d", i))) + for (i in seq_len(pkgs)) mk_file(file.path(meta, sprintf("pkgs-%03d.rds", i))) + if (keep_repos) { + mk_dir(file.path(meta, "CRAN-075c426938")) + mk_dir(file.path(meta, "BioCsoft-1ac964ed6c")) + mk_file(file.path(meta, "bioc-sysreqs.dcf.gz")) + mk_dir(file.path(root, "R", "pkgcache", "pkg")) # downloads, must survive + } + root +} + +n_churn <- function(root) { + meta <- file.path(root, "R", "pkgcache", "_metadata") + length(Sys.glob(file.path(meta, "patched-*"))) + + length(Sys.glob(file.path(meta, "pkgs-*.rds"))) +} + +test_that("empty cache_dir is a no-op", { + expect_identical(trim_pkgcache_metadata("", keep = 5L, min_age_secs = 0), 0L) +}) + +test_that("missing _metadata dir is a no-op", { + expect_identical( + trim_pkgcache_metadata(tempfile("absent-"), keep = 5L, min_age_secs = 0), + 0L + ) +}) + +test_that("fewer than keep entries removes nothing", { + root <- make_meta(patched = 2L, pkgs = 2L) + expect_identical(trim_pkgcache_metadata(root, keep = 20L, min_age_secs = 0), 0L) + expect_identical(n_churn(root), 4L) +}) + +test_that("trims down to keep newest, leaving churn == keep", { + root <- make_meta(patched = 30L, pkgs = 30L) # 60 churn entries, all old + removed <- trim_pkgcache_metadata(root, keep = 20L, min_age_secs = 0) + expect_identical(removed, 40L) + expect_identical(n_churn(root), 20L) +}) + +test_that("entries younger than min_age_secs are protected", { + root <- make_meta(patched = 30L, pkgs = 0L, keep_repos = FALSE, age_secs = 60) + # keep=5 would drop 25, but all are 60s old < 600s gate -> nothing removed + expect_identical(trim_pkgcache_metadata(root, keep = 5L, min_age_secs = 600), 0L) + expect_identical(n_churn(root), 30L) +}) + +test_that("stable repo dirs and pkg downloads are never touched", { + root <- make_meta(patched = 30L, pkgs = 30L) + trim_pkgcache_metadata(root, keep = 0L, min_age_secs = 0) + meta <- file.path(root, "R", "pkgcache", "_metadata") + expect_true(dir.exists(file.path(meta, "CRAN-075c426938"))) + expect_true(dir.exists(file.path(meta, "BioCsoft-1ac964ed6c"))) + expect_true(file.exists(file.path(meta, "bioc-sysreqs.dcf.gz"))) + expect_true(dir.exists(file.path(root, "R", "pkgcache", "pkg"))) +})