Skip to content

Commit 5b78a8a

Browse files
Merge pull request #77 from NewGraphEnvironment/76-wire-cd-cache-read-path
Wire cd_cache into the COG read path (kill recurring S3 egress)
2 parents 72990d0 + 027f442 commit 5b78a8a

19 files changed

Lines changed: 781 additions & 8 deletions

DESCRIPTION

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: cd
22
Title: Climate Departure Analysis from ERA5-Land Reanalysis
3-
Version: 0.3.2
4-
Date: 2026-06-06
3+
Version: 0.4.0
4+
Date: 2026-06-25
55
Authors@R: c(
66
person("Allan", "Irvine", , "al@newgraphenvironment.com", role = c("aut", "cre"),
77
comment = c(ORCID = "0000-0002-3495-2128")),
@@ -35,9 +35,11 @@ Suggests:
3535
rmarkdown,
3636
testthat (>= 3.0.0),
3737
tidyterra,
38+
withr,
3839
zyp
3940
Config/testthat/edition: 3
40-
Imports:
41+
Imports:
42+
curl,
4143
dplyr,
4244
jsonlite,
4345
rappdirs,

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export(cd_aggregate)
44
export(cd_anomaly)
55
export(cd_baseline)
66
export(cd_cache_clear)
7+
export(cd_cache_fetch)
78
export(cd_cache_info)
89
export(cd_cache_path)
910
export(cd_catalog)

NEWS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# cd 0.4.0 (2026-06-25)
2+
3+
* On-disk caching wired into the consumer read path, so repeated extractions, report renders, and vignette rebuilds pull each COG from S3 **once** and read locally thereafter — turning the dominant recurring S3 egress driver into a one-time cost. New exported `cd_cache_fetch()` downloads a remote http(s) COG to the cd cache (keyed by URL hash, with a sidecar `.meta` recording the S3 ETag and size), validates freshness with a cheap HTTP HEAD (ETag, falling back to Content-Length), and serves the local copy on a hit. Downloads are size-validated and atomically renamed so a truncated file is never served; a failed HEAD with a cached copy present serves the cache, and `options(cd.cache_revalidate = FALSE)` skips revalidation entirely for offline work. `cd_crop()` and `cd_extract()` gain `cache = TRUE` (default), threading remote reads through the cache while local paths pass through unchanged. Live S3 confirmation: a repeat read drops from a full-COG download (megabytes) to a ~1 KB HEAD (or zero network with revalidation off). Adds `curl` to Imports. See the new README "Caching" section, which also documents the GDAL `/vsicurl/` env-var stopgap. ([#76](https://github.com/NewGraphEnvironment/cd/pull/76))
4+
15
# cd 0.3.2 (2026-06-06)
26

37
* Both regional vignettes (kootenay-lake, peace-fwcp) rewritten for new readers: plainer-language opener for the snowpack section ("In BC, most of the year's runoff starts as winter snow…" instead of the "hinge of BC hydrology" metaphor), Trends / Recent-Decade / bias-notes preambles compressed and de-jargoned, Annual snowpack signals intro reduced to a 3-bullet plain-language list, salmonid Interpretation closer tightened to one paragraph with three bold knock-on effects. Figure trim: cut `plot-tmean` (covered by `facet-tmean`), `plot-dtr` (asymmetry numbers already in prose), and `snow-rate-peak` (not load-bearing); fold `plot-tmax` + `plot-tmin` into one 2-panel faceted `plot-tmaxmin`, and `snow-swe-max` + `snow-doy-50` + `snow-fraction` into one 3-panel faceted `snow-annual` (free y-scales). Net per vignette: 3 fewer standalone figures, same coverage. Bibliography: dropped `kouki_etal2023` and `yue_wang2002` (no longer cited); union now 15/15. ([#75](https://github.com/NewGraphEnvironment/cd/pull/75))

R/cd_cache_fetch.R

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#' Fetch a remote COG through the on-disk cache
2+
#'
3+
#' Given a remote `href` (http/https), downloads the file once to the cd
4+
#' cache directory and returns a local path; subsequent calls read the
5+
#' local copy instead of re-pulling from the network. Freshness is
6+
#' checked with a cheap HTTP HEAD request (comparing the S3 ETag), so a
7+
#' monthly catalog republish is picked up automatically while repeat
8+
#' builds do near-zero egress. Local paths — and non-http URLs such as
9+
#' `s3://`, which GDAL reads directly — are returned unchanged.
10+
#'
11+
#' @param href Character. Path or URL to a COG.
12+
#' @param refresh Logical. If `TRUE`, force a re-download even when a
13+
#' valid cached copy exists. Default `FALSE`.
14+
#' @param cache_dir Character. Override the cache location. If `NULL`,
15+
#' uses [cd_cache_path()].
16+
#'
17+
#' @details
18+
#' Freshness uses the ETag when the server provides one, falling back to
19+
#' the `Content-Length` size when it does not. A host that returns
20+
#' neither validator cannot be proven fresh, so the file is re-downloaded
21+
#' on each call (safe, but un-cached) — S3, the default host, always
22+
#' returns both. Revalidation can be disabled for a fully-offline fast
23+
#' path with `options(cd.cache_revalidate = FALSE)`, which serves any
24+
#' existing cached copy without an HTTP HEAD. When the HEAD fails (e.g.
25+
#' offline) but a cached copy exists, the cached copy is served with a
26+
#' message. Downloads are written to a temporary file, validated against
27+
#' the advertised `Content-Length`, then atomically renamed, so a
28+
#' truncated download is never served as complete.
29+
#'
30+
#' @return Character path to the local (cached) file, or `href`
31+
#' unchanged for local / non-http inputs.
32+
#'
33+
#' @examples
34+
#' # Local files pass through untouched:
35+
#' f <- system.file("extdata", "example_climate.tif", package = "cd")
36+
#' identical(cd_cache_fetch(f), f)
37+
#'
38+
#' @export
39+
cd_cache_fetch <- function(href, refresh = FALSE, cache_dir = NULL) {
40+
if (length(href) != 1L || is.na(href) || !cd_is_remote(href)) {
41+
return(href)
42+
}
43+
44+
dir <- cd_cache_path(cache_dir)
45+
ext <- tools::file_ext(href)
46+
key <- rlang::hash(href)
47+
fname <- if (nzchar(ext)) paste0(key, ".", ext) else key
48+
local_path <- file.path(dir, fname)
49+
meta_path <- paste0(local_path, ".meta")
50+
51+
have_local <- file.exists(local_path) && file.exists(meta_path)
52+
revalidate <- isTRUE(getOption("cd.cache_revalidate", default = TRUE))
53+
54+
# Offline fast path: trust an existing cache without a HEAD request.
55+
if (have_local && !refresh && !revalidate) {
56+
return(local_path)
57+
}
58+
59+
head <- cd_remote_head(href)
60+
61+
# HEAD failed (offline / server error): serve a cached copy if present.
62+
if (is.null(head)) {
63+
if (have_local && !refresh) {
64+
rlang::inform(
65+
paste0("cd_cache_fetch: could not reach '", href,
66+
"'; serving cached copy.")
67+
)
68+
return(local_path)
69+
}
70+
stop("cd_cache_fetch: failed to reach '", href,
71+
"' and no cached copy is available.", call. = FALSE)
72+
}
73+
74+
# Valid cache: serve local, no download.
75+
if (have_local && !refresh) {
76+
meta <- jsonlite::read_json(meta_path)
77+
if (cd_cache_valid(head, meta)) {
78+
return(local_path)
79+
}
80+
}
81+
82+
# Download to a temp file, validate size, atomic rename, write meta.
83+
tmp <- tempfile(tmpdir = dir, fileext = if (nzchar(ext)) paste0(".", ext) else "")
84+
on.exit(if (file.exists(tmp)) unlink(tmp), add = TRUE)
85+
cd_remote_download(href, tmp)
86+
87+
if (!is.null(head$size) && !is.na(head$size)) {
88+
got <- file.size(tmp)
89+
if (is.na(got) || got != head$size) {
90+
stop("cd_cache_fetch: incomplete download of '", href, "' (",
91+
got, " of ", head$size, " bytes).", call. = FALSE)
92+
}
93+
}
94+
95+
if (!file.rename(tmp, local_path)) {
96+
stop("cd_cache_fetch: failed to move the download into the cache for '",
97+
href, "'.", call. = FALSE)
98+
}
99+
jsonlite::write_json(
100+
list(url = href, etag = head$etag, size = head$size,
101+
downloaded_at = format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z")),
102+
meta_path, auto_unbox = TRUE
103+
)
104+
local_path
105+
}
106+
107+
#' Is an href a cacheable remote (http/https) URL?
108+
#' @noRd
109+
cd_is_remote <- function(href) {
110+
grepl("^https?://", href)
111+
}
112+
113+
#' Is a cached copy still valid against fresh HEAD metadata?
114+
#'
115+
#' Prefers the ETag; falls back to Content-Length size when the server
116+
#' (or the stored meta) carries no ETag, so ETag-less hosts still get a
117+
#' cache hit instead of re-downloading on every call.
118+
#' @noRd
119+
cd_cache_valid <- function(head, meta) {
120+
if (!is.null(head$etag) && !is.null(meta$etag)) {
121+
return(identical(head$etag, meta$etag))
122+
}
123+
if (!is.null(head$size) && !is.na(head$size) && !is.null(meta$size)) {
124+
return(isTRUE(as.numeric(meta$size) == head$size))
125+
}
126+
FALSE
127+
}
128+
129+
#' HTTP HEAD a remote COG; return its ETag and size, or NULL on failure.
130+
#' @noRd
131+
cd_remote_head <- function(href) {
132+
handle <- curl::new_handle(nobody = TRUE)
133+
res <- tryCatch(
134+
curl::curl_fetch_memory(href, handle = handle),
135+
error = function(e) NULL
136+
)
137+
if (is.null(res) || res$status_code >= 400) {
138+
return(NULL)
139+
}
140+
hdrs <- curl::parse_headers_list(res$headers)
141+
etag <- hdrs[["etag"]]
142+
cl <- hdrs[["content-length"]]
143+
list(
144+
etag = if (!is.null(etag)) gsub('"', "", etag) else NULL,
145+
size = if (!is.null(cl)) as.numeric(cl) else NA_real_
146+
)
147+
}
148+
149+
#' Download a remote COG to destfile (binary).
150+
#' @noRd
151+
cd_remote_download <- function(href, destfile) {
152+
curl::curl_download(href, destfile, mode = "wb")
153+
}

R/cd_crop.R

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
#'
77
#' @param href Character. Path or URL to a COG or raster file.
88
#' @param aoi An `sf` or `SpatVector` polygon to crop to.
9+
#' @param cache Logical. If `TRUE` (default), route remote http(s) hrefs
10+
#' through the on-disk cache via [cd_cache_fetch()] so repeated reads
11+
#' pull from S3 once instead of every call. Local paths are unaffected.
912
#'
1013
#' @return A [terra::SpatRaster] cropped and masked to the AOI.
1114
#'
@@ -19,7 +22,10 @@
1922
#' r
2023
#'
2124
#' @export
22-
cd_crop <- function(href, aoi) {
25+
cd_crop <- function(href, aoi, cache = TRUE) {
26+
if (isTRUE(cache)) {
27+
href <- cd_cache_fetch(href)
28+
}
2329
r <- terra::rast(href)
2430
if (inherits(aoi, "sf") || inherits(aoi, "sfc")) {
2531
aoi <- terra::vect(aoi)

R/cd_extract.R

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
#' @param periods Character vector of periods to extract.
1414
#' Defaults to all periods in `catalog`.
1515
#' @param years Optional integer vector to filter specific years.
16+
#' @param cache Logical. If `TRUE` (default), remote COGs are read
17+
#' through the on-disk cache (see [cd_cache_fetch()]) so repeated
18+
#' extractions and report rebuilds download each COG from S3 once
19+
#' rather than on every call. Passed through to [cd_crop()].
1620
#'
1721
#' @return A tibble with columns:
1822
#' \describe{
@@ -36,11 +40,12 @@
3640
cd_extract <- function(catalog, aoi,
3741
variables = catalog$variable,
3842
periods = catalog$period,
39-
years = NULL) {
43+
years = NULL,
44+
cache = TRUE) {
4045
rows <- catalog[catalog$variable %in% variables & catalog$period %in% periods, ]
4146

4247
results <- lapply(seq_len(nrow(rows)), function(i) {
43-
r <- cd_crop(rows$href[i], aoi)
48+
r <- cd_crop(rows$href[i], aoi, cache = cache)
4449
means <- terra::global(r, fun = "mean", na.rm = TRUE)
4550
yr <- as.integer(names(r))
4651

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,40 @@ cd_summary(trn)
4141
cd_compare(ts, window_a = 1956:1960, window_b = 1951:1955)
4242
```
4343

44+
## Caching
45+
46+
`cd_extract()` and `cd_crop()` cache each COG on first read, so repeated
47+
extractions, report renders, and vignette rebuilds pull each file from S3
48+
**once** and read locally thereafter — turning recurring S3 egress into a
49+
one-time cost. Caching is on by default (`cache = TRUE`).
50+
51+
```r
52+
# First call downloads; later calls read the local cache.
53+
ts <- cd_extract(catalog, aoi) # cache = TRUE by default
54+
55+
cd_cache_info() # where the cache lives + size
56+
cd_cache_clear() # wipe it
57+
cd_extract(catalog, aoi, cache = FALSE) # bypass the cache for one call
58+
```
59+
60+
Freshness is checked with a cheap HTTP HEAD (S3 ETag), so the monthly
61+
catalog republish is picked up automatically; `cd_cache_fetch(href,
62+
refresh = TRUE)` forces a re-download. For a fully-offline session set
63+
`options(cd.cache_revalidate = FALSE)` to serve cached copies without any
64+
network call.
65+
66+
**Stopgap without the cache.** If you read COGs through GDAL directly
67+
(e.g. raw `terra::rast("/vsicurl/...")` outside `cd_crop()`), you can cut
68+
repeat egress within a session by enabling GDAL's `/vsicurl/` cache:
69+
70+
```r
71+
Sys.setenv(VSI_CACHE = "TRUE", VSI_CACHE_SIZE = "100000000") # 100 MB
72+
Sys.setenv(GDAL_HTTP_MAX_RETRY = "3", GDAL_HTTP_RETRY_DELAY = "1")
73+
```
74+
75+
This only persists within one R session; the `cd_*` cache above persists
76+
across sessions, which is what kills recurring report-dev egress.
77+
4478
## Data
4579

4680
The producer pipeline fetches ERA5-Land hourly reanalysis from

man/cd_cache_fetch.Rd

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/cd_crop.Rd

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/cd_extract.Rd

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)