Skip to content

Commit c4d336d

Browse files
thodson-usgsclaude
andcommitted
refactor(waterdata): tidy parallel_chunks (rename param, align validation, 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>
1 parent 115a230 commit c4d336d

3 files changed

Lines changed: 40 additions & 54 deletions

File tree

dataretrieval/ogc/chunking.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373

7474
import asyncio
7575
import functools
76+
import numbers
7677
import os
7778
from collections.abc import Callable, Iterator
7879
from contextlib import contextmanager
@@ -258,8 +259,9 @@ def parallel_chunks(n: int) -> Iterator[None]:
258259
--------
259260
ChunkPlan._refine : the planning-side effect of ``n``.
260261
"""
261-
# ``bool`` is an ``int`` subclass but nonsensical here; reject it explicitly.
262-
if not isinstance(n, int) or isinstance(n, bool) or n < 1:
262+
# Accept any integer type (incl. numpy ints, mirroring ``max_rows``); ``bool``
263+
# is an ``Integral`` subclass but nonsensical here, so reject it explicitly.
264+
if not isinstance(n, numbers.Integral) or isinstance(n, bool) or n < 1:
263265
raise ValueError(
264266
f"parallel_chunks(n): n must be a positive integer (e.g. 2, 8, 32); "
265267
f"got {n!r}."
@@ -739,7 +741,7 @@ def wrapper(
739741
# here up front, so a later resume — which re-issues the
740742
# already-planned sub-requests — needs no snapshot.
741743
plan = ChunkPlan(
742-
args, build_request, limit, max_chunks_per_axis=_parallel_chunks.get()
744+
args, build_request, limit, max_chunks=_parallel_chunks.get()
743745
)
744746
retry_policy = RetryPolicy.from_env()
745747
# The concurrency cap is resolved inside ``resume()`` from

dataretrieval/ogc/planning.py

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -329,11 +329,11 @@ class ChunkPlan:
329329
url_limit : int
330330
Byte budget for the request (URL + body) — a hard ceiling every
331331
sub-request must fit.
332-
max_chunks_per_axis : int, optional
332+
max_chunks : int, optional
333333
Soft cap on the plan's total sub-request count (default ``0`` = off).
334334
``0`` chunks only as much as ``url_limit`` requires — the most
335335
conservative plan, fewest sub-requests. A positive cap fans the plan
336-
out to up to ``max_chunks_per_axis`` sub-requests overall (the
336+
out to up to ``max_chunks`` sub-requests overall (the
337337
cartesian product across axes, never fewer than the byte budget
338338
already forces) — capped as a whole, not per axis, so several
339339
multi-value axes can't multiply past the cap. Set from the
@@ -370,7 +370,7 @@ def __init__(
370370
args: dict[str, Any],
371371
build_request: Callable[..., httpx.Request],
372372
url_limit: int,
373-
max_chunks_per_axis: int = 0,
373+
max_chunks: int = 0,
374374
) -> None:
375375
self.args = args
376376
self.axes: list[_Axis] = []
@@ -423,9 +423,9 @@ def __init__(
423423
# A request that already fits and hasn't opted into finer chunking is
424424
# the common passthrough: leave ``axes``/``chunks`` empty so
425425
# ``total == 1`` and ``iter_sub_args`` yields the original args
426-
# verbatim. Only when ``max_chunks_per_axis`` asks for extra fan-out do
426+
# verbatim. Only when ``max_chunks`` asks for extra fan-out do
427427
# we set the axes up to be refined below.
428-
if fits and max_chunks_per_axis <= 0:
428+
if fits and max_chunks <= 0:
429429
return
430430

431431
self.axes = axes
@@ -436,8 +436,8 @@ def __init__(
436436
self._plan(build_request, url_limit)
437437
# Soft pass: optionally split further than the byte budget requires.
438438
# Purely additive — never re-raises, and the byte budget stays
439-
# satisfied; a no-op at ``max_chunks_per_axis <= 0``.
440-
self._refine(max_chunks_per_axis)
439+
# satisfied; a no-op at ``max_chunks <= 0``.
440+
self._refine(max_chunks)
441441

442442
if self.canonical_url is None:
443443
# Original URL was un-constructable (httpx.InvalidURL); fall
@@ -491,15 +491,15 @@ def _plan(
491491
)
492492
_split_at(self.chunks[biggest_axis.arg_key], biggest_idx)
493493

494-
def _refine(self, max_chunks_per_axis: int) -> None:
494+
def _refine(self, max_chunks: int) -> None:
495495
"""
496496
Fan the plan out more finely than the byte budget alone requires —
497497
the parallel_chunks dial (see
498498
:func:`~dataretrieval.ogc.chunking.parallel_chunks` for why a caller
499499
would want this).
500500
501501
Caps the plan's *total* sub-request count (:attr:`total`, the
502-
cartesian product across all axes) at ``max_chunks_per_axis``, not
502+
cartesian product across all axes) at ``max_chunks``, not
503503
each axis independently — with several multi-value axes, a cap of 32
504504
still means at most 32 sub-requests overall, not ``32 ** n_axes``.
505505
Each split picks the single largest splittable chunk across *every*
@@ -508,20 +508,19 @@ def _refine(self, max_chunks_per_axis: int) -> None:
508508
before another is touched. Purely additive — only ever *splits*
509509
existing chunks, so the byte pass's work and the ``url_limit``
510510
invariant are both preserved, and it never raises. A no-op at
511-
``max_chunks_per_axis <= 0``.
511+
``max_chunks <= 0``.
512512
513513
Parameters
514514
----------
515-
max_chunks_per_axis : int
515+
max_chunks : int
516516
Soft cap on the plan's total sub-request count (``0`` = off) — the
517-
``parallel_chunks(n)`` value. The parameter name is legacy: the cap
518-
is on the whole plan (the cartesian product across axes), which
519-
matches "sub-chunks per argument" only in the common single-axis
520-
case — multi-axis plans are capped on the product, not per axis.
517+
``parallel_chunks(n)`` value. The cap is on the whole plan (the
518+
cartesian product across axes), not per axis, so several multi-value
519+
axes can't multiply past it.
521520
"""
522-
if max_chunks_per_axis <= 0:
521+
if max_chunks <= 0:
523522
return
524-
while self.total < max_chunks_per_axis:
523+
while self.total < max_chunks:
525524
# Largest splittable chunk across every axis; a chunk of size 1
526525
# can't be split further. ``max`` with a stable input order
527526
# breaks ties by axis order, then lowest index within an axis.

tests/waterdata_chunking_test.py

Lines changed: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2115,30 +2115,30 @@ async def fetch(args):
21152115
# ``parallel_chunks`` context manager). ``_fake_build``'s base is 200 bytes, so
21162116
# a handful of short atoms sits far under ``url_limit=8000`` — the byte pass
21172117
# passes it through untouched, and any splitting below is the ``n`` cap alone.
2118-
# ``ChunkPlan`` takes the integer cap (``max_chunks_per_axis``) directly;
2118+
# ``ChunkPlan`` takes the integer cap (``max_chunks``) directly;
21192119
# ``parallel_chunks(n)`` publishes ``n`` onto it. The cap bounds the plan's
21202120
# *total* sub-request count (the cartesian product across axes), not each axis
21212121
# independently — see ``test_cap_caps_the_total_across_axes``.
21222122
# ---------------------------------------------------------------------------
21232123

21242124

21252125
def test_zero_cap_preserves_passthrough():
2126-
"""``max_chunks_per_axis=0`` (the default) must not perturb the existing
2126+
"""``max_chunks=0`` (the default) must not perturb the existing
21272127
plan: a multi-value request that fits the byte limit is still the trivial
21282128
passthrough (no axes, ``total == 1``), byte-for-byte the pre-feature
21292129
behavior."""
21302130
args = {"monitoring_location_id": ["A", "B", "C", "D"]}
2131-
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks_per_axis=0)
2131+
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks=0)
21322132
assert plan.axes == []
21332133
assert plan.total == 1
21342134
assert list(plan.iter_sub_args()) == [args]
21352135

