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