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.
Files changed (33) hide show
  1. lsst/daf/butler/_quantum_backed.py +3 -2
  2. lsst/daf/butler/cli/butler.py +1 -1
  3. lsst/daf/butler/cli/cmd/_remove_runs.py +2 -0
  4. lsst/daf/butler/cli/utils.py +32 -4
  5. lsst/daf/butler/configs/datastores/formatters.yaml +1 -0
  6. lsst/daf/butler/configs/storageClasses.yaml +2 -0
  7. lsst/daf/butler/datastore/_datastore.py +20 -2
  8. lsst/daf/butler/datastore/generic_base.py +2 -2
  9. lsst/daf/butler/datastores/chainedDatastore.py +8 -4
  10. lsst/daf/butler/datastores/fileDatastore.py +151 -84
  11. lsst/daf/butler/datastores/inMemoryDatastore.py +32 -4
  12. lsst/daf/butler/direct_butler/_direct_butler.py +35 -10
  13. lsst/daf/butler/registry/bridge/ephemeral.py +16 -6
  14. lsst/daf/butler/registry/bridge/monolithic.py +21 -6
  15. lsst/daf/butler/registry/collections/_base.py +23 -6
  16. lsst/daf/butler/registry/interfaces/_bridge.py +13 -1
  17. lsst/daf/butler/registry/tests/_registry.py +26 -11
  18. lsst/daf/butler/remote_butler/server/_server.py +2 -0
  19. lsst/daf/butler/remote_butler/server/_telemetry.py +105 -0
  20. lsst/daf/butler/remote_butler/server/handlers/_query_streaming.py +7 -3
  21. lsst/daf/butler/script/removeRuns.py +9 -3
  22. lsst/daf/butler/tests/cliCmdTestBase.py +1 -1
  23. lsst/daf/butler/version.py +1 -1
  24. {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/METADATA +1 -1
  25. {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/RECORD +33 -32
  26. {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/WHEEL +1 -1
  27. {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/entry_points.txt +0 -0
  28. {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/licenses/COPYRIGHT +0 -0
  29. {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/licenses/LICENSE +0 -0
  30. {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/licenses/bsd_license.txt +0 -0
  31. {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/licenses/gpl-v3.0.txt +0 -0
  32. {lsst_daf_butler-29.2025.1900.dist-info → lsst_daf_butler-29.2025.2000.dist-info}/top_level.txt +0 -0
  33. {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
- self._datastore.trash(refs)
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
- self._registry.removeCollection(name)
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
- self._datastore.emptyTrash()
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
- self._datastore.trash(refs)
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
- self._registry.removeDatasets(refs)
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
- for tag in tags:
1527
- self._registry.disassociate(tag, refs)
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
- self._datastore.emptyTrash()
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 self._trashedIds
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 self._trashedIds)
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 self._trashedIds])
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(self._trashedIds)
127
- self._trashedIds = set()
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
- ).alias("items_in_trash")
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(items_in_trash.columns[record_column]).select_from(
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
- items_in_trash,
275
- onclause=items_in_trash.columns[record_column]
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(parent_collection_name)
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 this lock for a given
719
- collection at a time. Concurrent calls will block until the caller
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(registry: SqlRegistry):
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
- registry._managers.collections.prepend_collection_chain("chain", ["a"])
980
+ butler.collections.prepend_chain("chain", ["a"])
981
981
 
982
- def unblocked_thread_func(registry: SqlRegistry):
983
- registry._managers.collections.prepend_collection_chain("chain", ["b"])
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(registry: SqlRegistry):
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
- registry.setCollectionChain("chain", ["a"])
999
+ butler.collections.redefine_chain("chain", ["a"])
1000
1000
 
1001
- def unblocked_thread_func(registry: SqlRegistry):
1002
- registry.setCollectionChain("chain", ["b"])
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[[SqlRegistry]], unblocked_thread_func: Callable[[SqlRegistry]]
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, registry1)
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, registry2)
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
- async with contextmanager_in_threadpool(query.setup()) as ctx:
179
- async for page in iterate_in_threadpool(query.execute(ctx)):
180
- await queue.put(page)
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 run in runs:
144
- for parent in run.parents:
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.stdout, expectedMsg)
177
+ self.assertRegex(result.output, expectedMsg)
178
178
 
179
179
  def test_help(self) -> None:
180
180
  self.assertFalse(
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "29.2025.1900"
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.1900
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