|
| 1 | +# Findings — dft_stac_fetch cache key omits AOI (#25) |
| 2 | + |
| 3 | +## Issue context |
| 4 | + |
| 5 | +**Repo:** NewGraphEnvironment/drift · **Severity:** high (silent wrong data, no error) · **Version seen:** 0.2.2 |
| 6 | + |
| 7 | +### Summary |
| 8 | + |
| 9 | +`dft_stac_fetch()` caches fetched rasters at `file.path(cache_source_dir, paste0(yr, ".nc"))` |
| 10 | +(`R/dft_stac_fetch.R:103`) — keyed only by **source** and **year**, with **no AOI component**. Any |
| 11 | +two calls with the same `source`/`year` but different `aoi` collide: the second call finds the |
| 12 | +first call's NetCDF, skips the fetch (when `force = FALSE`, the default), and returns the **first |
| 13 | +AOI's raster masked to the second AOI**. No warning, no error — just wrong data. |
| 14 | + |
| 15 | +### Evidence (real occurrence) |
| 16 | + |
| 17 | +Running two BC watershed areas through a floodplain/LULC pipeline that calls |
| 18 | +`dft_stac_fetch(source = "io-lulc", years = c(2017, 2020, 2023))`: |
| 19 | + |
| 20 | +1. Area A (Neexdzii, a reach of the Bulkley) ran first → populated |
| 21 | + `~/Library/Caches/drift/io-lulc/{2017,2020,2023}.nc` with Neexdzii's extent. Correct output. |
| 22 | +2. Area B (MORR / Morice, ~80 km west, larger) ran second → `dft_stac_fetch` found the cache files |
| 23 | + and returned **Neexdzii's** rasters, masked to the MORR floodplain. |
| 24 | + |
| 25 | +Cache extent vs. AOIs (EPSG:32609, metres): |
| 26 | + |
| 27 | +| | E min–max | N min–max | |
| 28 | +|---|---|---| |
| 29 | +| cache `io-lulc/*.nc` | 645443–696463 | 6000758–6056578 | |
| 30 | +| **Neexdzii** fp bbox | 645444–696461 | 6000762–6056573 | ← cache == Area A | |
| 31 | +| **MORR** fp bbox | 566715–651331 | 5948369–6035818 | ← what Area B should have gotten | |
| 32 | + |
| 33 | +Result: MORR's land cover was classified over only the ~3% where the Neexdzii cached extent |
| 34 | +overlaps the MORR floodplain (near the shared Bulkley/Morice confluence); "tree loss" came out |
| 35 | +22 ha of Bulkley-valley agricultural transitions instead of the true MORR figure. |
| 36 | + |
| 37 | +### Secondary bug: `force = TRUE` cannot overwrite |
| 38 | + |
| 39 | +`force = TRUE` routes to the fetch branch and calls `gdalcubes::write_ncdf(cube, cache_file)` |
| 40 | +without removing the existing file first. When the cache file exists, `write_ncdf` errors: |
| 41 | + |
| 42 | +``` |
| 43 | +Error: File already exists, please change the output filename or set overwrite = TRUE |
| 44 | +``` |
| 45 | + |
| 46 | +So `force = TRUE` cannot be used to bypass a stale/colliding cache — the user must manually delete |
| 47 | +the file (or call `dft_cache_clear()`). |
| 48 | + |
| 49 | +### Fix (from issue) |
| 50 | + |
| 51 | +1. **Put the AOI in the cache key.** Hash the AOI (bbox + geometry) into the filename. Preserves |
| 52 | + caching for repeat runs of the *same* AOI while eliminating cross-AOI collisions. (Also fold |
| 53 | + `res`, `crs`, `aggregation` into the key, since they change the output too.) |
| 54 | +2. **Fix `force = TRUE`** to overwrite instead of erroring. |
| 55 | +3. **Defensive check (optional):** on a cache hit, verify the cached raster's extent covers the |
| 56 | + requested AOI bbox; if not, re-fetch. |
| 57 | + |
| 58 | +### Minimal repro |
| 59 | + |
| 60 | +```r |
| 61 | +library(drift) |
| 62 | +a <- sf::st_as_sf(sf::st_sfc(sf::st_buffer(sf::st_point(c(-126.75, 54.41)), 0.1), crs = 4326)) |
| 63 | +b <- sf::st_as_sf(sf::st_sfc(sf::st_buffer(sf::st_point(c(-127.75, 54.05)), 0.1), crs = 4326)) # ~65 km west |
| 64 | +ra <- dft_stac_fetch(a, source = "io-lulc", years = 2020) # fetches |
| 65 | +rb <- dft_stac_fetch(b, source = "io-lulc", years = 2020) # returns a's cached raster, masked to b -> mostly NA |
| 66 | +# terra::ext(rb[["2020"]]) matches a, not b |
| 67 | +``` |
| 68 | + |
| 69 | +## Plan-mode exploration (2026-07-06) |
| 70 | + |
| 71 | +### Code facts |
| 72 | + |
| 73 | +- Only place the `<year>.nc` filename is constructed: `R/dft_stac_fetch.R:103`. Written at :126, |
| 74 | + read at :107/:127. No other code, test, vignette, or data-raw script assumes the pattern. |
| 75 | +- `dft_cache_clear()` / `dft_cache_info()` (`R/dft_cache.R`) are filename-agnostic |
| 76 | + (`list.files(recursive = TRUE)` / `unlink(recursive = TRUE)`) — unaffected by a filename change. |
| 77 | + `dft_cache_clear(source=)` assumes only the per-source subdirectory, which is kept. |
| 78 | +- Fetch-affecting params NOT in the current key: `aoi`, `res`, `crs`, `dt`, `aggregation`, |
| 79 | + `resampling`, `stac_url`, `collection`, `asset`. All must enter the hash. `sign_fn` doesn't |
| 80 | + affect pixels; `source` remains the directory. |
| 81 | +- `rlang` and `sf` are already in Imports; `digest` is not a dependency and isn't needed — |
| 82 | + `rlang::hash()` (XXH128) works. No hashing exists anywhere in the package yet. |
| 83 | +- Existing tests never exercise the network fetch path (only `auto_utm_epsg` and the |
| 84 | + missing-gdalcubes error). Cache-key helper is unit-testable fully offline via `drift:::`. |
| 85 | + |
| 86 | +### Design decisions (validated against installed sf 1.1.0 / rlang 1.2.0 / gdalcubes 0.7.3) |
| 87 | + |
| 88 | +- **Hash WKB (`sf::st_as_binary(sf::st_geometry(x), endian = "little")`), not the sfc object.** |
| 89 | + sfc carries a PROJ-generated CRS WKT that drifts across PROJ versions → spurious cache misses. |
| 90 | + WKB is coordinates + geometry type only; CRS enters the key separately as `target_crs`. |
| 91 | + Also immune to sf attribute columns (verified: sf-with-attributes and bare sfc hash identically |
| 92 | + via WKB). |
| 93 | +- **`as.numeric(res)`** — `10L` vs `10` serialize differently under `rlang::hash()`; identical |
| 94 | + fetches would get different keys without coercion. |
| 95 | +- **Hash post-resolution `stac_url`/`collection`/`asset`** (after the `%||%` config resolution), |
| 96 | + never the raw possibly-NULL args — otherwise `dft_stac_fetch(aoi)` and an explicit-but-identical |
| 97 | + call hash differently. Bonus: also fixes a latent collision where a custom collection with |
| 98 | + default `source = "io-lulc"` landed in the io-lulc dir keyed only by year. |
| 99 | +- **Year stays out of the hash** — key computed once before the per-year `lapply`; filename |
| 100 | + `<year>_<key>.nc` groups all years of one call under a shared readable suffix. |
| 101 | +- **`write_ncdf(..., overwrite = TRUE)` over bare `unlink()`** — gdalcubes 0.7.3 signature is |
| 102 | + `write_ncdf(x, fname, overwrite = FALSE, ...)`. Bare `unlink()` fails *silently* on Windows when |
| 103 | + a prior SpatRaster holds a GDAL handle on the file, reproducing the original confusing error. |
| 104 | +- **Extent check (issue's optional item 3) skipped** — user confirmed 2026-07-06. gdalcubes' |
| 105 | + `cube_view` only ever *enlarges* extents to fit the pixel grid, so a containment check with |
| 106 | + one-pixel tolerance would validate nothing; post-hash-fix, legacy `<year>.nc` files can never |
| 107 | + match the new pattern anyway. |
| 108 | +- **Old-format cache files become dead weight** — correct behavior; do NOT auto-delete (can't |
| 109 | + attribute them to an AOI). NEWS notes existing caches refetch and `dft_cache_clear()` reclaims |
| 110 | + space. |
| 111 | +- **POSIX silent-swap caveat** — with `force = TRUE`, a SpatRaster returned by an earlier call and |
| 112 | + backed by the same cache file may lazily reopen and see the new content. Newly reachable (the |
| 113 | + old behavior errored first), but benign under the hash key: the overwritten file corresponds to |
| 114 | + the identical parameter set. Documented in `@param force` rather than engineered around. |
0 commit comments