Repository: bluesky/bluesky
Pull request #1919 (2025-06-16 14:39:59)

ENH: Support async scan_id_source functions
Allow scan_id_source functions to be async

## Description
If the scan id is determined by an external service (as is the case at DLS), it
would be useful to not block on the request.

## Motivation and Context
See #1895

## How Has This Been Tested?
New tests asserting scan id is updated correctly on `open_run` with both sync
and async source functions



Patch:
diff --git a/docs/api_changes.rst b/docs/api_changes.rst
index 98ab950e..4bfaee15 100644
--- a/docs/api_changes.rst
+++ b/docs/api_changes.rst
@@ -2,6 +2,14 @@
  Release History
 =================
 
+v1.14.3 (yyyy-mm-dd)
+====================
+
+Changed
+-------
+
+- RunEngine now supports both sync and async functions as a `scan_id_source`
+
 v1.14.1 (2025-05-21)
 ====================
 
diff --git a/src/bluesky/run_engine.py b/src/bluesky/run_engine.py
index f7bd3c54..6605887d 100644
--- a/src/bluesky/run_engine.py
+++ b/src/bluesky/run_engine.py
@@ -37,6 +37,7 @@ from .protocols import (
     Stageable,
     Status,
     Stoppable,
+    SyncOrAsync,
     T,
     Triggerable,
     check_supports,
@@ -270,9 +271,9 @@ class RunEngine:
         Expected return: normalized metadata
 
     scan_id_source : callable, optional
-        a function that will be used to calculate scan_id. Default is to
-        increment scan_id by 1 each time. However you could pass in a
-        customized function to get a scan_id from any source.
+        a (possibly async) function that will be used to calculate scan_id.
+        Default is to increment scan_id by 1 each time. However you could pass
+        in a customized function to get a scan_id from any source.
         Expected signature: f(md)
         Expected return: updated scan_id value
 
@@ -418,7 +419,7 @@ class RunEngine:
         context_managers: typing.Optional[list] = None,
         md_validator: typing.Optional[typing.Callable] = None,
         md_normalizer: typing.Optional[typing.Callable] = None,
-        scan_id_source: typing.Optional[typing.Callable] = default_scan_id_source,
+        scan_id_source: typing.Callable[[dict], SyncOrAsync[int]] = default_scan_id_source,
         during_task: typing.Optional[DuringTask] = None,
         call_returns_result: bool = False,
     ):
@@ -1857,7 +1858,7 @@ class RunEngine:
             raise IllegalMessageSequence("A 'close_run' message was not received before the 'open_run' message")
 
         # Run scan_id calculation method
-        self.md["scan_id"] = self.scan_id_source(self.md)
+        self.md["scan_id"] = await maybe_await(self.scan_id_source(self.md))
 
         # For metadata below, info about plan passed to self.__call__ for.
         plan_type = type(self._plan).__name__


Test patch:
diff --git a/src/bluesky/tests/test_run_engine.py b/src/bluesky/tests/test_run_engine.py
index d004108a..3eeaa1cf 100644
--- a/src/bluesky/tests/test_run_engine.py
+++ b/src/bluesky/tests/test_run_engine.py
@@ -2128,3 +2128,21 @@ def test_configure_multiple_descritpors(RE):
         assert len(d.event[desc["uid"]]) == 1
 
     assert stop["num_events"]["primary"] == 2
+
+
+def test_sync_scan_id_source(RE):
+    def sync_scan_source(md: dict) -> int:
+        return 314159
+
+    RE.scan_id_source = sync_scan_source
+    RE([Msg("open_run")])
+    assert RE.md["scan_id"] == 314159
+
+
+def test_async_scan_id_source(RE):
+    async def async_scan_source(md: dict) -> int:
+        return 42
+
+    RE.scan_id_source = async_scan_source
+    RE([Msg("open_run")])
+    assert RE.md["scan_id"] == 42