Coverage for python / lsst / ctrl / bps / htcondor / prepare_utils.py: 5%
375 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-26 12:24 -0800
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-26 12:24 -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 _update_dicts,
52 condor_status,
53 htc_escape,
54)
56_LOG = logging.getLogger(__name__)
58DEFAULT_HTC_EXEC_PATT = ".*worker.*"
59"""Default pattern for searching execute machines in an HTCondor pool.
60"""
63def _create_job(subdir_template, cached_values, generic_workflow, gwjob, out_prefix):
64 """Convert GenericWorkflow job nodes to DAG jobs.
66 Parameters
67 ----------
68 subdir_template : `str`
69 Template for making subdirs.
70 cached_values : `dict`
71 Site and label specific values.
72 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
73 Generic workflow that is being converted.
74 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
75 The generic job to convert to a HTCondor job.
76 out_prefix : `str`
77 Directory prefix for HTCondor files.
79 Returns
80 -------
81 htc_job : `lsst.ctrl.bps.wms.htcondor.HTCJob`
82 The HTCondor job equivalent to the given generic job.
83 """
84 htc_job = HTCJob(gwjob.name, label=gwjob.label)
86 curvals = defaultdict(str)
87 curvals["label"] = gwjob.label
88 if gwjob.tags:
89 curvals.update(gwjob.tags)
91 subdir = Path("jobs") / subdir_template.format_map(curvals)
92 htc_job.subdir = subdir
93 htc_job.subfile = f"{gwjob.name}.sub"
94 htc_job.add_dag_cmds({"dir": subdir})
96 htc_job_cmds = {
97 "universe": "vanilla",
98 "should_transfer_files": "YES",
99 "when_to_transfer_output": "ON_EXIT_OR_EVICT",
100 "transfer_output_files": '""', # Set to empty string to disable
101 "transfer_executable": "False",
102 "getenv": "True",
103 # Exceeding memory sometimes triggers SIGBUS or SIGSEGV error. Tell
104 # htcondor to put on hold any jobs which exited by a signal. If
105 # executed in a bash script, like finalJob, the signals will become
106 # exit codes above 128 (exit code = 128 + signal number).
107 "on_exit_hold": "ExitBySignal == true || ExitCode > 128",
108 "on_exit_hold_reason": "ExitBySignal == true ? "
109 'strcat("Job raised a signal ", string(ExitSignal), '
110 '". Handling job as if it has gone over memory limit.") : '
111 'strcat("Job exit code (", string(ExitCode), ") > 128. '
112 'Handling job as if it has gone over memory limit.")',
113 "on_exit_hold_subcode": "34",
114 }
116 htc_job_cmds.update(_translate_job_cmds(cached_values, generic_workflow, gwjob))
118 # Combine stdout and stderr to reduce the number of files.
119 for key in ("output", "error"):
120 if cached_values["overwriteJobFiles"]:
121 htc_job_cmds[key] = f"{gwjob.name}.$(Cluster).out"
122 else:
123 htc_job_cmds[key] = f"{gwjob.name}.$(Cluster).$$([NumJobStarts ?: 0]).out"
124 _LOG.debug("HTCondor %s = %s", key, htc_job_cmds[key])
126 key = "log"
127 htc_job_cmds[key] = f"{gwjob.name}.$(Cluster).{key}"
128 _LOG.debug("HTCondor %s = %s", key, htc_job_cmds[key])
130 htc_job_cmds.update(
131 _handle_job_inputs(generic_workflow, gwjob.name, cached_values["bpsUseShared"], out_prefix)
132 )
134 htc_job_cmds.update(
135 _handle_job_outputs(generic_workflow, gwjob.name, cached_values["bpsUseShared"], out_prefix)
136 )
138 # If specified, add nodeset to the job
139 if "nodeset" in cached_values:
140 htc_job.add_job_attrs({"JobNodeset": cached_values["nodeset"]})
141 clause = f'( Target.Nodeset == "{cached_values["nodeset"]}" )'
142 if "requirements" in htc_job_cmds:
143 htc_job_cmds["requirements"] = f"({htc_job_cmds['requirements']}) && {clause}"
144 else:
145 htc_job_cmds["requirements"] = clause
147 # Add the job cmds dict to the job object.
148 htc_job.add_job_cmds(htc_job_cmds)
150 # Add job-related cmds to the DAG (e.g., VARS)
151 htc_job.add_dag_cmds(_translate_dag_cmds(gwjob))
153 # Add job attributes to job.
154 _LOG.debug("gwjob.attrs = %s", gwjob.attrs)
155 htc_job.add_job_attrs(gwjob.attrs)
156 htc_job.add_job_attrs(cached_values["attrs"])
157 htc_job.add_job_attrs({"bps_job_quanta": create_count_summary(gwjob.quanta_counts)})
158 htc_job.add_job_attrs({"bps_job_name": gwjob.name, "bps_job_label": gwjob.label})
160 return htc_job
163def _translate_job_cmds(cached_vals, generic_workflow, gwjob):
164 """Translate the job data that are one to one mapping
166 Parameters
167 ----------
168 cached_vals : `dict` [`str`, `~typing.Any`]
169 Config values common to jobs with same site or label.
170 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
171 Generic workflow that contains job to being converted.
172 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
173 Generic workflow job to be converted.
175 Returns
176 -------
177 htc_job_commands : `dict` [`str`, `~typing.Any`]
178 Contains commands which can appear in the HTCondor submit description
179 file.
180 """
181 # Values in the job script that just are name mappings.
182 job_translation = {
183 "mail_to": "notify_user",
184 "when_to_mail": "notification",
185 "request_cpus": "request_cpus",
186 "priority": "priority",
187 "category": "category",
188 "accounting_group": "accounting_group",
189 "accounting_user": "accounting_group_user",
190 }
192 jobcmds = {}
193 for gwkey, htckey in job_translation.items():
194 jobcmds[htckey] = getattr(gwjob, gwkey, None)
196 # If accounting info was not set explicitly, use site settings if any.
197 if not gwjob.accounting_group:
198 jobcmds["accounting_group"] = cached_vals.get("accountingGroup")
199 if not gwjob.accounting_user:
200 jobcmds["accounting_group_user"] = cached_vals.get("accountingUser")
202 # job commands that need modification
203 if gwjob.retry_unless_exit:
204 if isinstance(gwjob.retry_unless_exit, int):
205 jobcmds["retry_until"] = f"{gwjob.retry_unless_exit}"
206 elif isinstance(gwjob.retry_unless_exit, list):
207 jobcmds["retry_until"] = (
208 f"member(ExitCode, {{{','.join([str(x) for x in gwjob.retry_unless_exit])}}})"
209 )
210 else:
211 raise ValueError("retryUnlessExit must be an integer or a list of integers.")
213 if gwjob.request_disk:
214 jobcmds["request_disk"] = f"{gwjob.request_disk}MB"
216 if gwjob.request_memory:
217 jobcmds["request_memory"] = f"{gwjob.request_memory}"
219 memory_max = 0
220 if gwjob.memory_multiplier:
221 # Do not use try-except! At the moment, BpsConfig returns an empty
222 # string if it does not contain the key.
223 memory_limit = cached_vals["memoryLimit"]
224 if not memory_limit:
225 raise RuntimeError(
226 "Memory autoscaling enabled, but automatic detection of the memory limit "
227 "failed; setting it explicitly with 'memoryLimit' or changing worker node "
228 "search pattern 'executeMachinesPattern' might help."
229 )
231 # Set maximal amount of memory job can ask for.
232 #
233 # The check below assumes that 'memory_limit' was set to a value which
234 # realistically reflects actual physical limitations of a given compute
235 # resource.
236 memory_max = memory_limit
237 if gwjob.request_memory_max and gwjob.request_memory_max < memory_limit:
238 memory_max = gwjob.request_memory_max
240 # Make job ask for more memory each time it failed due to insufficient
241 # memory requirements.
242 jobcmds["request_memory"] = _create_request_memory_expr(
243 gwjob.request_memory, gwjob.memory_multiplier, memory_max
244 )
246 user_release_expr = cached_vals.get("releaseExpr", "")
247 if gwjob.number_of_retries is not None and gwjob.number_of_retries >= 0:
248 jobcmds["max_retries"] = gwjob.number_of_retries
250 # No point in adding periodic_release if 0 retries
251 if gwjob.number_of_retries > 0:
252 periodic_release = _create_periodic_release_expr(
253 gwjob.request_memory, gwjob.memory_multiplier, memory_max, user_release_expr
254 )
255 if periodic_release:
256 jobcmds["periodic_release"] = periodic_release
258 jobcmds["periodic_remove"] = _create_periodic_remove_expr(
259 gwjob.request_memory, gwjob.memory_multiplier, memory_max
260 )
262 # Assume concurrency_limit implemented using HTCondor concurrency limits.
263 # May need to move to special site-specific implementation if sites use
264 # other mechanisms.
265 if gwjob.concurrency_limit:
266 jobcmds["concurrency_limit"] = gwjob.concurrency_limit
268 # Handle command line
269 if gwjob.executable.transfer_executable:
270 jobcmds["transfer_executable"] = "True"
271 jobcmds["executable"] = gwjob.executable.src_uri
272 else:
273 jobcmds["executable"] = _fix_env_var_syntax(gwjob.executable.src_uri)
275 if gwjob.arguments:
276 arguments = gwjob.arguments
277 arguments = _replace_cmd_vars(arguments, gwjob)
278 arguments = _replace_file_vars(cached_vals["bpsUseShared"], arguments, generic_workflow, gwjob)
279 arguments = _fix_env_var_syntax(arguments)
280 jobcmds["arguments"] = arguments
282 if gwjob.environment:
283 env_str = ""
284 for name, value in gwjob.environment.items():
285 if isinstance(value, str):
286 value2 = _replace_cmd_vars(value, gwjob)
287 value2 = _fix_env_var_syntax(value2)
288 value2 = htc_escape(value2)
289 env_str += f"{name}='{value2}' " # Add single quotes to allow internal spaces
290 else:
291 env_str += f"{name}={value} "
293 # Process above added one trailing space
294 jobcmds["environment"] = env_str.rstrip()
296 # Add extra "pass-thru" job commands
297 if gwjob.profile:
298 for key, val in gwjob.profile.items():
299 jobcmds[key] = val
300 for key, val in cached_vals["profile"].items():
301 jobcmds[key] = val
303 return jobcmds
306def _translate_dag_cmds(gwjob):
307 """Translate job values into DAGMan commands.
309 Parameters
310 ----------
311 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
312 Job containing values to be translated.
314 Returns
315 -------
316 dagcmds : `dict` [`str`, `~typing.Any`]
317 DAGMan commands for the job.
318 """
319 # Values in the dag script that just are name mappings.
320 dag_translation = {
321 "abort_on_value": "abort_dag_on",
322 "abort_return_value": "abort_exit",
323 "priority": "priority",
324 }
326 dagcmds = {}
327 for gwkey, htckey in dag_translation.items():
328 dagcmds[htckey] = getattr(gwjob, gwkey, None)
330 # Still to be coded: vars "pre_cmdline", "post_cmdline"
331 return dagcmds
334def _fix_env_var_syntax(oldstr):
335 """Change ENV place holders to HTCondor Env var syntax.
337 Parameters
338 ----------
339 oldstr : `str`
340 String in which environment variable syntax is to be fixed.
342 Returns
343 -------
344 newstr : `str`
345 Given string with environment variable syntax fixed.
346 """
347 newstr = oldstr
348 for key in re.findall(r"<ENV:([^>]+)>", oldstr):
349 newstr = newstr.replace(rf"<ENV:{key}>", f"$ENV({key})")
350 return newstr
353def _replace_file_vars(use_shared, arguments, workflow, gwjob):
354 """Replace file placeholders in command line arguments with correct
355 physical file names.
357 Parameters
358 ----------
359 use_shared : `bool`
360 Whether HTCondor can assume shared filesystem.
361 arguments : `str`
362 Arguments string in which to replace file placeholders.
363 workflow : `lsst.ctrl.bps.GenericWorkflow`
364 Generic workflow that contains file information.
365 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
366 The job corresponding to the arguments.
368 Returns
369 -------
370 arguments : `str`
371 Given arguments string with file placeholders replaced.
372 """
373 # Replace input file placeholders with paths.
374 for gwfile in workflow.get_job_inputs(gwjob.name, data=True, transfer_only=False):
375 if not gwfile.wms_transfer:
376 # Must assume full URI if in command line and told WMS is not
377 # responsible for transferring file.
378 uri = gwfile.src_uri
379 elif use_shared:
380 if gwfile.job_shared:
381 # Have shared filesystems and jobs can share file.
382 uri = gwfile.src_uri
383 else:
384 uri = os.path.basename(gwfile.src_uri)
385 else: # Using push transfer
386 uri = os.path.basename(gwfile.src_uri)
387 arguments = arguments.replace(f"<FILE:{gwfile.name}>", uri)
389 # Replace output file placeholders with paths.
390 for gwfile in workflow.get_job_outputs(gwjob.name, data=True, transfer_only=False):
391 if not gwfile.wms_transfer:
392 # Must assume full URI if in command line and told WMS is not
393 # responsible for transferring file.
394 uri = gwfile.src_uri
395 elif use_shared:
396 if gwfile.job_shared:
397 # Have shared filesystems and jobs can share file.
398 uri = gwfile.src_uri
399 else:
400 uri = os.path.basename(gwfile.src_uri)
401 else: # Using push transfer
402 uri = os.path.basename(gwfile.src_uri)
403 arguments = arguments.replace(f"<FILE:{gwfile.name}>", uri)
404 return arguments
407def _replace_cmd_vars(arguments, gwjob):
408 """Replace format-style placeholders in arguments.
410 Parameters
411 ----------
412 arguments : `str`
413 Arguments string in which to replace placeholders.
414 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
415 Job containing values to be used to replace placeholders
416 (in particular gwjob.cmdvals).
418 Returns
419 -------
420 arguments : `str`
421 Given arguments string with placeholders replaced.
422 """
423 replacements = gwjob.cmdvals if gwjob.cmdvals is not None else {}
424 try:
425 arguments = arguments.format(**replacements)
426 except (KeyError, TypeError) as exc: # TypeError in case None instead of {}
427 _LOG.error("Could not replace command variables: replacement for %s not provided", str(exc))
428 _LOG.debug("arguments: %s\ncmdvals: %s", arguments, replacements)
429 raise
430 return arguments
433def _handle_job_inputs(
434 generic_workflow: GenericWorkflow, job_name: str, use_shared: bool, out_prefix: str
435) -> dict[str, str]:
436 """Add job input files from generic workflow to job.
438 Parameters
439 ----------
440 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
441 The generic workflow (e.g., has executable name and arguments).
442 job_name : `str`
443 Unique name for the job.
444 use_shared : `bool`
445 Whether job has access to files via shared filesystem.
446 out_prefix : `str`
447 The root directory into which all WMS-specific files are written.
449 Returns
450 -------
451 htc_commands : `dict` [`str`, `str`]
452 HTCondor commands for the job submission script.
453 """
454 inputs = []
455 for gwf_file in generic_workflow.get_job_inputs(job_name, data=True, transfer_only=True):
456 _LOG.debug("src_uri=%s", gwf_file.src_uri)
458 uri = Path(gwf_file.src_uri)
460 # Note if use_shared and job_shared, don't need to transfer file.
462 if not use_shared: # Copy file using push to job
463 inputs.append(str(uri))
464 elif not gwf_file.job_shared: # Jobs require own copy
465 # if using shared filesystem, but still need copy in job. Use
466 # HTCondor's curl plugin for a local copy.
467 if uri.is_dir():
468 raise RuntimeError(
469 f"HTCondor plugin cannot transfer directories locally within job {gwf_file.src_uri}"
470 )
471 inputs.append(f"file://{uri}")
473 htc_commands = {}
474 if inputs:
475 htc_commands["transfer_input_files"] = ",".join(inputs)
476 _LOG.debug("transfer_input_files=%s", htc_commands["transfer_input_files"])
477 return htc_commands
480def _handle_job_outputs(
481 generic_workflow: GenericWorkflow, job_name: str, use_shared: bool, out_prefix: str
482) -> dict[str, str]:
483 """Add job output files from generic workflow to the job if any.
485 Parameters
486 ----------
487 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
488 The generic workflow (e.g., has executable name and arguments).
489 job_name : `str`
490 Unique name for the job.
491 use_shared : `bool`
492 Whether job has access to files via shared filesystem.
493 out_prefix : `str`
494 The root directory into which all WMS-specific files are written.
496 Returns
497 -------
498 htc_commands : `dict` [`str`, `str`]
499 HTCondor commands for the job submission script.
500 """
501 outputs = []
502 output_remaps = []
503 for gwf_file in generic_workflow.get_job_outputs(job_name, data=True, transfer_only=True):
504 _LOG.debug("src_uri=%s", gwf_file.src_uri)
506 uri = Path(gwf_file.src_uri)
507 if not use_shared:
508 outputs.append(uri.name)
509 output_remaps.append(f"{uri.name}={str(uri)}")
511 # Set to an empty string to disable and only update if there are output
512 # files to transfer. Otherwise, HTCondor will transfer back all files in
513 # the job’s temporary working directory that have been modified or created
514 # by the job.
515 htc_commands = {"transfer_output_files": '""'}
516 if outputs:
517 htc_commands["transfer_output_files"] = ",".join(outputs)
518 _LOG.debug("transfer_output_files=%s", htc_commands["transfer_output_files"])
520 htc_commands["transfer_output_remaps"] = f'"{";".join(output_remaps)}"'
521 _LOG.debug("transfer_output_remaps=%s", htc_commands["transfer_output_remaps"])
522 return htc_commands
525def _create_periodic_release_expr(
526 memory: int, multiplier: float | None, limit: int, additional_expr: str = ""
527) -> str:
528 """Construct an HTCondorAd expression for releasing held jobs.
530 Parameters
531 ----------
532 memory : `int`
533 Requested memory in MB.
534 multiplier : `float` or None
535 Memory growth rate between retries.
536 limit : `int`
537 Memory limit.
538 additional_expr : `str`, optional
539 Expression to add to periodic_release. Defaults to empty string.
541 Returns
542 -------
543 expr : `str`
544 A string representing an HTCondor ClassAd expression for releasing job.
545 """
546 _LOG.debug(
547 "periodic_release: memory: %s, multiplier: %s, limit: %s, additional_expr: %s",
548 memory,
549 multiplier,
550 limit,
551 additional_expr,
552 )
554 # ctrl_bps sets multiplier to None in the GenericWorkflow if
555 # memoryMultiplier <= 1, but checking value just in case.
556 if (not multiplier or multiplier <= 1) and not additional_expr:
557 return ""
559 # Job ClassAds attributes 'HoldReasonCode' and 'HoldReasonSubCode' are
560 # UNDEFINED if job is not HELD (i.e. when 'JobStatus' is not 5).
561 # The special comparison operators ensure that all comparisons below will
562 # evaluate to FALSE in this case.
563 #
564 # Note:
565 # May not be strictly necessary. Operators '&&' and '||' are not strict so
566 # the entire expression should evaluate to FALSE when the job is not HELD.
567 # According to ClassAd evaluation semantics FALSE && UNDEFINED is FALSE,
568 # but better safe than sorry.
569 is_held = "JobStatus == 5"
570 is_retry_allowed = "NumJobStarts <= JobMaxRetries"
572 mem_expr = ""
573 if memory and multiplier and multiplier > 1 and limit:
574 was_mem_exceeded = (
575 "(HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 "
576 "|| HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34)"
577 )
578 was_below_limit = f"min({{int({memory} * pow({multiplier}, NumJobStarts - 1)), {limit}}}) < {limit}"
579 mem_expr = f"{was_mem_exceeded} && {was_below_limit}"
581 user_expr = ""
582 if additional_expr:
583 # Never auto release a job held by user.
584 user_expr = f"HoldReasonCode =!= 1 && {additional_expr}"
586 expr = f"{is_held} && {is_retry_allowed}"
587 if user_expr and mem_expr:
588 expr += f" && ({mem_expr} || {user_expr})"
589 elif user_expr:
590 expr += f" && {user_expr}"
591 elif mem_expr:
592 expr += f" && {mem_expr}"
594 return expr
597def _create_periodic_remove_expr(memory, multiplier, limit):
598 """Construct an HTCondorAd expression for removing jobs from the queue.
600 Parameters
601 ----------
602 memory : `int`
603 Requested memory in MB.
604 multiplier : `float`
605 Memory growth rate between retries.
606 limit : `int`
607 Memory limit.
609 Returns
610 -------
611 expr : `str`
612 A string representing an HTCondor ClassAd expression for removing jobs.
613 """
614 # Job ClassAds attributes 'HoldReasonCode' and 'HoldReasonSubCode'
615 # are UNDEFINED if job is not HELD (i.e. when 'JobStatus' is not 5).
616 # The special comparison operators ensure that all comparisons below
617 # will evaluate to FALSE in this case.
618 #
619 # Note:
620 # May not be strictly necessary. Operators '&&' and '||' are not
621 # strict so the entire expression should evaluate to FALSE when the
622 # job is not HELD. According to ClassAd evaluation semantics
623 # FALSE && UNDEFINED is FALSE, but better safe than sorry.
624 is_held = "JobStatus == 5"
625 is_retry_disallowed = "NumJobStarts > JobMaxRetries"
627 mem_expr = ""
628 if memory and multiplier and multiplier > 1 and limit:
629 mem_limit_expr = f"min({{int({memory} * pow({multiplier}, NumJobStarts - 1)), {limit}}}) == {limit}"
631 mem_expr = ( # Add || here so only added if adding memory expr
632 " || ((HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 "
633 f"|| HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34) && {mem_limit_expr})"
634 )
636 expr = f"{is_held} && ({is_retry_disallowed}{mem_expr})"
637 return expr
640def _create_request_memory_expr(memory, multiplier, limit):
641 """Construct an HTCondor ClassAd expression for safe memory scaling.
643 Parameters
644 ----------
645 memory : `int`
646 Requested memory in MB.
647 multiplier : `float`
648 Memory growth rate between retries.
649 limit : `int`
650 Memory limit.
652 Returns
653 -------
654 expr : `str`
655 A string representing an HTCondor ClassAd expression enabling safe
656 memory scaling between job retries.
657 """
658 # The check if the job was held due to exceeding memory requirements
659 # will be made *after* job was released back to the job queue (is in
660 # the IDLE state), hence the need to use `Last*` job ClassAds instead of
661 # the ones describing job's current state.
662 #
663 # Also, 'Last*' job ClassAds attributes are UNDEFINED when a job is
664 # initially put in the job queue. The special comparison operators ensure
665 # that all comparisons below will evaluate to FALSE in this case.
666 was_mem_exceeded = (
667 "LastJobStatus =?= 5 "
668 "&& (LastHoldReasonCode =?= 34 && LastHoldReasonSubCode =?= 0 "
669 "|| LastHoldReasonCode =?= 3 && LastHoldReasonSubCode =?= 34)"
670 )
672 # If job runs the first time or was held for reasons other than exceeding
673 # the memory, set the required memory to the requested value or use
674 # the memory value measured by HTCondor (MemoryUsage) depending on
675 # whichever is greater.
676 expr = (
677 f"({was_mem_exceeded}) "
678 f"? min({{int({memory} * pow({multiplier}, NumJobStarts)), {limit}}}) "
679 f": max({{{memory}, MemoryUsage ?: 0}})"
680 )
681 return expr
684def _gather_site_values(config, compute_site):
685 """Gather values specific to given site.
687 Parameters
688 ----------
689 config : `lsst.ctrl.bps.BpsConfig`
690 BPS configuration that includes necessary submit/runtime
691 information.
692 compute_site : `str`
693 Compute site name.
695 Returns
696 -------
697 site_values : `dict` [`str`, `~typing.Any`]
698 Values specific to the given site.
699 """
700 site_values = {"attrs": {}, "profile": {}}
701 search_opts = {}
702 if compute_site:
703 search_opts["curvals"] = {"curr_site": compute_site}
705 # Determine the hard limit for the memory requirement.
706 found, limit = config.search("memoryLimit", opt=search_opts)
707 if not found:
708 search_opts["default"] = DEFAULT_HTC_EXEC_PATT
709 _, patt = config.search("executeMachinesPattern", opt=search_opts)
710 del search_opts["default"]
712 # To reduce the amount of data, ignore dynamic slots (if any) as,
713 # by definition, they cannot have more memory than
714 # the partitionable slot they are the part of.
715 constraint = f'SlotType != "Dynamic" && regexp("{patt}", Machine)'
716 pool_info = condor_status(constraint=constraint)
717 try:
718 limit = max(int(info["TotalSlotMemory"]) for info in pool_info.values())
719 except ValueError:
720 _LOG.debug("No execute machine in the pool matches %s", patt)
721 if limit:
722 config[".bps_defined.memory_limit"] = limit
724 _, site_values["bpsUseShared"] = config.search("bpsUseShared", opt={"default": False})
725 site_values["memoryLimit"] = limit
727 found, value = config.search("accountingGroup", opt=search_opts)
728 if found:
729 site_values["accountingGroup"] = value
730 found, value = config.search("accountingUser", opt=search_opts)
731 if found:
732 site_values["accountingUser"] = value
734 found, nodeset = config.search("nodeset", opt=search_opts)
735 if found:
736 site_values["nodeset"] = nodeset
738 searchobj = config[f".site.{compute_site}.profile.condor"]
739 if searchobj:
740 search_opts["searchobj"] = searchobj
741 search_opts["replaceVars"] = True
742 for key in searchobj:
743 if key.startswith("+"):
744 _, val = config.search(key, opt=search_opts)
745 site_values["attrs"][key[1:]] = val
746 else:
747 _, val = config.search(key, opt=search_opts)
748 site_values["profile"][key] = val
750 _LOG.debug("site_values = %s", site_values)
751 return site_values
754def _gather_label_values(config: BpsConfig, label: str) -> dict[str, Any]:
755 """Gather values specific to given job label.
757 Parameters
758 ----------
759 config : `lsst.ctrl.bps.BpsConfig`
760 BPS configuration that includes necessary submit/runtime
761 information.
762 label : `str`
763 GenericWorkflowJob label.
765 Returns
766 -------
767 values : `dict` [`str`, `~typing.Any`]
768 Values specific to the given job label.
769 """
770 values: dict[str, Any] = {"attrs": {}, "profile": {}}
772 search_opts = {}
773 profile_key = ""
774 if label == "finalJob" and "finalJob" in config:
775 search_opts["searchobj"] = config["finalJob"]
776 profile_key = ".finalJob.profile.condor"
777 elif label in config["cluster"]:
778 search_opts["curvals"] = {"curr_cluster": label}
779 profile_key = f".cluster.{label}.profile.condor"
780 elif label in config["pipetask"]:
781 search_opts["curvals"] = {"curr_pipetask": label}
782 profile_key = f".pipetask.{label}.profile.condor"
784 found, value = config.search("releaseExpr", opt=search_opts)
785 if found:
786 values["releaseExpr"] = value
788 found, value = config.search("overwriteJobFiles", opt=search_opts)
789 if found:
790 values["overwriteJobFiles"] = value
791 else:
792 values["overwriteJobFiles"] = True
794 if profile_key and profile_key in config:
795 for subkey, val in config[profile_key].items():
796 if subkey.startswith("+"):
797 values["attrs"][subkey[1:]] = val
798 else:
799 values["profile"][subkey] = val
801 return values
804def _group_to_subdag(
805 config: BpsConfig, generic_workflow_group: GenericWorkflowGroup, out_prefix: str
806) -> HTCJob:
807 """Convert a generic workflow group to an HTCondor dag.
809 Parameters
810 ----------
811 config : `lsst.ctrl.bps.BpsConfig`
812 Workflow configuration.
813 generic_workflow_group : `lsst.ctrl.bps.GenericWorkflowGroup`
814 The generic workflow group to convert.
815 out_prefix : `str`
816 Location prefix to be used when creating jobs.
818 Returns
819 -------
820 htc_job : `lsst.ctrl.bps.htcondor.HTCJob`
821 Job for running the HTCondor dag.
822 """
823 jobname = f"wms_{generic_workflow_group.name}"
824 htc_job = HTCJob(name=jobname, label=generic_workflow_group.label)
825 htc_job.add_dag_cmds({"dir": f"subdags/{jobname}"})
826 htc_job.subdag = _generic_workflow_to_htcondor_dag(config, generic_workflow_group, out_prefix)
827 if not generic_workflow_group.blocking:
828 htc_job.dagcmds["post"] = {
829 "defer": "",
830 "executable": f"{os.path.dirname(__file__)}/subdag_post.sh",
831 "arguments": f"{jobname} $RETURN",
832 }
833 return htc_job
836def _create_check_job(group_job_name: str, job_label: str) -> HTCJob:
837 """Create a job to check status of a group job.
839 Parameters
840 ----------
841 group_job_name : `str`
842 Name of the group job.
843 job_label : `str`
844 Label to use for the check status job.
846 Returns
847 -------
848 htc_job : `lsst.ctrl.bps.htcondor.HTCJob`
849 Job description for the job to check group job status.
850 """
851 htc_job = HTCJob(name=f"wms_check_status_{group_job_name}", label=job_label)
852 htc_job.subfile = "${CTRL_BPS_HTCONDOR_DIR}/python/lsst/ctrl/bps/htcondor/check_group_status.sub"
853 htc_job.add_dag_cmds({"dir": f"subdags/{group_job_name}", "vars": {"group_job_name": group_job_name}})
855 return htc_job
858def _generic_workflow_to_htcondor_dag(
859 config: BpsConfig, generic_workflow: GenericWorkflow, out_prefix: str
860) -> HTCDag:
861 """Convert a GenericWorkflow to a HTCDag.
863 Parameters
864 ----------
865 config : `lsst.ctrl.bps.BpsConfig`
866 Workflow configuration.
867 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
868 The GenericWorkflow to convert.
869 out_prefix : `str`
870 Location prefix where the HTCondor files will be written.
872 Returns
873 -------
874 dag : `lsst.ctrl.bps.htcondor.HTCDag`
875 The HTCDag representation of the given GenericWorkflow.
876 """
877 dag = HTCDag(name=generic_workflow.name)
879 _LOG.debug("htcondor dag attribs %s", generic_workflow.run_attrs)
880 dag.add_attribs(generic_workflow.run_attrs)
881 dag.add_attribs(
882 {
883 "bps_run_quanta": create_count_summary(generic_workflow.quanta_counts),
884 "bps_job_summary": create_count_summary(generic_workflow.job_counts),
885 }
886 )
888 _, tmp_template = config.search("subDirTemplate", opt={"replaceVars": False, "default": ""})
889 if isinstance(tmp_template, str):
890 subdir_template = defaultdict(lambda: tmp_template)
891 else:
892 subdir_template = tmp_template
894 # Create all DAG jobs
895 site_values = {} # Cache compute site specific values to reduce config lookups.
896 cached_values = {} # Cache label-specific values to reduce config lookups.
897 # Note: Can't use get_job_by_label because those only include payload jobs.
898 for job_name in generic_workflow:
899 gwjob = generic_workflow.get_job(job_name)
900 if gwjob.node_type == GenericWorkflowNodeType.PAYLOAD:
901 gwjob = cast(GenericWorkflowJob, gwjob)
902 if gwjob.compute_site not in site_values:
903 site_values[gwjob.compute_site] = _gather_site_values(config, gwjob.compute_site)
904 if gwjob.label not in cached_values:
905 cached_values[gwjob.label] = deepcopy(site_values[gwjob.compute_site])
906 _update_dicts(cached_values[gwjob.label], _gather_label_values(config, gwjob.label))
907 _LOG.debug("cached: %s= %s", gwjob.label, cached_values[gwjob.label])
908 htc_job = _create_job(
909 subdir_template[gwjob.label],
910 cached_values[gwjob.label],
911 generic_workflow,
912 gwjob,
913 out_prefix,
914 )
915 elif gwjob.node_type == GenericWorkflowNodeType.NOOP:
916 gwjob = cast(GenericWorkflowNoopJob, gwjob)
917 htc_job = HTCJob(f"wms_{gwjob.name}", label=gwjob.label)
918 htc_job.subfile = "${CTRL_BPS_HTCONDOR_DIR}/python/lsst/ctrl/bps/htcondor/noop.sub"
919 htc_job.add_job_attrs({"bps_job_name": gwjob.name, "bps_job_label": gwjob.label})
920 htc_job.add_dag_cmds({"noop": True})
921 elif gwjob.node_type == GenericWorkflowNodeType.GROUP:
922 gwjob = cast(GenericWorkflowGroup, gwjob)
923 htc_job = _group_to_subdag(config, gwjob, out_prefix)
924 else:
925 raise RuntimeError(f"Unsupported generic workflow node type {gwjob.node_type} ({gwjob.name})")
926 _LOG.debug("Calling adding job %s %s", htc_job.name, htc_job.label)
927 dag.add_job(htc_job)
929 # Add job dependencies to the DAG (be careful with wms_ jobs)
930 for job_name in generic_workflow:
931 gwjob = generic_workflow.get_job(job_name)
932 parent_name = (
933 gwjob.name if gwjob.node_type == GenericWorkflowNodeType.PAYLOAD else f"wms_{gwjob.name}"
934 )
935 successor_jobs = [generic_workflow.get_job(j) for j in generic_workflow.successors(job_name)]
936 children_names = []
937 if gwjob.node_type == GenericWorkflowNodeType.GROUP:
938 gwjob = cast(GenericWorkflowGroup, gwjob)
939 group_children = [] # Dependencies between same group jobs
940 for sjob in successor_jobs:
941 if sjob.node_type == GenericWorkflowNodeType.GROUP and sjob.label == gwjob.label:
942 group_children.append(f"wms_{sjob.name}")
943 elif sjob.node_type == GenericWorkflowNodeType.PAYLOAD:
944 children_names.append(sjob.name)
945 else:
946 children_names.append(f"wms_{sjob.name}")
947 if group_children:
948 dag.add_job_relationships([parent_name], group_children)
949 if not gwjob.blocking:
950 # Since subdag will always succeed, need to add a special
951 # job that fails if group failed to block payload children.
952 check_job = _create_check_job(f"wms_{gwjob.name}", gwjob.label)
953 dag.add_job(check_job)
954 dag.add_job_relationships([f"wms_{gwjob.name}"], [check_job.name])
955 parent_name = check_job.name
956 else:
957 for sjob in successor_jobs:
958 if sjob.node_type == GenericWorkflowNodeType.PAYLOAD:
959 children_names.append(sjob.name)
960 else:
961 children_names.append(f"wms_{sjob.name}")
963 dag.add_job_relationships([parent_name], children_names)
965 # If final job exists in generic workflow, create DAG final job
966 final = generic_workflow.get_final()
967 if final and isinstance(final, GenericWorkflowJob):
968 if final.compute_site and final.compute_site not in site_values:
969 site_values[final.compute_site] = _gather_site_values(config, final.compute_site)
970 if final.label not in cached_values:
971 cached_values[final.label] = deepcopy(site_values[final.compute_site])
972 _update_dicts(cached_values[final.label], _gather_label_values(config, final.label))
973 final_htjob = _create_job(
974 subdir_template[final.label],
975 cached_values[final.label],
976 generic_workflow,
977 final,
978 out_prefix,
979 )
980 if "post" not in final_htjob.dagcmds:
981 final_htjob.dagcmds["post"] = {
982 "defer": "",
983 "executable": f"{os.path.dirname(__file__)}/final_post.sh",
984 "arguments": f"{final.name} $DAG_STATUS $RETURN",
985 }
986 dag.add_final_job(final_htjob)
987 elif final and isinstance(final, GenericWorkflow):
988 raise NotImplementedError("HTCondor plugin does not support a workflow as the final job")
989 elif final:
990 raise TypeError(f"Invalid type for GenericWorkflow.get_final() results ({type(final)})")
992 return dag