Page dft_stac_fetch() to exhaustion (#51) - #52
Merged
Conversation
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
NewGraphEnvironment
deleted the
51-dft-stac-fetch-pages-to-exhaustion
branch
September 2, 2026 16:42
This was referenced Sep 2, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
dft_stac_fetch()calledrstac::get_request()with noitems_fetch(), so it read one page of STAC items and handed the partial collection straight togdalcubes::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
nextlink survives". Measured against Planetary Computer (io-lulc-annual-v02, packaged AOI, 2017–2023, ground truth 14 items):The
nextlink survives a successfulitems_fetch(). Confirmed at source:rstac:::items_fetch.doc_itemsmutates onlyitems$featuresand neveritems$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-runningitems_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 indft_stac_fetch()isstac_image_collection()followed by real cube reads).stac_image_collection()drops duplicates behind a.pkgenv$debug-gated message so nothing downstream would ever report them. Anditems_matched()vs the count — PC sends nonumberMatched, so it never executes there and is fixtured in tests rather than left as dead code.ext_filter/CQL2 requires it, not for paging. Commented, with the trigger to switch if fetch ever gainsintersectsor a filter.attr(, "cache_key")(the issue's secondary ask), documented as per-call not per-year.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 underforce = FALSE.stac_cache_key()gains a salt; the frozen guardian moves79f67b7b9dae→2264b5dbef6e, 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:
nextnot strippeditems_matchedguardis.nullinstead oflengthguarddft_stac_fetch()bypasses the helpernextstripOne 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, withrstac::get_request()booby-trapped so a bypass errors differently and offline.Also: interop against the real consumer (the links-rewritten
doc_itemsstill satisfiesrstac::items_length()and builds a 4-image gdalcubes collection); lintr 1 lint against a 2-lint baseline, the remainder a confirmed false positive.DRIFT_TEST_NETWORK=true→ 92 pass / 0 fail on this file against live Planetary ComputerWhat 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:next-link strip to be case-insensitive, reasoning it was strictly safer. It is not:rstac:::items_next.doc_itemsselects withlinks(items, rel == "next"), which is case-sensitive (measured:next1,NEXT0,Next0). So aNEXTlink is inert toitems_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.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()hardcodingsign_fn— making a documented argument a silent no-op — was invisible); everyitems_signmock ignoredsign_fn; my own "pin the defaultlimit" test pinned the test helper's default rather than the function's;expect_error(paged(), "99")passed with the two user-facing numbers swapped; andexpect_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 estimatednumberMatchedand aborting there would fail toward abort on a complete fetch.fc2e861landed aR/dft_stac_fetch.Rmissing 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.bbb679aswept the repair in, so the tip has always been healthy and every commit from019707aonward parses — but agit bisectthroughfc2e861hits an unloadable package. I rebuilt the history to remove it and the force-push was declined, so it remains. Squash-merge and it never reachesmain; 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 bygit statusbeing 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 onmain. Bisected to90f9d93, 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..github/workflows/holds onlypkgdown.yamlandupdate-citation-cff.yaml. That is why the above survived a release cycle — every CI run is green because nothing runs the tests.This means
acc3243cuts 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