lsst-pipe-base 30.2026.200__py3-none-any.whl → 30.2026.400__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. lsst/pipe/base/_instrument.py +10 -12
  2. lsst/pipe/base/_status.py +29 -10
  3. lsst/pipe/base/automatic_connection_constants.py +9 -1
  4. lsst/pipe/base/cli/cmd/__init__.py +16 -2
  5. lsst/pipe/base/cli/cmd/commands.py +42 -4
  6. lsst/pipe/base/connectionTypes.py +72 -160
  7. lsst/pipe/base/connections.py +3 -6
  8. lsst/pipe/base/execution_reports.py +0 -5
  9. lsst/pipe/base/log_capture.py +8 -4
  10. lsst/pipe/base/log_on_close.py +79 -0
  11. lsst/pipe/base/mp_graph_executor.py +51 -15
  12. lsst/pipe/base/pipeline.py +3 -4
  13. lsst/pipe/base/pipelineIR.py +0 -6
  14. lsst/pipe/base/pipelineTask.py +5 -7
  15. lsst/pipe/base/pipeline_graph/_edges.py +19 -7
  16. lsst/pipe/base/pipeline_graph/_pipeline_graph.py +8 -0
  17. lsst/pipe/base/quantum_graph/_common.py +7 -4
  18. lsst/pipe/base/quantum_graph/_multiblock.py +6 -16
  19. lsst/pipe/base/quantum_graph/_predicted.py +111 -10
  20. lsst/pipe/base/quantum_graph/_provenance.py +727 -26
  21. lsst/pipe/base/quantum_graph/aggregator/_communicators.py +26 -50
  22. lsst/pipe/base/quantum_graph/aggregator/_config.py +78 -9
  23. lsst/pipe/base/quantum_graph/aggregator/_ingester.py +12 -11
  24. lsst/pipe/base/quantum_graph/aggregator/_scanner.py +48 -234
  25. lsst/pipe/base/quantum_graph/aggregator/_structs.py +6 -116
  26. lsst/pipe/base/quantum_graph/aggregator/_supervisor.py +24 -18
  27. lsst/pipe/base/quantum_graph/aggregator/_writer.py +33 -350
  28. lsst/pipe/base/quantum_graph/formatter.py +171 -0
  29. lsst/pipe/base/quantum_graph/ingest_graph.py +356 -0
  30. lsst/pipe/base/quantum_graph_executor.py +116 -13
  31. lsst/pipe/base/quantum_provenance_graph.py +17 -2
  32. lsst/pipe/base/separable_pipeline_executor.py +18 -2
  33. lsst/pipe/base/single_quantum_executor.py +59 -41
  34. lsst/pipe/base/struct.py +4 -0
  35. lsst/pipe/base/version.py +1 -1
  36. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/METADATA +2 -1
  37. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/RECORD +45 -42
  38. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/WHEEL +1 -1
  39. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/entry_points.txt +0 -0
  40. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/licenses/COPYRIGHT +0 -0
  41. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/licenses/LICENSE +0 -0
  42. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/licenses/bsd_license.txt +0 -0
  43. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/licenses/gpl-v3.0.txt +0 -0
  44. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/top_level.txt +0 -0
  45. {lsst_pipe_base-30.2026.200.dist-info → lsst_pipe_base-30.2026.400.dist-info}/zip-safe +0 -0
@@ -35,19 +35,25 @@ __all__ = (
35
35
  "ProvenanceLogRecordsModel",
36
36
  "ProvenanceQuantumGraph",
37
37
  "ProvenanceQuantumGraphReader",
38
+ "ProvenanceQuantumGraphWriter",
38
39
  "ProvenanceQuantumInfo",
39
40
  "ProvenanceQuantumModel",
41
+ "ProvenanceQuantumScanData",
42
+ "ProvenanceQuantumScanModels",
43
+ "ProvenanceQuantumScanStatus",
40
44
  "ProvenanceTaskMetadataModel",
41
45
  )
42
46
 
43
47
 
44
48
  import dataclasses
49
+ import enum
50
+ import itertools
45
51
  import sys
46
52
  import uuid
47
53
  from collections import Counter
48
- from collections.abc import Iterable, Iterator, Mapping
49
- from contextlib import contextmanager
50
- from typing import TYPE_CHECKING, Any, TypedDict, TypeVar
54
+ from collections.abc import Callable, Iterable, Iterator, Mapping
55
+ from contextlib import ExitStack, contextmanager
56
+ from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict, TypeVar
51
57
 
52
58
  import astropy.table
53
59
  import networkx
@@ -57,15 +63,21 @@ import pydantic
57
63
  from lsst.daf.butler import DataCoordinate
58
64
  from lsst.daf.butler.logging import ButlerLogRecord, ButlerLogRecords
59
65
  from lsst.resources import ResourcePathExpression
66
+ from lsst.utils.iteration import ensure_iterable
67
+ from lsst.utils.logging import LsstLogAdapter, getLogger
60
68
  from lsst.utils.packages import Packages
61
69
 
70
+ from .. import automatic_connection_constants as acc
62
71
  from .._status import ExceptionInfo, QuantumAttemptStatus, QuantumSuccessCaveats
63
72
  from .._task_metadata import TaskMetadata
73
+ from ..log_capture import _ExecutionLogRecordsExtra
74
+ from ..log_on_close import LogOnClose
64
75
  from ..pipeline_graph import PipelineGraph, TaskImportMode, TaskInitNode
65
76
  from ..resource_usage import QuantumResourceUsage
