external data your package depends on, pinned to exact bytes
One engine that retrieves, verifies, caches and garbage-collects the large data files your package declares it needs.
Some of what a package needs is too large to ship inside it: a reference dataset, a trained model, an archive of examples. At multiple GB it lives somewhere else, and every user has to end up with the same copy of it. Declare it in a few kilobytes, and retrieving it is one call:
library(getaca)
backbone <- resource(
"backbone", "2026-06",
urls = "https://primary.invalid/backbone-2026-06.zip",
sha256 = "97f28a53a7912a80c12cc8f26e0c422d9212e067e11479ae61ef0a91b456b53a"
)
reg <- registry(package = "yourpkg", resources = list(backbone))
path <- getaca("backbone", registry = reg)Write that registry to inst/getaca/registry.rds and
every later call reaches it from anywhere, with the package name
alone:
path <- getaca("backbone", package = "yourpkg")getaca resolves the declaration through an explicit
policy, verifies what arrives against the checksum, records where it
came from, and returns an ordinary local path. The same installed
package resolves the same bytes on every machine and in every rerun.
That path is the zip. A record can also carry the step that unpacks it, so what comes back is the directory:
backbone <- resource(
"backbone", "2026-06",
urls = "https://primary.invalid/backbone-2026-06.zip",
sha256 = "97f28a53a7912a80c12cc8f26e0c422d9212e067e11479ae61ef0a91b456b53a",
processor = unpack()
)unpack() reads the format from the file name and covers
.zip, the tarballs under any compression, and a single
.gz, .bz2 or .xz. The archive is
verified first and unpacked once. The result gets its own cache slot, so
a later session finds it already unpacked, and
processed = FALSE hands back the zip it was built from.
Take one subtree of a large archive with
unpack(members = "tables"), and name the format where the
file name does not carry one with unpack("gzip").
Downloading a file into a cache directory and checking its hash is a few dozen lines, and for one file that never moves that is the right amount of code. Two things come after it, and they are what the declaration carries.
The data are republished on their own schedule. A
checksum written into your sources holds until your next release, so a
dead mirror or a fresh upstream cut waits for CRAN. A declaration can
name a remote registry you keep: mirrors get repaired,
2026-09 gets published, and installed copies follow. A
version that has been published still names the bytes it always named,
since a registry redefining one is refused, and the registry can be
signed so a user’s session can tell your declaration from anything else
that host might one day serve.
R CMD check has no network, and CRAN reads
tools::R_user_dir() as a cache you are expected to
manage. Resolution collapses to offline under
check whatever the policy says, three helpers cover tests, examples and
vignettes, and the retention sweeps run after every retrieval. That is
the part that turns a few dozen lines into a few hundred, in every
package that depends on external data.
So the package ships the declaration and getaca does the
rest. There is one engine and many declarations, the way there is one
renv and many lockfiles.
A retrieval produces the bytes the package was built against, or an error naming who can act on it. Eleven situations get eleven answers, each classed so callers can branch on the cause:
| Condition | Meaning | Who acts |
|---|---|---|
getaca_error_unavailable |
no mirror answered | user |
getaca_error_offline |
not cached, network not permitted here | user |
getaca_error_incomplete |
transfer ended short | user |
getaca_error_credentials |
every mirror refused to serve | user, or author |
getaca_error_upstream_changed |
publisher replaced a published version | upstream |
getaca_error_cache_corrupt |
local copy drifted from its own record | user |
getaca_error_redeclared |
the declaration names different bytes for a version already held | author |
getaca_error_invalid_registry |
the declaration is malformed or inconsistent | author |
getaca_error_declaration |
every mirror agrees, the registry disagrees | author |
getaca_error_composition |
the parts arrived intact and compose to something else | author |
getaca_error_signature |
a registry that must be signed carried no usable one | author |
When several independent mirrors return identical bytes and none of
them match the declared checksum, the registry is the likely error, and
getaca_error_declaration says so.
Each condition carries an actor field, so a declaring
package can catch the ones its users will meet and answer in its own
vocabulary:
install_backbone <- function(name = "backbone") {
path <- tryCatch(
getaca(name, package = "yourpkg"),
getaca_error_unavailable = function(e) {
stop("The backbone is not installed and no network is available.\n",
"Connect, then run: yourpkg::install_backbone()", call. = FALSE)
}
)
open_backbone(path)
}R CMD check with a 4 GB dependencyResolution collapses to offline under
R CMD check, whatever policy is set. Three helpers cover
the three contexts CRAN cares about:
# in tests
test_that("the backbone parses", {
getaca_skip_if_unavailable("backbone", package = "yourpkg")
expect_s3_class(read_backbone(getaca("backbone", package = "yourpkg")), "backbone")
})
# in examples and vignettes
path <- getaca_optional("backbone", package = "yourpkg")
if (!is.null(path)) summarise_backbone(path)
# anywhere a plain logical is easier
if (getaca_available("backbone", package = "yourpkg")) { }Point GETACA_CACHE at a pre-seeded directory and a CI
job finds everything already there. The cache is a plain directory tree,
so the usual actions cache it by key:
- uses: actions/cache@v4
with:
path: ~/.cache/getaca
key: getaca-${{ hashFiles('inst/getaca/registry.rds') }}
- run: Rscript -e 'getaca::getaca_prefetch(package = "yourpkg")'
env:
GETACA_CACHE: ~/.cache/getacaKeying the cache on the registry file means a new declaration downloads once and every later job reuses it.
A resource record is immutable:
yourpkg / backbone / 2026-06 names exact bytes forever. A
channel maps the logical name onto one record, and
channels move.
| Policy | Resolves through | Use when |
|---|---|---|
bundled |
the registry shipped with the package | default; same install, same bytes |
current |
author’s remote registry, falling back to bundled | mirrors need repair, or data releases outpace CRAN |
pinned |
a frozen local snapshot | an analysis must keep resolving what it was written against |
offline |
cached and bundled information only | no network permitted |
A remote channel may repair a dead mirror and may publish
2026-09. 2026-06 keeps its meaning, and a
registry that redefines it is rejected as invalid.
The registry states which record the channel points at. Version
strings here are labels, and source-2026-06_build-3 has no
defensible ordering, so declaration order cannot stand in for one:
registry(
package = "yourpkg",
current = c(backbone = "2026-09"),
resources = list(
resource("backbone", "2026-06", urls = "...", sha256 = "..."),
resource("backbone", "2026-09", urls = "...", sha256 = "...")
)
)A name offering several versions and naming no head is refused as an
invalid registry. The one mistake this design is exposed to, appending
2026-03 below 2026-09 and moving every user
backwards, becomes an error at registry() on the author’s
machine.
A remote registry can be signed, so a user’s session can tell your declaration from whatever else the host might one day serve:
public <- registry_keygen("~/.keys/yourpkg.key") # once, kept out of the repo
registry(package = "yourpkg", remote = "https://host.example/yourpkg.rds",
keys = public, resources = list(...))
registry_write(reg, "publish/yourpkg.rds")
registry_sign("publish/yourpkg.rds", key = "~/.keys/yourpkg.key")The key travels in the registry your package ships and the declaration comes from your host, so the two reach a user by different routes. That is what a signature rests on, and it is why the public key belongs in the installed package rather than beside the file it vouches for.
The signature covers the declaration, when it was published, and when it stops being accepted, so an old registry cannot be replayed in place of the current one. A host that cannot be reached still falls back to the bundled declaration; a registry that arrives and fails its signature stops instead. Declaring no keys leaves everything as it was.
Everything an author writes, in one file:
# data-raw/registry.R, run at build time
registry_write(
registry(
package = "yourpkg",
policy = "current",
remote = "https://yourpkg.invalid/getaca-registry.rds",
current = c(backbone = "2026-06"),
resources = list(
resource("backbone", "2026-06",
urls = c("https://zenodo.invalid/records/1234567/files/backbone-2026-06.zip",
"https://releases.invalid/backbone/2026.06/backbone.zip"),
sha256 = "9f2c...",
size = 4.1e9,
license = "CC-BY-4.0",
doi = "10.5281/zenodo.1234567",
upstream = list(source_release = "2026-06", build = "3"))
)
),
"inst/getaca/registry.rds"
)Installing the package copies a few kilobytes. The first real call retrieves, verifies and caches:
path <- getaca("backbone", package = "yourpkg")Zenodo and GitHub releases both host files this size for free, and listing one of each is what makes an outage at either survivable. What the rest of the declaration buys:
sha256 turns a truncated or
substituted file into an error at retrieval, where it is
diagnosabledoi records the identifier for the
artefact, so an analysis can cite the exact bytes it readupstream keeps both identities, the
publisher’s release and the build that turned it into the file you ship,
so provenance answers which one movedpolicy = "current" lets a dead mirror
be repaired, or 2026-09 published, without a CRAN
releasecurrent states which of the published
versions a bare getaca("backbone") returnsWhen the publisher issues 2026-09, the remote registry
adds the record and moves the head. When the publisher replaces
2026-06 in place, the checksum stops matching, the cached
copy is left alone, and the error names the publisher as the party who
changed something.
Everything in that record can be typed except the checksum, which has
to come from the bytes. registry_draft() takes the
locations, retrieves each file once, hashes it locally and returns a
registry:
reg <- registry_draft(
c(backbone = "https://zenodo.invalid/records/1234567/files/backbone-2026-06.zip"),
package = "yourpkg",
version = "2026-06"
)A location is a plain URL, or an identifier for an archive holding several files. Zenodo, figshare and Dataverse are read off the string, and each supplies the licence, the version and a DOI for the artefact:
registry_draft("10.5281/zenodo.4924875", package = "yourpkg")Drafting a large record costs no disk: the file is hashed as it
arrives and never written down. keep = TRUE writes it to
the cache instead, where the first getaca() call finds it.
Where you have the file already, local = hashes the copy on
your machine and transfers nothing, and sha256 = declares a
checksum you already hold. Given both, the local copy is hashed and held
to the checksum, which is the check to run after a deposit: hashing the
download says what users receive, hashing your build says what you
uploaded, and a host that recompresses on upload makes those
different.
An archive is consulted when a registry is written and never when a
user fetches. What ships is ordinary https://
locations.
Some archives serve their files only to a registered account. A declaration says which credential a host requires, and never carries one:
registry(
package = "canopy",
auth = list(
auth_host("data.ornldaac.earthdata.nasa.gov",
bearer("EARTHDATA_TOKEN"),
register = "https://urs.earthdata.nasa.gov/users/new")
),
resources = list(...)
)bearer() and basic() name environment
variables. The value is read at the moment of the request and never
stored, so nothing secret enters a registry, a manifest, a digest, a
provenance record or an error message. It travels as an
Authorization header, which libcurl withholds from a
redirect to another host, and to the declared host only: hosts match
exactly, so a record listing an authenticated mirror beside a public one
presents the credential to the first and falls through to the
second.
A refusal is its own failure. getaca_error_credentials
names the variable wanted, says whether it is set, and points at where
to register, so nobody is told to connect to a network they already
have. getaca_credentials() answers the same question before
a fetch:
getaca_credentials(package = "canopy")
#> package host scheme variable set
#> 1 canopy data.ornldaac.earthdata.nasa.gov bearer EARTHDATA_TOKEN FALSE
#> register
#> 1 https://urs.earthdata.nasa.gov/users/newgetaca() returns a path to a complete file, verified
against the declared checksum, at the resolved version, in a slot
getaca owns.
Bytes land in .tmp/, are sized, hashed, and only then
admitted to the cache, so an interrupted transfer can never appear as a
valid cached resource and a failed transfer never touches a copy that
was already good. The temporary file is named after the declared
checksum, so an interrupted download resumes on the next attempt. Each
mirror gets its own temporary file, because a partial transfer is
resumable only against the mirror that produced it.
Bytes then live once, under their own checksum, and a version slot holds a name for them. Two packages declaring the same file keep one copy and two separate dependency records, and the second package to ask for it waits for the first transfer rather than starting its own. Everything the cache owns is read-only, since shared bytes make one caller’s stray write everybody’s problem.
Verification asks three questions and keeps the answers apart:
| when | recorded as | |
|---|---|---|
| full re-hash | on download, on verify = TRUE, and every
getaca.verify_days |
verified_at |
| size check | on ordinary access | checked_at |
| use | on ordinary access | accessed_at |
“Verified” therefore means the bytes were re-hashed then, rather than that somebody looked at the file at some point. A mismatch found in shared bytes reaches every package holding them: the stamp is withdrawn from each slot naming those bytes, and each re-hashes its own copy on next access.
Two sessions asking for the same 4 GB file wait on a portable directory mutex keyed on the checksum, and the second observes the first’s success. A lock whose holder died goes stale and is taken over.
getaca_info("backbone", package = "yourpkg")
#> <getaca cache entry> yourpkg/backbone@2026-06
#> path ~/.cache/R/getaca/yourpkg/backbone/2026-06/raw/backbone-2026-06.zip
#> store hardlink to blobs/sha256/9f/9f2c8d1e5a3b
#> sha256 9f2c8d1e...
#> size 4,100,000,000 bytes
#> license CC-BY-4.0
#> doi 10.5281/zenodo.1234567
#> built from source_release: 2026-06
#> built from build: 3
#> resolved by current registry sha256:8b31e0da54cf (published 2026-07-22)
#> source url https://zenodo.invalid/records/1234567/files/backbone-2026-06.zip
#> getaca 0.1.5
#> fetched 2026-07-26 11:02:13
#> verified 2026-07-26 11:09:44 (full re-hash)
#> checked 2026-07-26 15:31:02 (size and mtime)That is a reproducibility appendix, and a bug report that says which mirror served the bytes and which registry state chose them. The registry digest is derived from the declaration itself, so it identifies that state exactly.
getaca_catalogue() widens it to a data frame covering
both halves, every resource the installed packages declare and every
copy the cache holds:
getaca_catalogue()[, c("package", "name", "version", "current", "declared", "cached")]
#> package name version current declared cached
#> 1 yourpkg backbone 2026-06 TRUE TRUE TRUE
#> 2 yourpkg backbone 2026-03 FALSE FALSE TRUE
#> 3 yourpkg grid 2026-06 TRUE TRUE FALSERow 2 is a copy of a version nothing asks for any more, which is what the retention sweeps reclaim first. Row 3 is work still to do on this machine.
unpack() is one case of a general one. A processor turns
any verified path into another, so anything you would otherwise make
every user do on first load can happen once, in the cache: converting a
format on arrival, or building the layout your package reads.
processor("index-v1", function(input, output_dir) {
out <- file.path(output_dir, "backbone.fst")
write_index(read_backbone(input), out)
out
})The function receives the verified path and a staging directory, and the directory is renamed into place once it returns, so one that fails part-way leaves nothing behind. Both forms stay reachable afterwards:
built <- getaca("backbone", package = "yourpkg") # processed
zip <- getaca("backbone", package = "yourpkg", processed = FALSE) # as it arrivedChanging what the transformation does means changing the id, which
invalidates previously processed copies without touching the download
they were built from: users re-run the transformation rather than
re-fetching gigabytes. getaca knows nothing about file
formats and never reads data.
A host that caps file size, or a publisher issuing deltas against a
base release, gives you a resource that arrives as a series. Declare the
pieces, and sha256 describes the artefact they compose:
resource("backbone", "2026-09",
sha256 = "b104...",
file = "backbone.parquet",
parts = list(
part("https://primary.invalid/backbone-base.bin", sha256 = "91cc..."),
part("https://primary.invalid/backbone-2026-09.bin", sha256 = "4e77...")
))Each part is verified and stored under its own digest, so the base is transferred once however many versions declare it, and publishing a version costs your users the delta. The composed result is hashed against the record’s own checksum before anyone sees it, then joins the store exactly as a downloaded file does.
Parts are concatenated unless the record declares a
combiner(), which is what a delta format needs. Either way
the artefact is the identity: re-splitting a file or moving a piece to a
new host changes the route, and what a version means is fixed by the
checksum at the end of it.
getaca drives its own transfer loop, so what a download
looks like is a setting:
getaca_progress("bar") # redraws one line, the default when interactive
getaca_progress("line") # one line to start and one to finish, for a log
getaca_progress("none")The share is measured against the size the registry declares, so it is right before the first byte arrives and stays right for a mirror that sends no content length. A series reports each piece under its own label, and a resumed transfer counts from what was already on disk.
A package that wants downloads to look like its own writes a
reporter(), which is a function of one argument:
getaca_progress(reporter("shiny", function(event) {
if (identical(event$type, "bytes")) {
shiny::setProgress(event$bytes / event$total, format(event$id))
}
}))The events carry the resource, the mirror, the declared size and the
resume offset. quiet = TRUE on a single call reports
nothing whatever the session is set to.
CRAN permits tools::R_user_dir() on condition that
contents are “actively managed (including removing outdated material)”.
getaca reads that as a retention policy, and collects after
every successful retrieval.
Removal runs cheapest and safest first: broken material, abandoned transfers, superseded versions past their retention window, least-recently-used entries once over the size ceiling, and finally bytes that no declaration references any more. Superseded and not-recently-used age on separate clocks, so an expensive resource is never dropped merely for being old. Pinned entries, the version the bundled registry names, and anything under an active lock are never touched.
getaca_clean(dry_run = TRUE) # what would go, and why
getaca_keep("backbone", package = "yourpkg") # exempt this one permanently
options(getaca.max_bytes = 50 * 1024^3) # raise the ceiling| companion data package | getaca |
|
|---|---|---|
| Size | fits a repository | too large to bundle |
| Release cadence | coupled to code releases | independent of them |
| Shape | naturally R objects | any file, any format |
| Granularity | all of it, always | users take what they need |
| License | redistribution permitted | download permitted, redistribution discouraged |
A companion package can itself use getaca, though that
is rarely the first recommendation: it moves the complexity one step
along.
Several established packages solve neighbouring problems, and one of them may be the better fit depending on which problem you have.
pins publishes “data sets, models, and other R objects, making it easy to share them across projects and with your colleagues”, across boards including local folders, Posit Connect and AWS S3. It is built around the person sharing an artefact and the board it lives on.
BiocFileCache “creates a persistent on-disk cache of files that the user can add, update, and retrieve”, for resources that are costly to create or fetched from the web, backed by an SQLite metadata database.
pooch
is where Python puts this problem, “a friend to fetch your data files”:
a registry of file names and hashes shipped as package data, a cache
folder, one URL per file, and a version documented as “the
version string for your project”, which names the subfolder the cache
uses.
getaca is built around a package declaring what it
needs. Identity is package / name / version, where the
version labels the data rather than the code, so a channel can move a
name onto new bytes between releases. The declaration ships inside the
installed package, and resolution, verification, offline behaviour and
retention are the same for every declaring package because there is one
engine.
Imports: curl, plus stats,
tools and utils from base R. Recursive
footprint outside base R: zero packages. YAML and JSON
registries, testthat helpers and vignettes live in Suggests
and are gated at call time.
Hashing is SHA-256 in C, in src/sha256.c, with no
LinkingTo and nothing to configure. Where the CPU has
SHA-256 instructions the block compression uses them, which puts
verification at disk speed: 1.43 GB/s on an i9-14900K, so a 4 GB
resource is verified in under three seconds.
getaca() retrieve a declared resource,
return a local pathresource() declare one immutable
recordregistry() collect a package’s
declarations and name the channel headregistry_draft() build one from the
locations, every checksum taken from the bytesregistry_write() ship them at
inst/getaca/registry.rdsregistry_digest(),
registry_manifest() the identity of a
declaration state, and the text it is taken overas_registry() build one from a YAML or
JSON authoring filepart(),
combiner() declare a record that arrives
as a series, and how the series composesprocessor() declare a
post-verification transformationunpack() the stock one: extract an
archive or a compressed fileauth_host(),
bearer(),
basic() name the credential a host
requires, without holding onegetaca_credentials() which variables a
declaration reads, and whether they are setgetaca_progress(),
reporter() choose what a transfer looks
like, or write your owngetaca_info() full provenance for a
cached resourcegetaca_catalogue() what is declared,
what is current, what is cachedgetaca_refresh() forget cached
registry state within a sessiongetaca_prefetch() warm a cache before
going offlinegetaca_pin() freeze current resolution
into a pin filegetaca_keep() exempt a resource from
collectiongetaca_clean() run the retention
sweeps by handgetaca_available(),
getaca_optional(),
getaca_skip_if_unavailable() check-safe
accessgetaca_policy(),
getaca_cache_dir() settingsinstall.packages("pak")
pak::pak("gcol33/getaca")“Software is like sex: it’s better when it’s free.” — Linus Torvalds
I’m a PhD student who builds R packages in my free time because I believe good tools should be free and open. I started these projects for my own work and figured others might find them useful too.
If this package saved you some time, buying me a coffee is a nice way to say thanks. It helps with my coffee addiction.
MIT (see the LICENSE.md file)
@software{getaca,
author = {Colling, Gilles},
title = {getaca: Reproducible External Data Dependencies},
year = {2026},
url = {https://github.com/gcol33/getaca}
}