mpengine 0.1.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpengine
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: A small, general-purpose multiprocessing engine: dispatch any callable across processes with run manifests, per-worker logs, on-disk outputs and failure isolation
5
5
  Author: Amandeep Singh
6
6
  License-Expression: MIT
@@ -42,6 +42,7 @@ Every run produces three things:
42
42
  | a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
43
43
  | each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
44
44
  | one log file per **worker process** | `<base>/logs/<run_id>/worker_<pid>.log` |
45
+ | a log of the run as a whole | `<base>/logs/<run_id>/run.log` |
45
46
 
46
47
  A single job failing does **not** abort the run — it is recorded as a failed
47
48
  job and the rest continue.
@@ -122,14 +123,48 @@ run with a custom `save_fn`, pass the matching reader as
122
123
  | `save_fn` | how each result is written. Default `save_pickle`; supply your own (e.g. parquet) |
123
124
  | `labels` | names for each job; defaults to `job_0000`, `job_0001`, … |
124
125
  | `task` | names the run; defaults to `func.__name__` |
125
- | `n_threads` | worker count; defaults to `min(cpu_count, 8)` |
126
+ | `n_workers` | worker *process* count (not threads — see below); defaults to `os.cpu_count()`, clamped down to the number of jobs if there are fewer jobs than that |
126
127
  | `debug` | run sequentially in-process — real tracebacks you can attach a debugger to, no pool |
127
128
  | `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
128
129
 
129
130
  Develop with `debug=True`, then flip it off. Chasing a bug through a process
130
- pool means reading a `RemoteTraceback` from a worker that has already exited,
131
+ pool means reading a traceback re-raised from a worker that has already exited,
131
132
  and it never tells you which job dict was at fault.
132
133
 
134
+ ### Logging
135
+
136
+ The library logs its whole lifecycle through the standard `logging` module and
137
+ writes nothing to stdout except the banner, so embedding it never pollutes a
138
+ host application's output. It installs only a `NullHandler` — to see anything,
139
+ configure logging yourself:
140
+
141
+ ```python
142
+ import logging
143
+ logging.basicConfig(level=logging.INFO)
144
+ ```
145
+
146
+ Two loggers, so either half can be tuned or silenced independently:
147
+
148
+ | logger | emits |
149
+ |---|---|
150
+ | `mpengine.orchestrator` | run start/done, resolved dirs, per-job outcomes, worker ranking |
151
+ | `mpengine.engine` | dispatch start/done, worker-count clamping, progress milestones, worker death |
152
+
153
+ Levels: **INFO** for run lifecycle, **DEBUG** for per-job success (a 10,000-job
154
+ sweep would otherwise be 10,000 INFO lines), **WARNING** for a failed job or a
155
+ clamped worker count, **ERROR** for a dead worker.
156
+
157
+ Every run also writes `<log_dir>/<run_id>/run.log` containing the parent's
158
+ whole view of that run — dispatch, milestones, every job outcome, the summary —
159
+ alongside the existing per-worker log files. That happens regardless of how you
160
+ configure logging, so a run is always a self-contained durable record.
161
+
162
+ **Progress display is terminal-aware.** Interactively you get the tqdm bars and
163
+ per-worker lines as before. When stderr is not a terminal — piped, redirected to
164
+ a log file, running under CI or a scheduler — those are suppressed (they render
165
+ as unreadable escape-sequence noise in a file) and periodic completion
166
+ milestones are logged at INFO instead.
167
+
133
168
  ### Closures and lambdas
134
169
 
135
170
  `func`, and any custom `save_fn`, can be a closure or a lambda — not just a
@@ -150,7 +185,7 @@ you want to drive the pool yourself:
150
185
  from mpengine import expand_call, process_jobs, process_jobs_
151
186
 
152
187
  jobs = [{"func": my_task, "x": 1, "y": 2}, {"func": other_task, "n": 5}]
153
- results = process_jobs(jobs, n_threads=8) # or process_jobs_ to stay sequential
188
+ results = process_jobs(jobs) # n_workers defaults to os.cpu_count(); or process_jobs_ to stay sequential
154
189
  ```
155
190
 
156
191
  A job is just a dict carrying its own callback plus that callback's kwargs, so a
@@ -168,6 +203,11 @@ for triangular workloads — where item `i` costs `O(i)`, such as an
168
203
  expanding-window computation — which keeps workers from idling while one
169
204
  overloaded worker finishes.
170
205
 
