Coverage for tests / test_aggregator.py: 13%
377 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 01:22 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 01:22 -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# (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 <https://www.gnu.org/licenses/>.
28import dataclasses
29import itertools
30import os
31import time
32import unittest.mock
33import uuid
34from collections.abc import Iterator
35from contextlib import contextmanager
37import numpy as np
38from click.testing import CliRunner, Result
40import lsst.utils.tests
41from lsst.daf.butler import Butler, ButlerLogRecords, QuantumBackedButler
42from lsst.pipe.base import (
43 AlgorithmError,
44 QuantumAttemptStatus,
45 QuantumSuccessCaveats,
46 TaskMetadata,
47)
48from lsst.pipe.base import automatic_connection_constants as acc
49from lsst.pipe.base.cli.cmd.commands import aggregate_graph as aggregate_graph_cli
50from lsst.pipe.base.graph_walker import GraphWalker
51from lsst.pipe.base.pipeline_graph import Edge
52from lsst.pipe.base.quantum_graph import (
53 FORMAT_VERSION,
54 PredictedDatasetInfo,
55 PredictedQuantumGraph,
56 PredictedQuantumInfo,
57 ProvenanceDatasetInfo,
58 ProvenanceQuantumGraph,
59 ProvenanceQuantumGraphReader,
60 ProvenanceQuantumInfo,
61)
62from lsst.pipe.base.quantum_graph.aggregator import AggregatorConfig, FatalWorkerError, aggregate_graph
63from lsst.pipe.base.resource_usage import QuantumResourceUsage
64from lsst.pipe.base.single_quantum_executor import SingleQuantumExecutor
65from lsst.pipe.base.tests.mocks import (
66 DirectButlerRepo,
67 DynamicConnectionConfig,
68 DynamicTestPipelineTaskConfig,
69)
70from lsst.pipe.base.tests.util import patch_deterministic_uuid4
71from lsst.resources import ResourcePath
72from lsst.utils.packages import Packages
75@dataclasses.dataclass
76class PrepInfo:
77 """Struct of objects used in an aggregator test."""
79 butler: Butler
80 butler_path: str
81 predicted: PredictedQuantumGraph
82 predicted_path: str
83 config: AggregatorConfig
86class AggregatorTestCase(unittest.TestCase):
87 """Unit tests for `lsst.pipe.base.quantum_graph.aggregator`."""
89 @staticmethod
90 @contextmanager
91 def make_test_repo() -> Iterator[PrepInfo]:
92 """Make a test data repository and predicted quantum graph.
94 Returns
95 -------
96 prep_info : `PrepInfo`
97 Objects used in aggregator tests.
99 Notes
100 -----
101 The pipeline graph used by this task looks like this:
103 ■ calibrate: {detector, visit}
104 ╭─┤
105 ■ │ consolidate: {visit}
106 │
107 ■ resample: {patch, visit}
108 │
109 ■ coadd: {band, patch}
111 The data can be visualized via::
113 python -m lsst.daf.butler.tests.registry_data.spatial
115 One of the 'calibrate' quanta (visit=2, detector=2) is configured to
116 fail with `lsst.pipe.base.AnnotatedPartialOutputsError`. This lets us
117 test both success-with-caveats and failures, depending on how we
118 configure the executor. This ``{visit: 2, detector: 2}`` data ID is
119 the only one that overlaps ``{tract: 1, patch: 1}`` and
120 ``{tract: 0, patch: 5}``, so it should chain to the 'resample' and
121 'coadd' tasks, too.
122 """
123 with patch_deterministic_uuid4(100):
124 with DirectButlerRepo.make_temporary("base.yaml", "spatial.yaml") as (helper, root):
125 calibrate_config = DynamicTestPipelineTaskConfig()
126 calibrate_config.fail_exception = "lsst.pipe.base.AnnotatedPartialOutputsError"
127 calibrate_config.fail_condition = "visit=2 AND detector=2"
128 helper.add_task(
129 "calibrate",
130 config=calibrate_config,
131 dimensions=["visit", "detector"],
132 inputs={
133 "input_image": DynamicConnectionConfig(
134 dataset_type_name="raw",
135 dimensions=["visit", "detector"],
136 )
137 },
138 prerequisite_inputs={
139 "refcat": DynamicConnectionConfig(
140 dataset_type_name="references",
141 dimensions=["htm7"],
142 multiple=True,
143 )
144 },
145 init_outputs={
146 "output_schema": DynamicConnectionConfig(
147 dataset_type_name="source_schema",
148 )
149 },
150 outputs={
151 "output_image": DynamicConnectionConfig(
152 dataset_type_name="image",
153 dimensions=["visit", "detector"],
154 ),
155 "output_table": DynamicConnectionConfig(
156 dataset_type_name="source_detector",
157 dimensions=["visit", "detector"],
158 ),
159 },
160 )
161 helper.add_task(
162 "consolidate",
163 dimensions=["visit"],
164 init_inputs={
165 "input_schema": DynamicConnectionConfig(
166 dataset_type_name="source_schema",
167 )
168 },
169 inputs={
170 "input_table": DynamicConnectionConfig(
171 dataset_type_name="source_detector",
172 dimensions=["visit", "detector"],
173 multiple=True,
174 )
175 },
176 outputs={
177 "output_table": DynamicConnectionConfig(
178 dataset_type_name="source",
179 dimensions=["visit"],
180 )
181 },
182 )
183 helper.add_task(
184 "resample",
185 dimensions=["patch", "visit"],
186 inputs={
187 "input_image": DynamicConnectionConfig(
188 dataset_type_name="image",
189 dimensions=["visit", "detector"],
190 multiple=True,
191 )
192 },
193 outputs={
194 "output_image": DynamicConnectionConfig(
195 dataset_type_name="warp",
196 dimensions=["patch", "visit"],
197 )
198 },
199 )
200 helper.add_task(
201 "coadd",
202 dimensions=["patch", "band"],
203 inputs={
204 "input_image": DynamicConnectionConfig(
205 dataset_type_name="warp",
206 dimensions=["patch", "visit"],
207 multiple=True,
208 )
209 },
210 outputs={
211 "output_image": DynamicConnectionConfig(
212 dataset_type_name="coadd",
213 dimensions=["patch", "band"],
214 ),
215 },
216 )
217 pqgc = helper.make_quantum_graph_builder().finish(output="out_chain")
218 # We use the butler root for various QG files just because it's
219 # a convenient temporary directory.
220 predicted_path = os.path.join(root, "predicted.qg")
221 pqgc.write(predicted_path)
222 config = AggregatorConfig(
223 output_path=os.path.join(root, "provenance.qg"),
224 # Set these small to see logic paths that otherwise only
225 # affect large graphs.
226 ingest_batch_size=10,
227 zstd_dict_size=256,
228 zstd_dict_n_inputs=16,
229 )
230 yield PrepInfo(
231 butler=helper.butler,
232 butler_path=root,
233 predicted=pqgc.assemble(),
234 predicted_path=predicted_path,
235 config=config,
236 )
238 def iter_graph_execution(
239 self,
240 repo: ResourcePath,
241 qg: PredictedQuantumGraph,
242 raise_on_partial_outputs: bool,
243 is_retry: bool = False,
244 ) -> Iterator[uuid.UUID]:
245 """Return an iterator that executes and yields quanta one by one.
247 Parameters
248 ----------
249 repo : `lsst.resources.ResourcePath`
250 Butler repository path.
251 qg : `lsst.pipe.base.quantum_graph.PredictedQuantumGraph`
252 Predicted quantum graph. Must have datastore records attached,
253 since execution uses a quantum-backed butler.
254 raise_on_partial_outputs : `bool`
255 Whether to raise on `lsst.pipe.base.AnnotatedPartialOutputsError`
256 or treat it as a success with caveats.
257 is_retry : `bool`, optional
258 If `True`, this is a retry attempt and hence some outputs may
259 already be present; skip successes and reprocess failures.
261 Returns
262 -------
263 quanta : `~collections.abc.Iterator` [`uuid.UUID`]
264 An iterator over all executed quantum IDs (not blocked ones).
265 """
266 qbb = qg.make_init_qbb(repo)
267 self.enterContext(qbb)
268 qg.init_output_run(qbb)
269 sqe = SingleQuantumExecutor(
270 limited_butler_factory=lambda quantum: QuantumBackedButler.initialize(
271 repo,
272 quantum,
273 qg.pipeline_graph.universe,
274 ),
275 assume_no_existing_outputs=not is_retry,
276 skip_existing=is_retry,
277 clobber_outputs=is_retry,
278 raise_on_partial_outputs=raise_on_partial_outputs,
279 )
280 qg.build_execution_quanta()
281 xgraph = qg.quantum_only_xgraph
282 walker = GraphWalker[uuid.UUID](xgraph.copy())
283 for ready in walker:
284 for quantum_id in ready:
285 info = xgraph.nodes[quantum_id]
286 try:
287 sqe.execute(info["pipeline_node"], info["quantum"], quantum_id)
288 except AlgorithmError:
289 walker.fail(quantum_id)
290 else:
291 walker.finish(quantum_id)
292 yield quantum_id
294 def check_provenance_graph(
295 self,
296 pred: PredictedQuantumGraph,
297 prov_reader: ProvenanceQuantumGraphReader,
298 butler: Butler,
299 expect_failure: bool,
300 start_time: float,
301 expect_failures_retried: bool = False,
302 ) -> ProvenanceQuantumGraph:
303 """Run a batter of tests on a provenance quantum graph produced by
304 scanning the graph created by `make_test_repo`.
306 Parameters
307 ----------
308 pred: `lsst.pipe.base.quantum_graph.PredictedQuantumGraph`
309 Predicted quantum graph.
310 prov_reader : \
311 `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraphReader`
312 Reader for the provenance quantum graph.
313 butler : `lsst.daf.butler.Butler`
314 Client for the data repository.
315 expect_failure : `bool`
316 Whether to expect one quantum of 'calibrate' to fail (`True`) or
317 succeed without writing anything (`False`).
318 start_time : `float`
319 A POSIX timestamp that strictly precedes the start time of any
320 quantum's execution.
321 expect_failures_retried : `bool`, optional
322 If `True`, expect an initial attempt with failures prior to the
323 most recent attempt.
325 Returns
326 -------
327 prov : `ProvenanceQuantumGraph`
328 The full provenance quantum graph.
329 """
330 prov_reader.read_full_graph()
331 prov = prov_reader.graph
332 checked_some_metadata = False
333 checked_some_log = False
334 self.maxDiff = None
335 self.assertEqual(prov.header.version, FORMAT_VERSION)
336 self.assertEqual(
337 list(butler.collections.get_info(prov.header.output).children),
338 [prov.header.output_run]
339 + list(butler.collections.query(prov.header.inputs, flatten_chains=True)),
340 )
341 self.assertEqual(pred.quanta_by_task.keys(), prov.quanta_by_task.keys())
342 for task_label in pred.quanta_by_task:
343 self.assertEqual(pred.quanta_by_task[task_label], prov.quanta_by_task[task_label])
344 self.assertEqual(pred.datasets_by_type.keys() - {"packages"}, prov.datasets_by_type.keys())
345 for dataset_type_name in prov.datasets_by_type:
346 self.assertEqual(
347 pred.datasets_by_type[dataset_type_name], prov.datasets_by_type[dataset_type_name]
348 )
349 self.assertEqual(prov.init_quanta.keys(), pred.quanta_by_task.keys())
350 for quantum_id in pred:
351 # Check consistency between the predicted and provenance quantum
352 # node attributes.
353 pred_qinfo: PredictedQuantumInfo = pred.bipartite_xgraph.nodes[quantum_id]
354 prov_qinfo: ProvenanceQuantumInfo = prov.bipartite_xgraph.nodes[quantum_id]
355 self.assertEqual(pred_qinfo["task_label"], prov_qinfo["task_label"])
356 self.assertEqual(pred_qinfo["data_id"], prov_qinfo["data_id"])
357 msg = f"{pred_qinfo['task_label']}@{pred_qinfo['data_id']}"
358 # Check consistency between the predicted and provenance dataset
359 # node attributes and edges. Also gather existence information for
360 # use later.
361 existence: dict[str, list[bool]] = {}
362 pipeline_edges: list[Edge]
363 for dataset_id, _, pipeline_edges in pred.bipartite_xgraph.in_edges(
364 quantum_id, data="pipeline_edges"
365 ):
366 self.assertTrue(prov.bipartite_xgraph.has_predecessor(quantum_id, dataset_id))
367 for edge in pipeline_edges:
368 existence.setdefault(edge.connection_name, []).append(
369 self.check_dataset(dataset_id, pred, prov, butler)
370 )
371 for _, dataset_id, pipeline_edges in pred.bipartite_xgraph.out_edges(
372 quantum_id, data="pipeline_edges"
373 ):
374 self.assertTrue(prov.bipartite_xgraph.has_successor(quantum_id, dataset_id))
375 for edge in pipeline_edges:
376 existence.setdefault(edge.connection_name, []).append(
377 self.check_dataset(dataset_id, pred, prov, butler)
378 )
379 # Check quantum status and dataset existence against the known
380 # structure of the graph and where failures/caveats occur.
381 match (pred_qinfo["task_label"], dict(pred_qinfo["data_id"].required)):
382 case "calibrate", {"visit": 2, "detector": 2}:
383 # This is the quantum that can directly raise.
384 self._expect_all_exist(existence["input_image"], msg=msg)
385 self._expect_all_exist(existence["refcat"], msg=msg)
386 self._expect_none_exist(existence["output_image"], msg=msg)
387 self._expect_none_exist(existence["output_table"], msg=msg)
388 if expect_failure:
389 self._expect_failure(prov_qinfo, existence, msg=msg)
390 else:
391 self._expect_successful(
392 prov_qinfo,
393 existence,
394 caveats=(
395 QuantumSuccessCaveats.PARTIAL_OUTPUTS_ERROR
396 | QuantumSuccessCaveats.ALL_OUTPUTS_MISSING
397 | QuantumSuccessCaveats.ANY_OUTPUTS_MISSING
398 ),
399 exception_type="lsst.pipe.base.tests.mocks.MockAlgorithmError",
400 msg=msg,
401 )
402 if expect_failures_retried:
403 self.assertEqual(len(prov_qinfo["attempts"]), 2)
404 self.assertEqual(
405 prov_qinfo["attempts"][0].exception.type_name,
406 "lsst.pipe.base.tests.mocks.MockAlgorithmError",
407 )
408 else:
409 self.assertEqual(len(prov_qinfo["attempts"]), 1)
410 case "consolidate", {"visit": 2}:
411 # This quantum will succeed (with one predicted input
412 # missing) or be blocked.
413 self._expect_one_missing(existence["input_table"], msg=msg)
414 if expect_failure:
415 self._expect_blocked(prov_qinfo, existence, msg=msg)
416 else:
417 self._expect_successful(prov_qinfo, existence, msg=msg)
418 self.assertEqual(
419 len(prov_qinfo["attempts"]), expect_failures_retried or not expect_failure
420 )
421 case (
422 "resample" | "coadd",
423 {"tract": 1, "patch": 1} | {"tract": 0, "patch": 5},
424 ):
425 # These quanta will be blocked by an upstream failure or do
426 # chained caveats, since they won't have enough inputs to
427 # run.
428 if expect_failure:
429 self._expect_blocked(prov_qinfo, existence, msg=msg)
430 else:
431 self._expect_successful(
432 prov_qinfo,
433 existence,
434 caveats=(
435 QuantumSuccessCaveats.ADJUST_QUANTUM_RAISED
436 | QuantumSuccessCaveats.NO_WORK
437 | QuantumSuccessCaveats.ALL_OUTPUTS_MISSING
438 | QuantumSuccessCaveats.ANY_OUTPUTS_MISSING
439 ),
440 msg=msg,
441 )
442 self.assertEqual(
443 len(prov_qinfo["attempts"]), expect_failures_retried or not expect_failure
444 )
445 case (
446 "resample",
447 {"tract": 0, "patch": 4, "visit": 2} | {"tract": 1, "patch": 0, "visit": 2},
448 ):
449 # This will succeed or be blocked, with one input missing
450 # regardless.
451 self._expect_one_missing(existence["input_image"], msg=msg)
452 if expect_failure:
453 self._expect_blocked(prov_qinfo, existence, msg=msg)
454 else:
455 self._expect_successful(prov_qinfo, existence, msg=msg)
456 self.assertEqual(
457 len(prov_qinfo["attempts"]), expect_failures_retried or not expect_failure
458 )
459 case (
460 "coadd",
461 {"tract": 0, "patch": 4, "band": "r"} | {"tract": 1, "patch": 0, "band": "r"},
462 ):
463 # This will succeed with no inputs missing or be blocked
464 # with one input missing.
465 if expect_failure:
466 self._expect_one_missing(existence["input_image"], msg=msg)
467 self._expect_blocked(prov_qinfo, existence, msg=msg)
468 else:
469 self._expect_all_exist(existence["input_image"], msg=msg)
470 self._expect_successful(prov_qinfo, existence, msg=msg)
471 self.assertEqual(
472 len(prov_qinfo["attempts"]), expect_failures_retried or not expect_failure
473 )
474 case _:
475 # All other quanta should succeed and have all inputs
476 # present.
477 for connection_name in prov_qinfo["pipeline_node"].inputs.keys():
478 self._expect_all_exist(existence[connection_name], msg=msg)
479 self._expect_successful(prov_qinfo, existence, msg=msg)
480 self.assertEqual(len(prov_qinfo["attempts"]), 1)
481 if not checked_some_metadata and prov_qinfo["status"] is QuantumAttemptStatus.SUCCESSFUL:
482 self.check_metadata(quantum_id, prov_reader, butler)
483 checked_some_metadata = True
484 if not checked_some_log and prov_qinfo["status"] in (
485 QuantumAttemptStatus.SUCCESSFUL,
486 QuantumAttemptStatus.FAILED,
487 ):
488 self.check_log(quantum_id, prov_reader, butler)
489 checked_some_log = True
490 self.assertTrue(checked_some_metadata)
491 self.assertTrue(checked_some_log)
492 self.check_resource_usage_table(
493 prov_reader.graph, expect_failure=expect_failure, start_time=start_time
494 )
495 self.check_packages(prov_reader, butler)
496 self.check_quantum_table(prov_reader.graph, expect_failure=expect_failure)
497 self.check_exception_table(prov_reader.graph, expect_failure=expect_failure)
498 return prov
500 def _expect_all_exist(self, existence: list[bool], msg: str) -> None:
501 self.assertTrue(all(existence), msg=msg)
503 def _expect_none_exist(self, existence: list[bool], msg: str) -> None:
504 self.assertFalse(any(existence), msg=msg)
506 def _expect_one_missing(self, existence: list[bool], msg: str) -> None:
507 self.assertEqual(existence.count(False), 1, msg=msg)
509 def _expect_successful(
510 self,
511 info: ProvenanceQuantumInfo,
512 existence: dict[str, list[bool]],
513 caveats: QuantumSuccessCaveats = QuantumSuccessCaveats.NO_CAVEATS,
514 exception_type: str | None = None,
515 *,
516 msg: str,
517 ) -> None:
518 self.assertEqual(info["status"], QuantumAttemptStatus.SUCCESSFUL, msg=msg)
519 self.assertEqual(info["caveats"], caveats, msg=msg)
520 if exception_type is None:
521 self.assertIsNone(info["exception"], msg=msg)
522 else:
523 assert info["exception"] is not None
524 self.assertEqual(info["exception"].type_name, exception_type, msg=msg)
525 self._expect_all_exist(existence[acc.LOG_OUTPUT_CONNECTION_NAME], msg=msg)
526 self._expect_all_exist(existence[acc.METADATA_OUTPUT_CONNECTION_NAME], msg=msg)
527 if not (caveats & QuantumSuccessCaveats.ANY_OUTPUTS_MISSING):
528 for connection_name in info["pipeline_node"].outputs.keys():
529 self._expect_all_exist(existence[connection_name], msg=msg)
530 if caveats & QuantumSuccessCaveats.ALL_OUTPUTS_MISSING:
531 for connection_name in info["pipeline_node"].outputs.keys():
532 self._expect_none_exist(existence[connection_name], msg=msg)
533 self.assertIsNotNone(info["resource_usage"], msg=msg)
534 self.assertGreater(info["resource_usage"].total_time, 0, msg=msg)
535 self.assertGreater(info["resource_usage"].memory, 0, msg=msg)
537 def _expect_failure(
538 self, info: ProvenanceQuantumInfo, existence: dict[str, list[bool]], msg: str
539 ) -> None:
540 self.assertEqual(info["status"], QuantumAttemptStatus.FAILED, msg=msg)
541 self.assertEqual(info["exception"].type_name, "lsst.pipe.base.tests.mocks.MockAlgorithmError")
542 self._expect_all_exist(existence[acc.LOG_OUTPUT_CONNECTION_NAME], msg=msg)
543 self._expect_none_exist(existence[acc.METADATA_OUTPUT_CONNECTION_NAME], msg=msg)
544 for connection_name in info["pipeline_node"].outputs.keys():
545 self._expect_none_exist(existence[connection_name], msg=msg)
547 def _expect_blocked(
548 self,
549 info: ProvenanceQuantumInfo,
550 existence: dict[str, list[bool]],
551 msg: str,
552 ) -> None:
553 self.assertEqual(info["status"], QuantumAttemptStatus.BLOCKED, msg=msg)
554 self.assertEqual(info["attempts"], [])
555 self._expect_none_exist(existence[acc.LOG_OUTPUT_CONNECTION_NAME], msg=msg)
556 self._expect_none_exist(existence[acc.METADATA_OUTPUT_CONNECTION_NAME], msg=msg)
557 for connection_name in info["pipeline_node"].outputs.keys():
558 self._expect_none_exist(existence[connection_name], msg=msg)
560 def check_dataset(
561 self,
562 dataset_id: uuid.UUID,
563 pred: PredictedQuantumGraph,
564 prov: ProvenanceQuantumGraph,
565 butler: Butler,
566 ) -> bool:
567 """Check a provenance dataset for consistency with its predicted
568 counterpart.
570 Parameters
571 ----------
572 dataset_id : `uuid.UUID`
573 Unique ID for the dataset.
574 pred: `lsst.pipe.base.quantum_graph.PredictedQuantumGraph`
575 Predicted quantum graph.
576 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph`
577 Provenance quantum graph.
578 butler : `lsst.daf.butler.Butler`
579 Client for the data repository.
581 Returns
582 -------
583 exists : `bool`
584 Whether the dataset was marked as existing in the provenance
585 quantum graph.
586 """
587 pred_info: PredictedDatasetInfo = pred.bipartite_xgraph.nodes[dataset_id]
588 prov_info: ProvenanceDatasetInfo = prov.bipartite_xgraph.nodes[dataset_id]
589 self.assertEqual(pred_info["dataset_type_name"], prov_info["dataset_type_name"])
590 self.assertEqual(pred_info["data_id"], prov_info["data_id"])
591 self.assertEqual(pred_info["run"], prov_info["run"])
592 exists = prov_info["produced"]
593 dataset_type_name = prov_info["dataset_type_name"]
594 # We can remove this guard when we ingest QG-backed metadata and logs.
595 if not dataset_type_name.endswith("_metadata") and not dataset_type_name.endswith("_log"):
596 self.assertEqual(
597 butler.get_dataset(dataset_id) is not None,
598 exists,
599 msg=(
600 f"Ingest/existence inconsistency for {dataset_type_name}"
601 f"@{prov_info['data_id']}/{dataset_id}]"
602 ),
603 )
604 return exists
606 def check_metadata(
607 self, quantum_id: uuid.UUID, provenance_reader: ProvenanceQuantumGraphReader, butler: Butler
608 ) -> None:
609 """Check reading a metadata dataset from the provenance reader,
610 and check that the original metadata file has been deleted.
612 Parameters
613 ----------
614 quantum_id : `uuid.UUID`
615 Unique ID for the quantum this metadata belongs to.
616 provenance_reader : \
617 `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraphReader`
618 Reader for the provenance quantum graph.
619 butler : `lsst.daf.butler.Butler`
620 Client for the data repository.
621 """
622 # Try reading metadata through the quantum ID.
623 ((metadata1,),) = provenance_reader.fetch_metadata([quantum_id]).values()
624 self.assertIsInstance(metadata1, TaskMetadata)
625 for _, dataset_id, pipeline_edges in provenance_reader.graph.bipartite_xgraph.out_edges(
626 quantum_id, data="pipeline_edges"
627 ):
628 if pipeline_edges[0].connection_name == acc.METADATA_OUTPUT_CONNECTION_NAME:
629 # Also try reading metadata through the dataset ID.
630 ((metadata2,),) = provenance_reader.fetch_metadata([dataset_id]).values()
631 break
632 else:
633 raise AssertionError("No metadata connection found.")
634 self.assertEqual(metadata1, metadata2)
635 # Also get the metadata from the butler.
636 ref = butler.get_dataset(dataset_id)
637 self.assertEqual(butler.get(ref), metadata1)
639 def check_log(
640 self, quantum_id: uuid.UUID, provenance_reader: ProvenanceQuantumGraphReader, butler: Butler
641 ) -> None:
642 """Check reading a log dataset from the provenance reader,
643 and check that the original log file has been deleted.
645 Parameters
646 ----------
647 quantum_id : `uuid.UUID`
648 Unique ID for the quantum this log belongs to.
649 provenance_reader : \
650 `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraphReader`
651 Reader for the provenance quantum graph.
652 butler : `lsst.daf.butler.Butler`
653 Client for the data repository.
654 """
655 # Try reading log through the quantum ID.
656 ((log1,),) = provenance_reader.fetch_logs([quantum_id]).values()
657 self.assertIsInstance(log1, ButlerLogRecords)
658 for _, dataset_id, pipeline_edges in provenance_reader.graph.bipartite_xgraph.out_edges(
659 quantum_id, data="pipeline_edges"
660 ):
661 if pipeline_edges[0].connection_name == acc.LOG_OUTPUT_CONNECTION_NAME:
662 # Also try reading log through the dataset ID.
663 ((log2,),) = provenance_reader.fetch_logs([dataset_id]).values()
664 break
665 else:
666 raise AssertionError("No log connection found.")
667 self.assertEqual(log1, log2)
668 # Also get the logs from the butler.
669 ref = butler.get_dataset(dataset_id)
670 # We have to compare the logs are lists because the 'extra' data is
671 # different, for now; when the logs are backed by the provenance QG
672 # this will change (but then we might stop ingesting logs at all).
673 self.assertEqual(list(butler.get(ref)), list(log1))
675 def check_packages(self, provenance_reader: ProvenanceQuantumGraphReader, butler: Butler) -> None:
676 """Check fetching package versions from the provenance graph.
678 Parameters
679 ----------
680 provenance_reader : \
681 `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraphReader`
682 Reader for the provenance quantum graph.
683 butler : `lsst.daf.butler.Butler`
684 Client for the data repository.
685 """
686 packages = provenance_reader.fetch_packages()
687 self.assertIsInstance(packages, Packages)
688 self.assertIn("pipe_base", packages)
689 # Also check that the packages are available from the butler. They are
690 # unfortunately not easy to compare because one passes include_all=True
691 # and the other passes include_all=False, and latter adds non-portable
692 # EUPS tag information to the versions.
693 butler_packages = butler.get("packages", collections=[provenance_reader.header.output_run])
694 self.assertFalse(
695 [
696 name
697 for name, (ver1, ver2) in packages.difference(butler_packages).items()
698 if not ver1.startswith(ver2)
699 ]
700 )
702 def check_resource_usage_table(
703 self, prov: ProvenanceQuantumGraph, expect_failure: bool, start_time: float
704 ) -> None:
705 """Check building a resource usage table from the provenance graph.
707 Parameters
708 ----------
709 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph`
710 Reader for the provenance quantum graph.
711 expect_failure : `bool`
712 Whether to expect one quantum of 'calibrate' to fail (`True`) or
713 succeed without writing anything (`False`).
714 start_time : `float`
715 A POSIX timestamp that strictly precedes the start time of any
716 quantum's execution.
717 """
718 tbl = prov.make_task_resource_usage_table("calibrate", include_data_ids=True)
719 self.assertEqual(len(tbl), prov.header.n_task_quanta["calibrate"])
720 self.assertCountEqual(
721 tbl.colnames,
722 ["quantum_id"]
723 + list(prov.pipeline_graph.tasks["calibrate"].dimensions.names)
724 + list(QuantumResourceUsage.model_fields),
725 )
726 # Check that quantum start times are bounded by the before-execution
727 # start_time and now. This makes sure we didn't get any timezone
728 # shenanigans.
729 end_time = time.time()
730 for quantum_start_time in tbl["start"]:
731 self.assertGreater(quantum_start_time, start_time)
732 self.assertLess(quantum_start_time, end_time)
733 self.assertTrue(np.all(tbl["init_time"] >= 0.0))
734 self.assertTrue(np.all(tbl["prep_time"] > 0.0))
735 self.assertTrue(np.all(tbl["run_time"] >= 0.0))
737 def check_quantum_table(self, prov: ProvenanceQuantumGraph, expect_failure: bool) -> None:
738 """Check `ProvenanceQuantumGraph.make_quantum_table`.
740 Parameters
741 ----------
742 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph`
743 Reader for the provenance quantum graph.
744 expect_failure : `bool`
745 Whether to expect one quantum of 'calibrate' to fail (`True`) or
746 succeed without writing anything (`False`).
747 """
748 t = prov.make_quantum_table()
749 self.assertTrue(np.all(t["Unknown"] == 0))
750 self.assertEqual(list(t["Task"]), ["calibrate", "consolidate", "resample", "coadd"])
751 self.assertEqual(t["TOTAL"][0], 8)
752 self.assertEqual(t["EXPECTED"][0], 8)
753 self.assertEqual(t["Blocked"][0], 0)
754 self.assertEqual(t["TOTAL"][1], 2)
755 self.assertEqual(t["EXPECTED"][1], 2)
756 self.assertEqual(t["TOTAL"][2], 10)
757 self.assertEqual(t["EXPECTED"][2], 10)
758 if expect_failure:
759 # calibrate
760 self.assertEqual(t["Successful"][0], 7)
761 self.assertEqual(t["Caveats"][0], "")
762 self.assertEqual(t["Failed"][0], 1)
763 # consolidate
764 self.assertEqual(t["Successful"][1], 1)
765 self.assertEqual(t["Caveats"][1], "")
766 self.assertEqual(t["Failed"][1], 0)
767 self.assertEqual(t["Blocked"][1], 1)
768 # resample
769 self.assertEqual(t["Successful"][2], 6)
770 self.assertEqual(t["Caveats"][2], "")
771 self.assertEqual(t["Failed"][2], 0)
772 self.assertEqual(t["Blocked"][2], 4)
773 else:
774 # calibrate
775 self.assertEqual(t["Successful"][0], 8)
776 self.assertEqual(t["Caveats"][0], "*P(1)")
777 self.assertEqual(t["Failed"][0], 0)
778 # consolidate
779 self.assertEqual(t["Successful"][1], 2)
780 self.assertEqual(t["Caveats"][1], "")
781 self.assertEqual(t["Failed"][1], 0)
782 self.assertEqual(t["Blocked"][1], 0)
783 # resample
784 self.assertEqual(t["Successful"][2], 10)
785 self.assertEqual(t["Caveats"][2], "*A(2)")
786 self.assertEqual(t["Failed"][2], 0)
787 self.assertEqual(t["Blocked"][2], 0)
789 def check_exception_table(self, prov: ProvenanceQuantumGraph, expect_failure: bool) -> None:
790 """Check `ProvenanceQuantumGraph.make_exception_table`.
792 Parameters
793 ----------
794 prov : `lsst.pipe.base.quantum_graph.ProvenanceQuantumGraph`
795 Reader for the provenance quantum graph.
796 expect_failure : `bool`
797 Whether to expect one quantum of 'calibrate' to fail (`True`) or
798 succeed without writing anything (`False`).
799 """
800 t = prov.make_exception_table()
801 self.assertEqual(list(t["Task"]), ["calibrate"])
802 self.assertEqual(list(t["Exception"]), ["lsst.pipe.base.tests.mocks.MockAlgorithmError"])
803 self.assertEqual(list(t["Count"]), [1])
805 def test_all_successful(self) -> None:
806 """Test running a full graph with no failures, and then scanning the
807 results with assume_complete=False.
809 Because there are no failures, assume_complete should not be necessary.
810 """
811 with self.make_test_repo() as prep:
812 prep.config.assume_complete = False
813 start_time = time.time()
814 attempted_quanta = list(
815 self.iter_graph_execution(prep.butler_path, prep.predicted, raise_on_partial_outputs=False)
816 )
817 self.assertCountEqual(attempted_quanta, prep.predicted.quantum_only_xgraph.nodes.keys())
818 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config)
819 with ProvenanceQuantumGraphReader.open(prep.config.output_path) as reader:
820 prov = self.check_provenance_graph(
821 prep.predicted,
822 reader,
823 prep.butler,
824 expect_failure=False,
825 start_time=start_time,
826 )
827 for i, quantum_id in enumerate(attempted_quanta):
828 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id]
829 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, attempted_quanta[:i])
831 def test_all_successful_two_phase(self) -> None:
832 """Test running some of a graph with no failures, scanning with
833 assume_complete=False, then finishing the graph and scanning again.
834 """
835 with self.make_test_repo() as prep:
836 start_time = time.time()
837 execution_iter = self.iter_graph_execution(
838 prep.butler_path, prep.predicted, raise_on_partial_outputs=False
839 )
840 attempted_quanta = list(itertools.islice(execution_iter, 9))
841 self.assertEqual(len(attempted_quanta), 9)
842 # Run the scanner while telling it to assume failures might change,
843 # so it just abandons incomplete quanta.
844 prep.config.assume_complete = False
845 with self.assertRaises(RuntimeError):
846 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config)
847 # Finish executing the quanta.
848 attempted_quanta.extend(execution_iter)
849 # Scan again, and write the provenance QG.
850 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config)
851 # Run the scanner again.
852 with ProvenanceQuantumGraphReader.open(prep.config.output_path) as reader:
853 prov = self.check_provenance_graph(
854 prep.predicted,
855 reader,
856 prep.butler,
857 expect_failure=False,
858 start_time=start_time,
859 )
860 for i, quantum_id in enumerate(attempted_quanta):
861 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id]
862 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, attempted_quanta[:i])
864 def test_some_failed(self) -> None:
865 """Test running a full graph with some failures, and then scanning the
866 results with assume_complete=True.
867 """
868 with self.make_test_repo() as prep:
869 prep.config.assume_complete = True
870 start_time = time.time()
871 attempted_quanta = list(
872 self.iter_graph_execution(prep.butler_path, prep.predicted, raise_on_partial_outputs=True)
873 )
874 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config)
875 with ProvenanceQuantumGraphReader.open(prep.config.output_path) as reader:
876 prov = self.check_provenance_graph(
877 prep.predicted,
878 reader,
879 prep.butler,
880 expect_failure=True,
881 start_time=start_time,
882 )
883 for i, quantum_id in enumerate(attempted_quanta):
884 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id]
885 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, attempted_quanta[:i])
887 def test_some_failed_two_phase(self) -> None:
888 """Test running a full graph with some failures, then scanning the
889 results with assume_complete=False, then scanning again with
890 assume_complete=True.
891 """
892 with self.make_test_repo() as prep:
893 start_time = time.time()
894 attempted_quanta = list(
895 self.iter_graph_execution(prep.butler_path, prep.predicted, raise_on_partial_outputs=True)
896 )
897 prep.config.assume_complete = False
898 with self.assertRaisesRegex(RuntimeError, "1 quantum abandoned"):
899 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config)
900 prep.config.assume_complete = True
901 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config)
902 with ProvenanceQuantumGraphReader.open(prep.config.output_path) as reader:
903 prov = self.check_provenance_graph(
904 prep.predicted,
905 reader,
906 prep.butler,
907 expect_failure=True,
908 start_time=start_time,
909 )
910 for i, quantum_id in enumerate(attempted_quanta):
911 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id]
912 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, attempted_quanta[:i])
914 def test_retry(self) -> None:
915 """Test running a full graph with some failures, rerunning the quanta
916 that failed or were blocked in the first attempt, and then scanning
917 for provenance.
918 """
919 with self.make_test_repo() as prep:
920 prep.config.assume_complete = True
921 start_time = time.time()
922 attempted_quanta_1 = list(
923 self.iter_graph_execution(prep.butler_path, prep.predicted, raise_on_partial_outputs=True)
924 )
925 attempted_quanta_2 = list(
926 self.iter_graph_execution(
927 prep.butler_path, prep.predicted, raise_on_partial_outputs=False, is_retry=True
928 )
929 )
930 aggregate_graph(prep.predicted_path, prep.butler_path, prep.config)
931 with ProvenanceQuantumGraphReader.open(prep.config.output_path) as reader:
932 prov = self.check_provenance_graph(
933 prep.predicted,
934 reader,
935 prep.butler,
936 expect_failure=False,
937 start_time=start_time,
938 expect_failures_retried=True,
939 )
940 for i, quantum_id in enumerate(attempted_quanta_1):
941 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id]
942 self.assertEqual(qinfo["attempts"][0].previous_process_quanta, attempted_quanta_1[:i])
943 expected: list[uuid.UUID] = []
944 for quantum_id in attempted_quanta_2:
945 qinfo: ProvenanceQuantumInfo = prov.quantum_only_xgraph.nodes[quantum_id]
946 if (
947 quantum_id in attempted_quanta_1
948 and qinfo["attempts"][0].status is QuantumAttemptStatus.SUCCESSFUL
949 ):
950 # These weren't actually attempted twice, since they
951 # were already successful in the first round.
952 self.assertEqual(len(qinfo["attempts"]), 1)
953 else:
954 self.assertEqual(qinfo["attempts"][-1].previous_process_quanta, expected)
955 expected.append(quantum_id)
957 def test_worker_failures(self) -> None:
958 """Test that if failures occur on (multiple) workers we shut down
959 gracefully instead of hanging.
960 """
961 with self.make_test_repo() as prep:
962 with self.assertRaises(FatalWorkerError):
963 aggregate_graph(prep.predicted_path, "nonexistent", prep.config)
965 def test_cli_overrides(self) -> None:
966 """Test that command-line options override config attributes as
967 expected.
968 """
970 def mock_run(predicted_path: str, butler_path: str, config: AggregatorConfig) -> None:
971 print(config.model_dump_json(indent=2))
973 def check(result: Result, **kwargs: object) -> None:
974 self.assertEqual(result.exit_code, 0, msg=result.output)
975 self.assertEqual(
976 result.output.strip(), AggregatorConfig(**kwargs).model_dump_json(indent=2).strip()
977 )
979 self.maxDiff = None
980 runner = CliRunner()
981 with unittest.mock.patch("lsst.pipe.base.quantum_graph.aggregator.aggregate_graph", mock_run):
982 check(runner.invoke(aggregate_graph_cli, ("pg", "repo")))
983 check(runner.invoke(aggregate_graph_cli, ("pg", "repo", "--output", "out")), output_path="out")
984 check(runner.invoke(aggregate_graph_cli, ("pg", "repo", "-j", "4")), n_processes=4)
985 check(
986 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--incomplete")),
987 assume_complete=False,
988 )
989 check(runner.invoke(aggregate_graph_cli, ("pg", "repo", "--dry-run")), dry_run=True)
990 check(
991 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--interactive-status")),
992 interactive_status=True,
993 )
994 check(
995 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--log-status-interval", "120")),
996 log_status_interval=120,
997 )
998 check(
999 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--no-register-dataset-types")),
1000 register_dataset_types=False,
1001 )
1002 check(
1003 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--no-update-output-chain")),
1004 update_output_chain=False,
1005 )
1006 check(
1007 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--worker-log-dir", "wlogs")),
1008 worker_log_dir="wlogs",
1009 )
1010 check(
1011 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--worker-log-level", "DEBUG")),
1012 worker_log_level="DEBUG",
1013 )
1014 check(
1015 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--zstd-level", "11")),
1016 zstd_level=11,
1017 )
1018 check(
1019 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--zstd-dict-size", "143")),
1020 zstd_dict_size=143,
1021 )
1022 check(
1023 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--zstd-dict-n-inputs", "2")),
1024 zstd_dict_n_inputs=2,
1025 )
1026 check(
1027 runner.invoke(aggregate_graph_cli, ("pg", "repo", "--mock-storage-classes")),
1028 mock_storage_classes=True,
1029 )
1032if __name__ == "__main__":
1033 lsst.utils.tests.init()
1034 unittest.main()