Coverage for python / lsst / pipe / base / quantum_graph / ingest_graph.py: 23%

147 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-27 01:20 -0800

1# This file is part of pipe_base. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27 

28"""A tool for ingesting provenance quantum graphs (written by the `aggregator` 

29module) and [re-]ingesting other datasets (metadata/logs/configs) backed by the 

30same file. This "finalizes" the RUN collection, prohibiting (at least 

31conceptually) further processing. 

32 

33This always proceeds in three steps, so we can resume efficiently: 

34 

351. First we ask the butler to "forget" any metadata/log/config datasets that 

36 exist in the output RUN collection, removing any record of them from the 

37 butler database while preserving their files. 

38 

392. Next we ingest the ``run_provenance`` graph dataset itself. 

40 

413. Finally, in batches of quanta, we use a 

42 `~lsst.daf.butler.QuantumBackedButler` to delete the original 

43 metadata/log/config files and ingest new versions of those datasets into the 

44 butler. 

45 

46Thus, at any point, if the ``run_provenance`` dataset has not been ingested, 

47we know any metadata/log/config datasets that have been ingested are backed by 

48the original files. 

49 

50Moreover, if the ``run_provenance`` dataset has been ingested, any existing 

51metadata/log/config datasets must be backed by the graph file, and the original 

52files for those datasets will have been deleted. 

53 

54We also know that at all times the metadata/log/config *content* is safely 

55present in either the original files in the butler storage or in an 

56already-ingested ``run_provenance`` dataset. 

57""" 

58 

59from __future__ import annotations 

60 

61__all__ = ("ingest_graph",) 

62 

63import dataclasses 

64import itertools 

65import uuid 

66from collections.abc import Iterator 

67from contextlib import contextmanager 

68 

69from lsst.daf.butler import ( 

70 Butler, 

71 Config, 

72 DataCoordinate, 

73 DatasetRef, 

74 DatasetType, 

75 FileDataset, 

76 QuantumBackedButler, 

77) 

78from lsst.daf.butler.registry.sql_registry import SqlRegistry 

79from lsst.resources import ResourcePath, ResourcePathExpression 

80from lsst.utils.logging import getLogger 

81 

82from ..automatic_connection_constants import PROVENANCE_DATASET_TYPE_NAME, PROVENANCE_STORAGE_CLASS 

83from ._provenance import ( 

84 ProvenanceDatasetInfo, 

85 ProvenanceInitQuantumInfo, 

86 ProvenanceQuantumGraph, 

87 ProvenanceQuantumGraphReader, 

88 ProvenanceQuantumInfo, 

89) 

90from .formatter import ProvenanceFormatter 

91 

92_LOG = getLogger(__name__) 

93 

94 

95def ingest_graph( 

96 butler_config: str | Config, 

97 uri: ResourcePathExpression | None = None, 

98 *, 

99 transfer: str | None = "move", 

100 batch_size: int = 10000, 

101 output_run: str | None = None, 

102) -> None: 

103 """Ingest a provenance graph into a butler repository. 

104 

105 Parameters 

106 ---------- 

107 butler_config : `str` 

108 Path or alias for the butler repository, or a butler repository config 

109 object. 

110 uri : `lsst.resources.ResourcePathExpression` or `None`, optional 

111 Location of the provenance quantum graph to ingest. `None` indicates 

112 that the quantum graph has already been ingested, but other ingests 

113 and/or deletions failed and need to be resumed. 

114 transfer : `str` or `None`, optional 

115 Transfer mode to use when ingesting graph. Matches those supported 

116 by `lsst.resources.ResourcePath.transfer_from`. 

117 batch_size : `int`, optional 

118 Number of datasets to process in each transaction. 

119 output_run : `str`, optional 

120 Output `~lsst.daf.butler.CollectionType.RUN` collection name. Only 

121 needs to be provided if ``uri`` is `None`. If it is provided the 

122 output run in the graph is checked against it. 

123 

124 Notes 

125 ----- 

126 After this operation, no further processing may be done in the 

127 `~lsst.daf.butler.CollectionType.RUN` collection. 

128 

129 If this process is interrupted, it can pick up where it left off if run 

130 again (at the cost of some duplicate work to figure out how much progress 

131 it had made). 

132 """ 

133 with _GraphIngester.open(butler_config, uri, output_run) as helper: 

134 helper.fetch_already_ingested_datasets() 

135 if not helper.graph_already_ingested: 

136 assert uri is not None 

137 helper.forget_ingested_datasets(batch_size=batch_size) 

138 helper.ingest_graph_dataset(uri, transfer=transfer) 

139 helper.clean_and_reingest_datasets(batch_size=batch_size) 

140 

141 

142@dataclasses.dataclass 

143class _GraphIngester: 

144 butler_config: str | Config 

145 butler: Butler 

146 graph: ProvenanceQuantumGraph 

147 graph_already_ingested: bool 

148 n_datasets: int 

149 datasets_already_ingested: set[uuid.UUID] = dataclasses.field(default_factory=set) 

150 

151 @property 

152 def output_run(self) -> str: 