66
77
  from ._common import (
67
78
  BaseQuantumGraph,
68
79
  BaseQuantumGraphReader,
80
+ BaseQuantumGraphWriter,
69
81
  ConnectionName,
70
82
  DataCoordinateValues,
71
83
  DatasetInfo,
@@ -74,8 +86,26 @@ from ._common import (
74
86
  QuantumInfo,
75
87
  TaskLabel,
76
88
  )
77
- from ._multiblock import MultiblockReader
78
- from ._predicted import PredictedDatasetModel, PredictedQuantumDatasetsModel
89
+ from ._multiblock import Compressor, MultiblockReader, MultiblockWriter
90
+ from ._predicted import (
91
+ PredictedDatasetModel,
92
+ PredictedQuantumDatasetsModel,
93
+ PredictedQuantumGraph,
94
+ PredictedQuantumGraphComponents,
95
+ )
96
+
97
+ # Sphinx needs imports for type annotations of base class members.
98
+ if "sphinx" in sys.modules:
99
+ import zipfile # noqa: F401
100
+
101
+ from ._multiblock import AddressReader, Decompressor # noqa: F401
102
+
103
+
104
+ _T = TypeVar("_T")
105
+
106
+ LoopWrapper: TypeAlias = Callable[[Iterable[_T]], Iterable[_T]]
107
+
108
+ _LOG = getLogger(__file__)
79
109
 
80
110
  DATASET_ADDRESS_INDEX = 0
81
111
  QUANTUM_ADDRESS_INDEX = 1
@@ -87,7 +117,9 @@ QUANTUM_MB_NAME = "quanta"
87
117
  LOG_MB_NAME = "logs"
88
118
  METADATA_MB_NAME = "metadata"
89
119
 
90
- _I = TypeVar("_I", bound=uuid.UUID | int)
120
+
121
+ def pass_through(arg: _T) -> _T:
122
+ return arg
91
123
 
92
124
 
93
125
  class ProvenanceDatasetInfo(DatasetInfo):
@@ -161,6 +193,12 @@ class ProvenanceQuantumInfo(QuantumInfo):
161
193
  failure.
162
194
  """
163
195
 
196
+ metadata_id: uuid.UUID
197
+ """ID of this quantum's metadata dataset."""
198
+
199
+ log_id: uuid.UUID
200
+ """ID of this quantum's log dataset."""
201
+
164
202
 
165
203
  class ProvenanceInitQuantumInfo(TypedDict):
166
204
  """A typed dictionary that annotates the attributes of the NetworkX graph
@@ -187,6 +225,9 @@ class ProvenanceInitQuantumInfo(TypedDict):
187
225
  pipeline_node: TaskInitNode
188
226
  """Node in the pipeline graph for this task's init-only step."""
189
227
 
228
+ config_id: uuid.UUID
229
+ """ID of this task's config dataset."""
230
+
190
231
 
191
232
  class ProvenanceDatasetModel(PredictedDatasetModel):
192
233
  """Data model for the datasets in a provenance quantum graph file."""
@@ -621,6 +662,8 @@ class ProvenanceQuantumModel(pydantic.BaseModel):
621
662
  resource_usage=last_attempt.resource_usage,
622
663
  attempts=self.attempts,
623
664
  )
665
+ graph._quanta_by_task_label[self.task_label][data_id] = self.quantum_id
666
+ graph._quantum_only_xgraph.add_node(self.quantum_id, **graph._bipartite_xgraph.nodes[self.quantum_id])
624
667
  for connection_name, dataset_ids in self.inputs.items():
625
668
  read_edge = task_node.get_input_edge(connection_name)
626
669
  for dataset_id in dataset_ids:
@@ -630,6 +673,30 @@ class ProvenanceQuantumModel(pydantic.BaseModel):
630
673
  ).append(read_edge)
631
674
  for connection_name, dataset_ids in self.outputs.items():
632
675
  write_edge = task_node.get_output_edge(connection_name)
676
+ if connection_name == acc.METADATA_OUTPUT_CONNECTION_NAME:
677
+ graph._bipartite_xgraph.add_node(
678
+ dataset_ids[0],
679
+ data_id=data_id,
680
+ dataset_type_name=write_edge.dataset_type_name,
681
+ pipeline_node=graph.pipeline_graph.dataset_types[write_edge.dataset_type_name],
682
+ run=graph.header.output_run,
683
+ produced=last_attempt.status.has_metadata,
684
+ )
685
+ graph._datasets_by_type[write_edge.dataset_type_name][data_id] = dataset_ids[0]
686
+ graph._bipartite_xgraph.nodes[self.quantum_id]["metadata_id"] = dataset_ids[0]
687
+ graph._quantum_only_xgraph.nodes[self.quantum_id]["metadata_id"] = dataset_ids[0]
688
+ if connection_name == acc.LOG_OUTPUT_CONNECTION_NAME:
689
+ graph._bipartite_xgraph.add_node(
690
+ dataset_ids[0],
691
+ data_id=data_id,
692
+ dataset_type_name=write_edge.dataset_type_name,
693
+ pipeline_node=graph.pipeline_graph.dataset_types[write_edge.dataset_type_name],
694
+ run=graph.header.output_run,
695
+ produced=last_attempt.status.has_log,
696
+ )
697
+ graph._datasets_by_type[write_edge.dataset_type_name][data_id] = dataset_ids[0]
698
+ graph._bipartite_xgraph.nodes[self.quantum_id]["log_id"] = dataset_ids[0]
699
+ graph._quantum_only_xgraph.nodes[self.quantum_id]["log_id"] = dataset_ids[0]
633
700
  for dataset_id in dataset_ids:
634
701
  graph._bipartite_xgraph.add_edge(
635
702
  self.quantum_id,
@@ -638,8 +705,6 @@ class ProvenanceQuantumModel(pydantic.BaseModel):
638
705
  # There can only be one pipeline edge for an output.
639
706
  pipeline_edges=[write_edge],
640
707
  )
641
- graph._quanta_by_task_label[self.task_label][data_id] = self.quantum_id
642
- graph._quantum_only_xgraph.add_node(self.quantum_id, **graph._bipartite_xgraph.nodes[self.quantum_id])
643
708
  for dataset_id in graph._bipartite_xgraph.predecessors(self.quantum_id):
644
709
  for upstream_quantum_id in graph._bipartite_xgraph.predecessors(dataset_id):
645
710
  graph._quantum_only_xgraph.add_edge(upstream_quantum_id, self.quantum_id)
@@ -778,6 +843,15 @@ class ProvenanceInitQuantumModel(pydantic.BaseModel):
778
843
  ).append(read_edge)
779
844
  for connection_name, dataset_id in self.outputs.items():
780
845
  write_edge = task_init_node.get_output_edge(connection_name)
