py-cluster-api 0.7.0__tar.gz → 0.8.1__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.
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/CLAUDE.md +1 -1
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/PKG-INFO +45 -2
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/README.md +43 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/_types.py +1 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/core.py +41 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/executors/local.py +216 -28
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/executors/lsf.py +24 -1
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/docs/API.md +8 -3
- py_cluster_api-0.8.1/docs/superpowers/plans/2026-07-24-job-dependency-chaining.md +786 -0
- py_cluster_api-0.8.1/docs/superpowers/specs/2026-07-24-job-dependency-chaining-design.md +148 -0
- py_cluster_api-0.8.1/notes/lsf-implementations-comparison.html +561 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/pyproject.toml +1 -1
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_integration.py +47 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_local.py +163 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_lsf.py +197 -11
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_reconnect.py +7 -0
- py_cluster_api-0.7.0/unknown-fix.md +0 -131
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/.github/workflows/ci.yml +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/.gitignore +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/LICENSE +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/__init__.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/config.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/exceptions.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/executors/__init__.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/monitor.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/script.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/docs/Development.md +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/pixi.lock +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/__init__.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/cluster_config.example.yaml +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/conftest.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_config.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_core.py +0 -0
- {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_monitor.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Generic Python library for submitting and monitoring jobs on HPC clusters. Wraps scheduler CLIs (bsub/bjobs/bkill) behind an async executor abstraction with an active polling monitor that fires callbacks on job completion. Inspired by dask-jobqueue's script templating and Nextflow's portable config profiles, but unlike dask-jobqueue, this library actively polls the scheduler rather than relying on workers phoning home.
|
|
4
4
|
|
|
5
|
-
Key capabilities beyond submit/poll/cancel: `reconnect()` rediscovers running jobs after a process restart (requires `job_name_prefix`), and `cancel_by_name()` kills jobs by name pattern (LSF only).
|
|
5
|
+
Key capabilities beyond submit/poll/cancel: `reconnect()` rediscovers running jobs after a process restart (requires `job_name_prefix`), and `cancel_by_name()` kills jobs by name pattern (LSF only). `depends_on` on `submit()`/`submit_array()` chains jobs (native `bsub -w ... -ti` on LSF; poll-driven deferred start on Local) with auto-cancel of dependents when an upstream job fails.
|
|
6
6
|
|
|
7
7
|
Founding principles: async-only API, executors are thin wrappers around scheduler CLIs, all state lives in `JobRecord` dataclasses tracked in-process, monitoring is poll-based via `bjobs -json`, and configuration uses Nextflow-style YAML profiles.
|
|
8
8
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: py-cluster-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Generic Python library for running jobs on HPC clusters
|
|
5
5
|
Project-URL: Homepage, https://github.com/JaneliaSciComp/py-cluster-api
|
|
6
6
|
Project-URL: Repository, https://github.com/JaneliaSciComp/py-cluster-api
|
|
@@ -71,6 +71,9 @@ A Python library for submitting and monitoring jobs on HPC clusters. Supports ru
|
|
|
71
71
|
- **Zombie detection** — jobs that disappear from the scheduler are marked as failed
|
|
72
72
|
- **YAML config with profiles** — Nextflow-style config with per-environment profiles
|
|
73
73
|
- **Callback chaining** — register `on_success`, `on_failure`, or `on_exit` handlers on any job
|
|
74
|
+
- **Job dependencies** — chain jobs with `depends_on`; dependents are auto-cancelled if an upstream job fails
|
|
75
|
+
- **Environment control** — `inherit_env` and `login_shell` submit options control what environment a job runs with
|
|
76
|
+
- **Reconnect & external tracking** — `reconnect()` rediscovers jobs after a restart; `track()` seeds a job from an external store (e.g. a database) into the executor
|
|
74
77
|
|
|
75
78
|
## Installation
|
|
76
79
|
|
|
@@ -136,6 +139,24 @@ async def run_array():
|
|
|
136
139
|
|
|
137
140
|
The array index environment variable depends on the executor: LSF uses `$LSB_JOBINDEX`, while the local executor uses `$ARRAY_INDEX`.
|
|
138
141
|
|
|
142
|
+
### Job Dependencies
|
|
143
|
+
|
|
144
|
+
Chain jobs so each stage starts only after the previous one succeeds:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
convert = await executor.submit("convert.sh input.tif", name="convert-s0")
|
|
148
|
+
pyramid = await executor.submit(
|
|
149
|
+
"build_pyramid.sh", name="pyramid",
|
|
150
|
+
depends_on=[convert], # JobRecords or raw job-id strings
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`depends_on` accepts multiple jobs (fan-in). On LSF this uses native
|
|
155
|
+
`bsub -w "done(...)" -ti`, so chains keep running if your process exits,
|
|
156
|
+
and LSF terminates dependents whose upstream failed — no jobs stuck
|
|
157
|
+
pending forever. When a dependent is cancelled this way,
|
|
158
|
+
`record.metadata["dependency_failed"]` lists the failed upstream job ids.
|
|
159
|
+
|
|
139
160
|
### Reconnecting After Restart
|
|
140
161
|
|
|
141
162
|
If your process crashes or restarts, `reconnect()` rediscovers running jobs from the scheduler and resumes tracking them. Requires `job_name_prefix` to be set in config.
|
|
@@ -156,6 +177,28 @@ async def resume():
|
|
|
156
177
|
await monitor.stop()
|
|
157
178
|
```
|
|
158
179
|
|
|
180
|
+
### Environment Control
|
|
181
|
+
|
|
182
|
+
By default a submitted job inherits the submitting process's environment plus whatever `env` you pass. Set `inherit_env=False` to start from a minimal environment (only `env` and scheduler-provided variables), or `login_shell=True` to run under a login shell so the target user's own profile builds `PATH`, modules, conda, etc.:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
job = await executor.submit(
|
|
186
|
+
command="my_tool.sh",
|
|
187
|
+
name="my-job",
|
|
188
|
+
inherit_env=False,
|
|
189
|
+
login_shell=True,
|
|
190
|
+
env={"MY_VAR": "1"},
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Tracking Jobs From External State
|
|
195
|
+
|
|
196
|
+
`track()` seeds the executor with a job ID from a persistent store (e.g. a database) without re-submitting it, so a later `poll()` or `cancel()` can act on jobs submitted by a previous process:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
job = executor.track(job_id="12345", status=JobStatus.RUNNING)
|
|
200
|
+
```
|
|
201
|
+
|
|
159
202
|
### Local Testing
|
|
160
203
|
|
|
161
204
|
```python
|
|
@@ -19,6 +19,9 @@ A Python library for submitting and monitoring jobs on HPC clusters. Supports ru
|
|
|
19
19
|
- **Zombie detection** — jobs that disappear from the scheduler are marked as failed
|
|
20
20
|
- **YAML config with profiles** — Nextflow-style config with per-environment profiles
|
|
21
21
|
- **Callback chaining** — register `on_success`, `on_failure`, or `on_exit` handlers on any job
|
|
22
|
+
- **Job dependencies** — chain jobs with `depends_on`; dependents are auto-cancelled if an upstream job fails
|
|
23
|
+
- **Environment control** — `inherit_env` and `login_shell` submit options control what environment a job runs with
|
|
24
|
+
- **Reconnect & external tracking** — `reconnect()` rediscovers jobs after a restart; `track()` seeds a job from an external store (e.g. a database) into the executor
|
|
22
25
|
|
|
23
26
|
## Installation
|
|
24
27
|
|
|
@@ -84,6 +87,24 @@ async def run_array():
|
|
|
84
87
|
|
|
85
88
|
The array index environment variable depends on the executor: LSF uses `$LSB_JOBINDEX`, while the local executor uses `$ARRAY_INDEX`.
|
|
86
89
|
|
|
90
|
+
### Job Dependencies
|
|
91
|
+
|
|
92
|
+
Chain jobs so each stage starts only after the previous one succeeds:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
convert = await executor.submit("convert.sh input.tif", name="convert-s0")
|
|
96
|
+
pyramid = await executor.submit(
|
|
97
|
+
"build_pyramid.sh", name="pyramid",
|
|
98
|
+
depends_on=[convert], # JobRecords or raw job-id strings
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`depends_on` accepts multiple jobs (fan-in). On LSF this uses native
|
|
103
|
+
`bsub -w "done(...)" -ti`, so chains keep running if your process exits,
|
|
104
|
+
and LSF terminates dependents whose upstream failed — no jobs stuck
|
|
105
|
+
pending forever. When a dependent is cancelled this way,
|
|
106
|
+
`record.metadata["dependency_failed"]` lists the failed upstream job ids.
|
|
107
|
+
|
|
87
108
|
### Reconnecting After Restart
|
|
88
109
|
|
|
89
110
|
If your process crashes or restarts, `reconnect()` rediscovers running jobs from the scheduler and resumes tracking them. Requires `job_name_prefix` to be set in config.
|
|
@@ -104,6 +125,28 @@ async def resume():
|
|
|
104
125
|
await monitor.stop()
|
|
105
126
|
```
|
|
106
127
|
|
|
128
|
+
### Environment Control
|
|
129
|
+
|
|
130
|
+
By default a submitted job inherits the submitting process's environment plus whatever `env` you pass. Set `inherit_env=False` to start from a minimal environment (only `env` and scheduler-provided variables), or `login_shell=True` to run under a login shell so the target user's own profile builds `PATH`, modules, conda, etc.:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
job = await executor.submit(
|
|
134
|
+
command="my_tool.sh",
|
|
135
|
+
name="my-job",
|
|
136
|
+
inherit_env=False,
|
|
137
|
+
login_shell=True,
|
|
138
|
+
env={"MY_VAR": "1"},
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Tracking Jobs From External State
|
|
143
|
+
|
|
144
|
+
`track()` seeds the executor with a job ID from a persistent store (e.g. a database) without re-submitting it, so a later `poll()` or `cancel()` can act on jobs submitted by a previous process:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
job = executor.track(job_id="12345", status=JobStatus.RUNNING)
|
|
148
|
+
```
|
|
149
|
+
|
|
107
150
|
### Local Testing
|
|
108
151
|
|
|
109
152
|
```python
|
|
@@ -98,6 +98,7 @@ class JobRecord:
|
|
|
98
98
|
start_time: datetime | None = None
|
|
99
99
|
finish_time: datetime | None = None
|
|
100
100
|
array_elements: dict[int, ArrayElement] = field(default_factory=dict)
|
|
101
|
+
depends_on: list[str] = field(default_factory=list)
|
|
101
102
|
_callbacks: list[tuple[JobExitCondition, Callable]] = field(
|
|
102
103
|
default_factory=list, repr=False
|
|
103
104
|
)
|
|
@@ -7,6 +7,7 @@ import asyncio
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
from collections.abc import Sequence
|
|
10
11
|
from datetime import datetime, timezone
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
@@ -73,6 +74,15 @@ class Executor(abc.ABC):
|
|
|
73
74
|
|
|
74
75
|
# --- Submission ---
|
|
75
76
|
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _resolve_dependency_ids(
|
|
79
|
+
depends_on: Sequence[JobRecord | str] | None,
|
|
80
|
+
) -> list[str]:
|
|
81
|
+
"""Resolve JobRecords or raw id strings to a list of job ids."""
|
|
82
|
+
if not depends_on:
|
|
83
|
+
return []
|
|
84
|
+
return [d.job_id if isinstance(d, JobRecord) else str(d) for d in depends_on]
|
|
85
|
+
|
|
76
86
|
async def submit(
|
|
77
87
|
self,
|
|
78
88
|
command: str,
|
|
@@ -84,6 +94,7 @@ class Executor(abc.ABC):
|
|
|
84
94
|
metadata: dict[str, Any] | None = None,
|
|
85
95
|
inherit_env: bool = True,
|
|
86
96
|
login_shell: bool = False,
|
|
97
|
+
depends_on: Sequence[JobRecord | str] | None = None,
|
|
87
98
|
) -> JobRecord:
|
|
88
99
|
"""Submit a job to the scheduler.
|
|
89
100
|
|
|
@@ -95,14 +106,20 @@ class Executor(abc.ABC):
|
|
|
95
106
|
login_shell: When True, the job runs under a login shell so the
|
|
96
107
|
target user's own profile builds the environment (PATH,
|
|
97
108
|
modules, conda), as if they had SSH'd to the node.
|
|
109
|
+
depends_on: Jobs that must finish successfully before this job
|
|
110
|
+
starts. Accepts JobRecords or raw job-id strings. If any
|
|
111
|
+
dependency fails or is killed, this job is cancelled instead
|
|
112
|
+
of pending forever.
|
|
98
113
|
"""
|
|
99
114
|
resources = resources or ResourceSpec()
|
|
100
115
|
full_name = _sanitize_job_name(f"{self._prefix}-{name}" if self._prefix else name)
|
|
116
|
+
dep_ids = self._resolve_dependency_ids(depends_on)
|
|
101
117
|
|
|
102
118
|
job_id, script_path = await self._submit_job(
|
|
103
119
|
command, full_name, resources, prologue, epilogue, env,
|
|
104
120
|
cwd=resources.work_dir,
|
|
105
121
|
inherit_env=inherit_env, login_shell=login_shell,
|
|
122
|
+
depends_on=dep_ids or None,
|
|
106
123
|
)
|
|
107
124
|
|
|
108
125
|
record = JobRecord(
|
|
@@ -113,6 +130,7 @@ class Executor(abc.ABC):
|
|
|
113
130
|
resources=resources,
|
|
114
131
|
script_path=script_path,
|
|
115
132
|
metadata=metadata or {},
|
|
133
|
+
depends_on=dep_ids,
|
|
116
134
|
_last_seen=datetime.now(timezone.utc),
|
|
117
135
|
)
|
|
118
136
|
self._jobs[job_id] = record
|
|
@@ -132,15 +150,18 @@ class Executor(abc.ABC):
|
|
|
132
150
|
max_concurrent: int | None = None,
|
|
133
151
|
inherit_env: bool = True,
|
|
134
152
|
login_shell: bool = False,
|
|
153
|
+
depends_on: Sequence[JobRecord | str] | None = None,
|
|
135
154
|
) -> JobRecord:
|
|
136
155
|
"""Submit a job array to the scheduler."""
|
|
137
156
|
resources = resources or ResourceSpec()
|
|
138
157
|
full_name = _sanitize_job_name(f"{self._prefix}-{name}" if self._prefix else name)
|
|
158
|
+
dep_ids = self._resolve_dependency_ids(depends_on)
|
|
139
159
|
|
|
140
160
|
job_id, script_path = await self._submit_array_job(
|
|
141
161
|
command, full_name, array_range, resources, prologue, epilogue,
|
|
142
162
|
env, max_concurrent, cwd=resources.work_dir,
|
|
143
163
|
inherit_env=inherit_env, login_shell=login_shell,
|
|
164
|
+
depends_on=dep_ids or None,
|
|
144
165
|
)
|
|
145
166
|
|
|
146
167
|
meta = {**(metadata or {}), "array_range": array_range}
|
|
@@ -155,6 +176,7 @@ class Executor(abc.ABC):
|
|
|
155
176
|
resources=resources,
|
|
156
177
|
script_path=script_path,
|
|
157
178
|
metadata=meta,
|
|
179
|
+
depends_on=dep_ids,
|
|
158
180
|
_last_seen=datetime.now(timezone.utc),
|
|
159
181
|
)
|
|
160
182
|
self._jobs[job_id] = record
|
|
@@ -177,6 +199,7 @@ class Executor(abc.ABC):
|
|
|
177
199
|
cwd: str | None = None,
|
|
178
200
|
inherit_env: bool = True,
|
|
179
201
|
login_shell: bool = False,
|
|
202
|
+
depends_on: list[str] | None = None,
|
|
180
203
|
) -> tuple[str, str | None]:
|
|
181
204
|
"""Submit a single job.
|
|
182
205
|
|
|
@@ -199,11 +222,13 @@ class Executor(abc.ABC):
|
|
|
199
222
|
cwd: str | None = None,
|
|
200
223
|
inherit_env: bool = True,
|
|
201
224
|
login_shell: bool = False,
|
|
225
|
+
depends_on: list[str] | None = None,
|
|
202
226
|
) -> tuple[str, str | None]:
|
|
203
227
|
"""Submit an array job. Override in subclasses."""
|
|
204
228
|
return await self._submit_job(
|
|
205
229
|
command, name, resources, prologue, epilogue, env, cwd=cwd,
|
|
206
230
|
inherit_env=inherit_env, login_shell=login_shell,
|
|
231
|
+
depends_on=depends_on,
|
|
207
232
|
)
|
|
208
233
|
|
|
209
234
|
def _job_id_from_submit_output(self, out: str) -> str:
|
|
@@ -336,6 +361,22 @@ class Executor(abc.ABC):
|
|
|
336
361
|
record = self._jobs[parent_id]
|
|
337
362
|
record.status = record.compute_array_status()
|
|
338
363
|
|
|
364
|
+
# Annotate dependency-driven failures so callbacks can distinguish
|
|
365
|
+
# "upstream broke" from "my job broke".
|
|
366
|
+
for record in self._jobs.values():
|
|
367
|
+
if (
|
|
368
|
+
record.depends_on
|
|
369
|
+
and record.status in (JobStatus.FAILED, JobStatus.KILLED)
|
|
370
|
+
and "dependency_failed" not in record.metadata
|
|
371
|
+
):
|
|
372
|
+
culprits = [
|
|
373
|
+
d for d in record.depends_on
|
|
374
|
+
if (dep := self._jobs.get(d)) is not None
|
|
375
|
+
and dep.status in (JobStatus.FAILED, JobStatus.KILLED)
|
|
376
|
+
]
|
|
377
|
+
if culprits:
|
|
378
|
+
record.metadata["dependency_failed"] = culprits
|
|
379
|
+
|
|
339
380
|
return {jid: r.status for jid, r in self._jobs.items()}
|
|
340
381
|
|
|
341
382
|
# --- Subprocess helper ---
|
|
@@ -6,6 +6,8 @@ import asyncio
|
|
|
6
6
|
import itertools
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
|
+
import signal
|
|
10
|
+
import time
|
|
9
11
|
from datetime import datetime, timezone
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
from typing import Any
|
|
@@ -13,6 +15,7 @@ from typing import Any
|
|
|
13
15
|
from .._types import ArrayElement, JobStatus, ResourceSpec
|
|
14
16
|
from ..config import ClusterConfig
|
|
15
17
|
from ..core import Executor, _ARRAY_ELEMENT_RE
|
|
18
|
+
from ..exceptions import ClusterAPIError
|
|
16
19
|
from ..script import render_script, write_script
|
|
17
20
|
|
|
18
21
|
logger = logging.getLogger(__name__)
|
|
@@ -48,6 +51,7 @@ class LocalExecutor(Executor):
|
|
|
48
51
|
self._open_fds: dict[str, tuple[int, int]] = {}
|
|
49
52
|
self._next_id = 1
|
|
50
53
|
self._script_counter = itertools.count(1)
|
|
54
|
+
self._waiting: dict[str, dict] = {}
|
|
51
55
|
|
|
52
56
|
def build_header(
|
|
53
57
|
self, name: str, resources: ResourceSpec | None = None
|
|
@@ -55,6 +59,14 @@ class LocalExecutor(Executor):
|
|
|
55
59
|
"""Local executor doesn't need scheduler directives."""
|
|
56
60
|
return [f"# LOCAL Job: {name}"]
|
|
57
61
|
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _job_env(env: dict[str, str] | None, inherit_env: bool) -> dict[str, str]:
|
|
64
|
+
return {**os.environ, **(env or {})} if inherit_env else _baseline_env(env)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _bash_cmd(script_path: str, login_shell: bool) -> list[str]:
|
|
68
|
+
return ["bash", "-l", script_path] if login_shell else ["bash", script_path]
|
|
69
|
+
|
|
58
70
|
async def _submit_job(
|
|
59
71
|
self,
|
|
60
72
|
command: str,
|
|
@@ -67,23 +79,42 @@ class LocalExecutor(Executor):
|
|
|
67
79
|
cwd: str | None = None,
|
|
68
80
|
inherit_env: bool = True,
|
|
69
81
|
login_shell: bool = False,
|
|
82
|
+
depends_on: list[str] | None = None,
|
|
70
83
|
) -> tuple[str, str | None]:
|
|
71
|
-
"""Render script, write to disk, run as a background subprocess.
|
|
84
|
+
"""Render script, write to disk, run as a background subprocess.
|
|
85
|
+
|
|
86
|
+
The job_id is the subprocess PID, which doubles as its process-group
|
|
87
|
+
id (the child is spawned with ``start_new_session=True``). This makes
|
|
88
|
+
the id a durable OS handle: a fresh executor in a later process can
|
|
89
|
+
cancel the job's whole tree by PID alone, without the in-memory
|
|
90
|
+
``_processes`` entry (see :meth:`cancel`).
|
|
91
|
+
"""
|
|
72
92
|
header = self.build_header(name, resources)
|
|
73
93
|
script = render_script(self.config, command, header, prologue, epilogue)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
token = next(self._script_counter)
|
|
95
|
+
script_path = write_script(resources.work_dir, script, name, token)
|
|
96
|
+
|
|
97
|
+
if depends_on:
|
|
98
|
+
job_id = f"waiting-{self._next_id}"
|
|
99
|
+
self._next_id += 1
|
|
100
|
+
self._waiting[job_id] = {
|
|
101
|
+
"script_path": script_path,
|
|
102
|
+
"resources": resources,
|
|
103
|
+
"env": env,
|
|
104
|
+
"cwd": cwd,
|
|
105
|
+
"inherit_env": inherit_env,
|
|
106
|
+
"login_shell": login_shell,
|
|
107
|
+
"array_range": None,
|
|
108
|
+
}
|
|
109
|
+
return job_id, script_path
|
|
110
|
+
|
|
111
|
+
full_env = self._job_env(env, inherit_env)
|
|
112
|
+
bash_cmd = self._bash_cmd(script_path, login_shell)
|
|
113
|
+
|
|
114
|
+
# Open logs before spawn (they become the child's stdout/stderr). The
|
|
115
|
+
# PID isn't known yet, so key them by the script token, then re-key to
|
|
116
|
+
# the PID once we have it.
|
|
117
|
+
stdout_dest, stderr_dest = self._open_output_files(resources, job_id=str(token))
|
|
87
118
|
|
|
88
119
|
proc = await asyncio.create_subprocess_exec(
|
|
89
120
|
*bash_cmd,
|
|
@@ -91,8 +122,18 @@ class LocalExecutor(Executor):
|
|
|
91
122
|
stderr=stderr_dest,
|
|
92
123
|
env=full_env,
|
|
93
124
|
cwd=cwd,
|
|
125
|
+
start_new_session=True,
|
|
94
126
|
)
|
|
95
127
|
|
|
128
|
+
job_id = str(proc.pid)
|
|
129
|
+
self._open_fds[job_id] = self._open_fds.pop(str(token))
|
|
130
|
+
# Default log files were named by the pre-spawn token; re-point them at
|
|
131
|
+
# the PID so the documented stdout.{job_id}.log resolves. The open fds
|
|
132
|
+
# keep writing to the same inode across the rename.
|
|
133
|
+
if not resources.stdout_path:
|
|
134
|
+
base = Path(resources.work_dir)
|
|
135
|
+
for stream in ("stdout", "stderr"):
|
|
136
|
+
(base / f"{stream}.{token}.log").rename(base / f"{stream}.{job_id}.log")
|
|
96
137
|
self._processes[job_id] = proc
|
|
97
138
|
return job_id, script_path
|
|
98
139
|
|
|
@@ -110,6 +151,7 @@ class LocalExecutor(Executor):
|
|
|
110
151
|
cwd: str | None = None,
|
|
111
152
|
inherit_env: bool = True,
|
|
112
153
|
login_shell: bool = False,
|
|
154
|
+
depends_on: list[str] | None = None,
|
|
113
155
|
) -> tuple[str, str | None]:
|
|
114
156
|
"""Spawn one subprocess per array element with ARRAY_INDEX env var."""
|
|
115
157
|
if max_concurrent is not None:
|
|
@@ -122,14 +164,30 @@ class LocalExecutor(Executor):
|
|
|
122
164
|
script = render_script(self.config, command, header, prologue, epilogue)
|
|
123
165
|
script_path = write_script(resources.work_dir, script, name, next(self._script_counter))
|
|
124
166
|
|
|
125
|
-
|
|
167
|
+
if depends_on:
|
|
168
|
+
job_id = f"waiting-{self._next_id}"
|
|
169
|
+
self._next_id += 1
|
|
170
|
+
self._waiting[job_id] = {
|
|
171
|
+
"script_path": script_path,
|
|
172
|
+
"resources": resources,
|
|
173
|
+
"env": env,
|
|
174
|
+
"cwd": cwd,
|
|
175
|
+
"inherit_env": inherit_env,
|
|
176
|
+
"login_shell": login_shell,
|
|
177
|
+
"array_range": array_range,
|
|
178
|
+
}
|
|
179
|
+
return job_id, script_path
|
|
180
|
+
|
|
181
|
+
# Array jobs spawn N processes, so no single PID identifies the job.
|
|
182
|
+
# Use a synthetic, deliberately non-numeric id: it can't be mistaken
|
|
183
|
+
# for a process-group id by the stateless cancel path (which would
|
|
184
|
+
# otherwise int() it), so an array is only ever cancelled in-process
|
|
185
|
+
# via its element handles.
|
|
186
|
+
job_id = f"array-{self._next_id}"
|
|
126
187
|
self._next_id += 1
|
|
127
188
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
else:
|
|
131
|
-
full_env = _baseline_env(env)
|
|
132
|
-
bash_cmd = ["bash", "-l", script_path] if login_shell else ["bash", script_path]
|
|
189
|
+
full_env = self._job_env(env, inherit_env)
|
|
190
|
+
bash_cmd = self._bash_cmd(script_path, login_shell)
|
|
133
191
|
|
|
134
192
|
for index in range(array_range[0], array_range[1] + 1):
|
|
135
193
|
element_env = {**full_env, "ARRAY_INDEX": str(index)}
|
|
@@ -147,6 +205,44 @@ class LocalExecutor(Executor):
|
|
|
147
205
|
|
|
148
206
|
return job_id, script_path
|
|
149
207
|
|
|
208
|
+
async def _spawn_deferred(self, job_id: str, entry: dict) -> None:
|
|
209
|
+
"""Spawn a parked job whose dependencies are now satisfied.
|
|
210
|
+
|
|
211
|
+
The job keeps its synthetic ``waiting-N`` id (ids never change once
|
|
212
|
+
returned), so the PID-based stateless cancel path does not apply —
|
|
213
|
+
same trade-off as local array jobs.
|
|
214
|
+
"""
|
|
215
|
+
resources = entry["resources"]
|
|
216
|
+
full_env = self._job_env(entry["env"], entry["inherit_env"])
|
|
217
|
+
bash_cmd = self._bash_cmd(entry["script_path"], entry["login_shell"])
|
|
218
|
+
|
|
219
|
+
if entry["array_range"] is None:
|
|
220
|
+
stdout_dest, stderr_dest = self._open_output_files(resources, job_id=job_id)
|
|
221
|
+
proc = await asyncio.create_subprocess_exec(
|
|
222
|
+
*bash_cmd,
|
|
223
|
+
stdout=stdout_dest,
|
|
224
|
+
stderr=stderr_dest,
|
|
225
|
+
env=full_env,
|
|
226
|
+
cwd=entry["cwd"],
|
|
227
|
+
start_new_session=True,
|
|
228
|
+
)
|
|
229
|
+
self._processes[job_id] = proc
|
|
230
|
+
else:
|
|
231
|
+
start, end = entry["array_range"]
|
|
232
|
+
for index in range(start, end + 1):
|
|
233
|
+
element_env = {**full_env, "ARRAY_INDEX": str(index)}
|
|
234
|
+
stdout_dest, stderr_dest = self._open_output_files(
|
|
235
|
+
resources, job_id=job_id, element_index=index,
|
|
236
|
+
)
|
|
237
|
+
proc = await asyncio.create_subprocess_exec(
|
|
238
|
+
*bash_cmd,
|
|
239
|
+
stdout=stdout_dest,
|
|
240
|
+
stderr=stderr_dest,
|
|
241
|
+
env=element_env,
|
|
242
|
+
cwd=entry["cwd"],
|
|
243
|
+
)
|
|
244
|
+
self._processes[f"{job_id}[{index}]"] = proc
|
|
245
|
+
|
|
150
246
|
def _build_status_args(self) -> list[str]:
|
|
151
247
|
# Not used for local executor; poll() is overridden
|
|
152
248
|
return []
|
|
@@ -223,11 +319,48 @@ class LocalExecutor(Executor):
|
|
|
223
319
|
if record.is_terminal:
|
|
224
320
|
record.finish_time = datetime.now(timezone.utc)
|
|
225
321
|
|
|
322
|
+
# --- Waiting (dependency-deferred) jobs ---
|
|
323
|
+
for job_id in list(self._waiting):
|
|
324
|
+
record = self._jobs.get(job_id)
|
|
325
|
+
if record is None or record.is_terminal:
|
|
326
|
+
self._waiting.pop(job_id)
|
|
327
|
+
continue
|
|
328
|
+
dep_records = [self._jobs.get(d) for d in record.depends_on]
|
|
329
|
+
failed = [
|
|
330
|
+
d for d, r in zip(record.depends_on, dep_records)
|
|
331
|
+
if r is None or r.status in {JobStatus.FAILED, JobStatus.KILLED}
|
|
332
|
+
]
|
|
333
|
+
now = datetime.now(timezone.utc)
|
|
334
|
+
if failed:
|
|
335
|
+
# An untracked dep (None) can never be satisfied — treat as failed.
|
|
336
|
+
self._waiting.pop(job_id)
|
|
337
|
+
record.status = JobStatus.KILLED
|
|
338
|
+
record.metadata["dependency_failed"] = failed
|
|
339
|
+
record.finish_time = now
|
|
340
|
+
elif all(r.status == JobStatus.DONE for r in dep_records):
|
|
341
|
+
entry = self._waiting.pop(job_id)
|
|
342
|
+
await self._spawn_deferred(job_id, entry)
|
|
343
|
+
record._last_seen = now
|
|
344
|
+
else:
|
|
345
|
+
record._last_seen = now # keep zombie detection at bay while parked
|
|
346
|
+
|
|
226
347
|
return {jid: r.status for jid, r in self._jobs.items()}
|
|
227
348
|
|
|
228
349
|
async def cancel(self, job_id: str, *, done: bool = False) -> None:
|
|
229
|
-
"""Terminate a local
|
|
230
|
-
|
|
350
|
+
"""Terminate a local job and its whole process tree.
|
|
351
|
+
|
|
352
|
+
Fast path: if this executor submitted the job (its ``asyncio``
|
|
353
|
+
subprocess handle is still in ``_processes``), signal that handle
|
|
354
|
+
directly. Stateless path: otherwise ``job_id`` is treated as the
|
|
355
|
+
job's process-group id (== leader PID from submit) and the entire
|
|
356
|
+
group is killed by PID — so a fresh executor in a later process can
|
|
357
|
+
cancel a job it never submitted.
|
|
358
|
+
"""
|
|
359
|
+
# A parked (dependency-deferred) job has no process yet; discard its
|
|
360
|
+
# entry so a later poll can't spawn it after cancellation.
|
|
361
|
+
deferred = self._waiting.pop(job_id, None)
|
|
362
|
+
|
|
363
|
+
# Fast path: live in-memory handles for this job (single + array elements).
|
|
231
364
|
live: list[tuple[str, asyncio.subprocess.Process]] = []
|
|
232
365
|
proc = self._processes.get(job_id)
|
|
233
366
|
if proc and proc.returncode is None:
|
|
@@ -237,10 +370,10 @@ class LocalExecutor(Executor):
|
|
|
237
370
|
if key.startswith(prefix) and proc.returncode is None:
|
|
238
371
|
live.append((key, proc))
|
|
239
372
|
|
|
240
|
-
# Send SIGTERM to all, then wait concurrently
|
|
241
|
-
for _key, p in live:
|
|
242
|
-
p.terminate()
|
|
243
373
|
if live:
|
|
374
|
+
# Send SIGTERM to all, then wait concurrently
|
|
375
|
+
for _key, p in live:
|
|
376
|
+
p.terminate()
|
|
244
377
|
tasks = [asyncio.ensure_future(p.wait()) for _key, p in live]
|
|
245
378
|
_, pending = await asyncio.wait(tasks, timeout=5.0)
|
|
246
379
|
# SIGKILL any that didn't exit in time
|
|
@@ -250,9 +383,11 @@ class LocalExecutor(Executor):
|
|
|
250
383
|
# Reap the killed processes
|
|
251
384
|
if pending:
|
|
252
385
|
await asyncio.wait(pending, timeout=5.0)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
386
|
+
for key, _p in live:
|
|
387
|
+
self._close_output_files(key)
|
|
388
|
+
elif deferred is None and job_id not in self._processes:
|
|
389
|
+
# Stateless path: no handle for this job — kill by process group.
|
|
390
|
+
await self._terminate_group(job_id)
|
|
256
391
|
|
|
257
392
|
target_status = JobStatus.DONE if done else JobStatus.KILLED
|
|
258
393
|
if job_id in self._jobs:
|
|
@@ -263,6 +398,59 @@ class LocalExecutor(Executor):
|
|
|
263
398
|
elem.status = target_status
|
|
264
399
|
logger.info("Cancelled local job %s (done=%s)", job_id, done)
|
|
265
400
|
|
|
401
|
+
async def _terminate_group(
|
|
402
|
+
self, job_id: str, grace_seconds: float = 3.0
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Kill a job's process group (SIGTERM, then SIGKILL) by PID.
|
|
405
|
+
|
|
406
|
+
``job_id`` is the process-group id (the leader PID from submit, which
|
|
407
|
+
used ``start_new_session=True``). Killing the group reaches the launcher
|
|
408
|
+
bash *and* its workload — bash does not forward SIGTERM to its child, so
|
|
409
|
+
signalling the leader alone would orphan the real job.
|
|
410
|
+
|
|
411
|
+
Raises ClusterAPIError if anything in the group outlives SIGKILL.
|
|
412
|
+
"""
|
|
413
|
+
try:
|
|
414
|
+
pgid = int(job_id)
|
|
415
|
+
except ValueError:
|
|
416
|
+
# Non-PID id (e.g. an array counter) — nothing to kill statelessly.
|
|
417
|
+
logger.warning("Cannot cancel local job %s: no live handle", job_id)
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
def present() -> bool:
|
|
421
|
+
# True while the group has any member — including zombies not yet
|
|
422
|
+
# reaped. After SIGKILL a dead process lingers as a zombie until its
|
|
423
|
+
# parent (init, once the submitter exited) reaps it, so we poll
|
|
424
|
+
# briefly rather than trusting the first check.
|
|
425
|
+
try:
|
|
426
|
+
os.killpg(pgid, 0)
|
|
427
|
+
return True
|
|
428
|
+
except ProcessLookupError:
|
|
429
|
+
return False
|
|
430
|
+
except PermissionError:
|
|
431
|
+
# Group exists but is owned by someone else — treat as alive.
|
|
432
|
+
return True
|
|
433
|
+
|
|
434
|
+
# ponytail: no PID-reuse guard — the group id could have been recycled
|
|
435
|
+
# onto an unrelated process. This matches the prior job.pid behavior;
|
|
436
|
+
# add a start-time check here if reuse ever bites in practice.
|
|
437
|
+
if not present():
|
|
438
|
+
return
|
|
439
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
440
|
+
deadline = time.monotonic() + grace_seconds
|
|
441
|
+
while present() and time.monotonic() < deadline:
|
|
442
|
+
await asyncio.sleep(0.1)
|
|
443
|
+
if present():
|
|
444
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
445
|
+
# SIGKILL is immediate, but reaping the resulting zombies isn't.
|
|
446
|
+
deadline = time.monotonic() + 2.0
|
|
447
|
+
while present() and time.monotonic() < deadline:
|
|
448
|
+
await asyncio.sleep(0.05)
|
|
449
|
+
if present():
|
|
450
|
+
raise ClusterAPIError(
|
|
451
|
+
f"Local job {job_id} survived SIGKILL; may still be running"
|
|
452
|
+
)
|
|
453
|
+
|
|
266
454
|
def _open_output_files(
|
|
267
455
|
self,
|
|
268
456
|
resources: ResourceSpec,
|