feat(waterdata): add parallel_chunks to control OGC chunk fan-out#341
Draft
thodson-usgs wants to merge 10 commits into
Draft
feat(waterdata): add parallel_chunks to control OGC chunk fan-out#341thodson-usgs wants to merge 10 commits into
thodson-usgs wants to merge 10 commits into
Conversation
thodson-usgs
force-pushed
the
feat/chunk-granularity
branch
2 times, most recently
from
July 1, 2026 15:12
ec8269d to
b47e5cc
Compare
The OGC getters chunk a multi-value request only as far as the server's ~8 KB URL limit forces — the fewest sub-requests. But because every sub-request paginates, splitting a large result further is usually quota-neutral, so that conservative default can be needlessly coarse: ten states pulled as one under-limit request page just as many times as ten per-state requests would. Add `waterdata.chunk_granularity(level)`, a context manager that lets a caller who knows their pull is large opt into a finer split — trading the same pages for more, smaller sub-requests (smoother progress, more even concurrency, a smaller unit of retry/resume). The level is "low", "medium", or "high" (typed as `GranularityLevel`, a Literal, so a type checker rejects anything else; an invalid string raises ValueError at the `with`). Each level caps how many sub-chunks a multi-value argument is split into, derived from the default fan-out concurrency (`API_USGS_CONCURRENT`): high = the full width, medium a quarter, low a sixteenth (32 / 8 / 2 by default). Capping the aggressive end at the concurrency width bounds the blast radius so an accidental "high" on a huge list can't explode into thousands of sub-requests. There is no "off" level — not entering the block is off. It is a scoped `with` block, not an env var, because the library can't tell in advance whether a query is large (a short-window query might fit one page, where extra chunks only burn quota). Implementation: a soft `ChunkPlan._refine` pass runs after the hard byte pass; it only ever splits further, so the url_limit invariant holds and it never raises. The resolved per-axis cap is read from a contextvar (Ambient) set by the context manager at plan-construction time. Exported (with the `GranularityLevel` type) from `dataretrieval.waterdata` and the top-level `dataretrieval` package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thodson-usgs
force-pushed
the
feat/chunk-granularity
branch
from
July 1, 2026 16:25
b47e5cc to
0195113
Compare
…t + benchmark Cap chunk_granularity on the plan's TOTAL sub-request count (2/8/32) rather than per multi-value axis, so several multi-value arguments can't multiply past the ceiling. ChunkPlan._refine now splits the largest splittable chunk across every axis round-robin; behavior is identical for the common single-axis query. Add a 'Speeding up large downloads' usage section to the README (Water Data API) with a measured cold-cache benchmark: parallelizing a large paginated pull's sub-requests gave ~6x (production page size, cold) up to ~12x (latency-bound) on 271 Ohio discharge sites. Temper the 'quota-neutral' claim in the docstring, NEWS, and user guide: a finer split is only ~quota-neutral when each sub-request still spans many pages; otherwise each chunk's partial final page adds some requests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the public context manager chunk_granularity to parallel_chunks and the GranularityLevel type to ParallelChunksLevel (both exported from dataretrieval and dataretrieval.waterdata), plus internals (_resolve_level, _MAX_PARALLEL_CHUNKS, _LEVEL_CAPS, the _parallel_chunks ambient). 'parallel_chunks' names what the knob does — fan a query into more, parallel sub-requests — without colliding with API_USGS_CONCURRENT (the separate in-flight cap). Docstrings, NEWS, user guide, README, and tests updated to match. Pure rename; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dium/high Replace the three-tier "low"/"medium"/"high" enum with a plain positive integer: parallel_chunks(n) fans a call out into n sub-requests. More expressive (any n, not just 2/8/32), precise, and mirrors the int-valued API_USGS_CONCURRENT. Removes ParallelChunksLevel, the _LEVEL_CAPS/_MAX_PARALLEL_CHUNKS constants, and _resolve_level; validation is now an inline positive-int check (rejects 0, negatives, floats, bool, str). n is bounded below by the byte-limit minimum and above by the number of values to split; n=1 is an explicit no-op. Docstrings, NEWS, user guide, README, exports, and tests updated; 2/8/32 remain as documented examples. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion, dedup test) /simplify cleanup: (1) rename ChunkPlan's max_chunks_per_axis -> max_chunks — the cap is on the plan's TOTAL sub-request count, not per axis, so the old name needed apologetic docstrings; dropped them. (2) Validate parallel_chunks(n) with numbers.Integral (matching the max_rows guard in engine.py), so numpy integers are accepted like the sibling validator. (3) Fold the n=1 no-op test into the parametrized arbitrary-n test, removing a duplicated fetch fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Experiments to determine safe parallelism levels for the USGS API. Results: 20+ trials at n=8/16/32 with 10s sleep between queries show zero hangs across all levels. n=16 shows the best avg response time. The only failure mode is proper HTTP 429 when quota is exhausted.
…_refine _refine grew the plan with `while self.total < max_chunks`, but with more than one axis a single split multiplies total by (k+1)/k for the split axis — it adds the product of the *other* axes, not one — so total could step *past* the cap (two 8-atom axes at cap 10 landed on 12; three 10-atom axes at cap 30 landed on 32). That broke the documented "at most n / can't multiply past it" guarantee the dial exists to provide. Take a split only when it keeps total within the cap: skip any axis whose split would overshoot (the increment is per-axis, total // k). The cap is now a true ceiling — never exceeded — and the plan lands below it when no whole split hits it exactly (two even axes reach 4, not 5). Single-axis plans are unchanged (still hit n exactly, or saturate at one atom/chunk). Docstrings updated (soft cap -> hard ceiling; note the may-undershoot). Repurpose the many-axes test to a cap the old loop overshot (30, was 32) and add a parametrized regression over the 2-axis overshoot combos. Signed-off-by: thodson-usgs <thodson@usgs.gov> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h, negative raises)
Two edges from the SOLID review of the parallel_chunks dial:
- max_chunks=1 ("no extra fan-out") took the fan-out path: on a fitting
request it materialized axes and re-rendered them through iter_sub_args
before _refine(1) no-oped, instead of the verbatim passthrough. Widen the
passthrough guard to `max_chunks <= 1` so 0 (off) and 1 (no fan-out) both
short-circuit to the trivial plan.
- A negative max_chunks silently no-oped. It can only be a caller bug (the
public parallel_chunks(n) already rejects n < 1), so raise ValueError at
construction rather than mask it.
Docstring + Raises updated; adds a unit passthrough test for max_chunks=1
and a negative-raises test.
Signed-off-by: thodson-usgs <thodson@usgs.gov>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…f, reject 0)
Follow-up to the parallel_chunks contract cleanup. max_chunks is a
sub-request *count*, so its valid domain is the positive integers. Move the
"off" sentinel from 0 to 1 (which already behaves identically — no extra
fan-out, per the earlier n=1-passthrough change) and reject anything below 1:
- ChunkPlan.max_chunks default 0 -> 1; guard `< 0` -> `< 1`, so 0 and
negatives now raise ValueError instead of silently no-oping.
- _parallel_chunks ambient default 0 -> 1; read-site + comments updated.
- _refine off-guard `<= 0` -> `<= 1` (clearer intent; the loop already
no-oped at 1).
- Docstrings (class param, Raises, _refine, ambient) updated.
Tests: zero-cap passthrough -> default/unit passthrough; negative-raises ->
invalid-raises parametrized over {0, -1}; ramp param (0,1) -> (1,1) with the
coverage guard keyed on expected_pieces; the two byte-only baselines and the
ambient-default assertions move 0 -> 1. The public parallel_chunks(n) already
rejected n < 1, so no user-facing behavior changes.
Signed-off-by: thodson-usgs <thodson@usgs.gov>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chunks
From the xhigh workflow review of the parallel_chunks branch:
- [5] Extract the duplicated positive-integer validation (numbers.Integral +
bool-exclusion + < 1) into utils._require_positive_int; call it from both
max_rows (engine) and parallel_chunks (chunking) so the two count knobs
can't drift. Drops the now-unused `import numbers` from both. ChunkPlan's
own `< 1` guard keeps its domain-specific "1 disables fan-out" message.
- [1,3] Document the consequences of fanning out a fitting request in the
parallel_chunks docstring (new Notes section): with max_rows the result is
drawn from the union of the sub-requests (a different, still-valid, sorted
row set than the un-fanned call); a fanned-out call can fail partway and
become resumable; cross-chunk dedup keys on id. These are the same caveats
byte-forced chunking already carries — the block just extends their reach.
- [6] Collapse the triplicated cap-contract prose in planning.py: the
ChunkPlan.max_chunks param docstring is the single authority; _refine now
documents only its algorithm and cross-references the contract.
No behavior change. Messages preserve the substrings the tests match
("positive integer"); numpy-int caps still accepted, bool/float/str/< 1
still rejected.
Signed-off-by: thodson-usgs <thodson@usgs.gov>
Co-Authored-By: Claude Opus 4.8 (1M context) <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
Adds
waterdata.parallel_chunks(n)— a context manager to control how finely the OGCwaterdata(and NGWMN) getters split multi-value requests into chunked sub-requests: it fans a call out intonparallel sub-requests.Today the chunker splits a request only as much as the server's ~8 KB URL-byte limit forces — the fewest sub-requests. That is the safe default, but it can be needlessly conservative. Because every sub-request paginates, splitting a large result further costs little or no extra quota as long as each sub-request still spans many pages: ten states pulled as one request then page nearly as many times as ten per-state requests would. In that situation finer chunks buy smoother progress, more even concurrency, and a smaller unit of retry/resume. (When a split instead leaves each sub-request only a page or two, its partial final page is extra — so on a smaller pull finer chunks do add some requests; reserve a large
nfor pulls you know are large.)The library can't tell in advance whether a query is large (ten states over a short window might fit in a single page, where extra chunks would only burn quota), so this is a deliberate, scoped knob the user sets with their own judgment — not automatic, and not a process-wide env var (which would be a quota footgun). Scoping it to a
withblock keeps an aggressive setting from leaking into unrelated calls.Measured speedup (271 Ohio discharge sites,
get_daily, cold cache, eachnon its own time window): a large paginated pull ran ~6× faster at the production page size and up to ~12× faster when per-request latency dominates — the fan-out parallelizes what is otherwise sequential pagination. See the new README section for the table.The dial
parallel_chunks(n)takes a positive integer — the number of sub-requests to fan the whole call out into.2,8, and32are typical values; a non-integer, non-positive value, or aboolraisesValueErrorat thewith.ncaps the plan's total sub-request count — the cartesian product across every multi-value argument combined, not each argument independently — so several multi-value arguments can't multiply past it. The actual count is bounded below by what the ~8 KB URL limit already forces and above by how many values there are to split (so annlarger than the input allows just yields one sub-request per value);n=1asks for no extra fan-out. There is no"off"— not entering the block is off.Cost and the useful range. Each sub-request fetches at least one page, so it costs at least one request against your hourly rate limit — a larger
nspends more quota. And how many sub-requests run at once is capped separately byAPI_USGS_CONCURRENT(default 32), so annbeyond that adds quota without adding parallelism. The useful range is therefore roughly2up toAPI_USGS_CONCURRENT. Fan-out volume and simultaneity stay deliberately independent controls.Exported as
waterdata.parallel_chunksand, for parity withChunkInterrupted, at the top level asdataretrieval.parallel_chunks.Implementation
ChunkPlan._refine(max_chunks)— a soft pass that runs after the existing hard byte pass (_plan). It splits the largest splittable chunk across every axis (round-robin) until the plan's total sub-request count reaches the cap, only ever splitting further (via the shared_split_atprimitive), so theurl_limitinvariant always holds and it never raises. A no-op at cap 0, so the default path is byte-for-byte unchanged (passthrough preserved).nis read from anAmbient(contextvar) set by the context manager, at plan-construction time insidemulti_value_chunked's wrapper — so a laterresume()(which re-issues already-planned sub-requests) needs no extra snapshot.parallel_chunks(n)validatesninline (a positiveint, rejectingbool/float/str/None) and publishes it on the ambient;ChunkPlanreads that plain int asmax_chunks. There is no level→cap lookup table — the value the user passes is the cap.Tests & checks
tests/waterdata_chunking_test.py, plus an export-surface test; covers the cap→pieces ramp/saturation (with cover-partition checks), support for an arbitraryn(not just 2/8/32), the total-cap product bound across multiple multi-value axes, the guardrail on long axes, byte-budget preservation, filter-axis + multi-axis behavior,n-validation (rejecting0/negative/float/str/None/bool),n=1as an explicit no-op, context-manager scoping/nesting, and the passthrough-unchanged default.ruff check,ruff format --check, andmypy --strictall clean.Note
The dial has been through a few shapes: an
off/1–5/maxscale, a per-axis cap derived from the concurrency width, then a fixed"low"/"medium"/"high"enum — and is now a plain integern, the number of sub-requests to fan out into. The integer is more expressive than three arbitrary tiers, gives precise control, and mirrors the int-valuedAPI_USGS_CONCURRENT;2/8/32survive only as documented examples. The knob was also renamedchunk_granularity→parallel_chunks: "parallel_chunks" says what it does — fan a query into more, parallel sub-requests — without colliding withAPI_USGS_CONCURRENT, the separate in-flight cap. Still a draft.🤖 Generated with Claude Code