Skip to content

feat: Blueapi plan pause - #1589

Open
Alexj9837 wants to merge 8 commits into
mainfrom
plan-pause
Open

feat: Blueapi plan pause#1589
Alexj9837 wants to merge 8 commits into
mainfrom
plan-pause

Conversation

@Alexj9837

Copy link
Copy Markdown
Contributor

Bluesky's RunEngine pauses via a RunEngineInterrupted, but blueapi was treating that exception like any other plan failure , pausing a task marked it as failed instead of leaving it resumable. On top of that, resume() and cancel_active_task() mutated RunEngine state directly from the calling thread, racing with the worker thread that owns it, which could leave a paused RunEngine stuck forever, apply a stale cancel, or double-set a task's outcome.

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.84946% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.33%. Comparing base (872ea21) to head (5e78865).

Files with missing lines Patch % Lines
src/blueapi/worker/task_worker.py 97.84% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1589      +/-   ##
==========================================
+ Coverage   95.96%   96.33%   +0.37%     
==========================================
  Files          45       45              
  Lines        3317     3387      +70     
==========================================
+ Hits         3183     3263      +80     
+ Misses        134      124      -10     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/blueapi/worker/task_worker.py Fixed
Comment thread src/blueapi/worker/task_worker.py Fixed
…ead signals

Route resume() and cancel_active_task() through the worker thread's
signal queue instead of mutating state directly from the caller, so
state stays consistent across pause/resume/cancel transitions. Includes
tests and a fix for cancel_active_task() when the RunEngine is already
in a paused state.
- Resolve a paused task before stopping the worker, instead of leaving
  the RunEngine paused and the task incomplete forever with no thread
  left to resolve it.
- Apply the latest cancel_active_task() request while paused instead of
  a stale queued one.
- Prevent a race between the caller thread and worker thread when
  recording a cancelled task's outcome.
