Source code for daq_queuing_service.task_queue.queue

import asyncio
from collections.abc import Callable, Sequence
from copy import deepcopy
from enum import StrEnum
from functools import cached_property
from types import TracebackType
from typing import Literal, TypedDict

from blueapi.worker.event import TaskError, TaskResult
from pydantic import BaseModel
from tiled.client.container import Container

from daq_queuing_service.broadcaster import Broadcaster, Event
from daq_queuing_service.external_interaction.blueapi.blueapi_call import (
    BlueapiCall,
    BlueapiCallResponse,
    CallStatus,
)
from daq_queuing_service.external_interaction.runtime_estimator.client import (
    RuntimeEstimatorClient,
)
from daq_queuing_service.external_interaction.tiled.tiled import (
    get_tiled_and_scan_ids,
    get_tiled_client,
)
from daq_queuing_service.log import (
    LOGGER,
    add_session_to_logger,
    get_session_filter,
    notify,
)
from daq_queuing_service.plugins.converter import Converter, ConverterError
from daq_queuing_service.task_queue.queue_utils import (
    NegativePositionError,
    TaskIdInUseError,
    TaskInProgressError,
    TaskNotClaimedError,
    TaskNotFoundError,
    TaskNotInQueueError,
)
from daq_queuing_service.task_queue.task import Status, Task, TaskWithPosition


class TaskRegistry(dict[str, Task]):
    def __missing__(self, task_id: str) -> Task:
        raise TaskNotFoundError(f"No task found matching id: {task_id}")


class PauseReason(StrEnum):
    USER_REQUESTED = "Pause requested by user"
    EMPTY_QUEUE = "Paused as queue completed"
    ERROR = "Paused as last task errored"
    PLUGIN_REQUESTED = "Pause requested by plugin"


class QueueState(BaseModel):
    paused: bool
    last_pause_reason: PauseReason
    estimated_runtime: int | None = None


class QueueContents(TypedDict):
    """A class to represent the contents of the queue

    tasks (dict[str, Task]): A dict containing all the tasks contained in the queue,
        including history, with the task ID as the key.

    queue (list[str]): A list of the task IDs of the tasks in the queue. The order of
        the list represents the order of the queue.

    history (list[str]): A list of the task IDs of any completed tasks.

    call_queue: (list[BlueapiCall]): A list of blueapi calls that will be executed in
        order to complete the queued tasks.

    call_history: (list[BlueapiCall]): A list of completed blueapi calls.
    """

    tasks: dict[str, Task]
    queue: list[str]
    history: list[str]
    call_queue: list[BlueapiCall]
    call_history: list[BlueapiCall]


class Modifying(asyncio.Condition):
    def __init__(
        self,
        lock: asyncio.Lock,
        on_exit: Callable[[], None],
        on_error: Callable[[], None],
    ):
        super().__init__(lock=lock)
        self._on_exit = on_exit
        self._on_error = on_error

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ):
        try:
            if exc is not None:
                self._on_error()
                return

            self._on_exit()

        except Exception:
            self._on_error()
            raise

        finally:
            await super().__aexit__(exc_type, exc, tb)


QUEUE_EVENTS = Literal[
    "state_update",
    "queue_update",
    "history_update",
    "tasks_update",
    "call_queue_update",
    "call_history_update",
]


