lsst-daf-butler 29.2025.1900__py3-none-any.whl → 29.2025.2000__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/daf/butler/_quantum_backed.py +3 -2
- lsst/daf/butler/cli/butler.py +1 -1
- lsst/daf/butler/cli/cmd/_remove_runs.py +2 -0
- lsst/daf/butler/cli/utils.py +32 -4
- lsst/daf/butler/configs/datastores/formatters.yaml +1 -0
- lsst/daf/butler/configs/storageClasses.yaml +2 -0
- lsst/daf/butler/datastore/_datastore.py +20 -2
- lsst/daf/butler/datastore/generic_base.py +2 -2
- lsst/daf/butler/datastores/chainedDatastore.py +8 -4
- lsst/daf/butler/datastores/fileDatastore.py +151 -84
- lsst/daf/butler/datastores/inMemoryDatastore.py +32 -4
- lsst/daf/butler/direct_butler/_direct_butler.py +35 -10
- lsst/daf/butler/registry/bridge/ephemeral.py +16 -6
- lsst/daf/butler/registry/bridge/monolithic.py +21 -6
- lsst/daf/butler/registry/collections/_base.py +23 -6
- lsst/daf/butler/registry/interfaces/_bridge.py +13 -1
- lsst/daf/butler/registry/tests/_registry.py +26 -11
- lsst/daf/butler/remote_butler/server/_server.py +2 -0
- lsst/daf/butler/remote_butler/server/_telemetry.py +105 -0
- lsst/daf/butler/remote_butler/server/handlers/_query_streaming.py +7 -3
- lsst/daf/butler/script/removeRuns.py +9 -3
- lsst/daf/butler/tests/cliCmdTestBase.py +1 -1
- lsst/daf/butler/version.py +1 -1
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/METADATA +1 -1
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/RECORD +33 -32
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/WHEEL +1 -1
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/entry_points.txt +0 -0
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/licenses/COPYRIGHT +0 -0
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/licenses/LICENSE +0 -0
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/licenses/bsd_license.txt +0 -0
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/licenses/gpl-v3.0.txt +0 -0
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/top_level.txt +0 -0
- {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/zip-safe +0 -0
|
@@ -54,6 +54,7 @@ from sqlalchemy.exc import IntegrityError
|
|
|
54
54
|
from lsst.resources import ResourcePath, ResourcePathExpression
|
|
55
55
|
from lsst.utils.introspection import get_class_of
|
|
56
56
|
from lsst.utils.logging import VERBOSE, getLogger
|
|
57
|
+
from lsst.utils.timer import time_this
|
|
57
58
|
|
|
58
59
|
from .._butler import Butler
|
|
59
60
|
from .._butler_config import ButlerConfig
|
|
@@ -1464,14 +1465,25 @@ class DirectButler(Butler): # numpydoc ignore=PR02
|
|
|
1464
1465
|
refs.extend(query.datasets(dt, collections=name))
|
|
1465
1466
|
with self._datastore.transaction(), self._registry.transaction():
|
|
1466
1467
|
if unstore:
|
|
1467
|
-
|
|
1468
|
+
with time_this(
|
|
1469
|
+
_LOG, msg="Marking %d datasets for removal to clear RUN collections", args=(len(refs),)
|
|
1470
|
+
):
|
|
1471
|
+
self._datastore.trash(refs)
|
|
1468
1472
|
else:
|
|
1469
1473
|
self._datastore.forget(refs)
|
|
1470
1474
|
for name in names:
|
|
1471
|
-
|
|
1475
|
+
with time_this(_LOG, msg="Removing registry entries for RUN collection %s", args=(name,)):
|
|
1476
|
+
self._registry.removeCollection(name)
|
|
1472
1477
|
if unstore:
|
|
1473
|
-
# Point of no return for removing artifacts
|
|
1474
|
-
|
|
1478
|
+
# Point of no return for removing artifacts. Restrict the trash
|
|
1479
|
+
# emptying to the datasets from this specific collection rather
|
|
1480
|
+
# than everything in the trash.
|
|
1481
|
+
with time_this(
|
|
1482
|
+
_LOG,
|
|
1483
|
+
msg="Attempting to remove artifacts for %d datasets associated with RUN collections",
|
|
1484
|
+
args=(len(refs),),
|
|
1485
|
+
):
|
|
1486
|
+
self._datastore.emptyTrash(refs=refs)
|
|
1475
1487
|
|
|
1476
1488
|
def pruneDatasets(
|
|
1477
1489
|
self,
|
|
@@ -1518,13 +1530,20 @@ class DirectButler(Butler): # numpydoc ignore=PR02
|
|
|
1518
1530
|
# Registry operations.
|
|
1519
1531
|
with self._datastore.transaction(), self._registry.transaction():
|
|
1520
1532
|
if unstore:
|
|
1521
|
-
|
|
1533
|
+
with time_this(
|
|
1534
|
+
_LOG, msg="Marking %d datasets for removal to during pruning", args=(len(refs),)
|
|
1535
|
+
):
|
|
1536
|
+
self._datastore.trash(refs)
|
|
1522
1537
|
if purge:
|
|
1523
|
-
|
|
1538
|
+
with time_this(_LOG, msg="Removing %d pruned datasets from registry", args=(len(refs),)):
|
|
1539
|
+
self._registry.removeDatasets(refs)
|
|
1524
1540
|
elif disassociate:
|
|
1525
1541
|
assert tags, "Guaranteed by earlier logic in this function."
|
|
1526
|
-
|
|
1527
|
-
|
|
1542
|
+
with time_this(
|
|
1543
|
+
_LOG, msg="Disassociating %d datasets from tagged collections", args=(len(refs),)
|
|
1544
|
+
):
|
|
1545
|
+
for tag in tags:
|
|
1546
|
+
self._registry.disassociate(tag, refs)
|
|
1528
1547
|
# We've exited the Registry transaction, and apparently committed.
|
|
1529
1548
|
# (if there was an exception, everything rolled back, and it's as if
|
|
1530
1549
|
# nothing happened - and we never get here).
|
|
@@ -1535,8 +1554,14 @@ class DirectButler(Butler): # numpydoc ignore=PR02
|
|
|
1535
1554
|
# deleting everything on disk and in private Datastore tables that is
|
|
1536
1555
|
# in the dataset_location_trash table.
|
|
1537
1556
|
if unstore:
|
|
1538
|
-
# Point of no return for removing artifacts
|
|
1539
|
-
|
|
1557
|
+
# Point of no return for removing artifacts. Restrict the trash
|
|
1558
|
+
# emptying to the refs that this call trashed.
|
|
1559
|
+
with time_this(
|
|
1560
|
+
_LOG,
|
|
1561
|
+
msg="Attempting to remove artifacts for %d datasets associated with pruning",
|
|
1562
|
+
args=(len(refs),),
|
|
1563
|
+
):
|
|
1564
|
+
self._datastore.emptyTrash(refs=refs)
|
|
1540
1565
|
|
|
1541
1566
|
@transactional
|
|
1542
1567
|
def ingest_zip(self, zip_file: ResourcePathExpression, transfer: str = "auto") -> None:
|
|
@@ -28,7 +28,7 @@ from __future__ import annotations
|
|
|
28
28
|
|
|
29
29
|
__all__ = ("EphemeralDatastoreRegistryBridge",)
|
|
30
30
|
|
|
31
|
-
from collections.abc import Iterable, Iterator
|
|
31
|
+
from collections.abc import Collection, Iterable, Iterator
|
|
32
32
|
from contextlib import contextmanager
|
|
33
33
|
from typing import TYPE_CHECKING
|
|
34
34
|
|
|
@@ -100,28 +100,38 @@ class EphemeralDatastoreRegistryBridge(DatastoreRegistryBridge):
|
|
|
100
100
|
records_table: OpaqueTableStorage | None = None,
|
|
101
101
|
record_class: type[StoredDatastoreItemInfo] | None = None,
|
|
102
102
|
record_column: str | None = None,
|
|
103
|
+
selected_ids: Collection[DatasetId] | None = None,
|
|
104
|
+
dry_run: bool = False,
|
|
103
105
|
) -> Iterator[tuple[Iterable[tuple[DatasetIdRef, StoredDatastoreItemInfo | None]], set[str] | None]]:
|
|
104
106
|
# Docstring inherited from DatastoreRegistryBridge
|
|
105
107
|
matches: Iterable[tuple[FakeDatasetRef, StoredDatastoreItemInfo | None]] = ()
|
|
108
|
+
trashed_ids = self._trashedIds
|
|
109
|
+
|
|
110
|
+
if selected_ids is not None:
|
|
111
|
+
trashed_ids = {tid for tid in trashed_ids if tid in selected_ids}
|
|
112
|
+
|
|
106
113
|
if isinstance(records_table, OpaqueTableStorage):
|
|
107
114
|
if record_class is None:
|
|
108
115
|
raise ValueError("Record class must be provided if records table is given.")
|
|
109
116
|
matches = (
|
|
110
117
|
(FakeDatasetRef(id), record_class.from_record(record))
|
|
111
|
-
for id in
|
|
118
|
+
for id in trashed_ids
|
|
112
119
|
for record in records_table.fetch(dataset_id=id)
|
|
113
120
|
)
|
|
114
121
|
else:
|
|
115
|
-
matches = ((FakeDatasetRef(id), None) for id in
|
|
122
|
+
matches = ((FakeDatasetRef(id), None) for id in trashed_ids)
|
|
116
123
|
|
|
117
124
|
# Indicate to caller that we do not know about artifacts that
|
|
118
125
|
# should be retained.
|
|
119
126
|
yield ((matches, None))
|
|
120
127
|
|
|
128
|
+
if dry_run:
|
|
129
|
+
return
|
|
130
|
+
|
|
121
131
|
if isinstance(records_table, OpaqueTableStorage):
|
|
122
132
|
# Remove the records entries
|
|
123
|
-
records_table.delete(["dataset_id"], *[{"dataset_id": id} for id in
|
|
133
|
+
records_table.delete(["dataset_id"], *[{"dataset_id": id} for id in trashed_ids])
|
|
124
134
|
|
|
125
135
|
# Empty the trash table
|
|
126
|
-
self._datasetIds.difference_update(
|
|
127
|
-
self._trashedIds =
|
|
136
|
+
self._datasetIds.difference_update(trashed_ids)
|
|
137
|
+
self._trashedIds = self._trashedIds - trashed_ids
|
|
@@ -32,12 +32,13 @@ __all__ = ("MonolithicDatastoreRegistryBridge", "MonolithicDatastoreRegistryBrid
|
|
|
32
32
|
|
|
33
33
|
import copy
|
|
34
34
|
from collections import namedtuple
|
|
35
|
-
from collections.abc import Iterable, Iterator
|
|
35
|
+
from collections.abc import Collection, Iterable, Iterator
|
|
36
36
|
from contextlib import contextmanager
|
|
37
37
|
from typing import TYPE_CHECKING, cast
|
|
38
38
|
|
|
39
39
|
import sqlalchemy
|
|
40
40
|
|
|
41
|
+
from ..._dataset_ref import DatasetId
|
|
41
42
|
from ..._named import NamedValueSet
|
|
42
43
|
from ...datastore.stored_file_info import StoredDatastoreItemInfo
|
|
43
44
|
from ..interfaces import (
|
|
@@ -209,6 +210,8 @@ class MonolithicDatastoreRegistryBridge(DatastoreRegistryBridge):
|
|
|
209
210
|
records_table: OpaqueTableStorage | None = None,
|
|
210
211
|
record_class: type[StoredDatastoreItemInfo] | None = None,
|
|
211
212
|
record_column: str | None = None,
|
|
213
|
+
selected_ids: Collection[DatasetId] | None = None,
|
|
214
|
+
dry_run: bool = False,
|
|
212
215
|
) -> Iterator[tuple[Iterable[tuple[DatasetIdRef, StoredDatastoreItemInfo | None]], set[str] | None]]:
|
|
213
216
|
# Docstring inherited from DatastoreRegistryBridge
|
|
214
217
|
|
|
@@ -243,6 +246,11 @@ class MonolithicDatastoreRegistryBridge(DatastoreRegistryBridge):
|
|
|
243
246
|
# table that is not listed in the records table. Such an
|
|
244
247
|
# inconsistency would be missed by this query.
|
|
245
248
|
info_in_trash = join_records(records_table._table.select(), self._tables.dataset_location_trash)
|
|
249
|
+
if selected_ids:
|
|
250
|
+
info_in_trash = info_in_trash.where(
|
|
251
|
+
self._tables.dataset_location_trash.columns["dataset_id"].in_(selected_ids)
|
|
252
|
+
)
|
|
253
|
+
info_in_trash = info_in_trash.with_for_update(skip_locked=True)
|
|
246
254
|
|
|
247
255
|
# Run query, transform results into a list of dicts that we can later
|
|
248
256
|
# use to delete.
|
|
@@ -265,14 +273,21 @@ class MonolithicDatastoreRegistryBridge(DatastoreRegistryBridge):
|
|
|
265
273
|
items_in_trash = join_records(
|
|
266
274
|
sqlalchemy.sql.select(records_table._table.columns[record_column]),
|
|
267
275
|
self._tables.dataset_location_trash,
|
|
268
|
-
)
|
|
276
|
+
)
|
|
277
|
+
if selected_ids:
|
|
278
|
+
items_in_trash = items_in_trash.where(
|
|
279
|
+
self._tables.dataset_location_trash.columns["dataset_id"].in_(selected_ids)
|
|
280
|
+
)
|
|
281
|
+
items_in_trash_alias = items_in_trash.alias("items_in_trash")
|
|
269
282
|
|
|
270
283
|
# A query for paths that are referenced by datasets in the trash
|
|
271
284
|
# and datasets not in the trash.
|
|
272
|
-
items_to_preserve = sqlalchemy.sql.select(
|
|
285
|
+
items_to_preserve = sqlalchemy.sql.select(
|
|
286
|
+
items_in_trash_alias.columns[record_column]
|
|
287
|
+
).select_from(
|
|
273
288
|
items_not_in_trash.join(
|
|
274
|
-
|
|
275
|
-
onclause=
|
|
289
|
+
items_in_trash_alias,
|
|
290
|
+
onclause=items_in_trash_alias.columns[record_column]
|
|
276
291
|
== items_not_in_trash.columns[record_column],
|
|
277
292
|
)
|
|
278
293
|
)
|
|
@@ -288,7 +303,7 @@ class MonolithicDatastoreRegistryBridge(DatastoreRegistryBridge):
|
|
|
288
303
|
yield ((id_info, preserved))
|
|
289
304
|
|
|
290
305
|
# No exception raised in context manager block.
|
|
291
|
-
if not rows:
|
|
306
|
+
if not rows or dry_run:
|
|
292
307
|
return
|
|
293
308
|
|
|
294
309
|
# Delete the rows from the records table
|
|
@@ -570,7 +570,16 @@ class DefaultCollectionManager(CollectionManager[K]):
|
|
|
570
570
|
child_collection_names,
|
|
571
571
|
# Removing members from a chain can't create collection cycles
|
|
572
572
|
skip_cycle_check=True,
|
|
573
|
+
# It is OK for multiple instances of `remove_from_collection_chain`
|
|
574
|
+
# to run concurrently on the same collection, because it doesn't
|
|
575
|
+
# read/modify the position numbers of the children -- it only
|
|
576
|
+
# deletes existing rows.
|
|
577
|
+
#
|
|
578
|
+
# However, other chain modification operations must still be
|
|
579
|
+
# blocked to avoid consistency issues.
|
|
580
|
+
exclusive_lock=False,
|
|
573
581
|
) as c:
|
|
582
|
+
self._block_for_concurrency_test()
|
|
574
583
|
self._remove_collection_chain_rows(c.parent_key, c.child_keys)
|
|
575
584
|
|
|
576
585
|
@contextmanager
|
|
@@ -581,6 +590,7 @@ class DefaultCollectionManager(CollectionManager[K]):
|
|
|
581
590
|
*,
|
|
582
591
|
skip_caching_check: bool = False,
|
|
583
592
|
skip_cycle_check: bool = False,
|
|
593
|
+
exclusive_lock: bool = True,
|
|
584
594
|
) -> Iterator[_CollectionChainModificationContext[K]]:
|
|
585
595
|
if (not skip_caching_check) and self._caching_context.collection_records is not None:
|
|
586
596
|
# Avoid having cache-maintenance code around that is unlikely to
|
|
@@ -604,7 +614,9 @@ class DefaultCollectionManager(CollectionManager[K]):
|
|
|
604
614
|
with self._db.transaction():
|
|
605
615
|
# Lock the parent collection to prevent concurrent updates to the
|
|
606
616
|
# same collection chain.
|
|
607
|
-
parent_key = self._find_and_lock_collection_chain(
|
|
617
|
+
parent_key = self._find_and_lock_collection_chain(
|
|
618
|
+
parent_collection_name, exclusive_lock=exclusive_lock
|
|
619
|
+
)
|
|
608
620
|
yield _CollectionChainModificationContext[K](
|
|
609
621
|
parent_key=parent_key, child_keys=child_keys, child_records=child_records
|
|
610
622
|
)
|
|
@@ -704,7 +716,7 @@ class DefaultCollectionManager(CollectionManager[K]):
|
|
|
704
716
|
|
|
705
717
|
return position
|
|
706
718
|
|
|
707
|
-
def _find_and_lock_collection_chain(self, collection_name: str) -> K:
|
|
719
|
+
def _find_and_lock_collection_chain(self, collection_name: str, *, exclusive_lock: bool) -> K:
|
|
708
720
|
"""
|
|
709
721
|
Take a row lock on the specified collection's row in the collections
|
|
710
722
|
table, and return the collection's primary key.
|
|
@@ -715,14 +727,19 @@ class DefaultCollectionManager(CollectionManager[K]):
|
|
|
715
727
|
collection chain table -- all operations that modify collection chains
|
|
716
728
|
must obtain this lock first. The database will NOT automatically
|
|
717
729
|
prevent modification of tables based on this lock. The only guarantee
|
|
718
|
-
is that only one caller will be allowed to hold
|
|
719
|
-
collection at a time. Concurrent calls will block until the
|
|
720
|
-
holding the lock has completed its transaction.
|
|
730
|
+
is that only one caller will be allowed to hold the exclusive lock for
|
|
731
|
+
a given collection at a time. Concurrent calls will block until the
|
|
732
|
+
caller holding the lock has completed its transaction.
|
|
721
733
|
|
|
722
734
|
Parameters
|
|
723
735
|
----------
|
|
724
736
|
collection_name : `str`
|
|
725
737
|
Name of the collection whose chain is being modified.
|
|
738
|
+
exclusive_lock : `bool`
|
|
739
|
+
If `True`, an exclusive lock will be taken to block all concurrent
|
|
740
|
+
modifications to the same collection. If `False`, a shared lock
|
|
741
|
+
will be taken which will only block operations that request an
|
|
742
|
+
exclusive lock.
|
|
726
743
|
|
|
727
744
|
Returns
|
|
728
745
|
-------
|
|
@@ -742,7 +759,7 @@ class DefaultCollectionManager(CollectionManager[K]):
|
|
|
742
759
|
)
|
|
743
760
|
assert self._db.isWriteable(), "Collection row locks are only useful for write operations."
|
|
744
761
|
|
|
745
|
-
query = self._select_pkey_by_name(collection_name).with_for_update()
|
|
762
|
+
query = self._select_pkey_by_name(collection_name).with_for_update(read=not exclusive_lock)
|
|
746
763
|
with self._db.query(query) as cursor:
|
|
747
764
|
rows = cursor.all()
|
|
748
765
|
|
|
@@ -29,7 +29,7 @@ from __future__ import annotations
|
|
|
29
29
|
__all__ = ("DatasetIdRef", "DatastoreRegistryBridge", "DatastoreRegistryBridgeManager", "FakeDatasetRef")
|
|
30
30
|
|
|
31
31
|
from abc import ABC, abstractmethod
|
|
32
|
-
from collections.abc import Iterable
|
|
32
|
+
from collections.abc import Collection, Iterable
|
|
33
33
|
from contextlib import AbstractContextManager
|
|
34
34
|
from typing import TYPE_CHECKING, Any
|
|
35
35
|
|
|
@@ -191,6 +191,8 @@ class DatastoreRegistryBridge(ABC):
|
|
|
191
191
|
records_table: OpaqueTableStorage | None = None,
|
|
192
192
|
record_class: type[StoredDatastoreItemInfo] | None = None,
|
|
193
193
|
record_column: str | None = None,
|
|
194
|
+
selected_ids: Collection[DatasetId] | None = None,
|
|
195
|
+
dry_run: bool = False,
|
|
194
196
|
) -> AbstractContextManager[
|
|
195
197
|
tuple[Iterable[tuple[DatasetIdRef, StoredDatastoreItemInfo | None]], set[str] | None]
|
|
196
198
|
]:
|
|
@@ -206,6 +208,16 @@ class DatastoreRegistryBridge(ABC):
|
|
|
206
208
|
Class to use when reading records from ``records_table``.
|
|
207
209
|
record_column : `str`, optional
|
|
208
210
|
Name of the column in records_table that refers to the artifact.
|
|
211
|
+
selected_ids : `collections.abc.Collection` [ `DatasetId` ] \
|
|
212
|
+
or `None`, optional
|
|
213
|
+
If provided, collection of IDs that should be trashed. Only records
|
|
214
|
+
within this selection will be yielded and then removed. This
|
|
215
|
+
can be used to allow a subset of the trash table to be emptied.
|
|
216
|
+
If an empty set is given no artifacts will be trashed. If `None`
|
|
217
|
+
the full list from the trash table will be used.
|
|
218
|
+
dry_run : `bool`, optional
|
|
219
|
+
If `True`, the trash table will be queried and results reported
|
|
220
|
+
but no artifacts will be removed.
|
|
209
221
|
|
|
210
222
|
Yields
|
|
211
223
|
------
|
|
@@ -973,14 +973,14 @@ class RegistryTests(ABC):
|
|
|
973
973
|
expected.
|
|
974
974
|
"""
|
|
975
975
|
|
|
976
|
-
def blocked_thread_func(
|
|
976
|
+
def blocked_thread_func(butler: Butler):
|
|
977
977
|
# This call will become blocked after it has decided on positions
|
|
978
978
|
# for the new children in the collection chain, but before
|
|
979
979
|
# inserting them.
|
|
980
|
-
|
|
980
|
+
butler.collections.prepend_chain("chain", ["a"])
|
|
981
981
|
|
|
982
|
-
def unblocked_thread_func(
|
|
983
|
-
|
|
982
|
+
def unblocked_thread_func(butler: Butler):
|
|
983
|
+
butler.collections.prepend_chain("chain", ["b"])
|
|
984
984
|
|
|
985
985
|
registry = self._do_collection_concurrency_test(blocked_thread_func, unblocked_thread_func)
|
|
986
986
|
|
|
@@ -993,13 +993,13 @@ class RegistryTests(ABC):
|
|
|
993
993
|
expected.
|
|
994
994
|
"""
|
|
995
995
|
|
|
996
|
-
def blocked_thread_func(
|
|
996
|
+
def blocked_thread_func(butler: Butler):
|
|
997
997
|
# This call will become blocked after deleting children, but before
|
|
998
998
|
# inserting new ones.
|
|
999
|
-
|
|
999
|
+
butler.collections.redefine_chain("chain", ["a"])
|
|
1000
1000
|
|
|
1001
|
-
def unblocked_thread_func(
|
|
1002
|
-
|
|
1001
|
+
def unblocked_thread_func(butler: Butler):
|
|
1002
|
+
butler.collections.redefine_chain("chain", ["b"])
|
|
1003
1003
|
|
|
1004
1004
|
registry = self._do_collection_concurrency_test(blocked_thread_func, unblocked_thread_func)
|
|
1005
1005
|
|
|
@@ -1008,8 +1008,23 @@ class RegistryTests(ABC):
|
|
|
1008
1008
|
# chain with "b".
|
|
1009
1009
|
self.assertEqual(("b",), registry.getCollectionChain("chain"))
|
|
1010
1010
|
|
|
1011
|
+
def testCollectionChainRemoveConcurrency(self):
|
|
1012
|
+
def blocked_thread_func(butler: Butler):
|
|
1013
|
+
# This call will become blocked after taking the lock, but before
|
|
1014
|
+
# deleting the children.
|
|
1015
|
+
butler.collections.remove_from_chain("chain", ["b"])
|
|
1016
|
+
|
|
1017
|
+
def unblocked_thread_func(butler: Butler):
|
|
1018
|
+
butler.collections.redefine_chain("chain", ["b", "a"])
|
|
1019
|
+
|
|
1020
|
+
registry = self._do_collection_concurrency_test(blocked_thread_func, unblocked_thread_func)
|
|
1021
|
+
|
|
1022
|
+
# blocked_thread_func should have finished first, removing "b".
|
|
1023
|
+
# unblocked_thread_func should have finished second, putting "b" back.
|
|
1024
|
+
self.assertEqual(("b", "a"), registry.getCollectionChain("chain"))
|
|
1025
|
+
|
|
1011
1026
|
def _do_collection_concurrency_test(
|
|
1012
|
-
self, blocked_thread_func: Callable[[
|
|
1027
|
+
self, blocked_thread_func: Callable[[Butler]], unblocked_thread_func: Callable[[Butler]]
|
|
1013
1028
|
) -> SqlRegistry:
|
|
1014
1029
|
# This function:
|
|
1015
1030
|
# 1. Sets up two registries pointing at the same database.
|
|
@@ -1048,12 +1063,12 @@ class RegistryTests(ABC):
|
|
|
1048
1063
|
|
|
1049
1064
|
with ThreadPoolExecutor(max_workers=1) as exec1:
|
|
1050
1065
|
with ThreadPoolExecutor(max_workers=1) as exec2:
|
|
1051
|
-
future1 = exec1.submit(blocked_thread_func,
|
|
1066
|
+
future1 = exec1.submit(blocked_thread_func, butler1)
|
|
1052
1067
|
enter_barrier.wait()
|
|
1053
1068
|
|
|
1054
1069
|
# At this point registry 1 has entered the critical section and
|
|
1055
1070
|
# is waiting for us to release it. Start the other thread.
|
|
1056
|
-
future2 = exec2.submit(unblocked_thread_func,
|
|
1071
|
+
future2 = exec2.submit(unblocked_thread_func, butler2)
|
|
1057
1072
|
# thread2 should block inside a database call, but we have no
|
|
1058
1073
|
# way to detect when it is in this state.
|
|
1059
1074
|
time.sleep(0.200)
|
|
@@ -40,12 +40,14 @@ from ..._exceptions import ButlerUserError
|
|
|
40
40
|
from .._errors import serialize_butler_user_error
|
|
41
41
|
from ..server_models import CLIENT_REQUEST_ID_HEADER_NAME, ERROR_STATUS_CODE, ErrorResponseModel
|
|
42
42
|
from ._config import load_config
|
|
43
|
+
from ._telemetry import enable_telemetry
|
|
43
44
|
from .handlers._external import external_router
|
|
44
45
|
from .handlers._external_query import query_router
|
|
45
46
|
from .handlers._internal import internal_router
|
|
46
47
|
|
|
47
48
|
configure_logging(name="lsst.daf.butler.remote_butler.server")
|
|
48
49
|
configure_uvicorn_logging()
|
|
50
|
+
enable_telemetry()
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
def create_app() -> FastAPI:
|
|
@@ -0,0 +1,105 @@
|
|
|
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/>.
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
31
|
+
from typing import Any, Protocol
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import sentry_sdk
|
|
35
|
+
|
|
36
|
+
_SENTRY_AVAILABLE = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
_SENTRY_AVAILABLE = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TelemetryContext(Protocol):
|
|
42
|
+
"""Interface for adding information to trace telemetry."""
|
|
43
|
+
|
|
44
|
+
def span(self, name: str) -> AbstractContextManager[None]: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NullTelemetryContext(TelemetryContext):
|
|
48
|
+
"""No-op implementation of telemetry used when no telemetry provider is
|
|
49
|
+
configured.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def span(self, name: str) -> Iterator[None]:
|
|
54
|
+
yield
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SentryTelemetryContext(TelemetryContext):
|
|
58
|
+
"""Implementation of telemetry using Sentry."""
|
|
59
|
+
|
|
60
|
+
@contextmanager
|
|
61
|
+
def span(self, name: str) -> Iterator[None]:
|
|
62
|
+
with sentry_sdk.start_span(name=name):
|
|
63
|
+
yield
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_telemetry_context: TelemetryContext = NullTelemetryContext()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def enable_telemetry() -> None:
|
|
70
|
+
"""Turn on upload of trace telemetry to Sentry, to allow performance
|
|
71
|
+
debugging of deployed server.
|
|
72
|
+
"""
|
|
73
|
+
if not _SENTRY_AVAILABLE:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Configuration will be pulled from SENTRY_* environment variables
|
|
77
|
+
# (see https://docs.sentry.io/platforms/python/configuration/options/).
|
|
78
|
+
# If SENTRY_DSN is not present, telemetry is disabled.
|
|
79
|
+
sentry_sdk.init(enable_tracing=True, traces_sampler=_decide_whether_to_sample_trace)
|
|
80
|
+
|
|
81
|
+
global _telemetry_context
|
|
82
|
+
_telemetry_context = SentryTelemetryContext()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_telemetry_context() -> TelemetryContext:
|
|
86
|
+
"""Return an object that can be used to add information to the trace
|
|
87
|
+
telemetry.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
telemetry_context : `TelemetryContext`
|
|
92
|
+
Object that can be used to add information to the trace telemetry.
|
|
93
|
+
"""
|
|
94
|
+
return _telemetry_context
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _decide_whether_to_sample_trace(context: dict[str, Any]) -> float:
|
|
98
|
+
asgi_scope = context.get("asgi_scope")
|
|
99
|
+
if asgi_scope is not None:
|
|
100
|
+
# Do not log health check endpoint.
|
|
101
|
+
if asgi_scope.get("path") == "/":
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
sampling_rate = float(os.getenv("BUTLER_TRACE_SAMPLING_RATE", "0.02"))
|
|
105
|
+
return sampling_rate
|
|
@@ -44,6 +44,7 @@ from lsst.daf.butler.remote_butler.server_models import (
|
|
|
44
44
|
|
|
45
45
|
from ...._exceptions import ButlerUserError
|
|
46
46
|
from ..._errors import serialize_butler_user_error
|
|
47
|
+
from .._telemetry import get_telemetry_context
|
|
47
48
|
|
|
48
49
|
# Restrict the maximum number of streaming queries that can be running
|
|
49
50
|
# simultaneously, to prevent the database connection pool and the thread pool
|
|
@@ -175,9 +176,12 @@ async def _enqueue_query_pages(
|
|
|
175
176
|
queue. Send `None` to the queue when there is no more data to read.
|
|
176
177
|
"""
|
|
177
178
|
try:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
telemetry = get_telemetry_context()
|
|
180
|
+
with telemetry.span("Execute query"):
|
|
181
|
+
async with contextmanager_in_threadpool(query.setup()) as ctx:
|
|
182
|
+
with telemetry.span("Read from DB and send results"):
|
|
183
|
+
async for page in iterate_in_threadpool(query.execute(ctx)):
|
|
184
|
+
await queue.put(page)
|
|
181
185
|
except ButlerUserError as e:
|
|
182
186
|
# If a user-facing error occurs, serialize it and send it to the
|
|
183
187
|
# client.
|
|
@@ -139,10 +139,16 @@ def removeRuns(
|
|
|
139
139
|
def _doRemove(runs: Sequence[RemoveRun]) -> None:
|
|
140
140
|
"""Perform the remove step."""
|
|
141
141
|
butler = Butler.from_config(repo, writeable=True)
|
|
142
|
+
# Collections must be removed from chains in a deterministic
|
|
143
|
+
# order to protect against parallelized purging on the same chain.
|
|
144
|
+
parents_to_children: dict[str, set[str]] = defaultdict(set)
|
|
145
|
+
for run in runs:
|
|
146
|
+
for parent in run.parents:
|
|
147
|
+
parents_to_children[parent].add(run.name)
|
|
148
|
+
|
|
142
149
|
with butler.transaction():
|
|
143
|
-
for
|
|
144
|
-
|
|
145
|
-
butler.collections.remove_from_chain(parent, run.name)
|
|
150
|
+
for parent in sorted(parents_to_children):
|
|
151
|
+
butler.collections.remove_from_chain(parent, sorted(parents_to_children[parent]))
|
|
146
152
|
butler.removeRuns([r.name for r in runs], unstore=True)
|
|
147
153
|
|
|
148
154
|
result = RemoveRunsResult(
|
|
@@ -174,7 +174,7 @@ class CliCmdTestBase(abc.ABC):
|
|
|
174
174
|
"""
|
|
175
175
|
result = self.run_command(inputs)
|
|
176
176
|
self.assertNotEqual(result.exit_code, 0, clickResultMsg(result))
|
|
177
|
-
self.assertRegex(result.
|
|
177
|
+
self.assertRegex(result.output, expectedMsg)
|
|
178
178
|
|
|
179
179
|
def test_help(self) -> None:
|
|
180
180
|
self.assertFalse(
|
lsst/daf/butler/version.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__all__ = ["__version__"]
|
|
2
|
-
__version__ = "29.2025.
|
|
2
|
+
__version__ = "29.2025.2000"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lsst-daf-butler
|
|
3
|
-
Version: 29.2025.
|
|
3
|
+
Version: 29.2025.2000
|
|
4
4
|
Summary: An abstraction layer for reading and writing astronomical data to datastores.
|
|
5
5
|
Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
|
|
6
6
|
License: BSD 3-Clause License
|