206
+ ## Changelog
207
+
208
+ See [CHANGELOG.md](CHANGELOG.md). Versions below 1.0 may carry breaking changes
209
+ in a minor release; each is listed there with the migration needed.
210
+
171
211
  ## What else is in the repo
172
212
 
173
213
  The `src/learning/` package holds demo and exercise scripts used while
@@ -13,6 +13,7 @@ Every run produces three things:
13
13
  | a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
14
14
  | each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
15
15
  | one log file per **worker process** | `<base>/logs/<run_id>/worker_<pid>.log` |
16
+ | a log of the run as a whole | `<base>/logs/<run_id>/run.log` |
16
17
 
17
18
  A single job failing does **not** abort the run — it is recorded as a failed
18
19
  job and the rest continue.
@@ -93,14 +94,48 @@ run with a custom `save_fn`, pass the matching reader as
93
94
  | `save_fn` | how each result is written. Default `save_pickle`; supply your own (e.g. parquet) |
94
95
  | `labels` | names for each job; defaults to `job_0000`, `job_0001`, … |
95
96
  | `task` | names the run; defaults to `func.__name__` |
96
- | `n_threads` | worker count; defaults to `min(cpu_count, 8)` |
97
+ | `n_workers` | worker *process* count (not threads — see below); defaults to `os.cpu_count()`, clamped down to the number of jobs if there are fewer jobs than that |
97
98
  | `debug` | run sequentially in-process — real tracebacks you can attach a debugger to, no pool |
98
99
  | `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
99
100
 
100
101
  Develop with `debug=True`, then flip it off. Chasing a bug through a process
101
- pool means reading a `RemoteTraceback` from a worker that has already exited,
102
+ pool means reading a traceback re-raised from a worker that has already exited,
102
103
  and it never tells you which job dict was at fault.
103
104
 
105
+ ### Logging
106
+
107
+ The library logs its whole lifecycle through the standard `logging` module and
108
+ writes nothing to stdout except the banner, so embedding it never pollutes a
109
+ host application's output. It installs only a `NullHandler` — to see anything,
110
+ configure logging yourself:
111
+
112
+ ```python
113
+ import logging
114
+ logging.basicConfig(level=logging.INFO)
115
+ ```
116
+
117
+ Two loggers, so either half can be tuned or silenced independently:
118
+
119
+ | logger | emits |
120
+ |---|---|
121
+ | `mpengine.orchestrator` | run start/done, resolved dirs, per-job outcomes, worker ranking |
122
+ | `mpengine.engine` | dispatch start/done, worker-count clamping, progress milestones, worker death |
123
+
124
+ Levels: **INFO** for run lifecycle, **DEBUG** for per-job success (a 10,000-job
125
+ sweep would otherwise be 10,000 INFO lines), **WARNING** for a failed job or a
126
+ clamped worker count, **ERROR** for a dead worker.
127
+
128
+ Every run also writes `<log_dir>/<run_id>/run.log` containing the parent's
129
+ whole view of that run — dispatch, milestones, every job outcome, the summary —
130
+ alongside the existing per-worker log files. That happens regardless of how you
131
+ configure logging, so a run is always a self-contained durable record.
132
+
133
+ **Progress display is terminal-aware.** Interactively you get the tqdm bars and
134
+ per-worker lines as before. When stderr is not a terminal — piped, redirected to
135
+ a log file, running under CI or a scheduler — those are suppressed (they render
136
+ as unreadable escape-sequence noise in a file) and periodic completion
137
+ milestones are logged at INFO instead.
138
+
104
139
  ### Closures and lambdas
105
140
 
106
141
  `func`, and any custom `save_fn`, can be a closure or a lambda — not just a
@@ -121,7 +156,7 @@ you want to drive the pool yourself:
121
156
  from mpengine import expand_call, process_jobs, process_jobs_
122
157
 
123
158
  jobs = [{"func": my_task, "x": 1, "y": 2}, {"func": other_task, "n": 5}]
124
- results = process_jobs(jobs, n_threads=8) # or process_jobs_ to stay sequential
159
+ results = process_jobs(jobs) # n_workers defaults to os.cpu_count(); or process_jobs_ to stay sequential
125
160
  ```
126
161
 
127
162
  A job is just a dict carrying its own callback plus that callback's kwargs, so a
@@ -139,6 +174,11 @@ for triangular workloads — where item `i` costs `O(i)`, such as an
139
174
  expanding-window computation — which keeps workers from idling while one
140
175
  overloaded worker finishes.
141
176
 