846
+ graph._bipartite_xgraph.add_node(
847
+ dataset_id,
848
+ data_id=empty_data_id,
849
+ dataset_type_name=write_edge.dataset_type_name,
850
+ pipeline_node=graph.pipeline_graph.dataset_types[write_edge.dataset_type_name],
851
+ run=graph.header.output_run,
852
+ produced=True,
853
+ )
854
+ graph._datasets_by_type[write_edge.dataset_type_name][empty_data_id] = dataset_id
781
855
  graph._bipartite_xgraph.add_edge(
782
856
  self.quantum_id,
783
857
  dataset_id,
@@ -785,6 +859,8 @@ class ProvenanceInitQuantumModel(pydantic.BaseModel):
785
859
  # There can only be one pipeline edge for an output.
786
860
  pipeline_edges=[write_edge],
787
861
  )
862
+ if write_edge.connection_name == acc.CONFIG_INIT_OUTPUT_CONNECTION_NAME:
863
+ graph._bipartite_xgraph.nodes[self.quantum_id]["config_id"] = dataset_id
788
864
  graph._init_quanta[self.task_label] = self.quantum_id
789
865
 
790
866
  # Work around the fact that Sphinx chokes on Pydantic docstring formatting,
@@ -969,6 +1045,8 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
969
1045
  types in the pipeline graph are included, even if none of their
970
1046
  datasets were loaded (i.e. nested mappings may be empty).
971
1047
 
1048
+ Reading a quantum also populates its log and metadata datasets.
1049
+
972
1050
  The returned object may be an internal dictionary; as the type
973
1051
  annotation indicates, it should not be modified in place.
974
1052
  """
@@ -1007,7 +1085,8 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
1007
1085
  `ProvenanceQuantumGraphReader.read_quanta`) or datasets (via
1008
1086
  `ProvenanceQuantumGraphReader.read_datasets`) will load those nodes
1009
1087
  with full attributes and edges to adjacent nodes with no attributes.
1010
- Loading quanta necessary to populate edge attributes.
1088
+ Loading quanta is necessary to populate edge attributes.
1089
+ Reading a quantum also populates its log and metadata datasets.
1011
1090
 
1012
1091
  Node attributes are described by the
1013
1092
  `ProvenanceQuantumInfo`, `ProvenanceInitQuantumInfo`, and
@@ -1079,10 +1158,6 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
1079
1158
  """Construct an `astropy.table.Table` with counts for each exception
1080
1159
  type raised by each task.
1081
1160
 
1082
- At present this only includes information from partial-outputs-error
1083
- successes, since exception information for failures is not tracked.
1084
- This may change in the future.
1085
-
1086
1161
  Returns
1087
1162
  -------
1088
1163
  table : `astropy.table.Table`
@@ -1269,19 +1344,19 @@ class ProvenanceQuantumGraphReader(BaseQuantumGraphReader):
1269
1344
  # also have other outstanding reference holders).
1270
1345
  continue
1271
1346
  node._add_to_graph(self.graph)
1272
- return
1273
- with MultiblockReader.open_in_zip(self.zf, mb_name, int_size=self.header.int_size) as mb_reader:
1274
- for node_id_or_index in nodes:
1275
- address_row = self.address_reader.find(node_id_or_index)
1276
- if "pipeline_node" in self.graph._bipartite_xgraph.nodes.get(address_row.key, {}):
1277
- # Use the old node to reduce memory usage (since it might
1278
- # also have other outstanding reference holders).
1279
- continue
1280
- node = mb_reader.read_model(
1281
- address_row.addresses[address_index], model_type, self.decompressor
1282
- )
1283
- if node is not None:
1284
- node._add_to_graph(self.graph)
1347
+ else:
1348
+ with MultiblockReader.open_in_zip(self.zf, mb_name, int_size=self.header.int_size) as mb_reader:
1349
+ for node_id_or_index in nodes:
1350
+ address_row = self.address_reader.find(node_id_or_index)
1351
+ if "pipeline_node" in self.graph._bipartite_xgraph.nodes.get(address_row.key, {}):
1352
+ # Use the old node to reduce memory usage (since it
1353
+ # might also have other outstanding reference holders).
1354
+ continue
1355
+ node = mb_reader.read_model(
1356
+ address_row.addresses[address_index], model_type, self.decompressor
1357
+ )
1358
+ if node is not None:
1359
+ node._add_to_graph(self.graph)
1285
1360
 
1286
1361
  def fetch_logs(self, nodes: Iterable[uuid.UUID]) -> dict[uuid.UUID, list[ButlerLogRecords | None]]:
