import asyncio
import contextlib
import logging
import os
from contextlib import asynccontextmanager, contextmanager
from typing import TYPE_CHECKING
from litestar.channels import ChannelsPlugin
from litestar.plugins import CLIPlugin, InitPlugin
from litestar_queues.config import MigrationConfiguringBackend, QueueConfig, execution_backend_name, queue_backend_name
from litestar_queues.exceptions import QueueConfigurationError
from litestar_queues.service import QueueService
from litestar_queues.task import load_task_modules, set_default_service
from litestar_queues.worker import Worker
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Generator, Iterable
from contextlib import AbstractContextManager
from click import Group as ClickGroup
from litestar import Litestar
from litestar.config.app import AppConfig
from litestar.datastructures import State
from litestar_queues.backends import BaseQueueBackend
from litestar_queues.events import QueueEventPublisher
from litestar_queues.events.typing import ChannelsLike
__all__ = ("QueuePlugin",)
_UNKNOWN = object()
_APP_PATH_ENV_VAR = "LITESTAR_APP"
_CLOUD_TASKS_BACKEND = "cloudtasks"
_PROCESS_LOCAL_CHANNELS_BACKENDS = frozenset({"MemoryChannelsBackend"})
_MISSING_SERVER_CONTEXT_ERROR = (
"WorkerConfig(placement='server') requires the Litestar CLI server lifecycle. Start the "
"application with 'litestar run', or choose placement='asgi' to run one worker inside each "
"ASGI process, or placement='external' to run 'litestar queues run' separately."
)
_PROCESS_LOCAL_CHANNELS_ERROR = (
"WorkerConfig(placement='server') runs the worker in its own process, but live event "
"delivery is configured against a process-local Channels backend ({backend}). Events "
"published by the worker would never reach this application's subscribers. Use a "
"cross-process Channels backend (for example RedisChannelsStreamBackend), or choose "
"placement='asgi' to keep the worker in this process."
)
_MISSING_APP_PATH_ERROR = (
"WorkerConfig(placement='server') loads a fresh application in the worker process, which "
"requires an explicit app path. Start with 'litestar --app module:app run' or set the "
"LITESTAR_APP environment variable; application autodiscovery is not supported for server "
"placement."
)
def _find_registered_channels_plugin(plugins: "Iterable[object]") -> "ChannelsLike | None":
return next((plugin for plugin in plugins if isinstance(plugin, ChannelsPlugin)), None)
def _process_local_channels_backend(channels: "object | None") -> "str | None":
"""Return the backend class name when it cannot cross a process boundary.
Matched by name, like the other Channels checks here, so this never imports
a Channels backend module that the application did not choose itself.
Returns:
The offending backend class name, or ``None`` when events can be shared.
"""
backend = getattr(channels, "_backend", None)
name = type(backend).__name__ if backend is not None else None
return name if name in _PROCESS_LOCAL_CHANNELS_BACKENDS else None
[docs]
class QueuePlugin(InitPlugin, CLIPlugin):
"""Litestar plugin for queue service dependency registration and lifecycle.
Inheriting the concrete :class:`~litestar.plugins.CLIPlugin` is what makes
Litestar register :meth:`server_lifespan`; satisfying ``CLIPluginProtocol``
structurally is not enough.
"""
__slots__ = (
"_auto_channels_backend",
"_config",
"_event_publisher",
"_logger",
"_queue_backend",
"_service",
"_worker",
"_worker_task",
)
[docs]
def __init__(self, config: "QueueConfig | None" = None) -> "None":
"""Initialize the queue plugin."""
self._config = config or QueueConfig()
self._logger = logging.getLogger(self._config.names.logger("plugin"))
self._service: "QueueService | None" = None
self._queue_backend: "BaseQueueBackend | None" = None
self._event_publisher: "QueueEventPublisher | None" = None
self._auto_channels_backend: "ChannelsLike | None" = None
self._worker: "Worker | None" = None
self._worker_task: "asyncio.Task[None] | None" = None
@property
def config(self) -> "QueueConfig":
"""Plugin configuration."""
return self._config
[docs]
def get_service(self, state: "State | None" = None) -> "QueueService":
"""Return a QueueService for this plugin."""
if self._service is not None:
return self._service
return QueueService(self._config, queue_backend=self._queue_backend, event_publisher=self._event_publisher)
[docs]
def create_worker_service(self) -> "QueueService":
"""Create a fresh service that owns its process-local event resources."""
return QueueService(
self._config,
queue_backend=self._config.get_queue_backend(),
event_publisher=self._config.get_event_publisher(
channels_backend=self._auto_channels_backend, manage_channels_lifecycle=True
),
)
def _configure_backend_migrations(self) -> "None":
"""Let the configured backend register whatever migrations it owns."""
backend_config = self._config.queue_backend
if isinstance(backend_config, MigrationConfiguringBackend):
backend_config.configure_migrations(self._config)
[docs]
def on_app_init(self, app_config: "AppConfig") -> "AppConfig":
"""Register queue dependencies, signature namespace, state, and the lifespan manager.
Returns:
The updated application configuration.
"""
self._configure_backend_migrations()
self._queue_backend = self._config.get_queue_backend()
event_config = self._config.events
if (
event_config is not None
and (event_config.delivery is not None or event_config.stream is not None)
and event_config.channels is None
):
# Zero-wiring live delivery or streaming without explicit channels resolves
# the app's registered ChannelsPlugin. The config
# object is never mutated so a QueueConfig shared across apps cannot leak
# one app's ChannelsPlugin into another's publisher.
self._auto_channels_backend = _find_registered_channels_plugin(app_config.plugins)
self._event_publisher = self._config.get_event_publisher(channels_backend=self._auto_channels_backend)
app_config.dependencies.update(self._config.dependencies)
app_config.signature_namespace.update(self._config.signature_namespace)
state = {
self._config.service_state_key: self._config,
self._config.event_publisher_state_key: self._event_publisher,
}
if self._config.events is not None and self._effective_channels_backend() is not None:
state[self._config.event_channels_state_key] = self._effective_channels_backend()
stream_config = self._config.events.stream if self._config.events is not None else None
if stream_config is not None:
from litestar_queues.events.streaming import _build_stream_router
self._verify_stream_channels_source(app_config)
if (
not app_config.guards
and not stream_config.guards
and stream_config.channel_authorizer is None
and stream_config.unauthenticated_access != "allow"
):
message = (
"Queue event streams have no configured authorization. Set a guard or channel_authorizer, "
"or explicitly set unauthenticated_access='allow'. See docs/usage/event-streams.rst."
)
if stream_config.unauthenticated_access == "error":
raise QueueConfigurationError(message)
self._logger.warning(message)
app_config.route_handlers.append(
_build_stream_router(self._config, stream_config, channels_backend=self._effective_channels_backend())
)
if execution_backend_name(self._config.execution_backend) == _CLOUD_TASKS_BACKEND:
# Imported here so an ordinary application never loads the delivery
# route, its request type, or anything the Cloud Tasks extra brings.
from litestar_queues.execution.cloudtasks.routes import build_cloud_tasks_route
app_config.route_handlers.append(build_cloud_tasks_route(self._config))
app_config.state.update(state)
# Register lifecycle as a lifespan context manager (not on_startup/on_shutdown
# hooks): Litestar runs on_shutdown hooks AFTER exiting every lifespan manager,
# so a hook-based worker drain would flush events into an already-closed
# ChannelsPlugin backend. As a lifespan manager appended after channels, exit is
# LIFO, so the worker drains before channels tears down.
app_config.lifespan.append(self._lifespan)
return app_config
def _effective_channels_backend(self) -> "ChannelsLike | None":
if self._config.events is not None and self._config.events.channels is not None:
return self._config.events.channels
return self._auto_channels_backend
def _validate_channels_shutdown_order(self, app: "Litestar") -> "None":
"""Fail fast at startup when a live-sink ChannelsPlugin is registered after this plugin.
Litestar exits lifespan managers in LIFO order, so a ChannelsPlugin listed after
``QueuePlugin`` tears its backend down before the queue worker drains and every
event published during the graceful-drain window hits a dead sink. Registration
order cannot be fixed from ``on_app_init`` (a later ChannelsPlugin has not
appended its lifespan manager yet), so misordering is rejected here instead.
Only ASGI placement has a drain to order. Server and external workers
publish events from their own process and lifecycle, so their teardown
is unaffected by this application's plugin registration order.
Raises:
QueueConfigurationError: If the ChannelsPlugin targeted by the live event
sink is registered after this plugin.
"""
if self._config.worker.placement != "asgi":
return
event_config = self._config.events
if event_config is None or event_config.delivery is None or event_config.delivery.sinks:
return
target = self._effective_channels_backend()
if target is None:
return
try:
registered: "object | None" = app.plugins.get(ChannelsPlugin)
except KeyError:
# PluginRegistry.get keys by exact type; fall back for ChannelsPlugin subclasses.
registered = _find_registered_channels_plugin(app.plugins)
if registered is None or registered is not target:
return
# ChannelsPlugin._on_startup creates _pub_queue; it is None before startup and
# after shutdown. A missing attribute (renamed litestar internals) degrades to
# "unknown" and skips validation rather than crashing.
if getattr(registered, "_pub_queue", _UNKNOWN) is not None:
return
msg = (
"ChannelsPlugin must be registered before QueuePlugin so the queue worker "
"drains before the channels backend closes on shutdown: "
"plugins=[channels, QueuePlugin(config)]"
)
raise QueueConfigurationError(msg)
def _verify_stream_channels_source(self, app_config: "AppConfig") -> "None":
source: "object | None" = None
if self._config.events is not None:
source = self._config.events.channels
if source is None:
source = next((plugin for plugin in app_config.plugins if type(plugin).__name__ == "ChannelsPlugin"), None)
if source is None or type(source).__name__ != "ChannelsPlugin":
return
if getattr(source, "_arbitrary_channels_allowed", False):
return
msg = (
"Queue event streaming requires a ChannelsPlugin created with "
"arbitrary_channels_allowed=True because queue channel names are dynamic "
"(litestar_queues:task:<id>:events, ...). Reconstruct the plugin as "
"ChannelsPlugin(backend=..., arbitrary_channels_allowed=True)."
)
raise QueueConfigurationError(msg)
[docs]
def on_cli_init(self, cli: "ClickGroup") -> "None":
"""Attach the ``queues`` subcommand group to the Litestar CLI.
Args:
cli: The root ``click.Group`` of the Litestar CLI.
"""
from litestar_queues._cli import register
self._configure_backend_migrations()
register(cli)
def _validate_server_placement(self) -> "None":
"""Reject a server invocation that cannot own a fresh worker process.
This runs before any marker, database, or child process exists, so a
misconfigured launch fails without leaving anything behind.
Raises:
QueueConfigurationError: If storage is process-local, execution is
inline, or no explicit application path was selected.
"""
backend = queue_backend_name(self._config.queue_backend)
if backend == "memory":
msg = (
"queue_backend='memory' is process-local and cannot be shared with a server-owned "
"worker process. Use the default queue_backend='ephemeral' or a persistent backend."
)
raise QueueConfigurationError(msg)
if execution_backend_name(self._config.execution_backend) == "immediate":
msg = (
"execution_backend='immediate' runs tasks inline at enqueue time, so a server-owned "
"worker would have nothing to claim. Use execution_backend='local'."
)
raise QueueConfigurationError(msg)
if not os.environ.get(_APP_PATH_ENV_VAR):
raise QueueConfigurationError(_MISSING_APP_PATH_ERROR)
events = self._config.events
if events is not None and events.delivery is not None:
backend_name = _process_local_channels_backend(self._effective_channels_backend())
if backend_name is not None:
raise QueueConfigurationError(_PROCESS_LOCAL_CHANNELS_ERROR.format(backend=backend_name))
def _storage_context(self, nonce: "str") -> "AbstractContextManager[object]":
"""Return the storage lifecycle this invocation owns.
Returns:
The private ephemeral database context, or a null context when the
configured backend already persists outside this invocation.
"""
if queue_backend_name(self._config.queue_backend) != "ephemeral":
return contextlib.nullcontext()
from litestar_queues.backends.ephemeral.server import EphemeralServerContext
return EphemeralServerContext(nonce=nonce, namespace=self._config.names)
[docs]
@contextmanager
def server_lifespan(self, app: "Litestar") -> "Generator[None]":
"""Own exactly one queue worker for the lifetime of a ``litestar run`` invocation.
Litestar enters this once, around the whole server command, for both its
direct Uvicorn call and its multi-worker/reload subprocess path. Any
alternative run-command plugin enters the same hook, so there is
deliberately no server-specific detection or flag parsing here.
Yields:
None: with the invocation marker, storage, and worker child active.
"""
del app
if self._config.worker.placement != "server":
yield
return
self._validate_server_placement()
from litestar_queues.worker import supervisor
from litestar_queues.worker.invocation import console_break_unwinds, server_context
with console_break_unwinds(), server_context(self._config.names) as nonce, self._storage_context(nonce):
server_worker = supervisor.ServerWorkerSupervisor.from_plugin(self)
server_worker.start()
try:
yield
finally:
server_worker.close()
@asynccontextmanager
async def _lifespan(self, app: "Litestar") -> "AsyncIterator[None]":
placement = self._config.worker.placement
if placement == "server":
# Fail before the service opens so a raw ASGI launch never accepts
# traffic against a queue whose worker was never started.
from litestar_queues.worker.invocation import server_context_active
if not server_context_active(self._config.names):
raise QueueConfigurationError(_MISSING_SERVER_CONTEXT_ERROR)
self._validate_channels_shutdown_order(app)
if self._config.task_modules:
load_task_modules(self._config.task_modules)
observability_runtime = None
observability_config = self._config.observability
if observability_config is not None:
from litestar_queues.observability import create_observability_runtime
observability_runtime = create_observability_runtime(
observability_config, app=app, namespace=self._config.names
)
if observability_runtime is not None:
app.state[self._config.observability_runtime_state_key] = observability_runtime
self._service = QueueService(
self._config,
queue_backend=self._queue_backend,
event_publisher=self._event_publisher,
observability_runtime=observability_runtime,
)
await self._service.open()
set_default_service(self._service)
app.state[self._config.service_state_key] = self._service
app.state[self._config.event_publisher_state_key] = self._service.get_event_publisher()
effective_channels = self._effective_channels_backend()
if self._config.events is not None and effective_channels is not None:
app.state[self._config.event_channels_state_key] = effective_channels
# Schedules belong to whichever process owns a worker, so an enqueue-only
# ASGI process never writes schedule records: server and external
# placements initialize them from that owner instead. A transport that
# schedules its own deliveries has no such owner anywhere, so this
# process writes the first occurrence and hands it over -- and still
# starts no worker. Instances racing each other are settled by the
# schedule key: the first record written is the one they all keep.
owns_schedules = placement == "asgi" or self._service.get_execution_backend().schedules_on_enqueue
if owns_schedules and self._config.initialize_schedules:
await self._service.initialize_schedules()
if placement == "asgi":
self._worker = Worker(self._service, self._config.worker)
self._worker_task = asyncio.create_task(self._worker.start())
self._worker_task.add_done_callback(self._log_worker_task_result)
await asyncio.sleep(0)
app.state[self._config.worker_state_key] = self._worker
try:
yield
finally:
if self._worker is not None:
await self._worker.stop()
if self._worker_task is not None:
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._worker_task
self._worker_task = None
if self._service is not None:
set_default_service(None)
await self._service.close()
self._service = None
def _log_worker_task_result(self, task: "asyncio.Task[None]") -> "None":
if task.cancelled():
return
exception = task.exception()
if exception is None:
return
self._logger.error(
"In-app queue worker stopped unexpectedly", exc_info=(type(exception), exception, exception.__traceback__)
)