Coverage for python/lsst/daf/butler/registry/bridge/monolithic.py: 84%

126 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-06 01:43 -0800

1# This file is part of daf_butler. 

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/>. 

27from __future__ import annotations 

28 

29from ... import ddl 

30 

31__all__ = ("MonolithicDatastoreRegistryBridge", "MonolithicDatastoreRegistryBridgeManager") 

32 

33from collections import namedtuple 

34from collections.abc import Collection, Iterable, Iterator 

35from contextlib import contextmanager 

36from typing import TYPE_CHECKING, cast 

37 

38import sqlalchemy 

39 

40from lsst.utils.iteration import chunk_iterable 

41 

42from ..._dataset_ref import DatasetId 

43from ...datastore.stored_file_info import StoredDatastoreItemInfo 

44from .._exceptions import ConflictingDefinitionError 

45from ..interfaces import ( 

46 DatasetIdRef, 

47 DatastoreRegistryBridge, 

48 DatastoreRegistryBridgeManager, 

49 FakeDatasetRef, 

50 OpaqueTableStorage, 

51 VersionTuple, 

52) 

53from ..opaque import ByNameOpaqueTableStorage 

54from .ephemeral import EphemeralDatastoreRegistryBridge 

55 

56if TYPE_CHECKING: 

57 from ...datastore import DatastoreTransaction 

58 from ...dimensions import DimensionUniverse 

59 from ..interfaces import ( 

60 Database, 

61 DatasetRecordStorageManager, 

62 OpaqueTableStorageManager, 

63 StaticTablesContext, 

64 ) 

65 

66_TablesTuple = namedtuple( 

67 "_TablesTuple", 

68 [ 

69 "dataset_location", 

70 "dataset_location_trash", 

71 ], 

72) 

73 

74# This has to be updated on every schema change 

75_VERSION = VersionTuple(0, 2, 1) 

76 

77 

78def _makeTableSpecs(datasets: type[DatasetRecordStorageManager]) -> _TablesTuple: 

79 """Construct specifications for tables used by the monolithic datastore 

80 bridge classes. 

81 

82 Parameters 

83 ---------- 

84 datasets : subclass of `DatasetRecordStorageManager` 

85 Manager class for datasets; used only to create foreign key fields. 

86 

87 Returns 

88 ------- 

89 specs : `_TablesTuple` 

90 A named tuple containing `ddl.TableSpec` instances. 

91 """ 

92 # We want the dataset_location and dataset_location_trash tables 

93 # to have the same definition, aside from the behavior of their link 

94 # to the dataset table: the trash table has no foreign key constraint. 

95 # The order of columns in dataset_location_trash is reversed, it is more 

96 # optimal for query planner. 

97 

98 datastore_field = ddl.FieldSpec( 

99 name="datastore_name", 

100 dtype=sqlalchemy.String, 

101 length=256, 

102 primaryKey=True, 

103 nullable=False, 

104 doc="Name of the Datastore this entry corresponds to.", 

105 ) 

106 

107 dataset_location = ddl.TableSpec( 

108 doc=( 

109 "A table that provides information on whether a dataset is stored in " 

110 "one or more Datastores. The presence or absence of a record in this " 

111 "table itself indicates whether the dataset is present in that " 

112 "Datastore. " 

113 ), 

114 fields=[datastore_field], 

115 ) 

116 datasets.addDatasetForeignKey(dataset_location, primaryKey=True) 

117 

118 dataset_location_trash = ddl.TableSpec( 

119 doc="A table that keeps iinformation about datasets that are removed from Datastores.", 

120 fields=[], 

121 ) 

122 datasets.addDatasetForeignKey(dataset_location_trash, primaryKey=True, constraint=False) 

123 dataset_location_trash.fields.add(datastore_field) 

124 

125 return _TablesTuple( 

126 dataset_location=dataset_location, 

127 dataset_location_trash=dataset_location_trash, 

128 ) 

129 

130 

131class MonolithicDatastoreRegistryBridge(DatastoreRegistryBridge): 

132 """An implementation of `DatastoreRegistryBridge` that uses the same two 

133 tables for all non-ephemeral datastores. 

134 

135 Parameters 

136 ---------- 

137 datastoreName : `str` 

138 Name of the `Datastore` as it should appear in `Registry` tables 

139 referencing it. 

140 db : `Database` 

141 Object providing a database connection and generic distractions. 

142 tables : `_TablesTuple` 

143 Named tuple containing `sqlalchemy.schema.Table` instances. 

144 """ 

145 

146 def __init__(self, datastoreName: str, *, db: Database, tables: _TablesTuple): 

147 super().__init__(datastoreName) 

148 self._db = db 

149 self._tables = tables 

