fix(rebuild): re-index and purge the CDN after a rebuild (#160)
Some checks failed
ci/crow/cron/process-updates/10 Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/cron/process-updates/13 Pipeline was successful
ci/crow/cron/process-updates/14 Pipeline was successful
ci/crow/cron/process-updates/15 Pipeline was successful
ci/crow/cron/process-updates/16 Pipeline was successful
ci/crow/cron/process-updates/11 Pipeline is running
ci/crow/cron/process-updates/18 Pipeline was successful
ci/crow/cron/process-updates/17 Pipeline was successful
ci/crow/cron/process-updates/12 Pipeline was successful
ci/crow/manual/weekly-audit-missing/6 Pipeline was successful
ci/crow/manual/weekly-audit-missing/5 Pipeline was successful
ci/crow/cron/process-updates/6 Pipeline failed
ci/crow/cron/process-updates/5 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/7 Pipeline was successful
ci/crow/cron/process-updates/8 Pipeline was successful
ci/crow/cron/process-updates/9 Pipeline was successful
ci/crow/cron/process-updates/3 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/5 Pipeline was canceled
Some checks failed
ci/crow/cron/process-updates/10 Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/cron/process-updates/13 Pipeline was successful
ci/crow/cron/process-updates/14 Pipeline was successful
ci/crow/cron/process-updates/15 Pipeline was successful
ci/crow/cron/process-updates/16 Pipeline was successful
ci/crow/cron/process-updates/11 Pipeline is running
ci/crow/cron/process-updates/18 Pipeline was successful
ci/crow/cron/process-updates/17 Pipeline was successful
ci/crow/cron/process-updates/12 Pipeline was successful
ci/crow/manual/weekly-audit-missing/6 Pipeline was successful
ci/crow/manual/weekly-audit-missing/5 Pipeline was successful
ci/crow/cron/process-updates/6 Pipeline failed
ci/crow/cron/process-updates/5 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/7 Pipeline was successful
ci/crow/cron/process-updates/8 Pipeline was successful
ci/crow/cron/process-updates/9 Pipeline was successful
ci/crow/cron/process-updates/3 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/5 Pipeline was canceled
## Problem
The rebuild now works — `AATtools 0.0.3` was detected as a source fallback, built, and published:
```
ℹ `upload_single_binary()`: Replacing the CRAN source published for AATtools 0.0.3 … with the binary.
✔ Successfully uploaded package AATtools with tag 0.0.3.
```
But clients still get the source, and will for about a year:
```
$ curl -sI .../src/contrib/AATtools_0.0.3.tar.gz
etag: "ea8127d953ca6a2f118ea49441772af6" # CRAN's source MD5
cdn-cache: HIT
cdn-cachedat: 08/09/2026 16:43:32 # predates the 18:12 upload
```
Two causes, both specific to a rebuild:
1. **The slot is never re-indexed.** `weekly-rebuild-missing` has no `upload_package_index` step, so the index keeps the old MD5 and — for anything that had been served from source — no `Built` stamp. This one self-heals at the next `process-updates` run.
2. **The tarball URL is never purged.** A normal update publishes new packages at *new* URLs, so `purge_cdn_cache.sh` only needs the five index files. A rebuild replaces an object *in place*, and the zone caches tarballs for `cache_expiration_time = 31919000` (~370 days). This does not self-heal.
Nothing about a stale package looks wrong from the outside, which is what makes it worth fixing rather than documenting.
## What this changes
**Re-index at the end of a rebuild**, flat and per-minor, mirroring the tail of `process-updates`. The codename is detected from the image's `/etc/os-release` (as `local/packages-to-build.R` already does) rather than adding `OS_ID` to all 18 matrix rows.
**Purge the zone afterwards**, via a new `scripts/purge_cdn_zone.sh`. One call to `POST /pullzone/{id}/purgeCache` covers every replaced object, and all three hostnames — `cran.devxy.io`, `cran.allianceswisspass.devxy.io`, `cran.rpkgs.com` — share pull zone `3857050`, confirmed from the `cdn-pullzone` response header.
Purging per URL was the alternative and is worse here: ~13.5k rate-limited calls per arch, where a single missed call leaves a package silently stale. The cost of the zone purge is a cold cache for everything else, which is why it stays out of the daily update path — `purge_cdn_cache.sh` is untouched.
The purge runs on failure too (`when: status: [success, failure]`): a rebuild that died part-way still replaced objects, and those are exactly the ones a stale edge keeps hiding.
## Verification
`crow lint .crow/` reports all ten configs valid; `bash -n` on the new script passes; prek hooks pass.
Not yet exercised against Bunny — it needs `BUNNYNET_API_KEY`, which is a CI secret. The failure mode is explicit rather than silent: any status other than 200/204 prints the response body and exits non-zero.
Reviewed-on: #160
This commit is contained in:
parent
6372f0f928
commit
01b8ab43df
2 changed files with 85 additions and 0 deletions
|
|
@ -163,6 +163,17 @@ steps:
|
||||||
- UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh httr2
|
- UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh httr2
|
||||||
- /opt/R/$R_VERSION/bin/R -q -e 'source("local/fetch-rebuild-packages-from-issue.R")'
|
- /opt/R/$R_VERSION/bin/R -q -e 'source("local/fetch-rebuild-packages-from-issue.R")'
|
||||||
- $XVFB $XVFB_ARGS -- /opt/R/$R_VERSION/bin/R -q -e "sink(stdout(), type = 'message'); options(crayon.enabled = TRUE, Ncpus = $NCPUS, future.globals.onReference = NULL); pkgs <- readLines('/tmp/rebuild_pkgs.txt'); if (length(pkgs) == 0) { cat('Nothing to rebuild\n'); q('no') }; excluded <- jsonlite::fromJSON('local/excluded-packages.json')[['package']]; pkgs <- setdiff(pkgs, excluded); cat(sprintf('Rebuilding %d packages\n', length(pkgs))); n <- length(pkgs); for (i in seq_along(pkgs)) { x <- pkgs[i]; cat(sprintf('[%d/%d] %s\n', i, n, x)); tryCatch(bincraft::build_binary_package(x, tag_limit = 1L, patches = 'local/patches', 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'), 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, upload = TRUE, store_build_metadata = TRUE), error = function(e) cat(sprintf('ERROR building %s - %s\n', x, conditionMessage(e)))) }" 2>&1
|
- $XVFB $XVFB_ARGS -- /opt/R/$R_VERSION/bin/R -q -e "sink(stdout(), type = 'message'); options(crayon.enabled = TRUE, Ncpus = $NCPUS, future.globals.onReference = NULL); pkgs <- readLines('/tmp/rebuild_pkgs.txt'); if (length(pkgs) == 0) { cat('Nothing to rebuild\n'); q('no') }; excluded <- jsonlite::fromJSON('local/excluded-packages.json')[['package']]; pkgs <- setdiff(pkgs, excluded); cat(sprintf('Rebuilding %d packages\n', length(pkgs))); n <- length(pkgs); for (i in seq_along(pkgs)) { x <- pkgs[i]; cat(sprintf('[%d/%d] %s\n', i, n, x)); tryCatch(bincraft::build_binary_package(x, tag_limit = 1L, patches = 'local/patches', 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'), 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, upload = TRUE, store_build_metadata = TRUE), error = function(e) cat(sprintf('ERROR building %s - %s\n', x, conditionMessage(e)))) }" 2>&1
|
||||||
|
# A rebuild replaces objects in place, so the slot's index still advertises
|
||||||
|
# the old MD5 and, for anything that had been served from source, no Built
|
||||||
|
# stamp. Re-index here rather than waiting for the next process-updates
|
||||||
|
# run, or the rebuilt binaries stay invisible to clients until then.
|
||||||
|
# The codename is detected from the image's /etc/os-release.
|
||||||
|
- /opt/R/$R_VERSION/bin/R -q -e 'library(bincraft); upload_package_index(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"))'
|
||||||
|
- |
|
||||||
|
for RBIN in /opt/R/[0-9]*/bin/R; do
|
||||||
|
RMINOR=$(basename "$(dirname "$(dirname "$RBIN")")" | cut -d. -f1-2)
|
||||||
|
/opt/R/$R_VERSION/bin/R -q -e "library(bincraft); upload_package_index(r_minor = '$RMINOR', 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'))" || true
|
||||||
|
done
|
||||||
backend_options:
|
backend_options:
|
||||||
docker:
|
docker:
|
||||||
resources:
|
resources:
|
||||||
|
|
@ -172,3 +183,25 @@ steps:
|
||||||
limits:
|
limits:
|
||||||
memory: 18Gi
|
memory: 18Gi
|
||||||
cpu: 3000m
|
cpu: 3000m
|
||||||
|
|
||||||
|
- name: Purge CDN cache
|
||||||
|
image: reg.devxy.io/docker.io/library/alpine:3.24
|
||||||
|
environment:
|
||||||
|
OTEL_R_TRACES_EXPORTER: none
|
||||||
|
OTEL_R_LOGS_EXPORTER: none
|
||||||
|
OTEL_R_METRICS_EXPORTER: none
|
||||||
|
BUNNYNET_API_KEY:
|
||||||
|
from_secret: BUNNYNET_API_KEY
|
||||||
|
REPO_RO_TOKEN:
|
||||||
|
from_secret: REPO_RO_TOKEN
|
||||||
|
# All hostnames on the zone share this id, so one purge covers
|
||||||
|
# cran.devxy.io, cran.allianceswisspass.devxy.io and cran.rpkgs.com.
|
||||||
|
BUNNY_PULLZONE: '3857050'
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache -q bash curl git
|
||||||
|
- git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git .
|
||||||
|
- bash scripts/purge_cdn_zone.sh "$BUNNYNET_API_KEY" "$BUNNY_PULLZONE"
|
||||||
|
# A rebuild that died part-way still replaced objects, and those are exactly
|
||||||
|
# the ones a stale edge would keep hiding, so purge either way.
|
||||||
|
when:
|
||||||
|
- status: [success, failure]
|
||||||
|
|
|
||||||
52
scripts/purge_cdn_zone.sh
Executable file
52
scripts/purge_cdn_zone.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Purge the entire BunnyCDN pull zone.
|
||||||
|
#
|
||||||
|
# `purge_cdn_cache.sh` purges the five index files by URL, which is right after
|
||||||
|
# a normal update: new packages arrive at new URLs, so only the index is stale.
|
||||||
|
#
|
||||||
|
# A rebuild is different. It replaces an object *in place*: a package whose
|
||||||
|
# build failed was published as its CRAN source, and the rebuilt binary takes
|
||||||
|
# exactly the same URL. The zone caches tarballs for ~370 days
|
||||||
|
# (`cache_expiration_time` in cdn.tf), so without a purge every client keeps
|
||||||
|
# receiving the source tarball for up to a year, and nothing about it looks
|
||||||
|
# wrong from the outside.
|
||||||
|
#
|
||||||
|
# Purging per URL would mean one API call per replaced package -- ~13.5k per
|
||||||
|
# arch against a rate-limited endpoint, where a single missed call leaves a
|
||||||
|
# silently stale package. One zone purge is a single call regardless of how many
|
||||||
|
# objects were replaced. The cost is a cold cache for everything else, which is
|
||||||
|
# why this is not used by the daily update path.
|
||||||
|
#
|
||||||
|
# All hostnames on the zone (cran.devxy.io, cran.allianceswisspass.devxy.io,
|
||||||
|
# cran.rpkgs.com) share pull zone 3857050, so one purge covers all of them.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# purge_cdn_zone.sh <BUNNYNET_API_KEY> <pull_zone_id>
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if (($# < 2)); then
|
||||||
|
echo "usage: $0 <api_key> <pull_zone_id>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_key="$1"
|
||||||
|
zone_id="$2"
|
||||||
|
|
||||||
|
echo "Purging BunnyCDN pull zone ${zone_id}"
|
||||||
|
|
||||||
|
status=$(
|
||||||
|
curl -sS -o /tmp/purge_zone_response.txt -w '%{http_code}' -X POST \
|
||||||
|
-H "AccessKey: ${api_key}" \
|
||||||
|
-H "Content-Length: 0" \
|
||||||
|
"https://api.bunny.net/pullzone/${zone_id}/purgeCache"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${status}" != "200" && "${status}" != "204" ]]; then
|
||||||
|
echo "Purge of pull zone ${zone_id} failed with HTTP ${status}:" >&2
|
||||||
|
cat /tmp/purge_zone_response.txt >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Purged pull zone ${zone_id} (HTTP ${status})"
|
||||||
Loading…
Reference in a new issue