Skip to content

Page dft_stac_fetch() to exhaustion (#51) - #52

Merged
NewGraphEnvironment merged 6 commits into
mainfrom
51-dft-stac-fetch-pages-to-exhaustion
Sep 2, 2026
Merged

Page dft_stac_fetch() to exhaustion (#51)#52
NewGraphEnvironment merged 6 commits into
mainfrom
51-dft-stac-fetch-pages-to-exhaustion

Conversation

@NewGraphEnvironment

@NewGraphEnvironment NewGraphEnvironment commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Problem

dft_stac_fetch() called rstac::get_request() with no items_fetch(), so it read one page of STAC items and handed the partial collection straight to gdalcubes::stac_image_collection(). A wide AOI therefore built a raster with missing tiles — no error, no warning, plausible output. Because it scales with AOI size it would have surfaced first on the largest, most-published area rather than in a test. dft_stac_cube() has always paged correctly; fetch was simply never brought across.

Closes #51.

Measurement came before the design, and killed the design the issue proposed

The issue suggested "a hard error when a next link survives". Measured against Planetary Computer (io-lulc-annual-v02, packaged AOI, 2017–2023, ground truth 14 items):

limit=NULL raw_n=14 raw_next=FALSE | fetched_n=14 fetched_next=FALSE | matched=NULL
limit=1    raw_n=1  raw_next=TRUE  | fetched_n=14 fetched_next=TRUE  | matched=NULL
limit=3    raw_n=3  raw_next=TRUE  | fetched_n=14 fetched_next=TRUE  | matched=NULL
limit=500  raw_n=14 raw_next=FALSE | fetched_n=14 fetched_next=FALSE | matched=NULL

The next link survives a successful items_fetch(). Confirmed at source: rstac:::items_fetch.doc_items mutates only items$features and never items$links, so what returns is page 1's document with a concatenated feature list. That guard would have aborted every correctly-paged fetch — fail-toward-abort.

So the link is stripped rather than errored on. That is not cosmetic: left attached to attr(, "stac_items"), a caller re-running items_fetch() on it re-fetches pages 2..N into an already-complete feature list and silently duplicates them. The fix creates that hazard, so the fix closes it.

What landed

  • stac_items_paged() — pages to exhaustion, then signs. Order is load-bearing: signing first leaves every item from page 2 onward unsigned. Extracted rather than inlined because the paging path is otherwise untestable offline (the next statement in dft_stac_fetch() is stac_image_collection() followed by real cube reads).
  • Two completeness checks of deliberately different reach. Duplicate item ids — never skipped, and the only signal available on PC, which matters because stac_image_collection() drops duplicates behind a .pkgenv$debug-gated message so nothing downstream would ever report them. And items_matched() vs the count — PC sends no numberMatched, so it never executes there and is fixtured in tests rather than left as dead code.
  • Keeps GET. The cube posts because ext_filter/CQL2 requires it, not for paging. Commented, with the trigger to switch if fetch ever gains intersects or a filter.
  • attr(, "cache_key") (the issue's secondary ask), documented as per-call not per-year.

⚠️ Deliberate cache-format break — fetch caches rebuild once

No cache-key parameter changes with this fix. Without a break, a raster written from a truncated item set keeps being served by the file.exists() short-circuit — so the wide-AOI users the bug hit hardest would get no fix at all on upgrade, silently and permanently under force = FALSE. stac_cache_key() gains a salt; the frozen guardian moves 79f67b7b9dae2264b5dbef6e, re-frozen with the reason recorded, as its own comment demands.

Cost is a one-time re-fetch of small annual land-cover rasters — not the cube's multi-hour Sentinel-2 stream, which is why the cube chose a read-path check for its analogous problem and fetch can afford a key break. dft_stac_cube() caches are unaffected; stac_cube_cache_key() is a separate function and is untouched.

Verification

Every guard was checked by restoring the defect and confirming the suite goes red — baseline FAIL=0:

restored defect FAIL
no paging (the reported bug) 2
sign before page 1
stale next not stripped 2
no duplicate-id check 1
no items_matched guard 1
cache salt removed 9
is.null instead of length guard 1
dft_stac_fetch() bypasses the helper 1
exact-case next strip 2

One test measured FAIL=0 and was rewritten. The wiring test originally asserted only that a stubbed stac_image_collection() was reached — which the old inline pipeline satisfies just as well, so it could not fail. Reading it would never have shown that. It now asserts on the item ids the stub received, with rstac::get_request() booby-trapped so a bypass errors differently and offline.

Also: interop against the real consumer (the links-rewritten doc_items still satisfies rstac::items_length() and builds a 4-image gdalcubes collection); lintr 1 lint against a 2-lint baseline, the remainder a confirmed false positive.

  • Offline suite 476 pass
  • DRIFT_TEST_NETWORK=true92 pass / 0 fail on this file against live Planetary Computer

What the code review changed

/code-check's three reviewers ran long but did deliver, and they are the reason this PR is worth reading. Between them they found fourteen mutations that passed a suite I had already verified with a nine-defect restoration harness. Two changed shipped behaviour:

  • A change made earlier on this branch was reversed. I had widened the next-link strip to be case-insensitive, reasoning it was strictly safer. It is not: rstac:::items_next.doc_items selects with links(items, rel == "next"), which is case-sensitive (measured: next 1, NEXT 0, Next 0). So a NEXT link is inert to items_fetch() and cannot cause the duplicate the strip guards against. What its presence actually means is that rstac could not follow it and stopped after page one — the very truncation this PR fixes — which on PC nothing else can detect. The "safer" strip was deleting the only local evidence of a truncated fetch. It now warns and keeps.
  • Deleting attr(, "cache_key") left the suite green. A documented v0.10.0 return element whose only assertion sat behind the network skip, i.e. absent from CI. Now covered offline.

Other blind spots closed, each re-measured in a worktree: the wiring mock discarded its arguments (so dft_stac_fetch() hardcoding sign_fn — making a documented argument a silent no-op — was invisible); every items_sign mock ignored sign_fn; my own "pin the default limit" test pinned the test helper's default rather than the function's; expect_error(paged(), "99") passed with the two user-facing numbers swapped; and expect_lt(truncated, big), labelled "the two answers must differ", could not fail.

Also from review: the items_matched() guard is now asymmetric — abort on fewer items than reported (truncation), warn on more, since pgstac/stac-fastapi can return an estimated numberMatched and aborting there would fail toward abort on a complete fetch.

⚠️ One commit in this branch's history does not parse

fc2e861 landed a R/dft_stac_fetch.R missing a comma and the cache salt, under a message claiming "458 pass". Cause: a review agent ran defect-restoration cycles in the shared checkout while the implementation session committed. bbb679a swept the repair in, so the tip has always been healthy and every commit from 019707a onward parses — but a git bisect through fc2e861 hits an unloadable package. I rebuilt the history to remove it and the force-push was declined, so it remains. Squash-merge and it never reaches main; if you merge-commit, it does.

The two guards that follow are worth keeping: a mutation harness runs in git worktree add --detach, never the live checkout, and a commit on a branch where one has run is verified by parsing the committed blob, not by git status being clean.

Two pre-existing defects found, deliberately not fixed here

Both out of scope, both worth issues, and they are the same finding:

  • test-dft_stac_cube.R:62 — the cube's frozen cache-key guardian is red on main. Bisected to 90f9d93, the commit that introduced it: it has never been green. Not re-frozen here, because re-freezing an unexplained drift is exactly what its own comment forbids.
  • drift has no CI workflow that runs the test suite. .github/workflows/ holds only pkgdown.yaml and update-citation-cff.yaml. That is why the above survived a release cycle — every CI run is green because nothing runs the tests.

This means acc3243 cuts v0.10.0 over a suite with one known-red test. Nothing is tagged yet, so that call is still open at merge time.

Relates to NewGraphEnvironment/sred-2025-2026#16

🤖 Generated with Claude Code

https://claude.ai/code/session_01W1utBhUeke24nQo5M97LUr

NewGraphEnvironment and others added 6 commits September 1, 2026 18:39
Phases derived from live measurement against Planetary Computer plus a
Plan-agent review. The issue's suggested guard (error on a surviving
"next" link) is rejected on measurement: the link survives a successful
items_fetch(), so the guard would abort every correctly-paged fetch.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1utBhUeke24nQo5M97LUr
dft_stac_fetch() called rstac::get_request() with no items_fetch(), so it
read ONE page of STAC items and handed the partial collection to
gdalcubes::stac_image_collection(). A wide AOI therefore built a raster
with missing tiles, silently — no error, no warning, plausible output.
dft_stac_cube() has always paged; fetch was never brought across.

New internal stac_items_paged() pages to exhaustion, then signs. Order is
load-bearing: signing first leaves every item from page 2 onward unsigned.

The guard the issue proposed is wrong and is deliberately not implemented.
rstac:::items_fetch.doc_items mutates only items$features and never
items$links, so a fully-paged collection still carries page 1's "next".
Measured on the packaged AOI (io-lulc-annual-v02, 14 items ground truth):

  limit=NULL raw=14 next=FALSE | fetched=14 next=FALSE | matched=NULL
  limit=1    raw=1  next=TRUE  | fetched=14 next=TRUE  | matched=NULL
  limit=3    raw=3  next=TRUE  | fetched=14 next=TRUE  | matched=NULL
  limit=500  raw=14 next=FALSE | fetched=14 next=FALSE | matched=NULL

Erroring on a surviving "next" would abort every correctly-paged fetch.
The link is stale, so it is stripped before attr(, "stac_items") reaches
callers — otherwise a caller re-running items_fetch() on that attribute
duplicates pages 2..N into an already-complete feature list.

Two completeness checks of deliberately different reach: duplicate item
ids (never skipped, and the only signal available on Planetary Computer,
since stac_image_collection() drops duplicates behind a debug-only
message), and items_matched() vs the count (PC sends no numberMatched, so
it never executes there and is fixtured rather than left dead).

Cache-format break, deliberate: no key parameter changes with this fix, so
a raster written from a truncated item set would be served from cache
forever under force = FALSE, and the wide-AOI users the bug hit hardest
would get no fix at all. stac_cache_key() gains a salt; the frozen
guardian moves 79f67b7b9dae -> 2264b5dbef6e. Cost is a one-time re-fetch
of small annual rasters. stac_cube_cache_key() is separate and untouched,
so cube caches are unaffected.

Also attaches attr(, "cache_key") so a caller can record which cache entry
served a fetch (per call, not per year).

Verified by restoring each defect and confirming the tests go red: no
paging FAIL=2, sign-before-page FAIL=1, next-link kept FAIL=2, no dup
check FAIL=1, no matched guard FAIL=1, salt removed FAIL=9, zero-length
matched FAIL=1, helper bypassed FAIL=1 — against a clean baseline. That
last one measured FAIL=0 as first written: the wiring test asserted only
that a stubbed stac_image_collection() was reached, which the old inline
pipeline satisfies equally, so it could not fail. Rewritten to assert on
the item ids the stub received with rstac::get_request() booby-trapped.

Offline suite 458 pass; DRIFT_TEST_NETWORK=true gives 74 pass / 0 fail on
this file against live Planetary Computer.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1utBhUeke24nQo5M97LUr
Self-probe of the guard's scope: `identical(l$rel, "next")` leaves a
differently-cased `rel` behind, which is the single outcome the strip
exists to prevent. STAC and rstac both emit lowercase, so this is
defensive rather than observed — but the failure direction is bad (a
surviving next link lets a caller re-page an already-complete collection
and silently duplicate features) while the cost of matching loosely is
nil, since nothing else is named "next".

A link carrying no `rel` at all is explicitly kept: only `next` is being
removed, and dropping unlabelled links would be a different change.

Verified by restoring the exact-match form: FAIL=2 against a clean
baseline.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1utBhUeke24nQo5M97LUr
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1utBhUeke24nQo5M97LUr
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1utBhUeke24nQo5M97LUr
…ts (#51)

Three reviewers found fourteen mutations that passed a suite already
verified with a nine-defect restoration harness. Two changed behaviour.

REVERSED: the next-link strip is case-SENSITIVE again, and a case-variant
is deliberately KEPT. An earlier commit on this branch widened it to
tolower(), reasoning that was strictly safer. It is not.
rstac:::items_next.doc_items selects with links(items, rel == "next"),
which is case-sensitive -- measured: next 1, NEXT 0, Next 0. So a NEXT
link is inert to items_fetch() and cannot cause the re-paging duplicate
the strip exists to prevent. What its presence actually means is that
rstac could not follow it and STOPPED AFTER PAGE ONE, i.e. the #51
truncation -- which on PC nothing else can detect, since items_matched()
is NULL and a short read yields no duplicate ids. That link is therefore
the last local evidence of a truncated fetch, and the "safer" strip
deleted it. Now warned and kept. Widening a matcher past what its
consumer matches is the trap: the guard stopped agreeing with the library
whose behaviour it compensates for.

An item with no usable id is now its own error. Folded into the duplicate
check it reported two id-less items as "duplicate item id: NA -- pages
overlapped", naming a cause that had not occurred.

Test blind spots closed, each re-measured in a worktree (all now red
against a clean baseline):
- deleting attr(, "cache_key") was FAIL=0: a documented v0.10.0 return
  element whose only assertion sat behind the network skip, absent from
  CI. Now covered offline by pre-seeding the cache so the file.exists()
  short-circuit fires.
- the wiring mock discarded its arguments, so it proved the helper was
  called and nothing about what with. dft_stac_fetch() hardcoding sign_fn
  -- making a documented argument a silent no-op -- was invisible. It now
  captures and asserts sign_fn, stac_url, collection, datetime and bbox.
- every items_sign mock ignored sign_fn, so dropping it from the call was
  FAIL=0. One mock now captures it.
- the default limit test pinned the TEST HELPER's default, not the
  function's; changing production's default to 1 stayed green. A helper
  that omits limit now pins it.
- expect_error(paged(), "99") passed when n_items and matched were
  swapped in the message -- the two numbers a user acts on. Both are now
  matched in order.
- expect_lt(truncated, big) could not fail: limit=500 returns all 14 in
  one page with or without items_fetch(), so 1 < 14 either way. The
  discriminating comparison is truncated vs small, both at limit=1.

Also: corrects roxygen that still claimed untiled keys stay stable, four
lines above the salt that moves them; records why limit is deliberately
not hashed; makes the items_matched guard ASYMMETRIC (abort on fewer,
warn on more) so an estimated numberMatched from pgstac cannot abort a
complete fetch.

lintr 1 from a 2-lint baseline. Offline 476 pass; DRIFT_TEST_NETWORK=true
gives 92 pass / 0 fail on this file.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1utBhUeke24nQo5M97LUr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dft_stac_fetch() reads one STAC page — a wide AOI can build a raster from a truncated item set

1 participant