build-cran-binaries/local/build-all.R
pat-s c442e8d35c
fix(build): give the existence cache a per-minor view of the slot
#189 stopped per-minor objects from pruning the candidate list, which was
right, but s3_cache.rds is derived from the same file_names. The cache
handed to build_binary_package() therefore contained no per-minor object
at all, its 'not present in the remote bucket' check answered not-present
for every one, and a restarted run recompiled everything it had already
built: amd64/resolute attempted 8683 packages, compiled 8683, and
reported 'already exists in S3' three times.

The two consumers need opposite views of one listing, and conflating them
was wrong in both directions. The candidate list must ignore per-minor
objects or a package present under one minor prunes itself from every
other minor's work. The existence cache must not ignore them or the work
is redone.

The cache now keeps paths relative to the slot and build-all.R selects
the part matching its pass: <minor>/ for a per-minor pass, the flat slot
plus Archive/ for the primary. build_binary_package() compares basenames
and cannot tell 4.4/curl from curl, so the choice has to be made where
the running R minor is known.

A cache written before this change holds bare basenames; filtering those
by path would select nothing and trigger the very rebuild this prevents,
so they are used unfiltered and #190's staleness check replaces them on
the next pipeline.
2026-09-01 21:47:17 +00:00

260 lines
9.5 KiB
R

