--- title: "The Cache" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{The Cache} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(getaca) # The session temp directory carries the account name of whoever knits, so # printing a cache path would put it in the built article. Redact it. local({ roots <- unique(c(tempdir(), normalizePath(tempdir(), winslash = "\\", mustWork = FALSE), normalizePath(tempdir(), winslash = "/", mustWork = FALSE))) roots <- unique(c(gsub("\\", "\\\\", roots, fixed = TRUE), roots)) roots <- roots[order(nchar(roots), decreasing = TRUE)] render <- knitr::knit_hooks$get("output") knitr::knit_hooks$set(output = function(x, options) { for (root in roots) x <- gsub(root, "", x, fixed = TRUE) render(x, options) }) }) .old_options <- options(getaca.cache = file.path(tempdir(), "getaca-cache-vignette")) .old_envvars <- Sys.getenv(c("GETACA_OFFLINE", "NOT_CRAN"), unset = NA) Sys.setenv(GETACA_OFFLINE = "true", NOT_CRAN = "true") reg <- registry("yourpkg", list( resource("backbone", "2026-06", urls = "https://host.invalid/backbone-2026-06.zip", sha256 = strrep("9f", 32), size = 797e6, license = "CC-BY-4.0") )) ``` The cache is where a declared resource becomes a local path. This article covers the layout, what makes a cached copy trustworthy, how two sessions avoid downloading the same file twice, and the retention policy CRAN requires of a package that writes to a user directory. None of it is private. The cache is an ordinary directory tree, and that is a deliberate property: copying it to an offline machine, restoring it from a CI cache action, or looking at it with a file browser all work. ## Where it lives ```{r} getaca_cache_dir() ``` That is the sandbox this vignette runs in. The default is `tools::R_user_dir("getaca", "cache")`, the location CRAN permits for cached downloads. Two overrides take precedence, in this order: ```{r, eval = FALSE} options(getaca.cache = "/mnt/fast/getaca") # this session Sys.setenv(GETACA_CACHE = "/mnt/fast/getaca") # this process and its children ``` The environment variable is the one to reach for in CI and in job scripts, because it survives into the R processes a build step spawns. Asking for the directory does not create it; the first successful retrieval does. ## Layout ``` / blobs/sha256// verified bytes, named by their own checksum .locks/ one per checksum, held during a transfer .tmp/ in-flight downloads, never visible as cache / index.rds provenance for this package only // raw/ this slot's name for a blob proc-/ processed result, own provenance ``` Everything a package declares is scoped by declaring package, then resource name, then version. That falls out of identity being the triple `package / name / version`, and it buys two things. Two packages declaring a resource called `"backbone"` never share a slot, so one package's registry update cannot affect another's cached data. And a version can never be overwritten by another version, so holding two releases side by side is the normal state rather than a special case. The bytes underneath are shared. A file lives once, at `blobs/sha256/`, under its own checksum, and the version slot holds a name for it: a hardlink where the filesystem allows one, a symlink or a copy where it does not. Two packages declaring the same 4 GB file therefore keep one copy and two independent dependency records. They also transfer it once, because the lock is keyed on the checksum, so the second session waits for the first and then finds the bytes already there. A resource composed from `part()` records stores its pieces in the same place. Each part is admitted under its own digest, so a base every version of a series declares is one blob however many versions declare it, and publishing a version costs a consumer the delta rather than the whole artefact. No version slot names a part. What reaches it is the entry composed from it, which records the part digests beside its provenance, and that is what keeps a base alive for exactly as long as the last cached version still holding it. Everything the cache owns is read-only. A caller writing to a returned path would otherwise damage every package that shares those bytes, so the write fails at the point of the mistake instead. A caller that needs a writable layout declares a `processor()`, which gets its own slot. The store keeps no metadata of its own. Whether a blob is still needed is answered by reading the package indexes, so there is no reference count that a crash, a restored backup or a hand-deleted directory could leave disagreeing with them. The processed result of a processor sits beside the raw artefact rather than replacing it, under a directory named for the processor id. Changing the transformation means changing the id, which invalidates the derived tree without touching the download it came from. `index.rds` is one small file per package holding the provenance records. It is written to a sibling temporary file and renamed, so a reader never observes a half-written index. A metadata database was considered and left out: the volume is tiny, and an atomic per-package file removes a dependency and a class of locking problems. ## What a cached copy has to prove Three different questions, three answers, and the entry record keeps them apart so that "verified" never quietly means "we looked at this sometime". | | what it does | when it runs | recorded as | |---|---|---|---| | full verification | re-hashes the bytes | on download, on `verify = TRUE`, and once the last one is older than `getaca.verify_days` | `verified_at` | | cheap check | compares size against the entry | on every ordinary access | `checked_at` | | access | none | on every ordinary access | `accessed_at` | The cheap check catches truncation, replacement by a different-sized file, and most accidental edits, for the cost of a `file.info()` call on a four-gigabyte file. A same-size substitution passes it, which is the case the scheduled re-hash exists to catch. ```{r, eval = FALSE} getaca("backbone", package = "yourpkg") # cheap check getaca("backbone", package = "yourpkg", verify = TRUE) # full re-hash first options(getaca.verify_days = 30) # re-hash more often ``` A cached copy that fails either check raises `getaca_error_cache_corrupt` rather than being silently refetched, and the message names the clean-up call. Silent repair would hide a disk going bad, and hide a colleague who edited a file in the cache directory. The failure is a verdict on bytes rather than on the slot that found it. Bytes live once and every package declaring them holds its own record, so a mismatch in the shared copy withdraws `verified_at` from every other slot naming it and each re-hashes on next access. A slot holding its own copy, which is what a filesystem refusing links leaves, and a processed tree derived from the bytes are answerable only for themselves. A part is re-hashed every time a composition reuses it, since nothing else ever looks at it. Scheduled re-verification is driven from the entries, and a part blob is named by an entry's part list rather than being an entry of its own, so a base that rotted on disk would otherwise surface as the declaration failing to produce its own artefact. A part failing its own checksum is dropped and fetched again the way a stale partial transfer is: the declaration lists mirrors for those bytes, and nothing else names them. The fourth timestamp is `fetched_at`, which never moves. Together the four answer questions that collapsing them would destroy: a resource fetched in January, re-hashed in April and read this morning reports exactly that. ## Provenance ```{r, eval = FALSE} getaca_info("backbone", package = "yourpkg") #> yourpkg/backbone@2026-06 #> path ~/.cache/R/getaca/yourpkg/backbone/2026-06/raw/backbone-2026-06.zip #> sha256 9f9f9f... #> size 797,000,000 bytes #> license CC-BY-4.0 #> built from source_release: 2026-06 #> resolved by current registry sha256:8b31e0da54cf (published 2026-07-22) #> source url https://host.invalid/backbone-2026-06.zip #> getaca 0.0.0.9000 #> 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) ``` A composed resource reports what produced it, under the source url its series leaves empty: ```{r, eval = FALSE} #> source url NA #> composed 3 parts via 'concat' #> part 919191919191 #> part 4e4e4e4e4e4e #> part 777777777777 ``` An uncached resource gives `NULL`, which is what makes the call safe in a report covering a machine that holds some of the set: ```{r} is.null(getaca_info("backbone", registry = reg)) ``` `getaca_catalogue()` is the same information across everything, plus the declarations that have never been downloaded: ```{r} getaca_catalogue(registry = reg)[, c("package", "name", "version", "current", "declared", "cached")] ``` With no arguments it covers every installed package that ships a registry together with every package holding cached resources, which is the report worth pasting into an issue: ```{r} str(getaca_catalogue(), max.level = 1) ``` The columns worth knowing: `size` in bytes, `license`, `source` and `registry_digest` naming the policy and the registry state that resolved it, `parts` for how many pieces the artefact was composed from and `0` where it was served whole, the three timestamps, `pinned`, and `path`. ## Two sessions, one download Two R sessions asking for the same four-gigabyte file must not both fetch it, and must never mistake each other's in-flight temporary file for a finished resource. The lock is a directory under `.locks/`, named for the declared checksum. `dir.create()` is atomic on both POSIX and Windows, which makes a directory a portable mutex with no compiled dependency and no lockfile library. Keying it on the checksum rather than on the resource triple means the two sessions need not be asking on behalf of the same package. Two packages declaring the same file are waiting for the same transfer, and the one that waits finds the bytes in the store when it wakes. What a second session does: 1. tries to create the lock directory, and fails 2. checks whether the lock is stale, by the age of the holder file inside it 3. waits, polling, until the holder releases 4. re-reads the cache index, finds the first session's entry, and returns that path Step 4 is the point. The waiter reads the entry the first session wrote and returns that path, so the second transfer never starts. The cache check is repeated after the lock is acquired precisely because the situation may have changed while waiting. Composing from parts takes one lock per distinct part digest, held for the whole composition rather than for each transfer. Two sessions must not both fetch one part, which is what the acquisition lock already does for a whole file. And a part blob is named by no index until the entry composed from it is written, so the lock is also what tells another session that bytes nothing references yet are wanted: the `unreferenced` sweep treats a blob under an active lock as live. A lock whose holder died leaves a directory behind. It goes stale after `getaca.lock_stale_seconds`, defaulting to 1800, after which the next session removes it and takes over. A session that waits longer than its timeout gets an error naming the lock path and the `unlink()` call that clears it, so a genuinely wedged lock is a one-line fix rather than a support thread. ```{r, eval = FALSE} options(getaca.lock_stale_seconds = 600) ``` Set it lower for short downloads on a shared machine, higher when a single transfer legitimately runs for an hour. ## How bytes get in Transfers land in `.tmp/`, are sized, hashed, and only then moved into place. 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 and the mirror that produced it. Naming it after the checksum makes an interrupted download resumable on the next attempt, which matters when the resource is measured in gigabytes. Giving each mirror its own file matters for a subtler reason: a partial transfer is resumable only against the host that produced it, so sharing one file across mirrors would let a failed attempt at the first be resumed onto by the second, and the resulting corruption is indistinguishable from the publisher having changed the bytes. A resumed transfer that completes but does not verify indicts the partial file rather than the publisher, so the same mirror is asked once more from empty before any conclusion is drawn about upstream. Without that, one stale temporary file makes a resource permanently unfetchable and blames the wrong party for it. Verified bytes are then admitted to the store under their own checksum, and the version slot is given a name for them. Admission is a rename, since `.tmp/` and `blobs/` share the cache root and therefore share a filesystem. Bytes already in the store are already named by their checksum, so admitting the same file a second time is a no-op and the temporary copy is dropped. A composed resource joins at that same point. Each part lands in `.tmp/` and is admitted under its own digest, the series is combined into a second temporary file named after the artefact's checksum, and that file is hashed against the record before anything else sees it. Only a result matching the declaration is admitted and given its view, so a `combiner()` needs no more trust than a mirror does: it cannot produce bytes the declaration did not already name. A part already in the store is used where it lies, which is the whole point of declaring one, and an interrupted composition is overwritten by the retry rather than accumulating. ## Watching a transfer getaca drives its own transfer loop, so what a download looks like is a setting rather than whatever the transfer library prints. ```{r, eval = FALSE} 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") # nothing ``` ``` yourpkg/backbone@2026-09 [============> ] 63% 512 MB / 812 MB 41 MB/s ETA 00:07 ``` The share is measured against the size the registry declares, which is known before the first byte arrives and stays right when a mirror sends no content length. A resource composed from parts reports each piece under its own label, so a series reads as one download in stages: ``` yourpkg/backbone@2026-09 (part 1 of 3) [===================] 100% 797 MB in 00:19 yourpkg/backbone@2026-09 (part 2 of 3) [========> ] 44% 4.0 MB / 9.1 MB ... ``` `quiet = TRUE` on a single call reports nothing whatever the session is set to, so one silent retrieval never needs the setting changed and put back: ```{r, eval = FALSE} getaca("backbone", package = "yourpkg", quiet = TRUE) ``` A package that wants a download to look like its own writes a `reporter()`, which is a function of one argument: ```{r} counter <- reporter("counter", function(event) { if (identical(event$type, "end") && identical(event$status, "ok")) { message(format(event$id), ": ", event$bytes, " bytes") } }) counter ``` The events are `begin`, `bytes` and `end`, and a handler switches on `event$type` and ignores what it does not use. `begin` carries the resource, the mirror, the declared `total` and the `offset` an interrupted transfer resumed from; `bytes` carries the cumulative count; `end` carries the outcome. See `?"getaca-progress"` for the fields. A reporter never decides whether a retrieval succeeds. One that raises is caught, reported once as a warning, and switched off for the rest of the call. ## Retention CRAN permits `tools::R_user_dir()` on condition that contents are "actively managed (including removing outdated material)". `getaca` reads that as a retention policy rather than a function users might discover, so collection runs automatically after every successful retrieval. Five sweeps, cheapest and safest first: | Sweep | Removes | Clock | |---|---|---| | `broken` | entries whose path is missing or fails the cheap check | none | | `temp` | abandoned transfers in `.tmp/` | 7 days by mtime | | `superseded` | unpinned versions the registry no longer names | `getaca.supersede_days`, default 30 | | `lru` | least recently used unpinned entries | only above `getaca.max_bytes`, default 20 GB | | `unreferenced` | bytes in the store that no entry names any more | none | The first four sweeps remove names. `unreferenced` runs last and removes the bytes those names were for, once the last one is gone. A blob under an active lock is left alone: it belongs to a session that has admitted it and has not yet written its entry. What an entry names, for this purpose, is the blob it holds together with the parts that blob was composed from. Reachability runs over both, so a base stays for as long as some cached version is still composed from it and goes with the last one. The size ceiling measures what the cache occupies, so shared bytes count once. Two packages declaring the same 4 GB file count 4 GB against `getaca.max_bytes`, and evicting one of them frees nothing until the other goes too. Part blobs are occupancy on the same terms: evicting one version of a series reclaims the pieces no surviving version holds, and leaves a base that another version still declares. Superseded and not-recently-used age on separate clocks on purpose. An expensive resource that is still the current version is never dropped merely for being old; it is dropped only when the cache is over its ceiling, and then only after everything already useless has gone. Three things are never removed: pinned entries, the version the registry currently names, and anything under an active lock. The automatic pass after a retrieval runs only `broken` and `temp`, the two sweeps that can only ever remove material which is already useless. Reclaiming a superseded four-gigabyte version is a decision, so it happens on a schedule rather than as a side effect of a download. ## Cleaning by hand ```{r} getaca_clean(dry_run = TRUE) ``` An empty result on a fresh cache. On a working one, each row names the package, the resource, the reason and the bytes it would reclaim, which is the report to read before running it for real. ```{r, eval = FALSE} getaca_clean(dry_run = TRUE) #> package resource reason bytes #> 1 yourpkg yourpkg/backbone@2026-03 superseded version past... 7.97e+08 #> 2 abandoned transfer 1.20e+07 getaca_clean() # run every sweep getaca_clean(what = "temp") # just the abandoned transfers getaca_clean(package = "yourpkg") # one package getaca_clean("backbone", package = "yourpkg") # one resource name ``` Keeping something the sweeps would otherwise take: ```{r, eval = FALSE} getaca_keep("backbone", package = "yourpkg") # pin it getaca_keep("backbone", package = "yourpkg", pinned = FALSE) # release the pin ``` A pinned entry is exempt from the superseded and LRU sweeps permanently. It is still subject to the `broken` sweep, because an entry whose bytes are gone is not worth protecting. ## Settings Every setting is readable from an option or an environment variable, with the option taking precedence. | Option | Environment variable | Default | Controls | |---|---|---|---| | `getaca.cache` | `GETACA_CACHE` | `R_user_dir()` | where everything lives | | `getaca.policy` | `GETACA_POLICY` | registry default | which channel resolves | | `getaca.progress` | `GETACA_PROGRESS` | `auto` | what a transfer looks like | | `getaca.verify_days` | `GETACA_VERIFY_DAYS` | 90 | scheduled re-hash interval | | `getaca.supersede_days` | `GETACA_SUPERSEDE_DAYS` | 30 | retention for undeclared versions | | `getaca.max_bytes` | `GETACA_MAX_BYTES` | 20 GB | ceiling above which LRU runs | | `getaca.timeout` | `GETACA_TIMEOUT` | 3600 | transfer timeout in seconds | | `getaca.lock_stale_seconds` | `GETACA_LOCK_STALE_SECONDS` | 1800 | when a lock is abandoned | | `getaca.pin_file` | | `getaca.pins.rds` in the working directory | where pins are read from | The defaults suit a laptop holding a couple of large reference datasets. Two are worth revisiting on a shared machine: raise `getaca.max_bytes` when the cache lives on a volume sized for it, and lower `getaca.lock_stale_seconds` when transfers are short and a wedged lock costs more than a rare duplicate download. ```{r, eval = FALSE} options( getaca.cache = "/mnt/data/getaca", getaca.max_bytes = 200 * 1024^3, getaca.verify_days = 30 ) ``` ## Moving a cache Copy the directory. There are no absolute paths recorded inside it, so a cache built on one machine works on another: ```{r, eval = FALSE} # on a connected machine Sys.setenv(GETACA_CACHE = "/tmp/seed") getaca_prefetch(package = "yourpkg") # then, on the machine that has no network Sys.setenv(GETACA_CACHE = "/opt/getaca") getaca_catalogue() # everything already there ``` The same property is what makes CI caching work: the archive an actions cache restores is the cache, with nothing to rebuild. ## Where to go next - `vignette("checks")` for seeding a cache in CI - `vignette("failures")` for `getaca_error_cache_corrupt` and its neighbours - `vignette("policies")` for what decides which version lands in the cache - `vignette("declaring")` for declaring a resource that arrives as a series ```{r, include = FALSE} options(.old_options) Sys.unsetenv(names(.old_envvars)[is.na(.old_envvars)]) .restore <- .old_envvars[!is.na(.old_envvars)] if (length(.restore)) do.call(Sys.setenv, as.list(.restore)) ```