[docs] class TaskQueue:
[docs] def __init__( self, converter: Converter, broadcaster: Broadcaster[QUEUE_EVENTS], runtime_estimator_client: RuntimeEstimatorClient | None = None, instrument: str | None = None, ): self.instrument = instrument self._tasks: TaskRegistry = TaskRegistry() self._queue: list[str] = [] self._history: list[str] = [] self._call_queue: list[BlueapiCall] = [] self._call_history: list[BlueapiCall] = [] self._save_contents() self._state: QueueState = QueueState( paused=True, last_pause_reason=PauseReason.EMPTY_QUEUE ) self._converter = converter self._broadcaster = broadcaster self._lock = asyncio.Lock() self._modifying = Modifying( lock=self._lock, on_exit=self._sync, on_error=self._restore_latest_good_contents, ) self._runtime_client = runtime_estimator_client
def _sync(self): """Syncs the task queue with the call queue, applying a conversion from queue of tasks to a queue of blueapi calls. This is called every time the queue is modified, and also right before a call is popped off the front of the queue. """ assert get_session_filter(LOGGER).instrument_session_var.get() is None, ( "Logger instrument session should not be set at this point" ) LOGGER.debug(f"Syncing. Queue before sync: {self._queue}") for task_id in list(self._queue): task = self._tasks[task_id] if task.status in (Status.COMPLETE, Status.ERROR): # Move task from queue to history if all blueapi calls complete # Or an error occurred during one of them self._queue.remove(task_id) self._history.append(task_id) for call in filter( lambda call: call.status == CallStatus.WAITING, task.blueapi_calls ): call.skip() elif task.status == Status.QUEUED: # If task is not in progress calls will be re-calculated task.blueapi_calls = [] try: new_tasks = self._converter.pre_process( self._get_running_task(), [ self._tasks[task_id] for task_id in self._queue if self._tasks[task_id].status == Status.QUEUED ], self._get_history(), self._get_call_history(), ) except Exception as e: raise ConverterError(e) from e # Update task_registry to match new tasks # Not needed as long as pre_process modifies in place for task in new_tasks: self._tasks[task.id] = task # Update queue to match new tasks self._queue = [ task_id for task_id in self._queue if not self._tasks[task_id].status == Status.QUEUED ] self._queue.extend(task.id for task in new_tasks) # This is needed because pre_process could remove tasks self._remove_tasks_from_registry(list(self._tasks.keys())) self._call_queue = [ # Persist calls which aren't complete but who's parent task is in progress # Once a task is in progress it is not provided to the converter # More work needed to allow for interleaved calls from different tasks, # and for in progress tasks to inform conversion call for call in self._call_queue if call.parent_task_id and call.parent_task_id in self._queue and self._tasks[call.parent_task_id].status == Status.IN_PROGRESS and call.status not in (CallStatus.SUCCESS, CallStatus.ERROR) ] try: new_calls = self._converter.construct_blueapi_calls( [ task for task in self._get_queue_with_positions() if task.status == Status.QUEUED ], self._get_history(), self._get_call_history(), ) except Exception as e: raise ConverterError(e) from e self._call_queue.extend(new_calls) for call in new_calls: # Add children to parent tasks if call.parent_task_id: self._tasks[call.parent_task_id].blueapi_calls.append(call) if not self._call_queue: self._pause_queue(PauseReason.EMPTY_QUEUE) self._add_runtime_estimates_to_tasks() self._save_contents() self._broadcast_changes() self._modifying.notify_all() LOGGER.debug(f"Queue after sync: {self._queue}") def _copy_contents(self) -> QueueContents: return deepcopy( { "tasks": self._tasks, "queue": self._queue, "history": self._history, "call_queue": self._call_queue, "call_history": self._call_history, } ) def _save_contents(self): self._last_good_contents = self._copy_contents() def _restore_from_contents(self, contents: QueueContents): LOGGER.info(f"Restoring to contents: {contents}") restored = deepcopy(contents) self._tasks = TaskRegistry(restored["tasks"]) self._queue = restored["queue"] self._history = restored["history"] self._call_queue = restored["call_queue"] self._call_history = restored["call_history"] def _restore_latest_good_contents(self): LOGGER.info("Restoring to last good contents") self._restore_from_contents(self._last_good_contents) def _broadcast_changes(self): queue = self._get_queue_with_positions() history = self._get_history() # broadcast only does anything if there have been changes self._broadcaster.broadcast(Event(type="queue_update", data=queue)) self._broadcaster.broadcast(Event(type="history_update", data=history)) self._broadcaster.broadcast(Event(type="tasks_update", data=history + queue)) self._broadcaster.broadcast( Event(type="call_queue_update", data=self._get_call_queue()) ) self._broadcaster.broadcast( Event(type="call_history_update", data=self._get_call_history()) ) self._broadcaster.broadcast(Event(type="state_update", data=self._state))
[docs] async def get_next_call_once_available(self) -> BlueapiCall: """Waits until a call is available before returning the call. A call is available if it's at the top of the call queue, is not already in progress or claimed, and the queue is not paused. Returns: BlueapiCall: The call at the top of the queue """ async with self._modifying: while not self._task_available(): await self._modifying.wait() self._sync() # Do conversion here to ensure conditions are up to date call = self._call_queue[0] with add_session_to_logger(call.task_request.instrument_session): call.claim() LOGGER.info(f"Plan {call} has been claimed") return call
[docs] async def wait_until_call_available(self): """Waits until a task is available before returning. A task is available if it's at the top of the queue, is not already in progress or claimed, and the queue is not paused. """ async with self._lock: while not self._task_available(): await self._modifying.wait()
[docs] async def return_call_to_queue(self, call: BlueapiCall): """Returns a task to the queue that had previously been claimed Args: task (Task): The task to return Raises: TaskNotClaimedError: Raised if the task's status is not 'Claimed' """ self._check_call_valid_to_be_returned(call) async with self._modifying: match call.status: case CallStatus.CLAIMED: assert call == self._call_queue[0] call.wait() case _: raise TaskNotClaimedError( f"Cannot return call {call}, " + f"it's status is {call.status}." ) with add_session_to_logger(call.task_request.instrument_session): LOGGER.info(f"Call {call} has been returned to the queue")
[docs] async def complete_call(self, call: BlueapiCall, result: TaskResult): """Sets a task to complete, removes it from the queue and adds it to history Args: task (Task): Task to be completed result (TaskResult): The result of the task from blueapi """ with add_session_to_logger(call.task_request.instrument_session): tiled_ids, scan_ids = get_tiled_and_scan_ids(self._tiled_client, call) should_pause = self._converter.pause_on_complete_blueapi_call( call.model_copy(), result ) LOGGER.info(f"Plan {call} completed successfully. Result: {result}") async with self._modifying: call.tiled_ids = tiled_ids call.scan_ids = scan_ids self._check_call_valid_to_be_returned(call) call.succeed(result) self._call_history.append(call) if should_pause: self._pause_queue(reason=PauseReason.PLUGIN_REQUESTED)
[docs] async def fail_call( self, call: BlueapiCall, errors: list[str | TaskError] | None = None ): """Sets a task to failed, removes it from the call queue and adds it to history. Args: task (Task): The task to fail errors (list[str | TaskError] | None, optional): A list of errors that occurred when trying to run the task. Defaults to None. """ with add_session_to_logger(call.task_request.instrument_session): should_pause = self._converter.pause_on_fail_blueapi_call( call.model_copy(), errors ) tiled_ids, scan_ids = get_tiled_and_scan_ids(self._tiled_client, call) LOGGER.error(f"Plan {call} has failed with the following errors: {errors}") async with self._modifying: call.tiled_ids = tiled_ids call.scan_ids = scan_ids if should_pause: self._pause_queue(PauseReason.ERROR) else: LOGGER.warning( "Call failed but queue not paused due to plugin decision" ) self._check_call_valid_to_be_returned(call) call.fail(errors) self._call_history.append(call)
[docs] async def get_task_by_id(self, task_id: str) -> TaskWithPosition: """Returns a task based on it's task ID Args: task_id (str): Task ID of the task Returns: TaskWithPosition: A copy of the task with a position field. Raises: TaskNotFoundError: Raised if the no task exists with the requested task ID. """ # Returns copy so don't have to be worried about caller modifying task. async with self._lock: return self._get_task_by_id(task_id)
def _get_task_by_id(self, task_id: str) -> TaskWithPosition: task = self._tasks[task_id] position = self._queue.index(task.id) if task.id in self._queue else None return TaskWithPosition.from_task(task, position)
[docs] async def get_task_by_position(self, position: int) -> TaskWithPosition | None: """Return a task based on it's position in the queue Args: position (int): The position of the task to be returned. Returns: TaskWithPosition | None: A copy of the task with a position field, or None if no task exists at the requested position. """ # Returns copy so don't have to be worried about caller modifying task. async with self._lock: return self._get_task_by_position(position)
def _get_task_by_position(self, position: int) -> TaskWithPosition | None: if position < -self.length or position >= self.length: return return self._get_task_by_id(self._queue[position])
[docs] async def get_queue(self) -> list[TaskWithPosition]: """Get the entire queue (not including history) Returns: list[TaskWithPosition]: A list of the tasks in the queue, in the order they will be run in. """ # Returns copies so don't have to be worried about caller modifying tasks. async with self._lock: return self._get_queue_with_positions()
[docs] async def get_history(self) -> list[TaskWithPosition]: """Get the history list. Returns: list[TaskWithPosition]: A list of the tasks in the history list, in chronological order. """ # Returns copies so don't have to be worried about caller modifying tasks. async with self._lock: return self._get_history()
[docs] async def get_tasks(self) -> list[TaskWithPosition]: """Get all the tasks in the queue and history list. Returns: list[TaskWithPosition]: A list of tasks in chronological order, starting with the history. """ # Returns copies so don't have to be worried about caller modifying tasks. async with self._lock: return self._get_history() + self._get_queue_with_positions()
[docs] async def add_tasks(self, tasks: list[Task], position: int | None = None) -> None: """Add tasks to the queue. Can specify a position to add the tasks in at. This position will apply to the first task in the list, with each subsequent task having a position of 1 more than the previous. By default adds tasks to the end of the queue. Args: tasks (list[Task]): List of tasks to add position (int | None, optional): Position of the tasks. Defaults to None. """ async with self._modifying: self._validate_new_tasks(tasks) if position is not None: position = self._get_valid_position(position) self._add_tasks(tasks, position) LOGGER.info(f"Successfully added tasks to queue: {[task.id for task in tasks]}")
[docs] async def move_task(self, task_id: str, position: int) -> int: """Move a task into a different position. If the requested position is unavailable, will move to the closest available position. If the requested task cannot be moved because it is complete or in progress, an error will be raised. Args: task_id (str): ID of the task to be moved new_position (int): New position of the task Returns: int: The new position of the task (may be different to what was requested) """ async with self._modifying: self._validate_tasks_for_move_or_deletion([task_id]) position = self._get_valid_position(position) self._remove_tasks_from_queue([task_id]) self._queue[position:position] = [task_id] new_position = self._queue.index(task_id) LOGGER.info(f"Successfully moved task {task_id} to position {new_position}") return new_position
[docs] async def cancel_tasks(self, task_ids: Sequence[str]) -> list[TaskWithPosition]: """Remove tasks from the queue. If one or more of the requested tasks cannot be cancelled as they are complete or in progress, an error will be raised and none of the requested tasks will be cancelled. Args: task_ids (Sequence[str]): List of task IDs to cancel Returns: list[TaskWithPosition]: List of the task objects that were removed from the queue. """ async with self._modifying: cancelled_tasks = self._cancel_tasks(task_ids) LOGGER.info(f"Successfully cancelled tasks: {task_ids}") return cancelled_tasks
[docs] async def cancel_all_tasks(self) -> list[TaskWithPosition]: async with self._modifying: task_ids = [ id for id in self._queue if self._get_task_by_id(id).status == Status.QUEUED ] cancelled_tasks = self._cancel_tasks(task_ids) LOGGER.info("Successfully cleared all queued tasks") return cancelled_tasks
[docs] async def clear_history(self): """Clears the history list. Any task in the history list at the time will be deleted permanently and inaccessible. """ async with self._modifying: self._history.clear() LOGGER.info("Successfully cleared history")
def _pause_queue(self, reason: PauseReason): reason = ( reason if reason == PauseReason.ERROR or not self._state.paused else self._state.last_pause_reason ) self._state.paused = True self._state.last_pause_reason = reason notify(f"Queue paused: {reason}", "INFO")
[docs] async def pause_queue(self, reason: PauseReason) -> QueueState: """Pause the queue if running. Args: paused (PauseReason): The reason for pausing the queue. Returns: QueueState: The new state of the queue. """ async with self._modifying: self._pause_queue(reason) return self._state
[docs] async def resume_queue(self) -> QueueState: """Resume the queue if paused. Returns: QueueState: The new state of the queue. """ LOGGER.info("Resuming queue") async with self._modifying: self._state.paused = False return self._state
@property def state(self): return self._state @property def length(self): return len(self._queue) def _task_available(self) -> bool: """Predicate that determines whether the queue has a task available. This returns True if the first task in the queue has a status of WAITING, and the queue is not paused. Returns: bool: Whether or not the queue has a task available. """ if self._state.paused or not self._call_queue: return False return self._call_queue[0].status == CallStatus.WAITING def _check_call_valid_to_be_returned(self, call: BlueapiCall): # Check caller has actual task object not copy # This ensures the caller has claimed the task, reducing the chance a task is # returned that is actually still being run/modified by a different process. # However if the worker crashes we then lose the Task object and can't return # the task? Needs discussion with others. assert call is self._call_queue[0] assert call.parent_task_id in self._queue, ( f"This call has no parent task: {call}" ) def _get_valid_position(self, position: int) -> int: if position < 0: raise NegativePositionError(f"Position must be >= 0, got {position}") if ( # if position 0 requested but a task is in progress, return position 1 position == 0 and self.length and self._tasks[self._queue[0]].status != Status.QUEUED ): return 1 return position def _validate_new_tasks(self, tasks: list[Task]): for task in tasks: if task.id in self._tasks: raise TaskIdInUseError(f"Task ID '{task.id}' already in use!") def _add_tasks(self, tasks: list[Task], position: int | None) -> None: task_ids = [task.id for task in tasks] if position is None: self._queue.extend(task_ids) else: self._queue[position:position] = task_ids for task in tasks: self._tasks[task.id] = task def _cancel_tasks(self, task_ids: Sequence[str]) -> list[TaskWithPosition]: task_ids = list(task_ids) self._validate_tasks_for_move_or_deletion(task_ids) self._remove_tasks_from_queue(task_ids) tasks = [self._tasks[task_id] for task_id in task_ids] for task in tasks: task.cancel() return [TaskWithPosition.from_task(task) for task in tasks] def _remove_tasks_from_queue(self, task_ids: list[str]) -> list[str]: # Only removes tasks in the queue (not history or registry) def should_be_removed(task_id: str): return ( task_id in self._queue and self._tasks[task_id].status != Status.IN_PROGRESS ) removed_ids = [task_id for task_id in task_ids if should_be_removed(task_id)] self._queue = [task_id for task_id in self._queue if task_id not in removed_ids] return removed_ids def _remove_tasks_from_registry(self, task_ids: list[str]) -> None: # Only removes tasks not present in the queue or history # So should remove tasks from queue/history before removing from registry def should_be_removed(task_id: str) -> bool: return ( task_id in self._tasks and self._tasks[task_id].status != Status.IN_PROGRESS and task_id not in self._queue and task_id not in self._history ) removed_ids = [task_id for task_id in task_ids if should_be_removed(task_id)] self._tasks = TaskRegistry( { task_id: task for task_id, task in self._tasks.items() if task.id not in removed_ids } ) def _validate_tasks_for_move_or_deletion(self, task_ids: list[str]): for task_id in task_ids: task = self._tasks[task_id] if task_id not in self._queue: raise TaskNotInQueueError(f"Task {task_id} isn't present in queue") if task.status != Status.QUEUED: raise TaskInProgressError( f"Cannot move task '{task_id}', it is currently in progress!" ) def _get_queue(self) -> list[Task]: return [self._tasks[task_id] for task_id in self._queue] def _get_queue_with_positions(self) -> list[TaskWithPosition]: return [ TaskWithPosition.from_task(self._tasks[task_id], i) for i, task_id in enumerate(self._queue) ] def _get_history(self) -> list[TaskWithPosition]: return [ TaskWithPosition.from_task(self._tasks[task_id]) for task_id in self._history ]
[docs] async def get_call_queue(self) -> list[BlueapiCallResponse]: async with self._lock: return self._get_call_queue()
def _get_call_queue(self) -> list[BlueapiCallResponse]: return [call.to_response() for call in self._call_queue]
[docs] async def get_call_history(self) -> list[BlueapiCallResponse]: async with self._lock: return self._get_call_history()
def _get_call_history(self) -> list[BlueapiCallResponse]: return [call.to_response() for call in self._call_history] def _get_running_task(self) -> TaskWithPosition | None: current_task = self._get_task_by_position(0) if current_task and current_task.status == Status.IN_PROGRESS: return current_task def _add_runtime_estimates_to_tasks(self): if self._runtime_client and self.instrument: tasks = self._get_queue() experiments = [task.experiment for task in tasks] try: total_runtime = self._runtime_client.estimate_runtime( experiments, self.instrument ) runtimes = self._runtime_client.estimate_runtime_per_task( experiments, self.instrument ) except Exception as e: # This shouldn't prevent syncing so catch and log LOGGER.exception(e) return self._state.estimated_runtime = total_runtime for task, runtime in zip(tasks, runtimes, strict=True): if task.status == Status.QUEUED: task.estimated_runtime = runtime @cached_property def _tiled_client(self) -> Container: return get_tiled_client()