Skip to content

feat(waterdata): add parallel_chunks to control OGC chunk fan-out#341

Open
thodson-usgs wants to merge 14 commits into
DOI-USGS:mainfrom
thodson-usgs:feat/chunk-granularity
Open

feat(waterdata): add parallel_chunks to control OGC chunk fan-out#341
thodson-usgs wants to merge 14 commits into
DOI-USGS:mainfrom
thodson-usgs:feat/chunk-granularity

Conversation

@thodson-usgs

@thodson-usgs thodson-usgs commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds waterdata.parallel_chunks(n) — a context manager to control how finely the OGC waterdata (and NGWMN) getters split multi-value requests into chunked sub-requests: it fans a call out into n parallel 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 n for 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 with block keeps an aggressive setting from leaking into unrelated calls.

from dataretrieval import waterdata

# Default: chunk only as much as the URL limit needs.
df, md = waterdata.get_daily(monitoring_location_id=many_sites)

# Opt into a finer split for a pull you know is large:
with waterdata.parallel_chunks(32):
    df, md = waterdata.get_daily(
        monitoring_location_id=many_sites, parameter_code="00060"
    )

Measured speedup (271 Ohio discharge sites, get_daily, cold cache, each n on 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, and 32 are typical values; a non-integer, non-positive value, or a bool raises ValueError at the with.

with waterdata.parallel_chunks(32):      # fan out into 32 sub-requests
    df, md = waterdata.get_daily(
        monitoring_location_id=many_sites, parameter_code="00060"
    )

n caps 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 an n larger than the input allows just yields one sub-request per value); n=1 asks 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 n spends more quota. And how many sub-requests run at once is capped separately by API_USGS_CONCURRENT (default 32), so an n beyond that adds quota without adding parallelism. The useful range is therefore roughly 2 up to API_USGS_CONCURRENT. Fan-out volume and simultaneity stay deliberately independent controls.

Exported as waterdata.parallel_chunks and, for parity with ChunkInterrupted, at the top level as dataretrieval.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_at primitive), so the url_limit invariant always holds and it never raises. A no-op at cap 0, so the default path is byte-for-byte unchanged (passthrough preserved).
  • n is read from an Ambient (contextvar) set by the context manager, at plan-construction time inside multi_value_chunked's wrapper — so a later resume() (which re-issues already-planned sub-requests) needs no extra snapshot.
  • parallel_chunks(n) validates n inline (a positive int, rejecting bool/float/str/None) and publishes it on the ambient; ChunkPlan reads that plain int as max_chunks. There is no level→cap lookup table — the value the user passes is the cap.

Tests & checks

  • Parallel-chunks unit + end-to-end tests in tests/waterdata_chunking_test.py, plus an export-surface test; covers the cap→pieces ramp/saturation (with cover-partition checks), support for an arbitrary n (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 (rejecting 0/negative/float/str/None/bool), n=1 as an explicit no-op, context-manager scoping/nesting, and the passthrough-unchanged default.
  • ruff check, ruff format --check, and mypy --strict all clean.
  • NEWS.md, a userguide section, and a README usage section + benchmark table updated.

Note

The dial has been through a few shapes: an off/15/max scale, a per-axis cap derived from the concurrency width, then a fixed "low"/"medium"/"high" enum — and is now a plain integer n, 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-valued API_USGS_CONCURRENT; 2/8/32 survive only as documented examples. The knob was also renamed chunk_granularityparallel_chunks: "parallel_chunks" says what it does — fan a query into more, parallel sub-requests — without colliding with API_USGS_CONCURRENT, the separate in-flight cap. Still a draft.

🤖 Generated with Claude Code

@thodson-usgs
thodson-usgs force-pushed the feat/chunk-granularity branch 2 times, most recently from ec8269d to b47e5cc Compare July 1, 2026 15:12
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
thodson-usgs force-pushed the feat/chunk-granularity branch from b47e5cc to 0195113 Compare July 1, 2026 16:25
thodson-usgs and others added 2 commits July 8, 2026 15:51
…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>
@thodson-usgs thodson-usgs changed the title feat(waterdata): add chunk_granularity to control OGC chunk fan-out feat(waterdata): add parallel_chunks to control OGC chunk fan-out Jul 8, 2026
thodson-usgs and others added 10 commits July 8, 2026 19:03
…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>
Two documentation/comment fixes from the 4-angle cleanup review; no logic
change:

- utils._require_positive_int: soften the "count-like knobs ... can't drift"
  claim. ChunkPlan.max_chunks deliberately bypasses the helper (internal,
  already-int, keeps its own "1 disables fan-out" message), so scope the
  claim to the two user-facing boundary knobs (max_rows, parallel_chunks).
- ChunkPlan._refine: note that its ranking key is atom count (len), not URL
  bytes like _plan, because the fan-out pass balances work rather than
  fitting a byte budget.

Considered-but-skipped from the review: extracting a shared
_largest_splittable helper for the _plan/_refine scans (line-neutral, adds
callable indirection — the depth review judged it more indirection than it
removes); removing _refine's max_chunks<=1 early-return (redundant with the
while-guard but a clearer explicit "off" marker); routing ChunkPlan's <1
guard through _require_positive_int (different contract, better bespoke
message); two _refine micro-optimizations (not worth it at max_chunks<=32).

Signed-off-by: thodson-usgs <thodson@usgs.gov>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The threshold-sensitivity experiment harnesses and their captured .jsonl
logs were scratch tooling used to choose the parallel_chunks defaults; they
don't belong in the shipped library. Untrack them (kept locally, git-excluded)
so the PR carries only the feature, tests, and docs.

Signed-off-by: thodson-usgs <thodson@usgs.gov>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thodson-usgs
thodson-usgs marked this pull request as ready for review July 17, 2026 17:53
…verified numbers

- Remove the parallel_chunks NEWS.md entry (per maintainer request).
- Re-ran the README benchmark against the live API with a hardened,
  cache-cold methodology: a fixed 271-site subset of Ohio stream gages,
  fixed page size (limit=250), each n on its own distinct 1-year window so no
  run is served from the server's data-window cache (off on 2003/2005, the
  fanned runs on 2007/2009/2011 — no shared cache keys).

  The multi-fold speedup is real but the earlier figures were optimistic:
  off 9.3s -> n=8 ~2.0s (~4.5x) -> n=32 1.2s (~8x), vs the previously stated
  ~6x/~12x. Page counts (~30/32/54) match. Noted that the multiplier scales
  with page count. The old note said "271 Ohio discharge sites", but
  state="Ohio",site_type_code="ST" now returns 2889, so the benchmark is
  pinned to a reproducible 271-site subset.

Signed-off-by: thodson-usgs <thodson@usgs.gov>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant