Source code for litestar_queues.worker.worker

import asyncio
import contextlib
import logging
import os
import random
import time
from datetime import timedelta
from typing import TYPE_CHECKING

from litestar_queues.config import WorkerConfig, execution_backend_name, queue_backend_name
from litestar_queues.events import bind_beat_sink
from litestar_queues.events.context import _cancel_task_context
from litestar_queues.exceptions import QueueConfigurationError
from litestar_queues.worker.heartbeat import WorkerHeartbeatManager

if TYPE_CHECKING:
    from uuid import UUID

    from litestar_queues.models import QueuedTaskRecord
    from litestar_queues.service import QueueService

__all__ = ("Worker",)


def _clamp(value: "float", *, low: "float", high: "float") -> "float":
    """Clamp a value between inclusive bounds.

    Returns:
        ``value`` restricted to ``[low, high]``.
    """
    return max(low, min(high, value))


def _next_backoff_interval(current: "float", *, base: "float", maximum: "float", multiplier: "float") -> "float":
    """Compute the next deterministic adaptive-polling interval after an empty cycle.

    The stored exponential state itself is never jittered; only the sampled
    wait derived from it is (see :func:`_apply_jitter`).

    Returns:
        ``current * multiplier`` clamped to ``[base, maximum]``.
    """
    return _clamp(current * multiplier, low=base, high=maximum)


def _sample_symmetric_jitter() -> "float":
    """Return a uniform random value in ``[-1.0, 1.0]`` for jitter sampling.

    Returns:
        A pseudo-random value used only to perturb a sampled wait.
    """
    return random.uniform(-1.0, 1.0)  # noqa: S311 - jitter is not security sensitive.


def _apply_jitter(interval: "float", *, base: "float", maximum: "float", jitter: "float") -> "float":
    """Apply bounded symmetric jitter to a sampled wait, without mutating stored state.

    Returns:
        ``interval`` offset by up to ``interval * jitter``, clamped to ``[base, maximum]``.
    """
    if jitter <= 0.0:
        return interval
    offset = interval * jitter * _sample_symmetric_jitter()
    return _clamp(interval + offset, low=base, high=maximum)