150 

151 def _refsToRows(self, refs: Iterable[DatasetIdRef]) -> list[dict]: 

152 """Transform an iterable of `DatasetRef` or `FakeDatasetRef` objects to 

153 a list of dictionaries that match the schema of the tables used by this 

154 class. 

155 

156 Parameters 

157 ---------- 

158 refs : `~collections.abc.Iterable` [ `DatasetRef` or `FakeDatasetRef` ] 

159 Datasets to transform. 

160 

161 Returns 

162 ------- 

163 rows : `list` [ `dict` ] 

164 List of dictionaries, with "datastoreName" and "dataset_id" keys. 

165 """ 

166 return [{"datastore_name": self.datastoreName, "dataset_id": ref.id} for ref in refs] 

167 

168 def ensure(self, refs: Iterable[DatasetIdRef]) -> None: 

169 # Docstring inherited from DatastoreRegistryBridge 

170 self._db.ensure(self._tables.dataset_location, *self._refsToRows(refs)) 

171 

172 def insert(self, refs: Iterable[DatasetIdRef]) -> None: 

173 # Docstring inherited from DatastoreRegistryBridge 

174 try: 

175 self._db.insert(self._tables.dataset_location, *self._refsToRows(refs)) 

176 except sqlalchemy.exc.IntegrityError as exc: 

177 raise ConflictingDefinitionError("Some datasets were already registered.") from exc 

178 

179 def forget(self, refs: Iterable[DatasetIdRef]) -> None: 

180 # Docstring inherited from DatastoreRegistryBridge 

181 with self._db.transaction(): 

182 # The list of IDs can be very large, split it into reasonable size 

183 # chunks to avoid hitting limits. 

184 for refs_chunk in chunk_iterable(refs, 50_000): 

185 dataset_ids = [ref.id for ref in refs_chunk] 

186 where = sqlalchemy.sql.and_( 

187 self._tables.dataset_location.columns.datastore_name == self.datastoreName, 

188 self._db.make_in_array_constraint( 

189 self._tables.dataset_location.columns.dataset_id, dataset_ids 

190 ), 

191 ) 

192 self._db.deleteWhere(self._tables.dataset_location, where) 

193 

194 def moveToTrash(self, refs: Iterable[DatasetIdRef], transaction: DatastoreTransaction | None) -> None: 

195 # Docstring inherited from DatastoreRegistryBridge 

196 location = self._tables.dataset_location 

197 location_trash = self._tables.dataset_location_trash 

198 with self._db.transaction(): 

199 for refs_chunk in chunk_iterable(refs, 50_000): 

200 # We only want to move IDs that actually exist in the 

201 # dataset_location table. Instead of querying for existing IDs, 

202 # which would need an extra query, we use INSERT ... SELECT 

203 # and DELETE using WHERE clause that limits operations to 

204 # existing IDs. 

205 dataset_ids = [ref.id for ref in refs_chunk] 

206 

207 where = sqlalchemy.sql.and_( 

208 location.columns.datastore_name == self.datastoreName, 

209 self._db.make_in_array_constraint(location.columns.dataset_id, dataset_ids), 

210 ) 

211 

212 select = ( 

213 sqlalchemy.sql.select(location.columns.datastore_name, location.columns.dataset_id) 

214 .where(where) 

215 .with_for_update() 

216 ) 

217 self._db.insert(location_trash, select=select) 

218 

219 self._db.deleteWhere(location, where) 

220 

221 def check(self, datasets: Iterable[DatasetId]) -> set[DatasetId]: 

222 # Docstring inherited from DatastoreRegistryBridge 

223 found: set[DatasetId] = set() 

224 with self._db.session(): 

225 for batch in chunk_iterable(datasets, 50000): 

226 sql = ( 

227 sqlalchemy.sql.select(self._tables.dataset_location.columns.dataset_id) 

228 .select_from(self._tables.dataset_location) 

229 .where( 

230 sqlalchemy.sql.and_( 

231 self._tables.dataset_location.columns.datastore_name == self.datastoreName, 

232 self._db.make_in_array_constraint( 

233 self._tables.dataset_location.columns.dataset_id, batch 

234 ), 

235 ) 

236 ) 

237 ) 

238 with self._db.query(sql) as sql_result: 

239 sql_ids = sql_result.scalars().all() 

240 found.update(sql_ids) 

241 

242 return found 

243 

244 @contextmanager 

245 def emptyTrash( 

246 self, 

247 records_table: OpaqueTableStorage | None = None, 

248 record_class: type[StoredDatastoreItemInfo] | None = None, 

249 record_column: str | None = None, 

250 selected_ids: Collection[DatasetId] | None = None, 

251 dry_run: bool = False, 

252 ) -> Iterator[tuple[Iterable[tuple[DatasetIdRef, StoredDatastoreItemInfo | None]], set[str] | None]]: 

