Coverage for python / lsst / ctrl / bps / parsl / workflow.py: 25%

105 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-07 01:20 -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/>. 

27 

28import logging 

29import os 

30import pickle 

31from collections.abc import Iterable, Mapping 

32 

33import parsl 

34import parsl.config 

35from parsl.app.app import bash_app 

36from parsl.app.bash import BashApp 

37from parsl.app.futures import Future 

38 

39from lsst.ctrl.bps import BaseWmsWorkflow, BpsConfig, GenericWorkflow, GenericWorkflowJob 

40 

41from .configuration import get_workflow_filename, set_parsl_logging 

42from .job import ParslJob, get_file_paths 

43from .site import SiteConfig 

44 

45__all__ = ("ParslWorkflow", "get_parsl_config") 

46 

47_log = logging.getLogger("lsst.ctrl.bps.parsl") 

48 

49 

50def get_parsl_config(config: BpsConfig) -> parsl.config.Config: 

51 """Construct parsl configuration from BPS configuration. 

52 

53 For details on the site configuration, see `SiteConfig`. For details on the 

54 monitor configuration, see ``get_parsl_monitor``. 

55 

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. 

60 

61 Parameters 

62 ---------- 

63 config : `lsst.ctrl.bps.BpsConfig` 

64 BPS configuration. 

65 

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() 

73 

74 

75class ParslWorkflow(BaseWmsWorkflow): 

76 """Parsl-based workflow object to manage execution of workflow. 

77 

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 `lsst.ctrl.bps.parsl.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 : `lsst.ctrl.bps.parsl.ParslJob`, optional 

96 Final job to be done, e.g., to merge the execution butler. This is done 

97 locally. 

98 """ 

99 

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) 

111 

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() 

118 

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 } 

126 

127 self.jobs = jobs 

128 self.parents = parents 

129 self.endpoints = endpoints 

130 self.final = final 

131 

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 ) 

143 

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. 

149 

150 Parameters 

151 ---------- 

152 config : `lsst.ctrl.bps.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. 

160 

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)) 

172 

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] 

175 

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)) 

182 

183 return cls(generic_workflow.name, config, out_prefix, jobs, parents, endpoints, final) 

184 

185 def write(self, out_prefix: str): 

186 """Write workflow state. 

187 

188 This, in combination with the parsl checkpoint files, can be used to 

189 restart a workflow that was interrupted. 

190 

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) 

201 

202 @classmethod 

203 def read(cls, out_prefix: str) -> "ParslWorkflow": 

204 """Construct from the saved workflow state. 

205 

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. 

211 

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 

222 

223 def run(self, block: bool = True) -> list[Future | None]: 

224 """Run the workflow. 

225 

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. 

233 

234 Returns 

235 ------- 

236 futures : `list` of `parsl.dataflow.futures.AppFuture` 

237 `parsl.dataflow.futures.AppFuture` objects linked to the execution 

238 of the endpoint jobs. 

239 """ 

240 futures = [self.execute(name) for name in self.endpoints] 

241 if block: 

242 # Calling .exception() for each future blocks returning 

243 # from this method until all the jobs have executed or 

244 # raised an error. This is needed for running in a 

245 # non-interactive python process that would otherwise end 

246 # before the futures resolve. 

247 for ff in futures: 

248 if ff is not None: 

249 ff.exception() 

250 self.shutdown() 

251 self.finalize_jobs() 

252 return futures 

253 

254 def execute(self, name: str) -> Future | None: 

255 """Execute a job. 

256 

257 Parameters 

258 ---------- 

259 name : `str` 

260 Name of job to execute. 

261 

262 Returns 

263 ------- 

264 future : `parsl.dataflow.futures.AppFuture` or `None` 

265 A `parsl.dataflow.futures.AppFuture` object linked to the execution 

266 of the job, or `None` if the job is being reserved to run locally. 

267 """ 

268 if name in ("pipetaskInit", "mergeExecutionButler"): 

269 # These get done outside of parsl 

270 return None 

271 job = self.jobs[name] 

272 inputs = [self.execute(parent) for parent in self.parents[name]] 

273 executors = self.parsl_config.executors 

274 if len(executors) > 1: 

275 label = self.site.select_executor(job) 

276 else: 

277 label = executors[0].label 

278 return job.get_future( 

279 self.apps[label], 

280 [ff for ff in inputs if ff is not None], 

281 self.command_prefix, 

282 self.site.resource_list, 

283 ) 

284 

285 def load_dfk(self): 

286 """Load data frame kernel. 

287 

288 This starts parsl. 

289 """ 

290 if self.dfk is not None: 

291 raise RuntimeError("Workflow has already started.") 

292 set_parsl_logging(self.bps_config) 

293 self.dfk = parsl.load(self.parsl_config) 

294 

295 def start(self): 

296 """Start the workflow.""" 

297 self.initialize_jobs() 

298 self.load_dfk() 

299 

300 def restart(self): 

301 """Restart the workflow after interruption.""" 

302 self.parsl_config.checkpoint_files = parsl.utils.get_last_checkpoint() 

303 self.load_dfk() 

304 

305 def shutdown(self): 

306 """Shut down the workflow. 

307 

308 This stops parsl. 

309 """ 

310 if self.dfk is None: 

311 raise RuntimeError("Workflow not started.") 

312 self.dfk.cleanup() 

313 self.dfk = None 

314 parsl.DataFlowKernelLoader.clear() 

315 

316 def initialize_jobs(self): 

317 """Run initial jobs. 

318 

319 These jobs are run locally before any other jobs are submitted to 

320 parsl. 

321 

322 This is used to set up the butler. 

323 """ 

324 job = self.jobs.get("pipetaskInit", None) 

325 if job is not None: 

326 os.makedirs(os.path.join(self.path, "logs")) 

327 job.run_local() 

328 

329 def finalize_jobs(self): 

330 """Run final jobs. 

331 

332 These jobs are run locally after all other jobs are complete. 

333 

334 This is used to merge the execution butler. 

335 """ 

336 if self.final is not None and not self.final.done: 

337 self.final.run_local()