[docs] class Worker: """Local in-process queue worker.""" __slots__ = ( "_batch_size", "_cancel_requested", "_cancellation_poll_interval", "_completion_event", "_current_poll_interval", "_expiry_check_interval", "_final_cancel_timeout", "_graceful_shutdown_timeout", "_heartbeat_manager", "_is_running", "_last_cancellation_check_at", "_last_expiry_check_at", "_last_reconcile_at", "_last_stale_check_at", "_listener_reconnect_pending", "_logger", "_max_concurrency", "_max_interruptions", "_poll_backoff_max", "_poll_backoff_multiplier", "_poll_interval", "_poll_jitter", "_queue_concurrency", "_queues", "_reconcile_interval", "_requeue_on_shutdown", "_running_tasks", "_service", "_stale_after", "_stale_check_interval", "_started_event", "_startup_completion", "_startup_error", "_stop_event", "_wakeup_started_at", "_worker_id", )
[docs] def __init__(self, service: "QueueService", config: "WorkerConfig | None" = None) -> "None": """Initialize the worker. Args: service: Queue service used to reach the configured backends. config: Worker runtime configuration; ``None`` uses defaults. """ worker_config = config or service.config.worker self._service = service self._logger = logging.getLogger(service.config.names.logger("worker")) self._batch_size = worker_config.batch_size self._cancellation_poll_interval = worker_config.cancellation_poll_interval self._poll_interval = worker_config.poll_interval self._poll_backoff_max = worker_config.poll_backoff_max self._poll_backoff_multiplier = worker_config.poll_backoff_multiplier self._poll_jitter = worker_config.poll_jitter self._current_poll_interval = worker_config.poll_interval self._max_concurrency = worker_config.max_concurrency self._queue_concurrency = dict(worker_config.queue_concurrency) self._reconcile_interval = worker_config.reconcile_interval self._requeue_on_shutdown = worker_config.requeue_on_shutdown self._max_interruptions = worker_config.max_interruptions self._expiry_check_interval = worker_config.expiry_check_interval self._stale_after = ( timedelta(seconds=worker_config.stale_after) if worker_config.stale_after is not None else None ) self._stale_check_interval = worker_config.stale_check_interval self._graceful_shutdown_timeout = worker_config.graceful_shutdown_timeout self._final_cancel_timeout = worker_config.final_cancel_timeout self._worker_id = worker_config.id if worker_config.id is not None else f"worker-{os.getpid()}" self._heartbeat_manager = WorkerHeartbeatManager( service, interval=worker_config.heartbeat_interval, miss_threshold=worker_config.heartbeat_miss_threshold, worker_id=self._worker_id, jitter_fraction=worker_config.heartbeat_jitter_fraction, on_claim_lost=self._cancel_claim_lost_task if worker_config.cancel_on_claim_loss else None, ) self._queues = worker_config.queues self._running_tasks: "dict[asyncio.Task[None], QueuedTaskRecord]" = {} self._cancel_requested: "set[asyncio.Task[None]]" = set() self._stop_event = asyncio.Event() self._completion_event = asyncio.Event() self._startup_completion: "asyncio.Future[BaseException | None] | None" = None self._started_event = asyncio.Event() self._startup_error: "BaseException | None" = None self._is_running = False self._last_reconcile_at = -float("inf") self._last_expiry_check_at = -float("inf") self._last_cancellation_check_at = -float("inf") self._last_stale_check_at = -float("inf") self._listener_reconnect_pending = False self._wakeup_started_at: "float | None" = None
@property def is_running(self) -> "bool": """Whether the worker loop is active.""" return self._is_running @property def worker_id(self) -> "str": """Worker identity used for events and logs.""" return self._worker_id
[docs] async def wait_started(self) -> "None": """Wait until heartbeat startup succeeds or propagate its failure.""" startup_completion = self._startup_completion if startup_completion is not None and startup_completion.done(): await asyncio.sleep(0) startup_completion = self._startup_completion if startup_completion is None: startup_completion = asyncio.get_running_loop().create_future() self._startup_completion = startup_completion startup_error = await asyncio.shield(startup_completion) if startup_error is not None: raise startup_error
def _prepare_startup(self) -> "asyncio.Future[BaseException | None]": """Create or adopt the completion shared by this startup generation.""" startup_completion = self._startup_completion if startup_completion is None or startup_completion.done(): startup_completion = asyncio.get_running_loop().create_future() self._startup_completion = startup_completion self._started_event.clear() self._startup_error = None return startup_completion def _complete_startup( self, startup_completion: "asyncio.Future[BaseException | None]", startup_error: "BaseException | None" ) -> "None": """Publish one startup generation's immutable outcome to its waiters.""" self._startup_error = startup_error self._started_event.set() startup_completion.set_result(startup_error) def _reset_poll_backoff(self) -> "None": """Reset the adaptive polling wait to the base interval. Called on worker start, any claimed record, a native notification, and a recoverable backend/listener exception. """ self._current_poll_interval = self._poll_interval def _advance_poll_backoff(self) -> "None": """Grow the adaptive polling wait after a fully empty poll/reconciliation cycle. A no-op while backoff is disabled (``poll_backoff_max`` is ``None``), preserving the fixed-interval path exactly. """ if self._poll_backoff_max is None: return self._current_poll_interval = _next_backoff_interval( self._current_poll_interval, base=self._poll_interval, maximum=self._poll_backoff_max, multiplier=self._poll_backoff_multiplier, ) async def _current_wait_timeout(self) -> "float": """Return the wait timeout for the next poll, with jitter applied when enabled. Jitter perturbs only the returned wait; :attr:`_current_poll_interval` (the stored exponential state) is never mutated by it. No random sampling occurs while backoff is disabled or jitter is zero. While backoff is enabled, the wait is additionally clamped to the backend's ``time_until_next_due()`` when known: no backend notifies a worker the instant a scheduled or retried record's due time arrives, so an uncapped backoff wait could otherwise sleep past already-known future work. This clamp never applies to the fixed (backoff-disabled) path, matching its exact prior behavior. Returns: The timeout, in seconds, to pass to ``wait_for_wakeups``. """ if self._poll_backoff_max is None: return self._current_poll_interval timeout = ( self._current_poll_interval if self._poll_jitter <= 0.0 else _apply_jitter( self._current_poll_interval, base=self._poll_interval, maximum=self._poll_backoff_max, jitter=self._poll_jitter, ) ) due_in = await self._service.get_queue_backend().time_until_next_due(queues=self._queues) if due_in is not None and due_in < timeout: timeout = due_in return timeout
[docs] async def start(self) -> "None": """Run the worker loop until stopped or cancelled.""" startup_completion = self._prepare_startup() self._is_running = True self._stop_event.clear() self._reset_poll_backoff() try: try: await self._heartbeat_manager.start() except BaseException as exc: self._complete_startup(startup_completion, exc) raise self._complete_startup(startup_completion, None) while not self._stop_event.is_set(): try: await self._maybe_expire_overdue() await self._maybe_requeue_stale() await self._maybe_reconcile_external() processed = await self.run_once() except asyncio.CancelledError: raise except Exception as exc: self._record_counter("litestar_queues.worker.loop.error", {"worker.error.type": type(exc).__name__}) self._logger.exception("Queue worker loop iteration failed", extra={"worker_id": self._worker_id}) self._reset_poll_backoff() await self._backoff_after_loop_error() continue if processed: self._reset_poll_backoff() continue outcome = await self._wait_for_work() if outcome: self._reset_poll_backoff() elif outcome is False: self._advance_poll_backoff() finally: self._stop_event.set() try: await self._drain_running() finally: try: await self._heartbeat_manager.aclose() finally: self._is_running = False
[docs] async def stop(self, *, force: "bool" = False) -> "bool": """Stop the worker loop and drain or cancel in-flight work. Returns: True when graceful drain escalated to cancellation. """ self._stop_event.set() if force: await self._cancel_running() return False return await self._drain_running()
[docs] async def run_once(self) -> "int": """Process one batch of due tasks. Returns: Number of claimed task records. Raises: QueueConfigurationError: If the execution backend schedules its own delivery, leaving a worker nothing it may safely claim. """ await self._maybe_cancel_running() execution_backend = self._service.get_execution_backend() if execution_backend.schedules_on_enqueue: msg = ( f"execution_backend={execution_backend_name(self._service.config.execution_backend)!r} " f"schedules delivery when a record is persisted, so a worker would dispatch it a " f"second time. Run this deployment with no worker process." ) raise QueueConfigurationError(msg) available = min(self._batch_size, max(0, self._max_concurrency - len(self._running_tasks))) if available <= 0: return 0 if execution_backend.is_external: records = await self._list_pending(limit=available) dispatched = await self._dispatch_external(records) if not dispatched: self._record_empty_poll() return dispatched claimed_records = await self._claim_available(limit=available) if not claimed_records: self._record_empty_poll() return 0 self._record_claimed(claimed_records) for record in claimed_records: self._track_execution(record) return len(claimed_records)
async def _claim_available(self, *, limit: "int") -> "list[QueuedTaskRecord]": execution_backend_name_ = execution_backend_name(self._service.config.execution_backend) active_by_queue: "dict[str, int]" = {} for record in self._running_tasks.values(): active_by_queue[record.queue] = active_by_queue.get(record.queue, 0) + 1 queue_limits = { queue: max(0, cap - active_by_queue.get(queue, 0)) for queue, cap in self._queue_concurrency.items() } claimed = await self._service.claim_tasks( limit=limit, queues=self._queues, execution_backend=execution_backend_name_, worker_id=self._worker_id, queue_limits=queue_limits or None, ) owned: "list[QueuedTaskRecord]" = [] backend = self._service.get_queue_backend() for record in claimed: assigned = await backend.assign_worker( record.id, worker_id=self._worker_id, expected_retry_count=record.retry_count ) if assigned is not None: owned.append(assigned) claimed = owned wakeup_started_at = self._wakeup_started_at self._wakeup_started_at = None if claimed and wakeup_started_at is not None: self._service.observability_runtime.record_duration( "litestar_queues.worker.wakeup_to_claim.duration", time.perf_counter() - wakeup_started_at, attributes=self._transport_metric_attributes(), ) return claimed async def _list_pending(self, *, limit: "int") -> "list[QueuedTaskRecord]": queue_backend = self._service.get_queue_backend() execution_backend_name_ = execution_backend_name(self._service.config.execution_backend) if not self._queues: return await queue_backend.list_pending(limit=limit, execution_backend=execution_backend_name_) records: 'list["QueuedTaskRecord"]' = [] seen: "set[object]" = set() for queue in self._queues: if len(records) >= limit: break queue_records = await queue_backend.list_pending( limit=limit - len(records), queue=queue, execution_backend=execution_backend_name_ ) for record in queue_records: if record.id in seen: continue seen.add(record.id) records.append(record) if len(records) >= limit: break return records def _track_execution(self, record: "QueuedTaskRecord") -> "asyncio.Task[None]": task = asyncio.create_task(self._execute_claimed(record)) self._running_tasks[task] = record task.add_done_callback(self._on_execution_done) return task def _on_execution_done(self, task: "asyncio.Task[None]") -> "None": self._running_tasks.pop(task, None) self._cancel_requested.discard(task) # Retrieve the outcome here rather than through a drain-time gather, so # a task that finishes outside a drain never trips asyncio's # "exception was never retrieved" reporting. if not task.cancelled(): error = task.exception() if error is not None: self._logger.error( "Queue task execution failed outside its own error handling", exc_info=error, extra={"worker_id": self._worker_id}, ) self._completion_event.set() def _cancel_claim_lost_task(self, task_id: "UUID") -> "None": """Cancel the local coroutine whose claim the heartbeat manager just lost. Deliberately a plain ``cancel()`` rather than the durable-cancel pair: claim loss is an ownership interruption, not a user cancel, so the attempt records ``interrupted`` telemetry and attempts no terminal write. The record already belongs to whichever worker took the claim. """ for task, record in self._running_tasks.items(): if record.id != task_id or task.done(): continue task.cancel() self._record_counter( "litestar_queues.worker.claim_lost_cancel", {"messaging.destination.name": record.queue} ) return async def _maybe_cancel_running(self) -> "None": if not self._running_tasks: return now = asyncio.get_running_loop().time() if now - self._last_cancellation_check_at < self._cancellation_poll_interval: return self._last_cancellation_check_at = now by_id = {record.id: task for task, record in self._running_tasks.items()} records = await self._service.get_queue_backend().get_tasks(tuple(by_id)) for record in records: if record.status != "cancelled": continue task = by_id.get(record.id) if task is None or task.done(): continue _cancel_task_context(str(record.id)) task.cancel()
[docs] async def reconcile_external(self, *, limit: "int | None" = None) -> "int": """Reconcile externally dispatched records by delegating to the service. The reconciliation contract (state transitions, unknown-backend skipping, and metrics) lives on :meth:`QueueService.reconcile_external`; the worker keeps only the periodic cadence and fleet lock. Returns: Number of records that reached a terminal queue status. """ return await self._service.reconcile_external(limit=limit)
async def _execute_claimed(self, record: "QueuedTaskRecord") -> "None": await self._heartbeat_manager.start() self._heartbeat_manager.register(record.id, expected_retry_count=record.retry_count) try: with bind_beat_sink(self._heartbeat_manager): should_interrupt = False try: await self._service.get_execution_backend().execute( self._service, record, worker_id=self._worker_id ) except asyncio.CancelledError: task_setting = record.metadata.get("requeue_on_shutdown") should_requeue = self._requeue_on_shutdown if task_setting is None else task_setting is True should_interrupt = self._stop_event.is_set() and should_requeue if not should_interrupt: raise if should_interrupt: try: updated = await self._service.interrupt_task( record, worker_id=self._worker_id, max_interruptions=self._max_interruptions ) except Exception: self._record_counter( "litestar_queues.worker.interrupt.error", {"messaging.destination.name": record.queue} ) self._logger.exception( "Queue task shutdown requeue failed; record remains running", extra={"worker_id": self._worker_id, "task_id": str(record.id)}, ) else: if updated is None: self._logger.warning( "Queue task shutdown requeue lost its fence", extra={"worker_id": self._worker_id, "task_id": str(record.id)}, ) raise asyncio.CancelledError finally: try: try: self._heartbeat_manager.unregister(record.id) except Exception as exc: # noqa: BLE001 - heartbeat cleanup must not skip backend clearing. self._record_heartbeat_failure(exc, "Queue task heartbeat cleanup failed") finally: await self._close_heartbeat_manager_if_idle() async def _dispatch_external(self, records: "list[QueuedTaskRecord]") -> "int": # The execution backend owns litestar_queues.execution.dispatch: it is # the only layer that can distinguish a fallback from an outright failure, # and a second emitter here would double-count every dispatch. execution_backend = self._service.get_execution_backend() dispatched = 0 for record in records: if record.execution_ref is not None: continue if record.is_expired: continue if await execution_backend.dispatch(self._service, record) is not None: dispatched += 1 return dispatched async def _maybe_requeue_stale(self) -> "None": if self._stale_after is None: return now = asyncio.get_running_loop().time() if now - self._last_stale_check_at < self._stale_check_interval: return self._last_stale_check_at = now if not await self._service.get_queue_backend().acquire_worker_lock( "stale_recovery", ttl=timedelta(seconds=max(self._stale_check_interval, 1.0)) ): return result = await self._service.recover_stale_tasks(stale_after=self._stale_after, worker_id=self._worker_id) # One bounded outcome label, each incremented by its own count. Recording the # counts as label *values* would mint a new time series per distinct tally. outcomes = ( ("requeued", result.requeued), ("failed", result.failed), ("skipped", result.skipped), ("handler_needed", result.handler_needed), ) for outcome, count in outcomes: if count: self._record_counter("litestar_queues.stale_recovery", {"queue.stale.outcome": outcome}, value=count) async def _maybe_expire_overdue(self) -> "None": interval = self._expiry_check_interval if interval is None: return now = asyncio.get_running_loop().time() if now - self._last_expiry_check_at < interval: return self._last_expiry_check_at = now if not await self._service.get_queue_backend().acquire_worker_lock( "expiry_sweep", ttl=timedelta(seconds=max(interval, 1.0)) ): return expired = await self._service.expire_overdue_tasks(worker_id=self._worker_id) if expired: self._record_counter("litestar_queues.expiry", {"queue.expiry.outcome": "expired"}, value=len(expired)) async def _maybe_reconcile_external(self) -> "None": # Check the local cadence before the fleet lock, matching the stale and # expiry passes. Taking the lock first made every worker loop iteration # write a coordination record only to discard it at the interval check. if self._reconcile_interval > 0: now = asyncio.get_running_loop().time() if now - self._last_reconcile_at < self._reconcile_interval: return self._last_reconcile_at = now if not await self._service.get_queue_backend().acquire_worker_lock( "external_reconcile", ttl=timedelta(seconds=max(self._reconcile_interval, 1.0)) ): return await self.reconcile_external(limit=self._batch_size) async def _drain_running(self) -> "bool": if not self._running_tasks: return False # `asyncio.wait`, not `wait_for(gather(...))`: a timed-out `wait_for` # cancels the gather, and that cancellation propagates into every # execution task as an unaccounted extra delivery. _, pending = await asyncio.wait(tuple(self._running_tasks), timeout=self._graceful_shutdown_timeout) if pending: await self._cancel_running() return True return False async def _cancel_running(self) -> "None": tasks = tuple(self._running_tasks) if not tasks: return # `stop()` and the loop's own shutdown drain both land here. Cancelling # a task twice delivers a second CancelledError into the task's own # shutdown-requeue handler and aborts the backend write mid-flight, so # each execution task is cancelled exactly once per worker. for task in tasks: if task not in self._cancel_requested: self._cancel_requested.add(task) task.cancel() await asyncio.wait(tasks, timeout=self._final_cancel_timeout) await self._hand_off_undead_tasks() async def _hand_off_undead_tasks(self) -> "None": """Null the heartbeats of tasks that outlived their cancellation budget. The records stay ``running`` and this worker no longer touches their heartbeats, so a stale sweep (``stale_after``) can reclaim them on its next pass instead of waiting out a full heartbeat age. """ survivors = [record for task, record in self._running_tasks.items() if not task.done()] if not survivors: return self._logger.warning( "Queue tasks survived cancellation; abandoning them to stale recovery", extra={"worker_id": self._worker_id, "task_ids": [str(record.id) for record in survivors]}, ) # `null_heartbeats` carries one fence value per call, so group the # survivors by the generation this worker still believes it owns. by_generation: "dict[int, list[UUID]]" = {} for record in survivors: by_generation.setdefault(record.retry_count, []).append(record.id) for expected_retry_count, task_ids in by_generation.items(): await self._null_heartbeats_quietly(task_ids, expected_retry_count=expected_retry_count) async def _null_heartbeats_quietly(self, task_ids: "list[UUID]", *, expected_retry_count: "int") -> "None": """Clear one generation's heartbeats, reporting rather than raising a backend failure.""" try: await self._service.get_queue_backend().null_heartbeats(task_ids, expected_retry_count=expected_retry_count) except Exception: self._logger.exception( "Nulling heartbeats for surviving queue tasks failed", extra={"worker_id": self._worker_id} ) async def _wait_for_work(self) -> "bool | None": """Wait for new work, a backend notification, or a stop signal. The adaptive polling wait (when enabled) is passed directly as the backend's wait timeout; no additional sleep follows a native wait. Returns: ``True`` when a native backend notification was observed (the caller resets the adaptive backoff to the base interval). ``False`` when the wait fully elapsed with no notification (the caller advances the backoff). ``None`` when the wait was interrupted by stop or a pending completion signal, in which case backoff state is left unchanged. """ if self._completion_event.is_set(): self._completion_event.clear() return None queue_backend = self._service.get_queue_backend() started_at = time.perf_counter() timeout = await self._current_wait_timeout() wait_attributes = {"queue.backend": self._queue_backend_name(), "worker.wait.kind": self._wait_kind()} self._service.observability_runtime.record_histogram( "litestar_queues.worker.poll.delay", timeout, unit="s", attributes=wait_attributes ) if self._listener_reconnect_pending: self._service.observability_runtime.record_counter( "litestar_queues.listener.reconnect", attributes=self._transport_metric_attributes() ) self._listener_reconnect_pending = False notification_task = asyncio.create_task(queue_backend.wait_for_wakeups(timeout=timeout)) control_task = asyncio.create_task( queue_backend.wait_for_worker_control(worker_id=self._worker_id, timeout=timeout) ) stop_task = asyncio.create_task(self._stop_event.wait()) completion_task = asyncio.create_task(self._completion_event.wait()) done, pending = await asyncio.wait( {notification_task, control_task, stop_task, completion_task}, return_when=asyncio.FIRST_COMPLETED ) self._completion_event.clear() for task in pending: task.cancel() with contextlib.suppress(asyncio.CancelledError): await task if control_task in done and await self._consume_control_task(control_task): # A saturated worker never reaches run_once's cadence check quickly # enough for the hint to matter, so the gate is reset before the pass. self._last_cancellation_check_at = -float("inf") await self._maybe_cancel_running() outcome: "bool | None" = None if notification_task in done: outcome = await self._consume_wait_task(notification_task) if outcome: self._wakeup_started_at = time.perf_counter() elapsed = time.perf_counter() - started_at self._service.observability_runtime.record_duration( "litestar_queues.worker.wait.duration", elapsed, attributes=wait_attributes ) self._service.observability_runtime.record_duration( "litestar_queues.worker.idle.duration", elapsed, attributes={**self._worker_metric_base_attributes(), "worker.wakeup": str(notification_task in done)}, ) return outcome async def _consume_wait_task(self, task: "asyncio.Task[bool]") -> "bool | None": try: result = task.result() except asyncio.TimeoutError: return False except asyncio.CancelledError: raise except Exception as exc: # A native read failure must not kill the run loop; durable polling # (run_once) still discovers work, and the next wait re-establishes # the listener from a clean state. The backoff resets so a stale # listener does not compound into a longer discovery delay. self._record_counter("litestar_queues.worker.loop.error", {"worker.error.type": type(exc).__name__}) self._service.observability_runtime.record_counter( "litestar_queues.listener.error", attributes={**self._transport_metric_attributes(), "queue.outcome": "read_failed"}, ) self._listener_reconnect_pending = True self._logger.exception("Queue worker loop iteration failed", extra={"worker_id": self._worker_id}) self._reset_poll_backoff() await self._backoff_after_loop_error() return None return result async def _consume_control_task(self, task: "asyncio.Task[bool]") -> "bool": """Return whether a worker-control hint arrived, containing read failures. Returns: True when a hint was observed and an immediate cancellation pass should run. """ try: return task.result() except asyncio.TimeoutError: return False except asyncio.CancelledError: raise except Exception as exc: # Control hints are lossy by contract: a failed read costs latency, # never correctness, because run_once still polls durable status. self._record_counter("litestar_queues.worker.loop.error", {"worker.error.type": type(exc).__name__}) self._service.observability_runtime.record_counter( "litestar_queues.listener.error", attributes={**self._transport_metric_attributes(), "queue.outcome": "control_read_failed"}, ) self._listener_reconnect_pending = True self._logger.exception("Queue worker loop iteration failed", extra={"worker_id": self._worker_id}) return False async def _backoff_after_loop_error(self) -> "None": timeout = min(max(self._poll_interval, 0.01), 1.0) with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(self._stop_event.wait(), timeout=timeout) async def _close_heartbeat_manager_if_idle(self) -> "None": if self._is_running or self._heartbeat_manager.has_registrations: return try: await self._heartbeat_manager.aclose() except Exception as exc: # noqa: BLE001 - heartbeat cleanup must not fail completed task execution. self._record_heartbeat_failure(exc, "Queue worker heartbeat manager close failed") def _record_heartbeat_failure(self, exc: "Exception", message: "str") -> "None": # Delegated so both emitters of litestar_queues.heartbeat.failure agree # on one label set; divergent label names break the Prometheus collector. self._heartbeat_manager.record_failure(exc, message) def _record_claimed(self, records: "list[QueuedTaskRecord]") -> "None": counts: "dict[str, int]" = {} for record in records: counts[record.queue] = counts.get(record.queue, 0) + 1 for queue, count in counts.items(): self._record_counter("litestar_queues.worker.claim", {"messaging.destination.name": queue}, value=count) def _record_empty_poll(self) -> "None": self._service.observability_runtime.record_counter( "litestar_queues.worker.poll.empty", attributes={"queue.backend": self._queue_backend_name()} ) def _queue_backend_name(self) -> "str": return queue_backend_name(self._service.config.queue_backend) def _transport_metric_attributes(self) -> "dict[str, str]": capabilities = self._service.get_queue_backend().capabilities return { "queue.backend": self._queue_backend_name(), "queue.transport": capabilities.wakeup_backend or "polling", } def _wait_kind(self) -> "str": return "native" if self._service.get_queue_backend().capabilities.supports_worker_wakeups else "polling" def _record_counter(self, name: "str", attributes: "dict[str, str]", *, value: "int" = 1) -> "None": self._service.observability_runtime.record_counter( name, value, attributes={**self._worker_metric_base_attributes(), **attributes} ) def _worker_metric_base_attributes(self) -> "dict[str, str]": return {"queue.execution.backend": execution_backend_name(self._service.config.execution_backend)}