snakemake-executor-plugin-aws-batch-flex 0.1.0__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.
Files changed (30) hide show
  1. snakemake_executor_plugin_aws_batch_flex/__init__.py +8 -0
  2. snakemake_executor_plugin_aws_batch_flex/bootstrap.py +376 -0
  3. snakemake_executor_plugin_aws_batch_flex/bundle.py +142 -0
  4. snakemake_executor_plugin_aws_batch_flex/check.py +129 -0
  5. snakemake_executor_plugin_aws_batch_flex/condacache.py +184 -0
  6. snakemake_executor_plugin_aws_batch_flex/executor.py +725 -0
  7. snakemake_executor_plugin_aws_batch_flex/headjob.py +244 -0
  8. snakemake_executor_plugin_aws_batch_flex/hints.py +75 -0
  9. snakemake_executor_plugin_aws_batch_flex/hostenv.py +221 -0
  10. snakemake_executor_plugin_aws_batch_flex/imageinfo.py +207 -0
  11. snakemake_executor_plugin_aws_batch_flex/jobdefs.py +221 -0
  12. snakemake_executor_plugin_aws_batch_flex/jobfixes.py +65 -0
  13. snakemake_executor_plugin_aws_batch_flex/jobmonitor.py +146 -0
  14. snakemake_executor_plugin_aws_batch_flex/naming.py +16 -0
  15. snakemake_executor_plugin_aws_batch_flex/orphans.py +51 -0
  16. snakemake_executor_plugin_aws_batch_flex/patches.py +196 -0
  17. snakemake_executor_plugin_aws_batch_flex/preflight.py +165 -0
  18. snakemake_executor_plugin_aws_batch_flex/profiles.py +98 -0
  19. snakemake_executor_plugin_aws_batch_flex/remote.py +409 -0
  20. snakemake_executor_plugin_aws_batch_flex/settings.py +482 -0
  21. snakemake_executor_plugin_aws_batch_flex/sizing.py +75 -0
  22. snakemake_executor_plugin_aws_batch_flex/sources.py +83 -0
  23. snakemake_executor_plugin_aws_batch_flex/tags.py +76 -0
  24. snakemake_executor_plugin_aws_batch_flex/transfer.py +193 -0
  25. snakemake_executor_plugin_aws_batch_flex/usage.py +208 -0
  26. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/METADATA +343 -0
  27. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/RECORD +30 -0
  28. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/WHEEL +4 -0
  29. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/entry_points.txt +3 -0
  30. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,725 @@
