Coverage for python / lsst / ctrl / bps / parsl / workflow.py: 25%
105 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-18 01:09 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-18 01:09 -0800
1# This file is part of ctrl_bps_parsl.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org) and the LSST DESC (https://www.lsstdesc.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/>.
28import logging
29import os
30import pickle
31from collections.abc import Iterable, Mapping
33import parsl
34import parsl.config
35from parsl.app.app import bash_app
36from parsl.app.bash import BashApp
37from parsl.app.futures import Future
39from lsst.ctrl.bps import BaseWmsWorkflow, BpsConfig, GenericWorkflow, GenericWorkflowJob
41from .configuration import get_workflow_filename, set_parsl_logging
42from .job import ParslJob, get_file_paths
43from .site import SiteConfig
45__all__ = ("ParslWorkflow", "get_parsl_config")
47_log = logging.getLogger("lsst.ctrl.bps.parsl")
50def get_parsl_config(config: BpsConfig) -> parsl.config.Config:
51 """Construct parsl configuration from BPS configuration.
53 For details on the site configuration, see `SiteConfig`. For details on the
54 monitor configuration, see ``get_parsl_monitor``.
56 `SiteConfig` provides an implementation of the method ``get_parsl_config``
57 which returns a Parsl configuration with sensible defaults. Subclasses
58 of `SiteConfig` can overwrite that method to configure Parsl in a
59 way specific to the site's configuration.
61 Parameters
62 ----------
63 config : `BpsConfig`
64 BPS configuration.
66 Returns
67 -------
68 parsl_config : `parsl.config.Config`
69 Parsl configuration.
70 """
71 site = SiteConfig.from_config(config)
72 return site.get_parsl_config()
75class ParslWorkflow(BaseWmsWorkflow):
76 """Parsl-based workflow object to manage execution of workflow.
78 Parameters
79 ----------
80 name : `str`
81 Unique name of workflow.
82 config : `lsst.ctrl.bps.BpsConfig`
83 Generic workflow config.
84 path : `str`
85 Path prefix for workflow output files.
86 jobs : `dict` mapping `str` to `ParslJob`
87 Jobs to be executed.
88 parents : `dict` mapping `str` to iterable of `str`
89 Dependency tree. Keywords are job names, and values are a list of job
90 names that must be executed before the keyword job name can be
91 executed.
92 endpoints : iterable of `str`
93 Endpoints of the dependency tree. These jobs (specified by name) have
94 no children.
95 final : `ParslJob`, optional
96 Final job to be done, e.g., to merge the execution butler. This is done
97 locally.
98 """
100 def __init__(
101 self,
102 name: str,
103 config: BpsConfig,
104 path: str,
105 jobs: dict[str, ParslJob],
106 parents: Mapping[str, Iterable[str]],
107 endpoints: Iterable[str],
108 final: ParslJob | None = None,
109 ):
110 super().__init__(name, config)
112 self.path = path
113 self.bps_config = config
114 self.parsl_config = get_parsl_config(config)
115 self.site = SiteConfig.from_config(config)
116 self.dfk: parsl.DataFlowKernel | None = None # type: ignore
117 self.command_prefix = self.site.get_command_prefix()
119 # these are function decorators
120 self.apps: dict[str, BashApp] = {
121 ex.label: bash_app( # type: ignore
122 executors=[ex.label], cache=True, ignore_for_cache=["stderr", "stdout"]
123 )
124 for ex in self.parsl_config.executors
125 }
127 self.jobs = jobs
128 self.parents = parents
129 self.endpoints = endpoints
130 self.final = final
132 def __reduce__(self):
133 """Recipe for pickle"""
134 return type(self), (
135 self.name,
136 self.bps_config,
137 self.path,
138 self.jobs,
139 self.parents,
140 self.endpoints,
141 self.final,
142 )
144 @classmethod
145 def from_generic_workflow(
146 cls, config: BpsConfig, generic_workflow: GenericWorkflow, out_prefix: str, service_class: str
147 ) -> BaseWmsWorkflow:
148 """Create a ParslWorkflow object from a BPS GenericWorkflow.
150 Parameters
151 ----------
152 config : `BpsConfig`
153 Configuration of the workflow.
154 generic_workflow : `lsst.ctrl.bps.generic_workflow.GenericWorkflow`
155 Generic representation of a single workflow.
156 out_prefix : `str`
157 Prefix for workflow output files.
158 service_class : `str`
159 Full module name of WMS service class that created this workflow.
161 Returns
162 -------
163 self : `ParslWorkflow`
164 Constructed workflow.
165 """
166 # Generate list of jobs
167 jobs: dict[str, ParslJob] = {}
168 for job_name in generic_workflow:
169 generic_job = generic_workflow.get_job(job_name)
170 assert generic_job.name not in jobs
171 jobs[job_name] = ParslJob(generic_job, config, get_file_paths(generic_workflow, job_name))
173 parents = {name: set(generic_workflow.predecessors(name)) for name in jobs}
174 endpoints = [name for name in jobs if generic_workflow.out_degree(name) == 0]
176 # Add final job: execution butler merge
177 job = generic_workflow.get_final()
178 final: ParslJob | None = None
179 if job is not None:
180 assert isinstance(job, GenericWorkflowJob)
181 final = ParslJob(job, config, get_file_paths(generic_workflow, job.name))
183 return cls(generic_workflow.name, config, out_prefix, jobs, parents, endpoints, final)
185 def write(self, out_prefix: str):
186 """Write workflow state.
188 This, in combination with the parsl checkpoint files, can be used to
189 restart a workflow that was interrupted.
191 Parameters
192 ----------
193 out_prefix : `str`
194 Root directory to be used for WMS workflow inputs and outputs
195 as well as internal WMS files.
196 """
197 filename = get_workflow_filename(out_prefix)
198 _log.info("Writing workflow with ID=%s", out_prefix)
199 with open(filename, "wb") as fd:
200 pickle.dump(self, fd)
202 @classmethod
203 def read(cls, out_prefix: str) -> "ParslWorkflow":
204 """Construct from the saved workflow state.
206 Parameters
207 ----------
208 out_prefix : `str`
209 Root directory to be used for WMS workflow inputs and outputs
210 as well as internal WMS files.
212 Returns
213 -------
214 self : `ParslWorkflow`
215 Constructed workflow.
216 """
217 filename = get_workflow_filename(out_prefix)
218 with open(filename, "rb") as fd:
219 self = pickle.load(fd)
220 assert isinstance(self, cls)
221 return self
223 def run(self, block: bool = True) -> list[Future | None]:
224 """Run the workflow.
226 Parameters
227 ----------
228 block : `bool`, optional
229 Block returning from this method until the workflow is complete? If
230 `False`, jobs may still be running when this returns, and it is the
231 user's responsibility to call the ``finalize_jobs`` and
232 ``shutdown`` methods when they are complete.
234 Returns
235 -------
236 futures : `list` of `Future`
237 `Future` objects linked to the execution of the endpoint jobs.
238 """
239 futures = [self.execute(name) for name in self.endpoints]
240 if block:
241 # Calling .exception() for each future blocks returning
242 # from this method until all the jobs have executed or
243 # raised an error. This is needed for running in a
244 # non-interactive python process that would otherwise end
245 # before the futures resolve.
246 for ff in futures:
247 if ff is not None:
248 ff.exception()
249 self.shutdown()
250 self.finalize_jobs()
251 return futures
253 def execute(self, name: str) -> Future | None:
254 """Execute a job.
256 Parameters
257 ----------
258 name : `str`
259 Name of job to execute.
261 Returns
262 -------
263 future : `Future` or `None`
264 A `Future` object linked to the execution of the job, or `None` if
265 the job is being reserved to run locally.
266 """
267 if name in ("pipetaskInit", "mergeExecutionButler"):
268 # These get done outside of parsl
269 return None
270 job = self.jobs[name]
271 inputs = [self.execute(parent) for parent in self.parents[name]]
272 executors = self.parsl_config.executors
273 if len(executors) > 1:
274 label = self.site.select_executor(job)
275 else:
276 label = executors[0].label
277 return job.get_future(
278 self.apps[label],
279 [ff for ff in inputs if ff is not None],
280 self.command_prefix,
281 self.site.resource_list,
282 )
284 def load_dfk(self):
285 """Load data frame kernel.
287 This starts parsl.
288 """
289 if self.dfk is not None:
290 raise RuntimeError("Workflow has already started.")
291 set_parsl_logging(self.bps_config)
292 self.dfk = parsl.load(self.parsl_config)
294 def start(self):
295 """Start the workflow."""
296 self.initialize_jobs()
297 self.load_dfk()
299 def restart(self):
300 """Restart the workflow after interruption."""
301 self.parsl_config.checkpoint_files = parsl.utils.get_last_checkpoint()
302 self.load_dfk()
304 def shutdown(self):
305 """Shut down the workflow.
307 This stops parsl.
308 """
309 if self.dfk is None:
310 raise RuntimeError("Workflow not started.")
311 self.dfk.cleanup()
312 self.dfk = None
313 parsl.DataFlowKernelLoader.clear()
315 def initialize_jobs(self):
316 """Run initial jobs.
318 These jobs are run locally before any other jobs are submitted to
319 parsl.
321 This is used to set up the butler.
322 """
323 job = self.jobs.get("pipetaskInit", None)
324 if job is not None:
325 os.makedirs(os.path.join(self.path, "logs"))
326 job.run_local()
328 def finalize_jobs(self):
329 """Run final jobs.
331 These jobs are run locally after all other jobs are complete.
333 This is used to merge the execution butler.
334 """
335 if self.final is not None and not self.final.done:
336 self.final.run_local()