21362136

21372137
@pytest.mark.parametrize(
2138-
("max_chunks_per_axis", "expected_pieces"),
2138+
("max_chunks", "expected_pieces"),
21392139
[(0, 1), (2, 2), (8, 8), (16, 10), (32, 10)],
21402140
)
2141-
def test_cap_ramps_then_saturates(max_chunks_per_axis, expected_pieces):
2141+
def test_cap_ramps_then_saturates(max_chunks, expected_pieces):
21422142
"""A single 10-atom axis that fits the byte limit splits into
21432143
``min(10, cap)`` pieces: 1 (off), 2, 8, then saturating at 10 (one atom per
21442144
chunk) once the cap overshoots the atom count. Monotonic and bounded, and
@@ -2149,10 +2149,10 @@ def test_cap_ramps_then_saturates(max_chunks_per_axis, expected_pieces):
21492149
{"monitoring_location_id": atoms},
21502150
_fake_build,
21512151
url_limit=8000,
2152-
max_chunks_per_axis=max_chunks_per_axis,
2152+
max_chunks=max_chunks,
21532153
)
21542154
assert plan.total == expected_pieces
2155-
if max_chunks_per_axis:
2155+
if max_chunks:
21562156
flattened = [
21572157
a for chunk in plan.chunks["monitoring_location_id"] for a in chunk
21582158
]
@@ -2170,7 +2170,7 @@ def test_cap_bounds_fan_out_for_a_long_axis():
21702170
{"monitoring_location_id": atoms},
21712171
_fake_build,
21722172
url_limit=8000,
2173-
max_chunks_per_axis=high,
2173+
max_chunks=high,
21742174
)
21752175
assert plan.total == high
21762176
flattened = [a for chunk in plan.chunks["monitoring_location_id"] for a in chunk]
@@ -2184,9 +2184,9 @@ def test_cap_below_byte_split_does_not_reduce_fan_out():
21842184
# Heavy axis of four 30-char atoms; a limit tight enough that the byte pass
21852185
# must drive every atom into its own sub-request (4 pieces > the cap of 2).
21862186
args = {"monitoring_location_id": ["X" * 30, "Y" * 30, "Z" * 30, "W" * 30]}
2187-
baseline = ChunkPlan(args, _fake_build, url_limit=250, max_chunks_per_axis=0)
2187+
baseline = ChunkPlan(args, _fake_build, url_limit=250, max_chunks=0)
21882188
assert baseline.total > 2 # byte pass alone already fanned out past 2
2189-
refined = ChunkPlan(args, _fake_build, url_limit=250, max_chunks_per_axis=2)
2189+
refined = ChunkPlan(args, _fake_build, url_limit=250, max_chunks=2)
21902190
# cap 2 < baseline pieces → refine is a no-op here.
21912191
assert refined.total == baseline.total
21922192

@@ -2197,8 +2197,8 @@ def test_cap_never_exceeds_the_byte_budget():
21972197
a chunk), and the fan-out is at least what the byte pass required."""
21982198
args = {"monitoring_location_id": ["X" * 30, "Y" * 30, "Z" * 30, "W" * 30]}
21992199
limit = 310
2200-
byte_only = ChunkPlan(args, _fake_build, url_limit=limit, max_chunks_per_axis=0)
2201-
plan = ChunkPlan(args, _fake_build, url_limit=limit, max_chunks_per_axis=32)
2200+
byte_only = ChunkPlan(args, _fake_build, url_limit=limit, max_chunks=0)
2201+
plan = ChunkPlan(args, _fake_build, url_limit=limit, max_chunks=32)
22022202
assert plan.total >= byte_only.total
22032203
for sub in plan.iter_sub_args():
22042204
assert _safe_request_bytes(_fake_build, sub, limit) <= limit
@@ -2210,7 +2210,7 @@ def test_cap_refines_the_filter_axis():
22102210
into ``min(N, cap)`` pieces."""
22112211
clauses = [f"p='{i}'" for i in range(8)]
22122212
args = {"filter": " OR ".join(clauses)}
2213-
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks_per_axis=4)
2213+
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks=4)
22142214
assert len(plan.chunks["filter"]) == 4 # min(8, 4)
22152215
assert plan.total == 4
22162216

@@ -2225,7 +2225,7 @@ def test_cap_caps_the_total_across_axes():
22252225
"monitoring_location_id": [f"L{i}" for i in range(6)],
22262226
"parameter_code": [f"{i:05d}" for i in range(6)],
22272227
}
2228-
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks_per_axis=4)
2228+
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks=4)
22292229
assert plan.total == 4
22302230
# Every atom on every axis is still covered exactly once.
22312231
for key, atoms in (
@@ -2249,7 +2249,7 @@ def test_cap_bounds_fan_out_across_many_axes():
22492249
"parameter_code": [f"{i:05d}" for i in range(10)],
22502250
"filter": " OR ".join(f"p='{i}'" for i in range(10)),
22512251
}
2252-
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks_per_axis=high)
2252+
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks=high)
22532253
assert plan.total <= high
22542254

22552255

@@ -2259,7 +2259,7 @@ def test_cap_does_not_mask_unchunkable():
22592259
act on and must not swallow the hard failure."""
22602260
args = {"monitoring_location_id": "one-huge-scalar"}
22612261
with pytest.raises(Unchunkable):
2262-
ChunkPlan(args, _fake_build, url_limit=10, max_chunks_per_axis=32)
2262+
ChunkPlan(args, _fake_build, url_limit=10, max_chunks=32)
22632263

