docs(spec): design for dynamic per-package patching during builds

Adds a brainstormed design for patching packages (env/configure overrides
and source diffs) before they are installed by pak, including transitive
dependencies like RcppParallel.
The mechanism lives in bincraft (pre-built patched binaries served from a
prepended local repo); the curated patch registry lives in this repo.
This commit is contained in:
Patrick Schratz 2026-06-30 07:58:43 +02:00
commit 6281ff6e5c
Signed by: pat-s
GPG key ID: 3C6318841EF78925

View file

@ -0,0 +1,157 @@
# Design: Dynamic per-package patching during binary builds
Date: 2026-06-30
Status: Approved (pending spec review)
## Problem
Some CRAN packages fail to compile on specific build platforms due to compiler- or OS-specific issues that have nothing to do with the package being built.
The canonical example is `RcppParallel`: its bundled Intel TBB sources fail to build on musl (Alpine) and on newer OS/compiler combinations.
Observed failure on `ubuntu-2604` ("resolute") with `g++ 15.2.0`:
```
../build/common.inc:74: *** "" is not supported. Add build/.inc file with os-specific settings . Stop.
make: *** [Makevars:163: tbb] Error 2
ERROR: compilation failed for package 'RcppParallel'
```
Because `RcppParallel` is a dependency of many packages, a single such failure cascades: every dependent package (e.g. `rts2`) also fails, even though nothing is wrong with the dependent itself.
Today there is no way to intervene.
A package can only be **excluded** (`local/excluded-packages.json`), which is all-or-nothing and does not help dependents.
## Goal
Allow a curated set of packages to be "patched" — via lightweight build-time overrides or, when necessary, real source diffs — **before** they are installed, whether the package is a direct build target or a transitive dependency pulled in by `pak`.
## Key constraint that drives the design
When `RcppParallel` fails here, it is being installed as a **transitive dependency** by `pak`, inside `bincraft::build_binary_package()`.
`pak` downloads, configures, and compiles it in one subprocess; this repo never touches that source.
For a fix to reach a dependency-of-a-dependency, the fixed package must be visible to `pak` itself, where `pak`'s repositories/sources are configured — which is inside `bincraft`.
Decisions taken during brainstorming:
- **Mechanism lives in `bincraft`** (the engine), because only there can transitive deps be influenced.
- **Patch tiers: both, env-overrides first.** Support cheap per-package env vars / configure args / Makevars (version-independent) *and* true source diffs (version-pinned), preferring the lightweight override.
- **Registry data lives in this repo** (`build-cran-binaries`) and is passed into `bincraft`, keeping `bincraft` as pure mechanism and the frequently-changing policy data with operational config.
## Approaches considered
| Approach | How pak sees the fix | Verdict |
|---|---|---|
| A. Patched **source** repo — drop patched `.tar.gz` source into a local repo, prepend it | pak recompiles from your source | Simple, but env-tier overrides leak globally (one subprocess builds everything) and the dep recompiles on every dependent build |
| **B. Pre-built patched binary repo (chosen)** | pak installs a ready binary by repo priority | Per-package scoping is free; no recompile; the binary is a cacheable/uploadable artifact that fits the existing system |
| C. pkgdepends per-build hook | intercept each build | No clean per-package pre-compile hook exists; fragile |
Chosen: **B**.
## Architecture
### Registry (this repo)
```
local/patches/
registry.json # the manifest
RcppParallel/
fix.patch # optional source diff, referenced by an entry
```
`registry.json` is an array of entries:
```json
[
{
"package": "RcppParallel",
"versions": "*",
"platforms": ["alpine", "ubuntu-2604"],
"env": { "RCPP_PARALLEL_USE_TBB": "0" },
"configure_args": [],
"makevars": {},
"patch": null,
"reason": "bundled TBB fails to build on musl / newer compilers"
}
]
```
Field semantics:
- `package` (string, required): CRAN package name.
- `versions` (string, required): `"*"` for any, a constraint such as `">=5.1.0"`, or an exact version `"5.1.11-2"`.
Env-tier fixes are typically `"*"`; source diffs are normally exact or lower-bounded because a diff is pinned to the source it was generated against.
- `platforms` (array of strings, required): matched against the running build's platform tokens — distro family (`alpine`, `ubuntu`, `redhat`), codename (`ubuntu-2604`, `alpine-324`), and arch (`amd64`, `arm64`).
An entry matches if any listed token matches any build token.
`["*"]` matches all platforms.
- `env` (object, optional): environment variables exported only for this package's isolated build.
- `configure_args` (array, optional): passed as `--configure-args` to the isolated build.
- `makevars` (object, optional): key/value pairs written into a package-local Makevars for the isolated build.
- `patch` (string or null, optional): path (relative to `local/patches/`) to a unified diff applied to the unpacked CRAN source before building.
- `reason` (string, required): human explanation, surfaced in logs and metadata.
A fix is any combination of `env`, `configure_args`, `makevars`, and `patch`.
"Env-first" is an authoring guideline (prefer the lightweight override) and an ordering of effort, not a runtime branch — all present fields are applied together for the isolated build.
### Flow (inside bincraft, around existing pak resolution)
1. **Resolve** the dependency set (dry-run) to learn the concrete versions `pak` will install.
Reuse bincraft's existing resolution where possible (e.g. a `pkgdepends` proposal: `$resolve()` → inspect resolution → ... → `$solve()` / `$install()` after the local repo is prepended).
2. For each resolved package that matches a registry entry (name + `versions` + `platforms`): obtain a **patched binary** for the exact `version × platform × arch × R-minor`:
- **Cache hit** (local `/mnt/cache/patched-binaries/` or S3): fetch it into the local repo.
- **Cache miss**: download the CRAN **source** for that version, apply the source `patch` (if any) to the unpacked tree, build the binary in isolation with `env` / `configure_args` / `makevars` applied, then place the binary in the local repo and write it to the cache (and S3 if uploading is enabled).
3. **Prepend** the local binary repo (`file://…`) to `pak`'s repo list, and regenerate its `PACKAGES` index.
4. Run the **normal install**.
`pak` resolves the patched binary for the matched package — direct or transitive — because it wins on repo priority for an equal version, and installs it without recompiling.
### Caching (essential)
`RcppParallel` is a dependency of dozens of packages; without caching the fix would be rebuilt on every dependent build.
Patched binaries are keyed by:
```
<package>_<version>_<platform>_<arch>_<rminor>_<patchhash>
```
`patchhash` is a hash of the normalized registry entry plus the referenced diff file contents.
Editing a patch therefore changes the hash and auto-invalidates stale cached binaries.
- Local cache: `/mnt/cache/patched-binaries/`.
- Optional S3 cache for cross-build reuse: a dedicated `…/patched/` slot under the existing arch/codename structure, mirroring how normal binaries are stored.
### S3 upload
Patched binaries **are** uploaded to S3 (in addition to the local cache) so they are reused across CI jobs and machines, not just within one container.
They live in a separate `patched/` slot and are not published into the user-facing `src/contrib` index — they are an internal build accelerator, not a distributed artifact.
## Error handling
- **Source diff fails to apply** (CRAN moved past the pinned version): log a clear warning, skip that entry, and proceed.
The package builds unpatched (status quo) and may fail.
The skipped/failed-to-apply patch is surfaced in build metadata.
- **Pre-build of the patched binary fails**: log a warning, skip, proceed.
- **No version or platform match**: skip silently (the entry simply does not apply to this build).
- **Overlapping entries for one package**: the most specific entry wins (a concrete `platforms`/`versions` beats `"*"`).
Genuine ambiguity (two equally specific, conflicting entries) is a validation error reported before the build.
## Observability
- One log line per applied patch, e.g.: `Applying patch to RcppParallel 5.1.11-2 [env: RCPP_PARALLEL_USE_TBB=0]: bundled TBB fails on musl / newer compilers`
- The set of applied patches (package, version, `patchhash`) is recorded in the Postgres build-metadata row for the build, so it is queryable later.
## Testing
- **Unit (registry):** parsing and matching — version constraints, platform token matching, precedence/specificity, and detection of ambiguous overlaps.
- **Unit (cache key):** `patchhash` changes when the entry or diff changes; is stable otherwise.
- **Integration:** `RcppParallel` on `resolute` (and/or Alpine) fails to build without a registry entry and succeeds with one; a dependent package such as `rts2` succeeds once the dependency is patched.
- **Failure path:** an entry pinned to an old version against a newer CRAN release → graceful skip with a warning, build continues.
## Out of scope
- Shipping a default registry inside `bincraft` (registry is repo-local for now; a baseline-in-engine + repo-override model can come later if needed).
- Publishing patched binaries into the public `src/contrib` index.
- Automatic detection of which packages need patches — entries are curated by hand.
## Split of work
- **bincraft:** the mechanism — registry ingestion, resolution hook, isolated patched-binary build, caching/upload, local-repo prepend, logging, metadata recording.
A new `patches` argument on `build_binary_package()`.
- **build-cran-binaries (this repo):** the `local/patches/` registry and diffs, passing `patches = "local/patches"` through `build-one.R` / `build-all.R`, and documentation.