1287
1362
  """Fetch log datasets.
@@ -1352,3 +1427,629 @@ class ProvenanceQuantumGraphReader(BaseQuantumGraphReader):
1352
1427
  """Fetch package version information."""
1353
1428
  data = self._read_single_block_raw("packages")
1354
1429
  return Packages.fromBytes(data, format="json")
1430
+
1431
+
1432
+ class ProvenanceQuantumGraphWriter:
1433
+ """A struct of low-level writer objects for the main components of a
1434
+ provenance quantum graph.
1435
+
1436
+ Parameters
1437
+ ----------
1438
+ output_path : `str`
1439
+ Path to write the graph to.
1440
+ exit_stack : `contextlib.ExitStack`
1441
+ Object that can be used to manage multiple context managers.
1442
+ log_on_close : `LogOnClose`
1443
+ Factory for context managers that log when closed.
1444
+ predicted : `.PredictedQuantumGraphComponents`
1445
+ Components of the predicted graph.
1446
+ zstd_level : `int`, optional
1447
+ Compression level.
1448
+ cdict_data : `bytes` or `None`, optional
1449
+ Bytes representation of the compression dictionary used by the
1450
+ compressor.
1451
+ loop_wrapper : `~collections.abc.Callable`, optional
1452
+ A callable that takes an iterable and returns an equivalent one, to be
1453
+ used in all potentially-large loops. This can be used to add progress
1454
+ reporting or check for cancelation signals.
1455
+ log : `LsstLogAdapter`, optional
1456
+ Logger to use for debug messages.
1457
+ """
1458
+
1459
+ def __init__(
1460
+ self,
1461
+ output_path: str,
1462
+ *,
1463
+ exit_stack: ExitStack,
1464
+ log_on_close: LogOnClose,
1465
+ predicted: PredictedQuantumGraphComponents | PredictedQuantumGraph,
1466
+ zstd_level: int = 10,
1467
+ cdict_data: bytes | None = None,
1468
+ loop_wrapper: LoopWrapper = pass_through,
1469
+ log: LsstLogAdapter | None = None,
1470
+ ) -> None:
1471
+ header = predicted.header.model_copy()
1472
+ header.graph_type = "provenance"
1473
+ if log is None:
1474
+ log = _LOG
1475
+ self.log = log
1476
+ self._base_writer = exit_stack.enter_context(
1477
+ log_on_close.wrap(
1478
+ BaseQuantumGraphWriter.open(
1479
+ output_path,
1480
+ header,
1481
+ predicted.pipeline_graph,
1482
+ address_filename="nodes",
1483
+ zstd_level=zstd_level,
1484
+ cdict_data=cdict_data,
1485
+ ),
1486
+ "Finishing writing provenance quantum graph.",
1487
+ )
1488
+ )
1489
+ self._base_writer.address_writer.addresses = [{}, {}, {}, {}]
1490
+ self._log_writer = exit_stack.enter_context(
1491
+ log_on_close.wrap(
1492
+ MultiblockWriter.open_in_zip(
1493
+ self._base_writer.zf, LOG_MB_NAME, header.int_size, use_tempfile=True
1494
+ ),
1495
+ "Copying logs into zip archive.",
1496
+ ),
1497
+ )
1498
+ self._base_writer.address_writer.addresses[LOG_ADDRESS_INDEX] = self._log_writer.addresses
1499
+ self._metadata_writer = exit_stack.enter_context(
1500
+ log_on_close.wrap(
1501
+ MultiblockWriter.open_in_zip(
1502
+ self._base_writer.zf, METADATA_MB_NAME, header.int_size, use_tempfile=True
1503
+ ),
1504
+ "Copying metadata into zip archive.",
1505
+ )
1506
+ )
1507
+ self._base_writer.address_writer.addresses[METADATA_ADDRESS_INDEX] = self._metadata_writer.addresses
1508
+ self._dataset_writer = exit_stack.enter_context(
1509
+ log_on_close.wrap(
1510
+ MultiblockWriter.open_in_zip(
1511
+ self._base_writer.zf, DATASET_MB_NAME, header.int_size, use_tempfile=True
1512
+ ),
1513
+ "Copying dataset provenance into zip archive.",
1514
+ )
1515
+ )
1516
+ self._base_writer.address_writer.addresses[DATASET_ADDRESS_INDEX] = self._dataset_writer.addresses
1517
+ self._quantum_writer = exit_stack.enter_context(
1518
+ log_on_close.wrap(
1519
+ MultiblockWriter.open_in_zip(
1520
+ self._base_writer.zf, QUANTUM_MB_NAME, header.int_size, use_tempfile=True
1521
+ ),
1522
+ "Copying quantum provenance into zip archive.",
1523
+ )
1524
+ )
1525
+ self._base_writer.address_writer.addresses[QUANTUM_ADDRESS_INDEX] = self._quantum_writer.addresses
1526
+ self._init_predicted_quanta(predicted)
1527
+ self._populate_xgraph_and_inputs(loop_wrapper)
1528
+ self._existing_init_outputs: set[uuid.UUID] = set()
1529
+
1530
+ def _init_predicted_quanta(
1531
+ self, predicted: PredictedQuantumGraph | PredictedQuantumGraphComponents
1532
+ ) -> None:
1533
+ self._predicted_init_quanta: list[PredictedQuantumDatasetsModel] = []
1534
+ self._predicted_quanta: dict[uuid.UUID, PredictedQuantumDatasetsModel] = {}
1535
+ if isinstance(predicted, PredictedQuantumGraph):
1536
+ self._predicted_init_quanta.extend(predicted._init_quanta.values())
1537
+ self._predicted_quanta.update(predicted._quantum_datasets)
1538
+ else:
1539
+ self._predicted_init_quanta.extend(predicted.init_quanta.root)
1540
+ self._predicted_quanta.update(predicted.quantum_datasets)
1541
+ self._predicted_quanta.update({q.quantum_id: q for q in self._predicted_init_quanta})
1542
+
1543
+ def _populate_xgraph_and_inputs(self, loop_wrapper: LoopWrapper = pass_through) -> None:
1544
+ self._xgraph = networkx.DiGraph()
1545
+ self._overall_inputs: dict[uuid.UUID, PredictedDatasetModel] = {}
1546
+ output_dataset_ids: set[uuid.UUID] = set()
1547
+ for predicted_quantum in loop_wrapper(self._predicted_quanta.values()):
1548
+ if not predicted_quantum.task_label:
1549
+ # Skip the 'packages' producer quantum.
1550
+ continue
1551
+ output_dataset_ids.update(predicted_quantum.iter_output_dataset_ids())
1552
+ for predicted_quantum in loop_wrapper(self._predicted_quanta.values()):
1553
+ if not predicted_quantum.task_label:
1554
+ # Skip the 'packages' producer quantum.
1555
+ continue
1556
+ for predicted_input in itertools.chain.from_iterable(predicted_quantum.inputs.values()):
1557
+ self._xgraph.add_edge(predicted_input.dataset_id, predicted_quantum.quantum_id)
1558
+ if predicted_input.dataset_id not in output_dataset_ids:
1559
+ self._overall_inputs.setdefault(predicted_input.dataset_id, predicted_input)
1560
+ for predicted_output in itertools.chain.from_iterable(predicted_quantum.outputs.values()):
1561
+ self._xgraph.add_edge(predicted_quantum.quantum_id, predicted_output.dataset_id)
1562
+
1563
+ @property
1564
+ def compressor(self) -> Compressor:
1565
+ """Object that should be used to compress all JSON blocks."""
1566
+ return self._base_writer.compressor
1567
+
1568
+ def write_packages(self) -> None:
1569
+ """Write package version information to the provenance graph."""
1570
+ packages = Packages.fromSystem(include_all=True)
1571
+ data = packages.toBytes("json")
1572
+ self._base_writer.write_single_block("packages", data)
1573
+
1574
+ def write_overall_inputs(self, loop_wrapper: LoopWrapper = pass_through) -> None:
1575
+ """Write provenance for overall-input datasets.
1576
+
1577
+ Parameters
1578
+ ----------
1579
+ loop_wrapper : `~collections.abc.Callable`, optional
1580
+ A callable that takes an iterable and returns an equivalent one, to
1581
+ be used in all potentially-large loops. This can be used to add
1582
+ progress reporting or check for cancelation signals.
1583
+ """
1584
+ for predicted_input in loop_wrapper(self._overall_inputs.values()):
1585
+ if predicted_input.dataset_id not in self._dataset_writer.addresses:
1586
+ self._dataset_writer.write_model(
1587
+ predicted_input.dataset_id,
1588
+ ProvenanceDatasetModel.from_predicted(
1589
+ predicted_input,
1590
+ producer=None,
1591
+ consumers=self._xgraph.successors(predicted_input.dataset_id),
1592
+ ),
1593
+ self.compressor,
1594
+ )
1595
+ del self._overall_inputs
1596
+
1597
+ def write_init_outputs(self, assume_existence: bool = True) -> None:
1598
+ """Write provenance for init-output datasets and init-quanta.
1599
+
1600
+ Parameters
1601
+ ----------
1602
+ assume_existence : `bool`, optional
1603
+ If `True`, just assume all init-outputs exist.
1604
+ """
1605
+ init_quanta = ProvenanceInitQuantaModel()
1606
+ for predicted_init_quantum in self._predicted_init_quanta:
1607
+ if not predicted_init_quantum.task_label:
1608
+ # Skip the 'packages' producer quantum.
1609
+ continue
1610
+ for predicted_output in itertools.chain.from_iterable(predicted_init_quantum.outputs.values()):
1611
+ provenance_output = ProvenanceDatasetModel.from_predicted(
1612
+ predicted_output,
1613
+ producer=predicted_init_quantum.quantum_id,
1614
+ consumers=self._xgraph.successors(predicted_output.dataset_id),
1615
+ )
1616
+ provenance_output.produced = assume_existence or (
1617
+ provenance_output.dataset_id in self._existing_init_outputs
1618
+ )
1619
+ self._dataset_writer.write_model(
1620
+ provenance_output.dataset_id, provenance_output, self.compressor
1621
+ )
1622
+ init_quanta.root.append(ProvenanceInitQuantumModel.from_predicted(predicted_init_quantum))
1623
+ self._base_writer.write_single_model("init_quanta", init_quanta)
1624
+
1625
+ def write_quantum_provenance(
1626
+ self, quantum_id: uuid.UUID, metadata: TaskMetadata | None, logs: ButlerLogRecords | None
1627
+ ) -> None:
1628
+ """Gather and write provenance for a quantum.
1629
+
1630
+ Parameters
1631
+ ----------
1632
+ quantum_id : `uuid.UUID`
1633
+ Unique ID for the quantum.
1634
+ metadata : `..TaskMetadata` or `None`
1635
+ Task metadata.
1636
+ logs : `lsst.daf.butler.logging.ButlerLogRecords` or `None`
1637
+ Task logs.
1638
+ """
1639
+ predicted_quantum = self._predicted_quanta[quantum_id]
1640
+ provenance_models = ProvenanceQuantumScanModels.from_metadata_and_logs(
1641
+ predicted_quantum, metadata, logs, incomplete=False
1642
+ )
1643
+ scan_data = provenance_models.to_scan_data(predicted_quantum, compressor=self.compressor)
1644
+ self.write_scan_data(scan_data)
1645
+
1646
+ def write_scan_data(self, scan_data: ProvenanceQuantumScanData) -> None:
1647
+ """Write the output of a quantum provenance scan to disk.
1648
+
1649
+ Parameters
1650
+ ----------
1651
+ scan_data : `ProvenanceQuantumScanData`
1652
+ Result of a quantum provenance scan.
1653
+ """
1654
+ if scan_data.status is ProvenanceQuantumScanStatus.INIT:
1655
+ self.log.debug("Handling init-output scan for %s.", scan_data.quantum_id)
1656
+ self._existing_init_outputs.update(scan_data.existing_outputs)
1657
+ return
1658
+ self.log.debug("Handling quantum scan for %s.", scan_data.quantum_id)
1659
+ # We shouldn't need this predicted quantum after this method runs; pop
1660
+ # from the dict it in the hopes that'll free up some memory when we're
1661
+ # done.
1662
+ predicted_quantum = self._predicted_quanta.pop(scan_data.quantum_id)
1663
+ outputs: dict[uuid.UUID, bytes] = {}
1664
+ for predicted_output in itertools.chain.from_iterable(predicted_quantum.outputs.values()):
1665
+ provenance_output = ProvenanceDatasetModel.from_predicted(
1666
+ predicted_output,
1667
+ producer=predicted_quantum.quantum_id,
1668
+ consumers=self._xgraph.successors(predicted_output.dataset_id),
1669
+ )
1670
+ provenance_output.produced = provenance_output.dataset_id in scan_data.existing_outputs
1671
+ outputs[provenance_output.dataset_id] = self.compressor.compress(
1672
+ provenance_output.model_dump_json().encode()
1673
+ )
1674
+ if not scan_data.quantum:
1675
+ scan_data.quantum = (
1676
+ ProvenanceQuantumModel.from_predicted(predicted_quantum).model_dump_json().encode()
1677
+ )
1678
+ if scan_data.is_compressed:
1679
+ scan_data.quantum = self.compressor.compress(scan_data.quantum)
1680
+ if not scan_data.is_compressed:
1681
+ scan_data.quantum = self.compressor.compress(scan_data.quantum)
1682
+ if scan_data.metadata:
1683
+ scan_data.metadata = self.compressor.compress(scan_data.metadata)
1684
+ if scan_data.logs:
1685
+ scan_data.logs = self.compressor.compress(scan_data.logs)
1686
+ self.log.debug("Writing quantum %s.", scan_data.quantum_id)
1687
+ self._quantum_writer.write_bytes(scan_data.quantum_id, scan_data.quantum)
1688
+ for dataset_id, dataset_data in outputs.items():
1689
+ self._dataset_writer.write_bytes(dataset_id, dataset_data)
1690
+ if scan_data.metadata:
1691
+ (metadata_output,) = predicted_quantum.outputs[acc.METADATA_OUTPUT_CONNECTION_NAME]
1692
+ address = self._metadata_writer.write_bytes(scan_data.quantum_id, scan_data.metadata)
1693
+ self._metadata_writer.addresses[metadata_output.dataset_id] = address
1694
+ if scan_data.logs:
1695
+ (log_output,) = predicted_quantum.outputs[acc.LOG_OUTPUT_CONNECTION_NAME]
1696
+ address = self._log_writer.write_bytes(scan_data.quantum_id, scan_data.logs)
1697
+ self._log_writer.addresses[log_output.dataset_id] = address
1698
+
1699
+
1700
+ class ProvenanceQuantumScanStatus(enum.Enum):
1701
+ """Status enum for quantum scanning.
1702
+
1703
+ Note that this records the status for the *scanning* which is distinct
1704
+ from the status of the quantum's execution.
1705
+ """
1706
+
1707
+ INCOMPLETE = enum.auto()
1708
+ """The quantum is not necessarily done running, and cannot be scanned
1709
+ conclusively yet.
1710
+ """
1711
+
1712
+ ABANDONED = enum.auto()
1713
+ """The quantum's execution appears to have failed but we cannot rule out
1714
+ the possibility that it could be recovered, but we've also waited long
1715
+ enough (according to `ScannerTimeConfigDict.retry_timeout`) that it's time
1716
+ to stop trying for now.
1717
+
1718
+ This state means `ProvenanceQuantumScanModels.from_metadata_and_logs` must
1719
+ be run again with ``incomplete=False``.
1720
+ """
1721
+
1722
+ SUCCESSFUL = enum.auto()
1723
+ """The quantum was conclusively scanned and was executed successfully,
1724
+ unblocking scans for downstream quanta.
1725
+ """
1726
+
1727
+ FAILED = enum.auto()
1728
+ """The quantum was conclusively scanned and failed execution, blocking
1729
+ scans for downstream quanta.
1730
+ """
1731
+
1732
+ BLOCKED = enum.auto()
1733
+ """A quantum upstream of this one failed."""
1734
+
1735
+ INIT = enum.auto()
1736
+ """Init quanta need special handling, because they don't have logs and
1737
+ metadata.
1738
+ """
1739
+
1740
+
1741
+ @dataclasses.dataclass
1742
+ class ProvenanceQuantumScanModels:
1743
+ """A struct that represents provenance information for a single quantum."""
1744
+
1745
+ quantum_id: uuid.UUID
1746
+ """Unique ID for the quantum."""
1747
+
1748
+ status: ProvenanceQuantumScanStatus = ProvenanceQuantumScanStatus.INCOMPLETE
1749
+ """Combined status for the scan and the execution of the quantum."""
1750
+
1751
+ attempts: list[ProvenanceQuantumAttemptModel] = dataclasses.field(default_factory=list)
1752
+ """Provenance information about each attempt to run the quantum."""
1753
+
1754
+ output_existence: dict[uuid.UUID, bool] = dataclasses.field(default_factory=dict)
1755
+ """Unique IDs of the output datasets mapped to whether they were actually
1756
+ produced.
1757
+ """
1758
+
1759
+ metadata: ProvenanceTaskMetadataModel = dataclasses.field(default_factory=ProvenanceTaskMetadataModel)
1760
+ """Task metadata information for each attempt.
1761
+ """
1762
+
1763
+ logs: ProvenanceLogRecordsModel = dataclasses.field(default_factory=ProvenanceLogRecordsModel)
1764
+ """Log records for each attempt.
1765
+ """
1766
+
1767
+ @classmethod
1768
+ def from_metadata_and_logs(
1769
+ cls,
1770
+ predicted: PredictedQuantumDatasetsModel,
1771
+ metadata: TaskMetadata | None,
1772
+ logs: ButlerLogRecords | None,
1773
+ *,
1774
+ incomplete: bool = False,
1775
+ ) -> ProvenanceQuantumScanModels:
1776
+ """Construct provenance information from task metadata and logs.
1777
+
1778
+ Parameters
1779
+ ----------
1780
+ predicted : `PredictedQuantumDatasetsModel`
1781
+ Information about the predicted quantum.
1782
+ metadata : `..TaskMetadata` or `None`
1783
+ Task metadata.
1784
+ logs : `lsst.daf.butler.logging.ButlerLogRecords` or `None`
1785
+ Task logs.
1786
+ incomplete : `bool`, optional
1787
+ If `True`, treat execution failures as possibly-incomplete quanta
1788
+ and do not fully process them; instead just set the status to
1789
+ `ProvenanceQuantumScanStatus.ABANDONED` and return.
1790
+
1791
+ Returns
1792
+ -------
1793
+ scan_models : `ProvenanceQuantumScanModels`
1794
+ Struct of models that describe quantum provenance.
1795
+
1796
+ Notes
1797
+ -----
1798
+ This method does not necessarily fully populate the `output_existence`
1799
+ field; it does what it can given the information in the metadata and
1800
+ logs, but the caller is responsible for filling in the existence status
1801
+ for any predicted outputs that are not present at all in that `dict`.
1802
+ """
1803
+ self = ProvenanceQuantumScanModels(predicted.quantum_id)
1804
+ last_attempt = ProvenanceQuantumAttemptModel()
1805
+ self._process_logs(predicted, logs, last_attempt, incomplete=incomplete)
1806
+ self._process_metadata(predicted, metadata, last_attempt, incomplete=incomplete)
1807
+ if self.status is ProvenanceQuantumScanStatus.ABANDONED:
1808
+ return self
1809
+ self._reconcile_attempts(last_attempt)
1810
+ self._extract_output_existence(predicted)
1811
+ return self
1812
+
1813
+ def _process_logs(
1814
+ self,
1815
+ predicted: PredictedQuantumDatasetsModel,
1816
+ logs: ButlerLogRecords | None,
1817
+ last_attempt: ProvenanceQuantumAttemptModel,
1818
+ *,
1819
+ incomplete: bool,
1820
+ ) -> None:
1821
+ (predicted_log_dataset,) = predicted.outputs[acc.LOG_OUTPUT_CONNECTION_NAME]
1822
+ if logs is None:
1823
+ self.output_existence[predicted_log_dataset.dataset_id] = False
1824
+ if incomplete:
1825
+ self.status = ProvenanceQuantumScanStatus.ABANDONED
1826
+ else:
1827
+ self.status = ProvenanceQuantumScanStatus.FAILED
1828
+ else:
1829
+ # Set the attempt's run status to FAILED, since the default is
1830
+ # UNKNOWN (i.e. logs *and* metadata are missing) and we now know
1831
+ # the logs exist. This will usually get replaced by SUCCESSFUL
1832
+ # when we look for metadata next.
1833
+ last_attempt.status = QuantumAttemptStatus.FAILED
1834
+ self.output_existence[predicted_log_dataset.dataset_id] = True
1835
+ if logs.extra:
1836
+ log_extra = _ExecutionLogRecordsExtra.model_validate(logs.extra)
1837
+ self._extract_from_log_extra(log_extra, last_attempt=last_attempt)
1838
+ self.logs.attempts.append(list(logs))
1839
+
1840
+ def _extract_from_log_extra(
1841
+ self,
1842
+ log_extra: _ExecutionLogRecordsExtra,
1843
+ last_attempt: ProvenanceQuantumAttemptModel | None,
1844
+ ) -> None:
1845
+ for previous_attempt_log_extra in log_extra.previous_attempts:
1846
+ self._extract_from_log_extra(
1847
+ previous_attempt_log_extra,
1848
+ last_attempt=None,
1849
+ )
1850
+ quantum_attempt: ProvenanceQuantumAttemptModel
1851
+ if last_attempt is None:
1852
+ # This is not the last attempt, so it must be a failure.
1853
+ quantum_attempt = ProvenanceQuantumAttemptModel(
1854
+ attempt=len(self.attempts), status=QuantumAttemptStatus.FAILED
1855
+ )
1856
+ # We also need to get the logs from this extra provenance, since
1857
+ # they won't be the main section of the log records.
1858
+ self.logs.attempts.append(log_extra.logs)
1859
+ # The special last attempt is only appended after we attempt to
1860
+ # read metadata later, but we have to append this one now.
1861
+ self.attempts.append(quantum_attempt)
1862
+ else:
1863
+ assert not log_extra.logs, "Logs for the last attempt should not be stored in the extra JSON."
1864
+ quantum_attempt = last_attempt
1865
+ if log_extra.exception is not None or log_extra.metadata is not None or last_attempt is None:
1866
+ # We won't be getting a separate metadata dataset, so anything we
1867
+ # might get from the metadata has to come from this extra
1868
+ # provenance in the logs.
1869
+ quantum_attempt.exception = log_extra.exception
1870
+ if log_extra.metadata is not None:
1871
+ quantum_attempt.resource_usage = QuantumResourceUsage.from_task_metadata(log_extra.metadata)
1872
+ self.metadata.attempts.append(log_extra.metadata)
1873
+ else:
1874
+ self.metadata.attempts.append(None)
1875
+ # Regardless of whether this is the last attempt or not, we can only
1876
+ # get the previous_process_quanta from the log extra.
1877
+ quantum_attempt.previous_process_quanta.extend(log_extra.previous_process_quanta)
1878
+
1879
+ def _process_metadata(
1880
+ self,
1881
+ predicted: PredictedQuantumDatasetsModel,
1882
+ metadata: TaskMetadata | None,
1883
+ last_attempt: ProvenanceQuantumAttemptModel,
1884
+ *,
1885
+ incomplete: bool,
1886
+ ) -> None:
1887
+ (predicted_metadata_dataset,) = predicted.outputs[acc.METADATA_OUTPUT_CONNECTION_NAME]
1888
+ if metadata is None:
1889
+ self.output_existence[predicted_metadata_dataset.dataset_id] = False
1890
+ if incomplete:
1891
+ self.status = ProvenanceQuantumScanStatus.ABANDONED
1892
+ else:
1893
+ self.status = ProvenanceQuantumScanStatus.FAILED
1894
+ else:
1895
+ self.status = ProvenanceQuantumScanStatus.SUCCESSFUL
1896
+ self.output_existence[predicted_metadata_dataset.dataset_id] = True
1897
+ last_attempt.status = QuantumAttemptStatus.SUCCESSFUL
1898
+ try:
1899
+ # Int conversion guards against spurious conversion to
1900
+ # float that can apparently sometimes happen in
1901
+ # TaskMetadata.
1902
+ last_attempt.caveats = QuantumSuccessCaveats(int(metadata["quantum"]["caveats"]))
1903
+ except LookupError:
1904
+ pass
1905
+ try:
1906
+ last_attempt.exception = ExceptionInfo._from_metadata(
1907
+ metadata[predicted.task_label]["failure"]
1908
+ )
1909
+ except LookupError:
1910
+ pass
1911
+ last_attempt.resource_usage = QuantumResourceUsage.from_task_metadata(metadata)
1912
+ self.metadata.attempts.append(metadata)
1913
+
1914
+ def _reconcile_attempts(self, last_attempt: ProvenanceQuantumAttemptModel) -> None:
1915
+ last_attempt.attempt = len(self.attempts)
1916
+ self.attempts.append(last_attempt)
1917
+ assert self.status is not ProvenanceQuantumScanStatus.INCOMPLETE
1918
+ assert self.status is not ProvenanceQuantumScanStatus.ABANDONED
1919
+ if len(self.logs.attempts) < len(self.attempts):
1920
+ # Logs were not found for this attempt; must have been a hard error
1921
+ # that kept the `finally` block from running or otherwise
1922
+ # interrupted the writing of the logs.
1923
+ self.logs.attempts.append(None)
1924
+ if self.status is ProvenanceQuantumScanStatus.SUCCESSFUL:
1925
+ # But we found the metadata! Either that hard error happened
1926
+ # at a very unlucky time (in between those two writes), or
1927
+ # something even weirder happened.
1928
+ self.attempts[-1].status = QuantumAttemptStatus.ABORTED_SUCCESS
1929
+ else:
1930
+ self.attempts[-1].status = QuantumAttemptStatus.FAILED
1931
+ if len(self.metadata.attempts) < len(self.attempts):
1932
+ # Metadata missing usually just means a failure. In any case, the
1933
+ # status will already be correct, either because it was set to a
1934
+ # failure when we read the logs, or left at UNKNOWN if there were
1935
+ # no logs. Note that scanners never process BLOCKED quanta at all.
1936
+ self.metadata.attempts.append(None)
1937
+ assert len(self.logs.attempts) == len(self.attempts) or len(self.metadata.attempts) == len(
1938
+ self.attempts
1939
+ ), (
1940
+ "The only way we can add more than one quantum attempt is by "
1941
+ "extracting info stored with the logs, and that always appends "
1942
+ "a log attempt and a metadata attempt, so this must be a bug in "
1943
+ "this class."
1944
+ )
1945
+
1946
+ def _extract_output_existence(self, predicted: PredictedQuantumDatasetsModel) -> None:
1947
+ try:
1948
+ outputs_put = self.metadata.attempts[-1]["quantum"].getArray("outputs") # type: ignore[index]
1949
+ except (
1950
+ IndexError, # metadata.attempts is empty
1951
+ TypeError, # metadata.attempts[-1] is None
1952
+ LookupError, # no 'quantum' entry in metadata or 'outputs' in that
1953
+ ):
1954
+ pass
1955
+ else:
1956
+ for id_str in ensure_iterable(outputs_put):
1957
+ self.output_existence[uuid.UUID(id_str)] = True
1958
+ # If the metadata told us what it wrote, anything not in that
1959
+ # list was not written.
1960
+ for predicted_output in itertools.chain.from_iterable(predicted.outputs.values()):
1961
+ self.output_existence.setdefault(predicted_output.dataset_id, False)
1962
+
1963
+ def to_scan_data(
1964
+ self: ProvenanceQuantumScanModels,
1965
+ predicted_quantum: PredictedQuantumDatasetsModel,
1966
+ compressor: Compressor | None = None,
1967
+ ) -> ProvenanceQuantumScanData:
1968
+ """Convert these models to JSON data.
1969
+
1970
+ Parameters
1971
+ ----------
1972
+ predicted_quantum : `PredictedQuantumDatasetsModel`
1973
+ Information about the predicted quantum.
1974
+ compressor : `Compressor`
1975
+ Object that can compress bytes.
1976
+
1977
+ Returns
1978
+ -------
1979
+ scan_data : `ProvenanceQuantumScanData`
1980
+ Scan information ready for serialization.
1981
+ """
1982
+ quantum: ProvenanceInitQuantumModel | ProvenanceQuantumModel
1983
+ if self.status is ProvenanceQuantumScanStatus.INIT:
1984
+ quantum = ProvenanceInitQuantumModel.from_predicted(predicted_quantum)
1985
+ else:
1986
+ quantum = ProvenanceQuantumModel.from_predicted(predicted_quantum)
1987
+ quantum.attempts = self.attempts
1988
+ for predicted_output in itertools.chain.from_iterable(predicted_quantum.outputs.values()):
1989
+ if predicted_output.dataset_id not in self.output_existence:
1990
+ raise RuntimeError(
1991
+ "Logic bug in provenance gathering or execution invariants: "
1992
+ f"no existence information for output {predicted_output.dataset_id} "
1993
+ f"({predicted_output.dataset_type_name}@{predicted_output.data_coordinate})."
1994
+ )
1995
+ data = ProvenanceQuantumScanData(
1996
+ self.quantum_id,
1997
+ self.status,
1998
+ existing_outputs={
1999
+ dataset_id for dataset_id, was_produced in self.output_existence.items() if was_produced
2000
+ },
2001
+ quantum=quantum.model_dump_json().encode(),
2002
+ logs=self.logs.model_dump_json().encode() if self.logs.attempts else b"",
2003
+ metadata=self.metadata.model_dump_json().encode() if self.metadata.attempts else b"",
2004
+ )
2005
+ if compressor is not None:
2006
+ data.compress(compressor)
2007
+ return data
2008
+
2009
+
2010
+ @dataclasses.dataclass
2011
+ class ProvenanceQuantumScanData:
2012
+ """A struct that represents ready-for-serialization provenance information
2013
+ for a single quantum.
2014
+ """
2015
+
2016
+ quantum_id: uuid.UUID
2017
+ """Unique ID for the quantum."""
2018
+
2019
+ status: ProvenanceQuantumScanStatus
2020
+ """Combined status for the scan and the execution of the quantum."""
2021
+
2022
+ existing_outputs: set[uuid.UUID] = dataclasses.field(default_factory=set)
2023
+ """Unique IDs of the output datasets that were actually written."""
2024
+
2025
+ quantum: bytes = b""
2026
+ """Serialized quantum provenance model.
2027
+
2028
+ This may be empty for quanta that had no attempts.
2029
+ """
2030
+
2031
+ metadata: bytes = b""
2032
+ """Serialized task metadata."""
2033
+
2034
+ logs: bytes = b""
2035
+ """Serialized logs."""
2036
+
2037
+ is_compressed: bool = False
2038
+ """Whether the `quantum`, `metadata`, and `log` attributes are
2039
+ compressed.
2040
+ """
2041
+
2042
+ def compress(self, compressor: Compressor) -> None:
2043
+ """Compress the data in this struct if it has not been compressed
2044
+ already.
2045
+
2046
+ Parameters
2047
+ ----------
2048
+ compressor : `Compressor`
2049
+ Object with a ``compress`` method that takes and returns `bytes`.
2050
+ """
2051
+ if not self.is_compressed:
2052
+ self.quantum = compressor.compress(self.quantum)
2053
+ self.logs = compressor.compress(self.logs) if self.logs else b""
2054
+ self.metadata = compressor.compress(self.metadata) if self.metadata else b""
2055
+ self.is_compressed = True