Skip to content

Commit 7091007

Browse files
authored
fix: Defer adaptive chunk adjustment without a measurable sample (#232)
Adaptive chunking computed duration_per_task = completed_task_duration / completed_task_count adaptive_chunk_size = target_runtime_seconds / duration_per_task with neither divisor guarded. Implements step 3 of the adaptive chunking algorithm in the CLI specification, added upstream in openjd-rs #282: "If the cumulative duration is zero or non-finite, keep the current chunk size and wait for a measurable sample." Be clear about severity: this is spec conformance and hardening, NOT a fix for a reachable crash. I could not reach either divisor through a normal `openjd run`: - completed_task_count cannot be zero. It accumulates len(IntRangeExpr.from_str(...)), and an IntRangeExpr cannot be empty -- "1-0" and "5-1" raise ValueError, "0-0" and "1-1" have len 1. - completed_task_duration cannot be 0.0 in practice. run_task() always spawns and waits on a real subprocess, and raises before the accumulation line on failure, so the measured delta would have to fit inside one perf_counter tick (~42 ns on this host, against a ~10 ms floor for the cheapest possible subprocess). Correcting an earlier claim of mine: I previously recorded this as a reproduced defect. It was not. The "reproduction" replayed the arithmetic with a hardcoded 0.0, which demonstrates nothing about the code path. The guard is still worth having, because the two implementations fail differently if it is ever reached: Rust produces inf and saturates to a huge chunk size, while Python raises ZeroDivisionError out of the run. Extracted as a module-level _calculate_adaptive_chunk_size returning Optional[int], mirroring openjd-rs's calculate_adaptive_chunk_size, so the deferral is unit-testable without provoking an unreachable timing condition. Mutation-checked, 10 mutants, 0 survivors: removing the guard or any one of its three arms, making it always defer, ignoring the sentinel at the call site, and removing the blend or the clamp are each caught by name. Removing the sentinel check is caught by the pre-existing end-to-end test_openjd_run_on_chunked_job_adaptive_chunking, which is the evidence the extraction is behaviour-preserving on the real path. One finding from that exercise worth recording. My first draft used `continue` on the deferral path, which also skips the maximum-task countdown further down the loop and so silently breaks --maximum-tasks. I caught it by reading, and then confirmed the suite would NOT have: the mutant survived. The existing test_openjd_run_on_chunked_job_maximum_task_count[1] covers adaptive chunking with --maximum-tasks, but with real durations the estimate never defers, so the interaction was unpinned. Adds test_maximum_task_count_is_honoured_when_every_estimate_defers, which forces every estimate to defer; it now catches that mutant. Raised as its own PR rather than added to #230: that PR is the RFC 0007/0008 feature work, is already MERGEABLE and waiting only on approval, and its merge unblocks the specifications conformance suite. This change is unrelated to its scope and not urgent enough to reset its review. Verified: 301 passed / 2 skipped (289 before), ruff clean, black clean, mypy clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent d697d7a commit 7091007

2 files changed

Lines changed: 225 additions & 12 deletions

File tree

src/openjd/cli/_run/_local_session/_session_manager.py

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from signal import signal, SIGINT, SIGTERM, SIG_DFL
99
from itertools import islice
1010
from datetime import datetime, timedelta, timezone
11+
from math import isfinite
1112

1213
from ._actions import (
1314
EnterEnvironmentAction,
@@ -49,6 +50,50 @@ def __init__(self, failed_action: SessionAction):
4950
super().__init__(f"Action failed: {failed_action}")
5051

5152

53+
def _calculate_adaptive_chunk_size(
54+
*,
55+
current_chunk_size: int,
56+
completed_task_count: int,
57+
completed_task_duration: float,
58+
target_runtime_seconds: float,
59+
) -> Optional[int]:
60+
"""The adaptive-chunking size estimate, or ``None`` to defer adjusting.
61+
62+
Implements steps 3-7 of the adaptive chunking algorithm in the Open Job
63+
Description CLI specification (``specs/cli/run.md`` in openjd-rs): if the
64+
cumulative duration is zero or non-finite, keep the current chunk size and
65+
wait for a measurable sample.
66+
67+
Deferring matters because a zero ``duration_per_task`` makes the ideal size
68+
unrepresentable. The two implementations fail differently without this
69+
guard: the Rust CLI produces ``inf``, which saturates to a huge chunk size,
70+
while Python raises ``ZeroDivisionError`` out of the run. Neither is
71+
reachable through a normal ``openjd run`` today -- ``run_task`` always
72+
spawns and waits on a real subprocess, so a measured chunk duration of
73+
exactly ``0.0`` would need that round trip to complete inside one
74+
``perf_counter`` tick (~42 ns here) -- so this is spec conformance and
75+
hardening rather than a fix for an observed failure.
76+
77+
Extracted as a module-level function, mirroring openjd-rs's
78+
``calculate_adaptive_chunk_size``, so the deferral is unit-testable without
79+
needing to provoke an unreachable timing condition end to end.
80+
"""
81+
if (
82+
completed_task_count <= 0
83+
or completed_task_duration <= 0.0
84+
or not isfinite(completed_task_duration)
85+
):
86+
return None
87+
88+
duration_per_task = completed_task_duration / completed_task_count
89+
adaptive_chunk_size = target_runtime_seconds / duration_per_task
90+
if completed_task_count < 10 and adaptive_chunk_size > current_chunk_size:
91+
# When we have data about only a few tasks, gradually blend in the new
92+
# estimate instead of cutting over immediately.
93+
adaptive_chunk_size = 0.75 * current_chunk_size + 0.25 * adaptive_chunk_size
94+
return max(int(adaptive_chunk_size), 1)
95+
96+
5297
class LocalSession:
5398
"""
5499
A class to manage a `Session` object from the `sessions` module,
@@ -300,27 +345,28 @@ def _run_tasks_adaptive_chunking(
300345
# Estimate a chunk size based on the statistics, and update the iterator. Note that this
301346
# logic is very simple, providing a good starting point that behaves reasonably for other implementations
302347
# to follow.
303-
duration_per_task = completed_task_duration / completed_task_count
304-
adaptive_chunk_size = target_runtime_seconds / duration_per_task
348+
new_chunk_size = _calculate_adaptive_chunk_size(
349+
current_chunk_size=task_parameters.chunks_default_task_count, # type: ignore
350+
completed_task_count=completed_task_count,
351+
completed_task_duration=completed_task_duration,
352+
target_runtime_seconds=target_runtime_seconds,
353+
)
354+
# `None` means there is no measurable sample yet: keep the current chunk
355+
# size and wait for one. Deliberately not `continue` -- the maximum-task
356+
# countdown below must still run for this completed chunk.
305357
if (
306-
completed_task_count < 10
307-
and adaptive_chunk_size > task_parameters.chunks_default_task_count # type: ignore
358+
new_chunk_size is not None
359+
and new_chunk_size != task_parameters.chunks_default_task_count
308360
):
309-
# When we have data about only a few tasks, gradually blend in the new estimate instead of cutting over immediately
310-
adaptive_chunk_size = (
311-
0.75 * task_parameters.chunks_default_task_count + 0.25 * adaptive_chunk_size # type: ignore
312-
)
313-
adaptive_chunk_size = max(int(adaptive_chunk_size), 1)
314-
if adaptive_chunk_size != task_parameters.chunks_default_task_count:
315361
LOG.info(
316362
msg=f"Open Job Description CLI: Ran {completed_task_count} tasks in {timedelta(seconds=completed_task_duration)}, average {timedelta(seconds=completed_task_duration / completed_task_count)}",
317363
extra={"session_id": self.session_id},
318364
)
319365
LOG.info(
320-
msg=f"Open Job Description CLI: Adjusting chunk size from {task_parameters.chunks_default_task_count} to {adaptive_chunk_size}",
366+
msg=f"Open Job Description CLI: Adjusting chunk size from {task_parameters.chunks_default_task_count} to {new_chunk_size}",
321367
extra={"session_id": self.session_id},
322368
)
323-
task_parameters.chunks_default_task_count = adaptive_chunk_size
369+
task_parameters.chunks_default_task_count = new_chunk_size
324370

325371
# If a maximum task count was specified, count them down
326372
if maximum_tasks and maximum_tasks > 0:
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
"""Adaptive chunking must defer, not divide, when there is no measurable sample.
3+
4+
Step 3 of the adaptive chunking algorithm in the CLI specification
5+
(``specs/cli/run.md`` in openjd-rs): "If the cumulative duration is zero or
6+
non-finite, keep the current chunk size and wait for a measurable sample."
7+
8+
Without it, a zero ``duration_per_task`` raises ``ZeroDivisionError`` out of the
9+
run in this implementation, and produces a saturating ``inf`` chunk size in the
10+
Rust one. These tests pin the deferral and the negative controls around it.
11+
"""
12+
13+
from math import inf, nan
14+
from pathlib import Path
15+
import re
16+
from unittest.mock import patch
17+
18+
import pytest
19+
20+
from openjd.cli._run._local_session._session_manager import _calculate_adaptive_chunk_size
21+
22+
from . import run_openjd_cli_main, format_capsys_outerr
23+
24+
CHUNKED_JOB_TEMPLATE_FILE = str(Path(__file__).parent / "templates" / "chunked_job.yaml")
25+
26+
27+
class TestDefersWithoutAMeasurableSample:
28+
@pytest.mark.parametrize(
29+
"completed_task_duration",
30+
[
31+
pytest.param(0.0, id="zero-duration"),
32+
pytest.param(-0.0, id="negative-zero-duration"),
33+
pytest.param(-1.0, id="negative-duration"),
34+
pytest.param(inf, id="infinite-duration"),
35+
pytest.param(nan, id="nan-duration"),
36+
],
37+
)
38+
def test_unusable_duration_defers(self, completed_task_duration: float) -> None:
39+
# GIVEN a completed chunk whose cumulative duration is not a usable
40+
# sample, WHEN the estimate is calculated
41+
result = _calculate_adaptive_chunk_size(
42+
current_chunk_size=1,
43+
completed_task_count=1,
44+
completed_task_duration=completed_task_duration,
45+
target_runtime_seconds=60.0,
46+
)
47+
48+
# THEN adjustment is deferred rather than dividing by it. Before the
49+
# guard, the 0.0 cases raised ZeroDivisionError.
50+
assert result is None
51+
52+
@pytest.mark.parametrize(
53+
"completed_task_count", [pytest.param(0, id="zero"), pytest.param(-1, id="negative")]
54+
)
55+
def test_non_positive_task_count_defers(self, completed_task_count: int) -> None:
56+
# GIVEN no counted tasks -- the other divisor in the same expression
57+
result = _calculate_adaptive_chunk_size(
58+
current_chunk_size=1,
59+
completed_task_count=completed_task_count,
60+
completed_task_duration=5.0,
61+
target_runtime_seconds=60.0,
62+
)
63+
64+
# THEN
65+
assert result is None
66+
67+
68+
class TestStillEstimatesNormally:
69+
"""Negative controls: the guard must not swallow the usable cases."""
70+
71+
def test_blends_while_the_sample_is_small(self) -> None:
72+
# GIVEN 1 task in 1s against a 60s target, so the ideal size is 60, and
73+
# fewer than 10 completed tasks means the conservative ramp applies:
74+
# 0.75 * 4 + 0.25 * 60 == 18.
75+
result = _calculate_adaptive_chunk_size(
76+
current_chunk_size=4,
77+
completed_task_count=1,
78+
completed_task_duration=1.0,
79+
target_runtime_seconds=60.0,
80+
)
81+
82+
# THEN
83+
assert result == 18
84+
85+
def test_uses_the_ideal_size_once_the_sample_is_large(self) -> None:
86+
# GIVEN 10 or more completed tasks, the blend no longer applies:
87+
# 10 tasks in 10s is 1s/task, so a 60s target is 60 tasks.
88+
result = _calculate_adaptive_chunk_size(
89+
current_chunk_size=4,
90+
completed_task_count=10,
91+
completed_task_duration=10.0,
92+
target_runtime_seconds=60.0,
93+
)
94+
95+
# THEN
96+
assert result == 60
97+
98+
def test_does_not_blend_when_the_ideal_size_shrinks(self) -> None:
99+
# GIVEN slow tasks, so the ideal size (2) is below the current size (50).
100+
# The ramp only applies when the estimate grows, so it is used directly.
101+
result = _calculate_adaptive_chunk_size(
102+
current_chunk_size=50,
103+
completed_task_count=1,
104+
completed_task_duration=30.0,
105+
target_runtime_seconds=60.0,
106+
)
107+
108+
# THEN
109+
assert result == 2
110+
111+
def test_clamps_to_at_least_one(self) -> None:
112+
# GIVEN tasks far slower than the target, so the ideal size rounds to 0
113+
result = _calculate_adaptive_chunk_size(
114+
current_chunk_size=1,
115+
completed_task_count=1,
116+
completed_task_duration=1000.0,
117+
target_runtime_seconds=1.0,
118+
)
119+
120+
# THEN a chunk of zero tasks would stall the run
121+
assert result == 1
122+
123+
124+
class TestDeferringDoesNotSkipTheRestOfTheLoop:
125+
def test_maximum_task_count_is_honoured_when_every_estimate_defers(self, capsys) -> None:
126+
"""Deferring must skip only the adjustment, not the loop body.
127+
128+
Regression for a bug in the first draft of this fix: returning early with
129+
`continue` when the estimate deferred also skipped the maximum-task
130+
countdown further down the loop, so ``--maximum-tasks`` was ignored and
131+
the entire parameter space ran.
132+
133+
``test_openjd_run_on_chunked_job_maximum_task_count[1]`` in
134+
``test_chunked_job.py`` does not catch this: with real subprocess
135+
durations the estimate never defers, so the deferral path is never taken
136+
there. Forcing every estimate to defer is what exercises it.
137+
"""
138+
# GIVEN adaptive chunking (TargetRuntime=1) where the estimate always
139+
# defers, and a maximum of 3 tasks over a larger parameter space
140+
with patch(
141+
"openjd.cli._run._local_session._session_manager._calculate_adaptive_chunk_size",
142+
return_value=None,
143+
) as mock_estimate:
144+
# WHEN
145+
outerr = run_openjd_cli_main(
146+
capsys,
147+
args=[
148+
"run",
149+
CHUNKED_JOB_TEMPLATE_FILE,
150+
"--step",
151+
"Chunked Step",
152+
"-p",
153+
"ChunkSize=3",
154+
"-p",
155+
"TargetRuntime=1",
156+
"--maximum-tasks",
157+
"3",
158+
],
159+
expected_exit_code=0,
160+
)
161+
162+
# THEN the run still stopped at the limit, and the deferral path really
163+
# was the one taken.
164+
assert mock_estimate.called, "the adaptive estimate was never consulted"
165+
assert re.search(
166+
"Chunks run: 3$", outerr.out, re.MULTILINE
167+
), f"Regex 'Chunks run: 3$' not matched in:\n{format_capsys_outerr(outerr)}"

0 commit comments

Comments
 (0)