Skip to content

Commit 2d6c4b9

Browse files
thodson-usgsclaude
andcommitted
fix(waterdata): make parallel_chunks cap a hard ceiling in ChunkPlan._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>
1 parent 28a23e2 commit 2d6c4b9

3 files changed

Lines changed: 87 additions & 31 deletions

File tree

dataretrieval/ogc/chunking.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,10 +224,13 @@ def parallel_chunks(n: int) -> Iterator[None]:
224224
integer such as ``2``, ``8``, or ``32``. It caps the plan's *total*
225225
sub-request count (the cartesian product across every multi-value
226226
argument combined, not per argument), so several multi-value arguments
227-
cannot multiply past it. The actual count is bounded below by what the
228-
~8 KB URL limit already forces and above by the number of values there
229-
are to split, so an ``n`` larger than the input allows simply yields one
230-
sub-request per value; ``n=1`` asks for no extra fan-out.
227+
cannot multiply past it. The cap is a ceiling, never exceeded: the
228+
actual count is bounded below by what the ~8 KB URL limit already
229+
forces and above by ``n``, so an ``n`` larger than the input allows
230+
simply yields one sub-request per value, and with several multi-value
231+
arguments the total may land somewhat below ``n`` because splits are
232+
whole (the plan can't always divide evenly onto ``n``); ``n=1`` asks
233+
for no extra fan-out.
231234
232235
Each sub-request fetches at least one page, so it costs at least one
233236
request against your hourly rate limit — a larger ``n`` spends more

dataretrieval/ogc/planning.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -330,15 +330,16 @@ class ChunkPlan:
330330
Byte budget for the request (URL + body) — a hard ceiling every
331331
sub-request must fit.
332332
max_chunks : int, optional
333-
Soft cap on the plan's total sub-request count (default ``0`` = off).
333+
Hard 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
336336
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
339-
multi-value axes can't multiply past the cap. Set from the
340-
:func:`~dataretrieval.ogc.chunking.parallel_chunks` ``n``; see
341-
:meth:`_refine`.
339+
multi-value axes can't multiply past the cap. The plan never exceeds
340+
the cap and may land below it when no whole split lands on it exactly.
341+
Set from the :func:`~dataretrieval.ogc.chunking.parallel_chunks` ``n``;
342+
see :meth:`_refine`.
342343
343344
Attributes
344345
----------
@@ -502,38 +503,58 @@ def _refine(self, max_chunks: int) -> None:
502503
cartesian product across all axes) at ``max_chunks``, not
503504
each axis independently — with several multi-value axes, a cap of 32
504505
still means at most 32 sub-requests overall, not ``32 ** n_axes``.
505-
Each split picks the single largest splittable chunk across *every*
506-
axis (ties broken by axis-extraction order, then lowest index), so
507-
growth is distributed round-robin rather than one axis saturating
508-
before another is touched. Purely additive — only ever *splits*
509-
existing chunks, so the byte pass's work and the ``url_limit``
510-
invariant are both preserved, and it never raises. A no-op at
511-
``max_chunks <= 0``.
506+
The cap is a hard ceiling: every split multiplies the plan by
507+
``(k+1)/k`` for the chosen axis (adding ``total // k`` sub-requests,
508+
not one), so a split is taken only when it keeps ``total`` within the
509+
cap. When no in-budget split remains the plan lands *below* the cap
510+
rather than overshooting it — a multi-axis plan may not divide evenly
511+
onto ``max_chunks`` (e.g. two even axes can reach 4 but not 5, so a
512+
cap of 5 yields 4). Each split picks the single largest splittable
513+
chunk among the in-budget axes (ties broken by axis-extraction order,
514+
then lowest index), so growth is distributed round-robin rather than
515+
one axis saturating before another is touched. Purely additive — only
516+
ever *splits* existing chunks, so the byte pass's work and the
517+
``url_limit`` invariant are both preserved, and it never raises. A
518+
no-op at ``max_chunks <= 0``.
512519
513520
Parameters
514521
----------
515522
max_chunks : int
516-
Soft cap on the plan's total sub-request count (``0`` = off) — the
523+
Hard cap on the plan's total sub-request count (``0`` = off) — the
517524
``parallel_chunks(n)`` value. The cap is on the whole plan (the
518525
cartesian product across axes), not per axis, so several multi-value
519-
axes can't multiply past it.
526+
axes can't multiply past it; the plan never exceeds it and may fall
527+
short when no whole split lands on it exactly.
520528
"""
521529
if max_chunks <= 0:
522530
return
523-
while self.total < max_chunks:
524-
# Largest splittable chunk across every axis; a chunk of size 1
525-
# can't be split further. ``max`` with a stable input order
526-
# breaks ties by axis order, then lowest index within an axis.
531+
while True:
532+
total = self.total
533+
if total >= max_chunks:
534+
return
535+
# Largest splittable chunk among the axes whose split still fits the
536+
# cap. Splitting any chunk of an axis with ``k`` chunks turns that
537+
# ``k`` into ``k+1``, so it adds ``total // k`` sub-requests (the
538+
# product of the other axes) regardless of which chunk — hence the
539+
# budget test is per axis, not per chunk. Skipping an over-budget
540+
# axis makes ``max_chunks`` a true ceiling. A chunk of size 1 can't
541+
# be split further. ``max`` with a stable input order breaks ties by
542+
# axis order, then lowest index within an axis.
527543
candidate: tuple[_Axis, int] | None = None
528544
candidate_size = -1
529545
for axis in self.axes:
530-
for idx, chunk in enumerate(self.chunks[axis.arg_key]):
546+
axis_chunks = self.chunks[axis.arg_key]
547+
if total + total // len(axis_chunks) > max_chunks:
548+
continue # any split of this axis would overshoot the cap
549+
for idx, chunk in enumerate(axis_chunks):
531550
if len(chunk) <= 1:
532551
continue
533552
if len(chunk) > candidate_size:
534553
candidate, candidate_size = (axis, idx), len(chunk)
535554
if candidate is None:
536-
return # every axis saturated at one atom per chunk
555+
# Every axis is saturated at one atom per chunk or would
556+
# overshoot the cap; stop below it rather than exceed it.
557+
return
537558
axis, idx = candidate
538559
_split_at(self.chunks[axis.arg_key], idx)
539560

tests/waterdata_chunking_test.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2237,20 +2237,52 @@ def test_cap_caps_the_total_across_axes():
22372237

22382238

22392239
def test_cap_bounds_fan_out_across_many_axes():
2240-
"""The guardrail holds regardless of axis count: three multi-value axes
2241-
at ``n=32`` still top out at ``n`` sub-requests total, not ``n ** 3`` —
2242-
the property the single-axis-only cap in the original implementation did
2243-
not guarantee."""
2244-
high = 32
2240+
"""The guardrail holds regardless of axis count: three multi-value axes at
2241+
a cap of 30 fan out to *at most* 30 sub-requests total — never the
2242+
``30 ** 3`` a per-axis cap would allow, and never *over* the cap either.
2243+
30 is deliberately not evenly reachable by these axes: a single split
2244+
multiplies the plan by more than one, so the naive ``while total < cap``
2245+
the first refine used stepped past 30 (to 32). The cap is a hard ceiling —
2246+
the property neither the single-axis-only cap nor that naive loop
2247+
guaranteed."""
2248+
cap = 30
22452249
# Three chunkable axes (two list axes + the filter OR-axis), each with 10
2246-
# atoms — under the old per-axis cap this would have been high**3.
2250+
# atoms — under the old per-axis cap this would have been cap**3.
22472251
args = {
22482252
"monitoring_location_id": [f"L{i}" for i in range(10)],
22492253
"parameter_code": [f"{i:05d}" for i in range(10)],
22502254
"filter": " OR ".join(f"p='{i}'" for i in range(10)),
22512255
}
2252-
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks=high)
2253-
assert plan.total <= high
2256+
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks=cap)
2257+
assert 1 < plan.total <= cap # fanned out, but never past the ceiling
2258+
2259+
2260+
@pytest.mark.parametrize(
2261+
"atoms_per_axis, cap",
2262+
[
2263+
(4, 5), # pre-fix loop overshot 5 -> 6
2264+
(8, 10), # pre-fix loop overshot 10 -> 12
2265+
(10, 7), # pre-fix loop overshot 7 -> 8
2266+
],
2267+
)
2268+
def test_cap_is_a_hard_ceiling_never_overshoots(atoms_per_axis, cap):
2269+
"""The cap is a hard ceiling, not a soft target. With two multi-value axes
2270+
a single split multiplies the plan by ``(k+1)/k`` for the split axis —
2271+
adding the product of the *other* axes, not one — so a naive
2272+
``while total < cap`` loop steps *past* the cap. These are exactly the
2273+
(atoms, cap) combos that loop overshot (5->6, 10->12, 7->8). The plan must
2274+
fan out and cover every atom once, but never exceed the cap, landing below
2275+
it when no whole split lands on it exactly (two even axes reach 4, not 5)."""
2276+
args = {
2277+
"monitoring_location_id": [f"L{i:03d}" for i in range(atoms_per_axis)],
2278+
"parameter_code": [f"{i:05d}" for i in range(atoms_per_axis)],
2279+
}
2280+
plan = ChunkPlan(args, _fake_build, url_limit=8000, max_chunks=cap)
2281+
assert 1 < plan.total <= cap # fanned out, but never past the ceiling
2282+
# Every atom on every axis is still covered exactly once.
2283+
for key, atoms in args.items():
2284+
flattened = [a for chunk in plan.chunks[key] for a in chunk]
2285+
assert sorted(flattened) == sorted(atoms)
22542286

22552287

22562288
def test_cap_does_not_mask_unchunkable():

0 commit comments

Comments
 (0)