Coverage for python/lsst/ctrl/bps/drivers.py: 13%
210 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 01:55 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 01:55 -0800
1# This file is part of ctrl_bps.
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 <http://www.gnu.org/licenses/>.
28"""Driver functions for each subcommand.
30Driver functions ensure that ensure all setup work is done before running
31the subcommand method.
32"""
34__all__ = [
35 "acquire_qgraph_driver",
36 "cancel_driver",
37 "cluster_qgraph_driver",
38 "ping_driver",
39 "prepare_driver",
40 "report_driver",
41 "restart_driver",
42 "status_driver",
43 "submit_driver",
44 "submitcmd_driver",
45 "transform_driver",
46]
49import logging
50import os
51from pathlib import Path
53from lsst.pipe.base.quantum_graph import PredictedQuantumGraph
54from lsst.utils.timer import time_this
55from lsst.utils.usage import get_peak_mem_usage
57from . import BPS_DEFAULTS, BPS_SEARCH_ORDER, DEFAULT_MEM_FMT, DEFAULT_MEM_UNIT, BpsConfig
58from .bps_utils import _dump_env_info, _dump_pkg_info, _make_id_link
59from .cancel import cancel
60from .construct import construct
61from .initialize import (
62 custom_job_validator,
63 init_submission,
64 out_collection_validator,
65 output_run_validator,
66 submit_path_validator,
67)
68from .ping import ping
69from .pre_transform import acquire_quantum_graph, cluster_quanta
70from .prepare import prepare
71from .report import BPS_POSTPROCESSORS, display_report, retrieve_report
72from .restart import restart
73from .status import status
74from .submit import submit
75from .transform import transform
77_LOG = logging.getLogger(__name__)
80def _init_submission_driver(config_file: str, **kwargs) -> BpsConfig:
81 """Initialize runtime environment.
83 Parameters
84 ----------
85 config_file : `str`
86 Name of the configuration file.
87 **kwargs : `~typing.Any`
88 Additional modifiers to the configuration.
90 Returns
91 -------
92 config : `lsst.ctrl.bps.BpsConfig`
93 Batch Processing Service configuration.
94 """
95 validators = [submit_path_validator, output_run_validator, out_collection_validator]
96 _LOG.info("Initializing BPS configuration and creating submit directory")
97 with time_this(
98 log=_LOG,
99 level=logging.INFO,
100 prefix=None,
101 msg="BPS configuration initialized and submit directory created",
102 mem_usage=True,
103 mem_unit=DEFAULT_MEM_UNIT,
104 mem_fmt=DEFAULT_MEM_FMT,
105 ):
106 config = init_submission(config_file, validators=validators, **kwargs)
107 _log_mem_usage()
109 submit_path = config[".bps_defined.submitPath"]
110 print(f"Submit dir: {submit_path}")
111 return config
114def acquire_qgraph_driver(config_file: str, **kwargs) -> tuple[BpsConfig, PredictedQuantumGraph]:
115 """Read a quantum graph from a file or create one from pipeline definition.
117 Parameters
118 ----------
119 config_file : `str`
120 Name of the configuration file.
121 **kwargs : `~typing.Any`
122 Additional modifiers to the configuration.
124 Returns
125 -------
126 config : `lsst.ctrl.bps.BpsConfig`
127 Updated configuration.
128 qgraph : `lsst.pipe.base.quantum_graph.PredictedQuantumGraph`
129 A graph representing quanta.
130 """
131 config = _init_submission_driver(config_file, **kwargs)
133 _LOG.info("Starting acquire stage (generating and/or reading quantum graph)")
134 submit_path = config[".bps_defined.submitPath"]
135 with time_this(
136 log=_LOG,
137 level=logging.INFO,
138 prefix=None,
139 msg="Acquire stage completed",
140 mem_usage=True,
141 mem_unit=DEFAULT_MEM_UNIT,
142 mem_fmt=DEFAULT_MEM_FMT,
143 ):
144 qgraph_file, qgraph = acquire_quantum_graph(config, out_prefix=submit_path)
145 _log_mem_usage()
147 config[".bps_defined.runQgraphFile"] = qgraph_file
148 return config, qgraph
151def cluster_qgraph_driver(config_file, **kwargs):
152 """Group quanta into clusters.
154 Parameters
155 ----------
156 config_file : `str`
157 Name of the configuration file.
158 **kwargs : `~typing.Any`
159 Additional modifiers to the configuration.
161 Returns
162 -------
163 config : `lsst.ctrl.bps.BpsConfig`
164 Updated configuration.
165 clustered_qgraph : `lsst.ctrl.bps.ClusteredQuantumGraph`
166 A graph representing clustered quanta.
167 """
168 config, qgraph = acquire_qgraph_driver(config_file, **kwargs)
170 _LOG.info("Starting cluster stage (grouping quanta into jobs)")
171 with time_this(
172 log=_LOG,
173 level=logging.INFO,
174 prefix=None,
175 msg="Cluster stage completed",
176 mem_usage=True,
177 mem_unit=DEFAULT_MEM_UNIT,
178 mem_fmt=DEFAULT_MEM_FMT,
179 ):
180 clustered_qgraph = cluster_quanta(config, qgraph, config["uniqProcName"])
181 _log_mem_usage()
183 _LOG.info("ClusteredQuantumGraph contains %d cluster(s)", len(clustered_qgraph))
185 submit_path = config[".bps_defined.submitPath"]
186 _, save_clustered_qgraph = config.search("saveClusteredQgraph", opt={"default": False})
187 if save_clustered_qgraph:
188 clustered_qgraph.save(os.path.join(submit_path, "bps_clustered_qgraph.pickle"))
189 _, save_dot = config.search("saveDot", opt={"default": False})
190 if save_dot:
191 clustered_qgraph.draw(os.path.join(submit_path, "bps_clustered_qgraph.dot"))
192 return config, clustered_qgraph
195def transform_driver(config_file, **kwargs):
196 """Create a workflow for a specific workflow management system.
198 Parameters
199 ----------
200 config_file : `str`
201 Name of the configuration file.
202 **kwargs : `~typing.Any`
203 Additional modifiers to the configuration.
205 Returns
206 -------
207 generic_workflow_config : `lsst.ctrl.bps.BpsConfig`
208 Configuration to use when creating the workflow.
209 generic_workflow : `lsst.ctrl.bps.BaseWmsWorkflow`
210 Representation of the abstract/scientific workflow specific to a given
211 workflow management system.
212 """
213 config, clustered_qgraph = cluster_qgraph_driver(config_file, **kwargs)
214 submit_path = config[".bps_defined.submitPath"]
216 _LOG.info("Starting transform stage (creating generic workflow)")
217 with time_this(
218 log=_LOG,
219 level=logging.INFO,
220 prefix=None,
221 msg="Transform stage completed",
222 mem_usage=True,
223 mem_unit=DEFAULT_MEM_UNIT,
224 mem_fmt=DEFAULT_MEM_FMT,
225 ):
226 generic_workflow, generic_workflow_config = transform(config, clustered_qgraph, submit_path)
227 _LOG.info("Generic workflow name '%s'", generic_workflow.name)
228 _log_mem_usage()
230 num_jobs = sum(generic_workflow.job_counts.values())
231 _LOG.info("GenericWorkflow contains %d job(s) (including final)", num_jobs)
233 _, save_workflow = config.search("saveGenericWorkflow", opt={"default": False})
234 if save_workflow:
235 with open(os.path.join(submit_path, "bps_generic_workflow.pickle"), "wb") as outfh:
236 generic_workflow.save(outfh, "pickle")
237 _, save_dot = config.search("saveDot", opt={"default": False})
238 if save_dot:
239 with open(os.path.join(submit_path, "bps_generic_workflow.dot"), "w") as outfh:
240 generic_workflow.draw(outfh, "dot")
241 return generic_workflow_config, generic_workflow
244def prepare_driver(config_file, **kwargs):
245 """Create a representation of the generic workflow.
247 Parameters
248 ----------
249 config_file : `str`
250 Name of the configuration file.
251 **kwargs : `~typing.Any`
252 Additional modifiers to the configuration.
254 Returns
255 -------
256 wms_config : `lsst.ctrl.bps.BpsConfig`
257 Configuration to use when creating the workflow.
258 workflow : `lsst.ctrl.bps.BaseWmsWorkflow`
259 Representation of the abstract/scientific workflow specific to a given
260 workflow management system.
261 """
262 kwargs.setdefault("runWmsSubmissionChecks", True)
263 generic_workflow_config, generic_workflow = transform_driver(config_file, **kwargs)
264 submit_path = generic_workflow_config[".bps_defined.submitPath"]
266 _LOG.info("Starting prepare stage (creating specific implementation of workflow)")
267 with time_this(
268 log=_LOG,
269 level=logging.INFO,
270 prefix=None,
271 msg="Prepare stage completed",
272 mem_usage=True,
273 mem_unit=DEFAULT_MEM_UNIT,
274 mem_fmt=DEFAULT_MEM_FMT,
275 ):
276 wms_workflow = prepare(generic_workflow_config, generic_workflow, submit_path)
277 _log_mem_usage()
279 wms_workflow_config = generic_workflow_config
280 return wms_workflow_config, wms_workflow
283def submit_driver(config_file, **kwargs):
284 """Submit workflow for execution.
286 Parameters
287 ----------
288 config_file : `str`
289 Name of the configuration file.
290 **kwargs : `~typing.Any`
291 Additional modifiers to the configuration.
292 """
293 kwargs.setdefault("runWmsSubmissionChecks", True)
295 _LOG.info(
296 "DISCLAIMER: All values regarding memory consumption reported below are approximate and may "
297 "not accurately reflect actual memory usage by the bps process."
298 )
300 remote_build = {}
301 config = BpsConfig(
302 config_file,
303 search_order=BPS_SEARCH_ORDER,
304 defaults=BPS_DEFAULTS,
305 wms_service_class_fqn=kwargs.get("wms_service"),
306 )
307 _, remote_build = config.search("remoteBuild", opt={"default": {}})
308 if remote_build:
309 if config["wmsServiceClass"] == "lsst.ctrl.bps.panda.PanDAService":
310 if not remote_build.search("enabled", opt={"default": False})[1]:
311 remote_build = {}
312 _LOG.info("The workflow is submitted to the local Data Facility.")
313 else:
314 _LOG.info(
315 "Remote submission is enabled. The workflow is submitted to a remote Data Facility."
316 )
317 _LOG.info("Initializing execution environment")
318 with time_this(
319 log=_LOG,
320 level=logging.INFO,
321 prefix=None,
322 msg="Initializing execution environment completed",
323 mem_usage=True,
324 mem_unit=DEFAULT_MEM_UNIT,
325 mem_fmt=DEFAULT_MEM_FMT,
326 ):
327 config = _init_submission_driver(config_file, **kwargs)
328 kwargs["remote_build"] = remote_build
329 kwargs["config_file"] = config_file
330 wms_workflow = None
331 else:
332 _LOG.info("The workflow is submitted to the local Data Facility.")
334 _LOG.info("Starting submission process")
335 with time_this(
336 log=_LOG,
337 level=logging.INFO,
338 prefix=None,
339 msg="Submission process completed",
340 mem_usage=True,
341 mem_unit=DEFAULT_MEM_UNIT,
342 mem_fmt=DEFAULT_MEM_FMT,
343 ):
344 if not remote_build:
345 wms_workflow_config, wms_workflow = prepare_driver(config_file, **kwargs)
346 else:
347 wms_workflow_config = config
349 _LOG.info("Starting submit stage")
350 with time_this(
351 log=_LOG,
352 level=logging.INFO,
353 prefix=None,
354 msg="Submit stage completed",
355 mem_usage=True,
356 mem_unit=DEFAULT_MEM_UNIT,
357 mem_fmt=DEFAULT_MEM_FMT,
358 ):
359 workflow = submit(wms_workflow_config, wms_workflow, **kwargs)
360 if not wms_workflow:
361 wms_workflow = workflow
362 _LOG.info("Run '%s' submitted for execution with id '%s'", wms_workflow.name, wms_workflow.run_id)
363 _log_mem_usage()
365 _make_id_link(wms_workflow_config, wms_workflow.run_id)
367 print(f"Run Id: {wms_workflow.run_id}")
368 print(f"Run Name: {wms_workflow.name}")
371def restart_driver(wms_service, run_id):
372 """Restart a failed workflow.
374 Parameters
375 ----------
376 wms_service : `str`
377 Name of the class.
378 run_id : `str`
379 Id or path of workflow that need to be restarted.
380 """
381 if wms_service is None:
382 default_config = BpsConfig({}, defaults=BPS_DEFAULTS)
383 wms_service = default_config["wmsServiceClass"]
385 new_run_id, run_name, message = restart(wms_service, run_id)
386 if new_run_id is not None:
387 path = Path(run_id)
388 if path.exists():
389 _dump_env_info(f"{run_id}/{run_name}.env.info.yaml")
390 _dump_pkg_info(f"{run_id}/{run_name}.pkg.info.yaml")
391 config = BpsConfig(f"{run_id}/{run_name}_config.yaml")
392 _make_id_link(config, new_run_id)
394 print(f"Run Id: {new_run_id}")
395 print(f"Run Name: {run_name}")
396 else:
397 if message:
398 print(f"Restart failed: {message}")
399 else:
400 print("Restart failed: Unknown error")
403def report_driver(wms_service, run_id, user, hist_days, pass_thru, is_global=False, return_exit_codes=False):
404 """Print out summary of jobs submitted for execution.
406 Parameters
407 ----------
408 wms_service : `str`
409 Name of the class.
410 run_id : `str`
411 A run id the report will be restricted to.
412 user : `str`
413 A user the report will be restricted to.
414 hist_days : `float`
415 Number of days.
416 pass_thru : `str`
417 A string to pass directly to the WMS service class.
418 is_global : `bool`, optional
419 If set, all available job queues will be queried for job information.
420 Defaults to False which means that only a local job queue will be
421 queried for information.
423 Only applicable in the context of a WMS using distributed job queues
424 (e.g., HTCondor).
425 return_exit_codes : `bool`, optional
426 If set, return exit codes related to jobs with a
427 non-success status. Defaults to False, which means that only
428 the summary state is returned.
430 Only applicable in the context of a WMS with associated
431 handlers to return exit codes from jobs.
432 """
433 if wms_service is None:
434 default_config = BpsConfig(BPS_DEFAULTS)
435 wms_service = os.environ.get("BPS_WMS_SERVICE_CLASS", default_config["wmsServiceClass"])
437 # When reporting on single run:
438 # * increase history until better mechanism for handling completed jobs is
439 # available.
440 # * massage the retrieved reports using BPS report postprocessors.
441 if run_id:
442 hist_days = max(hist_days, 2)
443 postprocessors = BPS_POSTPROCESSORS
444 else:
445 postprocessors = None
447 runs, messages = retrieve_report(
448 wms_service,
449 run_id=run_id,
450 user=user,
451 hist=hist_days,
452 pass_thru=pass_thru,
453 is_global=is_global,
454 return_exit_codes=return_exit_codes,
455 postprocessors=postprocessors,
456 )
458 if runs or messages:
459 display_report(
460 runs,
461 messages,
462 is_detailed=bool(run_id),
463 is_global=is_global,
464 return_exit_codes=return_exit_codes,
465 )
466 else:
467 if run_id:
468 print(
469 f"No records found for job id '{run_id}'. "
470 f"Hints: Double check id, retry with a larger --hist value (currently: {hist_days}), "
471 "and/or use --global to search all job queues."
472 )
475def status_driver(wms_service: str, run_id: str, hist_days: float, is_global: bool = False) -> int:
476 """Print out status of workflow submitted for execution.
478 Parameters
479 ----------
480 wms_service : `str`
481 Name of the class.
482 run_id : `str`
483 A run id the report will be restricted to.
484 hist_days : `float`
485 Number of days.
486 is_global : `bool`, optional
487 If set, all available job queues will be queried for job information.
488 Defaults to False which means that only a local job queue will be
489 queried for information.
491 Only applicable in the context of a WMS using distributed job queues
492 (e.g., HTCondor).
494 Returns
495 -------
496 state : `int`
497 Status of submitted workflow.
498 """
499 if wms_service is None:
500 default_config = BpsConfig(BPS_DEFAULTS)
501 wms_service = os.environ.get("BPS_WMS_SERVICE_CLASS", default_config["wmsServiceClass"])
503 state, message = status(
504 wms_service,
505 run_id=run_id,
506 hist=hist_days,
507 is_global=is_global,
508 )
510 _LOG.info("status: %s", state.name)
511 if message:
512 _LOG.warning(message)
514 return state.value
517def cancel_driver(wms_service, run_id, user, require_bps, pass_thru, is_global=False):
518 """Cancel submitted workflows.
520 Parameters
521 ----------
522 wms_service : `str`
523 Name of the Workload Management System service class.
524 run_id : `str`
525 ID or path of job that should be canceled.
526 user : `str`
527 User whose submitted jobs should be canceled.
528 require_bps : `bool`
529 Whether to require given run_id/user to be a bps submitted job.
530 pass_thru : `str`
531 Information to pass through to WMS.
532 is_global : `bool`, optional
533 If set, all available job queues will be checked for jobs to cancel.
534 Defaults to False which means that only a local job queue will be
535 checked.
537 Only applicable in the context of a WMS using distributed job queues
538 (e.g., HTCondor).
539 """
540 if wms_service is None:
541 default_config = BpsConfig({}, defaults=BPS_DEFAULTS)
542 wms_service = default_config["wmsServiceClass"]
543 cancel(wms_service, run_id, user, require_bps, pass_thru, is_global=is_global)
546def ping_driver(wms_service=None, pass_thru=None):
547 """Check whether WMS services are up, reachable, and any authentication,
548 if needed, succeeds.
550 The services to be checked are those needed for submit, report, cancel,
551 restart, but ping cannot guarantee whether jobs would actually run
552 successfully.
554 Parameters
555 ----------
556 wms_service : `str`, optional
557 Name of the Workload Management System service class.
558 pass_thru : `str`, optional
559 Information to pass through to WMS.
561 Returns
562 -------
563 success : `int`
564 Whether services are up and usable (0) or not (non-zero).
565 """
566 if wms_service is None:
567 default_config = BpsConfig({}, defaults=BPS_DEFAULTS)
568 wms_service = default_config["wmsServiceClass"]
569 status, message = ping(wms_service, pass_thru)
571 if message:
572 if not status:
573 _LOG.info(message)
574 else:
575 _LOG.error(message)
577 # Log overall status message
578 if not status:
579 _LOG.info("Ping successful.")
580 else:
581 _LOG.error("Ping failed (%d).", status)
583 return status
586def submitcmd_driver(config_file: str, **kwargs) -> None:
587 """Submit a command for execution.
589 Parameters
590 ----------
591 config_file : `str`
592 Name of the configuration file.
593 **kwargs : `~typing.Any`
594 Additional modifiers to the configuration.
595 """
596 validators = [submit_path_validator, custom_job_validator]
597 _LOG.info("Initializing BPS configuration and creating submit directory")
598 with time_this(
599 log=_LOG,
600 level=logging.INFO,
601 prefix=None,
602 msg="BPS configuration initialized and submit directory created",
603 mem_usage=True,
604 mem_unit=DEFAULT_MEM_UNIT,
605 mem_fmt=DEFAULT_MEM_FMT,
606 ):
607 config = init_submission(config_file, validators=validators, **kwargs)
608 _log_mem_usage()
610 submit_path = config[".bps_defined.submitPath"]
612 _LOG.info("Starting construction stage (creating generic workflow)")
613 with time_this(
614 log=_LOG,
615 level=logging.INFO,
616 prefix=None,
617 msg="Construction stage completed",
618 mem_usage=True,
619 mem_unit=DEFAULT_MEM_UNIT,
620 mem_fmt=DEFAULT_MEM_FMT,
621 ):
622 generic_workflow, generic_workflow_config = construct(config)
623 _LOG.info("Generic workflow name '%s'", generic_workflow.name)
624 _log_mem_usage()
626 _, save_workflow = config.search("saveGenericWorkflow", opt={"default": False})
627 if save_workflow:
628 with open(os.path.join(submit_path, "bps_generic_workflow.pickle"), "wb") as outfh:
629 generic_workflow.save(outfh, "pickle")
630 _, save_dot = config.search("saveDot", opt={"default": False})
631 if save_dot:
632 with open(os.path.join(submit_path, "bps_generic_workflow.dot"), "w") as outfh:
633 generic_workflow.draw(outfh, "dot")
635 _LOG.info("Starting prepare stage (creating specific implementation of workflow)")
636 with time_this(
637 log=_LOG,
638 level=logging.INFO,
639 prefix=None,
640 msg="Prepare stage completed",
641 mem_usage=True,
642 mem_unit=DEFAULT_MEM_UNIT,
643 mem_fmt=DEFAULT_MEM_FMT,
644 ):
645 wms_workflow = prepare(generic_workflow_config, generic_workflow, submit_path)
646 _log_mem_usage()
648 wms_workflow_config = generic_workflow_config
650 if kwargs.get("dry_run", False):
651 return
653 _LOG.info("Starting submit stage")
654 with time_this(
655 log=_LOG,
656 level=logging.INFO,
657 prefix=None,
658 msg="Submit stage completed",
659 mem_usage=True,
660 mem_unit=DEFAULT_MEM_UNIT,
661 mem_fmt=DEFAULT_MEM_FMT,
662 ):
663 submit(wms_workflow_config, wms_workflow, **kwargs)
664 _log_mem_usage()
665 print(f"Run Id: {wms_workflow.run_id}")
666 print(f"Run Name: {wms_workflow.name}")
669def _log_mem_usage() -> None:
670 """Log memory usage."""
671 if _LOG.isEnabledFor(logging.INFO):
672 _LOG.info(
673 "Peak memory usage for bps process %s (main), %s (largest child process)",
674 *tuple(f"{val.to(DEFAULT_MEM_UNIT):{DEFAULT_MEM_FMT}}" for val in get_peak_mem_usage()),
675 )