253 # Docstring inherited from DatastoreRegistryBridge 

254 

255 if records_table is None: 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true

256 raise ValueError("This implementation requires a records table.") 

257 

258 assert isinstance(records_table, ByNameOpaqueTableStorage), ( 

259 f"Records table must support hidden attributes. Got {type(records_table)}." 

260 ) 

261 

262 if record_class is None: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 raise ValueError("Record class must be provided if records table is given.") 

264 

265 # Helper closure to generate the common join+where clause. 

266 def join_records( 

267 select: sqlalchemy.sql.Select, location_table: sqlalchemy.schema.Table 

268 ) -> sqlalchemy.sql.Select: 

269 # mypy needs to be sure 

270 assert isinstance(records_table, ByNameOpaqueTableStorage) 

271 return select.select_from( 

272 records_table._table.join( 

273 location_table, 

274 onclause=records_table._table.columns.dataset_id == location_table.columns.dataset_id, 

275 ) 

276 ).where(location_table.columns.datastore_name == self.datastoreName) 

277 

278 # SELECT records.dataset_id, records.path FROM records 

279 # JOIN records on dataset_location.dataset_id == records.dataset_id 

280 # WHERE dataset_location.datastore_name = datastoreName 

281 

282 # It's possible that we may end up with a ref listed in the trash 

283 # table that is not listed in the records table. Such an 

284 # inconsistency would be missed by this query. 

285 info_in_trash = join_records(records_table._table.select(), self._tables.dataset_location_trash) 

286 if selected_ids: 

287 info_in_trash = info_in_trash.where( 

288 self._db.make_in_array_constraint( 

289 self._tables.dataset_location_trash.columns["dataset_id"], selected_ids 

290 ) 

291 ) 

292 info_in_trash = info_in_trash.with_for_update(skip_locked=True) 

293 

294 # Run query, transform results into a list of dicts that we can later 

295 # use to delete. 

296 with self._db.query(info_in_trash) as sql_result: 

297 rows = [dict(row, datastore_name=self.datastoreName) for row in sql_result.mappings()] 

298 

299 # It is possible for trashed refs to be linked to artifacts that 

300 # are still associated with refs that are not to be trashed. We 

301 # need to be careful to consider those and indicate to the caller 

302 # that those artifacts should be retained. Can only do this check 

303 # if the caller provides a column name that can map to multiple 

304 # refs. 

305 preserved: set[str] | None = None 

306 if record_column is not None: 306 ↛ 344line 306 didn't jump to line 344 because the condition on line 306 was always true

307 # The artifacts belonging to the trashed refs under consideration. 

308 items_in_trash = join_records( 

309 sqlalchemy.sql.select(records_table._table.columns[record_column]), 

310 self._tables.dataset_location_trash, 

311 ) 

312 if selected_ids: 

313 items_in_trash = items_in_trash.where( 

314 self._db.make_in_array_constraint( 

315 self._tables.dataset_location_trash.columns["dataset_id"], selected_ids 

316 ) 

317 ) 

318 items_in_trash_alias = items_in_trash.alias("items_in_trash") 

319 

320 # Whether one of those artifacts is also referenced by a ref that 

321 # is not in the trash. Written as a correlated EXISTS so that the 

322 # query is driven by the trashed artifacts and probes the records 

323 # table by artifact, rather than materializing every artifact the 

324 # datastore holds in order to join the two sets. 

325 referenced_by_live_ref = join_records( 

326 sqlalchemy.sql.select(sqlalchemy.sql.literal(1)), self._tables.dataset_location 

327 ).where( 

328 records_table._table.columns[record_column] == items_in_trash_alias.columns[record_column] 

329 ) 

330 

331 # A query for paths that are referenced by datasets in the trash 

332 # and datasets not in the trash. 

333 items_to_preserve = ( 

334 sqlalchemy.sql.select(items_in_trash_alias.columns[record_column]) 

335 .distinct() 

336 .where(sqlalchemy.exists(referenced_by_live_ref)) 

337 ) 

338 with self._db.query(items_to_preserve) as sql_result: 

339 preserved = {row[record_column] for row in sql_result.mappings()} 

340 

341 # Convert results to a tuple of id+info and a record of the artifacts 

342 # that should not be deleted from datastore. The id+info tuple is 

343 # solely to allow logging to report the relevant ID. 

344 id_info = ((FakeDatasetRef(row["dataset_id"]), record_class.from_record(row)) for row in rows) 

345 

346 # Start contextmanager, return results 

347 yield ((id_info, preserved)) 

348 

