snakemake-executor-plugin-sge 0.5.9__py3-none-any.whl
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.
- snakemake_executor_plugin_sge/__init__.py +1128 -0
- snakemake_executor_plugin_sge/job_cancellation.py +34 -0
- snakemake_executor_plugin_sge/job_status_query.py +226 -0
- snakemake_executor_plugin_sge/submit_string.py +505 -0
- snakemake_executor_plugin_sge-0.5.9.dist-info/METADATA +117 -0
- snakemake_executor_plugin_sge-0.5.9.dist-info/RECORD +7 -0
- snakemake_executor_plugin_sge-0.5.9.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SGE job cancellation via qdel.
|
|
3
|
+
|
|
4
|
+
Called when Snakemake is interrupted (Ctrl-C / SIGTERM) to clean up all
|
|
5
|
+
still-running jobs. For array tasks we cancel the *parent* job ID, which
|
|
6
|
+
implicitly kills every task of that array.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from snakemake_interface_executor_plugins.executors.base import SubmittedJobInfo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cancel_sge_jobs(active_jobs: List[SubmittedJobInfo], logger) -> None:
|
|
16
|
+
"""Issue a single ``qdel`` call for all unique parent job IDs."""
|
|
17
|
+
if not active_jobs:
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
# Deduplicate: '12345.3' and '12345.7' both cancel by 'qdel 12345'
|
|
21
|
+
parent_ids = sorted({j.external_jobid.split(".")[0] for j in active_jobs})
|
|
22
|
+
id_str = " ".join(parent_ids)
|
|
23
|
+
logger.info(f"Cancelling SGE jobs: {id_str}")
|
|
24
|
+
try:
|
|
25
|
+
subprocess.run(
|
|
26
|
+
f"qdel {id_str}",
|
|
27
|
+
shell=True, check=True,
|
|
28
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
29
|
+
)
|
|
30
|
+
except subprocess.CalledProcessError as exc:
|
|
31
|
+
logger.warning(
|
|
32
|
+
f"qdel returned non-zero when cancelling {id_str}: "
|
|
33
|
+
f"{exc.stderr.decode(errors='replace').strip()}"
|
|
34
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""SGE job status querying via qstat and qacct.
|
|
2
|
+
|
|
3
|
+
SGE does not have a single command that reliably returns the status of both
|
|
4
|
+
running *and* recently completed jobs. We therefore use a two-stage approach:
|
|
5
|
+
|
|
6
|
+
1. ``qstat -j <id>`` (or ``qstat -u '*'``) → jobs that are *queued* or
|
|
7
|
+
*running* show up here.
|
|
8
|
+
2. ``qacct -j <id>`` → jobs that have *finished*
|
|
9
|
+
(successfully or with an error) are visible here after they leave the
|
|
10
|
+
scheduler. qacct is optional: it may not be available on all clusters.
|
|
11
|
+
|
|
12
|
+
Status mapping
|
|
13
|
+
--------------
|
|
14
|
+
The function returns a dict mapping external_jobid → status string where
|
|
15
|
+
status is one of:
|
|
16
|
+
|
|
17
|
+
``"running"`` – job is queued or running
|
|
18
|
+
``"finished"`` – job completed with exit code 0
|
|
19
|
+
``"failed"`` – job completed with non-zero exit code or with an error
|
|
20
|
+
``None`` – job not found in either qstat or qacct output (treat as
|
|
21
|
+
still queued, i.e. not yet visible)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from typing import Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Availability checks
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def is_qstat_available() -> bool:
|
|
36
|
+
"""Return True if ``qstat`` is in PATH."""
|
|
37
|
+
return shutil.which("qstat") is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_qacct_available() -> bool:
|
|
41
|
+
"""Return True if ``qacct`` is in PATH."""
|
|
42
|
+
return shutil.which("qacct") is not None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# qstat parsing
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
# SGE qstat -xml state codes
|
|
50
|
+
# r – running
|
|
51
|
+
# qw – queued waiting
|
|
52
|
+
# t – transferring (about to run)
|
|
53
|
+
# Rr – re-running (after node failure)
|
|
54
|
+
# s – suspended
|
|
55
|
+
# S – suspended by load threshold
|
|
56
|
+
# T – threshold suspended
|
|
57
|
+
# h – on hold
|
|
58
|
+
# Eqw– error in queued waiting
|
|
59
|
+
# Ec – error
|
|
60
|
+
_RUNNING_STATES = {"r", "t", "Rr", "s", "S", "T", "qw", "h", "hqw", "hRwq"}
|
|
61
|
+
_ERROR_STATES = {"Eqw", "Ec", "E", "d", "dr", "dt", "dRr", "dT"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _poll_qstat(job_ids: List[str], logger) -> Dict[str, str]:
|
|
65
|
+
"""Return a {job_id: status} dict from qstat output.
|
|
66
|
+
|
|
67
|
+
``status`` is ``'running'`` for jobs in the queue (any state) or
|
|
68
|
+
``'failed'`` for jobs in an error state. Jobs not returned by qstat
|
|
69
|
+
are absent from the dict (they have finished or never existed).
|
|
70
|
+
"""
|
|
71
|
+
result: Dict[str, str] = {}
|
|
72
|
+
if not job_ids:
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
# Use XML output for robust parsing
|
|
76
|
+
try:
|
|
77
|
+
raw = subprocess.check_output(
|
|
78
|
+
"qstat -xml -u '*'",
|
|
79
|
+
shell=True,
|
|
80
|
+
text=True,
|
|
81
|
+
stderr=subprocess.PIPE,
|
|
82
|
+
)
|
|
83
|
+
except subprocess.CalledProcessError as exc:
|
|
84
|
+
logger.warning(f"qstat failed: {exc.stderr.strip()}")
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
# Parse each <job_list> element
|
|
88
|
+
# We look for job number and state; we don't need full XML parsing.
|
|
89
|
+
for block in re.finditer(
|
|
90
|
+
r"<job_list[^>]*>(.*?)</job_list>", raw, re.DOTALL
|
|
91
|
+
):
|
|
92
|
+
content = block.group(1)
|
|
93
|
+
jn_m = re.search(r"<JB_job_number>(\d+)</JB_job_number>", content)
|
|
94
|
+
st_m = re.search(r"<state>(\S+)</state>", content)
|
|
95
|
+
if not jn_m:
|
|
96
|
+
continue
|
|
97
|
+
base_id = jn_m.group(1)
|
|
98
|
+
state = st_m.group(1) if st_m else "unknown"
|
|
99
|
+
|
|
100
|
+
# Match against full IDs (may include task suffix like 12345.3)
|
|
101
|
+
matched_ids = [
|
|
102
|
+
jid for jid in job_ids
|
|
103
|
+
if jid == base_id or jid.startswith(f"{base_id}.")
|
|
104
|
+
]
|
|
105
|
+
for jid in matched_ids:
|
|
106
|
+
if any(s in state for s in _ERROR_STATES):
|
|
107
|
+
result[jid] = "failed"
|
|
108
|
+
else:
|
|
109
|
+
result[jid] = "running"
|
|
110
|
+
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# qacct parsing
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def _poll_qacct(job_id: str, logger) -> Optional[str]:
|
|
119
|
+
"""Return ``'finished'`` or ``'failed'`` for *job_id* via qacct.
|
|
120
|
+
|
|
121
|
+
Returns ``None`` if the job is not in the accounting database yet.
|
|
122
|
+
|
|
123
|
+
For array jobs (``12345.3``) we query the parent ID and task.
|
|
124
|
+
"""
|
|
125
|
+
base_id = job_id.split(".")[0]
|
|
126
|
+
try:
|
|
127
|
+
raw = subprocess.check_output(
|
|
128
|
+
f"qacct -j {base_id}",
|
|
129
|
+
shell=True,
|
|
130
|
+
text=True,
|
|
131
|
+
stderr=subprocess.PIPE,
|
|
132
|
+
)
|
|
133
|
+
except subprocess.CalledProcessError:
|
|
134
|
+
# Job not in accounting yet
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
# If this is a task ID, filter to that task only
|
|
138
|
+
task_id = job_id.split(".")[1] if "." in job_id else None
|
|
139
|
+
|
|
140
|
+
# Split into per-task blocks (separated by dashes)
|
|
141
|
+
blocks = re.split(r"={10,}", raw)
|
|
142
|
+
for block in blocks:
|
|
143
|
+
if not block.strip():
|
|
144
|
+
continue
|
|
145
|
+
# If task_id specified, only look at matching task block
|
|
146
|
+
if task_id:
|
|
147
|
+
m = re.search(r"taskid\s+(\d+)", block)
|
|
148
|
+
if not m or m.group(1) != task_id:
|
|
149
|
+
continue
|
|
150
|
+
exit_m = re.search(r"exit_status\s+(\d+)", block)
|
|
151
|
+
failed_m = re.search(r"failed\s+(\d+)", block)
|
|
152
|
+
if exit_m:
|
|
153
|
+
exit_code = int(exit_m.group(1))
|
|
154
|
+
failed_flag = int(failed_m.group(1)) if failed_m else 0
|
|
155
|
+
if exit_code == 0 and failed_flag == 0:
|
|
156
|
+
return "finished"
|
|
157
|
+
else:
|
|
158
|
+
return "failed"
|
|
159
|
+
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Main status query coroutine
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
async def query_job_status(
|
|
168
|
+
active_jobs,
|
|
169
|
+
use_qacct: bool,
|
|
170
|
+
logger,
|
|
171
|
+
) -> Optional[Dict[str, str]]:
|
|
172
|
+
"""Asynchronously query qstat (and optionally qacct) for all active jobs."""
|
|
173
|
+
import time
|
|
174
|
+
job_ids = [j.external_jobid for j in active_jobs]
|
|
175
|
+
if not job_ids:
|
|
176
|
+
return {}
|
|
177
|
+
|
|
178
|
+
loop = asyncio.get_event_loop()
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
qstat_status = await loop.run_in_executor(
|
|
182
|
+
None, _poll_qstat, job_ids, logger
|
|
183
|
+
)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
logger.warning(f"qstat query raised an exception: {exc}")
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
status_map: Dict[str, str] = {}
|
|
189
|
+
|
|
190
|
+
active_jobs_map = {j.external_jobid: j for j in active_jobs}
|
|
191
|
+
|
|
192
|
+
for jid in job_ids:
|
|
193
|
+
# qstat is the ground truth for currently running jobs
|
|
194
|
+
if jid in qstat_status:
|
|
195
|
+
status_map[jid] = qstat_status[jid]
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
# If it's not in qstat, it might have finished or hasn't appeared yet.
|
|
199
|
+
# Check grace period first!
|
|
200
|
+
j = active_jobs_map.get(jid)
|
|
201
|
+
submit_time = j.aux.get("submit_time", 0) if j and j.aux else 0
|
|
202
|
+
if time.time() - submit_time < 20:
|
|
203
|
+
# Job is too young to be considered finished, even if it's not in qstat
|
|
204
|
+
# or if qacct returns an old reused job ID.
|
|
205
|
+
continue # leave it absent from status_map -> treats as queued/running
|
|
206
|
+
|
|
207
|
+
# Job is old enough. We can now trust qacct or assume it's finished.
|
|
208
|
+
if use_qacct and is_qacct_available():
|
|
209
|
+
try:
|
|
210
|
+
acct_status = await loop.run_in_executor(
|
|
211
|
+
None, _poll_qacct, jid, logger
|
|
212
|
+
)
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
logger.warning(f"qacct query for {jid} raised: {exc}")
|
|
215
|
+
acct_status = None
|
|
216
|
+
|
|
217
|
+
if acct_status is not None:
|
|
218
|
+
status_map[jid] = acct_status
|
|
219
|
+
else:
|
|
220
|
+
pass
|
|
221
|
+
else:
|
|
222
|
+
# qacct disabled or unavailable: assume finished since it's old enough
|
|
223
|
+
# and no longer in qstat.
|
|
224
|
+
status_map[jid] = "finished"
|
|
225
|
+
|
|
226
|
+
return status_map
|