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.
Files changed (34) hide show
  1. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/CLAUDE.md +1 -1
  2. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/PKG-INFO +45 -2
  3. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/README.md +43 -0
  4. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/_types.py +1 -0
  5. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/core.py +41 -0
  6. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/executors/local.py +216 -28
  7. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/executors/lsf.py +24 -1
  8. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/docs/API.md +8 -3
  9. py_cluster_api-0.8.1/docs/superpowers/plans/2026-07-24-job-dependency-chaining.md +786 -0
  10. py_cluster_api-0.8.1/docs/superpowers/specs/2026-07-24-job-dependency-chaining-design.md +148 -0
  11. py_cluster_api-0.8.1/notes/lsf-implementations-comparison.html +561 -0
  12. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/pyproject.toml +1 -1
  13. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_integration.py +47 -0
  14. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_local.py +163 -0
  15. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_lsf.py +197 -11
  16. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_reconnect.py +7 -0
  17. py_cluster_api-0.7.0/unknown-fix.md +0 -131
  18. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/.github/workflows/ci.yml +0 -0
  19. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/.gitignore +0 -0
  20. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/LICENSE +0 -0
  21. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/__init__.py +0 -0
  22. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/config.py +0 -0
  23. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/exceptions.py +0 -0
  24. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/executors/__init__.py +0 -0
  25. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/monitor.py +0 -0
  26. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/cluster_api/script.py +0 -0
  27. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/docs/Development.md +0 -0
  28. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/pixi.lock +0 -0
  29. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/__init__.py +0 -0
  30. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/cluster_config.example.yaml +0 -0
  31. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/conftest.py +0 -0
  32. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_config.py +0 -0
  33. {py_cluster_api-0.7.0 → py_cluster_api-0.8.1}/tests/test_core.py +0 -0
  34. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: py-cluster-api
3
- Version: 0.7.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
- script_path = write_script(resources.work_dir, script, name, next(self._script_counter))
75
-
76
- job_id = str(self._next_id)
77
- self._next_id += 1
78
-
79
- if inherit_env:
80
- full_env = {**os.environ, **(env or {})}
81
- else:
82
- full_env = _baseline_env(env)
83
- bash_cmd = ["bash", "-l", script_path] if login_shell else ["bash", script_path]
84
-
85
- # Write stdout/stderr directly to log files for real-time access
86
- stdout_dest, stderr_dest = self._open_output_files(resources, job_id=job_id)
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
- job_id = str(self._next_id)
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
- if inherit_env:
129
- full_env = {**os.environ, **(env or {})}
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 subprocess (or all element processes for an array job)."""
230
- # Collect all live processes for this job (single + array elements)
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
- for key, _p in live:
255
- self._close_output_files(key)
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,