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,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