Source code for litestar_queues.consumer

"""Public, framework-agnostic consumer API for external execution backends.

``run_task`` / ``consume_one`` / ``TaskExitCode`` are the programmatic twin of
``litestar queues run-task``: run one queued record by id on any external
executor (a Cloud Run Job, a serverless handler, a custom runner) and exit with
a deterministic code. Click-free on purpose so broker consumers and in-process
handlers can import them without pulling ``click`` into the module graph (see
test_plugin_lifecycle import boundary).
"""

import asyncio
import contextlib
import logging
import os
from enum import IntEnum
from importlib import import_module
from typing import TYPE_CHECKING, cast
from uuid import UUID

from litestar_queues._environment import CONFIG_FACTORY_ENV, TASK_ID_ENV
from litestar_queues.config import QueueConfig
from litestar_queues.events import bind_beat_sink
from litestar_queues.exceptions import QueueConfigurationError
from litestar_queues.models import HeartbeatTouch
from litestar_queues.service import QueueService
from litestar_queues.task import load_task_modules
from litestar_queues.worker.heartbeat import SingleTaskBeatSink

if TYPE_CHECKING:
    from collections.abc import AsyncIterator, Callable, Mapping
    from contextlib import AbstractAsyncContextManager

    from litestar_queues.models import QueuedTaskRecord

    ServiceFactory = Callable[[], QueueConfig | QueueService | AbstractAsyncContextManager[QueueService]]

__all__ = ("TaskExitCode", "consume_one", "run_task")
logger = logging.getLogger("queues.consumer")

_TASK_ID_ENV_SUFFIX = "TASK_ID"


