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.
@@ -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