Source code for litestar_queues.events.context

"""Task execution context and helper APIs for queue event publishing."""

import asyncio
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol

from litestar_queues.events.models import QueueEvent, QueueEventActor, QueueEventEntityRef, QueueEventScope
from litestar_queues.exceptions import JobCancelledError

if TYPE_CHECKING:
    from collections.abc import Iterator, Sequence

    from litestar_queues.events.publisher import QueueEventPublisher


__all__ = (
    "TaskBeatSink",
    "TaskExecutionContext",
    "beat",
    "bind_beat_sink",
    "bind_task_context",
    "get_current_task_context",
    "publish_task_event",
    "publish_task_log",
    "publish_task_progress",
    "require_current_task_context",
)


[docs] class TaskBeatSink(Protocol): """Receives last-value-wins beat progress for a running task."""
[docs] def record_beat(self, task_id: "str", detail: "str | None") -> "None": """Record the latest beat detail reported by ``task_id``.""" ...
_current_task_context: 'ContextVar["TaskExecutionContext | None"]' = ContextVar( "litestar_queues_task_context", default=None ) _current_beat_sink: 'ContextVar["TaskBeatSink | None"]' = ContextVar("litestar_queues_beat_sink", default=None) _active_task_contexts: "dict[str, TaskExecutionContext]" = {}
[docs] @dataclass(slots=True) class TaskExecutionContext: """Context bound while a queue task is executing.""" task_id: "str" task_name: "str" queue: "str" worker_id: "str | None" execution_backend: "str" execution_profile: "str | None" attempt: "int" event_publisher: "QueueEventPublisher" scope_key: "str | None" = None actor: "QueueEventActor | None" = None entity: "QueueEventEntityRef | None" = None _sequence: "int" = field(default=0, init=False, repr=False) _cancelled: "asyncio.Event" = field(default_factory=asyncio.Event, init=False, repr=False) @property def is_cancelled(self) -> "bool": """Whether durable cancellation has reached this execution.""" return self._cancelled.is_set()
[docs] async def wait_cancelled(self) -> "None": """Wait until durable cancellation reaches this execution.""" await self._cancelled.wait()
[docs] def raise_if_cancelled(self) -> "None": """Raise :class:`JobCancelledError` after cancellation is requested.""" if self.is_cancelled: raise JobCancelledError
[docs] def mark_cancelled(self) -> "None": self._cancelled.set()
[docs] async def progress( self, *, current: "float | None" = None, total: "float | None" = None, percent: "float | None" = None, message: "str | None" = None, payload: "dict[str, Any] | None" = None, channels: "Sequence[str] | None" = None, scope_key: "str | None" = None, actor: "QueueEventActor | None" = None, entity: "QueueEventEntityRef | None" = None, immediate: "bool" = False, ) -> "None": """Publish a task progress event.""" progress_percent = percent if progress_percent is None and current is not None and total: progress_percent = float(current) / float(total) * 100 await self.publish( "task.progress", message=message, progress_current=current, progress_total=total, progress_percent=progress_percent, payload=payload, channels=channels, scope_key=scope_key, actor=actor, entity=entity, immediate=immediate, )
[docs] async def log( self, message: "str", *, level: "str" = "info", payload: "dict[str, Any] | None" = None, channels: "Sequence[str] | None" = None, scope_key: "str | None" = None, actor: "QueueEventActor | None" = None, entity: "QueueEventEntityRef | None" = None, immediate: "bool" = False, ) -> "None": """Publish a task log event.""" await self.publish( "task.log", level=level, message=message, payload=payload, channels=channels, scope_key=scope_key, actor=actor, entity=entity, immediate=immediate, )
[docs] async def event( self, event_type: "str", *, message: "str | None" = None, payload: "dict[str, Any] | None" = None, channels: "Sequence[str] | None" = None, scope_key: "str | None" = None, actor: "QueueEventActor | None" = None, entity: "QueueEventEntityRef | None" = None, immediate: "bool" = False, ) -> "None": """Publish a custom task event.""" await self.publish( event_type, message=message, payload=payload, channels=channels, scope_key=scope_key, actor=actor, entity=entity, immediate=immediate, )
[docs] async def lifecycle( self, event_type: "str", *, message: "str | None" = None, payload: "dict[str, Any] | None" = None ) -> "None": """Publish a worker-owned lifecycle event.""" await self.publish(event_type, message=message, payload=payload)
[docs] def beat(self, detail: "str | None" = None) -> "None": """Record last-value-wins progress for the next heartbeat tick.""" sink = _current_beat_sink.get() if sink is None: return sink.record_beat(self.task_id, detail)
[docs] async def publish( self, event_type: "str", *, level: "str | None" = None, message: "str | None" = None, progress_current: "float | None" = None, progress_total: "float | None" = None, progress_percent: "float | None" = None, payload: "dict[str, Any] | None" = None, channels: "Sequence[str] | None" = None, scope: "QueueEventScope" = "task", scope_key: "str | None" = None, actor: "QueueEventActor | None" = None, entity: "QueueEventEntityRef | None" = None, immediate: "bool" = False, ) -> "QueueEvent": """Build and publish an event for this task context. Returns: The published queue event. """ event = QueueEvent( type=event_type, scope=scope, scope_key=scope_key if scope_key is not None else self.scope_key, task_id=self.task_id, task_name=self.task_name, queue=self.queue, worker_id=self.worker_id, execution_backend=self.execution_backend, execution_profile=self.execution_profile, attempt=self.attempt, sequence=self._next_sequence(), level=level, message=message, progress_current=progress_current, progress_total=progress_total, progress_percent=progress_percent, payload=dict(payload or {}), actor=actor if actor is not None else self.actor, entity=entity if entity is not None else self.entity, ) await self.event_publisher.publish(event, channels=channels, immediate=immediate) return event
def _next_sequence(self) -> "int": self._sequence += 1 return self._sequence
[docs] def get_current_task_context() -> "TaskExecutionContext | None": """Return the task execution context for the current async context.""" return _current_task_context.get()
[docs] def require_current_task_context() -> "TaskExecutionContext": """Return the current task context or raise if none is bound. Raises: RuntimeError: If no task context is bound. """ context = get_current_task_context() if context is None: msg = "No queue task execution context is currently bound." raise RuntimeError(msg) return context
[docs] async def publish_task_progress( *, current: "float | None" = None, total: "float | None" = None, percent: "float | None" = None, message: "str | None" = None, payload: "dict[str, Any] | None" = None, channels: "Sequence[str] | None" = None, scope_key: "str | None" = None, actor: "QueueEventActor | None" = None, entity: "QueueEventEntityRef | None" = None, immediate: "bool" = False, ) -> "None": """Publish progress through the currently bound task context.""" await require_current_task_context().progress( current=current, total=total, percent=percent, message=message, payload=payload, channels=channels, scope_key=scope_key, actor=actor, entity=entity, immediate=immediate, )
[docs] async def publish_task_log( message: "str", *, level: "str" = "info", payload: "dict[str, Any] | None" = None, channels: "Sequence[str] | None" = None, scope_key: "str | None" = None, actor: "QueueEventActor | None" = None, entity: "QueueEventEntityRef | None" = None, immediate: "bool" = False, ) -> "None": """Publish a log event through the currently bound task context.""" await require_current_task_context().log( message, level=level, payload=payload, channels=channels, scope_key=scope_key, actor=actor, entity=entity, immediate=immediate, )
[docs] async def publish_task_event( event_type: "str", *, message: "str | None" = None, payload: "dict[str, Any] | None" = None, channels: "Sequence[str] | None" = None, scope_key: "str | None" = None, actor: "QueueEventActor | None" = None, entity: "QueueEventEntityRef | None" = None, immediate: "bool" = False, ) -> "None": """Publish a custom event through the currently bound task context.""" await require_current_task_context().event( event_type, message=message, payload=payload, channels=channels, scope_key=scope_key, actor=actor, entity=entity, immediate=immediate, )
[docs] def beat(detail: "str | None" = None) -> "None": """Record progress through the currently bound task context, if any.""" context = get_current_task_context() if context is not None: context.beat(detail)
[docs] @contextmanager def bind_task_context(context: "TaskExecutionContext") -> "Iterator[TaskExecutionContext]": """Bind ``context`` as the current task execution context. This is the supported entry point for external runtimes adopting the events subpackage standalone. While bound, :func:`require_current_task_context` and the module-level publish helpers resolve to ``context``. Yields: The bound task execution context. """ _active_task_contexts[context.task_id] = context token = _current_task_context.set(context) try: yield context finally: _active_task_contexts.pop(context.task_id, None) _current_task_context.reset(token)
[docs] @contextmanager def bind_beat_sink(sink: "TaskBeatSink") -> "Iterator[TaskBeatSink]": """Bind ``sink`` to receive :meth:`TaskExecutionContext.beat` calls. Yields: The bound beat sink. """ token = _current_beat_sink.set(sink) try: yield sink finally: _current_beat_sink.reset(token)
def _cancel_task_context(task_id: "str") -> "None": context = _active_task_contexts.get(task_id) if context is not None: context.mark_cancelled()