177
+ ## Changelog
178
+
179
+ See [CHANGELOG.md](CHANGELOG.md). Versions below 1.0 may carry breaking changes
180
+ in a minor release; each is listed there with the migration needed.
181
+
142
182
  ## What else is in the repo
143
183
 
144
184
  The `src/learning/` package holds demo and exercise scripts used while
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mpengine"
7
- version = "0.1.2"
7
+ version = "0.3.0"
8
8
  description = "A small, general-purpose multiprocessing engine: dispatch any callable across processes with run manifests, per-worker logs, on-disk outputs and failure isolation"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -49,11 +49,11 @@ Issues = "https://github.com/singhamandeep-kgp/multiprocessor/issues"
49
49
  where = ["src"]
50
50
  include = ["mpengine*"]
51
51
 
52
- # worker_names.json is data, not code - setuptools' package discovery above
53
- # only picks up .py files by default, so it needs to be listed explicitly to
54
- # actually ship inside a built wheel.
52
+ # worker_names.json and spider.txt are data, not code - setuptools' package
53
+ # discovery above only picks up .py files by default, so they need listing
54
+ # explicitly to actually ship inside a built wheel.
55
55
  [tool.setuptools.package-data]
56
- mpengine = ["worker_names.json"]
56
+ mpengine = ["worker_names.json", "spider.txt"]
57
57
 
58
58
  # `statarb` (needed only by src/learning) is installed separately as an editable
59
59
  # path install (pip install -e ../Equity_StatArb) rather than declared here,
@@ -39,6 +39,16 @@ Learning*, Ch.20 - the docstrings name the specific snippets - but nothing here
39
39
  is finance-specific; it parallelizes any callable.
