Keep build caches across runs so a rebuild compiles only what changed

Every build started from an empty container and recompiled R, JASP and 1000+
R packages, which is days of CPU on the runner. Now:

- /srv/jasp-cache on the host holds ccache and the renv package cache, linked
  into the paths jaspModuleTools::compile() hard-codes.
- RENV_PATHS_PREFIX is fixed: renv otherwise keys the cache on the archlinux
  image's build date, so any newer image started cold.
- Cached packages with unresolved sonames after an Arch update are purged, so
  only those are rebuilt.
- R packages compile without -g (the Stan/Eigen ones spend real time and GBs of
  memory on debug info), and retry passes skip modules that already built.

Claude-Session: https://claude.ai/code/session_01GGe7kUhooMFuzY6GaDb2w9
This commit is contained in:
agw
2026-09-30 19:55:00 +02:00
parent a1e65a4d47
commit 5601c0eb96
3 changed files with 81 additions and 8 deletions
+75 -8
View File
@@ -41,6 +41,23 @@ mkdir -p "$OUT_DIR" /build
log() { echo ":: $* "; }
# ------------------------------------------------------------- build caches
# /cache is a directory on the runner host that outlives the container. Without
# it every build recompiles R, JASP and 1000+ R packages from nothing, which is
# days of CPU on the runner; with it a rebuild only compiles what changed.
# /cache/ccache compiler cache for R and JASP's own C++
# /cache/renv-cache compiled R packages, shared by every module
# /cache/renv-root renv's download cache (source tarballs)
CACHE_DIR="${CACHE_DIR:-/cache}"
mkdir -p "$CACHE_DIR"/{ccache,renv-cache,renv-root}
export CCACHE_DIR="$CACHE_DIR/ccache"
export CCACHE_MAXSIZE="${CCACHE_MAXSIZE:-15G}"
# renv files its cache under the OS release, and the archlinux image stamps its
# build date there (linux-arch-20260809.0.570793), so every newer image would
# start from an empty cache. A rolling distro has no release to key on; staleness
# is handled by purge_stale_renv_cache below instead.
export RENV_PATHS_PREFIX=linux-arch
# ---------------------------------------------------------------- system deps
log "installing Arch dependencies"
pacman -Syu --noconfirm --needed \
@@ -121,7 +138,10 @@ cd "R-${R_VERSION}"
--with-x=no \
--with-tcltk=no \
LDFLAGS="-Wl,-rpath,${R_HOME_DIR}/lib/R/lib"
make -j"$JOBS"
# ccache through PATH rather than CC="ccache gcc": configure would bake the
# latter into the shipped Makeconf, and every package a user installs into
# JASP's R would then need ccache.
PATH="/usr/lib/ccache/bin:$PATH" make -j"$JOBS"
make install
# Guard the above: a silently external-BLAS build produces a JASP whose modules
@@ -268,6 +288,50 @@ PY
# with less parallelism than the C++ build uses.
R_JOBS="${R_JOBS:-2}"
export MAKEFLAGS="-j${R_JOBS}"
# R's default flags are -g -O2. Debug info in the Eigen/Stan-heavy packages
# (rstanarm, OpenMx) costs compile time and GBs of cc1plus memory, and nobody
# debugs them. User Makevars only affects this build, not the shipped Makeconf.
mkdir -p ~/.R
cat >~/.R/Makevars <<'EOF'
CFLAGS=-O2 -pipe
CXXFLAGS=-O2 -pipe
CXX11FLAGS=-O2 -pipe
CXX14FLAGS=-O2 -pipe
CXX17FLAGS=-O2 -pipe
CXX20FLAGS=-O2 -pipe
FFLAGS=-O2 -pipe
FCFLAGS=-O2 -pipe
EOF
# jaspModuleTools::compile() hard-codes RENV_PATHS_CACHE and RENV_PATHS_ROOT to
# directories under its workdir, so the persistent cache is linked in there.
MODULE_WORKDIR="$SRC_DIR/Tools/ModuleBundleBuildDir/build_dir"
mkdir -p "$MODULE_WORKDIR"
ln -sfn "$CACHE_DIR/renv-cache" "$MODULE_WORKDIR/renv-cache"
ln -sfn "$CACHE_DIR/renv-root" "$MODULE_WORKDIR/renv-root"
# A cached package is only reusable while the system libraries it linked
# against still exist. Arch rolls (an ICU or gsl soname bump), and renv's cache
# key knows nothing about that, so drop every entry with an unresolved library
# and let renv rebuild just those. Layout: .../<pkg>/<ver>/<hash>/<pkg>/libs/*.so
purge_stale_renv_cache() {
local so entry purged=0
while IFS= read -r so; do
if LD_LIBRARY_PATH="${JASP_R_HOME}/lib" ldd "$so" 2>/dev/null | grep -q 'not found'; then
entry="$(dirname "$(dirname "$(dirname "$so")")")"
[[ -d "$entry" ]] || continue
rm -rf "$entry"
purged=$((purged + 1))
fi
done < <(find "$CACHE_DIR/renv-cache/" -path '*/libs/*.so' 2>/dev/null)
log "renv cache: purged ${purged} entries with missing system libraries"
}
purge_stale_renv_cache
# ccache for R package compiles too: cheap insurance for when renv reinstalls a
# package whose compiler inputs did not actually change.
export PATH="/usr/lib/ccache/bin:$PATH"
# R leaves 00LOCK-<pkg> directories behind when an install is interrupted, and
# refuses to touch the library afterwards ("failed to lock directory ... for
# modifying"). A build that is killed mid-install - a timeout, an OOM, a stopped
@@ -340,14 +404,17 @@ for module in "${ALL_MODULES[@]}"; do
fi
done
MODULE_PATHS=()
for module in "${ALL_MODULES[@]}"; do
[[ -d "ModuleBundleBuildDir/Modules/${module}" ]] && \
MODULE_PATHS+=("ModuleBundleBuildDir/Modules/${module}")
done
for attempt in 1 2 3; do
log "module build pass ${attempt}/3"
# Hand over only modules without a bundle yet: the script rebuilds everything
# it is given, so a retry would otherwise redo every finished module.
MODULE_PATHS=()
for module in "${ALL_MODULES[@]}"; do
[[ -f "${MODULE_BUNDLES}/${module}.JASPModule" ]] && continue
[[ -d "ModuleBundleBuildDir/Modules/${module}" ]] && \
MODULE_PATHS+=("ModuleBundleBuildDir/Modules/${module}")
done
[[ ${#MODULE_PATHS[@]} -eq 0 ]] && break
log "module build pass ${attempt}/3: ${#MODULE_PATHS[@]} modules left"
./buildModuleBundlesLocally.R "${MODULE_PATHS[@]}" || true
built=0
for module in "${ALL_MODULES[@]}"; do