build-cran-binaries/local/prune-stale-flat-binaries.R
pat-s a84cca1949 fix(abi): prune the objects recorded broken, not every risky package (#211)
## Why

A dry run of the prune on `ubuntu-2404-amd64` (pipeline 12494):

```
Dry run: 3013 object(s) left in place. Re-run with --apply to delete.
```

3013 objects on one slot. The recorded verdicts say roughly **230** of that slot's objects actually fail `dyn.load()` anywhere.

The gap is the selector: the prune used `abi_cache_risky_packages()`, the source classifier, which calls **93%** of compiled packages risky where the binaries' own symbols say **4.3%**. Applying it would have deleted ~2800 working binaries and turned each into a source fallback for every client that cannot be routed by R minor.

This is the same mistake #129 fixed in the index writer. The prune was written before the per-object verdicts existed (#200, before #128/#129) and was never brought forward.

## What changed

- Selection comes from `abi_flat_safety`, every `NOT safe` row for the slot regardless of which minor it fails under, since the flat object is served to clients on all of them.
- Matching is by **object**, `<package>_<version>`, not by package name. A package can have a safe flat binary at one version and an unsafe one at another; the old code deleted both.
- The explicit `[pkg ...]` override is unchanged in behaviour and still matches by package name, which is what you want when naming a package by hand.
- The refusal guard moves with the selector. An empty result now means the slot's flat-safety backfill never ran — the exact state rhel8 was found in today — and pruning nothing while reporting success would have hidden it.

## Not changed

Still a dry run unless `PRUNE_APPLY=yes`, and still refuses to delete an object whose package CRAN no longer ships, since there would be no source to fall back to.

## Ordering note

A prune converts each deleted binary into a source fallback. bincraft #131 fixes source fallbacks of *archived* versions being advertised as binaries, which breaks `uvr` outright. That should land before any large prune, or this will manufacture new instances of it.

Reviewed-on: #211
2026-09-09 15:01:21 +00:00

175 lines
5.7 KiB
R

### Remove flat-slot binaries recorded as unable to load under some R minor.
#
# Usage:
# Rscript local/prune-stale-flat-binaries.R <arch> <codename> [--apply] [pkg ...]
#
# The list comes from `abi_flat_safety`, written by the flat-safety backfill
# from the binaries' own symbols, so it names exactly the objects that fail at
# dyn.load() somewhere. It deliberately does *not* use
# `abi_cache_risky_packages()`: that source heuristic calls 93% of compiled
# packages risky where the binaries say 4.3%, and pruning on it would delete
# roughly 3000 working objects per slot to fix about 230 broken ones. Selecting
# per object rather than per package is the same correction #129 made to the
# index writer.
#
# `upload_single_binary()` has written a non-portable package only to the
# per-minor slot since v5.3.0, so this is a one-time cleanup of objects
# uploaded before that, not a recurring sweep. The flat slot is what every
# client that cannot be routed by R minor receives, and such a binary was built
# under whichever R was primary at the time, so it fails at load under any
# minor that dropped a symbol it uses:
#
# base64enc.so: undefined symbol: SETLENGTH
#
# Deleting the flat object does not create a hole: bincraft's source fallback
# publishes the CRAN source under the same key, so the package stays installable
# everywhere and compiles against the client's own R. Clients that are routed
# keep getting the per-minor binary.
#
# Dry run by default. Nothing is deleted without --apply.
options(error = function() {
cat("ERROR:", geterrmessage(), "\n", file = stdout())
traceback(2)
q(status = 1)
})
library(bincraft, quietly = TRUE)
args <- commandArgs(trailingOnly = TRUE)
apply_changes <- "--apply" %in% args
args <- args[args != "--apply"]
if (length(args) < 2L) {
stop(
"usage: prune-stale-flat-binaries.R <arch> <codename> [--apply] [pkg ...]",
call. = FALSE
)
}
arch <- args[[1L]]
codename <- args[[2L]]
explicit <- args[-c(1L, 2L)]
bucket <- "devxy-rpkgs-binaries"
s3fs::s3_file_system(
aws_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"),
aws_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"),
endpoint = "https://s3.eu-central-003.backblazeb2.com",
region_name = "eu-central-003",
refresh = TRUE
)
contrib <- sprintf("%s/%s/%s/latest/src/contrib", bucket, arch, codename)
objects <- s3fs::s3_dir_ls(contrib, recurse = TRUE)
relative <- sub(sprintf("^(s3://)?%s/", contrib), "", objects)
is_tarball <- grepl("\\.tar\\.gz$", relative)
# Flat objects only: no `<minor>/` prefix and not under Archive/.
is_flat <- is_tarball & !grepl("/", relative, fixed = TRUE)
flat_files <- relative[is_flat]
flat_pkg <- sub("_.*$", "", flat_files)
if (length(explicit) > 0L) {
doomed <- flat_files[flat_pkg %in% explicit]
cat(sprintf(
"%s/%s: %d flat binaries, %d belong to the %d named package(s)\n",
arch,
codename,
length(flat_files),
length(doomed),
length(explicit)
))
} else {
con <- DBI::dbConnect(
RPostgres::Postgres(),
dbname = "build_metadata",
host = "r-binaries.devxy.io",
port = 15432L,
user = "rpkgs",
password = Sys.getenv("PGPASS"),
sslmode = "require"
)
on.exit(try(DBI::dbDisconnect(con), silent = TRUE), add = TRUE)
# Every recorded-unsafe row, whichever minor it fails under: the flat object
# is served to clients on all of them, so one failing minor condemns it.
rows <- DBI::dbGetQuery(
con,
paste0(
"SELECT package, version FROM abi_flat_safety ",
"WHERE NOT safe AND codename = $1 AND arch = $2"
),
params = list(codename, arch)
)
if (nrow(rows) == 0L) {
stop(
"No unsafe objects are recorded for this slot. Refusing to proceed: that is indistinguishable from a slot whose flat-safety backfill never ran, and pruning nothing while reporting success would hide it.", # nolint
call. = FALSE
)
}
# Match the object, not the package. A package can have a safe flat binary at
# one version and an unsafe one at another, and deleting both would throw
# away a working binary.
unsafe_objects <- sprintf("%s_%s", rows$package, rows$version)
doomed <- flat_files[sub("\\.tar\\.gz$", "", flat_files) %in% unsafe_objects]
cat(sprintf(
"%s/%s: %d flat binaries, %d recorded unable to load under some R minor\n",
arch,
codename,
length(flat_files),
length(doomed)
))
}
# Never remove the last artifact of a package. The source fallback republishes
# the CRAN source under this key, but only for packages CRAN still ships, so
# confirm the source exists before deleting the binary.
cran <- tryCatch(
{
con <- gzcon(url(
"https://cloud.r-project.org/src/contrib/PACKAGES.gz",
open = "rb"
))
on.exit(close(con), add = TRUE)
read.dcf(con, fields = "Package")[, "Package"]
},
error = function(e) character(0)
)
if (length(cran) == 0L) {
stop("Could not read CRAN's index; refusing to prune blind.", call. = FALSE)
}
orphaned <- doomed[!(sub("_.*$", "", doomed) %in% cran)]
if (length(orphaned) > 0L) {
cat(sprintf(
"Keeping %d object(s) whose package is no longer on CRAN, so there is no source to fall back to: %s\n",
length(orphaned),
paste(utils::head(orphaned, 5L), collapse = ", ")
))
doomed <- setdiff(doomed, orphaned)
}
if (length(doomed) == 0L) {
cat("Nothing to prune.\n")
quit(status = 0)
}
for (f in doomed) {
cat(sprintf(" %s %s\n", if (apply_changes) "DELETE" else "would delete", f))
}
if (!apply_changes) {
cat(sprintf(
"\nDry run: %d object(s) left in place. Re-run with --apply to delete.\n",
length(doomed)
))
quit(status = 0)
}
for (f in doomed) {
s3fs::s3_file_delete(file.path(contrib, f))
}
cat(sprintf(
"Deleted %d flat binary object(s). Re-index the slot so the source fallback republishes them.\n",
length(doomed)
))