Compare commits

...
Author SHA1 Message Date
6373712c70
fix(patches): add rstan entry to skip removed tbb_stddef.h
StanHeaders' init_threadpool_tbb.hpp unconditionally includes the legacy
<tbb/tbb_stddef.h> for TBB version detection, but that header was removed
in oneTBB 2021+ (the version StanHeaders now bundles), so rstan's Module.cpp
fails to compile when rstan is pulled in as a transitive dependency (e.g. of
ctsem).

Pre-define TBB_INTERFACE_NEW via CPPFLAGS so the header's own guard skips the
removed include and selects the modern tbb/global_control.h + tbb/task_arena.h
path that the bundled TBB provides. Preserve -I/usr/local/include since the
CPPFLAGS override replaces the R default.
2026-07-14 13:40:03 +00:00
a4b274f281 fix(ci): bound pkgcache _metadata growth to stop macmini disk-fill (#110)
Some checks failed
ci/crow/cron/process-updates/7 Pipeline was successful
ci/crow/cron/process-updates/3 Pipeline was successful
ci/crow/cron/process-updates/9 Pipeline was canceled
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/manual/build-all-versions-install-deps/2 Pipeline was successful
ci/crow/cron/process-updates/13 Pipeline was canceled
ci/crow/cron/process-updates/8 Pipeline failed
ci/crow/cron/process-updates/10 Pipeline was successful
ci/crow/cron/process-updates/17 Pipeline was canceled
ci/crow/cron/process-updates/11 Pipeline was successful
ci/crow/cron/process-updates/5 Pipeline failed
ci/crow/manual/build-all-versions-install-deps/1 Pipeline was successful
ci/crow/cron/process-updates/15 Pipeline failed
ci/crow/cron/process-updates/1 Pipeline was successful
ci/crow/cron/process-updates/14 Pipeline was successful
ci/crow/manual/build-all-versions/2 Pipeline was successful
ci/crow/cron/process-updates/16 Pipeline was successful
ci/crow/manual/build-all-versions/3 Pipeline failed
ci/crow/manual/build-all-versions/1 Pipeline failed
ci/crow/cron/process-updates/18 Pipeline was successful
ci/crow/manual/build-all-versions/6 Pipeline failed
ci/crow/manual/build-all-versions/5 Pipeline was canceled
ci/crow/manual/build-all-versions/8 Pipeline was canceled
ci/crow/cron/process-updates/12 Pipeline was canceled
ci/crow/manual/build-all-versions/7 Pipeline was canceled
ci/crow/cron/process-updates/6 Pipeline failed
ci/crow/cron/process-updates/2 Pipeline failed
ci/crow/manual/build-all-versions/4 Pipeline was canceled
## Problem

The arm64 `build-all` pipeline fills the macmini (gaia) host disk despite an 8h prune.
Root cause is not images or job volumes: it is the persistent dep-cache volume, specifically `pkgcache/R/pkgcache/_metadata`, which grew to ~165 GB.
`{pkgcache}` mints a new content hash for the "patched" binaries repo on every PACKAGES change, so each per-package build writes a fresh ~70 MB `pkgs-<hash>.rds` (+ `patched-<hash>/`) that is never evicted (2407 snapshots observed).
When the disk hits 100% OrbStack stops and the on-host prune can no longer connect to the daemon, so it never self-heals.

## Change (Workstream A of the disk-fill fix)

- Add `trim_pkgcache_metadata()` to `local/r-minor-helpers.R`: keeps the newest `keep` (default 20) `patched-*`/`pkgs-*.rds` entries under `_metadata`, deleting only entries older than `min_age_secs` (default 600s) so it never races the up-to-4 concurrent split-jobs sharing the volume.
Preserves `pkg/` downloads and the stable CRAN/BioC/INLA repo dirs.
No-op when `R_PKG_CACHE_DIR` is empty (amd64) or `_metadata` is absent (first run).
- Call it every 25 packages inside the build loop in `local/build-all.R`.
- Add a defensive start-of-run cleanup of `_metadata/patched-*` + `pkgs-*.rds` to the two workflows that mount the persistent volume (`build-all-versions.yaml`, `build-all-versions-install-deps.yaml`).

Only these paths are touched; `process-updates.yaml`/`weekly-rebuild-missing.yaml` (no persistent volume) are unchanged.

Follow-ups (separate workstreams): on-host self-healing prune watcher + OrbStack disk cap (ansible), and Prometheus/Grafana alerting (k8s-talos).
Upstream: bincraft patched-repo hash churn is the true source fix.

New unit tests (6) for the helper; full suite 24/24 green.

Reviewed-on: #110
2026-07-13 09:31:29 +00:00
ffc2319558 fix(build-all): exclude previously-errored versions in prefilter (#113)
Some checks failed
ci/crow/cron/process-updates/15 Pipeline was canceled
## Motivation

Build jobs were cycling through hundreds of packages that were only ever printed as `Skipping … due to previous build error recorded in metadata DB`, wasting wall-clock on per-package preparation before dropping each one.

## Cause

The prefilter query in `local/build-all.R` selected only successfully-built versions (`error_occurred = FALSE`) into `built`, so line 112 removed only those from the chunk.
Every previously-errored version stayed in the work list and was walked one-by-one, each hitting the internal skip in `build_binary_package()`.
This also explains the misleading `Skipped 0 already-built package versions` line for alphabetical chunks whose leading packages only have error records.

## Changes

- `local/build-all.R`: drop the `AND error_occurred = FALSE` clause so `built` holds every version already attempted (built or errored) for this platform/arch; the existing filter then removes all of them up front.
- Rename the log line to `already-attempted` so the reported count reflects successes and errors.
- Update the surrounding comment to explain why errored versions are excluded.

## Behaviour change

Previously-errored versions are now dropped before the build loop instead of being iterated and individually skipped.
No package that would otherwise build is affected, `build_binary_package()` already skipped these internally.
Retrying errored versions is out of scope and would need a separate opt-in flag on both the prefilter and the in-loop skip.

Reviewed-on: #113
2026-07-13 09:28:45 +00:00
09536cddd6 feat(build): raise BuildKit cache-mount budget for remote builders (#112)
Some checks failed
ci/crow/cron/weekly-rebuild-missing/5 Pipeline was successful
ci/crow/cron/weekly-rebuild-missing/10 Pipeline was successful
ci/crow/cron/weekly-rebuild-missing/11 Pipeline was successful
ci/crow/cron/weekly-rebuild-missing/12 Pipeline was successful
ci/crow/cron/weekly-rebuild-missing/13 Pipeline was successful
ci/crow/cron/weekly-rebuild-missing/9 Pipeline was canceled
ci/crow/cron/weekly-rebuild-missing/14 Pipeline was successful
ci/crow/cron/process-updates/15 Pipeline failed
ci/crow/cron/process-updates/11 Pipeline was canceled
ci/crow/cron/process-updates/16 Pipeline was canceled
ci/crow/cron/process-updates/12 Pipeline was successful
ci/crow/cron/process-updates/5 Pipeline was successful
ci/crow/cron/process-updates/6 Pipeline was successful
ci/crow/cron/process-updates/18 Pipeline was successful
ci/crow/cron/process-updates/17 Pipeline was canceled
ci/crow/cron/process-updates/1 Pipeline was canceled
ci/crow/cron/process-updates/2 Pipeline was canceled
ci/crow/cron/process-updates/8 Pipeline was canceled
ci/crow/manual/build-all-versions-install-deps/2 Pipeline was successful
ci/crow/cron/process-updates/9 Pipeline was canceled
ci/crow/cron/process-updates/3 Pipeline was canceled
ci/crow/cron/process-updates/7 Pipeline was successful
ci/crow/cron/process-updates/10 Pipeline was canceled
ci/crow/manual/build-all-versions/7 Pipeline was canceled
ci/crow/manual/build-all-versions/6 Pipeline failed
ci/crow/manual/build-all-versions/8 Pipeline was canceled
ci/crow/manual/build-all-versions/5 Pipeline was canceled
ci/crow/cron/process-updates/4 Pipeline was canceled
ci/crow/cron/process-updates/14 Pipeline was canceled
ci/crow/cron/process-updates/13 Pipeline was successful
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 on every rebuild.

## Changes

- **`docker/buildkitd.toml`** (new) — GC config passed via `--config` when creating the `docker-container` builders `artemis` (amd64) and `gaia` (arm64):
  - cache-mount tier: **512 MB → 8 GB**, retained 7 days
  - total cache bounded at **40 GB** with **20 GB min-free**
- **`justfile`** — document the `--config docker/buildkitd.toml` flag on the builder-create commands so a recreate does not silently revert to the 512 MB default.

## Notes

- Limits are absolute (not `%`) because the two hosts differ ~6× in free space (Hetzner ~42 GiB free vs Mac mini ~279 GiB). The 20 GB min-free is the safety valve on the disk-tight Hetzner host; the old default wanted 64 GiB free, which does not exist there.
- Both live builders were already recreated with this config and verified running.

Reviewed-on: #112
2026-07-08 08:22:03 +00:00
36d3bc8604 fix(patches): force fs to build vendored static libuv (#111)
Some checks failed
ci/crow/cron/process-updates/11 Pipeline was successful
ci/crow/cron/process-updates/17 Pipeline was successful
ci/crow/cron/process-updates/18 Pipeline was successful
ci/crow/cron/process-updates/6 Pipeline was successful
ci/crow/cron/process-updates/12 Pipeline was successful
ci/crow/cron/process-updates/5 Pipeline was successful
ci/crow/manual/build-all-versions-install-deps/2 Pipeline was successful
ci/crow/cron/process-updates/7 Pipeline was successful
ci/crow/cron/process-updates/1 Pipeline was successful
ci/crow/cron/process-updates/2 Pipeline was successful
ci/crow/cron/process-updates/9 Pipeline was successful
ci/crow/cron/process-updates/8 Pipeline was successful
ci/crow/cron/process-updates/3 Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/manual/build-all-versions/8 Pipeline was canceled
ci/crow/manual/build-all-versions/7 Pipeline was canceled
ci/crow/manual/build-all-versions/6 Pipeline was canceled
ci/crow/manual/build-all-versions/5 Pipeline was canceled
ci/crow/cron/process-updates/10 Pipeline was canceled
## Problem

The `fs` 2.1.0 binary links **system libuv** (`readelf -d fs.so` shows `NEEDED libuv.so.1`).
fs's `configure` prefers system libuv whenever `pkg-config` resolves it, and our build images ship `libuv-devel` (installed as a pak build-time system requirement), so the resulting binary is dynamically linked against `libuv.so.1`.

That binary fails to load on any consumer machine without runtime libuv:

```text
unable to load shared object '.../fs/libs/fs.so':
  libuv.so.1: cannot open shared object file: No such file or directory
```

`install.packages()`/renv do **not** install `SystemRequirements` (only `pak` does, and only inside the build container), so most consumers hit this.
Older fs 1.6.x always vendored libuv, so only the 2.x binaries regressed.
Reproduced in a clean `reg.devxy.io/r/r-alma:4.5-9`.

## Fix

Add `local/patches/fs/force-vendored-libuv.patch`, registered for all platforms.
It short-circuits `configure` to `cp -f src/Makevars.vendor src/Makevars; exit 0` before the pkg-config detection, forcing the bundled static libuv build (`tools/libuv-v1.52.0.tar.gz`, built via cmake).
An env/pkg-config override (`PKG_CONFIG_LIBDIR`) was tried first but the rebuilt binary still linked `libuv.so.1` (the registry `env` tier does not reach fs's configure step), so a source patch is used instead.

## Verification

Built end-to-end inside the real `build-env-redhat:9` image (system libuv present):

- patch fires (`Building static libuv (bincraft: forced vendored)`),
- cmake compiles the vendored libuv,
- resulting `fs.so` has **no `libuv.so.1`** in `NEEDED` (only libR, libstdc++, libm, libgcc_s, libc).

`Rscript local/validate-patches.R` passes (2 entries).
cmake confirmed present in the build-env images.

## Follow-up (not in this PR)

- Rebuild `fs 2.1.0` on every affected platform (rhel8/9/10, ubuntu jammy/noble, alpine 3.22/3.23; amd64 + arm64) and purge the CDN binary paths.
- CDN delivery gap: `purge_cdn_cache.sh` only purges `PACKAGES*`, never package binaries, so rebuilt binaries stay masked until their `.tar.gz` path is purged.

Reviewed-on: #111
2026-07-07 18:12:36 +00:00
9 changed files with 222 additions and 9 deletions

View file

@ -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

View file

@ -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

41
docker/buildkitd.toml Normal file
View file

@ -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://<user@host-amd64>
# docker buildx create --name gaia --driver docker-container \
# --config docker/buildkitd.toml ssh://<user@host-arm64>
#
# 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"

View file

@ -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://<user@host-amd64>
# docker buildx create --name gaia --driver docker-container ssh://<user@host-arm64>
# 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://<user@host-amd64>
# docker buildx create --name gaia --driver docker-container --config docker/buildkitd.toml ssh://<user@host-arm64>
# - exported secrets: B2_S3_ACCESS_KEY, B2_S3_SECRET_KEY, PGPASS (GITHUB_PAT optional)
#
# Overridable (env or `just VAR=… rebuild …`):

View file

@ -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,

View file

@ -0,0 +1,19 @@
diff --git a/configure b/configure
--- a/configure
+++ b/configure
@@ -11,6 +11,15 @@
PKG_TEST_HEADER="<uv.h>"
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}`

View file

@ -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 <tbb/tbb_stddef.h> (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)"
}
]

View file

@ -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-<hash>.rds (+ patched-<hash>/) 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)
}

View file

@ -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")))
})