1
+ """The executor: submits each Snakemake job as an AWS Batch job in its rule's image, follows
2
+ it, and prints what the run used at the end."""
3
+
4
+ import asyncio
5
+ import json
6
+ import importlib.metadata as md
7
+ import math
8
+ import os
9
+ import time
10
+ import uuid
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from dataclasses import dataclass
13
+ from typing import AsyncGenerator, List, Optional
14
+
15
+ import boto3
16
+ from botocore.config import Config as BotoConfig
17
+ from botocore.exceptions import BotoCoreError, ClientError
18
+ from snakemake_interface_common.exceptions import WorkflowError
19
+ from snakemake_interface_executor_plugins.executors.base import SubmittedJobInfo
20
+ from snakemake_interface_executor_plugins.executors.remote import RemoteExecutor
21
+ from snakemake_interface_executor_plugins.jobs import JobExecutorInterface
22
+
23
+ from .bootstrap import conda_snippet, job_tools_source, launcher_snippet, snakemake_prelude
24
+ from .hints import known_problem_hints
25
+ from .imageinfo import Entrypoints
26
+ from .jobdefs import JobDefinitions, describe_existing, log_group_of, mutable_tag, normalize_image
27
+ from .naming import batch_job_name
28
+ from .orphans import ACTIVE, find_orphans, workflow_id
29
+ from .patches import NOT_APPLIED, keep_unneeded_temp, recheck_s3_directories
30
+ from .preflight import (
31
+ SUBMIT_HINT,
32
+ access_denied,
33
+ ami_snakemake_version,
34
+ check_queue,
35
+ check_role,
36
+ describe_queue,
37
+ version_note,
38
+ )
39
+ from .settings import (
40
+ ExecutorSettings,
41
+ host_mounts,
42
+ normalized,
43
+ registry_secrets,
44
+ validate,
45
+ volume_mounts,
46
+ )
47
+ from .sizing import fargate_size, job_requirements
48
+ from .tags import job_tags, parse_pairs, parse_resource_string
49
+ from .usage import Usage
50
+
51
+ FARGATE_MAX_DISK_GIB = 200
52
+ CONDA_CACHE_HOST_DIR = "/var/cache/smkflex-conda"
53
+ CONDA_CACHE_JOB_DIR = "/tmp/smkflex-conda" # condacache.ROOT
54
+ FARGATE_MAX_MEM_MIB = 122880
55
+ WRAPPER_HTTP_PREFIX = "https://github.com/snakemake/snakemake-wrappers/raw/"
56
+ ECS_OVERRIDES_MAX = 8192 # characters of a job's command and environment
57
+ CANCEL_THREADS = 16 # terminate_job calls at once when the run is cancelled
58
+
59
+
60
+ def _out_of_memory(d: dict) -> bool:
61
+ """Batch/ECS says why a container stopped; out of memory is OutOfMemoryError."""
62
+ c = d.get("container") or {}
63
+ return "OutOfMemory" in (c.get("reason") or "") or "OutOfMemory" in (d.get("statusReason") or "")
64
+
65
+
66
+ @dataclass
67
+ class Submitted:
68
+ """What the plugin keeps about a submitted job (in SubmittedJobInfo.aux["smkflex"])."""
69
+ image: str
70
+ queue: str
71
+ platform: str # ec2 | fargate
72
+ vcpu: float
73
+ mem: int # MiB the job got
74
+ asked: int # MiB its rule asked for (before a retry's extra)
75
+ warned: bool = False # about waiting long in RUNNABLE
76
+
77
+
78
+ def _sub(info: SubmittedJobInfo) -> Submitted:
79
+ return info.aux["smkflex"]
80
+
81
+
82
+ def _not_allowed(e) -> bool:
83
+ """Was a Batch call refused for want of a permission? Batch reports a missing iam:PassRole
84
+ as a plain ClientException that says "not authorized"."""
85
+ return isinstance(e, ClientError) and (
86
+ access_denied(e) or "not authorized" in e.response.get("Error", {}).get("Message", ""))
87
+
88
+
89
+ class Executor(RemoteExecutor):
90
+ def __post_init__(self):
91
+ s: ExecutorSettings = validate(normalized(self.workflow.executor_settings))
92
+ recheck_s3_directories()
93
+ for what in NOT_APPLIED:
94
+ self.logger.warning(
95
+ f"This Snakemake ({md.version('snakemake')}) changed code the plugin works around, so "
96
+ f"this workaround is off: {what}. Please report it to the plugin."
97
+ )
98
+ mounts = volume_mounts(s) + host_mounts(s)
99
+ cache = s.conda_cache.strip()
100
+ self.conda_cache = cache
101
+ if cache != "none":
102
+ # the instance's folder, shared by its jobs (EC2; Fargate jobs keep their own)
103
+ mounts.append((CONDA_CACHE_HOST_DIR, CONDA_CACHE_JOB_DIR, False))
104
+ self.mounts = mounts
105
+ registry_credentials = registry_secrets(s)
106
+ self.entrypoints = Entrypoints(registry_credentials, self.logger)
107
+ self.settings = s
108
+ # lock, default_storage_prefix, container_image and wrapper_prefix are Snakemake's but not
109
+ # declared by the executor interface, hence getattr with a default
110
+ lock = getattr(self.workflow.execution_settings, "lock", True)
111
+ self.orphan_mode = s.orphan_jobs or ("cancel" if lock else "warn")
112
+ self.workflow_id = workflow_id(
113
+ os.getcwd(),
114
+ self.workflow.main_snakefile or "",
115
+ getattr(self.workflow.storage_settings, "default_storage_prefix", "") or "",
116
+ )
117
+ self._orphans_checked: set = set()
118
+ tags = parse_pairs(s.tags or [], "--aws-batch-flex-tags")
119
+ self.run_id = uuid.uuid4().hex[:12]
120
+ # jobs that install Snakemake at start get the packages as they were now, all the same
121
+ self.resolved_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
122
+
123
+ cfg = BotoConfig(
124
+ region_name=s.region, retries={"max_attempts": 10, "mode": "adaptive"}
125
+ )
126
+ self.batch = boto3.client("batch", config=cfg)
127
+ self.cwlogs = boto3.client("logs", config=cfg)
128
+ self.region = self.batch.meta.region_name or s.region
129
+ self.tags = tags
130
+ self.jobdefs = JobDefinitions(
131
+ self.batch,
132
+ self.logger,
133
+ prefix=s.job_definition_prefix,
134
+ platform=s.platform or "ec2",
135
+ job_role_arn=s.job_role_arn,
136
+ execution_role_arn=s.execution_role_arn,
137
+ log_group=s.log_group,
138
+ assign_public_ip=s.assign_public_ip,
139
+ ephemeral_storage_gib=s.ephemeral_storage_gib,
140
+ tags=tags,
141
+ mounts=mounts,
142
+ registry_credentials=registry_credentials,
143
+ )
144
+ self.job_code = self._upload_job_code()
145
+ self.default_image = s.default_image or getattr(
146
+ self.workflow.remote_execution_settings, "container_image", None
147
+ )
148
+ if not self.default_image:
149
+ raise WorkflowError(
150
+ "No default image: set --aws-batch-flex-default-image or give every "
151
+ "rule a `container:` directive."
152
+ )
153
+ self._platforms: dict = {} # queue -> "ec2" | "fargate"
154
+ self._preludes: dict = {} # platform -> job prelude
155
+ self._warned_fargate_mounts = False
156
+ self._warned_fargate_shm = False
157
+ self._said_resolved = False
158
+ self._warned_tags: set = set()
159
+ self._warned_disk: set = set()
160
+ self._existing_defs: dict = {} # ref -> ExistingJobDefinition
161
+ self._fair_share: dict = {} # queue -> has a scheduling policy
162
+ self._warned_existing: set = set()
163
+ self.usage = Usage()
164
+ self._oom: dict = {} # job id -> (times it ran out of memory, MiB its rule asked for then)
165
+ if not s.skip_preflight:
166
+ errors, warnings, detected = check_queue(self.batch, s.queue, s.platform)
167
+ self._platforms[s.queue] = s.platform or detected or "ec2"
168
+ if s.job_role_arn:
169
+ e2, w2 = check_role(boto3.client("iam", config=cfg), s.job_role_arn, "Job role")
170
+ errors, warnings = errors + e2, warnings + w2
171
+ self._report_checks(errors, warnings)
172
+ main_platform = self._queue_platform(s.queue)
173
+ self._handle_orphans(s.queue)
174
+ if s.bootstrap == "host" and main_platform == "ec2" and not s.skip_preflight:
175
+ ami = ami_snakemake_version(self.batch, boto3.client("ec2", config=cfg), s.queue)
176
+ level, note = version_note(ami, md.version("snakemake"))
177
+ if level == "warning":
178
+ self.logger.warning(note)
179
+ elif level == "info":
180
+ self.logger.info(note)
181
+ self._check_fargate_ok(main_platform, s.queue)
182
+ self.logger.info(
183
+ f"AWS Batch executor: queue={s.queue}, platform={main_platform}, "
184
+ f"default image={self.default_image}, bootstrap={s.bootstrap}"
185
+ )
186
+ if not s.no_auto_tags:
187
+ self.logger.info(f"Jobs are tagged snakemake-run-id={self.run_id}")
188
+
189
+ def _upload_job_code(self) -> Optional[str]:
190
+ """Put the job tools (conda env cache, usage figures) next to the workflow's files: too
191
+ long to send with every job, the jobs' launcher loads them from there. None: jobs run
192
+ without them."""
193
+ import hashlib
194
+
195
+ from . import headjob
196
+
197
+ prefix = str(getattr(self.workflow.storage_settings, "default_storage_prefix", "") or "").rstrip("/")
198
+ if not prefix:
199
+ return None
200
+ data = job_tools_source().encode()
201
+ # the jobs check the content against the sha256 in the name before running it
202
+ url = f"{prefix}/.smkflex/code/jobtools-{hashlib.sha256(data).hexdigest()}.py"
203
+ try:
204
+ if headjob.get(url) != data:
205
+ headjob.put(url, data)
206
+ except (ClientError, BotoCoreError, OSError) as e:
207
+ self.logger.warning(f"jobs run without the conda env cache and usage figures: could not "
208
+ f"upload their code to {url} ({e})")
209
+ return None
210
+ return url
211
+
212
+ def _queue_platform(self, queue: str) -> str:
213
+ """Platform of a queue: detected once from its compute environment (startup check),
214
+ else --aws-batch-flex-platform, else ec2."""
215
+ if queue not in self._platforms:
216
+ detected = None
217
+ if not self.settings.skip_preflight:
218
+ errors, warnings, detected = check_queue(self.batch, queue)
219
+ self._report_checks(errors, warnings)
220
+ self._platforms[queue] = detected or self.settings.platform or "ec2"
221
+ return self._platforms[queue]
222
+
223
+ def _report_checks(self, errors, warnings):
224
+ for w in warnings:
225
+ self.logger.warning(f"preflight: {w}")
226
+ if errors:
227
+ raise WorkflowError("Startup check failed:\n - " + "\n - ".join(errors) +
228
+ "\n(skip with --aws-batch-flex-skip-preflight)")
229
+
230
+ def _handle_orphans(self, queue: str):
231
+ if queue in self._orphans_checked or self.orphan_mode == "ignore" or self.settings.no_auto_tags:
232
+ return
233
+ self._orphans_checked.add(queue)
234
+ try:
235
+ found = find_orphans(
236
+ self.batch, queue, self.jobdefs.prefix, self.workflow_id, self.run_id
237
+ )
238
+ except (ClientError, BotoCoreError) as e:
239
+ self.logger.warning(f"Could not look for leftover jobs in queue {queue}: {e}")
240
+ return
241
+ if not found:
242
+ return
243
+ names = ", ".join(f"{name} ({status})" for _, name, status in found[:10])
244
+ more = f" and {len(found) - 10} more" if len(found) > 10 else ""
245
+ if self.orphan_mode == "warn":
246
+ self.logger.warning(
247
+ f"{len(found)} job(s) from an earlier run of this workflow are still active in "
248
+ f"queue {queue}: {names}{more}. They may run twice; cancel them or use "
249
+ "--aws-batch-flex-orphan-jobs cancel."
250
+ )
251
+ return
252
+ self.logger.info(
253
+ f"Cancelling {len(found)} job(s) left over from an earlier run of this workflow "
254
+ f"in queue {queue}: {names}{more}"
255
+ )
256
+ for jid, _, _ in found:
257
+ try:
258
+ self.batch.terminate_job(jobId=jid, reason="Left over from an earlier Snakemake run")
259
+ except (ClientError, BotoCoreError) as e:
260
+ self.logger.warning(f"Could not cancel leftover job {jid}: {e}")
261
+
262
+ def _platform_for(self, job: JobExecutorInterface, queue: str) -> str:
263
+ forced = job.resources.get("batch_platform")
264
+ if forced:
265
+ forced = str(forced).lower()
266
+ if forced not in ("ec2", "fargate"):
267
+ raise WorkflowError(
268
+ f"{job.name}: batch_platform must be 'ec2' or 'fargate', got '{forced}'."
269
+ )
270
+ return forced
271
+ return self._queue_platform(queue)
272
+
273
+ def _int_resource(self, job, names, low, high, default):
274
+ """First of `names` set on the job (else `default`), as an int in [low, high]."""
275
+ for name in names:
276
+ value = job.resources.get(name)
277
+ if value is not None:
278
+ break
279
+ else:
280
+ name, value = "setting", default
281
+ if value is None:
282
+ return None
283
+ try:
284
+ n = int(value)
285
+ if not low <= n <= high:
286
+ raise ValueError
287
+ except (TypeError, ValueError):
288
+ raise WorkflowError(f"{job.name}: {name} must be a whole number from {low} to {high}, got {value!r}.") from None
289
+ return n
290
+
291
+ def _spot_retries_for(self, job: JobExecutorInterface) -> int:
292
+ attempts = job.resources.get("aws_batch_spot_attempts") # the aws-batch plugin's name: attempts
293
+ if attempts is not None and job.resources.get("batch_spot_retries") is None:
294
+ return self._int_resource(job, ["aws_batch_spot_attempts"], 1, 10, None) - 1
295
+ return self._int_resource(job, ["batch_spot_retries"], 0, 9, self.settings.spot_retries) or 0
296
+
297
+ def _priority_for(self, job: JobExecutorInterface, queue: str) -> Optional[int]:
298
+ priority = self._int_resource(
299
+ job, ["batch_priority", "aws_batch_scheduling_priority"], 0, 9999, self.settings.scheduling_priority
300
+ )
301
+ if priority is None:
302
+ return None
303
+ if queue not in self._fair_share:
304
+ try:
305
+ q, _envs = describe_queue(self.batch, queue)
306
+ self._fair_share[queue] = bool(q and q.get("schedulingPolicyArn"))
307
+ except (ClientError, BotoCoreError):
308
+ self._fair_share[queue] = True # can't tell: send it and let Batch decide
309
+ if not self._fair_share[queue]:
310
+ self.logger.warning(
311
+ f"Queue {queue} has no fair-share scheduling policy, so job priorities are "
312
+ "ignored for it (batch_priority / --aws-batch-flex-scheduling-priority)."
313
+ )
314
+ return priority if self._fair_share[queue] else None
315
+
316
+ def _shm_for(self, job: JobExecutorInterface, platform: str) -> Optional[int]:
317
+ # batch_shm_mb, or shared_memory_size_mb as in the aws-batch plugin
318
+ value = job.resources.get("batch_shm_mb") or job.resources.get("shared_memory_size_mb")
319
+ if not value:
320
+ return None
321
+ try:
322
+ shm = int(value)
323
+ if shm <= 0:
324
+ raise ValueError
325
+ except (TypeError, ValueError):
326
+ raise WorkflowError(f"{job.name}: batch_shm_mb must be a positive number of MiB, got {value!r}.") from None
327
+ if platform == "fargate":
328
+ if not self._warned_fargate_shm:
329
+ self._warned_fargate_shm = True
330
+ self.logger.warning("Fargate doesn't support batch_shm_mb; /dev/shm stays at its default there.")
331
+ return None
332
+ return shm
333
+
334
+ def _more_memory_after_oom(self, job: JobExecutorInterface, req, platform: str):
335
+ """A retry of a job that ran out of memory asks for oom_memory_factor times more,
336
+ for every time it ran out (Fargate: at most 120 GiB). Unless its rule already asks for
337
+ more on this attempt (mem_mb=lambda wildcards, attempt: ...): that is used as it is."""
338
+ times, asked = self._oom.get(job.jobid, (0, None))
339
+ factor = self.settings.oom_memory_factor
340
+ if not times or factor <= 1:
341
+ return
342
+ if asked is not None and req.memory_mib > asked:
343
+ self.logger.info(
344
+ f"{job.name} (attempt {job.attempt}) ran out of memory with {asked} MiB; its rule asks "
345
+ f"for {req.memory_mib} MiB this time, so that is used."
346
+ )
347
+ return
348
+ before = req.memory_mib
349
+ mem = int(before * factor**times)
350
+ if platform == "fargate":
351
+ mem = min(mem, FARGATE_MAX_MEM_MIB)
352
+ req.vcpu, mem = fargate_size(float(req.vcpu), mem)
353
+ req.memory_mib = mem
354
+ self.logger.info(
355
+ f"{job.name} (attempt {job.attempt}) ran out of memory with {before} MiB; "
356
+ f"retrying with {mem} MiB."
357
+ )
358
+
359
+ def _disk_for(self, job: JobExecutorInterface, platform: str) -> Optional[int]:
360
+ """Fargate scratch disk in GiB for this job, from disk_mb; None keeps the default
361
+ (20 GiB, or ephemeral-storage-gib). EC2 jobs share the instance's disk."""
362
+ if platform != "fargate":
363
+ return None
364
+ value = job.resources.get("disk_mb")
365
+ if not value:
366
+ return None
367
+ # Snakemake's default for rules without inputs is a flat 50000; that isn't a request
368
+ if str(value) == "50000" and not list(job.input):
369
+ return None
370
+ try:
371
+ gib = math.ceil(int(job.resources.get("disk_mib") or value) / 1024)
372
+ except (TypeError, ValueError):
373
+ raise WorkflowError(f"{job.name}: disk_mb must be a number of MB, got {value!r}.") from None
374
+ if gib <= 20: # Fargate's default; 21 is the smallest size that can be set
375
+ return None
376
+ if gib > FARGATE_MAX_DISK_GIB:
377
+ if job.name not in self._warned_disk:
378
+ self._warned_disk.add(job.name)
379
+ self.logger.warning(
380
+ f"{job.name} asks for {gib} GiB of disk; Fargate allows at most "
381
+ f"{FARGATE_MAX_DISK_GIB}, so it gets {FARGATE_MAX_DISK_GIB}."
382
+ )
383
+ # The size is part of the job definition, so round up to 25 GiB steps; otherwise
384
+ # sizes derived from input files would register a new definition for nearly every job.
385
+ return min(FARGATE_MAX_DISK_GIB, math.ceil(gib / 25) * 25)
386
+
387
+ def _check_fargate_ok(self, platform: str, queue: str):
388
+ if platform != "fargate":
389
+ return
390
+ if not self.settings.execution_role_arn:
391
+ raise WorkflowError(
392
+ f"Queue '{queue}' runs on Fargate, which requires --aws-batch-flex-execution-role-arn."
393
+ )
394
+ if self.mounts and not self._warned_fargate_mounts:
395
+ self._warned_fargate_mounts = True
396
+ self.logger.warning(
397
+ "Fargate jobs get no host mounts (bootstrap=host env, --aws-batch-flex-volumes, "
398
+ "--aws-batch-flex-conda-path): they install Snakemake at job start instead, and "
399
+ "conda: rules need conda inside the image there."
400
+ )
401
+
402
+ def _bootstrap_for(self, platform: str, host_mount: bool = True) -> str:
403
+ # Without the host env (Fargate, or a job definition that doesn't mount it):
404
+ # install at job start instead.
405
+ if self.settings.bootstrap == "host" and (platform == "fargate" or not host_mount):
406
+ return "auto"
407
+ return self.settings.bootstrap
408
+
409
+ def _prelude_for(self, platform: str, host_mount: bool = True, conda_mount: bool = True) -> str:
410
+ key = (platform, host_mount, conda_mount)
411
+ if key not in self._preludes:
412
+ default = getattr(self.workflow.storage_settings, "default_storage_provider", None)
413
+ mode = self._bootstrap_for(platform, host_mount)
414
+ base = snakemake_prelude(self.settings, mode, default, self.resolved_at)
415
+ if mode in ("auto", "always") and not self._said_resolved:
416
+ self._said_resolved = True
417
+ self.logger.info(f"Jobs that install Snakemake at start get the packages as of {self.resolved_at}.")
418
+ if self.settings.conda_path and platform == "ec2" and conda_mount:
419
+ base += "\n" + conda_snippet(self.settings.conda_path)
420
+ base += "\n" + launcher_snippet(bool(self.job_code)) # SMK_FLEX_SCRIPT_PYTHON decides per job
421
+ self._preludes[key] = base
422
+ return self._preludes[key]
423
+
424
+ def _existing_jobdef(self, job: JobExecutorInterface, platform: str, image: str, shm_mb, disk_gib=None):
425
+ """The existing job definition this job should use, or None; warns once per rule
426
+ about rule settings that the job definition overrides."""
427
+ ref = job.resources.get("batch_job_definition") or self.settings.job_definition
428
+ if not ref:
429
+ return None
430
+ ref = str(ref)
431
+ if ref not in self._existing_defs:
432
+ self._existing_defs[ref] = describe_existing(self.batch, ref)
433
+ ex = self._existing_defs[ref]
434
+ if ex.platform != platform:
435
+ raise WorkflowError(
436
+ f"{job.name}: job definition '{ref}' is for {ex.platform.upper()} but queue "
437
+ f"{self._queue_for(job)} runs on {platform.upper()}."
438
+ )
439
+ key = (job.name, ref)
440
+ if key not in self._warned_existing:
441
+ self._warned_existing.add(key)
442
+ notes = []
443
+ subjobs = list(job.jobs) if job.is_group() else [job]
444
+ if image != ex.image and any(getattr(j, "container_img_url", None) for j in subjobs):
445
+ notes.append(f"its image {ex.image} is used, not {image}")
446
+ if self.settings.bootstrap == "host" and self.settings.host_env_path not in ex.container_paths:
447
+ notes.append(f"it doesn't mount {self.settings.host_env_path}, so Snakemake is installed at job start")
448
+ if shm_mb:
449
+ notes.append("batch_shm_mb is ignored (set sharedMemorySize in the job definition)")
450
+ if disk_gib:
451
+ notes.append("disk_mb is ignored (set ephemeralStorage in the job definition)")
452
+ if self.settings.job_role_arn and ex.job_role and ex.job_role != self.settings.job_role_arn:
453
+ notes.append(f"its job role {ex.job_role} is used")
454
+ if notes:
455
+ self.logger.warning(f"{job.name} uses job definition '{ref}': " + "; ".join(notes) + ".")
456
+ return ex
457
+
458
+ def _image_for(self, job: JobExecutorInterface) -> str:
459
+ subjobs = list(job.jobs) if job.is_group() else [job]
460
+ images = set()
461
+ for j in subjobs:
462
+ # `container_img_url` is a Snakemake Job attribute, not (yet) part of the
463
+ # stable executor-plugin interface, hence getattr.
464
+ url = getattr(j, "container_img_url", None)
465
+ images.add(normalize_image(url) if url else self.default_image)
466
+ if len(images) > 1:
467
+ raise WorkflowError(
468
+ f"Group job {job.name} mixes container images {sorted(images)}; "
469
+ "all rules in a group must use the same image."
470
+ )
471
+ image = images.pop()
472
+ if mutable_tag(image) and image not in self._warned_tags:
473
+ self._warned_tags.add(image)
474
+ self.logger.warning(
475
+ f"Image {image} has no version tag (or :latest), so what it contains can change "
476
+ "between jobs and runs. Use a fixed tag (e.g. samtools:1.21--h50ea8bc_0) or a digest (@sha256:...)."
477
+ )
478
+ return image
479
+
480
+ def _queue_for(self, job: JobExecutorInterface) -> str:
481
+ return str(job.resources.get("batch_queue") or self.settings.queue)
482
+
483
+ def run_job(self, job: JobExecutorInterface):
484
+ image = self._image_for(job)
485
+ queue = self._queue_for(job)
486
+ platform = self._platform_for(job, queue)
487
+ self._handle_orphans(queue)
488
+ self._check_fargate_ok(platform, queue)
489
+ req = job_requirements(job.resources, job.threads, platform, self.settings.mem_overhead_mib)
490
+ asked = req.memory_mib # by the rule, before the retry's extra
491
+ self._more_memory_after_oom(job, req, platform)
492
+ script_python = str(job.resources.get("batch_script_python") or self.settings.script_python).lower()
493
+ if script_python not in ("image", "host"):
494
+ raise WorkflowError(
495
+ f"{job.name}: batch_script_python must be 'image' or 'host', got '{script_python}'."
496
+ )
497
+ shm_mb = self._shm_for(job, platform)
498
+ disk_gib = self._disk_for(job, platform)
499
+ existing = self._existing_jobdef(job, platform, image, shm_mb, disk_gib)
500
+ if existing:
501
+ jobdef, image = existing.arn, existing.image
502
+ host_mount = self.settings.host_env_path in existing.container_paths
503
+ conda_mount = bool(self.settings.conda_path) and self.settings.conda_path in existing.container_paths
504
+ else:
505
+ jobdef = self.jobdefs.ensure(image, platform, shm_mb, disk_gib)
506
+ host_mount = conda_mount = True
507
+
508
+ script = f"{self._prelude_for(platform, host_mount, conda_mount)}\n{keep_unneeded_temp(self.format_job_exec(job))}"
509
+ resource_reqs = [
510
+ {"type": "VCPU", "value": req.vcpu},
511
+ {"type": "MEMORY", "value": str(req.memory_mib)},
512
+ ]
513
+ if req.gpu:
514
+ resource_reqs.append({"type": "GPU", "value": str(req.gpu)})
515
+
516
+ kwargs = dict(
517
+ jobName=batch_job_name(self.get_jobname(job)),
518
+ jobQueue=queue,
519
+ jobDefinition=jobdef,
520
+ containerOverrides={
521
+ # what the image's ENTRYPOINT, which Batch can't change, runs as /bin/sh -c script
522
+ "command": self.entrypoints.command(image, script, job.resources.get("batch_entrypoint"), job.name),
523
+ "resourceRequirements": resource_reqs,
524
+ "environment": [
525
+ {"name": k, "value": str(v)} for k, v in self.envvars().items()
526
+ ]
527
+ + [{"name": "SMK_FLEX_SCRIPT_PYTHON", "value": script_python}]
528
+ # the region, which jobs can't look up on the instance (IMDS) when it's closed to them
529
+ + [{"name": n, "value": self.region} for n in ("AWS_REGION", "AWS_DEFAULT_REGION") if self.region]
530
+ + ([{"name": "SMK_FLEX_JOB_CODE", "value": self.job_code}] if self.job_code else [])
531
+ + ([{"name": "SMK_FLEX_CONDA_CACHE", "value": self.conda_cache}]
532
+ if self.job_code and self.conda_cache != "none" else []),
533
+ },
534
+ )
535
+ spot_retries = self._spot_retries_for(job)
536
+ if spot_retries:
537
+ kwargs["retryStrategy"] = {
538
+ "attempts": spot_retries + 1,
539
+ "evaluateOnExit": [
540
+ {"onStatusReason": "Host EC2*", "action": "RETRY"},
541
+ {"onReason": "*", "action": "EXIT"},
542
+ ],
543
+ }
544
+ priority = self._priority_for(job, queue)
545
+ if priority is not None:
546
+ kwargs["schedulingPriorityOverride"] = priority
547
+ if req.timeout_seconds:
548
+ kwargs["timeout"] = {"attemptDurationSeconds": req.timeout_seconds}
549
+ tags = job_tags(
550
+ run_id=self.run_id,
551
+ workflow_id=self.workflow_id,
552
+ # Snakemake returns plain names; the interface type says rule objects. Accept both.
553
+ rule_names=[getattr(r, "name", r) for r in job.rules],
554
+ jobid=job.jobid,
555
+ global_tags=self.tags,
556
+ rule_tags=parse_resource_string(job.resources.get("batch_tags")),
557
+ auto=not self.settings.no_auto_tags,
558
+ )
559
+ if tags:
560
+ kwargs["tags"] = tags
561
+ kwargs["propagateTags"] = True
562
+
563
+ size = len(json.dumps(kwargs["containerOverrides"], separators=(",", ":")))
564
+ if size > ECS_OVERRIDES_MAX:
565
+ raise WorkflowError(
566
+ f"{job.name}: its command and environment come to {size} characters, more than the "
567
+ f"{ECS_OVERRIDES_MAX} ECS allows per job. What makes them long: many storage plugins or "
568
+ "extra packages installed at job start (bootstrap host avoids that), a long storage "
569
+ "prefix, or very many input/output files in one job."
570
+ )
571
+ try:
572
+ resp = self.batch.submit_job(**kwargs)
573
+ except (ClientError, BotoCoreError) as e:
574
+ hint = f"\n{SUBMIT_HINT}" if _not_allowed(e) else ""
575
+ raise WorkflowError(f"AWS Batch submit_job failed: {e}{hint}") from e
576
+
577
+ self.logger.info(
578
+ f"Submitted {job.name} as AWS Batch job {resp['jobId']} "
579
+ f"({image}, {req.vcpu} vCPU, {req.memory_mib} MiB, queue {queue}, {platform})"
580
+ )
581
+ self.report_job_submission(
582
+ SubmittedJobInfo(
583
+ job,
584
+ external_jobid=resp["jobId"],
585
+ aux={"smkflex": Submitted(image, queue, platform, float(req.vcpu), req.memory_mib, asked)},
586
+ )
587
+ )
588
+
589
+ async def check_active_jobs(
590
+ self, active_jobs: List[SubmittedJobInfo]
591
+ ) -> AsyncGenerator[SubmittedJobInfo, None]:
592
+ by_id = {j.external_jobid: j for j in active_jobs}
593
+ ids = list(by_id)
594
+ described = {}
595
+ for i in range(0, len(ids), 100): # describe_jobs takes at most 100 ids
596
+ async with self.status_rate_limiter:
597
+ try:
598
+ resp = await asyncio.to_thread(
599
+ self.batch.describe_jobs, jobs=ids[i : i + 100]
600
+ )
601
+ except (ClientError, BotoCoreError) as e:
602
+ self.logger.warning(f"AWS Batch status query failed, will retry: {e}")
603
+ for jid in ids[i : i + 100]:
604
+ yield by_id[jid]
605
+ continue
606
+ for d in resp["jobs"]:
607
+ described[d["jobId"]] = d
608
+
609
+ for jid, info in by_id.items():
610
+ d = described.get(jid)
611
+ if d is None:
612
+ self.report_job_error(
613
+ info, msg=f"AWS Batch no longer knows job {jid} (expired?)."
614
+ )
615
+ continue
616
+ status = d["status"]
617
+ if status in ("SUCCEEDED", "FAILED"):
618
+ await asyncio.to_thread(self._record_usage, info, d)
619
+ if status == "SUCCEEDED":
620
+ self.report_job_success(info)
621
+ elif status == "FAILED":
622
+ if _out_of_memory(d):
623
+ times, _asked = self._oom.get(info.job.jobid, (0, None))
624
+ self._oom[info.job.jobid] = (times + 1, _sub(info).asked)
625
+ msg = await asyncio.to_thread(self._failure_message, d, info)
626
+ self.report_job_error(info, msg=msg)
627
+ elif status in ACTIVE:
628
+ self._warn_if_stuck(d, info)
629
+ yield info
630
+ else:
631
+ yield info
632
+
633
+ def _record_usage(self, info: SubmittedJobInfo, d: dict):
634
+ sub = _sub(info)
635
+ self.usage.add(info.job.name, d, sub.vcpu, sub.mem, sub.platform)
636
+ c = d.get("container") or {}
637
+ if c.get("logStreamName") and d.get("status") == "SUCCEEDED":
638
+ group = log_group_of(c, self.settings.log_group)
639
+ self.usage.add_log(info.job.name, group, c["logStreamName"])
640
+
641
+ def shutdown(self):
642
+ super().shutdown()
643
+ if self.settings.no_usage_summary:
644
+ return
645
+ self.usage.fetch_peaks(self.cwlogs)
646
+ table = self.usage.table(self.settings.mem_overhead_mib)
647
+ if table:
648
+ self.logger.info(table)
649
+
650
+ def additional_general_args(self):
651
+ # A version-tagged wrapper ("v9.19.1/bio/...") is fetched with `git clone` when no prefix is
652
+ # set, and stock images have no git. The plain HTTP prefix fetches the same files without git.
653
+ # (Snakemake joins the result as a string, despite the interface docstring saying list.)
654
+ if getattr(self.workflow.workflow_settings, "wrapper_prefix", None) is None:
655
+ return f"--wrapper-prefix {WRAPPER_HTTP_PREFIX}"
656
+ return ""
657
+
658
+ def cancel_jobs(self, active_jobs: List[SubmittedJobInfo]):
659
+ # In parallel: a head job that is stopped has little time (ECS_CONTAINER_STOP_TIMEOUT)
660
+ # to cancel every job and save its state.
661
+ def cancel(info):
662
+ try:
663
+ self.batch.terminate_job(
664
+ jobId=info.external_jobid, reason="Cancelled by Snakemake"
665
+ )
666
+ except (ClientError, BotoCoreError) as e:
667
+ self.logger.warning(f"Could not terminate {info.external_jobid}: {e}")
668
+
669
+ with ThreadPoolExecutor(CANCEL_THREADS) as pool:
670
+ list(pool.map(cancel, active_jobs))
671
+
672
+ def _warn_if_stuck(self, d: dict, info: SubmittedJobInfo):
673
+ sub = _sub(info)
674
+ if d["status"] != "RUNNABLE" or sub.warned:
675
+ return
676
+ waited = (time.time() * 1000 - d.get("createdAt", time.time() * 1000)) / 60000
677
+ if waited >= self.settings.runnable_warn_minutes:
678
+ sub.warned = True
679
+ self.logger.warning(
680
+ f"AWS Batch job {d['jobId']} has waited {waited:.0f} min in RUNNABLE "
681
+ f"on queue {sub.queue}. Usually the compute environment cannot "
682
+ "provide the requested vCPU/memory (max vCPUs reached, instance types "
683
+ "too small, or subnets without capacity). Check the Batch console."
684
+ )
685
+
686
+ def _failure_message(self, d: dict, info: SubmittedJobInfo) -> str:
687
+ c = d.get("container") or {}
688
+ lines = [
689
+ f"AWS Batch job {d['jobId']} FAILED (image {_sub(info).image}).",
690
+ ]
691
+ if d.get("statusReason"):
692
+ lines.append(f" status reason: {d['statusReason']}")
693
+ if c.get("reason"):
694
+ lines.append(f" container reason: {c['reason']}")
695
+ if c.get("exitCode") is not None:
696
+ lines.append(f" exit code: {c['exitCode']}")
697
+ if _out_of_memory(d):
698
+ mem = _sub(info).mem
699
+ factor = self.settings.oom_memory_factor
700
+ retries = getattr(info.job, "restart_times", 0) or 0
701
+ if retries >= info.job.attempt and factor > 1:
702
+ lines.append(f" hint: the job ran out of memory ({mem} MiB); Snakemake retries it with more.")
703
+ else:
704
+ lines.append(
705
+ f" hint: the job ran out of memory ({mem} MiB). Raise the rule's mem_mb, or run with "
706
+ "--retries 2: the plugin then retries jobs that ran out of memory with twice as much."
707
+ )
708
+ stream = c.get("logStreamName")
709
+ if stream:
710
+ group = log_group_of(c, self.settings.log_group)
711
+ lines.append(f" CloudWatch: group {group}, stream {stream}")
712
+ try:
713
+ events = self.cwlogs.get_log_events(
714
+ logGroupName=group,
715
+ logStreamName=stream,
716
+ limit=40,
717
+ startFromHead=False,
718
+ )["events"]
719
+ if events:
720
+ lines.append(" last log lines:")
721
+ lines += [f" {e['message'].rstrip()}" for e in events]
722
+ lines += known_problem_hints([e["message"] for e in events])
723
+ except (ClientError, BotoCoreError):
724
+ lines.append(" (could not fetch log tail)")
725
+ return "\n".join(lines) + "\n"