[docs] class TaskExitCode(IntEnum): """Deterministic external-consumer process exit codes. ``CANCELLED`` reports a record whose durable execution result is ``cancelled``. A consumer whose own caller was cancelled reports nothing at all: the cancellation propagates instead, because the record is still running and no outcome has been reached. """ SUCCESS = 0 FAILURE = 1 MISSING_TASK_ID = 2 INVALID_TASK_ID = 3 MISSING_RECORD = 4 UNKNOWN_TASK = 5 CLAIM_LOST = 6 CANCELLED = 7 MISSING_CONFIG_FACTORY = 8
[docs] async def consume_one( queue: "QueueService", task_id: "UUID", *, expected_retry_count: "int | None" = None, expected_execution_ref: "str | None" = None, ) -> "TaskExitCode": """Claim, execute, and report one queued record identified by its id. The live record in the queue backend is authoritative; the id only locates it. Redelivery is fenced by the live ``expected_retry_count`` at claim time. The claim is taken before the task name is resolved, because retiring a record this process cannot run is itself a write, and a persistent backend only accepts one over a record that is running and owned. Returns: A deterministic task exit code. """ record = await queue.get_task(task_id) if record is None: return TaskExitCode.MISSING_RECORD claimed, expired = await queue.claim_task( record.id, expected_retry_count=expected_retry_count, expected_execution_ref=expected_execution_ref ) if expired is not None: return TaskExitCode.CLAIM_LOST if claimed is None: await queue.publish_claim_lost(record, phase="claim") return TaskExitCode.CLAIM_LOST task_registered = True try: queue.resolve_task(claimed.task_name) except KeyError: task_registered = False if not task_registered: return await _retire_unresolvable_record(queue, claimed) return await _execute_claimed_record(queue, claimed)
async def _retire_unresolvable_record(queue: "QueueService", claimed: "QueuedTaskRecord") -> "TaskExitCode": """Fail a claimed record whose task name is not registered in this process. Redelivery cannot teach this process a name it does not have, so leaving the record active would strand it: on a self-dispatching queue there is no worker that will come back for it. Returns: ``UNKNOWN_TASK``, or ``CLAIM_LOST`` when the attempt moved on first. """ updated = await queue.get_queue_backend().fail_task( claimed.id, f"Unknown queue task: {claimed.task_name!r}", retry=False, expected_retry_count=claimed.retry_count ) if updated is None: await queue.publish_claim_lost(claimed, phase="unknown_task", expected_retry_count=claimed.retry_count) return TaskExitCode.CLAIM_LOST return TaskExitCode.UNKNOWN_TASK
[docs] async def run_task( *, config: "QueueConfig | None" = None, service: "QueueService | None" = None, service_factory: "ServiceFactory | None" = None, task_id: "str | None" = None, config_factory: "str | None" = None, task_modules: "str | None" = None, env: "Mapping[str, str] | None" = None, ) -> "TaskExitCode": """Resolve a service and run one queued task by id. The prefix-aware environment is the default source for every input; the override arguments take precedence over it. ``config_factory`` replaces the ``CONFIG_FACTORY`` env var, ``task_id`` replaces the ``TASK_ID`` value, and ``task_modules`` replaces ``TASK_MODULES``. Returns: A deterministic task exit code. """ environ = env or os.environ if config_factory is not None: service_factory = _import_factory(config_factory) has_task_id_override = task_id is not None if _requires_config_factory( config=config, service=service, service_factory=service_factory ) and not _has_config_factory(config, environ): if not has_task_id_override and not environ.get(_env_name(config, _TASK_ID_ENV_SUFFIX)): return TaskExitCode.MISSING_TASK_ID _runtime_logger(config=config, service=service).error("External consumer process missing CONFIG_FACTORY") return TaskExitCode.MISSING_CONFIG_FACTORY async with contextlib.AsyncExitStack() as stack: try: queue = await stack.enter_async_context( _provide_service(config=config, service=service, service_factory=service_factory, env=environ) ) except QueueConfigurationError: # Process-local storage cannot be reached from a separate consumer # process. That is a configuration fault, not a missing factory. _runtime_logger(config=config, service=service).exception( "External consumer process cannot attach to the configured queue backend" ) return TaskExitCode.MISSING_CONFIG_FACTORY except Exception: if _requires_config_factory(config=config, service=service, service_factory=service_factory): _runtime_logger(config=config, service=service).exception( "External consumer process could not load CONFIG_FACTORY" ) return TaskExitCode.MISSING_CONFIG_FACTORY raise _load_configured_task_modules(queue.config, environ, override=task_modules) return await _resolve_and_consume(queue, environ, task_id=task_id)
async def _resolve_and_consume( queue: "QueueService", env: "Mapping[str, str]", *, task_id: "str | None" ) -> "TaskExitCode": raw = task_id if task_id is not None else env.get(_env_name(queue.config, _TASK_ID_ENV_SUFFIX)) if not raw: return TaskExitCode.MISSING_TASK_ID try: record_id = UUID(raw) except ValueError: return TaskExitCode.INVALID_TASK_ID return await consume_one(queue, record_id) async def _execute_claimed_record(queue: "QueueService", claimed: "QueuedTaskRecord") -> "TaskExitCode": expected_retry_count = claimed.retry_count beat_sink = SingleTaskBeatSink(claimed.id) with bind_beat_sink(beat_sink): heartbeat_task = asyncio.create_task( _heartbeat_loop(queue, claimed.id, expected_retry_count=expected_retry_count, beat_sink=beat_sink) ) execution_task = asyncio.create_task(queue.execute_record(claimed)) try: done, _pending = await asyncio.wait({heartbeat_task, execution_task}, return_when=asyncio.FIRST_COMPLETED) if heartbeat_task in done and not heartbeat_task.result(): execution_task.cancel() with contextlib.suppress(asyncio.CancelledError): await execution_task await queue.publish_claim_lost(claimed, phase="heartbeat", expected_retry_count=expected_retry_count) return TaskExitCode.CLAIM_LOST updated = await execution_task except asyncio.CancelledError: # The caller going away is not the queue deciding anything. The record # is still running and still owned, so stop the body, let the ``finally`` # clear the heartbeat that stale recovery reads, and let the # cancellation reach the caller rather than reporting an outcome it # could mistake for a settled one. execution_task.cancel() with contextlib.suppress(asyncio.CancelledError): await execution_task raise finally: heartbeat_task.cancel() with contextlib.suppress(asyncio.CancelledError): await heartbeat_task await queue.get_queue_backend().null_heartbeats([claimed.id], expected_retry_count=expected_retry_count) if updated.status == "completed": return TaskExitCode.SUCCESS if updated.status == "cancelled": return TaskExitCode.CANCELLED return TaskExitCode.FAILURE async def _heartbeat_loop( queue: "QueueService", task_id: "UUID", *, expected_retry_count: "int", beat_sink: "SingleTaskBeatSink" ) -> "bool": interval = queue.config.worker.heartbeat_interval while True: await asyncio.sleep(interval) detail = beat_sink.peek_detail() result = await queue.get_queue_backend().touch_heartbeats([ HeartbeatTouch( task_id=task_id, expected_retry_count=expected_retry_count, metadata_patch={"progress_detail": detail} if detail else None, ) ]) if task_id not in result.touched_task_ids: return False beat_sink.clear_detail() @contextlib.asynccontextmanager async def _provide_service( *, config: "QueueConfig | None", service: "QueueService | None", service_factory: "ServiceFactory | None", env: "Mapping[str, str]", ) -> "AsyncIterator[QueueService]": if service is not None: yield service return factory = service_factory or _load_config_factory(config, env) if factory is not None: provided = factory() if isinstance(provided, QueueConfig): async with QueueService(provided) as queue: yield queue return if isinstance(provided, QueueService): async with provided as queue: yield queue return async with provided as queue: yield queue return if config is None: msg = "External consumer process requires CONFIG_FACTORY when no QueueConfig or QueueService is provided." raise QueueConfigurationError(msg) async with QueueService(config) as queue: yield queue def _requires_config_factory( *, config: "QueueConfig | None", service: "QueueService | None", service_factory: "ServiceFactory | None" ) -> "bool": return config is None and service is None and service_factory is None def _has_config_factory(config: "QueueConfig | None", env: "Mapping[str, str]") -> "bool": del config return bool(env.get(CONFIG_FACTORY_ENV)) def _load_config_factory(config: "QueueConfig | None", env: "Mapping[str, str]") -> "ServiceFactory | None": del config import_path = env.get(CONFIG_FACTORY_ENV) if not import_path: return None return _import_factory(import_path) def _import_factory(import_path: "str") -> "ServiceFactory": module_path, separator, attribute = import_path.partition(":") if not separator: module_path, attribute = import_path.rsplit(".", 1) module = import_module(module_path) factory = getattr(module, attribute) if not callable(factory): msg = f"Consumer config factory {import_path!r} is not callable." raise TypeError(msg) return cast("ServiceFactory", factory) def _load_configured_task_modules( config: "QueueConfig", env: "Mapping[str, str]", *, override: "str | None" = None ) -> "None": modules = list(config.task_modules) extra = override if override is not None else env.get(_env_name(config, "TASK_MODULES")) if extra: modules.extend(module.strip() for module in extra.split(",") if module.strip()) if modules: load_task_modules(tuple(modules), force_reload=True) def _env_name(config: "QueueConfig | None", suffix: "str") -> "str": if suffix == _TASK_ID_ENV_SUFFIX: return TASK_ID_ENV raw_config = config.execution_backend if config is not None else None env_name = getattr(raw_config, "env_name", None) if callable(env_name): return str(env_name(suffix, namespace=cast("QueueConfig", config).names)) if config is not None: return config.names.environment(suffix.lower()) return f"QUEUES_{suffix}" def _runtime_logger(*, config: "QueueConfig | None" = None, service: "QueueService | None" = None) -> "logging.Logger": resolved_config = service.config if service is not None else config if resolved_config is None: return logger return logging.getLogger(resolved_config.names.logger("consumer"))