Skip to content

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

Draft
thodson-usgs wants to merge 5 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 5 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 2 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>
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