Coverage for python/lsst/pipe/base/separable_pipeline_executor.py: 97%

74 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-25 01:09 -0700

1# This file is part of pipe_base. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://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/>. 

27 

28 

29from __future__ import annotations 

30 

31__all__ = [ 

32 "SeparablePipelineExecutor", 

33] 

34 

35 

36import datetime 

37import getpass 

38import logging 

39from collections.abc import Iterable 

40from typing import Any 

41 

42import lsst.resources 

43from lsst.daf.butler import Butler, DatasetRef 

44from lsst.daf.butler._rubin.temporary_for_ingest import TemporaryForIngest 

45 

46from ._quantumContext import ExecutionResources 

47from .all_dimensions_quantum_graph_builder import AllDimensionsQuantumGraphBuilder 

48from .graph import QuantumGraph 

49from .mp_graph_executor import MPGraphExecutor, MPGraphExecutorError 

50from .pipeline import Pipeline 

51from .quantum_graph import PredictedQuantumGraph 

52from .quantum_graph_builder import QuantumGraphBuilder 

53from .quantum_graph_executor import QuantumGraphExecutor 

54from .single_quantum_executor import SingleQuantumExecutor 

55from .taskFactory import TaskFactory 

56 

57_LOG = logging.getLogger(__name__) 

58 

59 

60class SeparablePipelineExecutor: 

61 """An executor that allows each step of pipeline execution to be 

62 run independently. 

63 

64 The executor can run any or all of the following steps: 

65 

66 * pre-execution initialization 

67 * pipeline building 

68 * quantum graph generation 

69 * quantum graph execution 

70 

71 Any of these steps can also be handed off to external code without 

72 compromising the remaining ones. 

73 

74 Parameters 

75 ---------- 

76 butler : `lsst.daf.butler.Butler` 

77 A Butler whose ``collections`` and ``run`` attributes contain the input 

78 and output collections to use for processing. 

79 clobber_output : `bool`, optional 

80 If set, the pipeline execution overwrites existing output files. 

81 Otherwise, any conflict between existing and new outputs is an error. 

82 skip_existing_in : `~collections.abc.Iterable` [`str`], optional 

83 If not empty, the pipeline execution searches the listed collections 

84 for existing outputs, and skips any quanta that have run to completion 

85 (or have no work to do). Otherwise, all tasks are attempted (subject to 

86 ``clobber_output``). 

87 retained_dataset_types : `~collections.abc.Iterable` [`str`], optional 

88 Dataset type names or glob-style wildcard patterns for types that 

89 should be present in ``skip_existing_in`` whenever the producing task 

90 ran successfully. Dataset types not in this list are treated as not 

91 retained: when a downstream quantum must run, the builder propagates 

92 the must-run signal backward through non-retained input edges, forcing 

93 the upstream quanta that need to regenerate those intermediates to also 

94 run. Has no effect without ``skip_existing_in``. ``["*"]`` means 

95 retaining all datasets, equivalent to not providing this option. 

96 task_factory : `.TaskFactory`, optional 

97 A custom task factory for use in pre-execution and execution. By 

98 default, a new instance of `.TaskFactory` is used. 

99 resources : `.ExecutionResources` 

100 The resources available to each quantum being executed. 

101 raise_on_partial_outputs : `bool`, optional 

102 If `True` raise exceptions chained by 

103 `.AnnotatedPartialOutputsError` immediately, instead of 

104 considering the partial result a success and continuing to run 

105 downstream tasks. 

106 """ 

107 

108 def __init__( 

109 self, 

110 butler: Butler, 

111 clobber_output: bool = False, 

112 skip_existing_in: Iterable[str] | None = None, 

113 retained_dataset_types: Iterable[str] | None = None, 

114 task_factory: TaskFactory | None = None, 

115 resources: ExecutionResources | None = None, 

116 raise_on_partial_outputs: bool = True, 

117 ): 

118 self._butler = Butler.from_config( 

119 butler=butler, collections=butler.collections.defaults, run=butler.run 

120 ) 

121 if not self._butler.collections.defaults: 

122 raise ValueError("Butler must specify input collections for pipeline.") 

123 if not self._butler.run: 

124 raise ValueError("Butler must specify output run for pipeline.") 

125 

126 self._clobber_output = clobber_output 

127 self._skip_existing_in = list(skip_existing_in) if skip_existing_in else [] 

128 self._retained_dataset_types = list(retained_dataset_types) if retained_dataset_types else None 

129 

130 self._task_factory = task_factory if task_factory else TaskFactory() 

131 self.resources = resources 

132 self.raise_on_partial_outputs = raise_on_partial_outputs 

133 

134 def pre_execute_qgraph( 

135 self, 

136 graph: QuantumGraph | PredictedQuantumGraph, 

137 register_dataset_types: bool = False, 

138 save_init_outputs: bool = True, 

139 save_versions: bool = True, 

140 ) -> None: 

141 """Run pre-execution initialization. 

142 

143 This method will be deprecated after DM-38041, to be replaced with a 

144 method that takes either a `.Pipeline` or a 

145 resolved `.pipeline_graph.PipelineGraph` instead of a `.QuantumGraph`. 

146 

147 Parameters 

148 ---------- 

149 graph : `.QuantumGraph` or `.quantum_graph.PredictedQuantumGraph` 

150 The quantum graph defining the pipeline and datasets to 

151 be initialized. 

152 register_dataset_types : `bool`, optional 

153 If `True`, register all output dataset types from the pipeline 

154 represented by ``graph``. 

155 save_init_outputs : `bool`, optional 

156 If `True`, create init-output datasets in this object's output run. 

157 save_versions : `bool`, optional 

158 If `True`, save a package versions dataset. 

159 """ 

160 if register_dataset_types: 

161 graph.pipeline_graph.register_dataset_types(self._butler, include_packages=save_versions) 

162 if save_init_outputs: 

163 graph.write_init_outputs(self._butler, skip_existing=(self._butler.run in self._skip_existing_in)) 

164 graph.write_configs(self._butler) 

165 if save_versions: 

166 graph.write_packages(self._butler) 

167 

168 def make_pipeline(self, pipeline_uri: str | lsst.resources.ResourcePath) -> Pipeline: 

169 """Build a pipeline from pipeline and configuration information. 

170 

171 Parameters 

172 ---------- 

173 pipeline_uri : `str` or `lsst.resources.ResourcePath` 

174 URI to a file containing a pipeline definition. A URI fragment may 

175 be used to specify a subset of the pipeline, as described in 

176 :ref:`pipeline-running-intro`. 

177 

178 Returns 

179 ------- 

180 pipeline : `.Pipeline` 

181 The fully-built pipeline. 

182 """ 

183 return Pipeline.from_uri(pipeline_uri) 

184 

185 def make_quantum_graph_builder( 

186 self, 

187 pipeline: Pipeline, 

188 where: str = "", 

189 *, 

190 builder_class: type[QuantumGraphBuilder] = AllDimensionsQuantumGraphBuilder, 

191 **kwargs: Any, 

192 ) -> QuantumGraphBuilder: 

193 """Initialize a quantum graph builder from a pipeline and input 

194 datasets. 

195 

196 Parameters 

197 ---------- 

198 pipeline : `.Pipeline` 

199 The pipeline for which to generate a quantum graph. 

200 where : `str`, optional 

201 A data ID query that constrains the quanta generated. Must not be 

202 provided if a custom ``builder_class`` is given and that class does 

203 not accept ``where`` as a construction argument. 

204 builder_class : `type` [ \ 

205 `.quantum_graph_builder.QuantumGraphBuilder` ], optional 

206 Quantum graph builder implementation. Ignored if ``builder`` is 

207 provided. 

208 **kwargs 

209 Additional keyword arguments are forwarded to ``builder_class`` 

210 when a quantum graph builder instance is constructed. All 

211 arguments accepted by the 

212 `~.quantum_graph_builder.QuantumGraphBuilder` base 

213 class are provided automatically (from explicit arguments to this 

214 method and executor attributes) and do not need to be included 

215 as keyword arguments. 

216 

217 Returns 

218 ------- 

219 builder : `.quantum_graph_builder.QuantumGraphBuilder` 

220 A quantum graph builder. 

221 """ 

222 if where: 222 ↛ 225line 222 didn't jump to line 225 because the condition on line 222 was never true

223 # Only pass 'where' if it's actually provided, since some 

224 # QuantumGraphBuilder subclasses may not accept it. 

225 kwargs["where"] = where 

226 return builder_class( 

227 pipeline.to_graph(), 

228 self._butler, 

229 skip_existing_in=self._skip_existing_in, 

230 retained_dataset_types=self._retained_dataset_types, 

231 clobber=self._clobber_output, 

232 **kwargs, 

233 ) 

234 

235 def make_quantum_graph( 

236 self, 

237 pipeline: Pipeline, 

238 where: str = "", 

239 *, 

240 builder_class: type[QuantumGraphBuilder] = AllDimensionsQuantumGraphBuilder, 

241 attach_datastore_records: bool = False, 

242 **kwargs: Any, 

243 ) -> QuantumGraph: 

