feat(transport)!: page large results in parallel by offset; remove parallel_chunks - #357
Open
thodson-usgs wants to merge 2 commits into
Open
feat(transport)!: page large results in parallel by offset; remove parallel_chunks#357thodson-usgs wants to merge 2 commits into
thodson-usgs wants to merge 2 commits into
Conversation
…nks dial Cursor pagination is inherently sequential: page N+1's URL only exists once page N has been parsed, so a 10-page result costs 10 round trips end to end. Where a service honors `offset`, every page's URL is computable up front (offset = i * limit), so the same pages can be fetched concurrently. The request *count* is unchanged; only their timing is. That matters because the USGS quota is volume-based, so overlapping pages costs no extra quota. `transport/offsets.py` owns the service-neutral half: given a page-request builder and a page parser, drive a bounded, speculative, wave-by-wave fetch. Waves rather than a flat fan-out because `numberMatched` is optional in OGC API - Features and absent from Water Data responses, so the page count can't be known in advance and has to be probed; a wave of 8 wastes at most 7 requests, and only on the final wave. Removes `parallel_chunks(n)`, `ChunkPlan.max_chunks`, and `ChunkPlan._refine`. They bought parallelism the other way -- splitting a request that already fit the byte budget into more sub-requests -- which spends extra quota and does nothing for a single-site query, the case with no multi-value axis to split. Byte-driven chunking is unchanged and still a correctness requirement. Two fallbacks keep the result correct rather than merely fast: `offset` is a server extension, and an unrecognized query parameter is conventionally ignored, so a server answering every offset with page 1 is detected before any rows are returned and the query re-runs via standard `next`-link paging; and the API's hard 40000 offset ceiling hands the tail off to the cursor walk, rewinding one page so the seam lands on an offset the service still accepts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wave width started at the full ``API_USGS_CONCURRENT`` (default 32), so a one-page result fired 21 requests -- 32 offsets clipped to the 40000 ceiling -- to discover it was already finished. Since the quota is volume-based that is a 21x tax on exactly the queries with nothing to gain from parallelism, and it contradicted this feature's central claim that the request count is unchanged. Nothing caught it because no test exercised the shipped default: the conftest pins API_USGS_CONCURRENT=1 and the new integration tests pinned 4. The width now ramps 1, 2, 4, ... up to the cap. A single-page result costs exactly one request, total requests stay under 2x the pages that exist (doubling means all prior waves sum to less than the current one), and round trips stay logarithmic in the page count. Measured at the default width: a 1-page result goes 21 -> 1 request, and a 10-page result 21 -> 15. The ramp also made the first wave a single page, which would have silently disabled the ignore-detection guard -- it compares two pages at different offsets. It now spans waves, comparing the last kept page against the first of the current wave, so a server ignoring ``offset`` is still caught before any rows are returned. Adds three regression tests at the *default* width, including the one-request floor and the 2x bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Summary
A multi-page Water Data result used to cost one round trip per page, in strict
sequence. It no longer does. Where the API honors
offset, every page's URL iscomputable up front, so the pages are fetched concurrently — the request count
is unchanged, only their timing. Since the USGS quota is volume-based
(
x-ratelimit-limit, default 1000/hr), the speedup is free of quota.The same change removes
parallel_chunks(n), which bought parallelism theother way — splitting a request that already fit into more sub-requests. That
approach spent extra quota, and did nothing at all for a single-site query,
which has no multi-value axis to split. Overlapping the pages a request was
going to fetch anyway subsumes it.
Measured, fully warm, against the live API:
The second row is the case that had no answer before:
ChunkPlanprovablyreturns 1 sub-request for a single site regardless of
parallel_chunks(n)(verified at n=1, 8, and 32), so a single-site deep history was sequential and
there was no knob that changed it.
Why cursors are the slow part
OGC API - Features Part 1 defines paging as a
nextlink relation. Thelink's target is opaque — Water Data returns
?cursor=<token>— so pageN+1'sURL literally does not exist until page
Nhas come back and been parsed:Ten pages, ten serialized round trips. Latency dominates: each page is a small
payload behind a ~0.5 s round trip.
offsetbreaks the chain. It is not in the OGC spec (more on that below), butWater Data supports it, and with it the whole URL set is a closed-form
computation —
offset = i × limit:Same four requests. One round trip of wall clock instead of four.
How it knows when to stop
Here is the wrinkle: the walk does not know how many pages there are. OGC
API - Features Part 1 makes
numberMatchedoptional — "each page mayinclude information about the number of selected and returned features" — and
Water Data omits it. A page carries
numberReturned(its own row count) but nototal. So the page count cannot be computed in advance; it has to be probed.
That is why the walk fetches in waves of
widthrather than one flatfan-out. Each wave issues
widthrequests at offsets continuing where the lastwave stopped, then inspects the results for a stop signal. A too-clever flat
fan-out of 32 requests for a 3-page result would burn 29 requests to learn
nothing; a wave of 8 burns at most 7, and only on the final wave.
Worked example: 25 rows,
limit=10,width=4_stop_indexwalks the wave in offset order and returns the index of thepage that ends the walk — index
2here. Pages after it are dropped, so theovershoot at
offset=30is harmless: it was already paid for in parallel withthe pages that mattered, and its rows are never concatenated.
Result: 25 rows from 4 requests — 3 useful pages plus the single probe that
proved page 3 was the last one. The sequential walk needed those same 3 requests
plus a 4th to discover the end (
nextabsent), so the request count isidentical; only the wall clock changed.
tests/waterdata_offset_paging_test.py::test_page_count_is_not_inflated_by_parallelismpins exactly that count.
Returning an index rather than a bool is what makes the earliest terminal page
win. If
offset=20is short but a speculativeoffset=40also looks terminal,index 2 still ends the walk — a later page cannot resurrect the pages between.
The four stop conditions, in precedence order
_stop_indexis the single source of truth; the module docstring documents thesame order.
A short page — fewer than
limitrows. The last page by construction:the server had no more rows to give. This is the normal exit. The page is
kept, rows included.
An empty page — zero rows. The previous page ended exactly on a
limitboundary and this offset is past the end. Everything before it is kept; the
empty page is not. (With 20 rows at
limit=10, no page is ever short — theempty page at
offset=20is the only signal available.)The row cap —
max_rowsis reached, so further pages would be discardedanyway. A wave can overshoot the cap, so the cap is re-applied to the
combined frame (
result.head(row_cap)) rather than to a wave boundary;otherwise
max_rows=25would return whatever a wave happened to land on.The offset ceiling — Water Data rejects
offset > 40000with HTTP 400InvalidQuery. This is not an end-of-data signal, so it must not end thewalk, or a deep pull would silently truncate. Instead the offsets stop and a
sequential cursor continuation takes over. Offsets have a ceiling; cursors
don't. The hybrid is fast over the parallelizable prefix and complete over
the rest.
Waves continue until one of these fires, so a 10-page result at
width=4isthree waves (4 + 4 + 2 useful), not one flat guess.
The ceiling seam needs a rewind
Worth spelling out, because the obvious implementation is subtly wrong — and it
was wrong here until an integration test caught it.
At the ceiling, the next offset the walk would need is by definition past the
ceiling. So it can't seed the continuation either — that request would earn the
same HTTP 400 the offset walk just avoided (
offset=40010 > 40000). The walktherefore rewinds one page: it drops the last page it fetched and re-seeds
the cursor walk at
offsets[-1], the largest offset the service still accepts,following
nextlinks from there. One page is re-fetched per deep query, inexchange for a seam with neither a gap (missing rows) nor an overlap
(duplicates).
Risks, and what they cost
offsetis a non-standard extension. Part 1 defines onlylimitand thenextrelation. Worse, an unrecognized query parameter is conventionallyignored rather than rejected — so a server that drops
offsetsupport wouldanswer every offset with page 1, and a naive walk would concatenate the same
rows N times and report success. Silent duplication is the worst failure
mode available to this design, so it is checked for directly:
_offset_ignoredcompares the first two full-length pages of the first wave, and identical frames
raise
OffsetUnsupported. That happens before any rows are returned, so thefallback re-walk cannot double-count. The query then completes via standard
next-link paging — slower, correct, and needing no extensions. A falsepositive (two genuinely identical pages) costs a fallback, not an error.
The design does not assume the offset ceiling a priori in a way that can
break silently.
max_offsetis declared per service onOgcDialect, defaultNone, andNonemeans "don't use offsets at all" — so the conservative pathis what an undeclared service gets. If USGS lowers the ceiling, requests past
the new limit fail loudly with the existing typed HTTP error rather than
truncating. If they raise or remove it, the current value just leaves some
speed on the table.
What could still regress: a server that honors
offsetinconsistentlyacross pages (rather than not at all) would slip past a check that samples the
first wave. Nothing in the API's behavior suggests that, and the alternative —
validating every page against its neighbors — would mean holding the whole
result to compare it. Flagging it as the known gap rather than papering over it.
What was removed
parallel_chunks(n)(public,dataretrievalandwaterdata)ChunkPlan.max_chunksTypeError.ChunkPlan._refine()Byte-driven chunking is untouched and remains a correctness requirement —
the OGC edge WAF caps request bytes at ~8200 and returns HTTP 414 above it. Only
the parallelism half of the chunker is gone.
tests/waterdata_chunking_test.pynow pins that split explicitly:
test_byte_driven_chunking_survives_the_removaland
test_unchunkable_still_raised_without_the_dial.Migration
API_USGS_CONCURRENTnow bounds the page-fetch wave width as well assub-request fan-out — one env var for everything in flight.
API_USGS_CONCURRENT=1pages strictly sequentially, via standard cursors (not offsets with a wave of
one).
unboundedis clamped to a finite width here, because a wave isspeculative and an unbounded one would issue arbitrarily many past-the-end
requests to find a single short page.
Testing
ruff checkclean,ruff format --checkclean,mypy dataretrieval/clean on43 files,
pytest tests/671 passed.tests/transport_test.py) for the service-neutralwalk: each stop condition, no gap or overlap across waves, the ceiling
hand-off, warn-and-truncate without a continuation, refusing a server that
ignores
offset, accepting distinct equal-length pages, offset clipping, andpage-failure wrapping.
tests/waterdata_offset_paging_test.py) throughget_daily, fully mocked. These exist becauseconftest.pypinsAPI_USGS_CONCURRENT=1suite-wide, so the existing 671 tests never touchedthe parallel path — the new feature had zero end-to-end coverage. The module
immediately earned its place: it caught the ceiling-seam bug described above,
where the tail walk was seeded one page too far and would have failed a real
deep pull with HTTP 400.
Disclosed as incomplete: a head-to-head of offset paging against the removed
_refinefan-out on a large real multi-site pull was started and neverfinished — it ran into the 1,000-request anonymous quota (HTTP 429,
retry-after: 290). The two measurements in the table above are complete andrepeated; that third comparison is not, and is not being claimed.
Docs
README.mdanddocs/source/userguide/errors.rstreplace theirparallel_chunkssections with "large downloads are paged in parallelautomatically", covering quota neutrality, the
API_USGS_CONCURRENT=1escapehatch, and both fallbacks. ADR 0006 gains the second page-walk strategy — why
both live in transport, and why the strategy choice is a dialect decision rather
than a transport one.
NEWS.mdcarries the breaking-change entry.🤖 Generated with Claude Code