Coverage for python / lsst / pipe / base / mp_graph_executor.py: 14%
402 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-24 01:13 -0800
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-24 01:13 -0800
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/>.
28from __future__ import annotations
30__all__ = ["MPGraphExecutor", "MPGraphExecutorError", "MPTimeoutError"]
32import enum
33import importlib
34import logging
35import multiprocessing
36import pickle
37import signal
38import sys
39import threading
40import time
41import uuid
42from contextlib import ExitStack
43from typing import Literal, cast
45import networkx
47from lsst.daf.butler import DataCoordinate, Quantum
48from lsst.daf.butler.cli.cliLog import CliLog
49from lsst.daf.butler.logging import ButlerLogRecords
50from lsst.utils.threads import disable_implicit_threading
52from ._status import InvalidQuantumError, RepeatableQuantumError
53from ._task_metadata import TaskMetadata
54from .execution_graph_fixup import ExecutionGraphFixup
55from .graph import QuantumGraph
56from .graph_walker import GraphWalker
57from .log_on_close import LogOnClose
58from .pipeline_graph import TaskNode
59from .quantum_graph import PredictedQuantumGraph, PredictedQuantumInfo, ProvenanceQuantumGraphWriter
60from .quantum_graph_executor import QuantumExecutor, QuantumGraphExecutor
61from .quantum_reports import ExecutionStatus, QuantumReport, Report
63_LOG = logging.getLogger(__name__)
66class JobState(enum.Enum):
67 """Possible state for an executing task."""
69 PENDING = enum.auto()
70 """The job has not started yet."""
72 RUNNING = enum.auto()
73 """The job is currently executing."""
75 FINISHED = enum.auto()
76 """The job finished successfully."""
78 FAILED = enum.auto()
79 """The job execution failed (process returned non-zero status)."""
81 TIMED_OUT = enum.auto()
82 """The job was killed due to too long execution time."""
84 FAILED_DEP = enum.auto()
85 """One of the dependencies of this job failed or timed out."""
88class _Job:
89 """Class representing a job running single task.
91 Parameters
92 ----------
93 quantum_id : `uuid.UUID`
94 ID of the quantum this job executes.
95 quantum : `lsst.daf.butler.Quantum`
96 Description of the inputs and outputs.
97 task_node : `.pipeline_graph.TaskNode`
98 Description of the task and configuration.
99 """
101 def __init__(self, quantum_id: uuid.UUID, quantum: Quantum, task_node: TaskNode):
102 self.quantum_id = quantum_id
103 self.quantum = quantum
104 self.task_node = task_node
105 self.process: multiprocessing.process.BaseProcess | None = None
106 self._state = JobState.PENDING
107 self.started: float = 0.0
108 self._rcv_conn: multiprocessing.connection.Connection | None = None
109 self._terminated = False
111 @property
112 def state(self) -> JobState:
113 """Job processing state (JobState)."""
114 return self._state
116 @property
117 def terminated(self) -> bool:
118 """Return `True` if job was killed by stop() method and negative exit
119 code is returned from child process (`bool`).
120 """
121 if self._terminated:
122 assert self.process is not None, "Process must be started"
123 if self.process.exitcode is not None:
124 return self.process.exitcode < 0
125 return False
127 def start(
128 self,
129 quantumExecutor: QuantumExecutor,
130 startMethod: Literal["spawn"] | Literal["forkserver"],
131 fail_fast: bool,
132 ) -> None:
133 """Start process which runs the task.
135 Parameters
136 ----------
137 quantumExecutor : `QuantumExecutor`
138 Executor for single quantum.
139 startMethod : `str`, optional
140 Start method from `multiprocessing` module.
141 fail_fast : `bool`, optional
142 If `True` then kill subprocess on RepeatableQuantumError.
143 """
144 # Unpickling of quantum has to happen after butler/executor, also we
145 # want to setup logging before unpickling anything that can generate
146 # messages, this is why things are pickled manually here.
147 qe_pickle = pickle.dumps(quantumExecutor)
148 task_node_pickle = pickle.dumps(self.task_node)
149 quantum_pickle = pickle.dumps(self.quantum)
150 self._rcv_conn, snd_conn = multiprocessing.Pipe(False)
151 logConfigState = CliLog.configState
153 mp_ctx = multiprocessing.get_context(startMethod)
154 self.process = mp_ctx.Process( # type: ignore[attr-defined]
155 target=_Job._executeJob,
156 args=(
157 qe_pickle,
158 task_node_pickle,
159 quantum_pickle,
160 self.quantum_id,
161 logConfigState,
162 snd_conn,
163 fail_fast,
164 ),
165 name=f"task-{self.quantum.dataId}",
166 )
167 # mypy is getting confused by multiprocessing.
168 assert self.process is not None
169 self.process.start()
170 self.started = time.time()
171 self._state = JobState.RUNNING
173 @staticmethod
174 def _executeJob(
175 quantumExecutor_pickle: bytes,
176 task_node_pickle: bytes,
177 quantum_pickle: bytes,
178 quantum_id: uuid.UUID,
179 logConfigState: list,
180 snd_conn: multiprocessing.connection.Connection,
181 fail_fast: bool,
182 ) -> None:
183 """Execute a job with arguments.
185 Parameters
186 ----------
187 quantumExecutor_pickle : `bytes`
188 Executor for single quantum, pickled.
189 task_node_pickle : `bytes`
190 Task definition structure, pickled.
191 quantum_pickle : `bytes`
192 Quantum for this task execution in pickled form.
193 quantum_id : `uuid.UUID`
194 Unique ID for the quantum.
195 logConfigState : `list`
196 Logging state from parent process.
197 snd_conn : `multiprocessing.Connection`
198 Connection to send job report to parent process.
199 fail_fast : `bool`
200 If `True` then kill subprocess on RepeatableQuantumError.
201 """
202 # This terrible hack is a workaround for Python threading bug:
203 # https://github.com/python/cpython/issues/102512. Should be removed
204 # when fix for that bug is deployed. Inspired by
205 # https://github.com/QubesOS/qubes-core-admin-client/pull/236/files.
206 thread = threading.current_thread()
207 if isinstance(thread, threading._DummyThread):
208 if getattr(thread, "_tstate_lock", "") is None:
209 thread._set_tstate_lock() # type: ignore[attr-defined]
211 if logConfigState and not CliLog.configState:
212 # means that we are in a new spawned Python process and we have to
213 # re-initialize logging
214 CliLog.replayConfigState(logConfigState)
216 quantumExecutor: QuantumExecutor = pickle.loads(quantumExecutor_pickle)
217 task_node: TaskNode = pickle.loads(task_node_pickle)
218 quantum = pickle.loads(quantum_pickle)
219 report: QuantumReport | None = None
220 # Catch a few known failure modes and stop the process immediately,
221 # with exception-specific exit code.
222 try:
223 _, report = quantumExecutor.execute(task_node, quantum, quantum_id=quantum_id)
224 except RepeatableQuantumError as exc:
225 report = QuantumReport.from_exception(
226 quantumId=quantum_id,
227 exception=exc,
228 dataId=quantum.dataId,
229 taskLabel=task_node.label,
230 exitCode=exc.EXIT_CODE if fail_fast else None,
231 )
232 if fail_fast:
233 _LOG.warning("Caught repeatable quantum error for %s (%s):", task_node.label, quantum.dataId)
234 _LOG.warning(exc, exc_info=True)
235 sys.exit(exc.EXIT_CODE)
236 else:
237 raise
238 except InvalidQuantumError as exc:
239 _LOG.fatal("Invalid quantum error for %s (%s): %s", task_node.label, quantum.dataId)
240 _LOG.fatal(exc, exc_info=True)
241 report = QuantumReport.from_exception(
242 quantumId=quantum_id,
243 exception=exc,
244 dataId=quantum.dataId,
245 taskLabel=task_node.label,
246 exitCode=exc.EXIT_CODE,
247 )
248 sys.exit(exc.EXIT_CODE)
249 except Exception as exc:
250 _LOG.debug("exception from task %s dataId %s: %s", task_node.label, quantum.dataId, exc)
251 report = QuantumReport.from_exception(
252 quantumId=quantum_id,
253 exception=exc,
254 dataId=quantum.dataId,
255 taskLabel=task_node.label,
256 )
257 raise
258 finally:
259 if report is not None:
260 # If sending fails we do not want this new exception to be
261 # exposed.
262 try:
263 _LOG.debug("sending report for task %s dataId %s", task_node.label, quantum.dataId)
264 snd_conn.send(report)
265 except Exception:
266 pass
268 def stop(self) -> None:
269 """Stop the process."""
270 assert self.process is not None, "Process must be started"
271 self.process.terminate()
272 # give it 1 second to finish or KILL
273 for _ in range(10):
274 time.sleep(0.1)
275 if not self.process.is_alive():
276 break
277 else:
278 _LOG.debug("Killing process %s", self.process.name)
279 self.process.kill()
280 self._terminated = True
282 def cleanup(self) -> None:
283 """Release processes resources, has to be called for each finished
284 process.
285 """
286 if self.process and not self.process.is_alive():
287 self.process.close()
288 self.process = None
289 self._rcv_conn = None
291 def report(self) -> QuantumReport:
292 """Return task report, should be called after process finishes and
293 before cleanup().
294 """
295 assert self.process is not None, "Process must be started"
296 assert self._rcv_conn is not None, "Process must be started"
297 try:
298 report = self._rcv_conn.recv()
299 report.exitCode = self.process.exitcode
300 except Exception:
301 # Likely due to the process killed, but there may be other reasons.
302 # Exit code should not be None, this is to keep mypy happy.
303 exitcode = self.process.exitcode if self.process.exitcode is not None else -1
304 assert self.quantum.dataId is not None, "Quantum DataId cannot be None"
305 report = QuantumReport.from_exit_code(
306 quantumId=self.quantum_id,
307 exitCode=exitcode,
308 dataId=self.quantum.dataId,
309 taskLabel=self.task_node.label,
310 )
311 if self.terminated:
312 # Means it was killed, assume it's due to timeout
313 report.status = ExecutionStatus.TIMEOUT
314 return report
316 def failMessage(self) -> str:
317 """Return a message describing task failure."""
318 assert self.process is not None, "Process must be started"
319 assert self.process.exitcode is not None, "Process has to finish"
320 exitcode = self.process.exitcode
321 if exitcode < 0:
322 # Negative exit code means it is killed by signal
323 signum = -exitcode
324 msg = f"Task {self} failed, killed by signal {signum}"
325 # Just in case this is some very odd signal, expect ValueError
326 try:
327 strsignal = signal.strsignal(signum)
328 msg = f"{msg} ({strsignal})"
329 except ValueError:
330 pass
331 elif exitcode > 0:
332 msg = f"Task {self} failed, exit code={exitcode}"
333 else:
334 msg = ""
335 return msg
337 def __str__(self) -> str:
338 return f"<{self.task_node.label} dataId={self.quantum.dataId}>"
341class _JobList:
342 """Simple list of _Job instances with few convenience methods.
344 Parameters
345 ----------
346 xgraph : `networkx.DiGraph`
347 Directed acyclic graph of quantum IDs.
348 """
350 def __init__(self, xgraph: networkx.DiGraph):
351 self.jobs = {
352 quantum_id: _Job(
353 quantum_id=quantum_id,
354 quantum=xgraph.nodes[quantum_id]["quantum"],
355 task_node=xgraph.nodes[quantum_id]["pipeline_node"],
356 )
357 for quantum_id in xgraph
358 }
359 self.walker: GraphWalker[uuid.UUID] = GraphWalker(xgraph.copy())
360 self.pending = set(next(self.walker, ()))
361 self.running: set[uuid.UUID] = set()
362 self.finished: set[uuid.UUID] = set()
363 self.failed: set[uuid.UUID] = set()
364 self.timed_out: set[uuid.UUID] = set()
366 def submit(
367 self,
368 quantumExecutor: QuantumExecutor,
369 startMethod: Literal["spawn"] | Literal["forkserver"],
370 fail_fast: bool = False,
371 ) -> _Job:
372 """Submit a pending job for execution.
374 Parameters
375 ----------
376 quantumExecutor : `QuantumExecutor`
377 Executor for single quantum.
378 startMethod : `str`, optional
379 Start method from `multiprocessing` module.
380 fail_fast : `bool`, optional
381 If `True` then kill subprocess on RepeatableQuantumError.
383 Returns
384 -------
385 job : `_Job`
386 The job that was submitted.
387 """
388 quantum_id = self.pending.pop()
389 job = self.jobs[quantum_id]
390 job.start(quantumExecutor, startMethod, fail_fast=fail_fast)
391 self.running.add(job.quantum_id)
392 return job
394 def setJobState(self, job: _Job, state: JobState) -> list[_Job]:
395 """Update job state.
397 Parameters
398 ----------
399 job : `_Job`
400 Job to submit.
401 state : `JobState`
402 New job state; note that only the FINISHED, FAILED, and TIMED_OUT
403 states are acceptable.
405 Returns
406 -------
407 blocked : `list` [ `_Job` ]
408 Additional jobs that have been marked as failed because this job
409 was upstream of them and failed or timed out.
410 """
411 allowedStates = (JobState.FINISHED, JobState.FAILED, JobState.TIMED_OUT)
412 assert state in allowedStates, f"State {state} not allowed here"
414 # remove job from pending/running lists
415 if job.state == JobState.PENDING:
416 self.pending.remove(job.quantum_id)
417 elif job.state == JobState.RUNNING:
418 self.running.remove(job.quantum_id)
420 quantum_id = job.quantum_id
421 # it should not be in any of these, but just in case
422 self.finished.discard(quantum_id)
423 self.failed.discard(quantum_id)
424 self.timed_out.discard(quantum_id)
425 job._state = state
426 match job.state:
427 case JobState.FINISHED:
428 self.finished.add(quantum_id)
429 self.walker.finish(quantum_id)
430 self.pending.update(next(self.walker, ()))
431 return []
432 case JobState.FAILED:
433 self.failed.add(quantum_id)
434 case JobState.TIMED_OUT:
435 self.failed.add(quantum_id)
436 self.timed_out.add(quantum_id)
437 case _:
438 raise ValueError(f"Unexpected state value: {state}")
439 blocked: list[_Job] = []
440 for downstream_quantum_id in self.walker.fail(quantum_id):
441 self.failed.add(downstream_quantum_id)
442 blocked.append(self.jobs[downstream_quantum_id])
443 self.jobs[downstream_quantum_id]._state = JobState.FAILED_DEP
444 return blocked
446 def cleanup(self) -> None:
447 """Do periodic cleanup for jobs that did not finish correctly.
449 If timed out jobs are killed but take too long to stop then regular
450 cleanup will not work for them. Here we check all timed out jobs
451 periodically and do cleanup if they managed to die by this time.
452 """
453 for quantum_id in self.timed_out:
454 job = self.jobs[quantum_id]
455 assert job.state == JobState.TIMED_OUT, "Job state should be consistent with the set it's in."
456 if job.process is not None:
457 job.cleanup()
460class MPGraphExecutorError(Exception):
461 """Exception class for errors raised by MPGraphExecutor."""
463 pass
466class MPTimeoutError(MPGraphExecutorError):
467 """Exception raised when task execution times out."""
469 pass
472class MPGraphExecutor(QuantumGraphExecutor):
473 """Implementation of QuantumGraphExecutor using same-host multiprocess
474 execution of Quanta.
476 Parameters
477 ----------
478 num_proc : `int`
479 Number of processes to use for executing tasks.
480 timeout : `float`
481 Time in seconds to wait for tasks to finish.
482 quantum_executor : `.quantum_graph_executor.QuantumExecutor`
483 Executor for single quantum. For multiprocess-style execution when
484 ``num_proc`` is greater than one this instance must support pickle.
485 start_method : `str`, optional
486 Start method from `multiprocessing` module, `None` selects the best
487 one for current platform.
488 fail_fast : `bool`, optional
489 If set to ``True`` then stop processing on first error from any task.
490 pdb : `str`, optional
491 Debugger to import and use (via the ``post_mortem`` function) in the
492 event of an exception.
493 execution_graph_fixup : `.execution_graph_fixup.ExecutionGraphFixup`, \
494 optional
495 Instance used for modification of execution graph.
496 """
498 def __init__(
499 self,
500 *,
501 num_proc: int,
502 timeout: float,
503 quantum_executor: QuantumExecutor,
504 start_method: Literal["spawn"] | Literal["forkserver"] | None = None,
505 fail_fast: bool = False,
506 pdb: str | None = None,
507 execution_graph_fixup: ExecutionGraphFixup | None = None,
508 ):
509 self._num_proc = num_proc
510 self._timeout = timeout
511 self._quantum_executor = quantum_executor
512 self._fail_fast = fail_fast
513 self._pdb = pdb
514 self._execution_graph_fixup = execution_graph_fixup
515 self._report: Report | None = None
517 # We set default start method as spawn for all platforms.
518 if start_method is None:
519 start_method = "spawn"
520 self._start_method = start_method
522 def execute(
523 self, graph: QuantumGraph | PredictedQuantumGraph, *, provenance_graph_file: str | None = None
524 ) -> None:
525 # Docstring inherited from QuantumGraphExecutor.execute
526 old_graph: QuantumGraph | None = None
527 if isinstance(graph, QuantumGraph):
528 old_graph = graph
529 new_graph = PredictedQuantumGraph.from_old_quantum_graph(old_graph)
530 else:
531 new_graph = graph
532 xgraph = self._make_xgraph(new_graph, old_graph)
533 self._report = Report(qgraphSummary=new_graph._make_summary())
534 with ExitStack() as exit_stack:
535 provenance_writer: ProvenanceQuantumGraphWriter | None = None
536 if provenance_graph_file is not None:
537 if provenance_graph_file is not None and self._num_proc > 1:
538 raise NotImplementedError(
539 "Provenance writing is not implemented for multiprocess execution."
540 )
541 provenance_writer = ProvenanceQuantumGraphWriter(
542 provenance_graph_file,
543 exit_stack=exit_stack,
544 log_on_close=LogOnClose(_LOG.log),
545 predicted=new_graph,
546 )
547 try:
548 if self._num_proc > 1:
549 self._execute_quanta_mp(xgraph, self._report)
550 else:
551 self._execute_quanta_in_process(xgraph, self._report, provenance_writer)
552 except Exception as exc:
553 self._report.set_exception(exc)
554 raise
555 if provenance_writer is not None:
556 provenance_writer.write_overall_inputs()
557 provenance_writer.write_packages()
558 provenance_writer.write_init_outputs(assume_existence=True)
560 def _make_xgraph(
561 self, new_graph: PredictedQuantumGraph, old_graph: QuantumGraph | None
562 ) -> networkx.DiGraph:
563 """Obtain a networkx DAG from a quantum graph, applying any fixup and
564 adding `lsst.daf.butler.Quantum` and `~.pipeline_graph.TaskNode`
565 attributes.
567 Parameters
568 ----------
569 new_graph : `.quantum_graph.PredictedQuantumGraph`
570 New quantum graph object.
571 old_graph : `.QuantumGraph` or `None`
572 Equivalent old quantum graph object.
574 Returns
575 -------
576 xgraph : `networkx.DiGraph`
577 NetworkX DAG with quantum IDs as node keys.
579 Raises
580 ------
581 MPGraphExecutorError
582 Raised if execution graph cannot be ordered after modification,
583 i.e. it has dependency cycles.
584 """
585 new_graph.build_execution_quanta()
586 xgraph = new_graph.quantum_only_xgraph.copy()
587 if self._execution_graph_fixup:
588 try:
589 self._execution_graph_fixup.fixup_graph(xgraph, new_graph.quanta_by_task)
590 except NotImplementedError:
591 # Backwards compatibility.
592 if old_graph is None:
593 old_graph = new_graph.to_old_quantum_graph()
594 old_graph = self._execution_graph_fixup.fixupQuanta(old_graph)
595 # Adding all of the edges from old_graph is overkill, but the
596 # only option we really have to make sure we add any new ones.
597 xgraph.update([(a.nodeId, b.nodeId) for a, b in old_graph.graph.edges])
598 if networkx.dag.has_cycle(xgraph):
599 raise MPGraphExecutorError("Updated execution graph has dependency cycle.")
600 return xgraph
602 def _execute_quanta_in_process(
603 self, xgraph: networkx.DiGraph, report: Report, provenance_writer: ProvenanceQuantumGraphWriter | None
604 ) -> None:
605 """Execute all Quanta in current process.
607 Parameters
608 ----------
609 xgraph : `networkx.DiGraph`
610 DAG to execute. Should have quantum IDs for nodes and ``quantum``
611 (`lsst.daf.butler.Quantum`) and ``pipeline_node``
612 (`lsst.pipe.base.pipeline_graph.TaskNode`) attributes in addition
613 to those provided by
614 `.quantum_graph.PredictedQuantumGraph.quantum_only_xgraph`.
615 report : `Report`
616 Object for reporting execution status.
617 provenance_writer : `.quantum_graph.ProvenanceQuantumGraphWriter` or \
618 `None`
619 Object for recording provenance.
620 """
622 def tiebreaker_sort_key(quantum_id: uuid.UUID) -> tuple:
623 node_state = xgraph.nodes[quantum_id]
624 return (node_state["task_label"],) + node_state["data_id"].required_values
626 success_count, failed_count, total_count = 0, 0, len(xgraph.nodes)
627 walker = GraphWalker[uuid.UUID](xgraph.copy())
628 for unblocked_quanta in walker:
629 for quantum_id in sorted(unblocked_quanta, key=tiebreaker_sort_key):
630 node_state: PredictedQuantumInfo = xgraph.nodes[quantum_id]
631 data_id = node_state["data_id"]
632 task_node = node_state["pipeline_node"]
633 quantum = node_state["quantum"]
635 _LOG.debug("Executing %s (%s@%s)", quantum_id, task_node.label, data_id)
636 fail_exit_code: int | None = None
637 task_metadata: TaskMetadata | None = None
638 task_logs = ButlerLogRecords([])
639 try:
640 # For some exception types we want to exit immediately with
641 # exception-specific exit code, but we still want to start
642 # debugger before exiting if debugging is enabled.
643 try:
644 execution_result = self._quantum_executor.execute(
645 task_node, quantum, quantum_id=quantum_id, log_records=task_logs
646 )
647 if execution_result.report:
648 report.quantaReports.append(execution_result.report)
649 task_metadata = execution_result.task_metadata
650 success_count += 1
651 walker.finish(quantum_id)
652 except RepeatableQuantumError as exc:
653 if self._fail_fast:
654 _LOG.warning(
655 "Caught repeatable quantum error for %s (%s@%s):",
656 quantum_id,
657 task_node.label,
658 data_id,
659 )
660 _LOG.warning(exc, exc_info=True)
661 fail_exit_code = exc.EXIT_CODE
662 raise
663 except InvalidQuantumError as exc:
664 _LOG.fatal(
665 "Invalid quantum error for %s (%s@%s):", quantum_id, task_node.label, data_id
666 )
667 _LOG.fatal(exc, exc_info=True)
668 fail_exit_code = exc.EXIT_CODE
669 raise
670 except Exception as exc:
671 quantum_report = QuantumReport.from_exception(
672 exception=exc,
673 dataId=data_id,
674 taskLabel=task_node.label,
675 )
676 report.quantaReports.append(quantum_report)
678 if self._pdb and sys.stdin.isatty() and sys.stdout.isatty():
679 _LOG.error(
680 "%s (%s@%s) failed; dropping into pdb.",
681 quantum_id,
682 task_node.label,
683 data_id,
684 exc_info=exc,
685 )
686 try:
687 pdb = importlib.import_module(self._pdb)
688 except ImportError as imp_exc:
689 raise MPGraphExecutorError(
690 f"Unable to import specified debugger module ({self._pdb}): {imp_exc}"
691 ) from exc
692 if not hasattr(pdb, "post_mortem"):
693 raise MPGraphExecutorError(
694 f"Specified debugger module ({self._pdb}) can't debug with post_mortem",
695 ) from exc
696 pdb.post_mortem(exc.__traceback__)
698 report.status = ExecutionStatus.FAILURE
699 failed_count += 1
701 # If exception specified an exit code then just exit with
702 # that code, otherwise crash if fail-fast option is
703 # enabled.
704 if fail_exit_code is not None:
705 sys.exit(fail_exit_code)
706 if self._fail_fast:
707 raise MPGraphExecutorError(
708 f"Quantum {quantum_id} ({task_node.label}@{data_id}) failed."
709 ) from exc
710 else:
711 _LOG.error(
712 "%s (%s@%s) failed; processing will continue for remaining tasks.",
713 quantum_id,
714 task_node.label,
715 data_id,
716 exc_info=exc,
717 )
719 for downstream_quantum_id in walker.fail(quantum_id):
720 downstream_node_state = xgraph.nodes[downstream_quantum_id]
721 failed_quantum_report = QuantumReport(
722 status=ExecutionStatus.SKIPPED,
723 dataId=downstream_node_state["data_id"],
724 taskLabel=downstream_node_state["task_label"],
725 )
726 report.quantaReports.append(failed_quantum_report)
727 _LOG.error(
728 "Upstream job failed for task %s (%s@%s), skipping this quantum.",
729 downstream_quantum_id,
730 downstream_node_state["task_label"],
731 downstream_node_state["data_id"],
732 )
733 failed_count += 1
735 if provenance_writer is not None:
736 provenance_writer.write_quantum_provenance(
737 quantum_id, metadata=task_metadata, logs=task_logs
738 )
740 _LOG.info(
741 "Executed %d quanta successfully, %d failed and %d remain out of total %d quanta.",
742 success_count,
743 failed_count,
744 total_count - success_count - failed_count,
745 total_count,
746 )
748 # Raise an exception if there were any failures.
749 if failed_count:
750 raise MPGraphExecutorError("One or more tasks failed during execution.")
752 def _execute_quanta_mp(self, xgraph: networkx.DiGraph, report: Report) -> None:
753 """Execute all Quanta in separate processes.
755 Parameters
756 ----------
757 xgraph : `networkx.DiGraph`
758 DAG to execute. Should have quantum IDs for nodes and ``quantum``
759 (`lsst.daf.butler.Quantum`) and ``task_node``
760 (`lsst.pipe.base.pipeline_graph.TaskNode`) attributes in addition
761 to those provided by
762 `.quantum_graph.PredictedQuantumGraph.quantum_only_xgraph`.
763 report : `Report`
764 Object for reporting execution status.
765 """
766 disable_implicit_threading() # To prevent thread contention
768 _LOG.debug("Using %r for multiprocessing start method", self._start_method)
770 # re-pack input quantum data into jobs list
771 jobs = _JobList(xgraph)
773 # check that all tasks can run in sub-process
774 for job in jobs.jobs.values():
775 if not job.task_node.task_class.canMultiprocess:
776 raise MPGraphExecutorError(
777 f"Task {job.task_node.label!r} does not support multiprocessing; use single process"
778 )
780 finishedCount, failedCount = 0, 0
781 while jobs.pending or jobs.running:
782 _LOG.debug("#pendingJobs: %s", len(jobs.pending))
783 _LOG.debug("#runningJobs: %s", len(jobs.running))
785 # See if any jobs have finished
786 for quantum_id in list(jobs.running): # iterate over a copy so we can remove.
787 job = jobs.jobs[quantum_id]
788 assert job.process is not None, "Process cannot be None"
789 blocked: list[_Job] = []
790 if not job.process.is_alive():
791 _LOG.debug("finished: %s", job)
792 # finished
793 exitcode = job.process.exitcode
794 quantum_report = job.report()
795 report.quantaReports.append(quantum_report)
796 if exitcode == 0:
797 jobs.setJobState(job, JobState.FINISHED)
798 job.cleanup()
799 _LOG.debug("success: %s took %.3f seconds", job, time.time() - job.started)
800 else:
801 if job.terminated:
802 # Was killed due to timeout.
803 if report.status == ExecutionStatus.SUCCESS:
804 # Do not override global FAILURE status
805 report.status = ExecutionStatus.TIMEOUT
806 message = f"Timeout ({self._timeout} sec) for task {job}, task is killed"
807 blocked = jobs.setJobState(job, JobState.TIMED_OUT)
808 else:
809 report.status = ExecutionStatus.FAILURE
810 # failMessage() has to be called before cleanup()
811 message = job.failMessage()
812 blocked = jobs.setJobState(job, JobState.FAILED)
814 job.cleanup()
815 _LOG.debug("failed: %s", job)
816 if self._fail_fast or exitcode == InvalidQuantumError.EXIT_CODE:
817 # stop all running jobs
818 for stop_quantum_id in jobs.running:
819 stop_job = jobs.jobs[stop_quantum_id]
820 if stop_job is not job:
821 stop_job.stop()
822 if job.state is JobState.TIMED_OUT:
823 raise MPTimeoutError(f"Timeout ({self._timeout} sec) for task {job}.")
824 else:
825 raise MPGraphExecutorError(message)
826 else:
827 _LOG.error("%s; processing will continue for remaining tasks.", message)
828 else:
829 # check for timeout
830 now = time.time()
831 if now - job.started > self._timeout:
832 # Try to kill it, and there is a chance that it
833 # finishes successfully before it gets killed. Exit
834 # status is handled by the code above on next
835 # iteration.
836 _LOG.debug("Terminating job %s due to timeout", job)
837 job.stop()
839 for downstream_job in blocked:
840 quantum_report = QuantumReport(
841 quantumId=downstream_job.quantum_id,
842 status=ExecutionStatus.SKIPPED,
843 dataId=cast(DataCoordinate, downstream_job.quantum.dataId),
844 taskLabel=downstream_job.task_node.label,
845 )
846 report.quantaReports.append(quantum_report)
847 _LOG.error("Upstream job failed for task %s, skipping this task.", downstream_job)
849 # see if we can start more jobs
850 while len(jobs.running) < self._num_proc and jobs.pending:
851 job = jobs.submit(self._quantum_executor, self._start_method)
852 _LOG.debug("Submitted %s", job)
854 # Do cleanup for timed out jobs if necessary.
855 jobs.cleanup()
857 # Print progress message if something changed.
858 newFinished, newFailed = len(jobs.finished), len(jobs.failed)
859 if (finishedCount, failedCount) != (newFinished, newFailed):
860 finishedCount, failedCount = newFinished, newFailed
861 totalCount = len(jobs.jobs)
862 _LOG.info(
863 "Executed %d quanta successfully, %d failed and %d remain out of total %d quanta.",
864 finishedCount,
865 failedCount,
866 totalCount - finishedCount - failedCount,
867 totalCount,
868 )
870 # Here we want to wait until one of the running jobs completes
871 # but multiprocessing does not provide an API for that, for now
872 # just sleep a little bit and go back to the loop.
873 if jobs.running:
874 time.sleep(0.1)
876 if jobs.failed:
877 # print list of failed jobs
878 _LOG.error("Failed jobs:")
879 for quantum_id in jobs.failed:
880 job = jobs.jobs[quantum_id]
881 _LOG.error(" - %s: %s", job.state.name, job)
883 # if any job failed raise an exception
884 if jobs.failed == jobs.timed_out:
885 raise MPTimeoutError("One or more tasks timed out during execution.")
886 else:
887 raise MPGraphExecutorError("One or more tasks failed or timed out during execution.")
889 def getReport(self) -> Report:
890 # Docstring inherited from base class
891 if self._report is None:
892 raise RuntimeError("getReport() called before execute()")
893 return self._report