244 """Build a quantum graph from a pipeline and input datasets. 

245 

246 This returns an instance of the old `.QuantumGraph` class. Use 

247 `build_quantum_graph` to construct a 

248 `.quantum_graph.PredictedQuantumGraph`. 

249 

250 Parameters 

251 ---------- 

252 pipeline : `.Pipeline` 

253 The pipeline for which to generate a quantum graph. 

254 where : `str`, optional 

255 A data ID query that constrains the quanta generated. Must not be 

256 provided if a custom ``builder_class`` is given and that class does 

257 not accept ``where`` as a construction argument. 

258 builder_class : `type` [ \ 

259 `.quantum_graph_builder.QuantumGraphBuilder` ], optional 

260 Quantum graph builder implementation. Ignored if ``builder`` is 

261 provided. 

262 attach_datastore_records : `bool`, optional 

263 Whether to attach datastore records. These are currently used only 

264 by `lsst.daf.butler.QuantumBackedButler`, which is not used by 

265 `SeparablePipelineExecutor` for execution. 

266 **kwargs 

267 Additional keyword arguments are forwarded to ``builder_class`` 

268 when a quantum graph builder instance is constructed. All 

269 arguments accepted by the 

270 `~.quantum_graph_builder.QuantumGraphBuilder` base 

271 class are provided automatically (from explicit arguments to this 

272 method and executor attributes) and do not need to be included 

273 as keyword arguments. 

274 

275 Returns 

276 ------- 

277 graph : `.QuantumGraph` 

278 The quantum graph for ``.Pipeline`` as run on the datasets 

279 identified by ``where``. 

280 

281 Notes 

282 ----- 

283 This method does no special handling of empty quantum graphs. If 

284 needed, clients can use `len` to test if the returned graph is empty. 

285 """ 

286 metadata = { 

287 "input": self._butler.collections.defaults, 

288 "output_run": self._butler.run, 

289 "skip_existing_in": self._skip_existing_in, 

290 "skip_existing": bool(self._skip_existing_in), 

291 "retained_dataset_types": self._retained_dataset_types, 

292 "data_query": where, 

293 "user": getpass.getuser(), 

294 "time": str(datetime.datetime.now()), 

295 } 

296 qg_builder = self.make_quantum_graph_builder(pipeline, where, builder_class=builder_class, **kwargs) 

297 graph = qg_builder.build(metadata=metadata, attach_datastore_records=attach_datastore_records) 

298 _LOG.info( 

299 "QuantumGraph contains %d quanta for %d tasks, graph ID: %r", 

300 len(graph), 

301 len(graph.taskGraph), 

302 graph.graphID, 

303 ) 

304 return graph 

305 

306 def build_quantum_graph( 

307 self, 

308 pipeline: Pipeline, 

309 where: str = "", 

310 *, 

311 builder_class: type[QuantumGraphBuilder] = AllDimensionsQuantumGraphBuilder, 

312 attach_datastore_records: bool = False, 

313 **kwargs: Any, 

314 ) -> PredictedQuantumGraph: 

315 """Build a quantum graph from a pipeline and input datasets. 

316 

317 This returns an instance of the new 

318 `.quantum_graph.PredictedQuantumGraph` class. Use `make_quantum_graph` 

319 to construct a `.QuantumGraph`. 

320 

321 Parameters 

322 ---------- 

323 pipeline : `.Pipeline` 

324 The pipeline for which to generate a quantum graph. 

325 where : `str`, optional 

326 A data ID query that constrains the quanta generated. Must not be 

327 provided if a custom ``builder_class`` is given and that class does 

328 not accept ``where`` as a construction argument. 

329 builder_class : `type` [ \ 

330 `.quantum_graph_builder.QuantumGraphBuilder` ], optional 

331 Quantum graph builder implementation. Ignored if ``builder`` is 

332 provided. 

333 attach_datastore_records : `bool`, optional 

334 Whether to attach datastore records. These are currently used only 

335 by `lsst.daf.butler.QuantumBackedButler`, which is not used by 

336 `SeparablePipelineExecutor` for execution. 

337 **kwargs 

338 Additional keyword arguments are forwarded to ``builder_class`` 

339 when a quantum graph builder instance is constructed. All 

340 arguments accepted by the 

341 `~.quantum_graph_builder.QuantumGraphBuilder` base 

342 class are provided automatically (from explicit arguments to this 

343 method and executor attributes) and do not need to be included 

344 as keyword arguments. 

345 

346 Returns 

347 ------- 

348 graph : `.QuantumGraph` 

349 The quantum graph for ``.Pipeline`` as run on the datasets 

350 identified by ``where``. 

351 

352 Notes 

353 ----- 

354 This method does no special handling of empty quantum graphs. If 

355 needed, clients can use `len` to test if the returned graph is empty. 

356 """ 

