Skip to content

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

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

feat(waterdata): add parallel_chunks to control OGC chunk fan-out#341
thodson-usgs wants to merge 10 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 7 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>
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