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

122 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-13 08:57 +0000

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 ..interfaces import ( 

45 DatasetIdRef, 

46 DatastoreRegistryBridge, 

47 DatastoreRegistryBridgeManager, 

48 FakeDatasetRef, 

49 OpaqueTableStorage, 

50 VersionTuple, 

51) 

52from ..opaque import ByNameOpaqueTableStorage 

53from .ephemeral import EphemeralDatastoreRegistryBridge 

54 

55if TYPE_CHECKING: 

56 from ...datastore import DatastoreTransaction 

57 from ...dimensions import DimensionUniverse 

58 from ..interfaces import ( 

59 Database, 

60 DatasetRecordStorageManager, 

61 OpaqueTableStorageManager, 

62 StaticTablesContext, 

63 ) 

64 

65_TablesTuple = namedtuple( 

66 "_TablesTuple", 

67 [ 

68 "dataset_location", 

69 "dataset_location_trash", 

70 ], 

71) 

72 

73# This has to be updated on every schema change 

74_VERSION = VersionTuple(0, 2, 1) 

75 

76 

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

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

79 bridge classes. 

80 

81 Parameters 

82 ---------- 

83 datasets : subclass of `DatasetRecordStorageManager` 

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

85 

86 Returns 

87 ------- 

88 specs : `_TablesTuple` 

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

90 """ 

91 # We want the dataset_location and dataset_location_trash tables 

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

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

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

95 # optimal for query planner. 

96 

97 datastore_field = ddl.FieldSpec( 

98 name="datastore_name", 

99 dtype=sqlalchemy.String, 

100 length=256, 

101 primaryKey=True, 

102 nullable=False, 

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

104 ) 

105 

106 dataset_location = ddl.TableSpec( 

107 doc=( 

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

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

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

111 "Datastore. " 

112 ), 

113 fields=[datastore_field], 

114 ) 

115 datasets.addDatasetForeignKey(dataset_location, primaryKey=True) 

116 

117 dataset_location_trash = ddl.TableSpec( 

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

119 fields=[], 

120 ) 

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

122 dataset_location_trash.fields.add(datastore_field) 

123 

124 return _TablesTuple( 

125 dataset_location=dataset_location, 

126 dataset_location_trash=dataset_location_trash, 

127 ) 

128 

129 

130class MonolithicDatastoreRegistryBridge(DatastoreRegistryBridge): 

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

132 tables for all non-ephemeral datastores. 

133 

134 Parameters 

135 ---------- 

136 datastoreName : `str` 

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

138 referencing it. 

139 db : `Database` 

140 Object providing a database connection and generic distractions. 

141 tables : `_TablesTuple` 

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

143 """ 

144 

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

146 super().__init__(datastoreName) 

147 self._db = db 

148 self._tables = tables 

149 

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

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

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

153 class. 

154 

155 Parameters 

156 ---------- 

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

158 Datasets to transform. 

159 

160 Returns 

161 ------- 

162 rows : `list` [ `dict` ] 

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