357 metadata = { 

358 "skip_existing_in": self._skip_existing_in, 

359 "skip_existing": bool(self._skip_existing_in), 

360 "retained_dataset_types": self._retained_dataset_types, 

361 "data_query": where, 

362 } 

363 qg_builder = self.make_quantum_graph_builder(pipeline, where, builder_class=builder_class, **kwargs) 

364 graph = qg_builder.finish( 

365 metadata=metadata, attach_datastore_records=attach_datastore_records 

366 ).assemble() 

367 _LOG.info( 

368 "PredictedQuantumGraph contains %d quanta for %d tasks.", 

369 len(graph), 

370 len(graph.quanta_by_task), 

371 ) 

372 return graph 

373 

374 def run_pipeline( 

375 self, 

376 graph: QuantumGraph | PredictedQuantumGraph, 

377 fail_fast: bool = False, 

378 graph_executor: QuantumGraphExecutor | None = None, 

379 num_proc: int = 1, 

380 *, 

381 provenance_dataset_ref: DatasetRef | None = None, 

382 ) -> None: 

383 """Run a pipeline in the form of a prepared quantum graph. 

384 

385 Pre-execution initialization must have already been run; 

386 see `pre_execute_qgraph`. 

387 

388 Parameters 

389 ---------- 

390 graph : `.QuantumGraph` or `.quantum_graph.PredictedQuantumGraph` 

391 The pipeline and datasets to execute. 

392 fail_fast : `bool`, optional 

393 If `True`, abort all execution if any task fails when 

394 running with multiple processes. Only used with the default graph 

395 executor). 

396 graph_executor : `.quantum_graph_executor.QuantumGraphExecutor`,\ 

397 optional 

398 A custom graph executor. By default, a new instance of 

399 `.mp_graph_executor.MPGraphExecutor` is used. 

400 num_proc : `int`, optional 

401 The number of processes that can be used to run the pipeline. The 

402 default value ensures that no subprocess is created. Only used with 

403 the default graph executor. 

404 provenance_dataset_ref : `lsst.daf.butler.DatasetRef`, optional 

405 Dataset that should be used to save provenance. Provenance is only 

406 supported when running in a single process (at least for the 

407 default quantum executor), and should not be enabled in contexts 

408 where a quantum might be executed more than once (i.e. retried) 

409 within the same `~lsst.daf.butler.CollectionType.RUN` collection. 

410 The caller is responsible for registering the dataset type and for 

411 ensuring that the dimensions of this dataset do not lead to 

412 uniqueness conflicts. 

413 """ 

414 if not graph_executor: 414 ↛ 433line 414 didn't jump to line 433 because the condition on line 414 was always true

415 quantum_executor = SingleQuantumExecutor( 

416 butler=self._butler, 

417 task_factory=self._task_factory, 

418 skip_existing_in=self._skip_existing_in, 

419 clobber_outputs=self._clobber_output, 

420 resources=self.resources, 

421 raise_on_partial_outputs=self.raise_on_partial_outputs, 

422 ) 

423 graph_executor = MPGraphExecutor( 

424 num_proc=num_proc, 

425 timeout=2_592_000.0, # In practice, timeout is never helpful; set to 30 days. 

426 quantum_executor=quantum_executor, 

427 fail_fast=fail_fast, 

428 ) 

429 # Have to reset connection pool to avoid sharing connections with 

430 # forked processes. 

431 self._butler.registry.resetConnectionPool() 

432 

433 if provenance_dataset_ref is not None: 

434 with TemporaryForIngest(self._butler, provenance_dataset_ref) as temporary: 

435 try: 

436 graph_executor.execute(graph, provenance_graph_file=temporary.ospath) 

437 temporary.ingest() 

438 except MPGraphExecutorError: 

439 # If the graph executor itself raised, it will have 

440 # finished the provenance rewrite. In other cases the 

441 # temporary file might be incomplete or corrupted and we 

442 # can't roll the dice on ingesting it. 

443 temporary.ingest() 

444 raise 

445 

446 else: 

447 graph_executor.execute(graph)