6The §20 stand-in's parallel scheduler (Plan 240; ADR-T-15 slice).
10Turn ``--workers N | auto`` into real child processes without inventing
11a second classifier. The plan-239 mechanism in ``oct.testing.parallel``
12owns the resource vocabulary, the committed
13``parallel_manifest.json``, the grouping and the worker cap; this
14module imports all four and does exactly three things the pytest side
15does not need: it resolves the requested worker count against the cap,
16it turns the manifest's groups into scheduling units, and it runs those
17units in a bounded pool of children, reconciling their results into the
22- ``resolve_worker_plan`` — P2: ``auto`` = min(cap, cores); an explicit
23 count above the cap CLAMPS and carries the note that says so.
24- ``build_schedule`` — P3 as Plan 286 amended it: a group is one unit;
25 a ``free`` file is cut, in collection order, into units of at most
26 ``MAX_FREE_UNIT_TESTS`` tests, unless it carries the ``whole_file``
27 opt-out; units run largest first by collected count; unclassified
28 files share one serial unit. Deterministic for any input order.
29- ``load_groups`` — the manifest read, delegated to plan 239.
30- ``run_units`` — the bounded pool: one spawned child per unit, each
31 with its own §20.2 snapshot, reconciled fail-closed.
32- ``child_entry`` — the child-side entry point (module level so the
33 ``spawn`` start method can import it).
39 L2 — lifecycle events (pool start/stop, unit completion)
40 L3 — semantic details (resolved counts, unit shapes)
45- This module NEVER classifies a test file. Every group it uses comes
46 from the committed manifest through ``oct.testing.parallel``; a file
47 with no entry is :data:`GROUP_UNCLASSIFIED` and is reported, never
49- A child that dies without writing its result is a CRASH: its nodeids
50 are reported failed, never passed and never dropped (P3, spec-10
51 §8.4 worker-crash semantics).
52- The start method is always ``spawn``, so a child inherits the
53 parent's ``sys.path`` (the F17 shadow guard) and nothing else of the
54 parent's in-process state.
55- Ordering of everything this module returns is derived from the
56 schedule, not from completion order.
60- oc_diagnostics (structured debug logging via _dbg)
61- oct.testing.parallel (lazy; Plan 239: manifest, groups, worker cap)
62- oc_chat.testing.report (violation/error constructors)
63- oc_chat.testing.runner (lazy; the in-process session, imported in the child to keep the module graph acyclic)
65from __future__
import annotations
67from dataclasses
import dataclass, field
71from pathlib
import Path
74from typing
import Any, Iterable, Mapping, Sequence
76from oc_diagnostics
import _dbg
84DEFAULT_WORKER_CAP: int = 24
87GROUP_UNCLASSIFIED: str =
"unclassified-serial"
90GROUP_FREE: str =
"free"
99WHOLE_FILE_MARKER: str =
"whole_file"
114MAX_FREE_UNIT_TESTS: int = 100
118DIST_MODE_PARALLEL: str =
"oct_det_loadscope"
121DIST_MODE_SERIAL: str =
"serial"
124CODE_WORKERS_CLAMPED: str =
"9007"
127_POLL_SECONDS: float = 0.02
131 """Raised when the plan-239 mechanism cannot be imported."""
139 """Import ``oct.testing.parallel``, falling back to the checkout.
141 In a worktree the editable ``oct`` install may resolve to ANOTHER
142 checkout that does not carry the module yet; the sibling
143 subproject in this repository always does. Only that ONE module is
144 loaded by path — the rest of ``oct`` keeps resolving normally.
145 This mirrors the root conftest's fallback (Plan 239), because the
146 stand-in runs in exactly the same trees.
148 func =
"load_parallel_module"
150 from oct.testing
import parallel
153 if parallel
is not None:
155 if workspace_root
is None:
156 raise SchedulerUnavailable(
"oct.testing.parallel is not importable")
158 Path(workspace_root) /
"oct" /
"oct" /
"testing" /
"parallel.py"
160 if not candidate.is_file():
161 raise SchedulerUnavailable(
162 f
"oct.testing.parallel is not importable and {candidate} "
165 import importlib.util
168 spec = importlib.util.spec_from_file_location(
169 "oct_testing_parallel_checkout", candidate,
171 if spec
is None or spec.loader
is None:
172 raise SchedulerUnavailable(f
"{candidate} is not loadable as a module")
173 module = importlib.util.module_from_spec(spec)
174 sys.modules[spec.name] = module
175 spec.loader.exec_module(module)
176 _dbg(
"OC_TESTING", func, f
"loaded the classifier from {candidate}", 2)
182 testpaths: Sequence[str],
183 workspace_root: Path |
None =
None,
185 """The committed manifest implied by *testpaths* (Plan 239 P4)."""
187 roots = list(testpaths)
or [
"."]
188 return parallel.default_manifest_path(Path(root), roots)
193 relpaths: Iterable[str],
194 workspace_root: Path |
None =
None,
195) -> tuple[dict[str, str], tuple[str, ...]]:
196 """Map root-relative files to their groups through plan 239.
201 ``(path -> group, missing)``. A collected file with no manifest
202 entry maps to :data:`GROUP_UNCLASSIFIED` and is listed in
203 *missing* — the fail-closed state a stale manifest produces.
207 manifest = parallel.load_manifest(Path(manifest_path))
208 groups, missing = parallel.resolve_groups(manifest, list(relpaths))
209 _dbg(
"OC_TESTING", func,
210 f
"{len(groups)} file(s), {len(missing)} unclassified", 3)
211 return dict(groups), tuple(missing)
218@dataclass(frozen=True)
220 """The resolved ``--workers`` decision.
225 The CLI value verbatim: a positive integer or ``"auto"``.
227 ``subprojects[].test.max_workers`` for this package, or the
228 shipped default when the workspace manifest declares none.
230 The pool size this run may use.
232 True when an explicit request exceeded *cap*.
234 The human note naming both numbers; ``None`` when not clamped.
240 clamped: bool =
False
241 clamp_message: str |
None =
None
245 requested: int | str,
246 workspace_root: Path,
249 cpu_count: int |
None =
None,
251 """Resolve *requested* against the plan-239 cap (P2).
253 ``auto`` resolves to ``min(cap, cores)``; an explicit count above
254 the cap is clamped to it and the clamp is reported, never silent.
256 func =
"resolve_worker_plan"
259 cap = int(parallel.resolve_max_workers(Path(workspace_root), package))
260 except Exception
as exc:
265 _dbg(
"OC_TESTING", func, f
"cap unusable: {exc}", 1)
267 f
"the workspace manifest's worker cap for {package!r} is "
271 cores = cpu_count
if cpu_count
is not None else (os.cpu_count()
or 1)
272 cores = max(1, cores)
274 if requested ==
"auto":
275 resolved = max(1, min(cap, cores))
276 _dbg(
"OC_TESTING", func, f
"auto -> {resolved} (cap {cap}, "
277 f
"{cores} core(s))", 3)
278 return WorkerPlan(requested=
"auto", cap=cap, resolved=resolved)
280 count = max(1, int(requested))
283 f
"--workers {count} exceeds the manifest cap for {package!r} "
285 f
"and was clamped to {cap} "
286 "(subprojects[].test.max_workers, Plan 239 P1)"
288 _dbg(
"OC_TESTING", func, message, 2)
289 return WorkerPlan(requested=count, cap=cap, resolved=cap,
290 clamped=
True, clamp_message=message)
291 return WorkerPlan(requested=count, cap=cap, resolved=count)
295 """The §11 ``errors[]`` entry that reports a clamp."""
297 "configuration", CODE_WORKERS_CLAMPED,
"warning",
298 plan.clamp_message
or "",
306@dataclass(frozen=True)
308 """One unit of scheduling: everything one child will run.
313 The plan-239 group name this unit carries.
315 ``gw<n>``, assigned by the unit's position in the deterministic
316 schedule, so every nodeid of a group shares one worker id
317 regardless of which OS process picked it up.
319 Root-relative POSIX paths, sorted.
321 The parent's selection for those files, in collection order.
326 files: tuple[str, ...]
327 nodeids: tuple[str, ...]
331 groups: Mapping[str, str],
332 nodeids_by_file: Mapping[str, Sequence[str]],
334 whole_file: Iterable[str] = (),
335 max_free_unit_tests: int |
None =
None,
336) -> tuple[ScheduleUnit, ...]:
337 """Turn the plan-239 grouping into scheduling units (P3, Plan 286).
339 A group is the unit of scheduling: every file of a group runs in
340 ONE child, because that is what the group means, and the
341 unclassified set shares one serial unit because fail-closed means
344 A ``free`` file shares no resource with anything, so its tests may
345 run anywhere — including in different children (Plan 286 P2). Its
346 nodeids are therefore cut, in collection order, into units of at
347 most *max_free_unit_tests* tests, so that one file with hundreds of
348 tests no longer holds a child for its whole length. A file listed
349 in *whole_file* is the opt-out: it stays ONE unit however many
352 Units are scheduled largest first by collected count (longest
353 processing time first — the classic bound for a bounded pool) and
354 the result is deterministic for any input order.
359 Root-relative path -> plan-239 group name.
361 Root-relative path -> the parent's selection, in collection
364 Root-relative paths whose tests must stay in one unit: the
365 files the parent's collect pass saw carrying
366 :data:`WHOLE_FILE_MARKER`.
368 Override for :data:`MAX_FREE_UNIT_TESTS`; values below 1 are
369 read as 1, so a caller can never ask for an empty unit.
371 func =
"build_schedule"
373 MAX_FREE_UNIT_TESTS
if max_free_unit_tests
is None
374 else max(1, int(max_free_unit_tests))
376 intact = {str(path)
for path
in whole_file}
378 buckets: dict[str, list[str]] = {}
380 chunks: dict[str, tuple[str, ...]] = {}
381 for path
in sorted(nodeids_by_file):
382 group = groups.get(path, GROUP_UNCLASSIFIED)
383 if group != GROUP_FREE:
384 buckets.setdefault(group, []).append(path)
386 nodeids = tuple(nodeids_by_file.get(path, ()))
387 span = len(nodeids)
if path
in intact
else limit
388 for start
in range(0, max(1, len(nodeids)), max(1, span)):
389 key = f
"{path}#{start // max(1, span):04d}"
390 buckets.setdefault(key, []).append(path)
391 chunks[key] = nodeids[start:start + span]
393 def _tests(key: str, paths: Sequence[str]) -> int:
395 return len(chunks[key])
396 return sum(len(nodeids_by_file.get(path, ()))
for path
in paths)
400 key=
lambda item: (-_tests(item[0], item[1]), item[0]),
402 units: list[ScheduleUnit] = []
403 for index, (key, paths)
in enumerate(ordered):
404 files = tuple(sorted(paths))
405 nodeids = chunks.get(key)
or tuple(
406 nodeid
for path
in files
407 for nodeid
in nodeids_by_file.get(path, ())
412 group=groups.get(files[0], GROUP_UNCLASSIFIED),
413 worker_id=f
"gw{index}",
417 _dbg(
"OC_TESTING", func, f
"{len(units)} unit(s) from "
418 f
"{len(nodeids_by_file)} file(s)", 3)
426@dataclass(frozen=True)
428 """Everything a child needs; picklable by construction."""
431 pytest_args: tuple[str, ...]
432 nodeids: tuple[str, ...]
440 """One unit's reconciled result.
442 ``crashed`` is the fail-closed state: the child died without
443 writing a result, so nothing it may have reported can be trusted.
447 records: dict[str, dict] = field(default_factory=dict)
448 timed_out: tuple[str, ...] = ()
449 collect_errors: tuple[str, ...] = ()
450 violations: list[dict] = field(default_factory=list)
452 pid: int |
None =
None
453 started_seconds: float = 0.0
454 finished_seconds: float = 0.0
455 crashed: bool =
False
456 crash_detail: str =
""
461 """Wall clock the unit occupied, in seconds."""
466 """Run one unit and write its result document (the spawn target).
468 Imports the runner lazily: the runner imports THIS module at module
469 scope, and a child that re-imported it eagerly would close the
470 cycle. Any exception is recorded in the result document rather than
471 raised, so the parent distinguishes "the unit failed" from "the
475 payload: dict[str, Any]
479 outcome = run_session(
480 pytest_args=[*request.pytest_args, *request.nodeids],
481 root=Path(request.root),
482 timeout=request.timeout,
484 install_signal_handlers=
False,
485 violation_file=request.root,
486 violation_scope=f
"{request.worker_id}/{request.group}",
490 "records": outcome.records,
491 "timed_out": sorted(outcome.timed_out),
492 "collect_errors": list(outcome.collect_errors),
493 "violations": outcome.violations,
495 "output": outcome.output,
496 "internal_error":
None,
498 except BaseException
as exc:
499 _dbg(
"OC_TESTING", func, f
"child internal error: {exc!r}", 1)
504 "collect_errors": [],
508 "internal_error": f
"{type(exc).__name__}: {exc}"[:2000],
510 Path(result_path).write_text(
511 json.dumps(payload), encoding=
"utf-8", newline=
"\n",
523 pytest_args: Sequence[str],
527 """Start one child for *unit* and return the live process."""
528 context = multiprocessing.get_context(
"spawn")
531 pytest_args=tuple(pytest_args),
532 nodeids=unit.nodeids,
534 worker_id=unit.worker_id,
537 process = context.Process(
539 args=(request, str(result_path)),
540 name=f
"oc-standin-{unit.worker_id}",
554 """Turn a finished child into a :class:`UnitOutcome`, fail-closed."""
555 func =
"_read_result"
557 unit=unit, pid=process.pid,
558 started_seconds=started, finished_seconds=finished,
561 payload = json.loads(result_path.read_text(encoding=
"utf-8"))
562 except (OSError, json.JSONDecodeError)
as exc:
563 outcome.crashed =
True
564 outcome.crash_detail = (
565 f
"exit status {process.exitcode}; no readable result document "
566 f
"({type(exc).__name__})"
568 _dbg(
"OC_TESTING", func,
569 f
"{unit.worker_id} crashed: {outcome.crash_detail}", 1)
571 if payload.get(
"internal_error"):
572 outcome.crashed =
True
573 outcome.crash_detail = str(payload[
"internal_error"])
574 outcome.pid = payload.get(
"pid", outcome.pid)
576 outcome.records = dict(payload.get(
"records")
or {})
577 outcome.timed_out = tuple(payload.get(
"timed_out")
or ())
578 outcome.collect_errors = tuple(payload.get(
"collect_errors")
or ())
579 outcome.violations = list(payload.get(
"violations")
or [])
580 outcome.rc = int(payload.get(
"rc", 0))
581 outcome.pid = payload.get(
"pid", outcome.pid)
582 outcome.output = str(payload.get(
"output")
or "")
587 units: Sequence[ScheduleUnit],
591 pytest_args: Sequence[str],
594) -> tuple[list[UnitOutcome], bool]:
595 """Run *units* in a bounded pool of children.
600 Maximum number of children alive at once.
602 The run's ``time.monotonic()`` zero, so every window this
603 returns is relative to the report's own clock.
608 ``(outcomes in schedule order, interrupted)``. On interruption
609 the still-running children are terminated and the units that
610 never started are absent — the caller reports a partial run.
613 _dbg(
"OC_TESTING", func,
614 f
"{len(units)} unit(s) over {workers} worker(s)", 2)
615 pending = list(units)
616 running: list[tuple[ScheduleUnit, Any, Path, float]] = []
617 done: dict[str, UnitOutcome] = {}
620 with tempfile.TemporaryDirectory(prefix=
"oc_standin_")
as scratch:
621 scratch_dir = Path(scratch)
623 while pending
or running:
624 while pending
and len(running) < workers:
625 unit = pending.pop(0)
626 result_path = scratch_dir / f
"{unit.worker_id}.json"
627 started = time.monotonic() - origin
629 unit, root=root, pytest_args=pytest_args,
630 timeout=timeout, result_path=result_path,
632 running.append((unit, process, result_path, started))
633 time.sleep(_POLL_SECONDS)
634 for entry
in list(running):
635 unit, process, result_path, started = entry
636 if process.is_alive():
639 finished = time.monotonic() - origin
641 unit, process, result_path, started, finished,
643 running.remove(entry)
644 except KeyboardInterrupt:
646 _dbg(
"OC_TESTING", func,
"interrupted; terminating children", 1)
647 for _unit, process, _path, _started
in running:
648 if process.is_alive():
650 for _unit, process, _path, _started
in running:
651 process.join(timeout=10)
653 ordered = [done[unit.worker_id]
for unit
in units
654 if unit.worker_id
in done]
655 _dbg(
"OC_TESTING", func,
656 f
"{len(ordered)} unit(s) reconciled, interrupted={interrupted}", 2)
657 return ordered, interrupted
661 """The §11 ``contract_violations[]`` row a dead child produces."""
662 return make_violation(
663 "child_crash", outcome.unit.files[0],
None,
"error",
664 f
"scheduler child {outcome.unit.worker_id} running group "
665 f
"{outcome.unit.group!r} died before reporting "
666 f
"({outcome.crash_detail}); its "
667 f
"{len(outcome.unit.nodeids)} nodeid(s) are reported failed, "
668 "never passed (Plan 240 P3, spec-10 section 8.4)",
673 """The §12.4 ``WORKER_CRASHED`` entry a dead child produces."""
675 "orchestrator",
"WORKER_CRASHED",
"warning",
676 f
"worker {outcome.unit.worker_id} (group {outcome.unit.group!r}) "
677 f
"crashed: {outcome.crash_detail}",
682 """The §11 row an absent manifest entry produces (P1)."""
683 listed =
", ".join(sorted(missing))
684 return make_violation(
685 "unclassified_test_file", str(root),
None,
"error",
686 f
"{len(missing)} collected file(s) carry no parallel_manifest.json "
687 f
"entry and were run serially rather than in parallel: {listed}. "
688 "Regenerate the manifest (python -m oct.testing.parallel classify) "
689 "— a stale manifest may never silently widen parallelism",
693_dbg(
"OC_TESTING",
"module",
"loaded", 4)
696 "CODE_WORKERS_CLAMPED",
697 "DEFAULT_WORKER_CAP",
698 "DIST_MODE_PARALLEL",
701 "GROUP_UNCLASSIFIED",
702 "MAX_FREE_UNIT_TESTS",
706 "SchedulerUnavailable",
715 "load_parallel_module",
717 "resolve_worker_plan",
719 "unclassified_violation",
float duration_seconds(self)
tuple[dict[str, str], tuple[str,...]] load_groups(Path manifest_path, Iterable[str] relpaths, Path|None workspace_root=None)
UnitOutcome _read_result(ScheduleUnit unit, Any process, Path result_path, float started, float finished)
dict unclassified_violation(Path root, Sequence[str] missing)
tuple[list[UnitOutcome], bool] run_units(Sequence[ScheduleUnit] units, *, int workers, Path root, Sequence[str] pytest_args, int|None timeout, float origin)
Any load_parallel_module(Path|None workspace_root=None)
dict crash_violation(UnitOutcome outcome)
None child_entry(ChildRequest request, str result_path)
dict clamp_error(WorkerPlan plan)
Any _spawn(ScheduleUnit unit, *, Path root, Sequence[str] pytest_args, int|None timeout, Path result_path)
dict crash_error(UnitOutcome outcome)
tuple[ScheduleUnit,...] build_schedule(Mapping[str, str] groups, Mapping[str, Sequence[str]] nodeids_by_file, *, Iterable[str] whole_file=(), int|None max_free_unit_tests=None)
WorkerPlan resolve_worker_plan(int|str requested, Path workspace_root, str package, *, int|None cpu_count=None)
Path manifest_path_for(Path root, Sequence[str] testpaths, Path|None workspace_root=None)