Coverage for python / lsst / ctrl / bps / htcondor / prepare_utils.py: 5%
361 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-12 01:19 -0800
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-12 01:19 -0800
1# This file is part of ctrl_bps_htcondor.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <https://www.gnu.org/licenses/>.
28"""Utility functions for preparing the HTCondor workflow."""
30import logging
31import os
32import re
33from collections import defaultdict
34from copy import deepcopy
35from pathlib import Path
36from typing import Any, cast
38from lsst.ctrl.bps import (
39 BpsConfig,
40 GenericWorkflow,
41 GenericWorkflowGroup,
42 GenericWorkflowJob,
43 GenericWorkflowNodeType,
44 GenericWorkflowNoopJob,
45)
46from lsst.ctrl.bps.bps_utils import create_count_summary
48from .lssthtc import (
49 HTCDag,
50 HTCJob,
51 condor_status,
52 htc_escape,
53)
55_LOG = logging.getLogger(__name__)
57DEFAULT_HTC_EXEC_PATT = ".*worker.*"
58"""Default pattern for searching execute machines in an HTCondor pool.
59"""
62def _create_job(subdir_template, cached_values, generic_workflow, gwjob, out_prefix):
63 """Convert GenericWorkflow job nodes to DAG jobs.
65 Parameters
66 ----------
67 subdir_template : `str`
68 Template for making subdirs.
69 cached_values : `dict`
70 Site and label specific values.
71 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
72 Generic workflow that is being converted.
73 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
74 The generic job to convert to a HTCondor job.
75 out_prefix : `str`
76 Directory prefix for HTCondor files.
78 Returns
79 -------
80 htc_job : `lsst.ctrl.bps.wms.htcondor.HTCJob`
81 The HTCondor job equivalent to the given generic job.
82 """
83 htc_job = HTCJob(gwjob.name, label=gwjob.label)
85 curvals = defaultdict(str)
86 curvals["label"] = gwjob.label
87 if gwjob.tags:
88 curvals.update(gwjob.tags)
90 subdir = Path("jobs") / subdir_template.format_map(curvals)
91 htc_job.subdir = subdir
92 htc_job.subfile = f"{gwjob.name}.sub"
93 htc_job.add_dag_cmds({"dir": subdir})
95 htc_job_cmds = {
96 "universe": "vanilla",
97 "should_transfer_files": "YES",
98 "when_to_transfer_output": "ON_EXIT_OR_EVICT",
99 "transfer_output_files": '""', # Set to empty string to disable
100 "transfer_executable": "False",
101 "getenv": "True",
102 # Exceeding memory sometimes triggers SIGBUS or SIGSEGV error. Tell
103 # htcondor to put on hold any jobs which exited by a signal. If
104 # executed in a bash script, like finalJob, the signals will become
105 # exit codes above 128 (exit code = 128 + signal number).
106 "on_exit_hold": "ExitBySignal == true || ExitCode > 128",
107 "on_exit_hold_reason": "ExitBySignal == true ? "
108 'strcat("Job raised a signal ", string(ExitSignal), '
109 '". Handling job as if it has gone over memory limit.") : '
110 'strcat("Job exit code (", string(ExitCode), ") > 128. '
111 'Handling job as if it has gone over memory limit.")',
112 "on_exit_hold_subcode": "34",
113 }
115 htc_job_cmds.update(_translate_job_cmds(cached_values, generic_workflow, gwjob))
117 # Combine stdout and stderr to reduce the number of files.
118 for key in ("output", "error"):
119 if cached_values["overwriteJobFiles"]:
120 htc_job_cmds[key] = f"{gwjob.name}.$(Cluster).out"
121 else:
122 htc_job_cmds[key] = f"{gwjob.name}.$(Cluster).$$([NumJobStarts ?: 0]).out"
123 _LOG.debug("HTCondor %s = %s", key, htc_job_cmds[key])
125 key = "log"
126 htc_job_cmds[key] = f"{gwjob.name}.$(Cluster).{key}"
127 _LOG.debug("HTCondor %s = %s", key, htc_job_cmds[key])
129 htc_job_cmds.update(
130 _handle_job_inputs(generic_workflow, gwjob.name, cached_values["bpsUseShared"], out_prefix)
131 )
133 htc_job_cmds.update(
134 _handle_job_outputs(generic_workflow, gwjob.name, cached_values["bpsUseShared"], out_prefix)
135 )
137 # Add the job cmds dict to the job object.
138 htc_job.add_job_cmds(htc_job_cmds)
140 htc_job.add_dag_cmds(_translate_dag_cmds(gwjob))
142 # Add job attributes to job.
143 _LOG.debug("gwjob.attrs = %s", gwjob.attrs)
144 htc_job.add_job_attrs(gwjob.attrs)
145 htc_job.add_job_attrs(cached_values["attrs"])
146 htc_job.add_job_attrs({"bps_job_quanta": create_count_summary(gwjob.quanta_counts)})
147 htc_job.add_job_attrs({"bps_job_name": gwjob.name, "bps_job_label": gwjob.label})
149 return htc_job
152def _translate_job_cmds(cached_vals, generic_workflow, gwjob):
153 """Translate the job data that are one to one mapping
155 Parameters
156 ----------
157 cached_vals : `dict` [`str`, `~typing.Any`]
158 Config values common to jobs with same site or label.
159 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
160 Generic workflow that contains job to being converted.
161 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
162 Generic workflow job to be converted.
164 Returns
165 -------
166 htc_job_commands : `dict` [`str`, `~typing.Any`]
167 Contains commands which can appear in the HTCondor submit description
168 file.
169 """
170 # Values in the job script that just are name mappings.
171 job_translation = {
172 "mail_to": "notify_user",
173 "when_to_mail": "notification",
174 "request_cpus": "request_cpus",
175 "priority": "priority",
176 "category": "category",
177 "accounting_group": "accounting_group",
178 "accounting_user": "accounting_group_user",
179 }
181 jobcmds = {}
182 for gwkey, htckey in job_translation.items():
183 jobcmds[htckey] = getattr(gwjob, gwkey, None)
185 # If accounting info was not set explicitly, use site settings if any.
186 if not gwjob.accounting_group:
187 jobcmds["accounting_group"] = cached_vals.get("accountingGroup")
188 if not gwjob.accounting_user:
189 jobcmds["accounting_group_user"] = cached_vals.get("accountingUser")
191 # job commands that need modification
192 if gwjob.retry_unless_exit:
193 if isinstance(gwjob.retry_unless_exit, int):
194 jobcmds["retry_until"] = f"{gwjob.retry_unless_exit}"
195 elif isinstance(gwjob.retry_unless_exit, list):
196 jobcmds["retry_until"] = (
197 f"member(ExitCode, {{{','.join([str(x) for x in gwjob.retry_unless_exit])}}})"
198 )
199 else:
200 raise ValueError("retryUnlessExit must be an integer or a list of integers.")
202 if gwjob.request_disk:
203 jobcmds["request_disk"] = f"{gwjob.request_disk}MB"
205 if gwjob.request_memory:
206 jobcmds["request_memory"] = f"{gwjob.request_memory}"
208 memory_max = 0
209 if gwjob.memory_multiplier:
210 # Do not use try-except! At the moment, BpsConfig returns an empty
211 # string if it does not contain the key.
212 memory_limit = cached_vals["memoryLimit"]
213 if not memory_limit:
214 raise RuntimeError(
215 "Memory autoscaling enabled, but automatic detection of the memory limit "
216 "failed; setting it explicitly with 'memoryLimit' or changing worker node "
217 "search pattern 'executeMachinesPattern' might help."
218 )
220 # Set maximal amount of memory job can ask for.
221 #
222 # The check below assumes that 'memory_limit' was set to a value which
223 # realistically reflects actual physical limitations of a given compute
224 # resource.
225 memory_max = memory_limit
226 if gwjob.request_memory_max and gwjob.request_memory_max < memory_limit:
227 memory_max = gwjob.request_memory_max
229 # Make job ask for more memory each time it failed due to insufficient
230 # memory requirements.
231 jobcmds["request_memory"] = _create_request_memory_expr(
232 gwjob.request_memory, gwjob.memory_multiplier, memory_max
233 )
235 user_release_expr = cached_vals.get("releaseExpr", "")
236 if gwjob.number_of_retries is not None and gwjob.number_of_retries >= 0:
237 jobcmds["max_retries"] = gwjob.number_of_retries
239 # No point in adding periodic_release if 0 retries
240 if gwjob.number_of_retries > 0:
241 periodic_release = _create_periodic_release_expr(
242 gwjob.request_memory, gwjob.memory_multiplier, memory_max, user_release_expr
243 )
244 if periodic_release:
245 jobcmds["periodic_release"] = periodic_release
247 jobcmds["periodic_remove"] = _create_periodic_remove_expr(
248 gwjob.request_memory, gwjob.memory_multiplier, memory_max
249 )
251 # Assume concurrency_limit implemented using HTCondor concurrency limits.
252 # May need to move to special site-specific implementation if sites use
253 # other mechanisms.
254 if gwjob.concurrency_limit:
255 jobcmds["concurrency_limit"] = gwjob.concurrency_limit
257 # Handle command line
258 if gwjob.executable.transfer_executable:
259 jobcmds["transfer_executable"] = "True"
260 jobcmds["executable"] = gwjob.executable.src_uri
261 else:
262 jobcmds["executable"] = _fix_env_var_syntax(gwjob.executable.src_uri)
264 if gwjob.arguments:
265 arguments = gwjob.arguments
266 arguments = _replace_cmd_vars(arguments, gwjob)
267 arguments = _replace_file_vars(cached_vals["bpsUseShared"], arguments, generic_workflow, gwjob)
268 arguments = _fix_env_var_syntax(arguments)
269 jobcmds["arguments"] = arguments
271 if gwjob.environment:
272 env_str = ""
273 for name, value in gwjob.environment.items():
274 if isinstance(value, str):
275 value2 = _replace_cmd_vars(value, gwjob)
276 value2 = _fix_env_var_syntax(value2)
277 value2 = htc_escape(value2)
278 env_str += f"{name}='{value2}' " # Add single quotes to allow internal spaces
279 else:
280 env_str += f"{name}={value} "
282 # Process above added one trailing space
283 jobcmds["environment"] = env_str.rstrip()
285 # Add extra "pass-thru" job commands
286 if gwjob.profile:
287 for key, val in gwjob.profile.items():
288 jobcmds[key] = htc_escape(val)
289 for key, val in cached_vals["profile"].items():
290 jobcmds[key] = htc_escape(val)
292 return jobcmds
295def _translate_dag_cmds(gwjob):
296 """Translate job values into DAGMan commands.
298 Parameters
299 ----------
300 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
301 Job containing values to be translated.
303 Returns
304 -------
305 dagcmds : `dict` [`str`, `~typing.Any`]
306 DAGMan commands for the job.
307 """
308 # Values in the dag script that just are name mappings.
309 dag_translation = {"abort_on_value": "abort_dag_on", "abort_return_value": "abort_exit"}
311 dagcmds = {}
312 for gwkey, htckey in dag_translation.items():
313 dagcmds[htckey] = getattr(gwjob, gwkey, None)
315 # Still to be coded: vars "pre_cmdline", "post_cmdline"
316 return dagcmds
319def _fix_env_var_syntax(oldstr):
320 """Change ENV place holders to HTCondor Env var syntax.
322 Parameters
323 ----------
324 oldstr : `str`
325 String in which environment variable syntax is to be fixed.
327 Returns
328 -------
329 newstr : `str`
330 Given string with environment variable syntax fixed.
331 """
332 newstr = oldstr
333 for key in re.findall(r"<ENV:([^>]+)>", oldstr):
334 newstr = newstr.replace(rf"<ENV:{key}>", f"$ENV({key})")
335 return newstr
338def _replace_file_vars(use_shared, arguments, workflow, gwjob):
339 """Replace file placeholders in command line arguments with correct
340 physical file names.
342 Parameters
343 ----------
344 use_shared : `bool`
345 Whether HTCondor can assume shared filesystem.
346 arguments : `str`
347 Arguments string in which to replace file placeholders.
348 workflow : `lsst.ctrl.bps.GenericWorkflow`
349 Generic workflow that contains file information.
350 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
351 The job corresponding to the arguments.
353 Returns
354 -------
355 arguments : `str`
356 Given arguments string with file placeholders replaced.
357 """
358 # Replace input file placeholders with paths.
359 for gwfile in workflow.get_job_inputs(gwjob.name, data=True, transfer_only=False):
360 if not gwfile.wms_transfer:
361 # Must assume full URI if in command line and told WMS is not
362 # responsible for transferring file.
363 uri = gwfile.src_uri
364 elif use_shared:
365 if gwfile.job_shared:
366 # Have shared filesystems and jobs can share file.
367 uri = gwfile.src_uri
368 else:
369 uri = os.path.basename(gwfile.src_uri)
370 else: # Using push transfer
371 uri = os.path.basename(gwfile.src_uri)
372 arguments = arguments.replace(f"<FILE:{gwfile.name}>", uri)
374 # Replace output file placeholders with paths.
375 for gwfile in workflow.get_job_outputs(gwjob.name, data=True, transfer_only=False):
376 if not gwfile.wms_transfer:
377 # Must assume full URI if in command line and told WMS is not
378 # responsible for transferring file.
379 uri = gwfile.src_uri
380 elif use_shared:
381 if gwfile.job_shared:
382 # Have shared filesystems and jobs can share file.
383 uri = gwfile.src_uri
384 else:
385 uri = os.path.basename(gwfile.src_uri)
386 else: # Using push transfer
387 uri = os.path.basename(gwfile.src_uri)
388 arguments = arguments.replace(f"<FILE:{gwfile.name}>", uri)
389 return arguments
392def _replace_cmd_vars(arguments, gwjob):
393 """Replace format-style placeholders in arguments.
395 Parameters
396 ----------
397 arguments : `str`
398 Arguments string in which to replace placeholders.
399 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
400 Job containing values to be used to replace placeholders
401 (in particular gwjob.cmdvals).
403 Returns
404 -------
405 arguments : `str`
406 Given arguments string with placeholders replaced.
407 """
408 replacements = gwjob.cmdvals if gwjob.cmdvals is not None else {}
409 try:
410 arguments = arguments.format(**replacements)
411 except (KeyError, TypeError) as exc: # TypeError in case None instead of {}
412 _LOG.error("Could not replace command variables: replacement for %s not provided", str(exc))
413 _LOG.debug("arguments: %s\ncmdvals: %s", arguments, replacements)
414 raise
415 return arguments
418def _handle_job_inputs(
419 generic_workflow: GenericWorkflow, job_name: str, use_shared: bool, out_prefix: str
420) -> dict[str, str]:
421 """Add job input files from generic workflow to job.
423 Parameters
424 ----------
425 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
426 The generic workflow (e.g., has executable name and arguments).
427 job_name : `str`
428 Unique name for the job.
429 use_shared : `bool`
430 Whether job has access to files via shared filesystem.
431 out_prefix : `str`
432 The root directory into which all WMS-specific files are written.
434 Returns
435 -------
436 htc_commands : `dict` [`str`, `str`]
437 HTCondor commands for the job submission script.
438 """
439 inputs = []
440 for gwf_file in generic_workflow.get_job_inputs(job_name, data=True, transfer_only=True):
441 _LOG.debug("src_uri=%s", gwf_file.src_uri)
443 uri = Path(gwf_file.src_uri)
445 # Note if use_shared and job_shared, don't need to transfer file.
447 if not use_shared: # Copy file using push to job
448 inputs.append(str(uri))
449 elif not gwf_file.job_shared: # Jobs require own copy
450 # if using shared filesystem, but still need copy in job. Use
451 # HTCondor's curl plugin for a local copy.
452 if uri.is_dir():
453 raise RuntimeError(
454 f"HTCondor plugin cannot transfer directories locally within job {gwf_file.src_uri}"
455 )
456 inputs.append(f"file://{uri}")
458 htc_commands = {}
459 if inputs:
460 htc_commands["transfer_input_files"] = ",".join(inputs)
461 _LOG.debug("transfer_input_files=%s", htc_commands["transfer_input_files"])
462 return htc_commands
465def _handle_job_outputs(
466 generic_workflow: GenericWorkflow, job_name: str, use_shared: bool, out_prefix: str
467) -> dict[str, str]:
468 """Add job output files from generic workflow to the job if any.
470 Parameters
471 ----------
472 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
473 The generic workflow (e.g., has executable name and arguments).
474 job_name : `str`
475 Unique name for the job.
476 use_shared : `bool`
477 Whether job has access to files via shared filesystem.
478 out_prefix : `str`
479 The root directory into which all WMS-specific files are written.
481 Returns
482 -------
483 htc_commands : `dict` [`str`, `str`]
484 HTCondor commands for the job submission script.
485 """
486 outputs = []
487 output_remaps = []
488 for gwf_file in generic_workflow.get_job_outputs(job_name, data=True, transfer_only=True):
489 _LOG.debug("src_uri=%s", gwf_file.src_uri)
491 uri = Path(gwf_file.src_uri)
492 if not use_shared:
493 outputs.append(uri.name)
494 output_remaps.append(f"{uri.name}={str(uri)}")
496 # Set to an empty string to disable and only update if there are output
497 # files to transfer. Otherwise, HTCondor will transfer back all files in
498 # the job’s temporary working directory that have been modified or created
499 # by the job.
500 htc_commands = {"transfer_output_files": '""'}
501 if outputs:
502 htc_commands["transfer_output_files"] = ",".join(outputs)
503 _LOG.debug("transfer_output_files=%s", htc_commands["transfer_output_files"])
505 htc_commands["transfer_output_remaps"] = f'"{";".join(output_remaps)}"'
506 _LOG.debug("transfer_output_remaps=%s", htc_commands["transfer_output_remaps"])
507 return htc_commands
510def _create_periodic_release_expr(
511 memory: int, multiplier: float | None, limit: int, additional_expr: str = ""
512) -> str:
513 """Construct an HTCondorAd expression for releasing held jobs.
515 Parameters
516 ----------
517 memory : `int`
518 Requested memory in MB.
519 multiplier : `float` or None
520 Memory growth rate between retries.
521 limit : `int`
522 Memory limit.
523 additional_expr : `str`, optional
524 Expression to add to periodic_release. Defaults to empty string.
526 Returns
527 -------
528 expr : `str`
529 A string representing an HTCondor ClassAd expression for releasing job.
530 """
531 _LOG.debug(
532 "periodic_release: memory: %s, multiplier: %s, limit: %s, additional_expr: %s",
533 memory,
534 multiplier,
535 limit,
536 additional_expr,
537 )
539 # ctrl_bps sets multiplier to None in the GenericWorkflow if
540 # memoryMultiplier <= 1, but checking value just in case.
541 if (not multiplier or multiplier <= 1) and not additional_expr:
542 return ""
544 # Job ClassAds attributes 'HoldReasonCode' and 'HoldReasonSubCode' are
545 # UNDEFINED if job is not HELD (i.e. when 'JobStatus' is not 5).
546 # The special comparison operators ensure that all comparisons below will
547 # evaluate to FALSE in this case.
548 #
549 # Note:
550 # May not be strictly necessary. Operators '&&' and '||' are not strict so
551 # the entire expression should evaluate to FALSE when the job is not HELD.
552 # According to ClassAd evaluation semantics FALSE && UNDEFINED is FALSE,
553 # but better safe than sorry.
554 is_held = "JobStatus == 5"
555 is_retry_allowed = "NumJobStarts <= JobMaxRetries"
557 mem_expr = ""
558 if memory and multiplier and multiplier > 1 and limit:
559 was_mem_exceeded = (
560 "(HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 "
561 "|| HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34)"
562 )
563 was_below_limit = f"min({{int({memory} * pow({multiplier}, NumJobStarts - 1)), {limit}}}) < {limit}"
564 mem_expr = f"{was_mem_exceeded} && {was_below_limit}"
566 user_expr = ""
567 if additional_expr:
568 # Never auto release a job held by user.
569 user_expr = f"HoldReasonCode =!= 1 && {additional_expr}"
571 expr = f"{is_held} && {is_retry_allowed}"
572 if user_expr and mem_expr:
573 expr += f" && ({mem_expr} || {user_expr})"
574 elif user_expr:
575 expr += f" && {user_expr}"
576 elif mem_expr:
577 expr += f" && {mem_expr}"
579 return expr
582def _create_periodic_remove_expr(memory, multiplier, limit):
583 """Construct an HTCondorAd expression for removing jobs from the queue.
585 Parameters
586 ----------
587 memory : `int`
588 Requested memory in MB.
589 multiplier : `float`
590 Memory growth rate between retries.
591 limit : `int`
592 Memory limit.
594 Returns
595 -------
596 expr : `str`
597 A string representing an HTCondor ClassAd expression for removing jobs.
598 """
599 # Job ClassAds attributes 'HoldReasonCode' and 'HoldReasonSubCode'
600 # are UNDEFINED if job is not HELD (i.e. when 'JobStatus' is not 5).
601 # The special comparison operators ensure that all comparisons below
602 # will evaluate to FALSE in this case.
603 #
604 # Note:
605 # May not be strictly necessary. Operators '&&' and '||' are not
606 # strict so the entire expression should evaluate to FALSE when the
607 # job is not HELD. According to ClassAd evaluation semantics
608 # FALSE && UNDEFINED is FALSE, but better safe than sorry.
609 is_held = "JobStatus == 5"
610 is_retry_disallowed = "NumJobStarts > JobMaxRetries"
612 mem_expr = ""
613 if memory and multiplier and multiplier > 1 and limit:
614 mem_limit_expr = f"min({{int({memory} * pow({multiplier}, NumJobStarts - 1)), {limit}}}) == {limit}"
616 mem_expr = ( # Add || here so only added if adding memory expr
617 " || ((HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 "
618 f"|| HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34) && {mem_limit_expr})"
619 )
621 expr = f"{is_held} && ({is_retry_disallowed}{mem_expr})"
622 return expr
625def _create_request_memory_expr(memory, multiplier, limit):
626 """Construct an HTCondor ClassAd expression for safe memory scaling.
628 Parameters
629 ----------
630 memory : `int`
631 Requested memory in MB.
632 multiplier : `float`
633 Memory growth rate between retries.
634 limit : `int`
635 Memory limit.
637 Returns
638 -------
639 expr : `str`
640 A string representing an HTCondor ClassAd expression enabling safe
641 memory scaling between job retries.
642 """
643 # The check if the job was held due to exceeding memory requirements
644 # will be made *after* job was released back to the job queue (is in
645 # the IDLE state), hence the need to use `Last*` job ClassAds instead of
646 # the ones describing job's current state.
647 #
648 # Also, 'Last*' job ClassAds attributes are UNDEFINED when a job is
649 # initially put in the job queue. The special comparison operators ensure
650 # that all comparisons below will evaluate to FALSE in this case.
651 was_mem_exceeded = (
652 "LastJobStatus =?= 5 "
653 "&& (LastHoldReasonCode =?= 34 && LastHoldReasonSubCode =?= 0 "
654 "|| LastHoldReasonCode =?= 3 && LastHoldReasonSubCode =?= 34)"
655 )
657 # If job runs the first time or was held for reasons other than exceeding
658 # the memory, set the required memory to the requested value or use
659 # the memory value measured by HTCondor (MemoryUsage) depending on
660 # whichever is greater.
661 expr = (
662 f"({was_mem_exceeded}) "
663 f"? min({{int({memory} * pow({multiplier}, NumJobStarts)), {limit}}}) "
664 f": max({{{memory}, MemoryUsage ?: 0}})"
665 )
666 return expr
669def _gather_site_values(config, compute_site):
670 """Gather values specific to given site.
672 Parameters
673 ----------
674 config : `lsst.ctrl.bps.BpsConfig`
675 BPS configuration that includes necessary submit/runtime
676 information.
677 compute_site : `str`
678 Compute site name.
680 Returns
681 -------
682 site_values : `dict` [`str`, `~typing.Any`]
683 Values specific to the given site.
684 """
685 site_values = {"attrs": {}, "profile": {}}
686 search_opts = {}
687 if compute_site:
688 search_opts["curvals"] = {"curr_site": compute_site}
690 # Determine the hard limit for the memory requirement.
691 found, limit = config.search("memoryLimit", opt=search_opts)
692 if not found:
693 search_opts["default"] = DEFAULT_HTC_EXEC_PATT
694 _, patt = config.search("executeMachinesPattern", opt=search_opts)
695 del search_opts["default"]
697 # To reduce the amount of data, ignore dynamic slots (if any) as,
698 # by definition, they cannot have more memory than
699 # the partitionable slot they are the part of.
700 constraint = f'SlotType != "Dynamic" && regexp("{patt}", Machine)'
701 pool_info = condor_status(constraint=constraint)
702 try:
703 limit = max(int(info["TotalSlotMemory"]) for info in pool_info.values())
704 except ValueError:
705 _LOG.debug("No execute machine in the pool matches %s", patt)
706 if limit:
707 config[".bps_defined.memory_limit"] = limit
709 _, site_values["bpsUseShared"] = config.search("bpsUseShared", opt={"default": False})
710 site_values["memoryLimit"] = limit
712 found, value = config.search("accountingGroup", opt=search_opts)
713 if found:
714 site_values["accountingGroup"] = value
715 found, value = config.search("accountingUser", opt=search_opts)
716 if found:
717 site_values["accountingUser"] = value
719 key = f".site.{compute_site}.profile.condor"
720 if key in config:
721 for subkey, val in config[key].items():
722 if subkey.startswith("+"):
723 site_values["attrs"][subkey[1:]] = val
724 else:
725 site_values["profile"][subkey] = val
727 return site_values
730def _gather_label_values(config: BpsConfig, label: str) -> dict[str, Any]:
731 """Gather values specific to given job label.
733 Parameters
734 ----------
735 config : `lsst.ctrl.bps.BpsConfig`
736 BPS configuration that includes necessary submit/runtime
737 information.
738 label : `str`
739 GenericWorkflowJob label.
741 Returns
742 -------
743 values : `dict` [`str`, `~typing.Any`]
744 Values specific to the given job label.
745 """
746 values: dict[str, Any] = {"attrs": {}, "profile": {}}
748 search_opts = {}
749 profile_key = ""
750 if label == "finalJob":
751 search_opts["searchobj"] = config["finalJob"]
752 profile_key = ".finalJob.profile.condor"
753 elif label in config["cluster"]:
754 search_opts["curvals"] = {"curr_cluster": label}
755 profile_key = f".cluster.{label}.profile.condor"
756 elif label in config["pipetask"]:
757 search_opts["curvals"] = {"curr_pipetask": label}
758 profile_key = f".pipetask.{label}.profile.condor"
760 found, value = config.search("releaseExpr", opt=search_opts)
761 if found:
762 values["releaseExpr"] = value
764 found, value = config.search("overwriteJobFiles", opt=search_opts)
765 if found:
766 values["overwriteJobFiles"] = value
767 else:
768 values["overwriteJobFiles"] = True
770 if profile_key and profile_key in config:
771 for subkey, val in config[profile_key].items():
772 if subkey.startswith("+"):
773 values["attrs"][subkey[1:]] = val
774 else:
775 values["profile"][subkey] = val
777 return values
780def _group_to_subdag(
781 config: BpsConfig, generic_workflow_group: GenericWorkflowGroup, out_prefix: str
782) -> HTCJob:
783 """Convert a generic workflow group to an HTCondor dag.
785 Parameters
786 ----------
787 config : `lsst.ctrl.bps.BpsConfig`
788 Workflow configuration.
789 generic_workflow_group : `lsst.ctrl.bps.GenericWorkflowGroup`
790 The generic workflow group to convert.
791 out_prefix : `str`
792 Location prefix to be used when creating jobs.
794 Returns
795 -------
796 htc_job : `lsst.ctrl.bps.htcondor.HTCJob`
797 Job for running the HTCondor dag.
798 """
799 jobname = f"wms_{generic_workflow_group.name}"
800 htc_job = HTCJob(name=jobname, label=generic_workflow_group.label)
801 htc_job.add_dag_cmds({"dir": f"subdags/{jobname}"})
802 htc_job.subdag = _generic_workflow_to_htcondor_dag(config, generic_workflow_group, out_prefix)
803 if not generic_workflow_group.blocking:
804 htc_job.dagcmds["post"] = {
805 "defer": "",
806 "executable": f"{os.path.dirname(__file__)}/subdag_post.sh",
807 "arguments": f"{jobname} $RETURN",
808 }
809 return htc_job
812def _create_check_job(group_job_name: str, job_label: str) -> HTCJob:
813 """Create a job to check status of a group job.
815 Parameters
816 ----------
817 group_job_name : `str`
818 Name of the group job.
819 job_label : `str`
820 Label to use for the check status job.
822 Returns
823 -------
824 htc_job : `lsst.ctrl.bps.htcondor.HTCJob`
825 Job description for the job to check group job status.
826 """
827 htc_job = HTCJob(name=f"wms_check_status_{group_job_name}", label=job_label)
828 htc_job.subfile = "${CTRL_BPS_HTCONDOR_DIR}/python/lsst/ctrl/bps/htcondor/check_group_status.sub"
829 htc_job.add_dag_cmds({"dir": f"subdags/{group_job_name}", "vars": {"group_job_name": group_job_name}})
831 return htc_job
834def _generic_workflow_to_htcondor_dag(
835 config: BpsConfig, generic_workflow: GenericWorkflow, out_prefix: str
836) -> HTCDag:
837 """Convert a GenericWorkflow to a HTCDag.
839 Parameters
840 ----------
841 config : `lsst.ctrl.bps.BpsConfig`
842 Workflow configuration.
843 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
844 The GenericWorkflow to convert.
845 out_prefix : `str`
846 Location prefix where the HTCondor files will be written.
848 Returns
849 -------
850 dag : `lsst.ctrl.bps.htcondor.HTCDag`
851 The HTCDag representation of the given GenericWorkflow.
852 """
853 dag = HTCDag(name=generic_workflow.name)
855 _LOG.debug("htcondor dag attribs %s", generic_workflow.run_attrs)
856 dag.add_attribs(generic_workflow.run_attrs)
857 dag.add_attribs(
858 {
859 "bps_run_quanta": create_count_summary(generic_workflow.quanta_counts),
860 "bps_job_summary": create_count_summary(generic_workflow.job_counts),
861 }
862 )
864 _, tmp_template = config.search("subDirTemplate", opt={"replaceVars": False, "default": ""})
865 if isinstance(tmp_template, str):
866 subdir_template = defaultdict(lambda: tmp_template)
867 else:
868 subdir_template = tmp_template
870 # Create all DAG jobs
871 site_values = {} # Cache compute site specific values to reduce config lookups.
872 cached_values = {} # Cache label-specific values to reduce config lookups.
873 # Note: Can't use get_job_by_label because those only include payload jobs.
874 for job_name in generic_workflow:
875 gwjob = generic_workflow.get_job(job_name)
876 if gwjob.node_type == GenericWorkflowNodeType.PAYLOAD:
877 gwjob = cast(GenericWorkflowJob, gwjob)
878 if gwjob.compute_site not in site_values:
879 site_values[gwjob.compute_site] = _gather_site_values(config, gwjob.compute_site)
880 if gwjob.label not in cached_values:
881 cached_values[gwjob.label] = deepcopy(site_values[gwjob.compute_site])
882 cached_values[gwjob.label].update(_gather_label_values(config, gwjob.label))
883 _LOG.debug("cached: %s= %s", gwjob.label, cached_values[gwjob.label])
884 htc_job = _create_job(
885 subdir_template[gwjob.label],
886 cached_values[gwjob.label],
887 generic_workflow,
888 gwjob,
889 out_prefix,
890 )
891 elif gwjob.node_type == GenericWorkflowNodeType.NOOP:
892 gwjob = cast(GenericWorkflowNoopJob, gwjob)
893 htc_job = HTCJob(f"wms_{gwjob.name}", label=gwjob.label)
894 htc_job.subfile = "${CTRL_BPS_HTCONDOR_DIR}/python/lsst/ctrl/bps/htcondor/noop.sub"
895 htc_job.add_job_attrs({"bps_job_name": gwjob.name, "bps_job_label": gwjob.label})
896 htc_job.add_dag_cmds({"noop": True})
897 elif gwjob.node_type == GenericWorkflowNodeType.GROUP:
898 gwjob = cast(GenericWorkflowGroup, gwjob)
899 htc_job = _group_to_subdag(config, gwjob, out_prefix)
900 else:
901 raise RuntimeError(f"Unsupported generic workflow node type {gwjob.node_type} ({gwjob.name})")
902 _LOG.debug("Calling adding job %s %s", htc_job.name, htc_job.label)
903 dag.add_job(htc_job)
905 # Add job dependencies to the DAG (be careful with wms_ jobs)
906 for job_name in generic_workflow:
907 gwjob = generic_workflow.get_job(job_name)
908 parent_name = (
909 gwjob.name if gwjob.node_type == GenericWorkflowNodeType.PAYLOAD else f"wms_{gwjob.name}"
910 )
911 successor_jobs = [generic_workflow.get_job(j) for j in generic_workflow.successors(job_name)]
912 children_names = []
913 if gwjob.node_type == GenericWorkflowNodeType.GROUP:
914 gwjob = cast(GenericWorkflowGroup, gwjob)
915 group_children = [] # Dependencies between same group jobs
916 for sjob in successor_jobs:
917 if sjob.node_type == GenericWorkflowNodeType.GROUP and sjob.label == gwjob.label:
918 group_children.append(f"wms_{sjob.name}")
919 elif sjob.node_type == GenericWorkflowNodeType.PAYLOAD:
920 children_names.append(sjob.name)
921 else:
922 children_names.append(f"wms_{sjob.name}")
923 if group_children:
924 dag.add_job_relationships([parent_name], group_children)
925 if not gwjob.blocking:
926 # Since subdag will always succeed, need to add a special
927 # job that fails if group failed to block payload children.
928 check_job = _create_check_job(f"wms_{gwjob.name}", gwjob.label)
929 dag.add_job(check_job)
930 dag.add_job_relationships([f"wms_{gwjob.name}"], [check_job.name])
931 parent_name = check_job.name
932 else:
933 for sjob in successor_jobs:
934 if sjob.node_type == GenericWorkflowNodeType.PAYLOAD:
935 children_names.append(sjob.name)
936 else:
937 children_names.append(f"wms_{sjob.name}")
939 dag.add_job_relationships([parent_name], children_names)
941 # If final job exists in generic workflow, create DAG final job
942 final = generic_workflow.get_final()
943 if final and isinstance(final, GenericWorkflowJob):
944 if final.compute_site and final.compute_site not in site_values:
945 site_values[final.compute_site] = _gather_site_values(config, final.compute_site)
946 if final.label not in cached_values:
947 cached_values[final.label] = deepcopy(site_values[final.compute_site])
948 cached_values[final.label].update(_gather_label_values(config, final.label))
949 final_htjob = _create_job(
950 subdir_template[final.label],
951 cached_values[final.label],
952 generic_workflow,
953 final,
954 out_prefix,
955 )
956 if "post" not in final_htjob.dagcmds:
957 final_htjob.dagcmds["post"] = {
958 "defer": "",
959 "executable": f"{os.path.dirname(__file__)}/final_post.sh",
960 "arguments": f"{final.name} $DAG_STATUS $RETURN",
961 }
962 dag.add_final_job(final_htjob)
963 elif final and isinstance(final, GenericWorkflow):
964 raise NotImplementedError("HTCondor plugin does not support a workflow as the final job")
965 elif final:
966 raise TypeError(f"Invalid type for GenericWorkflow.get_final() results ({type(final)})")
968 return dag