153 return self.graph.header.output_run 

154 

155 @classmethod 

156 @contextmanager 

157 def open( 

158 cls, 

159 butler_config: str | Config, 

160 uri: ResourcePathExpression | None, 

161 output_run: str | None, 

162 ) -> Iterator[_GraphIngester]: 

163 with Butler.from_config(butler_config, collections=output_run, writeable=True) as butler: 

164 butler.registry.registerDatasetType( 

165 DatasetType(PROVENANCE_DATASET_TYPE_NAME, butler.dimensions.empty, PROVENANCE_STORAGE_CLASS) 

166 ) 

167 graph, graph_already_ingested = cls.read_graph(butler, uri) 

168 if output_run is not None and graph.header.output_run != output_run: 

169 raise ValueError( 

170 f"Given output run {output_run!r} does not match the graph " 

171 f"header {graph.header.output_run!r}." 

172 ) 

173 n_datasets = 2 * len(graph.quantum_only_xgraph) + len(graph.init_quanta) 

174 yield cls( 

175 butler_config=butler_config, 

176 butler=butler, 

177 graph=graph, 

178 graph_already_ingested=graph_already_ingested, 

179 n_datasets=n_datasets, 

180 ) 

181 

182 @staticmethod 

183 def read_graph( 

184 butler: Butler, 

185 uri: ResourcePathExpression | None, 

186 ) -> tuple[ProvenanceQuantumGraph, bool]: 

187 if uri is not None: 

188 _LOG.info("Reading the pre-ingest provenance graph.") 

189 with ProvenanceQuantumGraphReader.open(uri) as reader: 

190 reader.read_quanta() 

191 reader.read_init_quanta() 

192 graph = reader.graph 

193 already_ingested = ( 

194 butler.find_dataset(PROVENANCE_DATASET_TYPE_NAME, collections=[graph.header.output_run]) 

195 is not None 

196 ) 

197 return graph, already_ingested 

198 else: 

199 _LOG.info("Reading the already-ingested provenance graph.") 

200 parameters = {"datasets": [], "read_init_quanta": True} 

201 return butler.get(PROVENANCE_DATASET_TYPE_NAME, parameters=parameters), True 

202 

203 def fetch_already_ingested_datasets(self) -> None: 

204 _LOG.info("Querying for existing datasets in %r.", self.output_run) 

205 self.datasets_already_ingested.update(self.butler.registry._fetch_run_dataset_ids(self.output_run)) 

206 

207 def iter_datasets(self) -> Iterator[tuple[uuid.UUID, ProvenanceDatasetInfo]]: 

208 xgraph = self.graph.bipartite_xgraph 

209 for task_label, quanta_for_task in self.graph.quanta_by_task.items(): 

210 _LOG.verbose( 

211 "Batching up metadata and log datasets from %d %s quanta.", len(quanta_for_task), task_label 

212 ) 

213 for quantum_id in quanta_for_task.values(): 

214 quantum_info: ProvenanceQuantumInfo = xgraph.nodes[quantum_id] 

215 metadata_id = quantum_info["metadata_id"] 

216 yield metadata_id, xgraph.nodes[metadata_id] 

217 log_id = quantum_info["log_id"] 

218 yield log_id, xgraph.nodes[log_id] 

219 _LOG.verbose("Batching up config datasets from %d tasks.", len(self.graph.init_quanta)) 

220 for task_label, quantum_id in self.graph.init_quanta.items(): 

221 init_quantum_info: ProvenanceInitQuantumInfo = xgraph.nodes[quantum_id] 

222 config_id = init_quantum_info["config_id"] 

223 yield config_id, xgraph.nodes[config_id] 

224 

225 def forget_ingested_datasets(self, batch_size: int) -> None: 

226 _LOG.info( 

227 "Dropping database records for metadata/log/config datasets backed by their original files." 

228 ) 

229 to_forget: list[DatasetRef] = [] 

230 n_forgotten: int = 0 

231 n_skipped: int = 0 

232 for dataset_id, dataset_info in self.iter_datasets(): 

233 if dataset_info["produced"] and dataset_id in self.datasets_already_ingested: 

234 to_forget.append(self._make_ref_from_info(dataset_id, dataset_info)) 

235 self.datasets_already_ingested.remove(dataset_id) 

236 if len(to_forget) >= batch_size: 

237 n_forgotten += self._run_forget(to_forget, n_forgotten + n_skipped) 

238 else: 

239 n_skipped += 1 

240 n_forgotten += self._run_forget(to_forget, n_forgotten + n_skipped) 

241 _LOG.info( 

242 "Removed database records for %d metadata/log/config datasets, while %d were already absent.", 

243 n_forgotten, 

244 n_skipped, 

245 ) 

246 

247 def _run_forget(self, to_forget: list[DatasetRef], n_current: int) -> int: 

248 if to_forget: 

249 _LOG.verbose( 

250 "Forgetting a %d-dataset batch; %d/%d forgotten so far or already absent.", 

251 len(to_forget), 

252 n_current, 

253 self.n_datasets, 

254 ) 

255 with self.butler.registry.transaction(): 

