Coverage for python / lsst / ctrl / bps / htcondor / prepare_utils.py: 5%
361 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-07 01:19 -0800
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-07 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 = {
310 "abort_on_value": "abort_dag_on",
311 "abort_return_value": "abort_exit",
312 "priority": "priority",
313 }
315 dagcmds = {}
316 for gwkey, htckey in dag_translation.items():
317 dagcmds[htckey] = getattr(gwjob, gwkey, None)
319 # Still to be coded: vars "pre_cmdline", "post_cmdline"
320 return dagcmds
323def _fix_env_var_syntax(oldstr):
324 """Change ENV place holders to HTCondor Env var syntax.
326 Parameters
327 ----------
328 oldstr : `str`
329 String in which environment variable syntax is to be fixed.
331 Returns
332 -------
333 newstr : `str`
334 Given string with environment variable syntax fixed.
335 """
336 newstr = oldstr
337 for key in re.findall(r"<ENV:([^>]+)>", oldstr):
338 newstr = newstr.replace(rf"<ENV:{key}>", f"$ENV({key})")
339 return newstr
342def _replace_file_vars(use_shared, arguments, workflow, gwjob):
343 """Replace file placeholders in command line arguments with correct
344 physical file names.
346 Parameters
347 ----------
348 use_shared : `bool`
349 Whether HTCondor can assume shared filesystem.
350 arguments : `str`
351 Arguments string in which to replace file placeholders.
352 workflow : `lsst.ctrl.bps.GenericWorkflow`
353 Generic workflow that contains file information.
354 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
355 The job corresponding to the arguments.
357 Returns
358 -------
359 arguments : `str`
360 Given arguments string with file placeholders replaced.
361 """
362 # Replace input file placeholders with paths.
363 for gwfile in workflow.get_job_inputs(gwjob.name, data=True, transfer_only=False):
364 if not gwfile.wms_transfer:
365 # Must assume full URI if in command line and told WMS is not
366 # responsible for transferring file.
367 uri = gwfile.src_uri
368 elif use_shared:
369 if gwfile.job_shared:
370 # Have shared filesystems and jobs can share file.
371 uri = gwfile.src_uri
372 else:
373 uri = os.path.basename(gwfile.src_uri)
374 else: # Using push transfer
375 uri = os.path.basename(gwfile.src_uri)
376 arguments = arguments.replace(f"<FILE:{gwfile.name}>", uri)
378 # Replace output file placeholders with paths.
379 for gwfile in workflow.get_job_outputs(gwjob.name, data=True, transfer_only=False):
380 if not gwfile.wms_transfer:
381 # Must assume full URI if in command line and told WMS is not
382 # responsible for transferring file.
383 uri = gwfile.src_uri
384 elif use_shared:
385 if gwfile.job_shared:
386 # Have shared filesystems and jobs can share file.
387 uri = gwfile.src_uri
388 else:
389 uri = os.path.basename(gwfile.src_uri)
390 else: # Using push transfer
391 uri = os.path.basename(gwfile.src_uri)
392 arguments = arguments.replace(f"<FILE:{gwfile.name}>", uri)
393 return arguments
396def _replace_cmd_vars(arguments, gwjob):
397 """Replace format-style placeholders in arguments.
399 Parameters
400 ----------
401 arguments : `str`
402 Arguments string in which to replace placeholders.
403 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
404 Job containing values to be used to replace placeholders
405 (in particular gwjob.cmdvals).
407 Returns
408 -------
409 arguments : `str`
410 Given arguments string with placeholders replaced.
411 """
412 replacements = gwjob.cmdvals if gwjob.cmdvals is not None else {}
413 try:
414 arguments = arguments.format(**replacements)
415 except (KeyError, TypeError) as exc: # TypeError in case None instead of {}
416 _LOG.error("Could not replace command variables: replacement for %s not provided", str(exc))
417 _LOG.debug("arguments: %s\ncmdvals: %s", arguments, replacements)
418 raise
419 return arguments
422def _handle_job_inputs(
423 generic_workflow: GenericWorkflow, job_name: str, use_shared: bool, out_prefix: str
424) -> dict[str, str]:
425 """Add job input files from generic workflow to job.
427 Parameters
428 ----------
429 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
430 The generic workflow (e.g., has executable name and arguments).
431 job_name : `str`
432 Unique name for the job.
433 use_shared : `bool`
434 Whether job has access to files via shared filesystem.
435 out_prefix : `str`
436 The root directory into which all WMS-specific files are written.
438 Returns
439 -------
440 htc_commands : `dict` [`str`, `str`]
441 HTCondor commands for the job submission script.
442 """
443 inputs = []
444 for gwf_file in generic_workflow.get_job_inputs(job_name, data=True, transfer_only=True):
445 _LOG.debug("src_uri=%s", gwf_file.src_uri)
447 uri = Path(gwf_file.src_uri)
449 # Note if use_shared and job_shared, don't need to transfer file.
451 if not use_shared: # Copy file using push to job
452 inputs.append(str(uri))
453 elif not gwf_file.job_shared: # Jobs require own copy
454 # if using shared filesystem, but still need copy in job. Use
455 # HTCondor's curl plugin for a local copy.
456 if uri.is_dir():
457 raise RuntimeError(
458 f"HTCondor plugin cannot transfer directories locally within job {gwf_file.src_uri}"
459 )
460 inputs.append(f"file://{uri}")
462 htc_commands = {}
463 if inputs:
464 htc_commands["transfer_input_files"] = ",".join(inputs)
465 _LOG.debug("transfer_input_files=%s", htc_commands["transfer_input_files"])
466 return htc_commands
469def _handle_job_outputs(
470 generic_workflow: GenericWorkflow, job_name: str, use_shared: bool, out_prefix: str
471) -> dict[str, str]:
472 """Add job output files from generic workflow to the job if any.
474 Parameters
475 ----------
476 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
477 The generic workflow (e.g., has executable name and arguments).
478 job_name : `str`
479 Unique name for the job.
480 use_shared : `bool`
481 Whether job has access to files via shared filesystem.
482 out_prefix : `str`
483 The root directory into which all WMS-specific files are written.
485 Returns
486 -------
487 htc_commands : `dict` [`str`, `str`]
488 HTCondor commands for the job submission script.
489 """
490 outputs = []
491 output_remaps = []
492 for gwf_file in generic_workflow.get_job_outputs(job_name, data=True, transfer_only=True):
493 _LOG.debug("src_uri=%s", gwf_file.src_uri)
495 uri = Path(gwf_file.src_uri)
496 if not use_shared:
497 outputs.append(uri.name)
498 output_remaps.append(f"{uri.name}={str(uri)}")
500 # Set to an empty string to disable and only update if there are output
501 # files to transfer. Otherwise, HTCondor will transfer back all files in
502 # the job’s temporary working directory that have been modified or created
503 # by the job.
504 htc_commands = {"transfer_output_files": '""'}
505 if outputs:
506 htc_commands["transfer_output_files"] = ",".join(outputs)
507 _LOG.debug("transfer_output_files=%s", htc_commands["transfer_output_files"])
509 htc_commands["transfer_output_remaps"] = f'"{";".join(output_remaps)}"'
510 _LOG.debug("transfer_output_remaps=%s", htc_commands["transfer_output_remaps"])
511 return htc_commands
514def _create_periodic_release_expr(
515 memory: int, multiplier: float | None, limit: int, additional_expr: str = ""
516) -> str:
517 """Construct an HTCondorAd expression for releasing held jobs.
519 Parameters
520 ----------
521 memory : `int`
522 Requested memory in MB.
523 multiplier : `float` or None
524 Memory growth rate between retries.
525 limit : `int`
526 Memory limit.
527 additional_expr : `str`, optional
528 Expression to add to periodic_release. Defaults to empty string.
530 Returns
531 -------
532 expr : `str`
533 A string representing an HTCondor ClassAd expression for releasing job.
534 """
535 _LOG.debug(
536 "periodic_release: memory: %s, multiplier: %s, limit: %s, additional_expr: %s",
537 memory,
538 multiplier,
539 limit,
540 additional_expr,
541 )
543 # ctrl_bps sets multiplier to None in the GenericWorkflow if
544 # memoryMultiplier <= 1, but checking value just in case.
545 if (not multiplier or multiplier <= 1) and not additional_expr:
546 return ""
548 # Job ClassAds attributes 'HoldReasonCode' and 'HoldReasonSubCode' are
549 # UNDEFINED if job is not HELD (i.e. when 'JobStatus' is not 5).
550 # The special comparison operators ensure that all comparisons below will
551 # evaluate to FALSE in this case.
552 #
553 # Note:
554 # May not be strictly necessary. Operators '&&' and '||' are not strict so
555 # the entire expression should evaluate to FALSE when the job is not HELD.
556 # According to ClassAd evaluation semantics FALSE && UNDEFINED is FALSE,
557 # but better safe than sorry.
558 is_held = "JobStatus == 5"
559 is_retry_allowed = "NumJobStarts <= JobMaxRetries"
561 mem_expr = ""
562 if memory and multiplier and multiplier > 1 and limit:
563 was_mem_exceeded = (
564 "(HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 "
565 "|| HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34)"
566 )
567 was_below_limit = f"min({{int({memory} * pow({multiplier}, NumJobStarts - 1)), {limit}}}) < {limit}"
568 mem_expr = f"{was_mem_exceeded} && {was_below_limit}"
570 user_expr = ""
571 if additional_expr:
572 # Never auto release a job held by user.
573 user_expr = f"HoldReasonCode =!= 1 && {additional_expr}"
575 expr = f"{is_held} && {is_retry_allowed}"
576 if user_expr and mem_expr:
577 expr += f" && ({mem_expr} || {user_expr})"
578 elif user_expr:
579 expr += f" && {user_expr}"
580 elif mem_expr:
581 expr += f" && {mem_expr}"
583 return expr
586def _create_periodic_remove_expr(memory, multiplier, limit):
587 """Construct an HTCondorAd expression for removing jobs from the queue.
589 Parameters
590 ----------
591 memory : `int`
592 Requested memory in MB.
593 multiplier : `float`
594 Memory growth rate between retries.
595 limit : `int`
596 Memory limit.
598 Returns
599 -------
600 expr : `str`
601 A string representing an HTCondor ClassAd expression for removing jobs.
602 """
603 # Job ClassAds attributes 'HoldReasonCode' and 'HoldReasonSubCode'
604 # are UNDEFINED if job is not HELD (i.e. when 'JobStatus' is not 5).
605 # The special comparison operators ensure that all comparisons below
606 # will evaluate to FALSE in this case.
607 #
608 # Note:
609 # May not be strictly necessary. Operators '&&' and '||' are not
610 # strict so the entire expression should evaluate to FALSE when the
611 # job is not HELD. According to ClassAd evaluation semantics
612 # FALSE && UNDEFINED is FALSE, but better safe than sorry.
613 is_held = "JobStatus == 5"
614 is_retry_disallowed = "NumJobStarts > JobMaxRetries"
616 mem_expr = ""
617 if memory and multiplier and multiplier > 1 and limit:
618 mem_limit_expr = f"min({{int({memory} * pow({multiplier}, NumJobStarts - 1)), {limit}}}) == {limit}"
620 mem_expr = ( # Add || here so only added if adding memory expr
621 " || ((HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 "
622 f"|| HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34) && {mem_limit_expr})"
623 )
625 expr = f"{is_held} && ({is_retry_disallowed}{mem_expr})"
626 return expr
629def _create_request_memory_expr(memory, multiplier, limit):
630 """Construct an HTCondor ClassAd expression for safe memory scaling.
632 Parameters
633 ----------
634 memory : `int`
635 Requested memory in MB.
636 multiplier : `float`
637 Memory growth rate between retries.
638 limit : `int`
639 Memory limit.
641 Returns
642 -------
643 expr : `str`
644 A string representing an HTCondor ClassAd expression enabling safe
645 memory scaling between job retries.
646 """
647 # The check if the job was held due to exceeding memory requirements
648 # will be made *after* job was released back to the job queue (is in
649 # the IDLE state), hence the need to use `Last*` job ClassAds instead of
650 # the ones describing job's current state.
651 #
652 # Also, 'Last*' job ClassAds attributes are UNDEFINED when a job is
653 # initially put in the job queue. The special comparison operators ensure
654 # that all comparisons below will evaluate to FALSE in this case.
655 was_mem_exceeded = (
656 "LastJobStatus =?= 5 "
657 "&& (LastHoldReasonCode =?= 34 && LastHoldReasonSubCode =?= 0 "
658 "|| LastHoldReasonCode =?= 3 && LastHoldReasonSubCode =?= 34)"
659 )
661 # If job runs the first time or was held for reasons other than exceeding
662 # the memory, set the required memory to the requested value or use
663 # the memory value measured by HTCondor (MemoryUsage) depending on
664 # whichever is greater.
665 expr = (
666 f"({was_mem_exceeded}) "
667 f"? min({{int({memory} * pow({multiplier}, NumJobStarts)), {limit}}}) "
668 f": max({{{memory}, MemoryUsage ?: 0}})"
669 )
670 return expr
673def _gather_site_values(config, compute_site):
674 """Gather values specific to given site.
676 Parameters
677 ----------
678 config : `lsst.ctrl.bps.BpsConfig`
679 BPS configuration that includes necessary submit/runtime
680 information.
681 compute_site : `str`
682 Compute site name.
684 Returns
685 -------
686 site_values : `dict` [`str`, `~typing.Any`]
687 Values specific to the given site.
688 """
689 site_values = {"attrs": {}, "profile": {}}
690 search_opts = {}
691 if compute_site:
692 search_opts["curvals"] = {"curr_site": compute_site}
694 # Determine the hard limit for the memory requirement.
695 found, limit = config.search("memoryLimit", opt=search_opts)
696 if not found:
697 search_opts["default"] = DEFAULT_HTC_EXEC_PATT
698 _, patt = config.search("executeMachinesPattern", opt=search_opts)
699 del search_opts["default"]
701 # To reduce the amount of data, ignore dynamic slots (if any) as,
702 # by definition, they cannot have more memory than
703 # the partitionable slot they are the part of.
704 constraint = f'SlotType != "Dynamic" && regexp("{patt}", Machine)'
705 pool_info = condor_status(constraint=constraint)
706 try:
707 limit = max(int(info["TotalSlotMemory"]) for info in pool_info.values())
708 except ValueError:
709 _LOG.debug("No execute machine in the pool matches %s", patt)
710 if limit:
711 config[".bps_defined.memory_limit"] = limit
713 _, site_values["bpsUseShared"] = config.search("bpsUseShared", opt={"default": False})
714 site_values["memoryLimit"] = limit
716 found, value = config.search("accountingGroup", opt=search_opts)
717 if found:
718 site_values["accountingGroup"] = value
719 found, value = config.search("accountingUser", opt=search_opts)
720 if found:
721 site_values["accountingUser"] = value
723 key = f".site.{compute_site}.profile.condor"
724 if key in config:
725 for subkey, val in config[key].items():
726 if subkey.startswith("+"):
727 site_values["attrs"][subkey[1:]] = val
728 else:
729 site_values["profile"][subkey] = val
731 return site_values
734def _gather_label_values(config: BpsConfig, label: str) -> dict[str, Any]:
735 """Gather values specific to given job label.
737 Parameters
738 ----------
739 config : `lsst.ctrl.bps.BpsConfig`
740 BPS configuration that includes necessary submit/runtime
741 information.
742 label : `str`
743 GenericWorkflowJob label.
745 Returns
746 -------
747 values : `dict` [`str`, `~typing.Any`]
748 Values specific to the given job label.
749 """
750 values: dict[str, Any] = {"attrs": {}, "profile": {}}
752 search_opts = {}
753 profile_key = ""
754 if label == "finalJob":
755 search_opts["searchobj"] = config["finalJob"]
756 profile_key = ".finalJob.profile.condor"
757 elif label in config["cluster"]:
758 search_opts["curvals"] = {"curr_cluster": label}
759 profile_key = f".cluster.{label}.profile.condor"
760 elif label in config["pipetask"]:
761 search_opts["curvals"] = {"curr_pipetask": label}
762 profile_key = f".pipetask.{label}.profile.condor"
764 found, value = config.search("releaseExpr", opt=search_opts)
765 if found:
766 values["releaseExpr"] = value
768 found, value = config.search("overwriteJobFiles", opt=search_opts)
769 if found:
770 values["overwriteJobFiles"] = value
771 else:
772 values["overwriteJobFiles"] = True
774 if profile_key and profile_key in config:
775 for subkey, val in config[profile_key].items():
776 if subkey.startswith("+"):
777 values["attrs"][subkey[1:]] = val
778 else:
779 values["profile"][subkey] = val
781 return values
784def _group_to_subdag(
785 config: BpsConfig, generic_workflow_group: GenericWorkflowGroup, out_prefix: str
786) -> HTCJob:
787 """Convert a generic workflow group to an HTCondor dag.
789 Parameters
790 ----------
791 config : `lsst.ctrl.bps.BpsConfig`
792 Workflow configuration.
793 generic_workflow_group : `lsst.ctrl.bps.GenericWorkflowGroup`
794 The generic workflow group to convert.
795 out_prefix : `str`
796 Location prefix to be used when creating jobs.
798 Returns
799 -------
800 htc_job : `lsst.ctrl.bps.htcondor.HTCJob`
801 Job for running the HTCondor dag.
802 """
803 jobname = f"wms_{generic_workflow_group.name}"
804 htc_job = HTCJob(name=jobname, label=generic_workflow_group.label)
805 htc_job.add_dag_cmds({"dir": f"subdags/{jobname}"})
806 htc_job.subdag = _generic_workflow_to_htcondor_dag(config, generic_workflow_group, out_prefix)
807 if not generic_workflow_group.blocking:
808 htc_job.dagcmds["post"] = {
809 "defer": "",
810 "executable": f"{os.path.dirname(__file__)}/subdag_post.sh",
811 "arguments": f"{jobname} $RETURN",
812 }
813 return htc_job
816def _create_check_job(group_job_name: str, job_label: str) -> HTCJob:
817 """Create a job to check status of a group job.
819 Parameters
820 ----------
821 group_job_name : `str`
822 Name of the group job.
823 job_label : `str`
824 Label to use for the check status job.
826 Returns
827 -------
828 htc_job : `lsst.ctrl.bps.htcondor.HTCJob`
829 Job description for the job to check group job status.
830 """
831 htc_job = HTCJob(name=f"wms_check_status_{group_job_name}", label=job_label)
832 htc_job.subfile = "${CTRL_BPS_HTCONDOR_DIR}/python/lsst/ctrl/bps/htcondor/check_group_status.sub"
833 htc_job.add_dag_cmds({"dir": f"subdags/{group_job_name}", "vars": {"group_job_name": group_job_name}})
835 return htc_job
838def _generic_workflow_to_htcondor_dag(
839 config: BpsConfig, generic_workflow: GenericWorkflow, out_prefix: str
840) -> HTCDag:
841 """Convert a GenericWorkflow to a HTCDag.
843 Parameters
844 ----------
845 config : `lsst.ctrl.bps.BpsConfig`
846 Workflow configuration.
847 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
848 The GenericWorkflow to convert.
849 out_prefix : `str`
850 Location prefix where the HTCondor files will be written.
852 Returns
853 -------
854 dag : `lsst.ctrl.bps.htcondor.HTCDag`
855 The HTCDag representation of the given GenericWorkflow.
856 """
857 dag = HTCDag(name=generic_workflow.name)
859 _LOG.debug("htcondor dag attribs %s", generic_workflow.run_attrs)
860 dag.add_attribs(generic_workflow.run_attrs)
861 dag.add_attribs(
862 {
863 "bps_run_quanta": create_count_summary(generic_workflow.quanta_counts),
864 "bps_job_summary": create_count_summary(generic_workflow.job_counts),
865 }
866 )
868 _, tmp_template = config.search("subDirTemplate", opt={"replaceVars": False, "default": ""})
869 if isinstance(tmp_template, str):
870 subdir_template = defaultdict(lambda: tmp_template)
871 else:
872 subdir_template = tmp_template
874 # Create all DAG jobs
875 site_values = {} # Cache compute site specific values to reduce config lookups.
876 cached_values = {} # Cache label-specific values to reduce config lookups.
877 # Note: Can't use get_job_by_label because those only include payload jobs.
878 for job_name in generic_workflow:
879 gwjob = generic_workflow.get_job(job_name)
880 if gwjob.node_type == GenericWorkflowNodeType.PAYLOAD:
881 gwjob = cast(GenericWorkflowJob, gwjob)
882 if gwjob.compute_site not in site_values:
883 site_values[gwjob.compute_site] = _gather_site_values(config, gwjob.compute_site)
884 if gwjob.label not in cached_values:
885 cached_values[gwjob.label] = deepcopy(site_values[gwjob.compute_site])
886 cached_values[gwjob.label].update(_gather_label_values(config, gwjob.label))
887 _LOG.debug("cached: %s= %s", gwjob.label, cached_values[gwjob.label])
888 htc_job = _create_job(
889 subdir_template[gwjob.label],
890 cached_values[gwjob.label],
891 generic_workflow,
892 gwjob,
893 out_prefix,
894 )
895 elif gwjob.node_type == GenericWorkflowNodeType.NOOP:
896 gwjob = cast(GenericWorkflowNoopJob, gwjob)
897 htc_job = HTCJob(f"wms_{gwjob.name}", label=gwjob.label)
898 htc_job.subfile = "${CTRL_BPS_HTCONDOR_DIR}/python/lsst/ctrl/bps/htcondor/noop.sub"
899 htc_job.add_job_attrs({"bps_job_name": gwjob.name, "bps_job_label": gwjob.label})
900 htc_job.add_dag_cmds({"noop": True})
901 elif gwjob.node_type == GenericWorkflowNodeType.GROUP:
902 gwjob = cast(GenericWorkflowGroup, gwjob)
903 htc_job = _group_to_subdag(config, gwjob, out_prefix)
904 else:
905 raise RuntimeError(f"Unsupported generic workflow node type {gwjob.node_type} ({gwjob.name})")
906 _LOG.debug("Calling adding job %s %s", htc_job.name, htc_job.label)
907 dag.add_job(htc_job)
909 # Add job dependencies to the DAG (be careful with wms_ jobs)
910 for job_name in generic_workflow:
911 gwjob = generic_workflow.get_job(job_name)
912 parent_name = (
913 gwjob.name if gwjob.node_type == GenericWorkflowNodeType.PAYLOAD else f"wms_{gwjob.name}"
914 )
915 successor_jobs = [generic_workflow.get_job(j) for j in generic_workflow.successors(job_name)]
916 children_names = []
917 if gwjob.node_type == GenericWorkflowNodeType.GROUP:
918 gwjob = cast(GenericWorkflowGroup, gwjob)
919 group_children = [] # Dependencies between same group jobs
920 for sjob in successor_jobs:
921 if sjob.node_type == GenericWorkflowNodeType.GROUP and sjob.label == gwjob.label:
922 group_children.append(f"wms_{sjob.name}")
923 elif sjob.node_type == GenericWorkflowNodeType.PAYLOAD:
924 children_names.append(sjob.name)
925 else:
926 children_names.append(f"wms_{sjob.name}")
927 if group_children:
928 dag.add_job_relationships([parent_name], group_children)
929 if not gwjob.blocking:
930 # Since subdag will always succeed, need to add a special
931 # job that fails if group failed to block payload children.
932 check_job = _create_check_job(f"wms_{gwjob.name}", gwjob.label)
933 dag.add_job(check_job)
934 dag.add_job_relationships([f"wms_{gwjob.name}"], [check_job.name])
935 parent_name = check_job.name
936 else:
937 for sjob in successor_jobs:
938 if sjob.node_type == GenericWorkflowNodeType.PAYLOAD:
939 children_names.append(sjob.name)
940 else:
941 children_names.append(f"wms_{sjob.name}")
943 dag.add_job_relationships([parent_name], children_names)
945 # If final job exists in generic workflow, create DAG final job
946 final = generic_workflow.get_final()
947 if final and isinstance(final, GenericWorkflowJob):
948 if final.compute_site and final.compute_site not in site_values:
949 site_values[final.compute_site] = _gather_site_values(config, final.compute_site)
950 if final.label not in cached_values:
951 cached_values[final.label] = deepcopy(site_values[final.compute_site])
952 cached_values[final.label].update(_gather_label_values(config, final.label))
953 final_htjob = _create_job(
954 subdir_template[final.label],
955 cached_values[final.label],
956 generic_workflow,
957 final,
958 out_prefix,
959 )
960 if "post" not in final_htjob.dagcmds:
961 final_htjob.dagcmds["post"] = {
962 "defer": "",
963 "executable": f"{os.path.dirname(__file__)}/final_post.sh",
964 "arguments": f"{final.name} $DAG_STATUS $RETURN",
965 }
966 dag.add_final_job(final_htjob)
967 elif final and isinstance(final, GenericWorkflow):
968 raise NotImplementedError("HTCondor plugin does not support a workflow as the final job")
969 elif final:
970 raise TypeError(f"Invalid type for GenericWorkflow.get_final() results ({type(final)})")
972 return dag