feat(transport)!: page large results in parallel by offset; remove parallel_chunks - #357
Open
thodson-usgs wants to merge 3 commits into
Open
feat(transport)!: page large results in parallel by offset; remove parallel_chunks#357thodson-usgs wants to merge 3 commits into
thodson-usgs wants to merge 3 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>
…result Two defects found by probing the shipped defaults rather than the pinned test values. Connection pool. Sub-requests are gated by a semaphore sized to API_USGS_CONCURRENT, and the pool was sized to match. But each sub-request now fans its own pages out by offset, and those pages are deliberately ungated (a sub-request holds its permit for the whole attempt, so its own pages would deadlock waiting on it). Peak in-flight is therefore the *product* of the two fan-outs -- measured at 16 against a pool of 8 -- so the excess queued inside httpx against the 60s pool-acquire timeout. That is the exact spurious-timeout failure ChunkedCall._run's docstring warns about, and it showed up as a mid-walk ReadError and a resumable ServiceInterrupted in a live sweep at width=8. The pool is now sized to max_concurrent * page_concurrency(). Empty results. A query matching nothing returned one empty page, which the walk correctly discards as past-the-end -- leaving it with no response to report, so it fell through to its "issued no requests" guard and raised. The sequential walk returns an empty frame for the same query, and "a no-data result is not an error" is a documented promise of the modern getters, so this was a behavior regression reachable by any typo'd site id. The metadata response is now seeded before the keep/discard split. 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 — at a fixed
limitthe request count is essentially unchanged, only the timing. Since theUSGS quota is volume-based (
x-ratelimit-limit, default 1000/hr), overlappingpages costs no extra quota. See the caveat below on
limit: the speedup is realbut it is not available at the default page size.
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 (single site, full daily history,
limit=2000):API_USGS_CONCURRENT=1(sequential cursor walk)=4=16=32Important caveat, and the main thing to review. Those numbers use
limit=2000, passed explicitly. They do not describe default behavior.The offset stride must equal
limit(otherwise pages gap or overlap), and theAPI's ceiling is
offset <= 40000— a ceiling in rows, not pages. The defaultlimitis 50,000. So at defaults the only legal offset is0: the walk fetchesone page and hands the entire rest of the query to the sequential cursor tail.
Measured on mocks, a 200,000-row pull — how many requests get overlapped versus
walked one at a time:
limitSo this feature helps materially only when
limitis well below 40,000, and asmaller
limitmeans more requests — which the volume-based quota does chargefor. The "no extra quota" claim is true at a fixed
limit; it is not a claimthat shrinking
limitto buy parallelism is free. Reviewers should decidewhether the getters ought to pick a paging-friendly
limitthemselves (e.g.20,000, trading 10 requests for 4 overlapped instead of 2) rather than leaving
that to the caller. I did not make that change here — it alters default request
counts for every user, which is a policy call, not an implementation detail.
The single-site case is still the one with no prior answer:
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
no knob 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, and why the wave width ramps —
1 request, then 2, then 4, doubling up to the
API_USGS_CONCURRENTcap. A flatwave at the default width would make a one-page result cost 32 requests to
discover it was already finished; since the quota is volume-based, that is a
straight 32x tax on exactly the queries with nothing to gain from parallelism.
The ramp gives three properties:
sequential walk, so small queries pay nothing for this feature;
doubling every prior wave summed is less than the current one;
waves, not 10 serialized round trips.
Measured at the default width (
_CONCURRENCY_DEFAULT = 32):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. Pages after it are dropped, which is what makes a
speculative overshoot harmless — an overshooting request was already paid for in
parallel with the pages that mattered, and its rows are never concatenated.
Result: 25 rows from 3 requests in 2 round trips. The ramp happens to land
exactly on the three pages that exist, so parallel paging here costs the same
requests the sequential walk would have spent — it just spends them in 2 round
trips instead of 3.
tests/waterdata_offset_paging_test.py::test_page_count_is_not_inflated_by_parallelismpins that count.
Returning an index rather than a bool is what makes the earliest terminal page
win: if a wave contains both a short page and a later page that also looks
terminal, the earlier one ends the walk and no page between them can resurrect
it.
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, widening as long as every page comes
back full.
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.
Three bugs found while reviewing this, and fixed here
Worth reading, because each one was invisible to the tests as originally written
and each was found by probing the shipped defaults instead of the pinned test
values.
1. A one-page query cost 21 requests. The wave width started at the full
API_USGS_CONCURRENT(32), clipped to the ceiling — so a small query fired 21requests to discover it was already finished, a 21x quota tax on exactly the
queries with nothing to gain. Nothing caught it because the conftest pins
API_USGS_CONCURRENT=1and the new integration tests pinned4; no testexercised the default. Fixed by the ramp described above (1 request → 1). The
regression test deliberately deletes the env var rather than setting a number.
2. Peak in-flight requests exceeded the connection pool. Sub-requests are
gated by a semaphore sized to
API_USGS_CONCURRENT, and the pool was sized tomatch — but each sub-request now fans its own pages out, and those pages are
deliberately ungated (a sub-request holds its permit for its whole attempt, so
its own pages would deadlock waiting on it). Peak in-flight is the product:
measured 16 against a pool of 8. The excess queued inside httpx against the 60 s
pool-acquire timeout — the exact spurious-timeout failure
ChunkedCall._run'sown docstring warns about. It surfaced in the live sweep as a mid-walk
ReadErrorand a resumableServiceInterruptedatwidth=8. The pool is nowsized to the product.
3. A no-data query raised instead of returning an empty frame. The single
empty page was correctly discarded as past-the-end, which left the walk with no
response to report, so it fell through to its "issued no requests" guard and
raised
DataRetrievalError. The sequential walk returns an empty frame for thesame query, and "a no-data result is not an error" is a documented promise of
the modern getters — so this was a behavior regression reachable by any typo'd
site id or unmeasured parameter code.
Testing
ruff checkclean,ruff format --checkclean,mypy dataretrieval/clean on43 files,
pytest tests/676 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 tests never touched theparallel path — the feature had zero end-to-end coverage. The module earned
its place four times over: it caught the ceiling-seam bug (tail walk seeded one
page past the ceiling, which would have failed a real deep pull with HTTP 400)
plus the three bugs above. Several of these tests assert at the default
width specifically, since that was the blind spot.
sub-requests × pages.
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