256 self.butler._datastore.forget(to_forget) 

257 self.butler.registry.removeDatasets(to_forget) 

258 n = len(to_forget) 

259 to_forget.clear() 

260 return n 

261 

262 def ingest_graph_dataset(self, uri: ResourcePathExpression, transfer: str | None) -> None: 

263 _LOG.info("Ingesting the provenance quantum graph.") 

264 dataset_type = DatasetType( 

265 PROVENANCE_DATASET_TYPE_NAME, self.butler.dimensions.empty, PROVENANCE_STORAGE_CLASS 

266 ) 

267 self.butler.registry.registerDatasetType(dataset_type) 

268 ref = DatasetRef(dataset_type, DataCoordinate.make_empty(self.butler.dimensions), run=self.output_run) 

269 uri = ResourcePath(uri) 

270 self.butler.ingest( 

271 # We use .abspath() since butler assumes paths are relative to the 

272 # repo root, while users expects them to be relative to the CWD in 

273 # this context. 

274 FileDataset(refs=[ref], path=uri.abspath(), formatter=ProvenanceFormatter), 

275 transfer=transfer, 

276 ) 

277 

278 def clean_and_reingest_datasets(self, batch_size: int) -> None: 

279 _LOG.info( 

280 "Deleting original metadata/log/config files and re-ingesting them with provenance graph backing." 

281 ) 

282 direct_uri = self.butler.getURI(PROVENANCE_DATASET_TYPE_NAME, collections=[self.output_run]) 

283 qbb = self.make_qbb() 

284 to_process: list[DatasetRef] = [] 

285 n_processed: int = 0 

286 n_skipped: int = 0 

287 n_not_produced: int = 0 

288 for dataset_id, dataset_info in self.iter_datasets(): 

289 if not dataset_info["produced"]: 

290 n_not_produced += 1 

291 elif dataset_id not in self.datasets_already_ingested: 

292 to_process.append(self._make_ref_from_info(dataset_id, dataset_info)) 

293 if len(to_process) >= batch_size: 

294 n_processed += self._run_clean_and_ingest( 

295 qbb, direct_uri, to_process, n_processed + n_skipped 

296 ) 

297 else: 

298 n_skipped += 1 

299 n_processed += self._run_clean_and_ingest(qbb, direct_uri, to_process, n_processed + n_skipped) 

300 _LOG.info( 

301 "Deleted and re-ingested %d metadata/log/config datasets " 

302 "(%d had already been processed, %d were not produced).", 

303 n_processed, 

304 n_skipped, 

305 n_not_produced, 

306 ) 

307 

308 def _run_clean_and_ingest( 

309 self, qbb: QuantumBackedButler, direct_uri: ResourcePath, to_process: list[DatasetRef], n_current: int 

310 ) -> int: 

311 if not to_process: 

312 return 0 

313 _LOG.verbose( 

314 "Deleting and deleting a %d-dataset batch; %d/%d complete.", 

315 len(to_process), 

316 n_current, 

317 self.n_datasets, 

318 ) 

319 sql_registry: SqlRegistry = self.butler._registry # type: ignore[attr-defined] 

320 expanded_refs = sql_registry.expand_refs(to_process) 

321 # We need to pass predict=True to keep QBB/FileDatastore from wasting 

322 # time doing existence checks, since ResourcePath.mremove will ignore 

323 # nonexistent files anyway. 

324 original_uris = list( 

325 itertools.chain.from_iterable( 

326 ref_uris.iter_all() for ref_uris in qbb.get_many_uris(expanded_refs, predict=True).values() 

327 ) 

328 ) 

329 removal_status = ResourcePath.mremove(original_uris, do_raise=False) 

330 for path, status in removal_status.items(): 

331 if not status.success and not isinstance(status.exception, FileNotFoundError): 

332 assert status.exception is not None, "Exception should be set if success=False." 

333 status.exception.add_note(f"Attempting to delete original file at {path}.") 

334 raise status.exception 

335 file_dataset = FileDataset(refs=expanded_refs, path=direct_uri, formatter=ProvenanceFormatter) 

336 self.butler.ingest(file_dataset, transfer=None) 

337 n = len(to_process) 

338 to_process.clear() 

339 return n 

340 

341 @staticmethod 

342 def _make_ref_from_info(dataset_id: uuid.UUID, dataset_info: ProvenanceDatasetInfo) -> DatasetRef: 

343 return DatasetRef( 

344 dataset_info["pipeline_node"].dataset_type, 

345 dataset_info["data_id"], 

346 run=dataset_info["run"], 

347 id=dataset_id, 

348 ) 

349 

350 def make_qbb(self) -> QuantumBackedButler: 

351 dataset_types = {d.name: d.dataset_type for d in self.graph.pipeline_graph.dataset_types.values()} 

352 return QuantumBackedButler.from_predicted( 

353 config=self.butler_config, 

354 predicted_inputs=(), 

355 predicted_outputs=(), 

356 dimensions=self.butler.dimensions, 

357 datastore_records={}, 

358 dataset_types=dataset_types, 

359 )