164 """ 

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

166 

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

168 # Docstring inherited from DatastoreRegistryBridge 

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

170 

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

172 # Docstring inherited from DatastoreRegistryBridge 

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

174 

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

176 # Docstring inherited from DatastoreRegistryBridge 

177 with self._db.transaction(): 

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

179 # chunks to avoid hitting limits. 

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

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

182 where = sqlalchemy.sql.and_( 

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

184 self._db.make_in_array_constraint( 

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

186 ), 

187 ) 

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

189 

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

191 # Docstring inherited from DatastoreRegistryBridge 

192 location = self._tables.dataset_location 

193 location_trash = self._tables.dataset_location_trash 

194 with self._db.transaction(): 

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

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

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

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

199 # and DELETE using WHERE clause that limits operations to 

200 # existing IDs. 

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

202 

203 where = sqlalchemy.sql.and_( 

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

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

206 ) 

207 

208 select = ( 

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

210 .where(where) 

211 .with_for_update() 

212 ) 

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

214 

215 self._db.deleteWhere(location, where) 

216 

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

218 # Docstring inherited from DatastoreRegistryBridge 

219 found: set[DatasetId] = set() 

220 with self._db.session(): 

221 for batch in chunk_iterable(datasets, 50000): 

222 sql = ( 

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

224 .select_from(self._tables.dataset_location) 

225 .where( 

226 sqlalchemy.sql.and_( 

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

228 self._db.make_in_array_constraint( 

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

230 ), 

231 ) 

232 ) 

233 ) 

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

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

236 found.update(sql_ids) 

237 

238 return found 

239 

240 @contextmanager 

241 def emptyTrash( 

242 self, 

243 records_table: OpaqueTableStorage | None = None, 

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

245 record_column: str | None = None, 

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

247 dry_run: bool = False, 

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

249 # Docstring inherited from DatastoreRegistryBridge 

250 

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

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

253 

254 assert isinstance(records_table, ByNameOpaqueTableStorage), ( 

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

256 ) 

257 

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

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

260 

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

262 def join_records( 

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

264 ) -> sqlalchemy.sql.Select: 

265 # mypy needs to be sure 

266 assert isinstance(records_table, ByNameOpaqueTableStorage) 

267 return select.select_from( 

268 records_table._table.join( 

269 location_table, 

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

271 ) 

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

273 

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

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

276 # WHERE dataset_location.datastore_name = datastoreName 

277 

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

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

280 # inconsistency would be missed by this query. 

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

282 if selected_ids: 

283 info_in_trash = info_in_trash.where( 

284 self._db.make_in_array_constraint( 

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

286 ) 

287 ) 

288 info_in_trash = info_in_trash.with_for_update(skip_locked=True) 

289 

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

291 # use to delete. 

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

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

294 

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

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

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

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

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

300 # refs. 

301 preserved: set[str] | None = None 

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

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

304 items_in_trash = join_records( 

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

306 self._tables.dataset_location_trash, 

307 ) 

308 if selected_ids: 

309 items_in_trash = items_in_trash.where( 

310 self._db.make_in_array_constraint( 

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

312 ) 

313 ) 

314 items_in_trash_alias = items_in_trash.alias("items_in_trash") 

315 

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

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

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

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

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

321 referenced_by_live_ref = join_records( 

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

323 ).where( 

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

325 ) 

326 

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

328 # and datasets not in the trash. 

329 items_to_preserve = ( 

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

331 .distinct() 

332 .where(sqlalchemy.exists(referenced_by_live_ref)) 

333 ) 

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

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

336 

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

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

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

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

341 

342 # Start contextmanager, return results 

343 yield ((id_info, preserved)) 

344 

345 # No exception raised in context manager block. 

346 if not rows or dry_run: 

347 return 

348 

349 # Delete the rows from the records table 

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

351 

352 # Delete those rows from the trash table. 

353 self._db.delete( 

354 self._tables.dataset_location_trash, 

355 ["dataset_id", "datastore_name"], 

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

357 ) 

358 

359 

360class MonolithicDatastoreRegistryBridgeManager(DatastoreRegistryBridgeManager): 

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

362 two tables for all non-ephemeral datastores. 

363 

364 Parameters 

365 ---------- 

366 db : `Database` 

367 Object providing a database connection and generic distractions. 

368 tables : `_TablesTuple` 

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

370 opaque : `OpaqueTableStorageManager` 

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

372 universe : `DimensionUniverse` 

373 All dimensions know to the `Registry`. 

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

375 The version of the registry schema. 

376 """ 

377 

378 def __init__( 

379 self, 

380 *, 

381 db: Database, 

382 tables: _TablesTuple, 

383 opaque: OpaqueTableStorageManager, 

384 universe: DimensionUniverse, 

385 registry_schema_version: VersionTuple | None = None, 

386 ): 

387 super().__init__( 

388 opaque=opaque, 

389 universe=universe, 

390 registry_schema_version=registry_schema_version, 

391 ) 

392 self._db = db 

393 self._tables = tables 

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

395 

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

397 return MonolithicDatastoreRegistryBridgeManager( 

398 db=db, 

399 tables=self._tables, 

400 opaque=opaque, 

401 universe=self.universe, 

402 registry_schema_version=self._registry_schema_version, 

403 ) 

404 

405 @classmethod 

406 def initialize( 

407 cls, 

408 db: Database, 

409 context: StaticTablesContext, 

410 *, 

411 opaque: OpaqueTableStorageManager, 

412 datasets: type[DatasetRecordStorageManager], 

413 universe: DimensionUniverse, 

414 registry_schema_version: VersionTuple | None = None, 

415 ) -> DatastoreRegistryBridgeManager: 

416 # Docstring inherited from DatastoreRegistryBridge 

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

418 return cls( 

419 db=db, 

420 tables=cast(_TablesTuple, tables), 

421 opaque=opaque, 

422 universe=universe, 

423 registry_schema_version=registry_schema_version, 

424 ) 

425 

426 def refresh(self) -> None: 

427 # Docstring inherited from DatastoreRegistryBridge 

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

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

430 pass 

431 

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

433 # Docstring inherited from DatastoreRegistryBridge 

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

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

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

437 

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

439 # Docstring inherited from DatastoreRegistryBridge 

440 sql = ( 

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

442 .select_from(self._tables.dataset_location) 

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

444 ) 

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

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

447 for row in sql_rows: 

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

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

450 if ref in bridge: 

451 yield name 

452 

453 @classmethod 

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

455 # Docstring inherited from VersionedExtension. 

456 return [_VERSION]