pivot 0.3.7__tar.gz → 0.3.8__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.
- {pivot-0.3.7 → pivot-0.3.8}/PKG-INFO +3 -1
- {pivot-0.3.7 → pivot-0.3.8}/pyproject.toml +3 -1
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/worker_pool.py +58 -11
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/AGENTS.md +4 -2
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/core.py +5 -2
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/worker.py +179 -3
- {pivot-0.3.7 → pivot-0.3.8}/LICENSE +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/README.md +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/__init__.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/ast_utils.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/AGENTS.md +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/CLAUDE.md +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/__init__.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/_run_common.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/check_ignore.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/checkout.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/commit.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/completion.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/config.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/console.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/dag.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/data.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/decorators.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/doctor.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/errors.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/fingerprint.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/helpers.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/history.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/import_cmd.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/init.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/list.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/metrics.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/params.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/plots.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/remote.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/repro.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/run.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/status.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/targets.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/track.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/update.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/cli/verify.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/compose.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/config/__init__.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/config/io.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/config/models.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/dag/__init__.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/dag/render.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/decorators.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/discovery.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/__init__.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/agent_rpc.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/engine.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/graph.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/scheduler.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/sinks.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/sources.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/types.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/engine/watch.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/exceptions.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/CLAUDE.md +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/__init__.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/executor/commit.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/explain.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/fingerprint.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/git.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/ignore.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/import_artifact.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/loaders.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/metrics.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/names.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/outputs.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/parameters.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/path_policy.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/project.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/py.typed +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/registry.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/AGENTS.md +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/__init__.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/config.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/git_archive.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/github.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/storage.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/remote/sync.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/run_history.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/__init__.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/common.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/data.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/metrics.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/params.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/show/plots.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/skip.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/stage_def.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/status.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/AGENTS.md +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/CLAUDE.md +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/__init__.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/artifact_lock.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/cache.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/lock.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/presentation.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/restore.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/state.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/store.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/storage/track.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/types.py +0 -0
- {pivot-0.3.7 → pivot-0.3.8}/src/pivot/yaml_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pivot
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.8
|
|
4
4
|
Summary: High-performance Python pipeline tool with automatic code change detection
|
|
5
5
|
Author: Sami Jawhar
|
|
6
6
|
Author-email: Sami Jawhar <sami@metr.org>
|
|
@@ -23,6 +23,7 @@ Requires-Dist: loky>=3.4
|
|
|
23
23
|
Requires-Dist: networkx>=3.0
|
|
24
24
|
Requires-Dist: pathspec>=0.12.0
|
|
25
25
|
Requires-Dist: pandas>=2.0
|
|
26
|
+
Requires-Dist: psutil>=7.0
|
|
26
27
|
Requires-Dist: pydantic>=2.13
|
|
27
28
|
Requires-Dist: pygtrie>=2.5.0
|
|
28
29
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -55,6 +56,7 @@ Requires-Dist: pytest-xdist>=3.8.0 ; extra == 'dev'
|
|
|
55
56
|
Requires-Dist: pytest>=7.0 ; extra == 'dev'
|
|
56
57
|
Requires-Dist: ruff>=0.8.0 ; extra == 'dev'
|
|
57
58
|
Requires-Dist: types-aioboto3[all]>=13.0.0 ; extra == 'dev'
|
|
59
|
+
Requires-Dist: types-psutil>=7.0 ; extra == 'dev'
|
|
58
60
|
Requires-Dist: mkdocs>=1.6.0 ; extra == 'docs'
|
|
59
61
|
Requires-Dist: mkdocs-material>=9.5.0 ; extra == 'docs'
|
|
60
62
|
Requires-Dist: mkdocstrings[python]>=0.26.0 ; extra == 'docs'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pivot"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.8"
|
|
4
4
|
description = "High-performance Python pipeline tool with automatic code change detection"
|
|
5
5
|
requires-python = ">=3.13,<3.14"
|
|
6
6
|
readme = "README.md"
|
|
@@ -27,6 +27,7 @@ dependencies = [
|
|
|
27
27
|
"networkx>=3.0",
|
|
28
28
|
"pathspec>=0.12.0",
|
|
29
29
|
"pandas>=2.0",
|
|
30
|
+
"psutil>=7.0",
|
|
30
31
|
"pydantic>=2.13",
|
|
31
32
|
"pygtrie>=2.5.0",
|
|
32
33
|
"pyyaml>=6.0",
|
|
@@ -76,6 +77,7 @@ dev = [
|
|
|
76
77
|
"pytest>=7.0",
|
|
77
78
|
"ruff>=0.8.0",
|
|
78
79
|
"types-aioboto3[all]>=13.0.0",
|
|
80
|
+
"types-psutil>=7.0",
|
|
79
81
|
]
|
|
80
82
|
|
|
81
83
|
[project.scripts]
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import errno
|
|
4
5
|
import logging
|
|
5
6
|
import os
|
|
6
7
|
import pathlib
|
|
8
|
+
import signal
|
|
7
9
|
import threading
|
|
10
|
+
import time
|
|
8
11
|
import typing
|
|
9
12
|
from dataclasses import dataclass, field
|
|
10
13
|
|
|
@@ -17,6 +20,9 @@ _T = typing.TypeVar("_T")
|
|
|
17
20
|
|
|
18
21
|
_logger = logging.getLogger(__name__)
|
|
19
22
|
|
|
23
|
+
# How long hard_cancel waits for a SIGKILLed worker to be reaped; it never blocks pivot's exit.
|
|
24
|
+
_KILLED_WORKER_JOIN_S = 5.0
|
|
25
|
+
|
|
20
26
|
_FileStamp = tuple[int, int, int, int, int]
|
|
21
27
|
|
|
22
28
|
|
|
@@ -194,16 +200,57 @@ class WorkerPool:
|
|
|
194
200
|
self._shutdown_event = None
|
|
195
201
|
|
|
196
202
|
def hard_cancel(self) -> None:
|
|
197
|
-
"""
|
|
203
|
+
"""Stop workers mid-stage and signal the drain thread to stop.
|
|
204
|
+
|
|
205
|
+
Each worker is sent SIGTERM, which unwinds its running stage
|
|
206
|
+
(``worker.unwind_when_stopped``) so the stage's cleanup runs, and is given
|
|
207
|
+
``worker.TERMINATION_GRACE_S`` to exit; a worker still alive then is killed with its
|
|
208
|
+
descendants.
|
|
209
|
+
|
|
210
|
+
The workers are first taken out of loky's executor. Its manager thread takes a worker
|
|
211
|
+
that exits without being asked to for a broken pool and SIGKILLs every worker the
|
|
212
|
+
executor still lists, so the first worker to exit would cut the others' cleanup off.
|
|
213
|
+
"""
|
|
198
214
|
self._accepting = False
|
|
199
215
|
self._in_run = False
|
|
200
|
-
|
|
201
|
-
self._executor
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
216
|
+
try:
|
|
217
|
+
if self._executor is not None:
|
|
218
|
+
executor = self._executor
|
|
219
|
+
self._executor = None
|
|
220
|
+
try:
|
|
221
|
+
self._stop_workers(executor)
|
|
222
|
+
finally:
|
|
223
|
+
# Only shutdown wakes loky's manager thread once its workers are gone.
|
|
224
|
+
executor.shutdown(wait=False, kill_workers=True)
|
|
225
|
+
finally:
|
|
226
|
+
if self._manager is not None:
|
|
227
|
+
self._manager.shutdown()
|
|
228
|
+
self._manager = None
|
|
229
|
+
if self._shutdown_event is not None:
|
|
230
|
+
self._shutdown_event.set()
|
|
231
|
+
self._output_queue = None
|
|
232
|
+
self._shutdown_event = None
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _stop_workers(executor: loky.ProcessPoolExecutor) -> None:
|
|
236
|
+
"""Take ``executor``'s workers out of it, send each SIGTERM, and SIGKILL with its
|
|
237
|
+
descendants any worker still alive after ``worker.TERMINATION_GRACE_S``."""
|
|
238
|
+
# loky exposes no public list of its worker processes. A watch reload may have
|
|
239
|
+
# replaced this executor already, which leaves it no lock and no workers.
|
|
240
|
+
with executor._processes_management_lock or contextlib.nullcontext():
|
|
241
|
+
processes = list(executor._processes.values())
|
|
242
|
+
executor._processes.clear()
|
|
243
|
+
for process in processes:
|
|
244
|
+
if process.pid is not None:
|
|
245
|
+
with contextlib.suppress(ProcessLookupError):
|
|
246
|
+
os.kill(process.pid, signal.SIGTERM)
|
|
247
|
+
deadline = time.monotonic() + worker.TERMINATION_GRACE_S
|
|
248
|
+
for process in processes:
|
|
249
|
+
process.join(max(0.0, deadline - time.monotonic()))
|
|
250
|
+
for process in processes:
|
|
251
|
+
if process.pid is not None and process.exitcode is None:
|
|
252
|
+
worker.kill_descendants(process.pid)
|
|
253
|
+
# loky's process object has no kill(); signal the pid directly.
|
|
254
|
+
with contextlib.suppress(ProcessLookupError):
|
|
255
|
+
os.kill(process.pid, signal.SIGKILL)
|
|
256
|
+
process.join(_KILLED_WORKER_JOIN_S)
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
Workers execute in separate processes via `loky.get_reusable_executor()`. An Engine keeps its
|
|
4
4
|
workers and manager alive across its runs; each run receives a fresh manager queue for output,
|
|
5
5
|
and a manager that cannot be reached when a run starts is replaced then.
|
|
6
|
-
The loky initializer and the manager's initializer
|
|
7
|
-
|
|
6
|
+
The loky initializer (`worker.unwind_when_stopped`) and the manager's initializer
|
|
7
|
+
(`core._exit_when_parent_dies`) start the parent-death watcher, so a worker or manager exits
|
|
8
|
+
when its Pivot process dies; a worker first unwinds its running stage so its `finally` blocks
|
|
9
|
+
run, within `worker.TERMINATION_GRACE_S`. Keep `timeout=None` and
|
|
8
10
|
`reuse=False if kill_workers else "auto"` in the shared executor policy.
|
|
9
11
|
|
|
10
12
|
## WorkerStageInfo Contract
|
|
@@ -37,7 +37,10 @@ def _noop() -> None:
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def _exit_when_parent_dies(parent_pid: int) -> None:
|
|
40
|
-
"""Exit
|
|
40
|
+
"""Exit the output-queue manager after its owning Pivot process dies.
|
|
41
|
+
|
|
42
|
+
Workers use ``worker.unwind_when_stopped`` instead, which lets the running stage unwind.
|
|
43
|
+
"""
|
|
41
44
|
|
|
42
45
|
def _watch_parent() -> None:
|
|
43
46
|
poll = threading.Event()
|
|
@@ -82,7 +85,7 @@ def _get_executor(max_workers: int, *, kill_workers: bool = False) -> loky.Proce
|
|
|
82
85
|
timeout=_WORKER_IDLE_TIMEOUT_S,
|
|
83
86
|
kill_workers=kill_workers,
|
|
84
87
|
reuse=False if kill_workers else "auto",
|
|
85
|
-
initializer=
|
|
88
|
+
initializer=worker.unwind_when_stopped,
|
|
86
89
|
initargs=(os.getpid(),),
|
|
87
90
|
)
|
|
88
91
|
|
|
@@ -10,17 +10,31 @@ import codecs
|
|
|
10
10
|
import collections
|
|
11
11
|
import collections.abc as collections_abc
|
|
12
12
|
import contextlib
|
|
13
|
+
import dataclasses
|
|
13
14
|
import logging
|
|
14
15
|
import os
|
|
15
16
|
import pathlib
|
|
16
17
|
import queue
|
|
17
18
|
import random
|
|
19
|
+
import signal
|
|
18
20
|
import sys
|
|
19
21
|
import threading
|
|
20
22
|
import traceback
|
|
21
23
|
import warnings
|
|
22
|
-
from typing import
|
|
24
|
+
from typing import (
|
|
25
|
+
IO,
|
|
26
|
+
TYPE_CHECKING,
|
|
27
|
+
Any,
|
|
28
|
+
Literal,
|
|
29
|
+
NamedTuple,
|
|
30
|
+
NoReturn,
|
|
31
|
+
TypedDict,
|
|
32
|
+
cast,
|
|
33
|
+
overload,
|
|
34
|
+
override,
|
|
35
|
+
)
|
|
23
36
|
|
|
37
|
+
import psutil
|
|
24
38
|
import pydantic
|
|
25
39
|
|
|
26
40
|
from pivot import (
|
|
@@ -58,8 +72,142 @@ if TYPE_CHECKING:
|
|
|
58
72
|
from multiprocessing import Queue
|
|
59
73
|
from types import TracebackType
|
|
60
74
|
|
|
75
|
+
|
|
61
76
|
logger = logging.getLogger(__name__)
|
|
62
77
|
|
|
78
|
+
# How long a stopped worker's running stage may take to unwind before the worker exits.
|
|
79
|
+
TERMINATION_GRACE_S = 10.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WorkerTerminated(KeyboardInterrupt): # noqa: N818 - names the event, as KeyboardInterrupt does
|
|
83
|
+
"""Raised in a worker's running stage when its run is cancelled or its Pivot process dies.
|
|
84
|
+
|
|
85
|
+
It is a KeyboardInterrupt, as Ctrl-C raises at a terminal: a stage's ``except Exception``
|
|
86
|
+
does not stop it, and its ``except KeyboardInterrupt`` cleanup, ``finally`` blocks and
|
|
87
|
+
context managers run. A stage that catches it and carries on has its result discarded.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclasses.dataclass(slots=True)
|
|
92
|
+
class _StopState:
|
|
93
|
+
"""Process-wide stop state of a worker, set by its signal handlers and its watchdog.
|
|
94
|
+
|
|
95
|
+
Signals and the parent watchdog act on the whole process, so this state is too.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
stage_running: bool = False
|
|
99
|
+
# The running stage's run was cancelled: its result must not be committed.
|
|
100
|
+
stop_requested: bool = False
|
|
101
|
+
# WorkerTerminated was raised into the running stage; never raise it again mid-cleanup.
|
|
102
|
+
interrupted: bool = False
|
|
103
|
+
# The worker exits once the running stage has unwound (SIGTERM or parent death).
|
|
104
|
+
exit_requested: bool = False
|
|
105
|
+
exit_timer_started: bool = False
|
|
106
|
+
lock: threading.Lock = dataclasses.field(default_factory=threading.Lock)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_stop = _StopState()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def kill_descendants(pid: int) -> None:
|
|
113
|
+
"""SIGKILL every live descendant of process ``pid``. A process that detached from that
|
|
114
|
+
tree (double-forked, or orphaned when its parent exited) is not one."""
|
|
115
|
+
try:
|
|
116
|
+
descendants = psutil.Process(pid).children(recursive=True)
|
|
117
|
+
except psutil.Error:
|
|
118
|
+
return
|
|
119
|
+
for descendant in descendants:
|
|
120
|
+
with contextlib.suppress(psutil.Error):
|
|
121
|
+
descendant.kill()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _exit_worker(code: int) -> NoReturn:
|
|
125
|
+
"""Exit this worker, first killing the processes still descended from it, which would
|
|
126
|
+
otherwise be left to init (a stage's ``Popen`` with no cleanup, or one whose cleanup was
|
|
127
|
+
cut off)."""
|
|
128
|
+
kill_descendants(os.getpid())
|
|
129
|
+
os._exit(code)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _exit_after_grace() -> None:
|
|
133
|
+
threading.Event().wait(TERMINATION_GRACE_S)
|
|
134
|
+
_exit_worker(1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _start_exit_timer() -> None:
|
|
138
|
+
"""Exit this worker ``TERMINATION_GRACE_S`` from now, whatever its running stage does."""
|
|
139
|
+
with _stop.lock:
|
|
140
|
+
if _stop.exit_timer_started:
|
|
141
|
+
return
|
|
142
|
+
_stop.exit_timer_started = True
|
|
143
|
+
threading.Thread(target=_exit_after_grace, daemon=True, name="pivot-stop-grace").start()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _interrupt_stage(_signum: int, _frame: object) -> None:
|
|
147
|
+
"""SIGINT handler of a worker: Ctrl-C at a terminal, which reaches Pivot and its workers at
|
|
148
|
+
once. Interrupt the running stage once, so its cleanup runs, and keep the worker up until
|
|
149
|
+
Pivot stops it: loky takes a worker that exits on its own for a broken pool and SIGKILLs
|
|
150
|
+
every other worker, cutting their cleanup off."""
|
|
151
|
+
if not _stop.stage_running or _stop.interrupted:
|
|
152
|
+
return
|
|
153
|
+
_stop.stop_requested = True
|
|
154
|
+
_stop.interrupted = True
|
|
155
|
+
raise WorkerTerminated
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _stop_worker(_signum: int, _frame: object) -> None:
|
|
159
|
+
"""SIGTERM handler of a worker: interrupt the running stage once, exit once it has unwound,
|
|
160
|
+
and exit regardless after ``TERMINATION_GRACE_S``. An idle worker exits at once."""
|
|
161
|
+
_stop.stop_requested = True
|
|
162
|
+
_stop.exit_requested = True
|
|
163
|
+
_start_exit_timer()
|
|
164
|
+
if not _stop.stage_running:
|
|
165
|
+
_exit_worker(0)
|
|
166
|
+
if _stop.interrupted:
|
|
167
|
+
return
|
|
168
|
+
_stop.interrupted = True
|
|
169
|
+
raise WorkerTerminated
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _raise_if_stopped() -> None:
|
|
173
|
+
"""Refuse to commit the result of a stage whose run was cancelled, which can only be here
|
|
174
|
+
because the stage caught ``WorkerTerminated`` and carried on."""
|
|
175
|
+
if _stop.stop_requested:
|
|
176
|
+
raise WorkerTerminated
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def unwind_when_stopped(parent_pid: int) -> None:
|
|
180
|
+
"""Loky worker initializer: stop this worker when it is told to or its parent dies.
|
|
181
|
+
|
|
182
|
+
SIGTERM (``WorkerPool.hard_cancel``) or SIGINT (Ctrl-C at a terminal) raises
|
|
183
|
+
``WorkerTerminated`` in the running stage, so its cleanup runs (stopping the processes or
|
|
184
|
+
containers it started); after SIGTERM the worker then exits. When the Pivot process that
|
|
185
|
+
started the worker dies, as when it is SIGKILLed, a watchdog thread sends this worker's
|
|
186
|
+
main thread SIGTERM (a signal to that thread interrupts a blocking call there) and exits
|
|
187
|
+
the worker after ``TERMINATION_GRACE_S`` itself, in case the stage replaced the SIGTERM
|
|
188
|
+
handler or is blocked where no signal reaches it. Every exit first kills the processes
|
|
189
|
+
still descended from the worker.
|
|
190
|
+
|
|
191
|
+
A SIGINT the worker inherited as ignored (pivot run as a background job of a
|
|
192
|
+
non-interactive shell) stays ignored, as it does for pivot itself.
|
|
193
|
+
"""
|
|
194
|
+
signal.signal(signal.SIGTERM, _stop_worker)
|
|
195
|
+
if signal.getsignal(signal.SIGINT) is not signal.SIG_IGN:
|
|
196
|
+
signal.signal(signal.SIGINT, _interrupt_stage)
|
|
197
|
+
main_thread = threading.main_thread().ident
|
|
198
|
+
assert main_thread is not None
|
|
199
|
+
|
|
200
|
+
def _watch_parent() -> None:
|
|
201
|
+
poll = threading.Event()
|
|
202
|
+
while os.getppid() == parent_pid:
|
|
203
|
+
poll.wait(1)
|
|
204
|
+
_stop.exit_requested = True # first: a stage starting now keeps stop_requested set
|
|
205
|
+
_stop.stop_requested = True
|
|
206
|
+
_start_exit_timer()
|
|
207
|
+
signal.pthread_kill(main_thread, signal.SIGTERM)
|
|
208
|
+
|
|
209
|
+
threading.Thread(target=_watch_parent, daemon=True, name="pivot-parent-watch").start()
|
|
210
|
+
|
|
63
211
|
|
|
64
212
|
class _QueueLoggingHandler(logging.Handler):
|
|
65
213
|
"""Logging handler that sends records to the output queue.
|
|
@@ -375,6 +523,7 @@ def _execute_stage(
|
|
|
375
523
|
),
|
|
376
524
|
output_paths=lock_output_paths(stage_outs, store, project_root),
|
|
377
525
|
)
|
|
526
|
+
_raise_if_stopped()
|
|
378
527
|
deferred = _commit_lock_and_build_deferred(
|
|
379
528
|
stage_info,
|
|
380
529
|
new_lock_data,
|
|
@@ -447,6 +596,7 @@ def _execute_stage(
|
|
|
447
596
|
|
|
448
597
|
# Single StateDB open for post-execution work
|
|
449
598
|
with state.StateDB(state_dir, readonly=True) as state_db:
|
|
599
|
+
_raise_if_stopped()
|
|
450
600
|
deferred = _commit_lock_and_build_deferred(
|
|
451
601
|
stage_info,
|
|
452
602
|
new_lock_data,
|
|
@@ -509,14 +659,39 @@ def user_code_files() -> list[str]:
|
|
|
509
659
|
return sorted(files)
|
|
510
660
|
|
|
511
661
|
|
|
662
|
+
def _begin_stage() -> None:
|
|
663
|
+
"""Mark a stage running, clearing a Ctrl-C that interrupted the previous one. The running
|
|
664
|
+
flag is set last, so a stop signal before it is handled as one to an idle worker."""
|
|
665
|
+
_stop.interrupted = False
|
|
666
|
+
if not _stop.exit_requested:
|
|
667
|
+
_stop.stop_requested = False
|
|
668
|
+
_stop.stage_running = True
|
|
669
|
+
|
|
670
|
+
|
|
512
671
|
def execute_stage(
|
|
513
672
|
stage_name: str,
|
|
514
673
|
stage_info: WorkerStageInfo,
|
|
515
674
|
cache_dir: pathlib.Path,
|
|
516
675
|
output_queue: Queue[OutputMessage],
|
|
517
676
|
) -> StageResult:
|
|
518
|
-
"""Execute a stage and report every user-code module resident in this worker.
|
|
519
|
-
|
|
677
|
+
"""Execute a stage and report every user-code module resident in this worker.
|
|
678
|
+
|
|
679
|
+
A stage interrupted by a stop signal (``unwind_when_stopped``) fails without committing
|
|
680
|
+
anything. After SIGTERM or the death of its Pivot process, the worker exits once the stage
|
|
681
|
+
has unwound instead of returning.
|
|
682
|
+
"""
|
|
683
|
+
try:
|
|
684
|
+
_begin_stage()
|
|
685
|
+
result = _execute_stage(stage_name, stage_info, cache_dir, output_queue)
|
|
686
|
+
except WorkerTerminated:
|
|
687
|
+
# Raised outside the stage's own error handling, as while its logging is set up.
|
|
688
|
+
result = StageResult(
|
|
689
|
+
status=StageStatus.FAILED, reason="KeyboardInterrupt", input_hash=None, output_lines=[]
|
|
690
|
+
)
|
|
691
|
+
finally:
|
|
692
|
+
_stop.stage_running = False
|
|
693
|
+
if _stop.exit_requested:
|
|
694
|
+
_exit_worker(1)
|
|
520
695
|
result["user_code_files"] = user_code_files()
|
|
521
696
|
return result
|
|
522
697
|
|
|
@@ -1116,6 +1291,7 @@ def _run_stage_function_with_store(
|
|
|
1116
1291
|
_set_deterministic_seeds()
|
|
1117
1292
|
|
|
1118
1293
|
result = _execute_with_joblib_protection(func, kwargs)
|
|
1294
|
+
_raise_if_stopped()
|
|
1119
1295
|
|
|
1120
1296
|
output_values = _resolve_output_values(result, outs, stage_name)
|
|
1121
1297
|
output_paths = dict[types.ArtifactRef, pathlib.Path]()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|