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,1128 @@
|
|
|
1
|
+
"""Snakemake executor plugin for Sun Grid Engine (SGE/UGE/OGS).
|
|
2
|
+
|
|
3
|
+
This module is the main entry point for the plugin. It exposes:
|
|
4
|
+
- ExecutorSettings – all user-facing configuration options
|
|
5
|
+
- common_settings – static metadata consumed by the Snakemake framework
|
|
6
|
+
- Executor – the RemoteExecutor subclass that drives qsub/qstat/qdel
|
|
7
|
+
|
|
8
|
+
Design philosophy
|
|
9
|
+
-----------------
|
|
10
|
+
The implementation closely mirrors snakemake-executor-plugin-slurm so that
|
|
11
|
+
anyone already familiar with that plugin can read and extend this one. SGE
|
|
12
|
+
differences (array-job syntax, status polling via qstat, resource flags) are
|
|
13
|
+
isolated in helper modules:
|
|
14
|
+
|
|
15
|
+
submit_string.py – builds the qsub command string
|
|
16
|
+
job_status_query.py – wraps qstat/qacct polling
|
|
17
|
+
job_cancellation.py – wraps qdel
|
|
18
|
+
|
|
19
|
+
Array jobs for group jobs
|
|
20
|
+
-------------------------
|
|
21
|
+
Group jobs (Snakemake jobs that bundle several rule invocations) are
|
|
22
|
+
submitted as a single SGE array job (``qsub -t 1-N``). Each task unpacks
|
|
23
|
+
its own execution command from a zlib-compressed, base64-encoded map that
|
|
24
|
+
is baked into the submission script via an environment variable. This
|
|
25
|
+
reduces scheduler overhead and mirrors the SLURM plugin behaviour.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
__author__ = "Stylianos Serghiou"
|
|
29
|
+
__copyright__ = "Copyright 2025, Stylianos Serghiou"
|
|
30
|
+
__license__ = "MIT"
|
|
31
|
+
|
|
32
|
+
import atexit
|
|
33
|
+
import asyncio
|
|
34
|
+
import base64
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Generator, List, Optional
|
|
41
|
+
import re
|
|
42
|
+
import shlex
|
|
43
|
+
import subprocess
|
|
44
|
+
import time
|
|
45
|
+
import uuid
|
|
46
|
+
import zlib
|
|
47
|
+
|
|
48
|
+
from snakemake_interface_executor_plugins.executors.base import SubmittedJobInfo
|
|
49
|
+
from snakemake_interface_executor_plugins.executors.remote import RemoteExecutor
|
|
50
|
+
from snakemake_interface_executor_plugins.settings import (
|
|
51
|
+
CommonSettings,
|
|
52
|
+
ExecutorSettingsBase,
|
|
53
|
+
)
|
|
54
|
+
from snakemake_interface_executor_plugins.jobs import JobExecutorInterface
|
|
55
|
+
from snakemake_interface_common.exceptions import WorkflowError
|
|
56
|
+
|
|
57
|
+
from .submit_string import get_submit_command
|
|
58
|
+
from .job_status_query import query_job_status, is_qstat_available, is_qacct_available
|
|
59
|
+
from .job_cancellation import cancel_sge_jobs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# ExecutorSettings
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ExecutorSettings(ExecutorSettingsBase):
|
|
68
|
+
"""User-facing settings for the SGE executor plugin.
|
|
69
|
+
|
|
70
|
+
All fields map to ``--sge-<field_name>`` CLI flags when consumed by
|
|
71
|
+
Snakemake's plugin interface.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# ---- Queue / scheduling -----------------------------------------------
|
|
75
|
+
|
|
76
|
+
queue: Optional[str] = field(
|
|
77
|
+
default=None,
|
|
78
|
+
metadata={
|
|
79
|
+
"help": (
|
|
80
|
+
"SGE queue to submit jobs to (-q flag). "
|
|
81
|
+
"Can also be set per-rule via the 'sge_queue' resource."
|
|
82
|
+
),
|
|
83
|
+
"env_var": False,
|
|
84
|
+
"required": False,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
pe: Optional[str] = field(
|
|
89
|
+
default=None,
|
|
90
|
+
metadata={
|
|
91
|
+
"help": (
|
|
92
|
+
"SGE parallel environment name used for multi-threaded jobs "
|
|
93
|
+
"(-pe <pe> <threads>). Must match a PE defined on your cluster. "
|
|
94
|
+
"If unset, multi-threaded jobs are submitted without a PE "
|
|
95
|
+
"(may fail on strict clusters)."
|
|
96
|
+
),
|
|
97
|
+
"env_var": False,
|
|
98
|
+
"required": False,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
project: Optional[str] = field(
|
|
103
|
+
default=None,
|
|
104
|
+
metadata={
|
|
105
|
+
"help": (
|
|
106
|
+
"SGE project to charge jobs to (-P flag). "
|
|
107
|
+
"Can also be set per-rule via the 'sge_project' resource."
|
|
108
|
+
),
|
|
109
|
+
"env_var": False,
|
|
110
|
+
"required": False,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# ---- Array jobs -------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
disable_group_jobs_as_array: bool = field(
|
|
117
|
+
default=False,
|
|
118
|
+
metadata={
|
|
119
|
+
"help": (
|
|
120
|
+
"Disable submitting Snakemake group jobs as SGE array jobs. "
|
|
121
|
+
"By default, group jobs are submitted as array jobs (qsub -t 1-N), "
|
|
122
|
+
"which reduces scheduler overhead. Set this flag to fall back to "
|
|
123
|
+
"individual qsub calls per task."
|
|
124
|
+
),
|
|
125
|
+
"env_var": False,
|
|
126
|
+
"required": False,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def group_jobs_as_array(self) -> bool:
|
|
132
|
+
return not self.disable_group_jobs_as_array
|
|
133
|
+
|
|
134
|
+
array_limit: int = field(
|
|
135
|
+
default=75000,
|
|
136
|
+
metadata={
|
|
137
|
+
"help": (
|
|
138
|
+
"Maximum number of array tasks per qsub -t call. "
|
|
139
|
+
"If a group exceeds this limit, multiple array submissions are "
|
|
140
|
+
"performed. The default (75 000) is a conservative value that "
|
|
141
|
+
"fits within SGE's typical MaxArraySize. Adjust to match your "
|
|
142
|
+
"cluster's configured limit."
|
|
143
|
+
),
|
|
144
|
+
"env_var": False,
|
|
145
|
+
"required": False,
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ---- Logging ----------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
logdir: Optional[Path] = field(
|
|
152
|
+
default=None,
|
|
153
|
+
metadata={
|
|
154
|
+
"help": (
|
|
155
|
+
"Directory for SGE log files. Defaults to "
|
|
156
|
+
"'.snakemake/sge_logs' relative to the working directory. "
|
|
157
|
+
"Absolute paths are used as-is; relative paths are resolved "
|
|
158
|
+
"against the workflow working directory."
|
|
159
|
+
),
|
|
160
|
+
"env_var": False,
|
|
161
|
+
"required": False,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
keep_successful_logs: bool = field(
|
|
166
|
+
default=False,
|
|
167
|
+
metadata={
|
|
168
|
+
"help": (
|
|
169
|
+
"By default, log files for successful jobs are deleted at the "
|
|
170
|
+
"end of the workflow. Set this flag to preserve them."
|
|
171
|
+
),
|
|
172
|
+
"env_var": False,
|
|
173
|
+
"required": False,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
delete_logfiles_older_than: int = field(
|
|
178
|
+
default=10,
|
|
179
|
+
metadata={
|
|
180
|
+
"help": (
|
|
181
|
+
"Delete SGE log files older than this many days (default: 10). "
|
|
182
|
+
"Set to 0 or negative to disable automatic deletion."
|
|
183
|
+
),
|
|
184
|
+
"env_var": False,
|
|
185
|
+
"required": False,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
hold_jid: Optional[str] = field(
|
|
190
|
+
default=None,
|
|
191
|
+
metadata={
|
|
192
|
+
"help": "Hold this job until the specified SGE job IDs have finished.",
|
|
193
|
+
"env_var": False,
|
|
194
|
+
"required": False,
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
hold_jid_ad: Optional[str] = field(
|
|
199
|
+
default=None,
|
|
200
|
+
metadata={
|
|
201
|
+
"help": "Hold this array job until the corresponding array tasks of the specified SGE job IDs have finished.",
|
|
202
|
+
"env_var": False,
|
|
203
|
+
"required": False,
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# ---- Status polling ---------------------------------------------------
|
|
208
|
+
|
|
209
|
+
init_seconds_before_status_checks: int = field(
|
|
210
|
+
default=20,
|
|
211
|
+
metadata={
|
|
212
|
+
"help": (
|
|
213
|
+
"Seconds to wait after job submission before the first "
|
|
214
|
+
"qstat/qacct status poll. SGE schedulers are usually faster "
|
|
215
|
+
"than SLURM so 20 s is a reasonable default."
|
|
216
|
+
),
|
|
217
|
+
"env_var": False,
|
|
218
|
+
"required": False,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
status_attempts: int = field(
|
|
223
|
+
default=5,
|
|
224
|
+
metadata={
|
|
225
|
+
"help": (
|
|
226
|
+
"Number of consecutive qstat/qacct query attempts before "
|
|
227
|
+
"giving up on a status check cycle."
|
|
228
|
+
),
|
|
229
|
+
"env_var": False,
|
|
230
|
+
"required": False,
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
disable_qacct: bool = field(
|
|
235
|
+
default=False,
|
|
236
|
+
metadata={
|
|
237
|
+
"help": (
|
|
238
|
+
"Disable using qacct (accounting) in addition to qstat to detect "
|
|
239
|
+
"completed / failed jobs. Use this if qacct is not available "
|
|
240
|
+
"or is very slow on your cluster."
|
|
241
|
+
),
|
|
242
|
+
"env_var": False,
|
|
243
|
+
"required": False,
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def use_qacct(self) -> bool:
|
|
249
|
+
return not self.disable_qacct
|
|
250
|
+
|
|
251
|
+
# ---- Misc -------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
jobname_prefix: str = field(
|
|
254
|
+
default="",
|
|
255
|
+
metadata={
|
|
256
|
+
"help": (
|
|
257
|
+
"Optional prefix prepended to the SGE job name. "
|
|
258
|
+
"Must contain only alphanumeric characters, underscores, or "
|
|
259
|
+
"hyphens. Maximum 30 characters."
|
|
260
|
+
),
|
|
261
|
+
"env_var": False,
|
|
262
|
+
"required": False,
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def __post_init__(self) -> None:
|
|
267
|
+
if self.jobname_prefix and not re.match(
|
|
268
|
+
r"^[A-Za-z0-9_-]{1,30}$", self.jobname_prefix
|
|
269
|
+
):
|
|
270
|
+
raise WorkflowError(
|
|
271
|
+
"sge jobname_prefix must contain only alphanumeric characters, "
|
|
272
|
+
"underscores or hyphens and must not exceed 30 characters."
|
|
273
|
+
)
|
|
274
|
+
if self.array_limit < 1:
|
|
275
|
+
raise WorkflowError("sge array_limit must be at least 1.")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# CommonSettings – static metadata consumed by the Snakemake framework
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
common_settings = CommonSettings(
|
|
283
|
+
non_local_exec=True,
|
|
284
|
+
implies_no_shared_fs=False,
|
|
285
|
+
job_deploy_sources=False,
|
|
286
|
+
pass_default_storage_provider_args=True,
|
|
287
|
+
pass_default_resources_args=True,
|
|
288
|
+
pass_envvar_declarations_to_cmd=False,
|
|
289
|
+
auto_deploy_default_storage_provider=False,
|
|
290
|
+
# Wait 30s before the first status poll so SGE has time to register
|
|
291
|
+
# newly submitted jobs in qstat. Without this the wait thread polls
|
|
292
|
+
# immediately and sees an empty qstat, marking jobs as finished.
|
|
293
|
+
init_seconds_before_status_checks=30,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# Helpers
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def _resolve_logdir(workflow) -> Path:
|
|
302
|
+
"""Return the resolved path to the SGE log directory."""
|
|
303
|
+
logdir = workflow.executor_settings.logdir
|
|
304
|
+
if logdir and str(logdir).startswith("/"):
|
|
305
|
+
return Path(logdir)
|
|
306
|
+
elif logdir:
|
|
307
|
+
return Path(workflow.workdir_init) / logdir
|
|
308
|
+
else:
|
|
309
|
+
return (Path(workflow.workdir_init) / ".snakemake" / "sge_logs").resolve()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _get_job_wildcards(job: JobExecutorInterface) -> str:
|
|
313
|
+
"""Return a filesystem-safe wildcard string for a job."""
|
|
314
|
+
wc = getattr(job, "wildcards", None)
|
|
315
|
+
if wc is None:
|
|
316
|
+
return ""
|
|
317
|
+
parts = []
|
|
318
|
+
for k, v in sorted(wc.items()):
|
|
319
|
+
safe_v = re.sub(r"[^\w.-]", "_", str(v))
|
|
320
|
+
parts.append(f"{k}={safe_v}")
|
|
321
|
+
return "__".join(parts)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _wildcard_sort_key(job: JobExecutorInterface):
|
|
325
|
+
"""Return a stable sort key derived from a job's wildcards.
|
|
326
|
+
|
|
327
|
+
Used to assign deterministic SGE array task indices: two rules that
|
|
328
|
+
iterate the same wildcard space (e.g. {subject}) will produce the
|
|
329
|
+
same ordering, which is a precondition for -hold_jid_ad.
|
|
330
|
+
"""
|
|
331
|
+
wc = getattr(job, "wildcards", None)
|
|
332
|
+
if not wc:
|
|
333
|
+
# Fall back to jobid so order is at least deterministic per run
|
|
334
|
+
return ((), getattr(job, "jobid", 0))
|
|
335
|
+
return (tuple(sorted((k, str(v)) for k, v in wc.items())), 0)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
# Executor
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
class Executor(RemoteExecutor):
|
|
343
|
+
"""Snakemake executor that submits jobs to an SGE/UGE/OGS cluster.
|
|
344
|
+
|
|
345
|
+
The executor lifecycle mirrors snakemake-executor-plugin-slurm:
|
|
346
|
+
|
|
347
|
+
1. ``run_jobs`` – classify incoming jobs and dispatch to either
|
|
348
|
+
``run_job`` (single qsub) or
|
|
349
|
+
``run_array_job`` (qsub -t 1-N).
|
|
350
|
+
2. ``check_active_jobs`` – poll qstat / qacct and report success/failure.
|
|
351
|
+
3. ``cancel_jobs`` – qdel all still-running jobs on interrupt.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
def __init__(self, workflow, logger):
|
|
355
|
+
super().__init__(workflow, logger)
|
|
356
|
+
|
|
357
|
+
def __post_init__(self, test_mode: bool = False) -> None:
|
|
358
|
+
self.test_mode = test_mode
|
|
359
|
+
self.run_uuid = str(uuid.uuid4())
|
|
360
|
+
if self.workflow.executor_settings.jobname_prefix:
|
|
361
|
+
self.run_uuid = "_".join(
|
|
362
|
+
[self.workflow.executor_settings.jobname_prefix, self.run_uuid]
|
|
363
|
+
)
|
|
364
|
+
self.logger.info(f"SGE run ID: {self.run_uuid}")
|
|
365
|
+
|
|
366
|
+
self.sge_logdir = _resolve_logdir(self.workflow)
|
|
367
|
+
self.sge_logdir.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
|
|
369
|
+
self._job_submission_executor = ThreadPoolExecutor(
|
|
370
|
+
max_workers=4, thread_name_prefix="sge_job_submit"
|
|
371
|
+
)
|
|
372
|
+
self._main_event_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
373
|
+
|
|
374
|
+
# Track submitted job IDs for cancellation
|
|
375
|
+
self._submitted_job_ids: List[str] = []
|
|
376
|
+
|
|
377
|
+
# Authoritative mapping from a Snakemake job to its SGE submission.
|
|
378
|
+
# Each entry is (sge_jobid, task_idx) where task_idx is None for
|
|
379
|
+
# non-array (single qsub) submissions or the 1-based array task
|
|
380
|
+
# index for array submissions. Used to resolve cross-job
|
|
381
|
+
# dependencies under --immediate-submit without depending on
|
|
382
|
+
# Snakemake's persistence layer (which doesn't always have the
|
|
383
|
+
# external_jobid populated by the time downstream submissions
|
|
384
|
+
# need it).
|
|
385
|
+
self._job_to_sge: "dict[JobExecutorInterface, tuple]" = {}
|
|
386
|
+
|
|
387
|
+
# Per-rule task index offset: tracks how many tasks have been submitted
|
|
388
|
+
# for each rule so far. When Snakemake batches run_jobs() calls for
|
|
389
|
+
# the same rule, each batch's SGE_TASK_IDs continue from where the
|
|
390
|
+
# previous batch left off, keeping them consistent with the task map.
|
|
391
|
+
self._rule_task_offset: "dict[str, int]" = {}
|
|
392
|
+
# Per-rule accumulated task map (task_id → b64-zlib-cmd). Grown
|
|
393
|
+
# across batches so all arrays for a rule share one task_map.b64 file
|
|
394
|
+
# and any task can resolve its command regardless of which array it
|
|
395
|
+
# landed in.
|
|
396
|
+
self._rule_task_map: "dict[str, dict]" = {}
|
|
397
|
+
|
|
398
|
+
atexit.register(self.clean_old_logs)
|
|
399
|
+
|
|
400
|
+
# Warn if neither qstat nor qacct is available
|
|
401
|
+
if not is_qstat_available():
|
|
402
|
+
raise WorkflowError(
|
|
403
|
+
"'qstat' is not available on this system. "
|
|
404
|
+
"Please ensure that SGE/UGE client tools are in PATH."
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# ------------------------------------------------------------------
|
|
408
|
+
# Thread-safe report helpers
|
|
409
|
+
# ------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
def _report_submission_threadsafe(self, job_info: SubmittedJobInfo) -> None:
|
|
412
|
+
if self._main_event_loop is not None:
|
|
413
|
+
self._main_event_loop.call_soon_threadsafe(
|
|
414
|
+
self.report_job_submission, job_info
|
|
415
|
+
)
|
|
416
|
+
else:
|
|
417
|
+
self.report_job_submission(job_info)
|
|
418
|
+
|
|
419
|
+
def _report_error_threadsafe(
|
|
420
|
+
self, job_info: SubmittedJobInfo, msg: str
|
|
421
|
+
) -> None:
|
|
422
|
+
if self._main_event_loop is not None:
|
|
423
|
+
self._main_event_loop.call_soon_threadsafe(
|
|
424
|
+
self.report_job_error, job_info, msg
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
self.report_job_error(job_info, msg=msg)
|
|
428
|
+
|
|
429
|
+
# ------------------------------------------------------------------
|
|
430
|
+
# Job dispatch
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
def run_jobs(self, jobs: List[JobExecutorInterface]) -> None:
|
|
434
|
+
"""Classify and dispatch incoming jobs.
|
|
435
|
+
|
|
436
|
+
Regular (non-group) jobs are bucketed by rule name and submitted as
|
|
437
|
+
SGE array jobs. When Snakemake calls run_jobs() multiple times for
|
|
438
|
+
the same rule (batching behaviour under --immediate-submit), each
|
|
439
|
+
call produces a separate array submission. Task IDs are globally
|
|
440
|
+
consecutive across calls (tracked via _rule_task_offset) so that
|
|
441
|
+
SGE_TASK_ID always maps to the correct entry in the shared task map
|
|
442
|
+
file, and -hold_jid_ad indices remain consistent.
|
|
443
|
+
"""
|
|
444
|
+
if self._main_event_loop is None:
|
|
445
|
+
try:
|
|
446
|
+
self._main_event_loop = asyncio.get_running_loop()
|
|
447
|
+
except RuntimeError:
|
|
448
|
+
self._main_event_loop = None
|
|
449
|
+
|
|
450
|
+
immediate = self.workflow.remote_execution_settings.immediate_submit
|
|
451
|
+
|
|
452
|
+
group_jobs: List[JobExecutorInterface] = []
|
|
453
|
+
regular_buckets: "dict[str, List[JobExecutorInterface]]" = {}
|
|
454
|
+
for job in jobs:
|
|
455
|
+
if job.is_group():
|
|
456
|
+
group_jobs.append(job)
|
|
457
|
+
else:
|
|
458
|
+
regular_buckets.setdefault(job.name, []).append(job)
|
|
459
|
+
|
|
460
|
+
for bucket in regular_buckets.values():
|
|
461
|
+
bucket.sort(key=_wildcard_sort_key)
|
|
462
|
+
|
|
463
|
+
for bucket in regular_buckets.values():
|
|
464
|
+
if immediate:
|
|
465
|
+
self.run_array_job(bucket)
|
|
466
|
+
else:
|
|
467
|
+
self._job_submission_executor.submit(self.run_array_job, bucket)
|
|
468
|
+
|
|
469
|
+
if group_jobs:
|
|
470
|
+
settings = self.workflow.executor_settings
|
|
471
|
+
if settings.group_jobs_as_array and len(group_jobs) > 1:
|
|
472
|
+
if immediate:
|
|
473
|
+
self.run_array_job(group_jobs)
|
|
474
|
+
else:
|
|
475
|
+
self._job_submission_executor.submit(
|
|
476
|
+
self.run_array_job, group_jobs
|
|
477
|
+
)
|
|
478
|
+
else:
|
|
479
|
+
for job in group_jobs:
|
|
480
|
+
if immediate:
|
|
481
|
+
self.run_job(job)
|
|
482
|
+
else:
|
|
483
|
+
self._job_submission_executor.submit(self.run_job, job)
|
|
484
|
+
|
|
485
|
+
# ------------------------------------------------------------------
|
|
486
|
+
# Single-job submission
|
|
487
|
+
# ------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
# Resource keys that materially affect SGE scheduling. Differences in
|
|
490
|
+
# these across an array bucket are worth warning about; cosmetic
|
|
491
|
+
# resources like 'name' are intentionally excluded.
|
|
492
|
+
_ARRAY_RESOURCE_KEYS = (
|
|
493
|
+
"mem_mb",
|
|
494
|
+
"mem_mb_per_cpu",
|
|
495
|
+
"runtime",
|
|
496
|
+
"threads",
|
|
497
|
+
"sge_queue",
|
|
498
|
+
"sge_project",
|
|
499
|
+
"sge_pe",
|
|
500
|
+
"sge_resources",
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def _warn_on_heterogeneous_resources(
|
|
504
|
+
self, jobs: List[JobExecutorInterface]
|
|
505
|
+
) -> None:
|
|
506
|
+
"""Warn if jobs in an array bucket differ in scheduling resources.
|
|
507
|
+
|
|
508
|
+
SGE applies one resource spec to every task in -t, so divergent
|
|
509
|
+
per-task requirements would be silently flattened to the first
|
|
510
|
+
job's values.
|
|
511
|
+
"""
|
|
512
|
+
if len(jobs) < 2:
|
|
513
|
+
return
|
|
514
|
+
first = jobs[0]
|
|
515
|
+
differing: dict = {}
|
|
516
|
+
for key in self._ARRAY_RESOURCE_KEYS:
|
|
517
|
+
ref = first.resources.get(key)
|
|
518
|
+
for j in jobs[1:]:
|
|
519
|
+
if j.resources.get(key) != ref:
|
|
520
|
+
differing.setdefault(key, set()).add(repr(ref))
|
|
521
|
+
differing[key].add(repr(j.resources.get(key)))
|
|
522
|
+
break
|
|
523
|
+
if differing:
|
|
524
|
+
summary = ", ".join(
|
|
525
|
+
f"{k}={{{', '.join(sorted(v))}}}" for k, v in differing.items()
|
|
526
|
+
)
|
|
527
|
+
self.logger.warning(
|
|
528
|
+
f"SGE array for rule '{first.name}' contains tasks with "
|
|
529
|
+
f"differing resources ({summary}). The first task's values "
|
|
530
|
+
f"will be applied to every task."
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def _resolve_array_holds(
|
|
534
|
+
self,
|
|
535
|
+
chunk_jobs: List[JobExecutorInterface],
|
|
536
|
+
chunk_start: int,
|
|
537
|
+
):
|
|
538
|
+
"""Decide whether to hold this array chunk with -hold_jid_ad or -hold_jid.
|
|
539
|
+
|
|
540
|
+
Returns
|
|
541
|
+
-------
|
|
542
|
+
(hold_jid_ad, hold_jid_list)
|
|
543
|
+
|
|
544
|
+
``hold_jid_ad`` is the base SGE job ID of a single upstream array
|
|
545
|
+
when every chunk task has exactly one upstream task and the
|
|
546
|
+
upstream task index matches the downstream task index (the
|
|
547
|
+
contract enforced by qsub's -hold_jid_ad).
|
|
548
|
+
|
|
549
|
+
Otherwise ``hold_jid_ad`` is None and ``hold_jid_list`` carries
|
|
550
|
+
all the upstream array job IDs to pass to plain -hold_jid (whole
|
|
551
|
+
upstream array(s) must finish first).
|
|
552
|
+
"""
|
|
553
|
+
# Collect each chunk task's upstreams. Each entry is a list of
|
|
554
|
+
# (sge_jobid, task_idx) tuples; task_idx is None when the
|
|
555
|
+
# upstream was a single (non-array) submission.
|
|
556
|
+
per_task: List[List[tuple]] = []
|
|
557
|
+
all_base_ids: List[str] = []
|
|
558
|
+
for j in chunk_jobs:
|
|
559
|
+
entries: List[tuple] = []
|
|
560
|
+
for _, sge_jobid, task_idx in self._upstream_ext_ids(j):
|
|
561
|
+
entries.append((sge_jobid, task_idx))
|
|
562
|
+
if sge_jobid not in all_base_ids:
|
|
563
|
+
all_base_ids.append(sge_jobid)
|
|
564
|
+
per_task.append(entries)
|
|
565
|
+
|
|
566
|
+
if not all_base_ids:
|
|
567
|
+
return (None, [])
|
|
568
|
+
|
|
569
|
+
# -hold_jid_ad eligibility: every chunk task has exactly one
|
|
570
|
+
# upstream, all upstreams share a single array job, and each
|
|
571
|
+
# upstream task index equals the downstream task index.
|
|
572
|
+
if len(all_base_ids) != 1:
|
|
573
|
+
return (None, all_base_ids)
|
|
574
|
+
upstream_base = all_base_ids[0]
|
|
575
|
+
|
|
576
|
+
for offset, entries in enumerate(per_task):
|
|
577
|
+
if len(entries) != 1:
|
|
578
|
+
return (None, all_base_ids)
|
|
579
|
+
sge_jobid, task_idx = entries[0]
|
|
580
|
+
if task_idx is None:
|
|
581
|
+
# Upstream was a single (non-array) submission.
|
|
582
|
+
# -hold_jid_ad needs an array on both sides.
|
|
583
|
+
return (None, all_base_ids)
|
|
584
|
+
if sge_jobid != upstream_base:
|
|
585
|
+
return (None, all_base_ids)
|
|
586
|
+
if task_idx != chunk_start + offset:
|
|
587
|
+
return (None, all_base_ids)
|
|
588
|
+
|
|
589
|
+
self.logger.debug(
|
|
590
|
+
f"Array chunk eligible for -hold_jid_ad on {upstream_base}"
|
|
591
|
+
)
|
|
592
|
+
return (upstream_base, [])
|
|
593
|
+
|
|
594
|
+
def _upstream_ext_ids(self, job):
|
|
595
|
+
"""Yield ``(upstream_job, sge_jobid, task_idx)`` for each upstream.
|
|
596
|
+
|
|
597
|
+
Reads from our authoritative in-memory map. ``task_idx`` is
|
|
598
|
+
``None`` if the upstream was a single (non-array) submission.
|
|
599
|
+
"""
|
|
600
|
+
try:
|
|
601
|
+
dag_deps = self.workflow.dag.dependencies.get(job, {})
|
|
602
|
+
except Exception as exc:
|
|
603
|
+
self.logger.debug(
|
|
604
|
+
f"Could not read DAG dependencies for job {job.jobid}: {exc}"
|
|
605
|
+
)
|
|
606
|
+
return
|
|
607
|
+
for upstream_job in dag_deps:
|
|
608
|
+
entry = self._job_to_sge.get(upstream_job)
|
|
609
|
+
if entry is None:
|
|
610
|
+
# Upstream hasn't been submitted yet (shouldn't happen
|
|
611
|
+
# under --immediate-submit since Snakemake walks the DAG
|
|
612
|
+
# in topological order); skip silently.
|
|
613
|
+
continue
|
|
614
|
+
sge_jobid, task_idx = entry
|
|
615
|
+
yield upstream_job, sge_jobid, task_idx
|
|
616
|
+
|
|
617
|
+
def _resolve_sge_dependencies(self, job) -> List[str]:
|
|
618
|
+
"""Return a deduped list of upstream SGE base job IDs.
|
|
619
|
+
|
|
620
|
+
Used for single-task -hold_jid submission. Drops any per-task
|
|
621
|
+
suffix so the dependent waits on the whole upstream (array or
|
|
622
|
+
not).
|
|
623
|
+
"""
|
|
624
|
+
dep_ids: List[str] = []
|
|
625
|
+
for _, sge_jobid, _ in self._upstream_ext_ids(job):
|
|
626
|
+
base_id = str(sge_jobid).split(".")[0]
|
|
627
|
+
if base_id not in dep_ids:
|
|
628
|
+
dep_ids.append(base_id)
|
|
629
|
+
return dep_ids
|
|
630
|
+
|
|
631
|
+
def run_job(self, job: JobExecutorInterface) -> None:
|
|
632
|
+
"""Submit a single job via qsub."""
|
|
633
|
+
group_or_rule = f"group_{job.name}" if job.is_group() else f"rule_{job.name}"
|
|
634
|
+
wildcard_str = _get_job_wildcards(job)
|
|
635
|
+
|
|
636
|
+
logdir = self.sge_logdir / group_or_rule / wildcard_str
|
|
637
|
+
logdir.mkdir(parents=True, exist_ok=True)
|
|
638
|
+
|
|
639
|
+
# SGE uses separate stdout/stderr streams unless -j y is passed
|
|
640
|
+
log_stdout = logdir / "$JOB_ID.o"
|
|
641
|
+
log_stderr = logdir / "$JOB_ID.e"
|
|
642
|
+
|
|
643
|
+
job_params = {
|
|
644
|
+
"run_uuid": self.run_uuid,
|
|
645
|
+
"log_stdout": log_stdout,
|
|
646
|
+
"log_stderr": log_stderr,
|
|
647
|
+
"workdir": self.workflow.workdir_init,
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
# Resolve upstream SGE job IDs for -hold_jid (needed for --immediate-submit)
|
|
651
|
+
dep_ids = self._resolve_sge_dependencies(job)
|
|
652
|
+
|
|
653
|
+
exec_job = self.format_job_exec(job)
|
|
654
|
+
call = get_submit_command(
|
|
655
|
+
job,
|
|
656
|
+
job_params,
|
|
657
|
+
settings=self.workflow.executor_settings,
|
|
658
|
+
exec_cmd=exec_job,
|
|
659
|
+
hold_jid_list=dep_ids,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
self.logger.debug(f"qsub call: {call}")
|
|
663
|
+
try:
|
|
664
|
+
# We use check_output to get the job ID but we also want to show
|
|
665
|
+
# the output to the user for confirmation.
|
|
666
|
+
out = subprocess.check_output(
|
|
667
|
+
call,
|
|
668
|
+
shell=True,
|
|
669
|
+
text=True,
|
|
670
|
+
stderr=subprocess.STDOUT,
|
|
671
|
+
).strip()
|
|
672
|
+
# Print the qsub confirmation message so the user sees it
|
|
673
|
+
print(out, flush=True)
|
|
674
|
+
except subprocess.CalledProcessError as e:
|
|
675
|
+
err_msg = f"SGE qsub failed: {e.output.strip()}\n Command: {call}"
|
|
676
|
+
print(err_msg, flush=True)
|
|
677
|
+
self._report_error_threadsafe(
|
|
678
|
+
SubmittedJobInfo(job),
|
|
679
|
+
err_msg,
|
|
680
|
+
)
|
|
681
|
+
return
|
|
682
|
+
|
|
683
|
+
# qsub output: "Your job 12345 (\"name\") has been submitted"
|
|
684
|
+
sge_jobid = _parse_qsub_jobid(out)
|
|
685
|
+
if sge_jobid is None:
|
|
686
|
+
self._report_error_threadsafe(
|
|
687
|
+
SubmittedJobInfo(job),
|
|
688
|
+
f"Could not parse SGE job ID from qsub output: {out!r}",
|
|
689
|
+
)
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
self.logger.info(
|
|
693
|
+
f"Job {job.jobid} submitted as SGE job {sge_jobid} "
|
|
694
|
+
f"(log: {logdir})"
|
|
695
|
+
)
|
|
696
|
+
self._submitted_job_ids.append(sge_jobid)
|
|
697
|
+
# Record the job→SGE-id mapping BEFORE notifying Snakemake so any
|
|
698
|
+
# downstream submission triggered by the report sees it.
|
|
699
|
+
self._job_to_sge[job] = (sge_jobid, None)
|
|
700
|
+
# Resolve the actual log path now that we have the job ID
|
|
701
|
+
log_stdout_resolved = logdir / f"{sge_jobid}.o"
|
|
702
|
+
log_stderr_resolved = logdir / f"{sge_jobid}.e"
|
|
703
|
+
self._report_submission_threadsafe(
|
|
704
|
+
SubmittedJobInfo(
|
|
705
|
+
job,
|
|
706
|
+
external_jobid=sge_jobid,
|
|
707
|
+
aux={
|
|
708
|
+
"log_stdout": log_stdout_resolved,
|
|
709
|
+
"log_stderr": log_stderr_resolved,
|
|
710
|
+
"submit_time": time.time(),
|
|
711
|
+
},
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# ------------------------------------------------------------------
|
|
716
|
+
# Array-job submission (group jobs)
|
|
717
|
+
# ------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
def run_array_job(self, jobs: List[JobExecutorInterface]) -> None:
|
|
720
|
+
"""Submit all tasks in *jobs* as a single SGE array job.
|
|
721
|
+
|
|
722
|
+
Each task is encoded as a zlib-compressed, base64-encoded JSON
|
|
723
|
+
entry so that the submission script can unpack and execute it
|
|
724
|
+
based on ``$SGE_TASK_ID``.
|
|
725
|
+
|
|
726
|
+
The approach is identical to the SLURM plugin's ``run_array_jobs``
|
|
727
|
+
method, adapted for SGE's ``qsub -t <start>-<end>`` syntax.
|
|
728
|
+
|
|
729
|
+
Both group jobs and same-rule regular-job buckets are supported.
|
|
730
|
+
All tasks in a single submission share one set of SGE resources
|
|
731
|
+
(queue, memory, runtime, etc.) taken from the first job in the
|
|
732
|
+
bucket -- callers should bucket jobs whose resources are
|
|
733
|
+
compatible (e.g. all instances of the same rule).
|
|
734
|
+
"""
|
|
735
|
+
if not jobs:
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
group_or_rule = (
|
|
739
|
+
f"group_{jobs[0].name}"
|
|
740
|
+
if jobs[0].is_group()
|
|
741
|
+
else f"rule_{jobs[0].name}"
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
logdir = self.sge_logdir / group_or_rule
|
|
745
|
+
logdir.mkdir(parents=True, exist_ok=True)
|
|
746
|
+
|
|
747
|
+
# Determine the global starting task index for this batch.
|
|
748
|
+
# Successive run_jobs() calls for the same rule each produce a
|
|
749
|
+
# separate run_array_job() invocation. Without a global offset the
|
|
750
|
+
# second batch would number its tasks 1..50 again, but SGE assigns
|
|
751
|
+
# them 51..100, causing KeyError when the script looks up "51" in a
|
|
752
|
+
# map that only contains "1".."50".
|
|
753
|
+
rule_key = group_or_rule
|
|
754
|
+
global_start = self._rule_task_offset.get(rule_key, 0) + 1
|
|
755
|
+
n_tasks = len(jobs)
|
|
756
|
+
self._rule_task_offset[rule_key] = global_start + n_tasks - 1
|
|
757
|
+
|
|
758
|
+
# Build the compressed task → command map using globally consecutive
|
|
759
|
+
# task IDs so SGE_TASK_ID always matches the map key.
|
|
760
|
+
task_map = {
|
|
761
|
+
str(idx): base64.b64encode(
|
|
762
|
+
zlib.compress(self.format_job_exec(job).encode("utf-8"), level=9)
|
|
763
|
+
).decode()
|
|
764
|
+
for idx, job in enumerate(jobs, start=global_start)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
# Accumulate into the per-rule task map and (re)write the shared file
|
|
768
|
+
# so all arrays for this rule can read the complete map.
|
|
769
|
+
if rule_key not in self._rule_task_map:
|
|
770
|
+
self._rule_task_map[rule_key] = {}
|
|
771
|
+
self._rule_task_map[rule_key].update(task_map)
|
|
772
|
+
task_map_b64 = base64.b64encode(
|
|
773
|
+
json.dumps(self._rule_task_map[rule_key]).encode()
|
|
774
|
+
).decode()
|
|
775
|
+
|
|
776
|
+
# Manifest: human-readable record of task ID → wildcards for debugging.
|
|
777
|
+
manifest = {
|
|
778
|
+
str(idx): {
|
|
779
|
+
"snakemake_jobid": getattr(job, "jobid", None),
|
|
780
|
+
"wildcards": dict(job.wildcards) if getattr(job, "wildcards", None) else {},
|
|
781
|
+
"is_group": job.is_group(),
|
|
782
|
+
}
|
|
783
|
+
for idx, job in enumerate(jobs, start=global_start)
|
|
784
|
+
}
|
|
785
|
+
manifest_path = logdir / "task_manifest.json"
|
|
786
|
+
try:
|
|
787
|
+
existing = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
|
|
788
|
+
existing.update(manifest)
|
|
789
|
+
manifest_path.write_text(json.dumps(existing, indent=2))
|
|
790
|
+
except OSError as exc:
|
|
791
|
+
self.logger.debug(f"Could not write task manifest {manifest_path}: {exc}")
|
|
792
|
+
|
|
793
|
+
# SGE arrays share one resource spec across all tasks. When the
|
|
794
|
+
# bucket contains jobs whose resources differ (e.g. per-wildcard
|
|
795
|
+
# mem_mb / runtime), the first job's values are applied to every
|
|
796
|
+
# task. Warn so users notice silent over/under-allocation.
|
|
797
|
+
self._warn_on_heterogeneous_resources(jobs)
|
|
798
|
+
|
|
799
|
+
settings = self.workflow.executor_settings
|
|
800
|
+
array_limit = settings.array_limit
|
|
801
|
+
|
|
802
|
+
# Write the complete (cumulative) task map for this rule so all
|
|
803
|
+
# submitted arrays can resolve any task ID. Named per rule so
|
|
804
|
+
# concurrent rules don't overwrite each other's file.
|
|
805
|
+
task_map_file = logdir / "task_map.b64"
|
|
806
|
+
task_map_file.write_text(task_map_b64)
|
|
807
|
+
|
|
808
|
+
global_end = global_start + n_tasks - 1
|
|
809
|
+
for chunk_start in range(global_start, global_end + 1, array_limit):
|
|
810
|
+
chunk_end = min(chunk_start + array_limit - 1, global_end)
|
|
811
|
+
# Convert global task indices back to local slice indices (0-based)
|
|
812
|
+
local_start = chunk_start - global_start
|
|
813
|
+
local_end = chunk_end - global_start + 1
|
|
814
|
+
chunk_jobs = jobs[local_start:local_end]
|
|
815
|
+
|
|
816
|
+
kind = "group" if jobs[0].is_group() else "rule"
|
|
817
|
+
script_lines = [
|
|
818
|
+
"#!/bin/bash",
|
|
819
|
+
"set -euo pipefail",
|
|
820
|
+
f"# SGE array job for Snakemake {kind} '{jobs[0].name}'",
|
|
821
|
+
f"# run_uuid={self.run_uuid}",
|
|
822
|
+
"",
|
|
823
|
+
"# Read the task map from the shared filesystem file.",
|
|
824
|
+
"# Avoids ARG_MAX issues for large arrays (150+ tasks).",
|
|
825
|
+
f"export TASK_MAP_FILE={shlex.quote(str(task_map_file))}",
|
|
826
|
+
"",
|
|
827
|
+
"# Extract and run the command for this task",
|
|
828
|
+
"export _tid=${SGE_TASK_ID}",
|
|
829
|
+
"_cmd=$(",
|
|
830
|
+
" python3 - <<'PYEOF'",
|
|
831
|
+
"import sys, base64, zlib, json, os",
|
|
832
|
+
"task_map = json.loads(base64.b64decode(open(os.environ['TASK_MAP_FILE']).read()))",
|
|
833
|
+
"tid = str(os.environ['_tid'])",
|
|
834
|
+
"cmd = zlib.decompress(base64.b64decode(task_map[tid])).decode()",
|
|
835
|
+
"sys.stdout.write(cmd)",
|
|
836
|
+
"PYEOF",
|
|
837
|
+
")",
|
|
838
|
+
"eval \"$_cmd\"",
|
|
839
|
+
]
|
|
840
|
+
|
|
841
|
+
script_content = "\n".join(script_lines)
|
|
842
|
+
|
|
843
|
+
script_path = logdir / f"array_job_{chunk_start}_{chunk_end}.sh"
|
|
844
|
+
script_path.write_text(script_content)
|
|
845
|
+
script_path.chmod(0o755)
|
|
846
|
+
|
|
847
|
+
job_params = {
|
|
848
|
+
"run_uuid": self.run_uuid,
|
|
849
|
+
"log_stdout": logdir / "$JOB_ID.$TASK_ID.o",
|
|
850
|
+
"log_stderr": logdir / "$JOB_ID.$TASK_ID.e",
|
|
851
|
+
"workdir": self.workflow.workdir_init,
|
|
852
|
+
"array_range": f"{chunk_start}-{chunk_end}",
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
# Resolve cross-array dependencies. Two cases:
|
|
856
|
+
#
|
|
857
|
+
# 1. Per-task 1:1 matching across a single upstream array
|
|
858
|
+
# (e.g. run_bamos_correction[N] -> run_bamos[N], one
|
|
859
|
+
# subject per task). → use SGE's -hold_jid_ad so a
|
|
860
|
+
# downstream task starts the moment its specific
|
|
861
|
+
# upstream task finishes, instead of waiting for the
|
|
862
|
+
# whole upstream array.
|
|
863
|
+
#
|
|
864
|
+
# 2. Anything else (multiple upstream arrays, or task
|
|
865
|
+
# indices that don't line up). → fall back to
|
|
866
|
+
# -hold_jid (whole-array hold).
|
|
867
|
+
hold_ad_id, hold_ids = self._resolve_array_holds(
|
|
868
|
+
chunk_jobs, chunk_start
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
call = get_submit_command(
|
|
872
|
+
chunk_jobs[0],
|
|
873
|
+
job_params,
|
|
874
|
+
settings=settings,
|
|
875
|
+
exec_cmd=None, # command is in script
|
|
876
|
+
script_path=str(script_path),
|
|
877
|
+
is_array=True,
|
|
878
|
+
hold_jid_list=hold_ids,
|
|
879
|
+
hold_jid_ad_override=hold_ad_id,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
self.logger.debug(f"qsub array call: {call}")
|
|
883
|
+
try:
|
|
884
|
+
out = subprocess.check_output(
|
|
885
|
+
call,
|
|
886
|
+
shell=True,
|
|
887
|
+
text=True,
|
|
888
|
+
stderr=subprocess.STDOUT,
|
|
889
|
+
).strip()
|
|
890
|
+
print(out, flush=True)
|
|
891
|
+
except subprocess.CalledProcessError as e:
|
|
892
|
+
error_msg = (
|
|
893
|
+
f"SGE qsub array submission failed "
|
|
894
|
+
f"(tasks {chunk_start}-{chunk_end}): "
|
|
895
|
+
f"{e.output.strip()}\n Command: {call}"
|
|
896
|
+
)
|
|
897
|
+
self.logger.error(error_msg)
|
|
898
|
+
for job in chunk_jobs:
|
|
899
|
+
self._report_error_threadsafe(
|
|
900
|
+
SubmittedJobInfo(job),
|
|
901
|
+
f"Part of failed array qsub submission "
|
|
902
|
+
f"(tasks {chunk_start}-{chunk_end}); see log.",
|
|
903
|
+
)
|
|
904
|
+
continue
|
|
905
|
+
|
|
906
|
+
sge_jobid = _parse_qsub_jobid(out)
|
|
907
|
+
if sge_jobid is None:
|
|
908
|
+
self.logger.error(
|
|
909
|
+
f"Could not parse SGE array job ID from: {out!r}"
|
|
910
|
+
)
|
|
911
|
+
for job in chunk_jobs:
|
|
912
|
+
self._report_error_threadsafe(
|
|
913
|
+
SubmittedJobInfo(job),
|
|
914
|
+
f"Could not parse SGE job ID from qsub output: {out!r}",
|
|
915
|
+
)
|
|
916
|
+
continue
|
|
917
|
+
|
|
918
|
+
self._submitted_job_ids.append(sge_jobid)
|
|
919
|
+
hold_msg = ""
|
|
920
|
+
if hold_ad_id:
|
|
921
|
+
hold_msg = f" -hold_jid_ad {hold_ad_id}"
|
|
922
|
+
elif hold_ids:
|
|
923
|
+
hold_msg = f" -hold_jid {','.join(hold_ids)}"
|
|
924
|
+
self.logger.info(
|
|
925
|
+
f"Submitted SGE array job {sge_jobid} "
|
|
926
|
+
f"for {kind} '{jobs[0].name}' "
|
|
927
|
+
f"(tasks {chunk_start}-{chunk_end}){hold_msg}."
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
# Record the job→SGE-id mapping for the whole chunk BEFORE
|
|
931
|
+
# notifying Snakemake. Each report_job_submission may unblock
|
|
932
|
+
# the scheduler, which can immediately call run_jobs again
|
|
933
|
+
# with downstream tasks that need to read these mappings.
|
|
934
|
+
for task_idx, job in enumerate(chunk_jobs, start=chunk_start):
|
|
935
|
+
self._job_to_sge[job] = (sge_jobid, task_idx)
|
|
936
|
+
|
|
937
|
+
# Register each task with Snakemake
|
|
938
|
+
for task_idx, job in enumerate(chunk_jobs, start=chunk_start):
|
|
939
|
+
external_id = f"{sge_jobid}.{task_idx}"
|
|
940
|
+
log_o = logdir / f"{sge_jobid}.{task_idx}.o"
|
|
941
|
+
log_e = logdir / f"{sge_jobid}.{task_idx}.e"
|
|
942
|
+
self._report_submission_threadsafe(
|
|
943
|
+
SubmittedJobInfo(
|
|
944
|
+
job,
|
|
945
|
+
external_jobid=external_id,
|
|
946
|
+
aux={
|
|
947
|
+
"log_stdout": log_o,
|
|
948
|
+
"log_stderr": log_e,
|
|
949
|
+
"submit_time": time.time(),
|
|
950
|
+
},
|
|
951
|
+
)
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
# ------------------------------------------------------------------
|
|
955
|
+
# Status checking
|
|
956
|
+
# ------------------------------------------------------------------
|
|
957
|
+
|
|
958
|
+
async def check_active_jobs(
|
|
959
|
+
self, active_jobs: List[SubmittedJobInfo]
|
|
960
|
+
) -> Generator[SubmittedJobInfo, None, None]:
|
|
961
|
+
"""Poll qstat / qacct to determine job completion status.
|
|
962
|
+
|
|
963
|
+
Yields jobs that are still running/pending.
|
|
964
|
+
Reports completed jobs via ``report_job_success``.
|
|
965
|
+
Reports failed jobs via ``report_job_error``.
|
|
966
|
+
"""
|
|
967
|
+
if not active_jobs:
|
|
968
|
+
return
|
|
969
|
+
|
|
970
|
+
settings = self.workflow.executor_settings
|
|
971
|
+
max_sleep = 180
|
|
972
|
+
initial_interval = settings.init_seconds_before_status_checks
|
|
973
|
+
|
|
974
|
+
for _ in range(settings.status_attempts):
|
|
975
|
+
async with self.status_rate_limiter:
|
|
976
|
+
status_map = await query_job_status(
|
|
977
|
+
active_jobs,
|
|
978
|
+
use_qacct=settings.use_qacct,
|
|
979
|
+
logger=self.logger,
|
|
980
|
+
)
|
|
981
|
+
if status_map is not None:
|
|
982
|
+
break
|
|
983
|
+
else:
|
|
984
|
+
# All attempts failed – yield all jobs as still running
|
|
985
|
+
self.logger.warning(
|
|
986
|
+
"All qstat/qacct status query attempts failed; "
|
|
987
|
+
"treating all active jobs as still running."
|
|
988
|
+
)
|
|
989
|
+
for j in active_jobs:
|
|
990
|
+
yield j
|
|
991
|
+
return
|
|
992
|
+
|
|
993
|
+
any_finished = False
|
|
994
|
+
self.logger.debug(
|
|
995
|
+
f"check_active_jobs: {len(active_jobs)} active, "
|
|
996
|
+
f"status_map keys={list(status_map.keys())}, "
|
|
997
|
+
f"values={list(status_map.values())}"
|
|
998
|
+
)
|
|
999
|
+
for j in active_jobs:
|
|
1000
|
+
status = status_map.get(j.external_jobid)
|
|
1001
|
+
submit_t = j.aux.get("submit_time", "N/A") if j.aux else "no-aux"
|
|
1002
|
+
self.logger.debug(
|
|
1003
|
+
f" job {j.external_jobid}: status={status}, "
|
|
1004
|
+
f"submit_time={submit_t}, aux_keys={list(j.aux.keys()) if j.aux else None}"
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
if status is None:
|
|
1008
|
+
# Job not yet visible to qstat/qacct — assume still queued
|
|
1009
|
+
yield j
|
|
1010
|
+
continue
|
|
1011
|
+
|
|
1012
|
+
if status == "finished":
|
|
1013
|
+
self.report_job_success(j)
|
|
1014
|
+
any_finished = True
|
|
1015
|
+
if not settings.keep_successful_logs:
|
|
1016
|
+
self._delete_job_logs(j)
|
|
1017
|
+
elif status == "failed":
|
|
1018
|
+
log_files = [
|
|
1019
|
+
str(j.aux.get("log_stdout", "")),
|
|
1020
|
+
str(j.aux.get("log_stderr", "")),
|
|
1021
|
+
]
|
|
1022
|
+
self.report_job_error(
|
|
1023
|
+
j,
|
|
1024
|
+
msg=(
|
|
1025
|
+
f"SGE job '{j.external_jobid}' failed. "
|
|
1026
|
+
f"Check logs: {log_files}"
|
|
1027
|
+
),
|
|
1028
|
+
aux_logs=[lf for lf in log_files if lf],
|
|
1029
|
+
)
|
|
1030
|
+
else:
|
|
1031
|
+
# running / pending
|
|
1032
|
+
yield j
|
|
1033
|
+
|
|
1034
|
+
if not any_finished:
|
|
1035
|
+
self.next_seconds_between_status_checks = min(
|
|
1036
|
+
self.next_seconds_between_status_checks + 10,
|
|
1037
|
+
max_sleep,
|
|
1038
|
+
)
|
|
1039
|
+
else:
|
|
1040
|
+
self.next_seconds_between_status_checks = initial_interval
|
|
1041
|
+
|
|
1042
|
+
# ------------------------------------------------------------------
|
|
1043
|
+
# Cancellation
|
|
1044
|
+
# ------------------------------------------------------------------
|
|
1045
|
+
|
|
1046
|
+
def cancel_jobs(self, active_jobs: List[SubmittedJobInfo]) -> None:
|
|
1047
|
+
"""Cancel all active SGE jobs via qdel."""
|
|
1048
|
+
cancel_sge_jobs(active_jobs, self.logger)
|
|
1049
|
+
|
|
1050
|
+
# ------------------------------------------------------------------
|
|
1051
|
+
# Cleanup
|
|
1052
|
+
# ------------------------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
def shutdown(self) -> None:
|
|
1055
|
+
self._job_submission_executor.shutdown(wait=True)
|
|
1056
|
+
super().shutdown()
|
|
1057
|
+
self.clean_old_logs()
|
|
1058
|
+
|
|
1059
|
+
def clean_old_logs(self) -> None:
|
|
1060
|
+
"""Delete log files older than *delete_logfiles_older_than* days."""
|
|
1061
|
+
age_cutoff = self.workflow.executor_settings.delete_logfiles_older_than
|
|
1062
|
+
if age_cutoff <= 0:
|
|
1063
|
+
return
|
|
1064
|
+
if self.workflow.executor_settings.keep_successful_logs:
|
|
1065
|
+
return
|
|
1066
|
+
cutoff_secs = age_cutoff * 86400
|
|
1067
|
+
now = time.time()
|
|
1068
|
+
self.logger.debug(
|
|
1069
|
+
f"Cleaning SGE log files older than {age_cutoff} day(s)."
|
|
1070
|
+
)
|
|
1071
|
+
for path in self.sge_logdir.rglob("*"):
|
|
1072
|
+
if path.is_file():
|
|
1073
|
+
try:
|
|
1074
|
+
if now - path.stat().st_mtime > cutoff_secs:
|
|
1075
|
+
path.unlink()
|
|
1076
|
+
except OSError as exc:
|
|
1077
|
+
self.logger.warning(f"Could not delete log {path}: {exc}")
|
|
1078
|
+
# Clean up empty directories
|
|
1079
|
+
for path in sorted(self.sge_logdir.rglob("*"), reverse=True):
|
|
1080
|
+
if path.is_dir():
|
|
1081
|
+
try:
|
|
1082
|
+
path.rmdir() # Only removes if empty
|
|
1083
|
+
except OSError:
|
|
1084
|
+
pass
|
|
1085
|
+
|
|
1086
|
+
def _delete_job_logs(self, job_info: SubmittedJobInfo) -> None:
|
|
1087
|
+
"""Delete stdout/stderr log files for a completed job."""
|
|
1088
|
+
for key in ("log_stdout", "log_stderr"):
|
|
1089
|
+
log_path = job_info.aux.get(key)
|
|
1090
|
+
if log_path and Path(log_path).exists():
|
|
1091
|
+
try:
|
|
1092
|
+
Path(log_path).unlink()
|
|
1093
|
+
except OSError as exc:
|
|
1094
|
+
self.logger.warning(
|
|
1095
|
+
f"Could not delete log {log_path}: {exc}"
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
# ------------------------------------------------------------------
|
|
1099
|
+
# Additional args passed to exec_job
|
|
1100
|
+
# ------------------------------------------------------------------
|
|
1101
|
+
|
|
1102
|
+
def additional_general_args(self) -> str:
|
|
1103
|
+
"""Extra Snakemake arguments forwarded to job-step execution."""
|
|
1104
|
+
return "--executor local --jobs 1"
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
# ---------------------------------------------------------------------------
|
|
1108
|
+
# Utility
|
|
1109
|
+
# ---------------------------------------------------------------------------
|
|
1110
|
+
|
|
1111
|
+
def _parse_qsub_jobid(output: str) -> Optional[str]:
|
|
1112
|
+
"""Extract the numeric job ID from qsub's output.
|
|
1113
|
+
|
|
1114
|
+
Handles the common SGE/UGE variants::
|
|
1115
|
+
|
|
1116
|
+
Your job 12345 ("name") has been submitted
|
|
1117
|
+
Your job-array 12345.1-10:1 ("name") has been submitted
|
|
1118
|
+
12345
|
|
1119
|
+
"""
|
|
1120
|
+
# Try the standard verbose form first
|
|
1121
|
+
m = re.search(r"Your job(?:-array)?\s+(\d+)[.\s]", output)
|
|
1122
|
+
if m:
|
|
1123
|
+
return m.group(1)
|
|
1124
|
+
# Some clusters just emit the job ID on stdout
|
|
1125
|
+
m = re.match(r"^(\d+)$", output.strip())
|
|
1126
|
+
if m:
|
|
1127
|
+
return m.group(1)
|
|
1128
|
+
return None
|