lsst-pipe-base 30.0.1rc1__py3-none-any.whl → 30.2025.5100__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.
- lsst/pipe/base/_instrument.py +20 -31
- lsst/pipe/base/_quantumContext.py +3 -3
- lsst/pipe/base/_status.py +10 -43
- lsst/pipe/base/_task_metadata.py +2 -2
- lsst/pipe/base/all_dimensions_quantum_graph_builder.py +3 -8
- lsst/pipe/base/automatic_connection_constants.py +1 -20
- lsst/pipe/base/cli/cmd/__init__.py +2 -18
- lsst/pipe/base/cli/cmd/commands.py +4 -149
- lsst/pipe/base/connectionTypes.py +160 -72
- lsst/pipe/base/connections.py +9 -6
- lsst/pipe/base/execution_reports.py +5 -0
- lsst/pipe/base/graph/graph.py +10 -11
- lsst/pipe/base/graph/quantumNode.py +4 -4
- lsst/pipe/base/graph_walker.py +10 -8
- lsst/pipe/base/log_capture.py +80 -40
- lsst/pipe/base/mp_graph_executor.py +15 -51
- lsst/pipe/base/pipeline.py +6 -5
- lsst/pipe/base/pipelineIR.py +8 -2
- lsst/pipe/base/pipelineTask.py +7 -5
- lsst/pipe/base/pipeline_graph/_dataset_types.py +2 -2
- lsst/pipe/base/pipeline_graph/_edges.py +22 -32
- lsst/pipe/base/pipeline_graph/_mapping_views.py +7 -4
- lsst/pipe/base/pipeline_graph/_pipeline_graph.py +7 -14
- lsst/pipe/base/pipeline_graph/expressions.py +2 -2
- lsst/pipe/base/pipeline_graph/io.py +10 -7
- lsst/pipe/base/pipeline_graph/visualization/_dot.py +12 -13
- lsst/pipe/base/pipeline_graph/visualization/_layout.py +18 -16
- lsst/pipe/base/pipeline_graph/visualization/_merge.py +7 -4
- lsst/pipe/base/pipeline_graph/visualization/_printer.py +10 -10
- lsst/pipe/base/pipeline_graph/visualization/_status_annotator.py +0 -7
- lsst/pipe/base/prerequisite_helpers.py +1 -2
- lsst/pipe/base/quantum_graph/_common.py +20 -19
- lsst/pipe/base/quantum_graph/_multiblock.py +31 -37
- lsst/pipe/base/quantum_graph/_predicted.py +13 -111
- lsst/pipe/base/quantum_graph/_provenance.py +45 -1136
- lsst/pipe/base/quantum_graph/aggregator/__init__.py +1 -0
- lsst/pipe/base/quantum_graph/aggregator/_communicators.py +289 -204
- lsst/pipe/base/quantum_graph/aggregator/_config.py +9 -87
- lsst/pipe/base/quantum_graph/aggregator/_ingester.py +12 -13
- lsst/pipe/base/quantum_graph/aggregator/_scanner.py +235 -49
- lsst/pipe/base/quantum_graph/aggregator/_structs.py +116 -6
- lsst/pipe/base/quantum_graph/aggregator/_supervisor.py +39 -29
- lsst/pipe/base/quantum_graph/aggregator/_writer.py +351 -34
- lsst/pipe/base/quantum_graph/visualization.py +1 -5
- lsst/pipe/base/quantum_graph_builder.py +8 -21
- lsst/pipe/base/quantum_graph_executor.py +13 -116
- lsst/pipe/base/quantum_graph_skeleton.py +29 -31
- lsst/pipe/base/quantum_provenance_graph.py +12 -29
- lsst/pipe/base/separable_pipeline_executor.py +3 -19
- lsst/pipe/base/single_quantum_executor.py +42 -67
- lsst/pipe/base/struct.py +0 -4
- lsst/pipe/base/testUtils.py +3 -3
- lsst/pipe/base/tests/mocks/_storage_class.py +1 -2
- lsst/pipe/base/version.py +1 -1
- {lsst_pipe_base-30.0.1rc1.dist-info → lsst_pipe_base-30.2025.5100.dist-info}/METADATA +3 -3
- lsst_pipe_base-30.2025.5100.dist-info/RECORD +125 -0
- {lsst_pipe_base-30.0.1rc1.dist-info → lsst_pipe_base-30.2025.5100.dist-info}/WHEEL +1 -1
- lsst/pipe/base/log_on_close.py +0 -76
- lsst/pipe/base/quantum_graph/aggregator/_workers.py +0 -303
- lsst/pipe/base/quantum_graph/formatter.py +0 -171
- lsst/pipe/base/quantum_graph/ingest_graph.py +0 -413
- lsst_pipe_base-30.0.1rc1.dist-info/RECORD +0 -129
- {lsst_pipe_base-30.0.1rc1.dist-info → lsst_pipe_base-30.2025.5100.dist-info}/entry_points.txt +0 -0
- {lsst_pipe_base-30.0.1rc1.dist-info → lsst_pipe_base-30.2025.5100.dist-info}/licenses/COPYRIGHT +0 -0
- {lsst_pipe_base-30.0.1rc1.dist-info → lsst_pipe_base-30.2025.5100.dist-info}/licenses/LICENSE +0 -0
- {lsst_pipe_base-30.0.1rc1.dist-info → lsst_pipe_base-30.2025.5100.dist-info}/licenses/bsd_license.txt +0 -0
- {lsst_pipe_base-30.0.1rc1.dist-info → lsst_pipe_base-30.2025.5100.dist-info}/licenses/gpl-v3.0.txt +0 -0
- {lsst_pipe_base-30.0.1rc1.dist-info → lsst_pipe_base-30.2025.5100.dist-info}/top_level.txt +0 -0
- {lsst_pipe_base-30.0.1rc1.dist-info → lsst_pipe_base-30.2025.5100.dist-info}/zip-safe +0 -0
|
@@ -35,50 +35,37 @@ __all__ = (
|
|
|
35
35
|
"ProvenanceLogRecordsModel",
|
|
36
36
|
"ProvenanceQuantumGraph",
|
|
37
37
|
"ProvenanceQuantumGraphReader",
|
|
38
|
-
"ProvenanceQuantumGraphWriter",
|
|
39
38
|
"ProvenanceQuantumInfo",
|
|
40
39
|
"ProvenanceQuantumModel",
|
|
41
|
-
"ProvenanceQuantumReport",
|
|
42
|
-
"ProvenanceQuantumScanData",
|
|
43
|
-
"ProvenanceQuantumScanModels",
|
|
44
|
-
"ProvenanceQuantumScanStatus",
|
|
45
|
-
"ProvenanceReport",
|
|
46
40
|
"ProvenanceTaskMetadataModel",
|
|
47
41
|
)
|
|
48
42
|
|
|
43
|
+
|
|
49
44
|
import dataclasses
|
|
50
|
-
import enum
|
|
51
|
-
import itertools
|
|
52
45
|
import sys
|
|
53
46
|
import uuid
|
|
54
47
|
from collections import Counter
|
|
55
|
-
from collections.abc import
|
|
56
|
-
from contextlib import
|
|
57
|
-
from typing import TYPE_CHECKING, Any, TypedDict
|
|
48
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
49
|
+
from contextlib import contextmanager
|
|
50
|
+
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar
|
|
58
51
|
|
|
59
52
|
import astropy.table
|
|
60
53
|
import networkx
|
|
61
54
|
import numpy as np
|
|
62
55
|
import pydantic
|
|
63
56
|
|
|
64
|
-
from lsst.daf.butler import
|
|
57
|
+
from lsst.daf.butler import DataCoordinate
|
|
65
58
|
from lsst.daf.butler.logging import ButlerLogRecord, ButlerLogRecords
|
|
66
|
-
from lsst.resources import
|
|
67
|
-
from lsst.utils.iteration import ensure_iterable
|
|
68
|
-
from lsst.utils.logging import LsstLogAdapter, getLogger
|
|
59
|
+
from lsst.resources import ResourcePathExpression
|
|
69
60
|
from lsst.utils.packages import Packages
|
|
70
61
|
|
|
71
|
-
from .. import automatic_connection_constants as acc
|
|
72
62
|
from .._status import ExceptionInfo, QuantumAttemptStatus, QuantumSuccessCaveats
|
|
73
63
|
from .._task_metadata import TaskMetadata
|
|
74
|
-
from ..log_capture import _ExecutionLogRecordsExtra
|
|
75
|
-
from ..log_on_close import LogOnClose
|
|
76
64
|
from ..pipeline_graph import PipelineGraph, TaskImportMode, TaskInitNode
|
|
77
65
|
from ..resource_usage import QuantumResourceUsage
|
|
78
66
|
from ._common import (
|
|
79
67
|
BaseQuantumGraph,
|
|
80
68
|
BaseQuantumGraphReader,
|
|
81
|
-
BaseQuantumGraphWriter,
|
|
82
69
|
ConnectionName,
|
|
83
70
|
DataCoordinateValues,
|
|
84
71
|
DatasetInfo,
|
|
@@ -87,24 +74,8 @@ from ._common import (
|
|
|
87
74
|
QuantumInfo,
|
|
88
75
|
TaskLabel,
|
|
89
76
|
)
|
|
90
|
-
from ._multiblock import
|
|
91
|
-
from ._predicted import
|
|
92
|
-
PredictedDatasetModel,
|
|
93
|
-
PredictedQuantumDatasetsModel,
|
|
94
|
-
PredictedQuantumGraph,
|
|
95
|
-
PredictedQuantumGraphComponents,
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
# Sphinx needs imports for type annotations of base class members.
|
|
99
|
-
if "sphinx" in sys.modules:
|
|
100
|
-
import zipfile # noqa: F401
|
|
101
|
-
|
|
102
|
-
from ._multiblock import AddressReader, Decompressor # noqa: F401
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
type LoopWrapper[T] = Callable[[Iterable[T]], Iterable[T]]
|
|
106
|
-
|
|
107
|
-
_LOG = getLogger(__file__)
|
|
77
|
+
from ._multiblock import MultiblockReader
|
|
78
|
+
from ._predicted import PredictedDatasetModel, PredictedQuantumDatasetsModel
|
|
108
79
|
|
|
109
80
|
DATASET_ADDRESS_INDEX = 0
|
|
110
81
|
QUANTUM_ADDRESS_INDEX = 1
|
|
@@ -116,9 +87,7 @@ QUANTUM_MB_NAME = "quanta"
|
|
|
116
87
|
LOG_MB_NAME = "logs"
|
|
117
88
|
METADATA_MB_NAME = "metadata"
|
|
118
89
|
|
|
119
|
-
|
|
120
|
-
def pass_through[T](arg: T) -> T:
|
|
121
|
-
return arg
|
|
90
|
+
_I = TypeVar("_I", bound=uuid.UUID | int)
|
|
122
91
|
|
|
123
92
|
|
|
124
93
|
class ProvenanceDatasetInfo(DatasetInfo):
|
|
@@ -192,12 +161,6 @@ class ProvenanceQuantumInfo(QuantumInfo):
|
|
|
192
161
|
failure.
|
|
193
162
|
"""
|
|
194
163
|
|
|
195
|
-
metadata_id: uuid.UUID
|
|
196
|
-
"""ID of this quantum's metadata dataset."""
|
|
197
|
-
|
|
198
|
-
log_id: uuid.UUID
|
|
199
|
-
"""ID of this quantum's log dataset."""
|
|
200
|
-
|
|
201
164
|
|
|
202
165
|
class ProvenanceInitQuantumInfo(TypedDict):
|
|
203
166
|
"""A typed dictionary that annotates the attributes of the NetworkX graph
|
|
@@ -224,9 +187,6 @@ class ProvenanceInitQuantumInfo(TypedDict):
|
|
|
224
187
|
pipeline_node: TaskInitNode
|
|
225
188
|
"""Node in the pipeline graph for this task's init-only step."""
|
|
226
189
|
|
|
227
|
-
config_id: uuid.UUID
|
|
228
|
-
"""ID of this task's config dataset."""
|
|
229
|
-
|
|
230
190
|
|
|
231
191
|
class ProvenanceDatasetModel(PredictedDatasetModel):
|
|
232
192
|
"""Data model for the datasets in a provenance quantum graph file."""
|
|
@@ -558,131 +518,6 @@ class ProvenanceTaskMetadataModel(pydantic.BaseModel):
|
|
|
558
518
|
return super().model_validate_strings(*args, **kwargs)
|
|
559
519
|
|
|
560
520
|
|
|
561
|
-
class ProvenanceQuantumReport(pydantic.BaseModel):
|
|
562
|
-
"""A Pydantic model that used to report information about a single
|
|
563
|
-
(generally problematic) quantum.
|
|
564
|
-
"""
|
|
565
|
-
|
|
566
|
-
quantum_id: uuid.UUID
|
|
567
|
-
data_id: dict[str, int | str]
|
|
568
|
-
attempts: list[ProvenanceQuantumAttemptModel]
|
|
569
|
-
|
|
570
|
-
@classmethod
|
|
571
|
-
def from_info(cls, quantum_id: uuid.UUID, quantum_info: ProvenanceQuantumInfo) -> ProvenanceQuantumReport:
|
|
572
|
-
"""Construct from a provenance quantum graph node.
|
|
573
|
-
|
|
574
|
-
Parameters
|
|
575
|
-
----------
|
|
576
|
-
quantum_id : `uuid.UUID`
|
|
577
|
-
Unique ID for the quantum.
|
|
578
|
-
quantum_info : `ProvenanceQuantumInfo`
|
|
579
|
-
Node attributes for this quantum.
|
|
580
|
-
"""
|
|
581
|
-
return cls(
|
|
582
|
-
quantum_id=quantum_id,
|
|
583
|
-
data_id=dict(quantum_info["data_id"].mapping),
|
|
584
|
-
attempts=quantum_info["attempts"],
|
|
585
|
-
)
|
|
586
|
-
|
|
587
|
-
# Work around the fact that Sphinx chokes on Pydantic docstring formatting,
|
|
588
|
-
# when we inherit those docstrings in our public classes.
|
|
589
|
-
if "sphinx" in sys.modules and not TYPE_CHECKING:
|
|
590
|
-
|
|
591
|
-
def copy(self, *args: Any, **kwargs: Any) -> Any:
|
|
592
|
-
"""See `pydantic.BaseModel.copy`."""
|
|
593
|
-
return super().copy(*args, **kwargs)
|
|
594
|
-
|
|
595
|
-
def model_dump(self, *args: Any, **kwargs: Any) -> Any:
|
|
596
|
-
"""See `pydantic.BaseModel.model_dump`."""
|
|
597
|
-
return super().model_dump(*args, **kwargs)
|
|
598
|
-
|
|
599
|
-
def model_dump_json(self, *args: Any, **kwargs: Any) -> Any:
|
|
600
|
-
"""See `pydantic.BaseModel.model_dump_json`."""
|
|
601
|
-
return super().model_dump(*args, **kwargs)
|
|
602
|
-
|
|
603
|
-
def model_copy(self, *args: Any, **kwargs: Any) -> Any:
|
|
604
|
-
"""See `pydantic.BaseModel.model_copy`."""
|
|
605
|
-
return super().model_copy(*args, **kwargs)
|
|
606
|
-
|
|
607
|
-
@classmethod
|
|
608
|
-
def model_construct(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc, override]
|
|
609
|
-
"""See `pydantic.BaseModel.model_construct`."""
|
|
610
|
-
return super().model_construct(*args, **kwargs)
|
|
611
|
-
|
|
612
|
-
@classmethod
|
|
613
|
-
def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any:
|
|
614
|
-
"""See `pydantic.BaseModel.model_json_schema`."""
|
|
615
|
-
return super().model_json_schema(*args, **kwargs)
|
|
616
|
-
|
|
617
|
-
@classmethod
|
|
618
|
-
def model_validate(cls, *args: Any, **kwargs: Any) -> Any:
|
|
619
|
-
"""See `pydantic.BaseModel.model_validate`."""
|
|
620
|
-
return super().model_validate(*args, **kwargs)
|
|
621
|
-
|
|
622
|
-
@classmethod
|
|
623
|
-
def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any:
|
|
624
|
-
"""See `pydantic.BaseModel.model_validate_json`."""
|
|
625
|
-
return super().model_validate_json(*args, **kwargs)
|
|
626
|
-
|
|
627
|
-
@classmethod
|
|
628
|
-
def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any:
|
|
629
|
-
"""See `pydantic.BaseModel.model_validate_strings`."""
|
|
630
|
-
return super().model_validate_strings(*args, **kwargs)
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
class ProvenanceReport(pydantic.RootModel):
|
|
634
|
-
"""A Pydantic model that groups quantum information by task label, then
|
|
635
|
-
status (as a string), and then exception type.
|
|
636
|
-
"""
|
|
637
|
-
|
|
638
|
-
root: dict[TaskLabel, dict[str, dict[str | None, list[ProvenanceQuantumReport]]]] = {}
|
|
639
|
-
|
|
640
|
-
# Work around the fact that Sphinx chokes on Pydantic docstring formatting,
|
|
641
|
-
# when we inherit those docstrings in our public classes.
|
|
642
|
-
if "sphinx" in sys.modules and not TYPE_CHECKING:
|
|
643
|
-
|
|
644
|
-
def copy(self, *args: Any, **kwargs: Any) -> Any:
|
|
645
|
-
"""See `pydantic.BaseModel.copy`."""
|
|
646
|
-
return super().copy(*args, **kwargs)
|
|
647
|
-
|
|
648
|
-
def model_dump(self, *args: Any, **kwargs: Any) -> Any:
|
|
649
|
-
"""See `pydantic.BaseModel.model_dump`."""
|
|
650
|
-
return super().model_dump(*args, **kwargs)
|
|
651
|
-
|
|
652
|
-
def model_dump_json(self, *args: Any, **kwargs: Any) -> Any:
|
|
653
|
-
"""See `pydantic.BaseModel.model_dump_json`."""
|
|
654
|
-
return super().model_dump(*args, **kwargs)
|
|
655
|
-
|
|
656
|
-
def model_copy(self, *args: Any, **kwargs: Any) -> Any:
|
|
657
|
-
"""See `pydantic.BaseModel.model_copy`."""
|
|
658
|
-
return super().model_copy(*args, **kwargs)
|
|
659
|
-
|
|
660
|
-
@classmethod
|
|
661
|
-
def model_construct(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc, override]
|
|
662
|
-
"""See `pydantic.BaseModel.model_construct`."""
|
|
663
|
-
return super().model_construct(*args, **kwargs)
|
|
664
|
-
|
|
665
|
-
@classmethod
|
|
666
|
-
def model_json_schema(cls, *args: Any, **kwargs: Any) -> Any:
|
|
667
|
-
"""See `pydantic.BaseModel.model_json_schema`."""
|
|
668
|
-
return super().model_json_schema(*args, **kwargs)
|
|
669
|
-
|
|
670
|
-
@classmethod
|
|
671
|
-
def model_validate(cls, *args: Any, **kwargs: Any) -> Any:
|
|
672
|
-
"""See `pydantic.BaseModel.model_validate`."""
|
|
673
|
-
return super().model_validate(*args, **kwargs)
|
|
674
|
-
|
|
675
|
-
@classmethod
|
|
676
|
-
def model_validate_json(cls, *args: Any, **kwargs: Any) -> Any:
|
|
677
|
-
"""See `pydantic.BaseModel.model_validate_json`."""
|
|
678
|
-
return super().model_validate_json(*args, **kwargs)
|
|
679
|
-
|
|
680
|
-
@classmethod
|
|
681
|
-
def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Any:
|
|
682
|
-
"""See `pydantic.BaseModel.model_validate_strings`."""
|
|
683
|
-
return super().model_validate_strings(*args, **kwargs)
|
|
684
|
-
|
|
685
|
-
|
|
686
521
|
class ProvenanceQuantumModel(pydantic.BaseModel):
|
|
687
522
|
"""Data model for the quanta in a provenance quantum graph file."""
|
|
688
523
|
|
|
@@ -786,8 +621,6 @@ class ProvenanceQuantumModel(pydantic.BaseModel):
|
|
|
786
621
|
resource_usage=last_attempt.resource_usage,
|
|
787
622
|
attempts=self.attempts,
|
|
788
623
|
)
|
|
789
|
-
graph._quanta_by_task_label[self.task_label][data_id] = self.quantum_id
|
|
790
|
-
graph._quantum_only_xgraph.add_node(self.quantum_id, **graph._bipartite_xgraph.nodes[self.quantum_id])
|
|
791
624
|
for connection_name, dataset_ids in self.inputs.items():
|
|
792
625
|
read_edge = task_node.get_input_edge(connection_name)
|
|
793
626
|
for dataset_id in dataset_ids:
|
|
@@ -797,30 +630,6 @@ class ProvenanceQuantumModel(pydantic.BaseModel):
|
|
|
797
630
|
).append(read_edge)
|
|
798
631
|
for connection_name, dataset_ids in self.outputs.items():
|
|
799
632
|
write_edge = task_node.get_output_edge(connection_name)
|
|
800
|
-
if connection_name == acc.METADATA_OUTPUT_CONNECTION_NAME:
|
|
801
|
-
graph._bipartite_xgraph.add_node(
|
|
802
|
-
dataset_ids[0],
|
|
803
|
-
data_id=data_id,
|
|
804
|
-
dataset_type_name=write_edge.dataset_type_name,
|
|
805
|
-
pipeline_node=graph.pipeline_graph.dataset_types[write_edge.dataset_type_name],
|
|
806
|
-
run=graph.header.output_run,
|
|
807
|
-
produced=last_attempt.status.has_metadata,
|
|
808
|
-
)
|
|
809
|
-
graph._datasets_by_type[write_edge.dataset_type_name][data_id] = dataset_ids[0]
|
|
810
|
-
graph._bipartite_xgraph.nodes[self.quantum_id]["metadata_id"] = dataset_ids[0]
|
|
811
|
-
graph._quantum_only_xgraph.nodes[self.quantum_id]["metadata_id"] = dataset_ids[0]
|
|
812
|
-
if connection_name == acc.LOG_OUTPUT_CONNECTION_NAME:
|
|
813
|
-
graph._bipartite_xgraph.add_node(
|
|
814
|
-
dataset_ids[0],
|
|
815
|
-
data_id=data_id,
|
|
816
|
-
dataset_type_name=write_edge.dataset_type_name,
|
|
817
|
-
pipeline_node=graph.pipeline_graph.dataset_types[write_edge.dataset_type_name],
|
|
818
|
-
run=graph.header.output_run,
|
|
819
|
-
produced=last_attempt.status.has_log,
|
|
820
|
-
)
|
|
821
|
-
graph._datasets_by_type[write_edge.dataset_type_name][data_id] = dataset_ids[0]
|
|
822
|
-
graph._bipartite_xgraph.nodes[self.quantum_id]["log_id"] = dataset_ids[0]
|
|
823
|
-
graph._quantum_only_xgraph.nodes[self.quantum_id]["log_id"] = dataset_ids[0]
|
|
824
633
|
for dataset_id in dataset_ids:
|
|
825
634
|
graph._bipartite_xgraph.add_edge(
|
|
826
635
|
self.quantum_id,
|
|
@@ -829,6 +638,8 @@ class ProvenanceQuantumModel(pydantic.BaseModel):
|
|
|
829
638
|
# There can only be one pipeline edge for an output.
|
|
830
639
|
pipeline_edges=[write_edge],
|
|
831
640
|
)
|
|
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])
|
|
832
643
|
for dataset_id in graph._bipartite_xgraph.predecessors(self.quantum_id):
|
|
833
644
|
for upstream_quantum_id in graph._bipartite_xgraph.predecessors(dataset_id):
|
|
834
645
|
graph._quantum_only_xgraph.add_edge(upstream_quantum_id, self.quantum_id)
|
|
@@ -967,15 +778,6 @@ class ProvenanceInitQuantumModel(pydantic.BaseModel):
|
|
|
967
778
|
).append(read_edge)
|
|
968
779
|
for connection_name, dataset_id in self.outputs.items():
|
|
969
780
|
write_edge = task_init_node.get_output_edge(connection_name)
|
|
970
|
-
graph._bipartite_xgraph.add_node(
|
|
971
|
-
dataset_id,
|
|
972
|
-
data_id=empty_data_id,
|
|
973
|
-
dataset_type_name=write_edge.dataset_type_name,
|
|
974
|
-
pipeline_node=graph.pipeline_graph.dataset_types[write_edge.dataset_type_name],
|
|
975
|
-
run=graph.header.output_run,
|
|
976
|
-
produced=True,
|
|
977
|
-
)
|
|
978
|
-
graph._datasets_by_type[write_edge.dataset_type_name][empty_data_id] = dataset_id
|
|
979
781
|
graph._bipartite_xgraph.add_edge(
|
|
980
782
|
self.quantum_id,
|
|
981
783
|
dataset_id,
|
|
@@ -983,8 +785,6 @@ class ProvenanceInitQuantumModel(pydantic.BaseModel):
|
|
|
983
785
|
# There can only be one pipeline edge for an output.
|
|
984
786
|
pipeline_edges=[write_edge],
|
|
985
787
|
)
|
|
986
|
-
if write_edge.connection_name == acc.CONFIG_INIT_OUTPUT_CONNECTION_NAME:
|
|
987
|
-
graph._bipartite_xgraph.nodes[self.quantum_id]["config_id"] = dataset_id
|
|
988
788
|
graph._init_quanta[self.task_label] = self.quantum_id
|
|
989
789
|
|
|
990
790
|
# Work around the fact that Sphinx chokes on Pydantic docstring formatting,
|
|
@@ -1129,83 +929,6 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
|
|
|
1129
929
|
dataset_type_name: {} for dataset_type_name in self.pipeline_graph.dataset_types.keys()
|
|
1130
930
|
}
|
|
1131
931
|
|
|
1132
|
-
@classmethod
|
|
1133
|
-
@contextmanager
|
|
1134
|
-
def from_args(
|
|
1135
|
-
cls,
|
|
1136
|
-
repo_or_filename: str,
|
|
1137
|
-
/,
|
|
1138
|
-
collection: str | None = None,
|
|
1139
|
-
*,
|
|
1140
|
-
quanta: Iterable[uuid.UUID] | None = None,
|
|
1141
|
-
datasets: Iterable[uuid.UUID] | None = None,
|
|
1142
|
-
writeable: bool = False,
|
|
1143
|
-
) -> Iterator[tuple[ProvenanceQuantumGraph, Butler | None]]:
|
|
1144
|
-
"""Construct a `ProvenanceQuantumGraph` fron CLI-friendly arguments for
|
|
1145
|
-
a file or butler-ingested graph dataset.
|
|
1146
|
-
|
|
1147
|
-
Parameters
|
|
1148
|
-
----------
|
|
1149
|
-
repo_or_filename : `str`
|
|
1150
|
-
Either a provenance quantum graph filename or a butler repository
|
|
1151
|
-
path or alias.
|
|
1152
|
-
collection : `str`, optional
|
|
1153
|
-
Collection to search; presence indicates that the first argument
|
|
1154
|
-
is a butler repository, not a filename.
|
|
1155
|
-
quanta : `~collections.abc.Iterable` [ `str` ] or `None`, optional
|
|
1156
|
-
IDs of the quanta to load, or `None` to load all.
|
|
1157
|
-
datasets : `~collections.abc.Iterable` [ `str` ], optional
|
|
1158
|
-
IDs of the datasets to load, or `None` to load all.
|
|
1159
|
-
writeable : `bool`, optional
|
|
1160
|
-
Whether the butler should be constructed with write support.
|
|
1161
|
-
|
|
1162
|
-
Returns
|
|
1163
|
-
-------
|
|
1164
|
-
context : `contextlib.AbstractContextManager`
|
|
1165
|
-
A context manager that yields a tuple of
|
|
1166
|
-
|
|
1167
|
-
- the `ProvenanceQuantumGraph`
|
|
1168
|
-
- the `Butler` constructed (or `None`)
|
|
1169
|
-
|
|
1170
|
-
when entered.
|
|
1171
|
-
"""
|
|
1172
|
-
exit_stack = ExitStack()
|
|
1173
|
-
if collection is not None:
|
|
1174
|
-
try:
|
|
1175
|
-
butler = exit_stack.enter_context(
|
|
1176
|
-
Butler.from_config(repo_or_filename, collections=[collection], writeable=writeable)
|
|
1177
|
-
)
|
|
1178
|
-
except Exception as err:
|
|
1179
|
-
err.add_note(
|
|
1180
|
-
f"Expected {repo_or_filename!r} to be a butler repository path or alias because a "
|
|
1181
|
-
f"collection ({collection}) was provided."
|
|
1182
|
-
)
|
|
1183
|
-
raise
|
|
1184
|
-
with exit_stack:
|
|
1185
|
-
graph = butler.get(
|
|
1186
|
-
acc.PROVENANCE_DATASET_TYPE_NAME, parameters={"quanta": quanta, "datasets": datasets}
|
|
1187
|
-
)
|
|
1188
|
-
yield graph, butler
|
|
1189
|
-
else:
|
|
1190
|
-
try:
|
|
1191
|
-
reader = exit_stack.enter_context(ProvenanceQuantumGraphReader.open(repo_or_filename))
|
|
1192
|
-
except Exception as err:
|
|
1193
|
-
err.add_note(
|
|
1194
|
-
f"Expected a {repo_or_filename} to be a provenance quantum graph filename "
|
|
1195
|
-
f"because no collection was provided."
|
|
1196
|
-
)
|
|
1197
|
-
raise
|
|
1198
|
-
with exit_stack:
|
|
1199
|
-
if quanta is None:
|
|
1200
|
-
reader.read_quanta()
|
|
1201
|
-
elif not quanta:
|
|
1202
|
-
reader.read_quanta(quanta)
|
|
1203
|
-
if datasets is None:
|
|
1204
|
-
reader.read_datasets()
|
|
1205
|
-
elif not datasets:
|
|
1206
|
-
reader.read_datasets(datasets)
|
|
1207
|
-
yield reader.graph, None
|
|
1208
|
-
|
|
1209
932
|
@property
|
|
1210
933
|
def init_quanta(self) -> Mapping[TaskLabel, uuid.UUID]:
|
|
1211
934
|
"""A mapping from task label to the ID of the special init quantum for
|
|
@@ -1246,8 +969,6 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
|
|
|
1246
969
|
types in the pipeline graph are included, even if none of their
|
|
1247
970
|
datasets were loaded (i.e. nested mappings may be empty).
|
|
1248
971
|
|
|
1249
|
-
Reading a quantum also populates its log and metadata datasets.
|
|
1250
|
-
|
|
1251
972
|
The returned object may be an internal dictionary; as the type
|
|
1252
973
|
annotation indicates, it should not be modified in place.
|
|
1253
974
|
"""
|
|
@@ -1286,8 +1007,7 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
|
|
|
1286
1007
|
`ProvenanceQuantumGraphReader.read_quanta`) or datasets (via
|
|
1287
1008
|
`ProvenanceQuantumGraphReader.read_datasets`) will load those nodes
|
|
1288
1009
|
with full attributes and edges to adjacent nodes with no attributes.
|
|
1289
|
-
Loading quanta
|
|
1290
|
-
Reading a quantum also populates its log and metadata datasets.
|
|
1010
|
+
Loading quanta necessary to populate edge attributes.
|
|
1291
1011
|
|
|
1292
1012
|
Node attributes are described by the
|
|
1293
1013
|
`ProvenanceQuantumInfo`, `ProvenanceInitQuantumInfo`, and
|
|
@@ -1302,16 +1022,10 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
|
|
|
1302
1022
|
"""
|
|
1303
1023
|
return self._bipartite_xgraph.copy(as_view=True)
|
|
1304
1024
|
|
|
1305
|
-
def make_quantum_table(self
|
|
1025
|
+
def make_quantum_table(self) -> astropy.table.Table:
|
|
1306
1026
|
"""Construct an `astropy.table.Table` with a tabular summary of the
|
|
1307
1027
|
quanta.
|
|
1308
1028
|
|
|
1309
|
-
Parameters
|
|
1310
|
-
----------
|
|
1311
|
-
drop_unused_columns : `bool`, optional
|
|
1312
|
-
Whether to drop columns for rare states that did not actually
|
|
1313
|
-
occur in this run.
|
|
1314
|
-
|
|
1315
1029
|
Returns
|
|
1316
1030
|
-------
|
|
1317
1031
|
table : `astropy.table.Table`
|
|
@@ -1347,30 +1061,28 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
|
|
|
1347
1061
|
caveats = f"{code.concise()}({count})" # type: ignore[union-attr]
|
|
1348
1062
|
else:
|
|
1349
1063
|
caveats = ""
|
|
1350
|
-
|
|
1351
|
-
"Task": task_label,
|
|
1352
|
-
"Caveats": caveats,
|
|
1353
|
-
}
|
|
1354
|
-
for status in QuantumAttemptStatus:
|
|
1355
|
-
row[status.title] = status_counts.get(status, 0)
|
|
1356
|
-
row.update(
|
|
1064
|
+
rows.append(
|
|
1357
1065
|
{
|
|
1066
|
+
"Task": task_label,
|
|
1067
|
+
"Unknown": status_counts.get(QuantumAttemptStatus.UNKNOWN, 0),
|
|
1068
|
+
"Successful": status_counts.get(QuantumAttemptStatus.SUCCESSFUL, 0),
|
|
1069
|
+
"Caveats": caveats,
|
|
1070
|
+
"Blocked": status_counts.get(QuantumAttemptStatus.BLOCKED, 0),
|
|
1071
|
+
"Failed": status_counts.get(QuantumAttemptStatus.FAILED, 0),
|
|
1358
1072
|
"TOTAL": len(quanta_for_task),
|
|
1359
1073
|
"EXPECTED": self.header.n_task_quanta[task_label],
|
|
1360
1074
|
}
|
|
1361
1075
|
)
|
|
1362
|
-
|
|
1363
|
-
table = astropy.table.Table(rows)
|
|
1364
|
-
if drop_unused_columns:
|
|
1365
|
-
for status in QuantumAttemptStatus:
|
|
1366
|
-
if status.is_rare and not table[status.title].any():
|
|
1367
|
-
del table[status.title]
|
|
1368
|
-
return table
|
|
1076
|
+
return astropy.table.Table(rows)
|
|
1369
1077
|
|
|
1370
1078
|
def make_exception_table(self) -> astropy.table.Table:
|
|
1371
1079
|
"""Construct an `astropy.table.Table` with counts for each exception
|
|
1372
1080
|
type raised by each task.
|
|
1373
1081
|
|
|
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
|
+
|
|
1374
1086
|
Returns
|
|
1375
1087
|
-------
|
|
1376
1088
|
table : `astropy.table.Table`
|
|
@@ -1378,25 +1090,13 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
|
|
|
1378
1090
|
"""
|
|
1379
1091
|
rows = []
|
|
1380
1092
|
for task_label, quanta_for_task in self.quanta_by_task.items():
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
success_counts[exc_info.type_name] += 1
|
|
1389
|
-
else:
|
|
1390
|
-
failed_counts[exc_info.type_name] += 1
|
|
1391
|
-
for type_name in sorted(success_counts.keys() | failed_counts.keys()):
|
|
1392
|
-
rows.append(
|
|
1393
|
-
{
|
|
1394
|
-
"Task": task_label,
|
|
1395
|
-
"Exception": type_name,
|
|
1396
|
-
"Successes": success_counts.get(type_name, 0),
|
|
1397
|
-
"Failures": failed_counts.get(type_name, 0),
|
|
1398
|
-
}
|
|
1399
|
-
)
|
|
1093
|
+
counts_by_type = Counter(
|
|
1094
|
+
exc_info.type_name
|
|
1095
|
+
for q in quanta_for_task.values()
|
|
1096
|
+
if (exc_info := self._quantum_only_xgraph.nodes[q]["exception"]) is not None
|
|
1097
|
+
)
|
|
1098
|
+
for type_name, count in counts_by_type.items():
|
|
1099
|
+
rows.append({"Task": task_label, "Exception": type_name, "Count": count})
|
|
1400
1100
|
return astropy.table.Table(rows)
|
|
1401
1101
|
|
|
1402
1102
|
def make_task_resource_usage_table(
|
|
@@ -1439,171 +1139,6 @@ class ProvenanceQuantumGraph(BaseQuantumGraph):
|
|
|
1439
1139
|
array = np.array(rows, dtype=row_dtype)
|
|
1440
1140
|
return astropy.table.Table(array, units=QuantumResourceUsage.get_units())
|
|
1441
1141
|
|
|
1442
|
-
def make_status_report(
|
|
1443
|
-
self,
|
|
1444
|
-
states: Iterable[QuantumAttemptStatus] = (
|
|
1445
|
-
QuantumAttemptStatus.FAILED,
|
|
1446
|
-
QuantumAttemptStatus.ABORTED,
|
|
1447
|
-
QuantumAttemptStatus.ABORTED_SUCCESS,
|
|
1448
|
-
),
|
|
1449
|
-
*,
|
|
1450
|
-
also: QuantumAttemptStatus | Iterable[QuantumAttemptStatus] = (),
|
|
1451
|
-
with_caveats: QuantumSuccessCaveats | None = QuantumSuccessCaveats.PARTIAL_OUTPUTS_ERROR,
|
|
1452
|
-
data_id_table_dir: ResourcePathExpression | None = None,
|
|
1453
|
-
) -> ProvenanceReport:
|
|
1454
|
-
"""Make a JSON- or YAML-friendly report of all quanta with the given
|
|
1455
|
-
states.
|
|
1456
|
-
|
|
1457
|
-
Parameters
|
|
1458
|
-
----------
|
|
1459
|
-
states : `~collections.abc.Iterable` [`..QuantumAttemptStatus`] or \
|
|
1460
|
-
`..QuantumAttemptStatus`, optional
|
|
1461
|
-
A quantum is included if it has any of these states. Defaults to
|
|
1462
|
-
states that clearly represent problems.
|
|
1463
|
-
also : `~collections.abc.Iterable` [`..QuantumAttemptStatus`] or \
|
|
1464
|
-
`..QuantumAttemptStatus`, optional
|
|
1465
|
-
Additional states to consider; unioned with ``states``. This is
|
|
1466
|
-
provided so users can easily request additional states while also
|
|
1467
|
-
getting the defaults.
|
|
1468
|
-
with_caveats : `..QuantumSuccessCaveats` or `None`, optional
|
|
1469
|
-
If `..QuantumAttemptStatus.SUCCESSFUL` is in ``states``, only
|
|
1470
|
-
include quanta with these caveat flags. May be set to `None`
|
|
1471
|
-
to report on all successful quanta.
|
|
1472
|
-
data_id_table_dir : convertible to `~lsst.resources.ResourcePath`, \
|
|
1473
|
-
optional
|
|
1474
|
-
If provided, a directory to write data ID tables (in ECSV format)
|
|
1475
|
-
with all of the data IDs with the given states, for use with the
|
|
1476
|
-
``--data-id-tables`` argument to the quantum graph builder.
|
|
1477
|
-
Subdirectories for each task and status will created within this
|
|
1478
|
-
directory, with one file for each exception type (or ``UNKNOWN``
|
|
1479
|
-
when there is no exception).
|
|
1480
|
-
|
|
1481
|
-
Returns
|
|
1482
|
-
-------
|
|
1483
|
-
report : `ProvenanceModel`
|
|
1484
|
-
A Pydantic model that groups quanta by task label and exception
|
|
1485
|
-
type.
|
|
1486
|
-
"""
|
|
1487
|
-
states = set(ensure_iterable(states))
|
|
1488
|
-
states.update(ensure_iterable(also))
|
|
1489
|
-
result = ProvenanceReport(root={})
|
|
1490
|
-
if data_id_table_dir is not None:
|
|
1491
|
-
data_id_table_dir = ResourcePath(data_id_table_dir)
|
|
1492
|
-
for task_label, quanta_for_task in self.quanta_by_task.items():
|
|
1493
|
-
reports_for_task: dict[str, dict[str | None, list[ProvenanceQuantumReport]]] = {}
|
|
1494
|
-
table_rows_for_task: dict[str, dict[str | None, list[tuple[int | str, ...]]]] = {}
|
|
1495
|
-
for quantum_id in quanta_for_task.values():
|
|
1496
|
-
quantum_info: ProvenanceQuantumInfo = self._quantum_only_xgraph.nodes[quantum_id]
|
|
1497
|
-
quantum_status = quantum_info["status"]
|
|
1498
|
-
if quantum_status not in states:
|
|
1499
|
-
continue
|
|
1500
|
-
if (
|
|
1501
|
-
quantum_status is QuantumAttemptStatus.SUCCESSFUL
|
|
1502
|
-
and with_caveats is not None
|
|
1503
|
-
and (quantum_info["caveats"] is None or not (quantum_info["caveats"] & with_caveats))
|
|
1504
|
-
):
|
|
1505
|
-
continue
|
|
1506
|
-
key1 = quantum_status.name
|
|
1507
|
-
exc_info = quantum_info["exception"]
|
|
1508
|
-
key2 = exc_info.type_name if exc_info is not None else None
|
|
1509
|
-
reports_for_task.setdefault(key1, {}).setdefault(key2, []).append(
|
|
1510
|
-
ProvenanceQuantumReport.from_info(quantum_id, quantum_info)
|
|
1511
|
-
)
|
|
1512
|
-
if data_id_table_dir:
|
|
1513
|
-
table_rows_for_task.setdefault(key1, {}).setdefault(key2, []).append(
|
|
1514
|
-
quantum_info["data_id"].required_values
|
|
1515
|
-
)
|
|
1516
|
-
if reports_for_task:
|
|
1517
|
-
result.root[task_label] = reports_for_task
|
|
1518
|
-
if table_rows_for_task:
|
|
1519
|
-
assert data_id_table_dir is not None, "table_rows_for_task should be empty"
|
|
1520
|
-
for status_name, table_rows_for_status in table_rows_for_task.items():
|
|
1521
|
-
dir_for_task_and_status = data_id_table_dir.join(task_label, forceDirectory=True).join(
|
|
1522
|
-
status_name, forceDirectory=True
|
|
1523
|
-
)
|
|
1524
|
-
if dir_for_task_and_status.isLocal:
|
|
1525
|
-
dir_for_task_and_status.mkdir()
|
|
1526
|
-
for exc_name, data_id_rows in table_rows_for_status.items():
|
|
1527
|
-
table = astropy.table.Table(
|
|
1528
|
-
rows=data_id_rows,
|
|
1529
|
-
names=list(self.pipeline_graph.tasks[task_label].dimensions.required),
|
|
1530
|
-
)
|
|
1531
|
-
filename = f"{exc_name}.ecsv" if exc_name is not None else "UNKNOWN.ecsv"
|
|
1532
|
-
with dir_for_task_and_status.join(filename).open("w") as stream:
|
|
1533
|
-
table.write(stream, format="ecsv")
|
|
1534
|
-
return result
|
|
1535
|
-
|
|
1536
|
-
def make_many_reports(
|
|
1537
|
-
self,
|
|
1538
|
-
states: Iterable[QuantumAttemptStatus] = (
|
|
1539
|
-
QuantumAttemptStatus.FAILED,
|
|
1540
|
-
QuantumAttemptStatus.ABORTED,
|
|
1541
|
-
QuantumAttemptStatus.ABORTED_SUCCESS,
|
|
1542
|
-
),
|
|
1543
|
-
*,
|
|
1544
|
-
status_report_file: ResourcePathExpression | None = None,
|
|
1545
|
-
print_quantum_table: bool = False,
|
|
1546
|
-
print_exception_table: bool = False,
|
|
1547
|
-
also: QuantumAttemptStatus | Iterable[QuantumAttemptStatus] = (),
|
|
1548
|
-
with_caveats: QuantumSuccessCaveats | None = None,
|
|
1549
|
-
data_id_table_dir: ResourcePathExpression | None = None,
|
|
1550
|
-
) -> None:
|
|
1551
|
-
"""Write multiple reports.
|
|
1552
|
-
|
|
1553
|
-
Parameters
|
|
1554
|
-
----------
|
|
1555
|
-
states : `~collections.abc.Iterable` [`..QuantumAttemptStatus`] or \
|
|
1556
|
-
`..QuantumAttemptStatus`, optional
|
|
1557
|
-
A quantum is included in the status report and data ID tables if it
|
|
1558
|
-
has any of these states. Defaults to states that clearly represent
|
|
1559
|
-
problems.
|
|
1560
|
-
status_report_file : convertible to `~lsst.resources.ResourcePath`,
|
|
1561
|
-
optional
|
|
1562
|
-
Filename for the JSON status report (see `make_status_report`).
|
|
1563
|
-
print_quantum_table : `bool`, optional
|
|
1564
|
-
If `True`, print a quantum summary table (counts only) to STDOUT.
|
|
1565
|
-
print_exception_table : `bool`, optional
|
|
1566
|
-
If `True`, print an exception-type summary table (counts only) to
|
|
1567
|
-
STDOUT.
|
|
1568
|
-
also : `~collections.abc.Iterable` [`..QuantumAttemptStatus`] or \
|
|
1569
|
-
`..QuantumAttemptStatus`, optional
|
|
1570
|
-
Additional states to consider in the status report and data ID
|
|
1571
|
-
tables; unioned with ``states``. This is provided so users can
|
|
1572
|
-
easily request additional states while also getting the defaults.
|
|
1573
|
-
with_caveats : `..QuantumSuccessCaveats` or `None`, optional
|
|
1574
|
-
Only include quanta with these caveat flags in the status report
|
|
1575
|
-
and data ID tables. May be set to `None` to report on all
|
|
1576
|
-
successful quanta (an empty sequence reports on only quanta with no
|
|
1577
|
-
caveats). If provided, `QuantumAttemptStatus.SUCCESSFUL` is
|
|
1578
|
-
automatically included in ``states``.
|
|
1579
|
-
data_id_table_dir : convertible to `~lsst.resources.ResourcePath`, \
|
|
1580
|
-
optional
|
|
1581
|
-
If provided, a directory to write data ID tables (in ECSV format)
|
|
1582
|
-
with all of the data IDs with the given states, for use with the
|
|
1583
|
-
``--data-id-tables`` argument to the quantum graph builder.
|
|
1584
|
-
Subdirectories for each task and status will created within this
|
|
1585
|
-
directory, with one file for each exception type (or ``UNKNOWN``
|
|
1586
|
-
when there is no exception).
|
|
1587
|
-
"""
|
|
1588
|
-
if status_report_file is not None or data_id_table_dir is not None:
|
|
1589
|
-
status_report = self.make_status_report(
|
|
1590
|
-
states, also=also, with_caveats=with_caveats, data_id_table_dir=data_id_table_dir
|
|
1591
|
-
)
|
|
1592
|
-
if status_report_file is not None:
|
|
1593
|
-
status_report_file = ResourcePath(status_report_file)
|
|
1594
|
-
if status_report_file.isLocal:
|
|
1595
|
-
status_report_file.dirname().mkdir()
|
|
1596
|
-
with ResourcePath(status_report_file).open("w") as stream:
|
|
1597
|
-
stream.write(status_report.model_dump_json(indent=2))
|
|
1598
|
-
if print_quantum_table:
|
|
1599
|
-
quantum_table = self.make_quantum_table()
|
|
1600
|
-
quantum_table.pprint_all()
|
|
1601
|
-
print("")
|
|
1602
|
-
if print_exception_table:
|
|
1603
|
-
exception_table = self.make_exception_table()
|
|
1604
|
-
exception_table.pprint_all()
|
|
1605
|
-
print("")
|
|
1606
|
-
|
|
1607
1142
|
|
|
1608
1143
|
@dataclasses.dataclass
|
|
1609
1144
|
class ProvenanceQuantumGraphReader(BaseQuantumGraphReader):
|
|
@@ -1734,19 +1269,19 @@ class ProvenanceQuantumGraphReader(BaseQuantumGraphReader):
|
|
|
1734
1269
|
# also have other outstanding reference holders).
|
|
1735
1270
|
continue
|
|
1736
1271
|
node._add_to_graph(self.graph)
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
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)
|
|
1750
1285
|
|
|
1751
1286
|
def fetch_logs(self, nodes: Iterable[uuid.UUID]) -> dict[uuid.UUID, list[ButlerLogRecords | None]]:
|
|
1752
1287
|
"""Fetch log datasets.
|
|
@@ -1817,629 +1352,3 @@ class ProvenanceQuantumGraphReader(BaseQuantumGraphReader):
|
|
|
1817
1352
|
"""Fetch package version information."""
|
|
1818
1353
|
data = self._read_single_block_raw("packages")
|
|
1819
1354
|
return Packages.fromBytes(data, format="json")
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
class ProvenanceQuantumGraphWriter:
|
|
1823
|
-
"""A struct of low-level writer objects for the main components of a
|
|
1824
|
-
provenance quantum graph.
|
|
1825
|
-
|
|
1826
|
-
Parameters
|
|
1827
|
-
----------
|
|
1828
|
-
output_path : `str`
|
|
1829
|
-
Path to write the graph to.
|
|
1830
|
-
exit_stack : `contextlib.ExitStack`
|
|
1831
|
-
Object that can be used to manage multiple context managers.
|
|
1832
|
-
log_on_close : `LogOnClose`
|
|
1833
|
-
Factory for context managers that log when closed.
|
|
1834
|
-
predicted : `.PredictedQuantumGraphComponents`
|
|
1835
|
-
Components of the predicted graph.
|
|
1836
|
-
zstd_level : `int`, optional
|
|
1837
|
-
Compression level.
|
|
1838
|
-
cdict_data : `bytes` or `None`, optional
|
|
1839
|
-
Bytes representation of the compression dictionary used by the
|
|
1840
|
-
compressor.
|
|
1841
|
-
loop_wrapper : `~collections.abc.Callable`, optional
|
|
1842
|
-
A callable that takes an iterable and returns an equivalent one, to be
|
|
1843
|
-
used in all potentially-large loops. This can be used to add progress
|
|
1844
|
-
reporting or check for cancelation signals.
|
|
1845
|
-
log : `LsstLogAdapter`, optional
|
|
1846
|
-
Logger to use for debug messages.
|
|
1847
|
-
"""
|
|
1848
|
-
|
|
1849
|
-
def __init__(
|
|
1850
|
-
self,
|
|
1851
|
-
output_path: str,
|
|
1852
|
-
*,
|
|
1853
|
-
exit_stack: ExitStack,
|
|
1854
|
-
log_on_close: LogOnClose,
|
|
1855
|
-
predicted: PredictedQuantumGraphComponents | PredictedQuantumGraph,
|
|
1856
|
-
zstd_level: int = 10,
|
|
1857
|
-
cdict_data: bytes | None = None,
|
|
1858
|
-
loop_wrapper: LoopWrapper = pass_through,
|
|
1859
|
-
log: LsstLogAdapter | None = None,
|
|
1860
|
-
) -> None:
|
|
1861
|
-
header = predicted.header.model_copy()
|
|
1862
|
-
header.graph_type = "provenance"
|
|
1863
|
-
if log is None:
|
|
1864
|
-
log = _LOG
|
|
1865
|
-
self.log = log
|
|
1866
|
-
self._base_writer = exit_stack.enter_context(
|
|
1867
|
-
log_on_close.wrap(
|
|
1868
|
-
BaseQuantumGraphWriter.open(
|
|
1869
|
-
output_path,
|
|
1870
|
-
header,
|
|
1871
|
-
predicted.pipeline_graph,
|
|
1872
|
-
address_filename="nodes",
|
|
1873
|
-
zstd_level=zstd_level,
|
|
1874
|
-
cdict_data=cdict_data,
|
|
1875
|
-
),
|
|
1876
|
-
"Finishing writing provenance quantum graph.",
|
|
1877
|
-
)
|
|
1878
|
-
)
|
|
1879
|
-
self._base_writer.address_writer.addresses = [{}, {}, {}, {}]
|
|
1880
|
-
self._log_writer = exit_stack.enter_context(
|
|
1881
|
-
log_on_close.wrap(
|
|
1882
|
-
MultiblockWriter.open_in_zip(
|
|
1883
|
-
self._base_writer.zf, LOG_MB_NAME, header.int_size, use_tempfile=True
|
|
1884
|
-
),
|
|
1885
|
-
"Copying logs into zip archive.",
|
|
1886
|
-
),
|
|
1887
|
-
)
|
|
1888
|
-
self._base_writer.address_writer.addresses[LOG_ADDRESS_INDEX] = self._log_writer.addresses
|
|
1889
|
-
self._metadata_writer = exit_stack.enter_context(
|
|
1890
|
-
log_on_close.wrap(
|
|
1891
|
-
MultiblockWriter.open_in_zip(
|
|
1892
|
-
self._base_writer.zf, METADATA_MB_NAME, header.int_size, use_tempfile=True
|
|
1893
|
-
),
|
|
1894
|
-
"Copying metadata into zip archive.",
|
|
1895
|
-
)
|
|
1896
|
-
)
|
|
1897
|
-
self._base_writer.address_writer.addresses[METADATA_ADDRESS_INDEX] = self._metadata_writer.addresses
|
|
1898
|
-
self._dataset_writer = exit_stack.enter_context(
|
|
1899
|
-
log_on_close.wrap(
|
|
1900
|
-
MultiblockWriter.open_in_zip(
|
|
1901
|
-
self._base_writer.zf, DATASET_MB_NAME, header.int_size, use_tempfile=True
|
|
1902
|
-
),
|
|
1903
|
-
"Copying dataset provenance into zip archive.",
|
|
1904
|
-
)
|
|
1905
|
-
)
|
|
1906
|
-
self._base_writer.address_writer.addresses[DATASET_ADDRESS_INDEX] = self._dataset_writer.addresses
|
|
1907
|
-
self._quantum_writer = exit_stack.enter_context(
|
|
1908
|
-
log_on_close.wrap(
|
|
1909
|
-
MultiblockWriter.open_in_zip(
|
|
1910
|
-
self._base_writer.zf, QUANTUM_MB_NAME, header.int_size, use_tempfile=True
|
|
1911
|
-
),
|
|
1912
|
-
"Copying quantum provenance into zip archive.",
|
|
1913
|
-
)
|
|
1914
|
-
)
|
|
1915
|
-
self._base_writer.address_writer.addresses[QUANTUM_ADDRESS_INDEX] = self._quantum_writer.addresses
|
|
1916
|
-
self._init_predicted_quanta(predicted)
|
|
1917
|
-
self._populate_xgraph_and_inputs(loop_wrapper)
|
|
1918
|
-
self._existing_init_outputs: set[uuid.UUID] = set()
|
|
1919
|
-
|
|
1920
|
-
def _init_predicted_quanta(
|
|
1921
|
-
self, predicted: PredictedQuantumGraph | PredictedQuantumGraphComponents
|
|
1922
|
-
) -> None:
|
|
1923
|
-
self._predicted_init_quanta: list[PredictedQuantumDatasetsModel] = []
|
|
1924
|
-
self._predicted_quanta: dict[uuid.UUID, PredictedQuantumDatasetsModel] = {}
|
|
1925
|
-
if isinstance(predicted, PredictedQuantumGraph):
|
|
1926
|
-
self._predicted_init_quanta.extend(predicted._init_quanta.values())
|
|
1927
|
-
self._predicted_quanta.update(predicted._quantum_datasets)
|
|
1928
|
-
else:
|
|
1929
|
-
self._predicted_init_quanta.extend(predicted.init_quanta.root)
|
|
1930
|
-
self._predicted_quanta.update(predicted.quantum_datasets)
|
|
1931
|
-
self._predicted_quanta.update({q.quantum_id: q for q in self._predicted_init_quanta})
|
|
1932
|
-
|
|
1933
|
-
def _populate_xgraph_and_inputs(self, loop_wrapper: LoopWrapper = pass_through) -> None:
|
|
1934
|
-
self._xgraph = networkx.DiGraph()
|
|
1935
|
-
self._overall_inputs: dict[uuid.UUID, PredictedDatasetModel] = {}
|
|
1936
|
-
output_dataset_ids: set[uuid.UUID] = set()
|
|
1937
|
-
for predicted_quantum in loop_wrapper(self._predicted_quanta.values()):
|
|
1938
|
-
if not predicted_quantum.task_label:
|
|
1939
|
-
# Skip the 'packages' producer quantum.
|
|
1940
|
-
continue
|
|
1941
|
-
output_dataset_ids.update(predicted_quantum.iter_output_dataset_ids())
|
|
1942
|
-
for predicted_quantum in loop_wrapper(self._predicted_quanta.values()):
|
|
1943
|
-
if not predicted_quantum.task_label:
|
|
1944
|
-
# Skip the 'packages' producer quantum.
|
|
1945
|
-
continue
|
|
1946
|
-
for predicted_input in itertools.chain.from_iterable(predicted_quantum.inputs.values()):
|
|
1947
|
-
self._xgraph.add_edge(predicted_input.dataset_id, predicted_quantum.quantum_id)
|
|
1948
|
-
if predicted_input.dataset_id not in output_dataset_ids:
|
|
1949
|
-
self._overall_inputs.setdefault(predicted_input.dataset_id, predicted_input)
|
|
1950
|
-
for predicted_output in itertools.chain.from_iterable(predicted_quantum.outputs.values()):
|
|
1951
|
-
self._xgraph.add_edge(predicted_quantum.quantum_id, predicted_output.dataset_id)
|
|
1952
|
-
|
|
1953
|
-
@property
|
|
1954
|
-
def compressor(self) -> Compressor:
|
|
1955
|
-
"""Object that should be used to compress all JSON blocks."""
|
|
1956
|
-
return self._base_writer.compressor
|
|
1957
|
-
|
|
1958
|
-
def write_packages(self) -> None:
|
|
1959
|
-
"""Write package version information to the provenance graph."""
|
|
1960
|
-
packages = Packages.fromSystem(include_all=True)
|
|
1961
|
-
data = packages.toBytes("json")
|
|
1962
|
-
self._base_writer.write_single_block("packages", data)
|
|
1963
|
-
|
|
1964
|
-
def write_overall_inputs(self, loop_wrapper: LoopWrapper = pass_through) -> None:
|
|
1965
|
-
"""Write provenance for overall-input datasets.
|
|
1966
|
-
|
|
1967
|
-
Parameters
|
|
1968
|
-
----------
|
|
1969
|
-
loop_wrapper : `~collections.abc.Callable`, optional
|
|
1970
|
-
A callable that takes an iterable and returns an equivalent one, to
|
|
1971
|
-
be used in all potentially-large loops. This can be used to add
|
|
1972
|
-
progress reporting or check for cancelation signals.
|
|
1973
|
-
"""
|
|
1974
|
-
for predicted_input in loop_wrapper(self._overall_inputs.values()):
|
|
1975
|
-
if predicted_input.dataset_id not in self._dataset_writer.addresses:
|
|
1976
|
-
self._dataset_writer.write_model(
|
|
1977
|
-
predicted_input.dataset_id,
|
|
1978
|
-
ProvenanceDatasetModel.from_predicted(
|
|
1979
|
-
predicted_input,
|
|
1980
|
-
producer=None,
|
|
1981
|
-
consumers=self._xgraph.successors(predicted_input.dataset_id),
|
|
1982
|
-
),
|
|
1983
|
-
self.compressor,
|
|
1984
|
-
)
|
|
1985
|
-
del self._overall_inputs
|
|
1986
|
-
|
|
1987
|
-
def write_init_outputs(self, assume_existence: bool = True) -> None:
|
|
1988
|
-
"""Write provenance for init-output datasets and init-quanta.
|
|
1989
|
-
|
|
1990
|
-
Parameters
|
|
1991
|
-
----------
|
|
1992
|
-
assume_existence : `bool`, optional
|
|
1993
|
-
If `True`, just assume all init-outputs exist.
|
|
1994
|
-
"""
|
|
1995
|
-
init_quanta = ProvenanceInitQuantaModel()
|
|
1996
|
-
for predicted_init_quantum in self._predicted_init_quanta:
|
|
1997
|
-
if not predicted_init_quantum.task_label:
|
|
1998
|
-
# Skip the 'packages' producer quantum.
|
|
1999
|
-
continue
|
|
2000
|
-
for predicted_output in itertools.chain.from_iterable(predicted_init_quantum.outputs.values()):
|
|
2001
|
-
provenance_output = ProvenanceDatasetModel.from_predicted(
|
|
2002
|
-
predicted_output,
|
|
2003
|
-
producer=predicted_init_quantum.quantum_id,
|
|
2004
|
-
consumers=self._xgraph.successors(predicted_output.dataset_id),
|
|
2005
|
-
)
|
|
2006
|
-
provenance_output.produced = assume_existence or (
|
|
2007
|
-
provenance_output.dataset_id in self._existing_init_outputs
|
|
2008
|
-
)
|
|
2009
|
-
self._dataset_writer.write_model(
|
|
2010
|
-
provenance_output.dataset_id, provenance_output, self.compressor
|
|
2011
|
-
)
|
|
2012
|
-
init_quanta.root.append(ProvenanceInitQuantumModel.from_predicted(predicted_init_quantum))
|
|
2013
|
-
self._base_writer.write_single_model("init_quanta", init_quanta)
|
|
2014
|
-
|
|
2015
|
-
def write_quantum_provenance(
|
|
2016
|
-
self, quantum_id: uuid.UUID, metadata: TaskMetadata | None, logs: ButlerLogRecords | None
|
|
2017
|
-
) -> None:
|
|
2018
|
-
"""Gather and write provenance for a quantum.
|
|
2019
|
-
|
|
2020
|
-
Parameters
|
|
2021
|
-
----------
|
|
2022
|
-
quantum_id : `uuid.UUID`
|
|
2023
|
-
Unique ID for the quantum.
|
|
2024
|
-
metadata : `..TaskMetadata` or `None`
|
|
2025
|
-
Task metadata.
|
|
2026
|
-
logs : `lsst.daf.butler.logging.ButlerLogRecords` or `None`
|
|
2027
|
-
Task logs.
|
|
2028
|
-
"""
|
|
2029
|
-
predicted_quantum = self._predicted_quanta[quantum_id]
|
|
2030
|
-
provenance_models = ProvenanceQuantumScanModels.from_metadata_and_logs(
|
|
2031
|
-
predicted_quantum, metadata, logs, incomplete=False
|
|
2032
|
-
)
|
|
2033
|
-
scan_data = provenance_models.to_scan_data(predicted_quantum, compressor=self.compressor)
|
|
2034
|
-
self.write_scan_data(scan_data)
|
|
2035
|
-
|
|
2036
|
-
def write_scan_data(self, scan_data: ProvenanceQuantumScanData) -> None:
|
|
2037
|
-
"""Write the output of a quantum provenance scan to disk.
|
|
2038
|
-
|
|
2039
|
-
Parameters
|
|
2040
|
-
----------
|
|
2041
|
-
scan_data : `ProvenanceQuantumScanData`
|
|
2042
|
-
Result of a quantum provenance scan.
|
|
2043
|
-
"""
|
|
2044
|
-
if scan_data.status is ProvenanceQuantumScanStatus.INIT:
|
|
2045
|
-
self.log.debug("Handling init-output scan for %s.", scan_data.quantum_id)
|
|
2046
|
-
self._existing_init_outputs.update(scan_data.existing_outputs)
|
|
2047
|
-
return
|
|
2048
|
-
self.log.debug("Handling quantum scan for %s.", scan_data.quantum_id)
|
|
2049
|
-
# We shouldn't need this predicted quantum after this method runs; pop
|
|
2050
|
-
# from the dict it in the hopes that'll free up some memory when we're
|
|
2051
|
-
# done.
|
|
2052
|
-
predicted_quantum = self._predicted_quanta.pop(scan_data.quantum_id)
|
|
2053
|
-
outputs: dict[uuid.UUID, bytes] = {}
|
|
2054
|
-
for predicted_output in itertools.chain.from_iterable(predicted_quantum.outputs.values()):
|
|
2055
|
-
provenance_output = ProvenanceDatasetModel.from_predicted(
|
|
2056
|
-
predicted_output,
|
|
2057
|
-
producer=predicted_quantum.quantum_id,
|
|
2058
|
-
consumers=self._xgraph.successors(predicted_output.dataset_id),
|
|
2059
|
-
)
|
|
2060
|
-
provenance_output.produced = provenance_output.dataset_id in scan_data.existing_outputs
|
|
2061
|
-
outputs[provenance_output.dataset_id] = self.compressor.compress(
|
|
2062
|
-
provenance_output.model_dump_json().encode()
|
|
2063
|
-
)
|
|
2064
|
-
if not scan_data.quantum:
|
|
2065
|
-
scan_data.quantum = (
|
|
2066
|
-
ProvenanceQuantumModel.from_predicted(predicted_quantum).model_dump_json().encode()
|
|
2067
|
-
)
|
|
2068
|
-
if scan_data.is_compressed:
|
|
2069
|
-
scan_data.quantum = self.compressor.compress(scan_data.quantum)
|
|
2070
|
-
if not scan_data.is_compressed:
|
|
2071
|
-
scan_data.quantum = self.compressor.compress(scan_data.quantum)
|
|
2072
|
-
if scan_data.metadata:
|
|
2073
|
-
scan_data.metadata = self.compressor.compress(scan_data.metadata)
|
|
2074
|
-
if scan_data.logs:
|
|
2075
|
-
scan_data.logs = self.compressor.compress(scan_data.logs)
|
|
2076
|
-
self.log.debug("Writing quantum %s.", scan_data.quantum_id)
|
|
2077
|
-
self._quantum_writer.write_bytes(scan_data.quantum_id, scan_data.quantum)
|
|
2078
|
-
for dataset_id, dataset_data in outputs.items():
|
|
2079
|
-
self._dataset_writer.write_bytes(dataset_id, dataset_data)
|
|
2080
|
-
if scan_data.metadata:
|
|
2081
|
-
(metadata_output,) = predicted_quantum.outputs[acc.METADATA_OUTPUT_CONNECTION_NAME]
|
|
2082
|
-
address = self._metadata_writer.write_bytes(scan_data.quantum_id, scan_data.metadata)
|
|
2083
|
-
self._metadata_writer.addresses[metadata_output.dataset_id] = address
|
|
2084
|
-
if scan_data.logs:
|
|
2085
|
-
(log_output,) = predicted_quantum.outputs[acc.LOG_OUTPUT_CONNECTION_NAME]
|
|
2086
|
-
address = self._log_writer.write_bytes(scan_data.quantum_id, scan_data.logs)
|
|
2087
|
-
self._log_writer.addresses[log_output.dataset_id] = address
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
class ProvenanceQuantumScanStatus(enum.Enum):
|
|
2091
|
-
"""Status enum for quantum scanning.
|
|
2092
|
-
|
|
2093
|
-
Note that this records the status for the *scanning* which is distinct
|
|
2094
|
-
from the status of the quantum's execution.
|
|
2095
|
-
"""
|
|
2096
|
-
|
|
2097
|
-
INCOMPLETE = enum.auto()
|
|
2098
|
-
"""The quantum is not necessarily done running, and cannot be scanned
|
|
2099
|
-
conclusively yet.
|
|
2100
|
-
"""
|
|
2101
|
-
|
|
2102
|
-
ABANDONED = enum.auto()
|
|
2103
|
-
"""The quantum's execution appears to have failed but we cannot rule out
|
|
2104
|
-
the possibility that it could be recovered, but we've also waited long
|
|
2105
|
-
enough (according to `ScannerTimeConfigDict.retry_timeout`) that it's time
|
|
2106
|
-
to stop trying for now.
|
|
2107
|
-
|
|
2108
|
-
This state means `ProvenanceQuantumScanModels.from_metadata_and_logs` must
|
|
2109
|
-
be run again with ``incomplete=False``.
|
|
2110
|
-
"""
|
|
2111
|
-
|
|
2112
|
-
SUCCESSFUL = enum.auto()
|
|
2113
|
-
"""The quantum was conclusively scanned and was executed successfully,
|
|
2114
|
-
unblocking scans for downstream quanta.
|
|
2115
|
-
"""
|
|
2116
|
-
|
|
2117
|
-
FAILED = enum.auto()
|
|
2118
|
-
"""The quantum was conclusively scanned and failed execution, blocking
|
|
2119
|
-
scans for downstream quanta.
|
|
2120
|
-
"""
|
|
2121
|
-
|
|
2122
|
-
BLOCKED = enum.auto()
|
|
2123
|
-
"""A quantum upstream of this one failed."""
|
|
2124
|
-
|
|
2125
|
-
INIT = enum.auto()
|
|
2126
|
-
"""Init quanta need special handling, because they don't have logs and
|
|
2127
|
-
metadata.
|
|
2128
|
-
"""
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
@dataclasses.dataclass
|
|
2132
|
-
class ProvenanceQuantumScanModels:
|
|
2133
|
-
"""A struct that represents provenance information for a single quantum."""
|
|
2134
|
-
|
|
2135
|
-
quantum_id: uuid.UUID
|
|
2136
|
-
"""Unique ID for the quantum."""
|
|
2137
|
-
|
|
2138
|
-
status: ProvenanceQuantumScanStatus = ProvenanceQuantumScanStatus.INCOMPLETE
|
|
2139
|
-
"""Combined status for the scan and the execution of the quantum."""
|
|
2140
|
-
|
|
2141
|
-
attempts: list[ProvenanceQuantumAttemptModel] = dataclasses.field(default_factory=list)
|
|
2142
|
-
"""Provenance information about each attempt to run the quantum."""
|
|
2143
|
-
|
|
2144
|
-
output_existence: dict[uuid.UUID, bool] = dataclasses.field(default_factory=dict)
|
|
2145
|
-
"""Unique IDs of the output datasets mapped to whether they were actually
|
|
2146
|
-
produced.
|
|
2147
|
-
"""
|
|
2148
|
-
|
|
2149
|
-
metadata: ProvenanceTaskMetadataModel = dataclasses.field(default_factory=ProvenanceTaskMetadataModel)
|
|
2150
|
-
"""Task metadata information for each attempt.
|
|
2151
|
-
"""
|
|
2152
|
-
|
|
2153
|
-
logs: ProvenanceLogRecordsModel = dataclasses.field(default_factory=ProvenanceLogRecordsModel)
|
|
2154
|
-
"""Log records for each attempt.
|
|
2155
|
-
"""
|
|
2156
|
-
|
|
2157
|
-
@classmethod
|
|
2158
|
-
def from_metadata_and_logs(
|
|
2159
|
-
cls,
|
|
2160
|
-
predicted: PredictedQuantumDatasetsModel,
|
|
2161
|
-
metadata: TaskMetadata | None,
|
|
2162
|
-
logs: ButlerLogRecords | None,
|
|
2163
|
-
*,
|
|
2164
|
-
incomplete: bool = False,
|
|
2165
|
-
) -> ProvenanceQuantumScanModels:
|
|
2166
|
-
"""Construct provenance information from task metadata and logs.
|
|
2167
|
-
|
|
2168
|
-
Parameters
|
|
2169
|
-
----------
|
|
2170
|
-
predicted : `PredictedQuantumDatasetsModel`
|
|
2171
|
-
Information about the predicted quantum.
|
|
2172
|
-
metadata : `..TaskMetadata` or `None`
|
|
2173
|
-
Task metadata.
|
|
2174
|
-
logs : `lsst.daf.butler.logging.ButlerLogRecords` or `None`
|
|
2175
|
-
Task logs.
|
|
2176
|
-
incomplete : `bool`, optional
|
|
2177
|
-
If `True`, treat execution failures as possibly-incomplete quanta
|
|
2178
|
-
and do not fully process them; instead just set the status to
|
|
2179
|
-
`ProvenanceQuantumScanStatus.ABANDONED` and return.
|
|
2180
|
-
|
|
2181
|
-
Returns
|
|
2182
|
-
-------
|
|
2183
|
-
scan_models : `ProvenanceQuantumScanModels`
|
|
2184
|
-
Struct of models that describe quantum provenance.
|
|
2185
|
-
|
|
2186
|
-
Notes
|
|
2187
|
-
-----
|
|
2188
|
-
This method does not necessarily fully populate the `output_existence`
|
|
2189
|
-
field; it does what it can given the information in the metadata and
|
|
2190
|
-
logs, but the caller is responsible for filling in the existence status
|
|
2191
|
-
for any predicted outputs that are not present at all in that `dict`.
|
|
2192
|
-
"""
|
|
2193
|
-
self = ProvenanceQuantumScanModels(predicted.quantum_id)
|
|
2194
|
-
last_attempt = ProvenanceQuantumAttemptModel()
|
|
2195
|
-
self._process_logs(predicted, logs, last_attempt, incomplete=incomplete)
|
|
2196
|
-
self._process_metadata(predicted, metadata, last_attempt, incomplete=incomplete)
|
|
2197
|
-
if self.status is ProvenanceQuantumScanStatus.ABANDONED:
|
|
2198
|
-
return self
|
|
2199
|
-
self._reconcile_attempts(last_attempt)
|
|
2200
|
-
self._extract_output_existence(predicted)
|
|
2201
|
-
return self
|
|
2202
|
-
|
|
2203
|
-
def _process_logs(
|
|
2204
|
-
self,
|
|
2205
|
-
predicted: PredictedQuantumDatasetsModel,
|
|
2206
|
-
logs: ButlerLogRecords | None,
|
|
2207
|
-
last_attempt: ProvenanceQuantumAttemptModel,
|
|
2208
|
-
*,
|
|
2209
|
-
incomplete: bool,
|
|
2210
|
-
) -> None:
|
|
2211
|
-
(predicted_log_dataset,) = predicted.outputs[acc.LOG_OUTPUT_CONNECTION_NAME]
|
|
2212
|
-
if logs is None:
|
|
2213
|
-
self.output_existence[predicted_log_dataset.dataset_id] = False
|
|
2214
|
-
if incomplete:
|
|
2215
|
-
self.status = ProvenanceQuantumScanStatus.ABANDONED
|
|
2216
|
-
else:
|
|
2217
|
-
self.status = ProvenanceQuantumScanStatus.FAILED
|
|
2218
|
-
else:
|
|
2219
|
-
# Set the attempt's run status to FAILED, since the default is
|
|
2220
|
-
# UNKNOWN (i.e. logs *and* metadata are missing) and we now know
|
|
2221
|
-
# the logs exist. This will usually get replaced by SUCCESSFUL
|
|
2222
|
-
# when we look for metadata next.
|
|
2223
|
-
last_attempt.status = QuantumAttemptStatus.FAILED
|
|
2224
|
-
self.output_existence[predicted_log_dataset.dataset_id] = True
|
|
2225
|
-
if logs.extra:
|
|
2226
|
-
log_extra = _ExecutionLogRecordsExtra.model_validate(logs.extra)
|
|
2227
|
-
self._extract_from_log_extra(log_extra, last_attempt=last_attempt)
|
|
2228
|
-
self.logs.attempts.append(list(logs))
|
|
2229
|
-
|
|
2230
|
-
def _extract_from_log_extra(
|
|
2231
|
-
self,
|
|
2232
|
-
log_extra: _ExecutionLogRecordsExtra,
|
|
2233
|
-
last_attempt: ProvenanceQuantumAttemptModel | None,
|
|
2234
|
-
) -> None:
|
|
2235
|
-
for previous_attempt_log_extra in log_extra.previous_attempts:
|
|
2236
|
-
self._extract_from_log_extra(
|
|
2237
|
-
previous_attempt_log_extra,
|
|
2238
|
-
last_attempt=None,
|
|
2239
|
-
)
|
|
2240
|
-
quantum_attempt: ProvenanceQuantumAttemptModel
|
|
2241
|
-
if last_attempt is None:
|
|
2242
|
-
# This is not the last attempt, so it must be a failure.
|
|
2243
|
-
quantum_attempt = ProvenanceQuantumAttemptModel(
|
|
2244
|
-
attempt=len(self.attempts), status=QuantumAttemptStatus.FAILED
|
|
2245
|
-
)
|
|
2246
|
-
# We also need to get the logs from this extra provenance, since
|
|
2247
|
-
# they won't be the main section of the log records.
|
|
2248
|
-
self.logs.attempts.append(log_extra.logs)
|
|
2249
|
-
# The special last attempt is only appended after we attempt to
|
|
2250
|
-
# read metadata later, but we have to append this one now.
|
|
2251
|
-
self.attempts.append(quantum_attempt)
|
|
2252
|
-
else:
|
|
2253
|
-
assert not log_extra.logs, "Logs for the last attempt should not be stored in the extra JSON."
|
|
2254
|
-
quantum_attempt = last_attempt
|
|
2255
|
-
if log_extra.exception is not None or log_extra.metadata is not None or last_attempt is None:
|
|
2256
|
-
# We won't be getting a separate metadata dataset, so anything we
|
|
2257
|
-
# might get from the metadata has to come from this extra
|
|
2258
|
-
# provenance in the logs.
|
|
2259
|
-
quantum_attempt.exception = log_extra.exception
|
|
2260
|
-
if log_extra.metadata is not None:
|
|
2261
|
-
quantum_attempt.resource_usage = QuantumResourceUsage.from_task_metadata(log_extra.metadata)
|
|
2262
|
-
self.metadata.attempts.append(log_extra.metadata)
|
|
2263
|
-
else:
|
|
2264
|
-
self.metadata.attempts.append(None)
|
|
2265
|
-
# Regardless of whether this is the last attempt or not, we can only
|
|
2266
|
-
# get the previous_process_quanta from the log extra.
|
|
2267
|
-
quantum_attempt.previous_process_quanta.extend(log_extra.previous_process_quanta)
|
|
2268
|
-
|
|
2269
|
-
def _process_metadata(
|
|
2270
|
-
self,
|
|
2271
|
-
predicted: PredictedQuantumDatasetsModel,
|
|
2272
|
-
metadata: TaskMetadata | None,
|
|
2273
|
-
last_attempt: ProvenanceQuantumAttemptModel,
|
|
2274
|
-
*,
|
|
2275
|
-
incomplete: bool,
|
|
2276
|
-
) -> None:
|
|
2277
|
-
(predicted_metadata_dataset,) = predicted.outputs[acc.METADATA_OUTPUT_CONNECTION_NAME]
|
|
2278
|
-
if metadata is None:
|
|
2279
|
-
self.output_existence[predicted_metadata_dataset.dataset_id] = False
|
|
2280
|
-
if incomplete:
|
|
2281
|
-
self.status = ProvenanceQuantumScanStatus.ABANDONED
|
|
2282
|
-
else:
|
|
2283
|
-
self.status = ProvenanceQuantumScanStatus.FAILED
|
|
2284
|
-
else:
|
|
2285
|
-
self.status = ProvenanceQuantumScanStatus.SUCCESSFUL
|
|
2286
|
-
self.output_existence[predicted_metadata_dataset.dataset_id] = True
|
|
2287
|
-
last_attempt.status = QuantumAttemptStatus.SUCCESSFUL
|
|
2288
|
-
try:
|
|
2289
|
-
# Int conversion guards against spurious conversion to
|
|
2290
|
-
# float that can apparently sometimes happen in
|
|
2291
|
-
# TaskMetadata.
|
|
2292
|
-
last_attempt.caveats = QuantumSuccessCaveats(int(metadata["quantum"]["caveats"]))
|
|
2293
|
-
except LookupError:
|
|
2294
|
-
pass
|
|
2295
|
-
try:
|
|
2296
|
-
last_attempt.exception = ExceptionInfo._from_metadata(
|
|
2297
|
-
metadata[predicted.task_label]["failure"]
|
|
2298
|
-
)
|
|
2299
|
-
except LookupError:
|
|
2300
|
-
pass
|
|
2301
|
-
last_attempt.resource_usage = QuantumResourceUsage.from_task_metadata(metadata)
|
|
2302
|
-
self.metadata.attempts.append(metadata)
|
|
2303
|
-
|
|
2304
|
-
def _reconcile_attempts(self, last_attempt: ProvenanceQuantumAttemptModel) -> None:
|
|
2305
|
-
last_attempt.attempt = len(self.attempts)
|
|
2306
|
-
self.attempts.append(last_attempt)
|
|
2307
|
-
assert self.status is not ProvenanceQuantumScanStatus.INCOMPLETE
|
|
2308
|
-
assert self.status is not ProvenanceQuantumScanStatus.ABANDONED
|
|
2309
|
-
if len(self.logs.attempts) < len(self.attempts):
|
|
2310
|
-
# Logs were not found for this attempt; must have been a hard error
|
|
2311
|
-
# that kept the `finally` block from running or otherwise
|
|
2312
|
-
# interrupted the writing of the logs.
|
|
2313
|
-
self.logs.attempts.append(None)
|
|
2314
|
-
if self.status is ProvenanceQuantumScanStatus.SUCCESSFUL:
|
|
2315
|
-
# But we found the metadata! Either that hard error happened
|
|
2316
|
-
# at a very unlucky time (in between those two writes), or
|
|
2317
|
-
# something even weirder happened.
|
|
2318
|
-
self.attempts[-1].status = QuantumAttemptStatus.ABORTED_SUCCESS
|
|
2319
|
-
else:
|
|
2320
|
-
self.attempts[-1].status = QuantumAttemptStatus.FAILED
|
|
2321
|
-
if len(self.metadata.attempts) < len(self.attempts):
|
|
2322
|
-
# Metadata missing usually just means a failure. In any case, the
|
|
2323
|
-
# status will already be correct, either because it was set to a
|
|
2324
|
-
# failure when we read the logs, or left at UNKNOWN if there were
|
|
2325
|
-
# no logs. Note that scanners never process BLOCKED quanta at all.
|
|
2326
|
-
self.metadata.attempts.append(None)
|
|
2327
|
-
assert len(self.logs.attempts) == len(self.attempts) or len(self.metadata.attempts) == len(
|
|
2328
|
-
self.attempts
|
|
2329
|
-
), (
|
|
2330
|
-
"The only way we can add more than one quantum attempt is by "
|
|
2331
|
-
"extracting info stored with the logs, and that always appends "
|
|
2332
|
-
"a log attempt and a metadata attempt, so this must be a bug in "
|
|
2333
|
-
"this class."
|
|
2334
|
-
)
|
|
2335
|
-
|
|
2336
|
-
def _extract_output_existence(self, predicted: PredictedQuantumDatasetsModel) -> None:
|
|
2337
|
-
try:
|
|
2338
|
-
outputs_put = self.metadata.attempts[-1]["quantum"].getArray("outputs") # type: ignore[index]
|
|
2339
|
-
except (
|
|
2340
|
-
IndexError, # metadata.attempts is empty
|
|
2341
|
-
TypeError, # metadata.attempts[-1] is None
|
|
2342
|
-
LookupError, # no 'quantum' entry in metadata or 'outputs' in that
|
|
2343
|
-
):
|
|
2344
|
-
pass
|
|
2345
|
-
else:
|
|
2346
|
-
for id_str in ensure_iterable(outputs_put):
|
|
2347
|
-
self.output_existence[uuid.UUID(id_str)] = True
|
|
2348
|
-
# If the metadata told us what it wrote, anything not in that
|
|
2349
|
-
# list was not written.
|
|
2350
|
-
for predicted_output in itertools.chain.from_iterable(predicted.outputs.values()):
|
|
2351
|
-
self.output_existence.setdefault(predicted_output.dataset_id, False)
|
|
2352
|
-
|
|
2353
|
-
def to_scan_data(
|
|
2354
|
-
self: ProvenanceQuantumScanModels,
|
|
2355
|
-
predicted_quantum: PredictedQuantumDatasetsModel,
|
|
2356
|
-
compressor: Compressor | None = None,
|
|
2357
|
-
) -> ProvenanceQuantumScanData:
|
|
2358
|
-
"""Convert these models to JSON data.
|
|
2359
|
-
|
|
2360
|
-
Parameters
|
|
2361
|
-
----------
|
|
2362
|
-
predicted_quantum : `PredictedQuantumDatasetsModel`
|
|
2363
|
-
Information about the predicted quantum.
|
|
2364
|
-
compressor : `Compressor`
|
|
2365
|
-
Object that can compress bytes.
|
|
2366
|
-
|
|
2367
|
-
Returns
|
|
2368
|
-
-------
|
|
2369
|
-
scan_data : `ProvenanceQuantumScanData`
|
|
2370
|
-
Scan information ready for serialization.
|
|
2371
|
-
"""
|
|
2372
|
-
quantum: ProvenanceInitQuantumModel | ProvenanceQuantumModel
|
|
2373
|
-
if self.status is ProvenanceQuantumScanStatus.INIT:
|
|
2374
|
-
quantum = ProvenanceInitQuantumModel.from_predicted(predicted_quantum)
|
|
2375
|
-
else:
|
|
2376
|
-
quantum = ProvenanceQuantumModel.from_predicted(predicted_quantum)
|
|
2377
|
-
quantum.attempts = self.attempts
|
|
2378
|
-
for predicted_output in itertools.chain.from_iterable(predicted_quantum.outputs.values()):
|
|
2379
|
-
if predicted_output.dataset_id not in self.output_existence:
|
|
2380
|
-
raise RuntimeError(
|
|
2381
|
-
"Logic bug in provenance gathering or execution invariants: "
|
|
2382
|
-
f"no existence information for output {predicted_output.dataset_id} "
|
|
2383
|
-
f"({predicted_output.dataset_type_name}@{predicted_output.data_coordinate})."
|
|
2384
|
-
)
|
|
2385
|
-
data = ProvenanceQuantumScanData(
|
|
2386
|
-
self.quantum_id,
|
|
2387
|
-
self.status,
|
|
2388
|
-
existing_outputs={
|
|
2389
|
-
dataset_id for dataset_id, was_produced in self.output_existence.items() if was_produced
|
|
2390
|
-
},
|
|
2391
|
-
quantum=quantum.model_dump_json().encode(),
|
|
2392
|
-
logs=self.logs.model_dump_json().encode() if self.logs.attempts else b"",
|
|
2393
|
-
metadata=self.metadata.model_dump_json().encode() if self.metadata.attempts else b"",
|
|
2394
|
-
)
|
|
2395
|
-
if compressor is not None:
|
|
2396
|
-
data.compress(compressor)
|
|
2397
|
-
return data
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
@dataclasses.dataclass
|
|
2401
|
-
class ProvenanceQuantumScanData:
|
|
2402
|
-
"""A struct that represents ready-for-serialization provenance information
|
|
2403
|
-
for a single quantum.
|
|
2404
|
-
"""
|
|
2405
|
-
|
|
2406
|
-
quantum_id: uuid.UUID
|
|
2407
|
-
"""Unique ID for the quantum."""
|
|
2408
|
-
|
|
2409
|
-
status: ProvenanceQuantumScanStatus
|
|
2410
|
-
"""Combined status for the scan and the execution of the quantum."""
|
|
2411
|
-
|
|
2412
|
-
existing_outputs: set[uuid.UUID] = dataclasses.field(default_factory=set)
|
|
2413
|
-
"""Unique IDs of the output datasets that were actually written."""
|
|
2414
|
-
|
|
2415
|
-
quantum: bytes = b""
|
|
2416
|
-
"""Serialized quantum provenance model.
|
|
2417
|
-
|
|
2418
|
-
This may be empty for quanta that had no attempts.
|
|
2419
|
-
"""
|
|
2420
|
-
|
|
2421
|
-
metadata: bytes = b""
|
|
2422
|
-
"""Serialized task metadata."""
|
|
2423
|
-
|
|
2424
|
-
logs: bytes = b""
|
|
2425
|
-
"""Serialized logs."""
|
|
2426
|
-
|
|
2427
|
-
is_compressed: bool = False
|
|
2428
|
-
"""Whether the ``quantum``, ``metadata``, and ``log`` attributes are
|
|
2429
|
-
compressed.
|
|
2430
|
-
"""
|
|
2431
|
-
|
|
2432
|
-
def compress(self, compressor: Compressor) -> None:
|
|
2433
|
-
"""Compress the data in this struct if it has not been compressed
|
|
2434
|
-
already.
|
|
2435
|
-
|
|
2436
|
-
Parameters
|
|
2437
|
-
----------
|
|
2438
|
-
compressor : `Compressor`
|
|
2439
|
-
Object with a ``compress`` method that takes and returns `bytes`.
|
|
2440
|
-
"""
|
|
2441
|
-
if not self.is_compressed:
|
|
2442
|
-
self.quantum = compressor.compress(self.quantum)
|
|
2443
|
-
self.logs = compressor.compress(self.logs) if self.logs else b""
|
|
2444
|
-
self.metadata = compressor.compress(self.metadata) if self.metadata else b""
|
|
2445
|
-
self.is_compressed = True
|