sink(stdout(), type = "message")
options(crayon.enabled = TRUE, future.globals.onReference = NULL)
source(file.path("local", "r-minor-helpers.R"))
args <- commandArgs(trailingOnly = TRUE)
parsed <- parse_build_args(args)
split_into <- parsed$split_into
split_index <- parsed$split_index
ncpus <- parsed$ncpus
sensitive_only <- parsed$sensitive_only
options(Ncpus = ncpus)
# Load bincraft eagerly to avoid lazy-load memory spike during first build call
library(bincraft, quietly = TRUE)
library(future)
plan("sequential")
# The install-deps step precomputes the package snapshot into /mnt/cache, but
# that volume is per-agent: a job landing on a fresh agent (or racing
# install-deps) finds it empty. Recompute the snapshot here when any part is
# missing, so the first job on an agent repopulates the cache for the jobs that
# follow; concurrent jobs that also miss simply redo the work. Write via a
# temp file + atomic rename so a concurrent reader never sees a half-written rds.
package_cache_files <- c(
"/mnt/cache/packages/pkgs_to_build.rds",
"/mnt/cache/packages/r_minor_sensitive_pkgs.rds",
"/mnt/cache/packages/s3_cache.rds"
)
# Existence is not freshness. The snapshot describes S3 and CRAN state at the
# moment it was written, and the volume is per-agent, so an agent that ran an
# earlier pipeline keeps serving that pipeline's answer forever. arm64/alpine324
# reported "0 remaining" for both 4.4 and 4.6 from a stale snapshot listing 43
# sensitive packages, while the install-deps step in the very same pipeline had
# just computed 6871 on another agent.
#
# Keyed on the pipeline when the CI exposes one, so a new pipeline recomputes
# once per agent and its shards then share the result. Off CI, or when no such
# variable is set, fall back to an age check.
snapshot_id_path <- "/mnt/cache/packages/snapshot.id"
snapshot_ttl_hours <- as.numeric(
Sys.getenv("PACKAGE_SNAPSHOT_TTL_HOURS", unset = "2")
)
current_snapshot_id <- ""
for (v in c("CI_PIPELINE_NUMBER", "CI_BUILD_NUMBER", "CI_PIPELINE_ID")) {
val <- Sys.getenv(v, unset = "")
if (nzchar(val)) {
current_snapshot_id <- paste(v, val, sep = "=")
break
}
}
snapshot_is_stale <- function() {
if (!all(file.exists(package_cache_files))) {
return(TRUE)
}
if (nzchar(current_snapshot_id)) {
cached <- tryCatch(
readLines(snapshot_id_path, warn = FALSE)[1L],
error = function(e) NA_character_,
warning = function(w) NA_character_
)
return(!identical(cached, current_snapshot_id))
}
age_hours <- as.numeric(
difftime(Sys.time(), file.mtime(package_cache_files[1L]), units = "hours")
)
isTRUE(age_hours > snapshot_ttl_hours)
}
if (snapshot_is_stale()) {
message(
"Package snapshot missing or stale; recomputing via packages-to-build.R"
)
dir.create("/mnt/cache/packages", showWarnings = FALSE, recursive = TRUE)
save_rds_atomic <- function(obj, path) {
tmp <- paste0(path, ".tmp.", Sys.getpid())
saveRDS(obj, tmp)
file.rename(tmp, path)
}
source(file.path("local", "packages-to-build.R"))
save_rds_atomic(pkgs, "/mnt/cache/packages/pkgs_to_build.rds")
save_rds_atomic(
pkgs[r_minor_sensitive == TRUE],
"/mnt/cache/packages/r_minor_sensitive_pkgs.rds"
)
if (nzchar(current_snapshot_id)) {
writeLines(current_snapshot_id, snapshot_id_path)
}
message("Package snapshot recomputed.")
}
pkgs <- if (sensitive_only) {
readRDS("/mnt/cache/packages/r_minor_sensitive_pkgs.rds")
} else {
readRDS("/mnt/cache/packages/pkgs_to_build.rds")
}
# Back-compat: tolerate an older RDS without the column (treat all as non-sensitive)
if (is.null(pkgs$r_minor_sensitive)) {
pkgs$r_minor_sensitive <- FALSE
}
sprintf(
"Total# of remaining package versions: %s (sensitive_only=%s)",
nrow(pkgs),
sensitive_only
)
# Split into chunks for this worker
chunks <- split(pkgs, cut(seq_len(nrow(pkgs)), split_into, labels = FALSE))
chunk <- chunks[[split_index]]
sprintf("# of package versions for this job: %s", nrow(chunk))
# Exclude known problematic packages (single source of truth)
exclude <- jsonlite::fromJSON("local/excluded-packages.json")[["package"]]
chunk <- chunk[!chunk$Package %in% exclude, ]
# 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
# environment, so Sys.getenv() would return "" and this pre-filter would query
# platform "-" and skip nothing.
codename <- bincraft::set_codename(NULL)
platform <- switch(
codename,
jammy = "ubuntu-2204",
noble = "ubuntu-2404",
resolute = "ubuntu-2604",
rhel10 = "redhat-10",
rhel9 = "redhat-9",
rhel8 = "redhat-8",
alpine320 = "alpine-320",
alpine321 = "alpine-321",
alpine322 = "alpine-322",
alpine323 = "alpine-323",
alpine324 = "alpine-324",
alpine325 = "alpine-325",
alpine326 = "alpine-326",
NA_character_
)
local_machine <- Sys.info()[["machine"]]
arch <- if (grepl("arm64|aarch64", local_machine)) "arm64" else "amd64"
con <- DBI::dbConnect(
RPostgres::Postgres(),
dbname = "build_metadata",
host = "r-binaries.devxy.io",
port = 15432,
user = "rpkgs",
password = Sys.getenv("PGPASS"),
sslmode = "require"
)
# Scope the skip to the R minor this pass is running under. `single_builds`
# records `r_version` per attempt, but querying without it made a non-primary
# pass skip everything the primary pass had already attempted under a different
# minor - so `--sensitive-only` under 4.6 skipped packages that had only ever
# been built for 4.5, and the per-minor slots never filled. That is why
# amd64/resolute served 4000 fewer packages to a 4.6 client than to a 4.5 one.
r_minor <- paste(
R.version$major,
strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L],
sep = "."
)
built <- DBI::dbGetQuery(
con,
paste(
"SELECT name, tag FROM single_builds",
"WHERE platform = $1 AND arch = $2",
"AND substring(r_version from '^[0-9]+[.][0-9]+') = $3"
),
params = list(platform, arch, r_minor)
)
DBI::dbDisconnect(con)
before <- nrow(chunk)
chunk <- chunk[
!paste(chunk$Package, chunk$Version) %in% paste(built$name, built$tag),
]
sprintf(
"Skipped %d package versions already attempted under R %s; %d remaining for this job",
before - nrow(chunk),
r_minor,
nrow(chunk)
)
# Read pre-computed S3 listing from install-deps step
# This avoids loading s3fs/reticulate/Python in the build container,
# saving significant memory for the dependency-installer subprocesses
s3_cache_paths <- readRDS("/mnt/cache/packages/s3_cache.rds")
# The cache is stored as paths relative to the slot, so a pass can select the
# objects that belong to it. `build_binary_package()` compares basenames, which
# cannot distinguish `4.4/curl_1.0.tar.gz` from `curl_1.0.tar.gz`, so the choice
# has to be made here where the running R minor is known.
#
# Getting this wrong is expensive in both directions: hand it everything and a
# per-minor pass believes the flat slot's binaries are its own and builds
# nothing; hand it nothing and it rebuilds what it already has. amd64/resolute
# recompiled 8683 packages that way.
select_cache_for_pass <- function(paths, sensitive_only, r_minor) {
# A cache written before this change holds bare basenames. Filtering those by
# path would select nothing and trigger a full rebuild, so use them as they
# are; #190's staleness check replaces it on the next pipeline anyway.
if (!any(grepl("/", paths, fixed = TRUE))) {
message("S3 cache is in the legacy basename format; using it unfiltered.")
return(paths)
}
in_minor <- grepl(sprintf("^%s/", r_minor), paths)
if (sensitive_only) {
basename(paths[in_minor])
} else {
# The primary pass writes the flat slot. Archive/ counts as present there:
# those are versions built and later superseded.
basename(paths[!grepl("^[0-9]+\\.[0-9]+/", paths)])
}
}
s3_cache <- select_cache_for_pass(s3_cache_paths, sensitive_only, r_minor)
sprintf(
"S3 cache: %s files (%s of %s objects apply to this pass)",
length(s3_cache),
length(s3_cache),
length(s3_cache_paths)
)
n <- nrow(chunk)
mapply(
function(pkg, ver, sens, i) {
cat(sprintf("[%d/%d] %s_%s (r_minor_sensitive=%s)\n", i, n, pkg, ver, sens))
bincraft::build_binary_package(
pkg,
tag = ver,
is_r_minor_sensitive = isTRUE(sens),
s3_endpoint = "https://s3.eu-central-003.backblazeb2.com",
s3_region = "eu-central-003",
s3_bucket = "devxy-rpkgs-binaries",
s3_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"),
s3_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"),
s3_package_cache = s3_cache,
metadata_db_host = "r-binaries.devxy.io",
metadata_db_name = "build_metadata",
metadata_db_table = "single_builds",
metadata_db_user = "rpkgs",
metadata_db_password = Sys.getenv("PGPASS"),
metadata_db_sslmode = "require",
metadata_db_port = 15432,
archive = TRUE,
patches = "local/patches",
upload = TRUE,
store_build_metadata = TRUE
)
},
chunk$Package,
chunk$Version,
chunk$r_minor_sensitive,
seq_len(n)
)