40
40
  """
41
41
 
42
+ import logging as _logging
43
+
44
+ # A library must never configure logging for its host application - it only
45
+ # names its loggers and attaches a no-op handler so that a caller who has set
46
+ # nothing up sees no "No handlers could be found" noise. To actually see any
47
+ # of it: logging.basicConfig(level=logging.INFO). Loggers are
48
+ # "mpengine.engine" and "mpengine.orchestrator", so either half can be tuned
49
+ # or silenced on its own.
50
+ _logging.getLogger("mpengine").addHandler(_logging.NullHandler())
51
+
42
52
  from mpengine.engine import expand_call, process_jobs, process_jobs_, report_progress
43
53
  from mpengine.orchestrator import (
44
54
  JobResult,
@@ -73,4 +83,4 @@ __all__ = [
73
83
  "parts_to_molecules",
74
84
  ]
75
85
 
76
- __version__ = "0.1.2"
86
+ __version__ = "0.3.0"
@@ -0,0 +1,41 @@
1
+ """The run banner - purely cosmetic, deliberately kept out of everything else.
2
+
3
+ `orchestrator.py` imports exactly one name from here and calls it once. All the
4
+ ASCII art, formatting and printing lives in this module, so the engine and the
5
+ orchestration layer stay free of decoration.
6
+
7
+ The spider is the mascot: a multiprocessing engine, and a creature famous for
8
+ having eight legs. The art itself lives in `spider.txt` rather than as a string
9
+ literal here - it is data, not code, the same call made for `worker_names.json`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from importlib import resources
16
+
17
+
18
+ def _load_spider() -> str:
19
+ """Read the art from packaged data.
20
+
21
+ `read_text` applies universal-newline translation, so the file's stored
22
+ CRLF endings come back as `\\n` and render correctly on any OS without the
23
+ stored bytes ever being modified - which matters, because the art is kept
24
+ byte-for-byte as supplied.
25
+ """
26
+ return resources.files("mpengine").joinpath("spider.txt").read_text(encoding="utf-8")
27
+
28
+
29
+ def print_banner(task: str, n_workers: int, debug: bool, file=sys.stdout) -> None:
30
+ """Print the spider, then one line saying what is being launched.
31
+
32
+ Goes to stdout by default, matching the other run-level reporting `run()`
33
+ does (the stored-here paths, the worker ranking). The stderr stream stays
34
+ reserved for the live-redraw progress display, which is a different kind of
35
+ output and would interleave badly with anything else written there.
36
+ """
37
+ print(_load_spider(), file=file)
38
+ if debug:
39
+ print(f" mpengine - '{task}' sequentially (debug mode)\n", file=file)
40
+ else:
41
+ print(f" mpengine - '{task}' on {n_workers} workers\n", file=file)
@@ -0,0 +1,258 @@
1
+ """Ch.20 SS20.5 - the multiprocessing engine (exercises 6+7).
2
+
3
+ expand_call / process_jobs_ / process_jobs / report_progress - a generic
4
+ job-dict-to-function-call pipeline, reusable by any exercise rather than
5
+ tied to one atom/molecule shape. This is the book's own point: stop writing
6
+ a bespoke parallelization wrapper per function, and build one library that
7
+ can parallelize unknown functions regardless of their arguments or output.
8
+
9
+ A "job" here is just a dict: one 'func' entry (the callback) plus whatever
10
+ kwargs that callback needs. `expand_call` turns such a dict into a call,
11
+ which is the hinge the whole engine turns on.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime as dt
17
+ import logging
18
+ import os
19
+ import sys
20
+ import time
21
+ from concurrent.futures import ProcessPoolExecutor, as_completed
22
+ from concurrent.futures.process import BrokenProcessPool
23
+ from typing import Any, Callable
24
+
25
+ import cloudpickle
26
+
27
+ _log = logging.getLogger("mpengine.engine")
28
+
29
+
30
+ def expand_call(kargs: dict[str, Any]) -> Any:
31
+ """Ch.20 Snippet 20.10 (`expandCall`) - turn a job dict into a call.
32
+
33
+ Deviation from the book: `kargs` is copied before popping. The book's
34
+ literal `kargs.pop('func')` mutates the *caller's* dict in place, so the
35
+ same job list can only ever be run once - a second pass raises
36
+ KeyError('func'). ex04 deliberately runs one job list through both
37
+ `process_jobs_` and `process_jobs` to compare them, so the copy matters.
38
+ """
39
+ kargs = dict(kargs)
40
+ func = kargs.pop("func")
41
+ return func(**kargs)
42
+
43
+
44
+ def process_jobs_(jobs: list[dict[str, Any]]) -> list[Any]:
45
+ """Ch.20 Snippet 20.8 (`processJobs_`) - sequential fallback, for debugging.
46
+
47
+ Runs every job in-process, one at a time, in submission order. No pool,
48
+ no pickling, no spawn - so a failing job raises immediately, at its exact
49
+ position in the list, with an ordinary live traceback you can attach a
50
+ debugger to. That clarity is the entire reason this mode exists.
51
+ """
52
+ return [expand_call(job) for job in jobs]
53
+
54
+
55
+ def report_progress(job_num: int, num_jobs: int, time0: float, task: str) -> None:
56
+ """Ch.20 Snippet 20.9's `reportProgress`, modernized.
57
+
58
+ Overwrites its own line via '\\r' until the final job, then emits '\\n'.
59
+ Units stay the book's minutes even though a fast demo run always shows
60
+ "0.00 minutes" - the snippet is sized for real multi-minute workloads.
61
+ """
62
+ frac = job_num / num_jobs
63
+ minutes_elapsed = (time.time() - time0) / 60.0
64
+ minutes_remaining = minutes_elapsed * (1 / frac - 1) if frac > 0 else 0.0
65
+ timestamp = dt.datetime.now().isoformat(sep=" ", timespec="seconds")
66
+ msg = (
67
+ f"{timestamp} {frac * 100:5.1f}% {task} done after {minutes_elapsed:.2f} "
68
+ f"minutes. Remaining {minutes_remaining:.2f} minutes."
69
+ )
70
+ print(msg, end="\n" if job_num >= num_jobs else "\r", file=sys.stderr, flush=True)
71
+
72
+
73
+ def _run_from_blob(blob: bytes) -> tuple[int, float, Any]:
74
+ """Executor target: undo the cloudpickle wrapping `process_jobs` applies
75
+ before submission, then dispatch exactly as `expand_call` always has.
76
+
77
+ Returns `(pid, atom_seconds, result)`. The pid attributes the completion to
78
+ a specific worker process; `atom_seconds` is measured around the call
79
+ itself, *inside* the worker, so it is pure compute time - it cannot include
80
+ queueing, spawn cost, or the idle gap between this atom finishing and the
81
+ rest of the run finishing. That distinction is the whole point: timing the
82
+ same thing from the parent would measure when the result *arrived*, not how
83
+ long the work actually took.
84
+ """
85
+ job = cloudpickle.loads(blob)
86
+ t0 = time.perf_counter()
87
+ result = expand_call(job)
88
+ return os.getpid(), time.perf_counter() - t0, result
89
+
90
+
91
+ def _infer_task_name(jobs: list[dict[str, Any]]) -> str:
92
+ """Best-effort display name for a job list.
93
+
94
+ The obvious `jobs[0]["func"].__name__` crashes with AttributeError on the
95
+ very callables this engine goes out of its way to support: a
96
+ `functools.partial` (a natural way to pin one big fitted object once) and
97
+ any class instance implementing `__call__` have no `__name__`. Degrade to
98
+ the type name, then to a constant, rather than refusing to run.
99
+ """
100
+ func = jobs[0].get("func")
101
+ return getattr(func, "__name__", None) or type(func).__name__ or "job"
102
+
103
+
104
+ def process_jobs(
105
+ jobs: list[dict[str, Any]],
106
+ task: str | None = None,
107
+ n_workers: int = os.cpu_count() or 4,
108
+ on_progress: Callable[[int, float, Any], None] | None = None,
109
+ on_job_error: Callable[[int, BaseException], None] | None = None,
110
+ text_progress: bool | None = None,
111
+ ) -> list[Any]:
112
+ """Ch.20 Snippet 20.9 (`processJobs`) - parallel dispatch over a process pool.
113
+
114
+ Named `n_workers`, not `n_threads`: this spawns OS *processes*, each with
115
+ its own interpreter and memory space, not threads sharing one. That
116
+ distinction matters here specifically - ex02 measured that CPython's GIL
117
+ makes threads add nothing for CPU-bound work, which is exactly what this
118
+ engine dispatches.
119
+
120
+ Results arrive in *completion* order, not submission order, which is what
121
+ lets `report_progress` report honestly as each job lands - with uneven job
122
+ costs (see ex03), an early-submitted heavy job can finish long after
123
+ several later-submitted light ones. (`orchestrator.run` re-sorts back to
124
+ submission order before returning, since its results are label-addressed.)
125
+
126
+ Each job is serialized with `cloudpickle` before submission (rather than
127
+ relying on the executor's own stdlib-`pickle` handling), so a job's 'func'
128
+ - or a nested callable, like `orchestrator.run`'s `save_fn` - can be a
129
+ closure or a lambda, not just a module-level function. `_run_from_blob` is
130
+ itself a plain module-level function, so it still pickles by reference with
131
+ no dependence on `__main__` spawn fixup.
132
+
133
+ Deliberately built on `concurrent.futures.ProcessPoolExecutor` rather than
134
+ the book's literal `mp.Pool` + `imap_unordered`. `mp.Pool` cannot detect a
135
+ worker that DIES mid-job (OOM-killed, or segfaulting inside native code
136
+ such as BLAS): CPython only resolves a task slot when a result arrives on
137
+ the output queue, and a dead process posts nothing, so the run blocks
138
+ forever with no exception and no log line. `ProcessPoolExecutor` watches
139
+ its workers and raises `BrokenProcessPool` instead - a hang that never
140
+ surfaces is far worse for an unattended run than a loud failure. The
141
+ `with` block here is also genuinely graceful, unlike `mp.Pool.__exit__`,
142
+ which calls `terminate()`.
143
+
144
+ `on_progress`, if given, is called as `on_progress(pid, atom_seconds,
145
+ result)` for every completed job - `pid` identifies which worker process
146
+ produced it and `atom_seconds` is that job's pure compute time as measured
147
+ inside the worker, which is what lets a caller (see `orchestrator.run`'s
148
+ `show_progress`) drive a per-worker live display with real timings. The
149
+ return value here is unaffected either way - still the plain `list[Any]`
150
+ of results, never the `(pid, atom_seconds, result)` triples.
151
+ `text_progress` controls the book's own `\\r`-overwriting stderr line.
152
+ Left at None it is automatic: emitted only when no `on_progress` display
153
+ is running AND stderr is a real terminal. That distinction matters in
154
+ production - redirected to a log file, a `\\r` line per job is unreadable
155
+ noise, so when the text display is off this logs periodic completion
156
+ milestones at INFO instead. Pass True/False to force it either way.
157
+
158
+ `on_job_error`, if given, is called as `on_job_error(index, exc)` for any
159
+ job that cannot be *serialized* for submission, and that job is skipped
160
+ instead of sinking the batch. Jobs are cloudpickled up front, so without
161
+ this one unpicklable payload (a lock, a live socket, an open file handle
162
+ captured by a closure) raises before any job runs at all - job 517 of 1000
163
+ taking down the 999 that were perfectly runnable. Left as None, that
164
+ original fail-fast behaviour is preserved, which keeps this layer
165
+ book-faithful; `orchestrator.run` opts in to turn such a failure into one
166
+ failed job, matching the per-job isolation it already promises.
167
+ """
168
+ if not jobs:
169
+ return []
170
+ if task is None:
171
+ task = _infer_task_name(jobs)
172
+
173
+ # Serialize per job rather than in one comprehension, so a single
174
+ # unpicklable payload can be attributed and skipped instead of aborting
175
+ # the whole submission (see `on_job_error`).
176
+ blobs: list[bytes] = []
177
+ for i, job in enumerate(jobs):
178
+ try:
179
+ blobs.append(cloudpickle.dumps(job))
180
+ except Exception as exc:
181
+ if on_job_error is None:
182
+ raise
183
+ _log.warning(
184
+ "job could not be serialized for dispatch, skipping "
185
+ "task=%s job_index=%d error=%s", task, i, exc,
186
+ )
187
+ on_job_error(i, exc)
188
+ if not blobs:
189
+ _log.warning("nothing dispatchable task=%s n_jobs=%d", task, len(jobs))
190
+ return []
191
+
192
+ # Never spin up more workers than there are jobs to hand them: the
193
+ # executor starts its processes eagerly, so surplus workers pay full spawn
194
+ # cost to do nothing. This only ever clamps downward - when jobs outnumber
195
+ # n_workers no clamp is needed, since the same fixed pool keeps pulling
196
+ # jobs until the list is done. Worker count is sized to hardware, not to
197
+ # job count.
198
+ out: list[Any] = []
199
+ time0 = time.time()
200
+ n_submitted = len(blobs)
201
+ n_pool = min(n_workers, n_submitted)
202
+ if n_pool < n_workers:
203
+ _log.warning(
204
+ "n_workers=%d clamped to %d task=%s reason=fewer jobs than workers",
205
+ n_workers, n_pool, task,
206
+ )
207
+
208
+ # Auto: the book's carriage-return line is a terminal affordance, so only emit it when
209
+ # a terminal is actually there and nothing else owns the display.
210
+ if text_progress is None:
211
+ text_progress = on_progress is None and sys.stderr.isatty()
212
+ # When nothing is drawing live, log milestones so a redirected run still
213
+ # shows movement - roughly ten lines, whatever the job count.
214
+ milestone = max(1, n_submitted // 10) if not text_progress else 0
215
+
216
+ _log.info(
217
+ "dispatch start task=%s n_jobs=%d n_workers=%d text_progress=%s",
218
+ task, n_submitted, n_pool, text_progress,
219
+ )
220
+ with ProcessPoolExecutor(max_workers=n_pool) as executor:
221
+ futures = [executor.submit(_run_from_blob, blob) for blob in blobs]
222
+ try:
223
+ for i, future in enumerate(as_completed(futures), 1):
224
+ pid, atom_s, out_ = future.result()
225
+ out.append(out_)
226
+ if on_progress is not None:
227
+ on_progress(pid, atom_s, out_)
228
+ if text_progress:
229
+ report_progress(i, n_submitted, time0, task)
230
+ elif milestone and (i % milestone == 0 or i == n_submitted):
231
+ _log.info(
232
+ "progress task=%s done=%d/%d pct=%.0f elapsed_s=%.2f",
233
+ task, i, n_submitted, 100.0 * i / n_submitted,
234
+ time.time() - time0,
235
+ )
236
+ except BrokenProcessPool as exc:
237
+ # Cancel whatever has not started so shutdown does not block on
238
+ # work that can never complete, then re-raise with the context the
239
+ # bare stdlib error lacks: how far the run actually got.
240
+ for future in futures:
241
+ future.cancel()
242
+ _log.error(
243
+ "worker process died task=%s completed=%d/%d - run aborted",
244
+ task, len(out), n_submitted,
245
+ )
246
+ raise BrokenProcessPool(
247
+ f"a worker process died during task {task!r} after "
248
+ f"{len(out)}/{n_submitted} jobs completed - it was most likely "
249
+ f"OOM-killed or crashed inside native code (e.g. BLAS). The "
250
+ f"completed jobs' results were lost with the pool; if you need "
251
+ f"per-job durability use orchestrator.run, which saves each "
252
+ f"result to disk as it lands."
253
+ ) from exc
254
+ _log.info(
255
+ "dispatch done task=%s completed=%d/%d elapsed_s=%.2f",
256
+ task, len(out), n_submitted, time.time() - time0,
257
+ )
258
+ return out