22642264

22652265
def test_parallel_chunks_publishes_n_on_the_ambient():
@@ -2298,22 +2298,6 @@ def test_parallel_chunks_rejects_non_positive_int(bad):
22982298
assert _parallel_chunks.get() == 0
22992299

23002300

2301-
def test_parallel_chunks_n1_is_no_extra_fan_out():
2302-
"""``n=1`` is the explicit no-op: an under-limit request stays a single
2303-
passthrough call, exactly as if the block weren't entered."""
2304-
sites = [f"S{i:02d}" for i in range(8)]
2305-
calls: list[int] = []
2306-
2307-
@multi_value_chunked(build_request=_fake_build, url_limit=8000)
2308-
async def fetch(args):
2309-
calls.append(len(args["monitoring_location_id"]))
2310-
return pd.DataFrame(), _ok_response()
2311-
2312-
with parallel_chunks(1):
2313-
fetch({"monitoring_location_id": sites})
2314-
assert calls == [8] # one passthrough call carrying all sites
2315-
2316-
23172301
def test_parallel_chunks_drives_end_to_end_fan_out():
23182302
"""End-to-end: the same fitting request passes through as a single call by
23192303
default, but fans into ``n`` sub-requests inside a ``parallel_chunks(n)``
@@ -2344,10 +2328,11 @@ async def fetch(args):
23442328
assert sorted(df_fine["site"]) == sorted(sites)
23452329

23462330

2347-
@pytest.mark.parametrize("n", [2, 3, 8])
2331+
@pytest.mark.parametrize("n", [1, 2, 3, 8])
23482332
def test_parallel_chunks_supports_arbitrary_n(n):
23492333
"""An arbitrary ``n`` (not only 2/8/32) fans an under-limit request into
2350-
exactly ``n`` sub-requests, together covering every site once."""
2334+
exactly ``n`` sub-requests, together covering every site once — including
2335+
``n=1``, the explicit no-op that stays a single passthrough call."""
23512336
sites = [f"S{i:02d}" for i in range(8)]
23522337
calls: list[int] = []
23532338

0 commit comments

Comments
 (0)