349 # No exception raised in context manager block. 

350 if not rows or dry_run: 

351 return 

352 

353 # Delete the rows from the records table 

354 records_table.delete(["dataset_id"], *[{"dataset_id": row["dataset_id"]} for row in rows]) 

355 

356 # Delete those rows from the trash table. 

357 self._db.delete( 

358 self._tables.dataset_location_trash, 

359 ["dataset_id", "datastore_name"], 

360 *[{"dataset_id": row["dataset_id"], "datastore_name": row["datastore_name"]} for row in rows], 

361 ) 

362 

363 

364class MonolithicDatastoreRegistryBridgeManager(DatastoreRegistryBridgeManager): 

365 """An implementation of `DatastoreRegistryBridgeManager` that uses the same 

366 two tables for all non-ephemeral datastores. 

367 

368 Parameters 

369 ---------- 

370 db : `Database` 

371 Object providing a database connection and generic distractions. 

372 tables : `_TablesTuple` 

373 Named tuple containing `sqlalchemy.schema.Table` instances. 

374 opaque : `OpaqueTableStorageManager` 

375 Manager object for opaque table storage in the `Registry`. 

376 universe : `DimensionUniverse` 

377 All dimensions know to the `Registry`. 

378 registry_schema_version : `VersionTuple` or `None`, optional 

379 The version of the registry schema. 

380 """ 

381 

382 def __init__( 

383 self, 

384 *, 

385 db: Database, 

386 tables: _TablesTuple, 

387 opaque: OpaqueTableStorageManager, 

388 universe: DimensionUniverse, 

389 registry_schema_version: VersionTuple | None = None, 

390 ): 

391 super().__init__( 

392 opaque=opaque, 

393 universe=universe, 

394 registry_schema_version=registry_schema_version, 

395 ) 

396 self._db = db 

397 self._tables = tables 

398 self._ephemeral: dict[str, EphemeralDatastoreRegistryBridge] = {} 

399 

400 def clone(self, *, db: Database, opaque: OpaqueTableStorageManager) -> DatastoreRegistryBridgeManager: 

401 return MonolithicDatastoreRegistryBridgeManager( 

402 db=db, 

403 tables=self._tables, 

404 opaque=opaque, 

405 universe=self.universe, 

406 registry_schema_version=self._registry_schema_version, 

407 ) 

408 

409 @classmethod 

410 def initialize( 

411 cls, 

412 db: Database, 

413 context: StaticTablesContext, 

414 *, 

415 opaque: OpaqueTableStorageManager, 

416 datasets: type[DatasetRecordStorageManager], 

417 universe: DimensionUniverse, 

418 registry_schema_version: VersionTuple | None = None, 

419 ) -> DatastoreRegistryBridgeManager: 

420 # Docstring inherited from DatastoreRegistryBridge 

421 tables = context.addTableTuple(_makeTableSpecs(datasets)) 

422 return cls( 

423 db=db, 

424 tables=cast(_TablesTuple, tables), 

425 opaque=opaque, 

426 universe=universe, 

427 registry_schema_version=registry_schema_version, 

428 ) 

429 

430 def refresh(self) -> None: 

431 # Docstring inherited from DatastoreRegistryBridge 

432 # This implementation has no in-Python state that depends on which 

433 # datastores exist, so there's nothing to do. 

434 pass 

435 

436 def register(self, name: str, *, ephemeral: bool = False) -> DatastoreRegistryBridge: 

437 # Docstring inherited from DatastoreRegistryBridge 

438 if ephemeral: 438 ↛ 439line 438 didn't jump to line 439 because the condition on line 438 was never true

439 return self._ephemeral.setdefault(name, EphemeralDatastoreRegistryBridge(name)) 

440 return MonolithicDatastoreRegistryBridge(name, db=self._db, tables=self._tables) 

441 

442 def findDatastores(self, ref: DatasetIdRef) -> Iterable[str]: 

443 # Docstring inherited from DatastoreRegistryBridge 

444 sql = ( 

445 sqlalchemy.sql.select(self._tables.dataset_location.columns.datastore_name) 

446 .select_from(self._tables.dataset_location) 

447 .where(self._tables.dataset_location.columns.dataset_id == ref.id) 

448 ) 

449 with self._db.query(sql) as sql_result: 

450 sql_rows = sql_result.mappings().fetchall() 

451 for row in sql_rows: 

452 yield row[self._tables.dataset_location.columns.datastore_name] 

453 for name, bridge in self._ephemeral.items(): 

454 if ref in bridge: 

455 yield name 

456 

457 @classmethod 

458 def currentVersions(cls) -> list[VersionTuple]: 

459 # Docstring inherited from VersionedExtension. 

460 return [_VERSION]