racing the worker thread's own finalization, which could report a
completed task with no result.
@Alexj9837
Alexj9837 force-pushed the plan-pause branch 2 times, most recently from 5f85341 to f6f82cb Compare July 14, 2026 11:04
@Alexj9837
Alexj9837 marked this pull request as ready for review July 14, 2026 11:33
@Alexj9837
Alexj9837 requested a review from a team as a code owner July 14, 2026 11:33
return self._current.task_id
self._task_channel.put(CancelSignal(failure=failure, reason=reason))
add_span_attributes(
{"Task aborted" if failure else "Task stopped": reason or ""}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should 'default_reason' be used instead of ""? the default reason for abort and stop are different

@tpoliaw tpoliaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking a while to figure out what is going on with the task worker in general and what needed to change. There are a couple of blocking things (resumed plan results and exceptions) but feel free to ignore/question the rest.

Might need you to go over why the CancelSignal is needed when it wasn't before - I'm not sure I follow the logic.

default_reason = "Task failed for unknown reason"
if failure:
default_reason = "Task failed for unknown reason"
with self._status_lock:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be re-using the status lock? As far as I can see, its other use is related to monitoring statuses from the run engine, not for modifying the status of the worker.


def _apply_cancel(self, signal: "CancelSignal") -> None:
self._pending_cancel = None
default_reason = "Task failed for unknown reason"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really an unknown reason? If a user aborts a task wouldn't it be more useful to mark the task as "aborted by user" or similar?

if signal.failure:
reason = signal.reason or default_reason
self._ctx.run_engine.abort(reason)
self._current.set_exception(Exception(reason))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a generic exception here hides the fact it was aborted. Is it worth adding an Aborted outcome as an alternative to TaskResult/TaskError?

Or for a smaller change, add a mark_aborted method that creates a TaskError with the message.

elif self._ctx.run_engine.state == "paused":
if self._current is not None:
try:
result = self._ctx.run_engine.resume()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is missing all the logging instrumentation that plans currently have. The process_task call above has the plan_tag_filter_context etc around it.

result = self._ctx.run_engine.resume()
self._current.set_result(result)
except RunEngineInterrupted:
# Plan paused again immediately - not a failure,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"paused again or aborted" and doesn't have to be immediately. It could be a failure (in the abort as failure sense) but the outcome would already have been set in that case.

I think we also need to handle exceptions here. Currently a task that fails after being resumed does not have its outcome set.

finally:
if self._current_task_otel_context is not None:
if (
self._current_task_otel_context is not None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the context be left here or recreated when the task is resumed? I'm not sure how to check this but wouldn't this leave the resumed part of plan running with the telemetry of the original submit and lose trace of the resume happening? Maybe that is what we want?

if self._current is not None:
try:
result = self._ctx.run_engine.resume()
self._current.set_result(result)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resume returns a RunEngineResult not the return value of the plan

Suggested change
self._current.set_result(result)
self._current.set_result(result.plan_result)

@tpoliaw

tpoliaw commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Another thing to look at - running a count plan and then pausing/resuming several times, it occasionally fails with

"Received multiple indices in a `stream_datum` document for one event"
2026-08-11 17:00:58,066    ERROR blueapi.worker.task_worker Received multiple indices in a `stream_datum` document for one event,  during a `read()` `save()`. `stream_datum` should have indices {"start": n, "stop": n+1} in a `read()` `save()`.
Traceback (most recent call last):
  File "/scratch/athena/blueapi/src/blueapi/worker/task_worker.py", line 509, in _cycle
    result = self._ctx.run_engine.resume()
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/run_engine.py", line 1024, in resume
    plan_return = self._resume_task()
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/run_engine.py", line 1127, in _resume_task
    raise exc
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/run_engine.py", line 1762, in _run
    raise err
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/run_engine.py", line 1616, in _run
    msg = self._plan_stack[-1].send(resp)
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/utils/__init__.py", line 1351, in dec_inner
    return (yield from plan)
            ^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/dodal/plan_stubs/data_session.py", line 52, in attach_data_session_metadata_wrapper
    yield from plan
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/dodal/plans/wrapped.py", line 62, in count
    yield from bp.count(tuple(detectors), num, delay=delay, md=metadata)
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/plans.py", line 129, in count
    return (yield from inner_count())
            ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/utils/__init__.py", line 1351, in dec_inner
    return (yield from plan)
            ^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/preprocessors.py", line 1013, in stage_wrapper
    return (yield from finalize_wrapper(inner(), unstage_devices()))
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/preprocessors.py", line 548, in finalize_wrapper
    ret = yield from plan
          ^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/preprocessors.py", line 1011, in inner
    return (yield from plan)
            ^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/utils/__init__.py", line 1351, in dec_inner
    return (yield from plan)
            ^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/preprocessors.py", line 370, in run_wrapper
    yield from contingency_wrapper(plan, except_plan=except_plan, else_plan=close_run)
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/preprocessors.py", line 622, in contingency_wrapper
    ret = yield from plan
          ^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/plans.py", line 127, in inner_count
    return (yield from bps.repeat(partial(msg_per_step, detectors), num=num, delay=delay))
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/utils/__init__.py", line 2047, in __iter__
    return (yield from self._iter)
            ^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/plan_stubs.py", line 1825, in repeat
    return (yield from repeated_plan())
            ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/plan_stubs.py", line 1809, in repeated_plan
    yield from ensure_generator(plan())
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/utils/__init__.py", line 2047, in __iter__
    return (yield from self._iter)
            ^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/plan_stubs.py", line 1623, in one_shot
    yield from take_reading(list(detectors))  # type: ignore  # Movable issue
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/utils/__init__.py", line 2047, in __iter__
    return (yield from self._iter)
            ^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/plan_stubs.py", line 1485, in trigger_and_read
    return (yield from rewindable_wrapper(inner_trigger_and_read(), rewindable))
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/preprocessors.py", line 749, in rewindable_wrapper
    return (yield from plan)
            ^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/plan_stubs.py", line 1480, in inner_trigger_and_read
    ret = yield from contingency_wrapper(read_plan(), except_plan=exception_path, else_plan=standard_path)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/preprocessors.py", line 641, in contingency_wrapper
    yield from else_plan()
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/plan_stubs.py", line 1474, in standard_path
    yield from save()
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/utils/__init__.py", line 2047, in __iter__
    return (yield from self._iter)
            ^^^^^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/plan_stubs.py", line 128, in save
    return (yield Msg("save"))
            ^^^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/run_engine.py", line 1683, in _run
    new_response = await coro(msg)
                   ^^^^^^^^^^^^^^^
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/run_engine.py", line 2083, in _save
    await current_run.save(msg)
  File "/scratch/athena/blueapi/.venv/lib/python3.14/site-packages/bluesky/bundlers.py", line 612, in save
    raise RuntimeError(
    ...<3 lines>...
    )
RuntimeError: Received multiple indices in a `stream_datum` document for one event,  during a `read()` `save()`. `stream_datum` should have indices {"start": n, "stop": n+1} in a `read()` `save()`.

I'm not sure if it's related to this change or if there is a bug in the run engine/plan that has only just surfaced now pausing is possible.

@Alexj9837
Alexj9837 force-pushed the plan-pause branch 2 times, most recently from a5e8e0f to 70671d9 Compare August 12, 2026 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants