"""SQLSpec queue backend."""
import asyncio
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager, contextmanager, suppress
from datetime import datetime, timedelta, timezone
from inspect import isawaitable, iscoroutinefunction
from typing import TYPE_CHECKING, Any, cast, overload
from uuid import UUID, uuid4
from sqlspec import SQLSpec
from sqlspec.exceptions import SerializationConflictError
from sqlspec.extensions.events import normalize_event_channel_name, resolve_adapter_name
from sqlspec.utils.sync_tools import async_
from litestar_queues.backends._notification_wait import PendingNativeRead
from litestar_queues.backends.base import (
EXTERNAL_DISPATCH_RESERVATION_PREFIX,
STALE_HEARTBEAT_ERROR,
BaseQueueBackend,
attempts_consumed,
interruption_count,
is_external_dispatch_reservation,
record_matches_filters,
retry_schedule,
stale_requeue_error,
stale_requeue_priority,
)
from litestar_queues.backends.sqlspec.config import (
DEFAULT_CONTROL_CHANNEL,
DEFAULT_WAKEUP_CHANNEL,
SQLSpecBackendConfig,
)
from litestar_queues.backends.sqlspec.event_log import (
SQLSpecQueueEventLog,
create_event_log_store,
resolve_event_history_table_name,
)
from litestar_queues.backends.sqlspec.extension import QUEUE_EXTENSION_NAME
from litestar_queues.backends.sqlspec.maintenance import SQLSpecMaintenanceStore, create_maintenance_store
from litestar_queues.backends.sqlspec.reservation import create_task_reservation_store
from litestar_queues.backends.sqlspec.schema import (
DEFAULT_TABLE_NAME,
resolve_column_map,
validate_native_json_columns,
validate_table_name,
)
from litestar_queues.backends.sqlspec.stores.factory import create_queue_store
from litestar_queues.events import validate_event_history_extra_columns
from litestar_queues.exceptions import QueueConfigurationError
from litestar_queues.models import (
HeartbeatTouchResult,
QueueBackendCapabilities,
QueuedTaskRecord,
QueueStatistics,
StaleTaskRecoveryResult,
TaskRequest,
TaskReservation,
TaskStatus,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Generator, Iterator, Mapping, Sequence
from litestar_queues.backends.sqlspec._typing import (
SQLSpecConfig,
SQLSpecDriver,
SQLSpecManager,
SQLSpecSessionConfig,
SQLSpecStoreConfig,
)
from litestar_queues.backends.sqlspec.reservation import SQLSpecTaskReservationStore
from litestar_queues.backends.sqlspec.stores.base import SQLSpecQueueStore
from litestar_queues.config import QueueConfig
from litestar_queues.events import EventHistoryConfig, EventHistoryExtraColumn, QueueEventLog
from litestar_queues.models import HeartbeatTouch
__all__ = ("SQLSpecQueueBackend",)
_DUE_STATUSES = ("pending", "scheduled")
_RESERVE_MAX_ATTEMPTS = 12
_RESERVE_BACKOFF_SECONDS = 0.02
_DURABLE_WAKEUP_BACKENDS = frozenset({"aq", "notify_queue", "poll_queue", "txeventq"})
_EVENT_EXTENSION_NAME = "events"
_WAKEUP_TRANSPORT_POLLING = "polling"
_CANONICAL_EVENT_BACKENDS = frozenset({"aq", "notify", "notify_queue", "poll_queue", "txeventq"})
# Adapter families that can push worker wakeups. Every real Postgres driver
# (asyncpg, psycopg, psqlpy) ships SQLSpec's durable LISTEN/NOTIFY hybrid, so all
# three advertise ``notify_queue``. DuckDB is embedded with no LISTEN/NOTIFY, so
# it uses the durable table queue polled in-process. Everything else polls.
_WAKEUP_DURABLE_ADAPTERS = frozenset({"asyncpg", "psycopg", "psqlpy"})
_WAKEUP_TABLE_QUEUE_ADAPTERS = frozenset({"duckdb"})
# Durable event transports that ride the SQLSpec events queue table and must have
# it provisioned before a worker can publish or consume wakeups.
_EVENTS_TABLE_BACKENDS = frozenset({"notify_queue", "poll_queue"})
# Arrow ODBC exposes no portable rowcount API, and ADBC SQLite reports ``-1``
# for updates. Their cancellation path therefore verifies both the eligible
# before-image and the persisted after-image.
_UNRELIABLE_ROWCOUNT_ADAPTERS = frozenset({"adbc", "arrow_odbc"})
def _adapter_wakeup_transport(adapter_name: "str | None") -> "str":
"""Return the default wakeup transport for a SQLSpec adapter.
The wakeup transport is gated purely by adapter knowledge so backends only
advertise push wakeups where the driver can deliver them.
Returns:
``"notify_queue"`` for the Postgres drivers (asyncpg, psycopg, psqlpy),
``"poll_queue"`` for DuckDB, otherwise ``"polling"``.
"""
if adapter_name in _WAKEUP_DURABLE_ADAPTERS:
return "notify_queue"
if adapter_name in _WAKEUP_TABLE_QUEUE_ADAPTERS:
return "poll_queue"
return _WAKEUP_TRANSPORT_POLLING
def _canonical_wakeup_transport(backend_name: "str | None") -> "str | None":
"""Return a canonical SQLSpec event backend name.
Returns:
The canonical queue transport name, if the backend is supported.
"""
return backend_name if backend_name in _CANONICAL_EVENT_BACKENDS else None
[docs]
class SQLSpecQueueBackend(BaseQueueBackend):
"""SQLSpec-backed queue backend."""
__slots__ = (
"_column_map",
"_control_channel",
"_control_pending_read",
"_control_stream",
"_event_channel",
"_event_history_extra_columns",
"_event_history_table_name",
"_event_log",
"_event_log_store",
"_event_stream",
"_heartbeat_pool_config",
"_heartbeat_pool_enabled",
"_heartbeat_pool_registered",
"_heartbeat_sync_executor",
"_maintenance_store",
"_maintenance_table_name",
"_manage_schema",
"_native_json_columns",
"_native_observability_enabled",
"_opened",
"_owns_event_channel",
"_owns_sqlspec",
"_pending_read",
"_queue_table_name",
"_sqlcommenter_enabled",
"_sqlspec",
"_sqlspec_config",
"_store",
"_sync_executor",
"_task_reservation_store",
"_task_reservation_table_name",
"_wakeup_backend",
"_wakeup_channel",
"_wakeup_poll_interval",
"_wakeup_queue_table",
"_wakeup_settings",
"_wakeup_transport",
"_worker_wakeups_configured",
"_worker_wakeups_enabled",
)
[docs]
def __init__(
self, config: "QueueConfig | None" = None, *, backend_config: "SQLSpecBackendConfig | None" = None
) -> "None":
super().__init__(config=config)
backend_config = backend_config or SQLSpecBackendConfig()
self._column_map = resolve_column_map(backend_config.column_map)
self._native_json_columns = validate_native_json_columns(frozenset(backend_config.native_json_columns))
self._manage_schema = backend_config.manage_schema
self._sqlspec = backend_config.sqlspec
self._sqlspec_config: "SQLSpecConfig | SQLSpecStoreConfig | None" = backend_config.sqlspec_config
self._heartbeat_pool_config: "SQLSpecConfig | SQLSpecStoreConfig | None" = backend_config.heartbeat_pool_config
self._heartbeat_pool_enabled = self._heartbeat_pool_config is not None
self._heartbeat_pool_registered = False
self._owns_sqlspec = self._sqlspec is None
self._queue_table_name = (
validate_table_name(backend_config.queue_table_name)
if backend_config.queue_table_name is not None
else None
)
worker_wakeups = backend_config.worker_wakeups
self._worker_wakeups_configured = worker_wakeups is not None
self._event_channel = worker_wakeups.channel if worker_wakeups is not None else None
self._owns_event_channel = self._event_channel is None
self._wakeup_channel = worker_wakeups.channel_name if worker_wakeups is not None else None
if self._wakeup_channel is None and config is not None:
self._wakeup_channel = config.names.database_channel("tasks")
self._wakeup_transport = worker_wakeups.transport if worker_wakeups is not None else None
self._event_history_table_name = (
validate_table_name(backend_config.event_history_table_name)
if backend_config.event_history_table_name is not None
else None
)
self._event_history_extra_columns = validate_event_history_extra_columns(
backend_config.event_history_extra_columns
)
self._maintenance_table_name = (
validate_table_name(backend_config.maintenance_table_name)
if backend_config.maintenance_table_name is not None
else None
)
self._wakeup_queue_table = worker_wakeups.queue_table_name if worker_wakeups is not None else None
self._wakeup_poll_interval = worker_wakeups.poll_interval if worker_wakeups is not None else None
self._wakeup_settings = dict(worker_wakeups.settings) if worker_wakeups is not None else {}
self._native_observability_enabled = True
self._sqlcommenter_enabled = False
self._wakeup_backend = _canonical_wakeup_transport(getattr(self._event_channel, "_backend_name", None))
self._worker_wakeups_enabled = self._event_channel is not None
self._event_log_store: "Any | None" = None
self._event_log: "SQLSpecQueueEventLog | None" = None
self._maintenance_store: "SQLSpecMaintenanceStore | None" = None
self._store: "SQLSpecQueueStore | None" = None
self._task_reservation_store: "SQLSpecTaskReservationStore | None" = None
self._task_reservation_table_name = (
validate_table_name(backend_config.task_reservation_table_name)
if backend_config.task_reservation_table_name is not None
else None
)
self._event_stream: "Any | None" = None
self._pending_read = PendingNativeRead()
self._control_stream: "Any | None" = None
self._control_pending_read = PendingNativeRead()
self._control_channel = (
config.names.database_channel("worker_control") if config is not None else DEFAULT_CONTROL_CHANNEL
)
self._sync_executor: "ThreadPoolExecutor | None" = None
self._heartbeat_sync_executor: "ThreadPoolExecutor | None" = None
self._opened = False
[docs]
async def open(self) -> "bool":
"""Open SQLSpec resources.
Returns:
True when SQLSpec resources are ready.
"""
if self._opened:
return True
self._get_or_create_sqlspec()
self._resolve_queue_table_name()
self._apply_sqlcommenter()
self._configure_worker_wakeups()
self._register_heartbeat_pool()
sqlspec_config = self._get_sqlspec_config()
if not sqlspec_config.is_async:
if self._sync_executor is None:
self._sync_executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix=(
self.config.names.resource("sqlspec", "sync")
if self.config is not None
else "litestar-queues-sqlspec-sync"
),
)
if (
self._heartbeat_pool_enabled
and self._heartbeat_pool_config is not None
and self._heartbeat_sync_executor is None
):
self._heartbeat_sync_executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix=(
self.config.names.resource("sqlspec", "heartbeat-sync")
if self.config is not None
else "litestar-queues-sqlspec-heartbeat-sync"
),
)
self._opened = True
return True
[docs]
async def close(self) -> "None":
"""Close SQLSpec resources."""
if self._event_log is not None:
await self._event_log.flush_events()
await self._close_notification_stream()
await self._close_control_stream()
await self._close_heartbeat_pool()
if self._owns_event_channel and self._event_channel is not None:
await _invoke_event_channel_method(self._event_channel, "shutdown")
self._event_channel = None
if self._owns_sqlspec and self._sqlspec is not None:
await self._sqlspec.close_all_pools()
self._sqlspec = None
if self._sync_executor is not None:
self._sync_executor.shutdown(wait=True)
self._sync_executor = None
if self._heartbeat_sync_executor is not None:
self._heartbeat_sync_executor.shutdown(wait=True)
self._heartbeat_sync_executor = None
self._opened = False
[docs]
def get_event_log(self, config: "EventHistoryConfig") -> "QueueEventLog | None":
"""Return SQLSpec-managed durable queue event history when enabled."""
if self._event_log is None or (
config.extra_columns and getattr(self._event_log, "extra_columns", ()) != config.extra_columns
):
self._event_log = SQLSpecQueueEventLog(
session_factory=self._session,
datetime_serializer=self._serialize_datetime,
config=config,
store=self._get_event_log_store(extra_columns=config.extra_columns),
runtime_logger=self._logger,
)
return self._event_log
@property
def capabilities(self) -> "QueueBackendCapabilities":
"""Backend behavior capabilities."""
wakeup_backend = self._wakeup_backend
return QueueBackendCapabilities(
supports_worker_wakeups=self._worker_wakeups_enabled,
wakeup_backend=wakeup_backend,
wakeups_durable=wakeup_backend in _DURABLE_WAKEUP_BACKENDS,
supports_maintenance=True,
)
def _set_package_observability_enabled(self, enabled: "bool") -> "None":
"""Suppress SQLSpec queue-domain telemetry when package telemetry is active."""
self._native_observability_enabled = not enabled
def _set_sqlcommenter_enabled(self, enabled: "bool") -> "None":
"""Attach SQLCommenter attribution to queue statements when telemetry is active."""
self._sqlcommenter_enabled = enabled
def _apply_sqlcommenter(self) -> "None":
"""Turn on SQLCommenter attribution for the queue's SQLSpec config.
SQLSpec resolves ``traceparent`` from the current OpenTelemetry span, so the
comment carries the queue's producer or consumer span and joins database
telemetry to the task that issued the statement.
"""
if not self._sqlcommenter_enabled:
return
config = self._get_sqlspec_config()
statement_config = getattr(config, "statement_config", None)
replace = getattr(statement_config, "replace", None)
if replace is None or getattr(statement_config, "enable_sqlcommenter", False):
return
attributes = dict(getattr(statement_config, "sqlcommenter_attributes", None) or {})
attributes.setdefault(
"framework", self.config.names.resource() if self.config is not None else "litestar-queues"
)
config.statement_config = replace(
enable_sqlcommenter=True,
sqlcommenter_attributes=attributes,
sqlcommenter_enable_traceparent=True,
sqlcommenter_enable_context=True,
)
[docs]
async def create_schema(self) -> "None":
"""Create the SQLSpec queue table and indexes."""
if not self._manage_schema:
return
config = self._get_sqlspec_config()
queue_store = self._get_store()
stores = [queue_store, self._get_maintenance_store(), self._get_task_reservation_store()]
event_log_store = self._get_event_log_store_if_enabled()
if event_log_store is not None:
stores.append(event_log_store)
driver_stores: "list[SQLSpecQueueStore]" = []
for store in stores:
create_for_config = getattr(store, "create_schema_for_config", None)
if callable(create_for_config):
result = create_for_config(config)
if isawaitable(result):
await result
else:
driver_stores.append(store)
provision_events_queue = self._should_provision_events_queue()
if not driver_stores and not provision_events_queue:
return
async with self._session() as driver:
statements: "list[str]" = []
for store in driver_stores:
if store is queue_store:
statements.extend(await _create_schema_statements(store, driver))
else:
statements.extend(store.create_statements())
if provision_events_queue:
statements.extend(_events_queue_create_statements(config))
for statement in statements:
await driver.execute_script(statement)
await driver.commit()
[docs]
async def enqueue(
self,
task_name: "str",
*,
args: "tuple[Any, ...]" = (),
kwargs: "dict[str, Any] | None" = None,
queue: "str" = "default",
priority: "int" = 0,
max_retries: "int" = 0,
scheduled_at: "datetime | None" = None,
expires_at: "datetime | None" = None,
key: "str | None" = None,
execution_backend: "str" = "local",
execution_profile: "str | None" = None,
metadata: "dict[str, Any] | None" = None,
id: "UUID | None" = None, # noqa: A002
) -> "QueuedTaskRecord":
now = _utc_now()
record = QueuedTaskRecord(
task_name=task_name,
args=args,
kwargs=dict(kwargs or {}),
queue=queue,
execution_backend=execution_backend,
execution_profile=execution_profile,
status="scheduled" if scheduled_at is not None and scheduled_at > now else "pending",
priority=priority,
max_retries=max_retries,
scheduled_at=scheduled_at,
expires_at=expires_at,
key=key,
metadata=dict(metadata or {}),
created_at=now,
queued_at=now,
)
if id is not None:
record.id = id
if key is not None:
return await self._enqueue_keyed(record, key)
store = self._get_store()
if not type(store).supports_dml_returning:
return await self._enqueue_without_returning(record)
with self._observe_queue_operation("enqueue", queue=queue, task_name=task_name):
async with self._session() as driver:
await driver.execute(store.insert_returning_sql(), self._insert_params(record))
self._increment_queue_metric("enqueue")
await self.notify_new_task(record)
return record
def _insert_params(self, record: "QueuedTaskRecord") -> "dict[str, Any]":
return self._get_store().bulk_values([self._params_from_record(record)])[0]
async def _enqueue_keyed(self, record: "QueuedTaskRecord", key: "str") -> "QueuedTaskRecord":
with self._observe_queue_operation("enqueue", queue=record.queue, task_name=record.task_name):
async with self._session() as driver:
await driver.begin()
try:
existing_row = await self._select_task_by_key(driver, key)
if existing_row is not None:
existing = self._record_from_row(existing_row)
if not existing.is_terminal:
await driver.rollback()
return existing
await self._clear_key(driver, existing.id)
await driver.execute(self._get_store().insert_task(self._params_from_record(record)))
await driver.commit()
except Exception as exc:
with suppress(Exception):
await driver.rollback()
if _is_unique_violation(exc):
winner = await self.get_task_by_key(key)
if winner is not None and not winner.is_terminal:
return winner
raise
self._increment_queue_metric("enqueue")
await self.notify_new_task(record)
return record
async def _enqueue_without_returning(self, record: "QueuedTaskRecord") -> "QueuedTaskRecord":
with self._observe_queue_operation("enqueue", queue=record.queue, task_name=record.task_name):
async with self._session() as driver:
await driver.begin()
try:
await driver.execute(self._get_store().insert_task(self._params_from_record(record)))
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
self._increment_queue_metric("enqueue")
await self.notify_new_task(record)
return record
[docs]
async def enqueue_many(self, requests: "Sequence[TaskRequest]") -> "list[QueuedTaskRecord]":
"""Persist many tasks via the adapter's fastest bulk path.
Resolves existing deduplication keys in one round trip, then inserts the
remaining rows through the native Arrow ingest path
(:meth:`load_from_records`) when the adapter supports it, otherwise via a
batched ``execute_many``. Returns records in input order, with existing
non-terminal keyed tasks returned as-is (no duplicate insert) to match
the semantics of :meth:`enqueue`.
Returns:
Queue task records in the same order as ``requests``.
"""
if not requests:
return []
store = self._get_store()
now = _utc_now()
keyed = [request.key for request in requests if request.key is not None]
with self._observe_queue_operation("enqueue", task_count=len(requests)):
async with self._session() as driver:
await driver.begin()
try:
existing_by_key = await self._existing_records_by_key(driver, store, keyed)
results, to_insert, terminal_keys = self._plan_bulk_enqueue(requests, existing_by_key, now)
for task_id in terminal_keys:
await driver.execute(store.clear_key(task_id=str(task_id)))
if to_insert:
await self._bulk_insert(driver, store, to_insert)
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
self._increment_queue_metric("enqueue", float(len(to_insert)))
await self.notify_new_tasks(to_insert)
self._record_enqueue_batch(len(requests))
return results
[docs]
async def get_task(self, task_id: "UUID") -> "QueuedTaskRecord | None":
async with self._session() as driver:
row = await self._select_task(driver, task_id)
return self._record_from_row(row) if row is not None else None
[docs]
async def get_task_by_key(self, key: "str") -> "QueuedTaskRecord | None":
async with self._session() as driver:
row = await self._select_task_by_key(driver, key)
return self._record_from_row(row) if row is not None else None
[docs]
async def reserve_identity(self, key: "str", *, task_id: "UUID", task_name: "str") -> "TaskReservation | None":
"""Reserve a forever identity via an optimistic insert with a unique-violation fallback.
The reservation table's identity-key PRIMARY KEY is the atomicity arbiter:
exactly one concurrent insert wins; a loser catches the unique violation
and reads the winning owner. Serializable engines (CockroachDB) may abort
a losing transaction with a serialization/retry error before the unique
violation surfaces, so those are retried with bounded backoff. The
reservation table is separate from the queue table and terminal cleanup
never touches it.
Returns:
``None`` when this caller won the reservation; otherwise the existing
owner reservation.
"""
store = self._get_task_reservation_store()
values = {
"identity_key": key,
"task_id": str(task_id),
"task_name": task_name,
"created_at": self._serialize_datetime(_utc_now()),
}
last_exc: "Exception | None" = None
for attempt in range(_RESERVE_MAX_ATTEMPTS):
try:
return await self._reserve_identity_once(store, key, values)
except Exception as exc:
if _is_unique_violation(exc):
owner = await self.has_identity(key)
if owner is not None:
return owner
elif not _is_serialization_conflict(exc):
raise
last_exc = exc
await asyncio.sleep(_RESERVE_BACKOFF_SECONDS * (attempt + 1))
if last_exc is not None:
raise last_exc
return None
async def _reserve_identity_once(
self, store: "SQLSpecTaskReservationStore", key: "str", values: "dict[str, Any]"
) -> "TaskReservation | None":
async with self._session() as driver:
await driver.begin()
try:
existing = await self._select_one_row(driver, store.select_owner(key))
if existing is not None:
# Commit the read-only transaction rather than rolling back so
# single-connection engines (Spanner) do not double-finalize.
await driver.commit()
return _reservation_from_row(existing)
await driver.execute(store.insert_reservation(values))
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
return None
[docs]
async def has_identity(self, key: "str") -> "TaskReservation | None":
"""Return the reservation owning a reserved forever identity, if any."""
store = self._get_task_reservation_store()
async with self._session() as driver:
row = await self._select_one_row(driver, store.select_owner(key))
return _reservation_from_row(row) if row is not None else None
[docs]
async def reset_identity(self, key: "str", *, expected_task_id: "UUID | None" = None) -> "bool":
"""Delete a forever identity reservation via count-then-delete.
Returns:
``True`` when a reservation was removed.
"""
store = self._get_task_reservation_store()
serialized_task_id = str(expected_task_id) if expected_task_id is not None else None
async with self._session() as driver:
await driver.begin()
try:
count_row = await self._select_one_row(
driver, store.count_by_key(key, expected_task_id=serialized_task_id)
)
removed = int(count_row["reservation_count"]) if count_row is not None else 0
if removed > 0:
await driver.execute(store.delete_by_key(key, expected_task_id=serialized_task_id))
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
return removed > 0
[docs]
async def list_pending(
self, *, limit: "int" = 1, queue: "str | None" = None, execution_backend: "str | None" = None
) -> "list[QueuedTaskRecord]":
rows = await self._select_pending_rows(limit=limit, queue=queue, execution_backend=execution_backend)
return [self._record_from_row(row) for row in rows]
[docs]
async def claim_task(
self, task_id: "UUID", *, expected_retry_count: "int | None" = None, expected_execution_ref: "str | None" = None
) -> "QueuedTaskRecord | None":
claimed, _ = await self.claim_task_with_expired(
task_id, expected_retry_count=expected_retry_count, expected_execution_ref=expected_execution_ref
)
return claimed
[docs]
async def claim_task_with_expired(
self, task_id: "UUID", *, expected_retry_count: "int | None" = None, expected_execution_ref: "str | None" = None
) -> "tuple[QueuedTaskRecord | None, QueuedTaskRecord | None]":
"""Claim one task and identify a claim-time expiry owned by this call."""
claimed: "QueuedTaskRecord | None" = None
expired: "QueuedTaskRecord | None" = None
with self._observe_queue_operation("claim", task_id=str(task_id)):
async with self._session() as driver:
await driver.begin()
try:
row = await self._select_task(driver, task_id)
if row is None:
await driver.rollback()
return None, None
record = self._record_from_row(row)
if (
record.status not in _DUE_STATUSES
or not record.is_due
or is_external_dispatch_reservation(record.execution_ref)
):
await driver.rollback()
return None, None
now = _utc_now()
if record.execution_ref is None and record.expires_at is not None and record.expires_at <= now:
ownership_ref = f"__litestar_queues_expiry__:{uuid4()}"
result = await driver.execute(
self._get_store().expire_task_owned(
task_id=str(task_id),
now=self._serialize_datetime(now),
ownership_ref=ownership_ref,
expected_execution_ref=record.execution_ref,
)
)
if self._resolve_rows_affected(result) != 0:
expired_row = await self._select_task(driver, task_id)
if expired_row is not None:
candidate = self._record_from_row(expired_row)
if candidate.status == "expired" and candidate.execution_ref == ownership_ref:
await driver.execute(
self._get_store().clear_expiry_ownership(
task_ids=[str(task_id)], ownership_ref=ownership_ref
)
)
candidate.execution_ref = None
expired = candidate
await driver.commit()
return None, expired
result = await driver.execute(
self._get_store().claim_task(
task_id=str(task_id),
due_at=self._serialize_datetime(now),
heartbeat_at=self._serialize_datetime(now),
started_at=self._serialize_datetime(now),
expected_retry_count=expected_retry_count,
expected_execution_ref=expected_execution_ref,
)
)
if self._resolve_rows_affected(result) == 0:
await driver.rollback()
return None, None
updated_row = await self._select_task(driver, task_id)
if updated_row is None or self._record_from_row(updated_row).status != "running":
await driver.rollback()
return None, None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
claimed = self._record_from_row(updated_row) if updated_row is not None else None
if claimed is not None:
self._increment_queue_metric("claim")
return claimed, None
[docs]
async def expire_overdue(self, *, limit: "int | None" = None) -> "list[QueuedTaskRecord]":
"""Transition overdue pending or scheduled records to ``expired``."""
store = self._get_store()
now = _utc_now()
serialized_now = self._serialize_datetime(now)
async with self._session() as driver:
await driver.begin()
try:
rows = await self._select_rows(driver, store.select_expired(now=serialized_now, limit=limit))
task_ids = [str(self._record_from_row(row).id) for row in rows]
if not task_ids:
await driver.rollback()
return []
ownership_ref = f"__litestar_queues_expiry__:{uuid4()}"
result = await driver.execute(
store.expire_tasks(task_ids=task_ids, now=serialized_now, ownership_ref=ownership_ref)
)
if self._resolve_rows_affected(result) == 0:
await driver.rollback()
return []
updated_rows = await self._select_rows(driver, store.select_tasks_by_ids(task_ids))
updated_by_id = {
str(record.id): record
for row in updated_rows
if (record := self._record_from_row(row)).status == "expired"
and record.execution_ref == ownership_ref
}
expired = [updated_by_id[task_id] for task_id in task_ids if task_id in updated_by_id]
if expired:
expired_ids = [str(record.id) for record in expired]
await driver.execute(
store.clear_expiry_ownership(task_ids=expired_ids, ownership_ref=ownership_ref)
)
for record in expired:
record.execution_ref = None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
return expired
[docs]
async def claim_many(
self,
*,
limit: "int",
queues: "tuple[str, ...]" = (),
execution_backend: "str | None" = None,
queue_limits: "Mapping[str, int] | None" = None,
) -> "list[QueuedTaskRecord]":
"""Claim up to ``limit`` due tasks.
Postgres-family stores use one autocommit
``UPDATE ... FROM (SELECT ... FOR UPDATE SKIP LOCKED LIMIT $n) ... RETURNING``
statement. Other adapters fall back to the per-record claim loop.
Returns:
Claimed task records.
"""
if limit <= 0:
return []
store = self._get_store()
if not type(store).supports_returning_claim:
return await super().claim_many(
limit=limit, queues=queues, execution_backend=execution_backend, queue_limits=queue_limits
)
now = _utc_now()
parameters: "dict[str, Any]" = {
"now": self._serialize_datetime(now),
"expires_now": self._serialize_datetime(now),
"started_at": self._serialize_datetime(now),
"heartbeat_at": self._serialize_datetime(now),
"limit": limit,
"reservation_prefix": f"{EXTERNAL_DISPATCH_RESERVATION_PREFIX}%",
}
for index, queue in enumerate(queues):
parameters[f"queue_{index}"] = queue
if execution_backend is not None:
parameters["execution_backend"] = execution_backend
cap_count = self._bind_queue_limits(parameters, queue_limits)
sql_text = store.claim_batch_returning_sql(
queue_count=len(queues), filter_execution_backend=execution_backend is not None, cap_count=cap_count
)
with self._observe_queue_operation("claim", execution_backend=execution_backend):
async with self._session() as driver:
rows = await self._select_rows(driver, sql_text, parameters)
records = [self._record_from_row(row) for row in rows]
if records:
self._increment_queue_metric("claim", float(len(records)))
return records
[docs]
async def claim_many_with_expired(
self,
*,
limit: "int",
queues: "tuple[str, ...]" = (),
execution_backend: "str | None" = None,
queue_limits: "Mapping[str, int] | None" = None,
) -> "tuple[list[QueuedTaskRecord], list[QueuedTaskRecord]]":
"""Claim records and report every expiry transition owned by this call."""
if limit <= 0:
return [], []
store = self._get_store()
if type(store).supports_combined_expiry_claim:
return await self._claim_many_postgres_with_expired(
store, limit=limit, queues=queues, execution_backend=execution_backend, queue_limits=queue_limits
)
if queue_limits is not None:
return await super().claim_many_with_expired(
limit=limit, queues=queues, execution_backend=execution_backend, queue_limits=queue_limits
)
expired = await self.expire_overdue()
claimed: "list[QueuedTaskRecord]" = []
seen: "set[UUID]" = set()
for queue in queues or (None,):
while len(claimed) < limit:
rows = await self._select_pending_rows(
limit=max(10, limit - len(claimed)), queue=queue, execution_backend=execution_backend
)
candidates = [self._record_from_row(row) for row in rows if UUID(str(row["id"])) not in seen]
if not candidates:
break
for candidate in candidates:
seen.add(candidate.id)
claimed_record, expired_record = await self.claim_task_with_expired(candidate.id)
if expired_record is not None:
expired.append(expired_record)
if claimed_record is not None:
claimed.append(claimed_record)
if len(claimed) >= limit:
break
if len(rows) < max(10, limit - len(claimed)):
break
expired.extend(await self.expire_overdue())
unique_expired = {record.id: record for record in expired}
return claimed, list(unique_expired.values())
@staticmethod
def _bind_queue_limits(parameters: "dict[str, Any]", queue_limits: "Mapping[str, int] | None") -> "int":
"""Bind per-queue caps as ``qc_*`` parameters.
Returns:
The number of bound caps, which selects the capped statement variant.
"""
if not queue_limits:
return 0
for index, (queue, cap) in enumerate(sorted(queue_limits.items())):
parameters[f"qc_queue_{index}"] = queue
parameters[f"qc_cap_{index}"] = cap
return len(queue_limits)
async def _claim_many_postgres_with_expired(
self,
store: "SQLSpecQueueStore",
*,
limit: "int",
queues: "tuple[str, ...]",
execution_backend: "str | None",
queue_limits: "Mapping[str, int] | None" = None,
) -> "tuple[list[QueuedTaskRecord], list[QueuedTaskRecord]]":
now = _utc_now()
parameters: "dict[str, Any]" = {
"now": self._serialize_datetime(now),
"expires_now": self._serialize_datetime(now),
"completed_at": self._serialize_datetime(now),
"started_at": self._serialize_datetime(now),
"heartbeat_at": self._serialize_datetime(now),
"limit": limit,
"reservation_prefix": f"{EXTERNAL_DISPATCH_RESERVATION_PREFIX}%",
}
for index, queue in enumerate(queues):
parameters[f"queue_{index}"] = queue
if execution_backend is not None:
parameters["execution_backend"] = execution_backend
cap_count = self._bind_queue_limits(parameters, queue_limits)
sql_text = store.claim_batch_with_expired_returning_sql(
queue_count=len(queues), filter_execution_backend=execution_backend is not None, cap_count=cap_count
)
with self._observe_queue_operation("claim", execution_backend=execution_backend):
async with self._session() as driver:
rows = await self._select_rows(driver, sql_text, parameters)
claimed: "list[QueuedTaskRecord]" = []
expired: "list[QueuedTaskRecord]" = []
for row in rows:
record_row = dict(row)
outcome = str(record_row.pop("_claim_outcome"))
record = self._record_from_row(record_row)
(expired if outcome == "expired" else claimed).append(record)
if claimed:
self._increment_queue_metric("claim", float(len(claimed)))
return claimed, expired
[docs]
async def claim_next(
self, *, queues: "tuple[str, ...]" = (), execution_backend: "str | None" = None
) -> "QueuedTaskRecord | None":
store = self._get_store()
for queue in queues or (None,):
if store.supports_skip_locked:
claimed = await self._claim_next_skip_locked(store, queue=queue, execution_backend=execution_backend)
else:
claimed = await self._claim_next_optimistic(store, queue=queue, execution_backend=execution_backend)
if claimed is not None:
return claimed
return None
async def _claim_next_optimistic(
self, store: "SQLSpecQueueStore", *, queue: "str | None", execution_backend: "str | None"
) -> "QueuedTaskRecord | None":
rows = await self._select_pending_rows(limit=10, queue=queue, execution_backend=execution_backend)
for row in rows:
task_id = UUID(str(row["id"]))
try:
claimed = await self.claim_task(task_id)
except Exception as exc:
if _is_serialization_conflict(exc):
continue
raise
if claimed is not None:
return claimed
return None
async def _claim_next_skip_locked(
self, store: "SQLSpecQueueStore", *, queue: "str | None", execution_backend: "str | None"
) -> "QueuedTaskRecord | None":
"""Claim the next due task under ``SELECT ... FOR UPDATE SKIP LOCKED``.
Locks a single due row and claims it inside one transaction so
competing workers skip the locked row instead of colliding on the
optimistic CAS claim. The v1 fenced-claim contract is preserved: a row
that cannot be transitioned to ``running`` yields ``None``.
Returns:
The claimed task record, if a claim was available.
"""
with self._observe_queue_operation("claim", queue=queue, execution_backend=execution_backend):
async with self._session() as driver:
await driver.begin()
try:
now = _utc_now()
statement = store.select_claimable(
now=self._serialize_datetime(now), limit=1, queue=queue, execution_backend=execution_backend
)
stream_chunk_size = cast("int | None", getattr(store, "claim_select_stream_chunk_size", None))
row = await self._select_one_row(driver, statement, chunk_size=stream_chunk_size)
if row is None:
await driver.rollback()
return None
record = self._record_from_row(row)
result = await driver.execute(
store.claim_task(
task_id=str(record.id),
due_at=self._serialize_datetime(now),
heartbeat_at=self._serialize_datetime(now),
started_at=self._serialize_datetime(now),
)
)
if self._resolve_rows_affected(result) == 0:
await driver.rollback()
return None
updated_row = await self._select_task(driver, record.id)
if updated_row is None or self._record_from_row(updated_row).status != "running":
await driver.rollback()
return None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
claimed = self._record_from_row(updated_row)
self._increment_queue_metric("claim")
return claimed
[docs]
async def complete_task(
self, task_id: "UUID", *, result: "Any" = None, expected_retry_count: "int | None" = None
) -> "QueuedTaskRecord | None":
now = _utc_now()
store = self._get_store()
if not type(store).supports_dml_returning:
return await self._complete_task_without_returning(
task_id, result=result, expected_retry_count=expected_retry_count
)
parameters: "dict[str, Any]" = {
"id": str(task_id),
"completed_at": self._serialize_datetime(now),
"result_json": store.serialize_json("result_json", result),
}
if expected_retry_count is not None:
parameters["expected_retry_count"] = expected_retry_count
sql_text = store.complete_returning_sql(fence_retry_count=expected_retry_count is not None)
with self._observe_queue_operation("complete", task_id=str(task_id)):
async with self._session() as driver:
row = await self._select_one_row(driver, sql_text, parameters)
completed = self._record_from_row(row) if row is not None else None
if completed is not None:
self._increment_queue_metric("complete")
elif expected_retry_count is not None:
self._increment_queue_metric("claim_lost")
return completed
async def _complete_task_without_returning(
self, task_id: "UUID", *, result: "Any" = None, expected_retry_count: "int | None" = None
) -> "QueuedTaskRecord | None":
now = _utc_now()
store = self._get_store()
with self._observe_queue_operation("complete", task_id=str(task_id)):
async with self._session() as driver:
await driver.begin()
try:
updated = await driver.execute(
store.complete_task(
task_id=str(task_id),
completed_at=self._serialize_datetime(now),
heartbeat_at=self._serialize_datetime(now),
result_json=store.serialize_json("result_json", result),
expected_retry_count=expected_retry_count,
)
)
rows_affected = self._resolve_rows_affected(updated)
row = await self._select_task(driver, task_id) if rows_affected == 1 or rows_affected < 0 else None
if row is not None:
completed_record = self._record_from_row(row)
if completed_record.status != "completed" or (
expected_retry_count is not None and completed_record.retry_count != expected_retry_count
):
row = None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
completed = self._record_from_row(row) if row is not None else None
if completed is not None:
self._increment_queue_metric("complete")
elif expected_retry_count is not None:
self._increment_queue_metric("claim_lost")
return completed
[docs]
async def fail_task(
self,
task_id: "UUID",
error: "str",
*,
retry: "bool" = True,
expected_retry_count: "int | None" = None,
retry_at: "datetime | None" = None,
queued_at: "datetime | None" = None,
) -> "QueuedTaskRecord | None":
store = self._get_store()
if not type(store).supports_dml_returning:
return await self._fail_task_without_returning(
task_id,
error,
retry=retry,
expected_retry_count=expected_retry_count,
retry_at=retry_at,
queued_at=queued_at,
)
now = _utc_now()
stored_error = store.serialize_error(error)
parameters: "dict[str, Any]" = {
"id": str(task_id),
"error": stored_error,
"retry": retry,
"completed_at": self._serialize_datetime(now),
"queued_at": self._serialize_datetime(queued_at or now),
"retry_at": self._serialize_datetime(retry_at),
}
if expected_retry_count is not None:
parameters["expected_retry_count"] = expected_retry_count
sql_text = store.fail_returning_sql(fence_retry_count=expected_retry_count is not None)
with self._observe_queue_operation("fail", task_id=str(task_id), retry=retry):
async with self._session() as driver:
row = await self._select_one_row(driver, sql_text, parameters)
updated = self._record_from_row(row) if row is not None else None
if updated is None:
self._increment_queue_metric("claim_lost")
return None
self._increment_queue_metric("retry" if updated.status in {"pending", "scheduled"} else "fail")
if updated.status in {"pending", "scheduled"}:
await self.notify_new_task(updated)
return updated
async def _fail_task_without_returning(
self,
task_id: "UUID",
error: "str",
*,
retry: "bool" = True,
expected_retry_count: "int | None" = None,
retry_at: "datetime | None" = None,
queued_at: "datetime | None" = None,
) -> "QueuedTaskRecord | None":
with self._observe_queue_operation("fail", task_id=str(task_id), retry=retry):
async with self._session() as driver:
await driver.begin()
try:
store = self._get_store()
stored_error = store.serialize_error(error)
row = await self._select_task(driver, task_id)
if row is None:
await driver.rollback()
return None
record = self._record_from_row(row)
if record.status != "running" or (
expected_retry_count is not None and record.retry_count != expected_retry_count
):
await driver.commit()
self._increment_queue_metric("claim_lost")
return None
metric = "fail"
retry_fence = expected_retry_count if expected_retry_count is not None else record.retry_count
if retry and attempts_consumed(record) < record.max_retries:
updated = await driver.execute(
store.retry_task(
task_id=str(task_id),
error=stored_error,
retry_count=record.retry_count + 1,
expected_retry_count=retry_fence,
retry_at=self._serialize_datetime(retry_at),
queued_at=self._serialize_datetime(queued_at or _utc_now()),
)
)
metric = "retry"
expected_status = "scheduled" if retry_at is not None else "pending"
expected_retry_after_update = record.retry_count + 1
else:
now = _utc_now()
updated = await driver.execute(
store.fail_task(
task_id=str(task_id),
completed_at=self._serialize_datetime(now),
heartbeat_at=self._serialize_datetime(now),
error=stored_error,
expected_retry_count=retry_fence,
)
)
expected_status = "failed"
expected_retry_after_update = record.retry_count
rows_affected = self._resolve_rows_affected(updated)
if rows_affected == 1 or rows_affected < 0:
updated_row = await self._select_task(driver, task_id)
if updated_row is not None:
candidate = self._record_from_row(updated_row)
if (
candidate.status != expected_status
or candidate.retry_count != expected_retry_after_update
or candidate.error != stored_error
):
updated_row = None
else:
updated_row = None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
updated_record = self._record_from_row(updated_row) if updated_row is not None else None
if updated_record is not None:
self._increment_queue_metric(metric)
else:
self._increment_queue_metric("claim_lost")
return updated_record
[docs]
async def assign_worker(
self, task_id: "UUID", *, worker_id: "str", expected_retry_count: "int"
) -> "QueuedTaskRecord | None":
store = self._get_store()
async with self._session() as driver:
await driver.begin()
try:
result = await driver.execute(
store.assign_worker(
task_id=str(task_id), worker_id=worker_id, expected_retry_count=expected_retry_count
)
)
rows_affected = self._resolve_rows_affected(result)
updated_row = None
if rows_affected != 0:
updated_row = await self._select_task(driver, task_id)
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
if updated_row is None:
return None
record = self._record_from_row(updated_row)
if record.worker_id != worker_id or record.retry_count != expected_retry_count:
return None
return record
[docs]
async def interrupt_task(
self, task_id: "UUID", *, expected_retry_count: "int", worker_id: "str", queued_at: "datetime"
) -> "QueuedTaskRecord | None":
store = self._get_store()
async with self._session() as driver:
await driver.begin()
try:
current_row = await self._select_task(driver, task_id)
if current_row is None:
await driver.commit()
return None
current = self._record_from_row(current_row)
if (
current.status != "running"
or current.retry_count != expected_retry_count
or current.worker_id != worker_id
):
await driver.commit()
return None
metadata = dict(current.metadata)
metadata["interruptions"] = interruption_count(current) + 1
result = await driver.execute(
store.interrupt_task(
task_id=str(task_id),
expected_retry_count=expected_retry_count,
worker_id=worker_id,
queued_at=self._serialize_datetime(queued_at),
retry_count=current.retry_count + 1,
metadata_json=store.serialize_json("metadata_json", metadata),
)
)
rows_affected = self._resolve_rows_affected(result)
updated_row = None
if rows_affected != 0:
updated_row = await self._select_task(driver, task_id)
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
if updated_row is None:
return None
record = self._record_from_row(updated_row)
return record if record.status == "pending" else None
[docs]
async def cancel_task(
self, task_id: "UUID", *, include_running: "bool" = False, expected_retry_count: "int | None" = None
) -> "bool":
async with self._session() as driver:
await driver.begin()
try:
adapter_name = resolve_adapter_name(self._get_sqlspec_config())
before_row = (
await self._select_task(driver, task_id) if adapter_name in _UNRELIABLE_ROWCOUNT_ADAPTERS else None
)
result = await driver.execute(
self._get_store().cancel_task(
task_id=str(task_id),
completed_at=self._serialize_datetime(_utc_now()),
include_running=include_running,
expected_retry_count=expected_retry_count,
)
)
rows_affected = self._resolve_rows_affected(result)
if rows_affected < 0:
updated_row = await self._select_task(driver, task_id)
cancelled = False
if before_row is not None and updated_row is not None:
before = self._record_from_row(before_row)
record = self._record_from_row(updated_row)
eligible_statuses = (*_DUE_STATUSES, "running") if include_running else _DUE_STATUSES
cancelled = (
before.status in eligible_statuses
and (expected_retry_count is None or before.retry_count == expected_retry_count)
and record.status == "cancelled"
and (expected_retry_count is None or record.retry_count == expected_retry_count)
)
else:
cancelled = rows_affected == 1
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
return cancelled
[docs]
async def cancel_tasks(
self,
*,
task_name: "str | None" = None,
queue: "str | None" = None,
kwargs: "Mapping[str, Any] | None" = None,
metadata: "Mapping[str, Any] | None" = None,
include_running: "bool" = False,
) -> "int":
store = self._get_store()
async with self._session() as driver:
rows = await self._select_rows(
driver, store.list_cancellable(include_running=include_running, task_name=task_name, queue=queue)
)
cancelled = 0
for row in rows:
record = self._record_from_row(row)
if not record_matches_filters(record, task_name=task_name, queue=queue, kwargs=kwargs, metadata=metadata):
continue
if await self.cancel_task(record.id, include_running=include_running):
cancelled += 1
return cancelled
[docs]
async def touch_heartbeats(self, touches: "Sequence[HeartbeatTouch]") -> "HeartbeatTouchResult":
result = HeartbeatTouchResult()
if not touches:
return result
store = self._get_store()
async with self._heartbeat_session() as driver:
await driver.begin()
try:
bulk_result = await self._touch_heartbeats_bulk(driver, store, touches)
result = (
bulk_result
if bulk_result is not None
else await self._touch_heartbeats_loop(driver, store, touches)
)
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
return result
async def _touch_heartbeats_bulk(
self, driver: "SQLSpecDriver", store: "SQLSpecQueueStore", touches: "Sequence[HeartbeatTouch]"
) -> "HeartbeatTouchResult | None":
if not getattr(type(store), "supports_bulk_touch_heartbeats", False):
return None
task_ids = [touch.task_id for touch in touches]
if len(set(task_ids)) != len(task_ids):
return None
bulk_touches: "list[dict[str, Any]]" = []
for touch in touches:
metadata_json = None
if touch.metadata_patch:
metadata_json = store.serialize_json("metadata_json", touch.metadata_patch)
bulk_touches.append({
"task_id": str(touch.task_id),
"expected_retry_count": touch.expected_retry_count,
"metadata_json": metadata_json,
})
statement = store.bulk_touch_heartbeats(touches=bulk_touches, heartbeat_at=self._serialize_datetime(_utc_now()))
if statement is None:
return None
touched_rows = await driver.select(statement.sql, statement.parameters)
touched_task_ids = {UUID(str(row["id"])) for row in cast("list[dict[str, Any]]", touched_rows)}
return HeartbeatTouchResult(touched_task_ids=touched_task_ids, missed_task_ids=set(task_ids) - touched_task_ids)
async def _touch_heartbeats_loop(
self, driver: "SQLSpecDriver", store: "SQLSpecQueueStore", touches: "Sequence[HeartbeatTouch]"
) -> "HeartbeatTouchResult":
result = HeartbeatTouchResult()
for touch in touches:
row = await self._select_task(driver, touch.task_id)
if row is None:
result.missed_task_ids.add(touch.task_id)
continue
record = self._record_from_row(row)
if record.status != "running" or (
touch.expected_retry_count is not None and record.retry_count != touch.expected_retry_count
):
result.missed_task_ids.add(touch.task_id)
continue
metadata_json = None
if touch.metadata_patch:
metadata = dict(record.metadata)
metadata.update(touch.metadata_patch)
metadata_json = store.serialize_json("metadata_json", metadata)
execution_result = await driver.execute(
store.touch_heartbeats(
task_id=str(touch.task_id),
heartbeat_at=self._serialize_datetime(_utc_now()),
expected_retry_count=touch.expected_retry_count,
metadata_json=metadata_json,
)
)
rows_affected = self._resolve_rows_affected(execution_result)
if rows_affected == 1:
result.touched_task_ids.add(touch.task_id)
elif rows_affected == 0:
result.missed_task_ids.add(touch.task_id)
else:
touched_row = await self._select_task(driver, touch.task_id)
touched_record = self._record_from_row(touched_row) if touched_row is not None else None
if (
touched_record is not None
and touched_record.status == "running"
and (touch.expected_retry_count is None or touched_record.retry_count == touch.expected_retry_count)
):
result.touched_task_ids.add(touch.task_id)
else:
result.missed_task_ids.add(touch.task_id)
return result
[docs]
async def null_heartbeats(self, task_ids: "list[UUID]", *, expected_retry_count: "int | None" = None) -> "None":
if not task_ids:
return
async with self._heartbeat_session() as driver:
await driver.begin()
try:
filtered_task_ids = task_ids
if expected_retry_count is not None:
filtered_task_ids = []
for task_id in task_ids:
row = await self._select_task(driver, task_id)
if row is None:
continue
record = self._record_from_row(row)
if record.retry_count == expected_retry_count:
filtered_task_ids.append(task_id)
if filtered_task_ids:
await driver.execute(
self._get_store().null_heartbeats(task_ids=[str(task_id) for task_id in filtered_task_ids])
)
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
[docs]
async def requeue_stale_running(
self, *, stale_after: "timedelta", limit: "int | None" = None
) -> "StaleTaskRecoveryResult":
cutoff = _utc_now() - stale_after
store = self._get_store()
result = StaleTaskRecoveryResult()
serialized_cutoff = self._serialize_datetime(cutoff)
with self._observe_queue_operation("stale_recovered"):
async with self._session() as driver:
await driver.begin()
try:
rows = await self._select_rows(
driver, store.list_stale_running(cutoff=serialized_cutoff, limit=limit)
)
if not rows:
await driver.commit()
return result
failed_handler_needed: "list[UUID]" = []
for row in rows:
record = self._record_from_row(row)
requeue_on_stale = record.metadata.get("requeue_on_stale", True) is not False
if requeue_on_stale and attempts_consumed(record) < record.max_retries:
retry_error = stale_requeue_error(record.error)
retry_priority = stale_requeue_priority(
record.priority, self._stale_requeue_priority_policy()
)
queued_at, retry_at = retry_schedule(record)
updated = await driver.execute(
store.retry_task(
task_id=str(record.id),
error=retry_error,
retry_count=record.retry_count + 1,
expected_retry_count=record.retry_count,
heartbeat_cutoff=serialized_cutoff,
priority=retry_priority,
retry_at=self._serialize_datetime(retry_at),
queued_at=self._serialize_datetime(queued_at),
)
)
rows_affected = self._resolve_rows_affected(updated)
if rows_affected == 1 or (
rows_affected < 0
and await self._stale_retry_updated(
driver,
record.id,
record.retry_count,
expected_error=retry_error,
expected_priority=retry_priority,
)
):
result.requeued += 1
else:
result.skipped += 1
else:
now = _utc_now()
updated = await driver.execute(
store.fail_task(
task_id=str(record.id),
completed_at=self._serialize_datetime(now),
heartbeat_at=self._serialize_datetime(now),
error=STALE_HEARTBEAT_ERROR,
expected_retry_count=record.retry_count,
heartbeat_cutoff=serialized_cutoff,
)
)
rows_affected = self._resolve_rows_affected(updated)
if rows_affected == 1 or (
rows_affected < 0 and await self._stale_fail_updated(driver, record.id)
):
result.failed += 1
result.failed_task_ids.append(record.id)
if not requeue_on_stale:
failed_handler_needed.append(record.id)
else:
result.skipped += 1
for task_id in failed_handler_needed:
result.handler_needed += 1
result.handler_needed_task_ids.append(task_id)
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
recovered = result.requeued + result.failed
if recovered:
self._increment_queue_metric("stale_recovered", float(recovered))
if result.requeued:
self._increment_queue_metric("retry", float(result.requeued))
if result.failed:
self._increment_queue_metric("stale_failed", float(result.failed))
return result
[docs]
async def set_execution_ref(
self, task_id: "UUID", execution_backend: "str", execution_ref: "str", *, execution_profile: "str | None" = None
) -> "QueuedTaskRecord | None":
async with self._session() as driver:
await driver.begin()
try:
result = await driver.execute(
self._get_store().set_execution_ref(
task_id=str(task_id),
execution_backend=execution_backend,
execution_profile=execution_profile,
execution_ref=execution_ref,
)
)
row = await self._select_task(driver, task_id) if self._resolve_rows_affected(result) else None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
return self._record_from_row(row) if row is not None else None
[docs]
async def reserve_external_dispatch(
self,
task_id: "UUID",
execution_backend: "str",
reservation_ref: "str",
*,
execution_profile: "str | None" = None,
expected_retry_count: "int | None" = None,
) -> "QueuedTaskRecord | None":
async with self._session() as driver:
await driver.begin()
try:
result = await driver.execute(
self._get_store().reserve_external_dispatch(
task_id=str(task_id),
execution_backend=execution_backend,
execution_profile=execution_profile,
execution_ref=reservation_ref,
now=self._serialize_datetime(_utc_now()),
expected_retry_count=expected_retry_count,
)
)
row = await self._select_task(driver, task_id) if self._resolve_rows_affected(result) else None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
record = self._record_from_row(row) if row is not None else None
return record if record is not None and record.execution_ref == reservation_ref else None
[docs]
async def clear_execution_ref(
self, task_id: "UUID", expected_retry_count: "int", expected_execution_ref: "str"
) -> "QueuedTaskRecord | None":
async with self._session() as driver:
await driver.begin()
current_row = await self._select_task(driver, task_id)
current = self._record_from_row(current_row) if current_row is not None else None
if (
current is None
or current.status not in {"pending", "scheduled"}
or current.retry_count != expected_retry_count
or current.execution_ref != expected_execution_ref
):
await driver.rollback()
return None
result = await driver.execute(
self._get_store().clear_execution_ref(
task_id=str(task_id),
expected_retry_count=expected_retry_count,
expected_execution_ref=expected_execution_ref,
)
)
rows_affected = self._resolve_rows_affected(result)
row = await self._select_task(driver, task_id) if rows_affected == 1 or rows_affected < 0 else None
await driver.commit()
record = self._record_from_row(row) if row is not None else None
if record is not None and record.execution_ref is not None:
return None
if record is not None:
await self.notify_new_task(record)
return record
[docs]
async def replace_execution_ref(
self, task_id: "UUID", expected_retry_count: "int", expected_execution_ref: "str", execution_ref: "str"
) -> "QueuedTaskRecord | None":
async with self._session() as driver:
await driver.begin()
current_row = await self._select_task(driver, task_id)
current = self._record_from_row(current_row) if current_row is not None else None
if (
current is None
or current.status not in {"pending", "scheduled"}
or current.retry_count != expected_retry_count
or current.execution_ref != expected_execution_ref
):
await driver.rollback()
return None
result = await driver.execute(
self._get_store().replace_execution_ref(
task_id=str(task_id),
expected_retry_count=expected_retry_count,
expected_execution_ref=expected_execution_ref,
execution_ref=execution_ref,
)
)
rows_affected = self._resolve_rows_affected(result)
row = await self._select_task(driver, task_id) if rows_affected == 1 or rows_affected < 0 else None
await driver.commit()
record = self._record_from_row(row) if row is not None else None
return record if record is not None and record.execution_ref == execution_ref else None
[docs]
async def release_external_dispatch(
self,
task_id: "UUID",
reservation_ref: "str",
execution_backend: "str",
*,
execution_profile: "str | None" = None,
) -> "QueuedTaskRecord | None":
async with self._session() as driver:
await driver.begin()
try:
result = await driver.execute(
self._get_store().release_external_dispatch(
task_id=str(task_id),
reservation_ref=reservation_ref,
execution_backend=execution_backend,
execution_profile=execution_profile,
)
)
row = await self._select_task(driver, task_id) if self._resolve_rows_affected(result) else None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
record = self._record_from_row(row) if row is not None else None
if record is not None and record.execution_ref is not None:
record = None
if record is not None:
await self.notify_new_task(record)
return record
[docs]
async def finalize_external_dispatch(
self,
task_id: "UUID",
reservation_ref: "str",
execution_backend: "str",
execution_ref: "str",
*,
execution_profile: "str | None" = None,
) -> "QueuedTaskRecord | None":
async with self._session() as driver:
await driver.begin()
try:
result = await driver.execute(
self._get_store().finalize_external_dispatch(
task_id=str(task_id),
reservation_ref=reservation_ref,
execution_backend=execution_backend,
execution_profile=execution_profile,
execution_ref=execution_ref,
)
)
row = await self._select_task(driver, task_id) if self._resolve_rows_affected(result) else None
if row is not None and self._record_from_row(row).execution_ref != execution_ref:
row = None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
return self._record_from_row(row) if row is not None else None
[docs]
async def set_execution_backend(
self, task_id: "UUID", execution_backend: "str", *, execution_profile: "str | None" = None
) -> "QueuedTaskRecord | None":
async with self._session() as driver:
await driver.begin()
try:
result = await driver.execute(
self._get_store().set_execution_backend(
task_id=str(task_id), execution_backend=execution_backend, execution_profile=execution_profile
)
)
row = await self._select_task(driver, task_id) if self._resolve_rows_affected(result) else None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
record = self._record_from_row(row) if row is not None else None
if record is not None:
await self.notify_new_task(record)
return record
[docs]
async def list_running_external(self, *, limit: "int | None" = None) -> "list[QueuedTaskRecord]":
async with self._session() as driver:
rows = await self._select_rows(driver, self._get_store().list_running_external(limit=limit))
return [self._record_from_row(row) for row in rows]
[docs]
async def get_statistics(self, *, queue: "str | None" = None) -> "QueueStatistics":
statistics = QueueStatistics()
async with self._session() as driver:
rows = await self._select_rows(driver, self._get_store().statistics(queue=queue))
for row in rows:
status = _coerce_status(row["status"])
setattr(statistics, status, int(row["total"]))
return statistics
[docs]
async def iter_all(self, *, chunk_size: "int" = 1000) -> "AsyncIterator[QueuedTaskRecord]":
"""Stream every queue record without materializing the full table.
Uses SQLSpec ``select_stream`` so large administrative scans and exports
consume rows in chunks of ``chunk_size`` rather than loading the entire
result set into memory. The backend session stays open for the duration
of iteration, so callers should consume the iterator promptly.
Yields:
Queue task records from the backing SQLSpec table.
"""
session = self._session()
driver = await session.__aenter__()
try:
async for row in _select_stream(driver, self._get_store().list_all(), chunk_size=chunk_size):
yield self._record_from_row(cast("dict[str, Any]", row))
except BaseException as exc:
if not await session.__aexit__(type(exc), exc, exc.__traceback__):
raise
else:
await session.__aexit__(None, None, None)
[docs]
async def list_completed_by_task(
self, task_name: "str", *, since: "datetime | None" = None, limit: "int" = 10
) -> "list[QueuedTaskRecord]":
store = self._get_store()
statement = store.list_completed_by_task(
task_name=task_name, since=self._serialize_datetime(since), limit=limit
)
built = statement.build(dialect=store.dialect_name)
async with self._session() as driver:
rows = await self._select_rows(driver, built.sql, built.parameters)
return [self._record_from_row(row) for row in rows]
[docs]
async def cleanup_terminal(self, before: "datetime", *, limit: "int | None" = None) -> "int":
store = self._get_store()
before_str = self._serialize_datetime(before)
async with self._session() as driver:
await driver.begin()
try:
if limit is None:
# Some drivers (see _UNRELIABLE_ROWCOUNT_ADAPTERS) cannot
# reliably report ``rows_affected`` for DELETE. Count
# first inside the same transaction so the cleanup count is
# always exact.
count_row = await self._select_one_row(driver, store.count_terminal(before=before_str))
deleted = int(count_row["terminal_count"]) if count_row is not None else 0
if deleted > 0:
await driver.execute(store.cleanup_terminal(before=before_str))
else:
# DELETE ... LIMIT is not portable; select the oldest bounded
# id set and delete exactly those rows in the same transaction.
id_rows = await self._select_rows(driver, store.select_terminal_ids(before=before_str, limit=limit))
task_ids = [str(row["id"]) for row in id_rows]
deleted = len(task_ids)
if task_ids:
await driver.execute(store.delete_by_ids(task_ids=task_ids))
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
return deleted
[docs]
async def acquire_maintenance(self, name: "str", token: "str", *, ttl: "timedelta") -> "bool":
"""Acquire maintenance ownership via an adapter-portable compare-and-set.
Updates the named row to this token when its stored ownership has expired;
if a live row is present the update matches nothing and the row's token
differs, so ownership is denied. When no row exists a fresh one is
inserted, and a uniqueness race is treated as ownership denial. The
select-after-update read makes correctness independent of the adapter's
``rows_affected`` reliability.
Returns:
True when maintenance ownership is held under ``token``.
"""
store = self._get_maintenance_store()
now = _utc_now()
now_param = self._serialize_datetime(now)
expiry_param = self._serialize_datetime(now + ttl)
async with self._session() as driver:
await driver.begin()
committed = False
try:
await driver.execute(
store.acquire_update(name=name, token=token, expires_at=expiry_param, now=now_param)
)
row = await self._select_one_row(driver, store.select_coordination_token(name=name))
if row is not None:
acquired = str(row["token"]) == token
await driver.commit()
committed = True
return acquired
await driver.execute(store.insert_coordination(name=name, token=token, expires_at=expiry_param))
await driver.commit()
committed = True
return True # noqa: TRY300 - the insert must stay inside the try to catch a uniqueness race.
except Exception as exc:
if not committed:
with suppress(Exception):
await driver.rollback()
if _is_unique_violation(exc):
return False
raise
[docs]
async def release_maintenance(self, name: "str", token: "str") -> "bool":
"""Release maintenance ownership only when the stored token matches.
Returns:
True when ownership held under ``token`` was deleted and no successor
replaced it before the transaction's postcondition check.
"""
store = self._get_maintenance_store()
released = False
async with self._session() as driver:
await driver.begin()
try:
count_row = await self._select_one_row(driver, store.count_coordination(name=name, token=token))
matched = int(count_row["coordination_count"]) if count_row is not None else 0
if matched:
await driver.execute(store.delete_coordination(name=name, token=token))
remaining = await self._select_one_row(driver, store.select_coordination_token(name=name))
released = remaining is None
await driver.commit()
except Exception:
with suppress(Exception):
await driver.rollback()
raise
return released
[docs]
async def notify_new_task(self, record: "QueuedTaskRecord") -> "None":
"""Publish a SQLSpec event when configured queue work becomes available."""
if (
self._worker_wakeups_enabled
and self._event_channel is not None
and record.status in _DUE_STATUSES
and record.is_due
):
with self._observe_queue_operation("notify", queue=record.queue):
await _invoke_event_channel_method(
self._event_channel,
"publish",
self._resolve_wakeup_channel(),
{"event": "task_available"},
{"event_type": "litestar_queues.task_available"},
)
self._increment_queue_metric("notify")
self._record_wakeup_emitted()
[docs]
async def wait_for_wakeups(self, timeout: "float | None" = None) -> "bool":
"""Wait for a SQLSpec event when queue notifications are configured.
One ``iter_events`` stream and its pending ``anext`` read are retained
across worker poll timeouts; only an event, a driver failure, or
backend close ends them.
Returns:
True when a notification was received.
"""
if not self._worker_wakeups_enabled or self._event_channel is None:
return await super().wait_for_wakeups(timeout=timeout)
stream = self._event_stream
if stream is None:
stream = self._event_channel.iter_events(
self._resolve_wakeup_channel(), poll_interval=self._wakeup_poll_interval
)
self._event_stream = stream
task = await self._pending_read.race(lambda: _next_event(stream), timeout)
if task is None:
return False
exc = task.exception()
if exc is not None:
await self._close_notification_stream()
raise exc
event = task.result()
await _invoke_event_channel_method(self._event_channel, "ack", event.event_id)
return True
[docs]
async def notify_worker_control(self, worker_id: "str | None") -> "None":
"""Publish a worker-control hint on the SQLSpec events channel.
On the Postgres drivers this is a LISTEN/NOTIFY message. The hint is
lossy by contract: it only shortens the wait before the owning worker
reconciles durable status.
"""
if not self._worker_wakeups_enabled or self._event_channel is None:
return
await _invoke_event_channel_method(
self._event_channel,
"publish",
self._resolve_control_channel(),
{"event": "worker_control", "worker_id": worker_id},
{"event_type": "litestar_queues.worker_control"},
)
[docs]
async def wait_for_worker_control(self, *, worker_id: "str", timeout: "float | None" = None) -> "bool":
"""Wait for a SQLSpec worker-control hint.
One ``iter_events`` stream and its pending read are retained across
worker poll timeouts, exactly like the wakeup stream, and are never
shared with it.
Returns:
True when a control hint was observed.
Raises:
Exception: Whatever the event read raised, after the stream is
closed so the next wait re-establishes it.
"""
if not self._worker_wakeups_enabled or self._event_channel is None:
return await super().wait_for_worker_control(worker_id=worker_id, timeout=timeout)
stream = self._control_stream
if stream is None:
stream = self._event_channel.iter_events(
self._resolve_control_channel(), poll_interval=self._wakeup_poll_interval
)
self._control_stream = stream
task = await self._control_pending_read.race(lambda: _next_event(stream), timeout)
if task is None:
return False
exc = task.exception()
if exc is not None:
await self._close_control_stream()
raise exc
await _invoke_event_channel_method(self._event_channel, "ack", task.result().event_id)
return True
[docs]
async def time_until_next_due(self, *, queues: "tuple[str, ...]" = ()) -> "float | None":
"""Return seconds until the earliest not-yet-due pending/scheduled record.
Returns:
Seconds until the next due record, or ``None`` when there is no
upcoming scheduled work.
"""
now = _utc_now()
async with self._session() as driver:
row = await self._select_one_row(
driver, self._get_store().next_scheduled_at(now=self._serialize_datetime(now), queues=queues)
)
if row is None:
return None
next_at = _deserialize_datetime(row.get("next_scheduled_at"))
if next_at is None:
return None
return max((next_at - _utc_now()).total_seconds(), 0.0)
async def _close_notification_stream(self) -> "None":
"""Cancel the retained event read and close the iterator."""
await self._pending_read.aclose()
stream = self._event_stream
self._event_stream = None
if stream is not None:
with suppress(Exception):
close = getattr(stream, "aclose", None) or getattr(stream, "close", None)
if close is not None:
result = close()
if isawaitable(result):
await result
async def _close_control_stream(self) -> "None":
"""Cancel the retained control read and close its iterator."""
await self._control_pending_read.aclose()
stream = self._control_stream
self._control_stream = None
if stream is not None:
with suppress(Exception):
close = getattr(stream, "aclose", None) or getattr(stream, "close", None)
if close is not None:
result = close()
if isawaitable(result):
await result
@staticmethod
def _default_sqlspec_config() -> "SQLSpecConfig":
from sqlspec.adapters.aiosqlite import AiosqliteConfig
return cast("SQLSpecConfig", AiosqliteConfig())
def _resolve_queue_table_name(self) -> "str":
if self._queue_table_name is None:
queue_settings = _queue_extension_settings(self._sqlspec_config)
configured_table_name = _setting(queue_settings, "table_name") or DEFAULT_TABLE_NAME
self._queue_table_name = validate_table_name(str(configured_table_name))
return self._queue_table_name
def _resolve_wakeup_channel(self) -> "str":
if self._wakeup_channel is not None:
self._wakeup_channel = _normalize_wakeup_channel(str(self._wakeup_channel))
else:
self._wakeup_channel = DEFAULT_WAKEUP_CHANNEL
return self._wakeup_channel
def _resolve_control_channel(self) -> "str":
self._control_channel = _normalize_wakeup_channel(str(self._control_channel))
return self._control_channel
def _configure_worker_wakeups(self) -> "None":
sqlspec_config = self._get_sqlspec_config()
events_settings = _events_extension_settings(sqlspec_config)
transport = self._select_wakeup_transport(sqlspec_config)
if not self._worker_wakeups_should_enable(transport):
self._worker_wakeups_enabled = False
self._wakeup_backend = None
return
self._worker_wakeups_enabled = True
self._resolve_wakeup_channel()
if self._event_channel is None:
self._apply_wakeup_settings(sqlspec_config, events_settings, transport)
self._event_channel = cast("Any", self._get_or_create_sqlspec()).event_channel(sqlspec_config)
self._owns_event_channel = True
else:
# An injected channel already owns its backend; still resolve the
# configured poll interval so wait_for_wakeups honors it.
self._resolve_wakeup_poll_interval()
self._wakeup_backend = _canonical_wakeup_transport(
cast("str | None", getattr(self._event_channel, "_backend_name", None))
)
def _should_provision_events_queue(self) -> "bool":
"""Return whether ``create_schema`` should provision the events queue table.
Only the durable table-backed transports (``notify_queue`` / ``poll_queue``)
that this backend owns ride the SQLSpec events queue table. An injected
event channel owns its own storage, and the transient/native-only or
Oracle AQ transports never use this table.
"""
return (
self._worker_wakeups_enabled and self._owns_event_channel and self._wakeup_backend in _EVENTS_TABLE_BACKENDS
)
def _select_wakeup_transport(self, sqlspec_config: "SQLSpecConfig") -> "str":
"""Resolve the effective wakeup transport.
The typed worker-wakeup config wins over the per-adapter capability gate.
Returns:
A canonical wakeup transport name (``notify``, ``notify_queue``,
``poll_queue``, ``polling``, ``aq``, or ``txeventq``).
"""
return _resolve_wakeup_transport(explicit_transport=self._wakeup_transport, sqlspec_config=sqlspec_config)
def _worker_wakeups_should_enable(self, transport: "str") -> "bool":
"""Decide whether push wakeups are active for the resolved transport.
Native wakeups are default-on: whenever the resolved transport is
capability-native (anything other than ``polling``) an events channel
backs worker wakeups with no configuration. ``worker_wakeups=None`` is
the explicit opt-out, and a capability-gated ``polling`` transport (an
adapter that cannot push, with no explicit override) stays on interval
polling. Wakeups do not change task-claim ownership semantics.
Returns:
True when an events channel should back worker wakeups.
"""
if not self._worker_wakeups_configured:
return False
if self._event_channel is not None:
return True
return transport != _WAKEUP_TRANSPORT_POLLING
def _resolve_wakeup_poll_interval(self) -> "None":
if self._wakeup_poll_interval is None and "poll_interval" in self._wakeup_settings:
self._wakeup_poll_interval = float(self._wakeup_settings["poll_interval"])
def _apply_wakeup_settings(
self, sqlspec_config: "SQLSpecConfig", events_settings: "dict[str, Any]", transport: "str"
) -> "None":
merged_wakeup_settings = dict(events_settings)
merged_wakeup_settings.update(self._wakeup_settings)
merged_wakeup_settings["backend"] = transport
if self._wakeup_queue_table is not None:
merged_wakeup_settings["queue_table"] = str(self._wakeup_queue_table)
self._resolve_wakeup_poll_interval()
if self._wakeup_poll_interval is not None:
merged_wakeup_settings["poll_interval"] = self._wakeup_poll_interval
extension_config = dict(sqlspec_config.extension_config or {})
extension_config[_EVENT_EXTENSION_NAME] = merged_wakeup_settings
sqlspec_config.extension_config = extension_config
migration_config = dict(sqlspec_config.migration_config or {})
sqlspec_config.set_migration_config(migration_config)
def _get_or_create_sqlspec(self) -> "SQLSpec":
if self._sqlspec is None:
self._sqlspec = SQLSpec()
return self._sqlspec
def _get_sqlspec_config(self) -> "SQLSpecConfig":
if self._sqlspec_config is None:
registered_configs = tuple(cast("dict[int, SQLSpecConfig]", self._get_or_create_sqlspec().configs).values())
if len(registered_configs) == 1:
self._sqlspec_config = registered_configs[0]
elif len(registered_configs) > 1:
msg = (
"SQLSpecQueueBackend received a SQLSpec manager with multiple configs; "
"pass config to select the queue database."
)
raise QueueConfigurationError(msg)
else:
self._sqlspec_config = self._default_sqlspec_config()
return cast("SQLSpecConfig", self._sqlspec_config)
def _resolve_rows_affected(self, result: "Any") -> "int":
"""Return :func:`_rows_affected` normalized for this backend's configured adapter."""
return _rows_affected(result, resolve_adapter_name(self._get_sqlspec_config()))
def _get_store(self) -> "SQLSpecQueueStore":
if self._store is None:
self._store = create_queue_store(
self._get_sqlspec_config(),
table_name=self._resolve_queue_table_name(),
column_map=self._column_map,
native_json_columns=self._native_json_columns,
manage_schema=self._manage_schema,
)
return self._store
def _get_event_log_store(self, extra_columns: "Sequence[EventHistoryExtraColumn] | None" = None) -> "Any":
effective_columns = tuple(extra_columns) if extra_columns is not None else self._event_history_extra_columns
if self._event_log_store is None or (
extra_columns is not None and getattr(self._event_log_store, "extra_columns", ()) != effective_columns
):
store = create_event_log_store(
self._get_sqlspec_config(),
queue_table_name=self._resolve_queue_table_name(),
event_history_table_name=self._event_history_table_name,
manage_schema=self._manage_schema,
extra_columns=effective_columns,
)
self._event_history_table_name = store.table_name
self._event_log_store = store
return self._event_log_store
def _get_event_log_store_if_enabled(self) -> "Any | None":
return self._get_event_log_store() if self._event_history_enabled() else None
def _get_maintenance_store(self) -> "SQLSpecMaintenanceStore":
if self._maintenance_store is None:
store = create_maintenance_store(
self._get_sqlspec_config(),
queue_table_name=self._resolve_queue_table_name(),
maintenance_table_name=self._maintenance_table_name,
manage_schema=self._manage_schema,
)
self._maintenance_table_name = store.table_name
self._maintenance_store = store
return self._maintenance_store
def _get_task_reservation_store(self) -> "SQLSpecTaskReservationStore":
if self._task_reservation_store is None:
store = create_task_reservation_store(
self._get_sqlspec_config(),
queue_table_name=self._resolve_queue_table_name(),
task_reservation_table_name=self._task_reservation_table_name,
manage_schema=self._manage_schema,
)
self._task_reservation_table_name = store.table_name
self._task_reservation_store = store
return self._task_reservation_store
def _event_history_enabled(self) -> "bool":
return bool(
self.config is not None and self.config.events is not None and self.config.events.history is not None
)
def _resolve_event_history_table_name(self) -> "str":
if self._event_history_table_name is None:
self._event_history_table_name = resolve_event_history_table_name(self._resolve_queue_table_name())
return self._event_history_table_name
@asynccontextmanager
async def _session(self) -> "AsyncIterator[SQLSpecDriver]":
if not self._opened or self._sqlspec is None:
msg = "SQLSpecQueueBackend.open() must be called before using the backend."
raise RuntimeError(msg)
sqlspec_config = self._get_sqlspec_config()
store = self._get_store()
async with _bridge_session(
cast("SQLSpecManager", self._get_or_create_sqlspec()),
sqlspec_config,
skip_explicit_begin=store.skip_explicit_begin,
skip_cleanup_rollback=store.skip_cleanup_rollback,
executor=self._sync_executor,
thread_name_prefix=(
self.config.names.resource("sqlspec", "sync")
if self.config is not None
else "litestar-queues-sqlspec-sync"
),
) as driver:
yield driver
@asynccontextmanager
async def _heartbeat_session(self) -> "AsyncIterator[SQLSpecDriver]":
"""Yield a driver bound to the dedicated heartbeat pool when configured.
Falls back to the main pool when ``heartbeat_pool_config`` is not set,
or when the dedicated pool failed to register at ``open()`` time.
Yields:
A SQLSpec driver bound to the heartbeat or main pool.
Raises:
RuntimeError: When ``open()`` has not been called on the backend.
"""
if not self._opened or self._sqlspec is None:
msg = "SQLSpecQueueBackend.open() must be called before using the backend."
raise RuntimeError(msg)
if self._heartbeat_pool_enabled and self._heartbeat_pool_registered and self._heartbeat_pool_config is not None:
async with _bridge_session(
cast("SQLSpecManager", self._sqlspec),
cast("SQLSpecSessionConfig", self._heartbeat_pool_config),
skip_cleanup_rollback=self._get_store().skip_cleanup_rollback,
executor=self._heartbeat_sync_executor or self._sync_executor,
thread_name_prefix=(
self.config.names.resource("sqlspec", "heartbeat-sync")
if self.config is not None
else "litestar-queues-sqlspec-heartbeat-sync"
),
) as driver:
yield driver
else:
async with self._session() as driver:
yield driver
def _register_heartbeat_pool(self) -> "None":
"""Register the dedicated heartbeat pool with the SQLSpec manager.
Best effort. On failure the backend logs a warning and continues with
the main pool for heartbeats.
"""
if (
self._heartbeat_pool_enabled
and self._heartbeat_pool_config is not None
and not self._heartbeat_pool_registered
):
try:
cast("Any", self._get_or_create_sqlspec()).add_config(self._heartbeat_pool_config)
except Exception:
self._logger.warning(
"SQLSpecQueueBackend heartbeat pool registration failed; "
"falling back to main pool for heartbeat writes.",
exc_info=True,
)
self._heartbeat_pool_enabled = False
self._heartbeat_pool_registered = False
else:
self._heartbeat_pool_registered = True
async def _close_heartbeat_pool(self) -> "None":
"""Close the dedicated heartbeat pool if the backend opened one."""
if self._heartbeat_pool_registered and self._heartbeat_pool_config is not None:
try:
close_result = cast("SQLSpecConfig", self._heartbeat_pool_config).close_pool()
if isawaitable(close_result):
await close_result
except Exception:
self._logger.debug("SQLSpecQueueBackend heartbeat pool close failed.", exc_info=True)
self._heartbeat_pool_registered = False
async def _select_pending_rows(
self, *, limit: "int", queue: "str | None", execution_backend: "str | None"
) -> "list[dict[str, Any]]":
async with self._session() as driver:
return await self._select_rows(
driver,
self._get_store().list_pending(
now=self._serialize_datetime(_utc_now()),
limit=limit,
queue=queue,
execution_backend=execution_backend,
),
)
async def _select_task(self, driver: "SQLSpecDriver", task_id: "UUID") -> "dict[str, Any] | None":
return await self._select_one_row(driver, self._get_store().select_task(str(task_id)))
async def _select_task_by_key(self, driver: "SQLSpecDriver", key: "str") -> "dict[str, Any] | None":
return await self._select_one_row(driver, self._get_store().select_task_by_key(key))
async def _select_rows(
self, driver: "SQLSpecDriver", statement: "Any", *parameters: "Any", chunk_size: "int | None" = None
) -> "list[dict[str, Any]]":
stream_chunk_size = chunk_size
if stream_chunk_size is None:
stream_chunk_size = cast("int | None", getattr(self._get_store(), "select_stream_chunk_size", None))
if stream_chunk_size is not None and not isinstance(driver, _ManagedAsyncDriver):
rows: "list[dict[str, Any]]" = []
async for row in _select_stream(driver, statement, *parameters, chunk_size=stream_chunk_size):
rows.append(cast("dict[str, Any]", row))
return rows
return cast("list[dict[str, Any]]", await driver.select(statement, *parameters))
async def _select_one_row(
self, driver: "SQLSpecDriver", statement: "Any", *parameters: "Any", chunk_size: "int | None" = None
) -> "dict[str, Any] | None":
rows = await self._select_rows(driver, statement, *parameters, chunk_size=chunk_size)
return rows[0] if rows else None
async def _clear_key(self, driver: "SQLSpecDriver", task_id: "UUID") -> "None":
await driver.execute(self._get_store().clear_key(task_id=str(task_id)))
async def _stale_retry_updated(
self,
driver: "SQLSpecDriver",
task_id: "UUID",
previous_retry_count: "int",
*,
expected_error: "str",
expected_priority: "int",
) -> "bool":
row = await self._select_task(driver, task_id)
if row is None:
return False
record = self._record_from_row(row)
return (
record.status in {"pending", "scheduled"}
and record.retry_count == previous_retry_count + 1
and record.error == expected_error
and record.priority == expected_priority
)
async def _stale_fail_updated(self, driver: "SQLSpecDriver", task_id: "UUID") -> "bool":
row = await self._select_task(driver, task_id)
if row is None:
return False
record = self._record_from_row(row)
return record.status == "failed" and record.error == STALE_HEARTBEAT_ERROR
def _get_observability_runtime(self) -> "Any | None":
if not self._native_observability_enabled:
return None
return self._get_sqlspec_config().get_observability_runtime()
@contextmanager
def _observe_queue_operation(self, operation: "str", **attributes: "Any") -> "Generator[None]":
runtime = self._get_observability_runtime()
if runtime is None:
yield
return
span_attributes = {
"sqlspec.queue.operation": operation,
**{f"litestar_queues.{key}": value for key, value in attributes.items() if value is not None},
}
span = runtime.start_span(f"sqlspec.queue.{operation}", attributes=span_attributes)
error: "Exception | None" = None
try:
yield
except Exception as exc:
error = exc
raise
finally:
if span is not None:
runtime.end_span(span, error=error)
def _increment_queue_metric(self, name: "str", amount: "float" = 1.0) -> "None":
runtime = self._get_observability_runtime()
if runtime is not None and amount:
runtime.increment_metric(f"queue.{name}", amount)
async def _existing_records_by_key(
self, driver: "SQLSpecDriver", store: "SQLSpecQueueStore", keys: "list[str]"
) -> "dict[str, QueuedTaskRecord]":
"""Return a map of deduplication key to existing record for the given keys."""
existing: "dict[str, QueuedTaskRecord]" = {}
if not keys:
return existing
rows = await self._select_rows(driver, store.select_tasks_by_keys(keys))
for row in rows:
record = self._record_from_row(row)
if record.key is not None:
existing[record.key] = record
return existing
def _plan_bulk_enqueue(
self, requests: "Sequence[TaskRequest]", existing_by_key: "dict[str, QueuedTaskRecord]", now: "datetime"
) -> "tuple[list[QueuedTaskRecord], list[QueuedTaskRecord], list[UUID]]":
"""Resolve deduplication keys and build records, preserving input order.
Returns the ordered result records, the subset that must be inserted, and
the ids of terminal-key rows whose key must be cleared before insert.
Active (non-terminal) keys, whether already persisted or earlier in the
batch, reuse the existing record instead of inserting a duplicate.
Returns:
Ordered result records, records to insert, and terminal-key ids to clear.
"""
results: "list[QueuedTaskRecord]" = []
to_insert: "list[QueuedTaskRecord]" = []
terminal_keys_to_clear: "list[UUID]" = []
batch_new_by_key: "dict[str, QueuedTaskRecord]" = {}
for request in requests:
key = request.key
if key is not None:
reused = self._reuse_for_key(key, existing_by_key, batch_new_by_key, terminal_keys_to_clear)
if reused is not None:
results.append(reused)
continue
record = self._record_from_request(request, now)
results.append(record)
to_insert.append(record)
if key is not None:
batch_new_by_key[key] = record
return results, to_insert, terminal_keys_to_clear
@staticmethod
def _reuse_for_key(
key: "str",
existing_by_key: "dict[str, QueuedTaskRecord]",
batch_new_by_key: "dict[str, QueuedTaskRecord]",
terminal_keys_to_clear: "list[UUID]",
) -> "QueuedTaskRecord | None":
"""Return the record to reuse for ``key``, or ``None`` if a new row is needed.
Records a terminal key for clearing so its row can be replaced.
"""
active = existing_by_key.get(key)
if active is not None and not active.is_terminal:
return active
earlier = batch_new_by_key.get(key)
if earlier is not None:
return earlier
if active is not None:
terminal_keys_to_clear.append(active.id)
del existing_by_key[key]
return None
@staticmethod
def _record_from_request(request: "TaskRequest", now: "datetime") -> "QueuedTaskRecord":
return QueuedTaskRecord(
task_name=request.task_name,
args=request.args,
kwargs=dict(request.kwargs or {}),
queue=request.queue,
execution_backend=request.execution_backend,
execution_profile=request.execution_profile,
status=("scheduled" if request.scheduled_at is not None and request.scheduled_at > now else "pending"),
priority=request.priority,
max_retries=request.max_retries,
scheduled_at=request.scheduled_at,
expires_at=request.expires_at,
key=request.key,
metadata=dict(request.metadata or {}),
created_at=now,
queued_at=now,
)
async def _bulk_insert(
self, driver: "SQLSpecDriver", store: "SQLSpecQueueStore", records: "list[QueuedTaskRecord]"
) -> "None":
"""Insert records using the adapter's fastest available bulk tier."""
values = store.bulk_values([self._params_from_record(record) for record in records])
if store.supports_native_bulk_ingest:
await driver.load_from_records(store.table_name, values)
else:
await driver.execute_many(store.insert_tasks_template(), values)
@overload
def _serialize_datetime(self, value: "datetime") -> "datetime | str": ...
@overload
def _serialize_datetime(self, value: "None") -> "None": ...
def _serialize_datetime(self, value: "datetime | None") -> "datetime | str | None":
serialized = _serialize_datetime(value)
store = self._get_store()
if serialized is not None and store.bind_datetime_as_text:
formatter = getattr(store, "serialize_datetime_text", None)
if callable(formatter):
return cast("str", formatter(serialized))
return serialized.isoformat()
if serialized is not None and store.bind_datetime_as_naive_utc:
return serialized.replace(tzinfo=None)
return serialized
def _params_from_record(self, record: "QueuedTaskRecord") -> "dict[str, Any]":
store = self._get_store()
return {
"args_json": store.serialize_json("args_json", list(record.args)),
"completed_at": self._serialize_datetime(record.completed_at),
"created_at": self._serialize_datetime(record.created_at),
"queued_at": self._serialize_datetime(record.queued_at),
"error": record.error,
"expires_at": self._serialize_datetime(record.expires_at),
"execution_backend": record.execution_backend,
"execution_profile": record.execution_profile,
"execution_ref": record.execution_ref,
"worker_id": record.worker_id,
"heartbeat_at": self._serialize_datetime(record.heartbeat_at),
"id": str(record.id),
"kwargs_json": store.serialize_json("kwargs_json", record.kwargs),
"max_retries": record.max_retries,
"metadata_json": store.serialize_json("metadata_json", record.metadata),
"priority": record.priority,
"queue": record.queue,
"result_json": store.serialize_json("result_json", record.result),
"retry_count": record.retry_count,
"scheduled_at": self._serialize_datetime(record.scheduled_at),
"started_at": self._serialize_datetime(record.started_at),
"status": record.status,
"task_key": record.key,
"task_name": record.task_name,
}
def _record_from_row(self, row: "dict[str, Any]") -> "QueuedTaskRecord":
store = self._get_store()
args = _coerce_record_args(store.deserialize_json("args_json", row["args_json"]))
kwargs = _coerce_record_mapping("kwargs_json", store.deserialize_json("kwargs_json", row["kwargs_json"]))
metadata = _coerce_record_mapping(
"metadata_json", store.deserialize_json("metadata_json", row["metadata_json"])
)
return QueuedTaskRecord(
id=UUID(str(row["id"])),
task_name=str(row["task_name"]),
args=args,
kwargs=kwargs,
queue=str(row["queue"]),
execution_backend=str(row["execution_backend"]),
execution_profile=cast("str | None", row["execution_profile"]),
execution_ref=cast("str | None", row["execution_ref"]),
worker_id=cast("str | None", row["worker_id"]),
status=_coerce_status(row["status"]),
priority=int(row["priority"]),
max_retries=int(row["max_retries"]),
retry_count=int(row["retry_count"]),
scheduled_at=_deserialize_datetime(row["scheduled_at"]),
expires_at=_deserialize_datetime(row["expires_at"]),
created_at=cast("datetime", _deserialize_datetime(row["created_at"])),
queued_at=cast("datetime", _deserialize_datetime(row["queued_at"])),
started_at=_deserialize_datetime(row["started_at"]),
completed_at=_deserialize_datetime(row["completed_at"]),
heartbeat_at=_deserialize_datetime(row["heartbeat_at"]),
result=store.deserialize_json("result_json", row["result_json"]),
error=cast("str | None", row["error"]),
key=cast("str | None", row["task_key"]),
metadata=metadata,
)
class _ManagedAsyncDriver:
"""Expose sync SQLSpec driver methods through one session-bound executor."""
__slots__ = ("_driver", "_executor", "_skip_explicit_begin", "_transaction_finalized")
def __init__(
self, driver: "object", executor: "ThreadPoolExecutor", *, skip_explicit_begin: "bool" = False
) -> "None":
self._driver = cast("Any", driver)
self._executor = executor
self._skip_explicit_begin = skip_explicit_begin
self._transaction_finalized = False
@property
def transaction_finalized(self) -> "bool":
"""Whether the session explicitly committed or rolled back."""
return self._transaction_finalized
async def begin(self) -> "Any":
self._transaction_finalized = False
if self._skip_explicit_begin:
return None
return await async_(self._driver.begin, executor=self._executor)()
async def commit(self) -> "Any":
result = await async_(self._driver.commit, executor=self._executor)()
self._transaction_finalized = True
return result
async def rollback(self) -> "Any":
result = await async_(self._driver.rollback, executor=self._executor)()
self._transaction_finalized = True
return result
async def execute(self, statement: "Any", *parameters: "Any", **kwargs: "Any") -> "Any":
return await async_(self._driver.execute, executor=self._executor)(statement, *parameters, **kwargs)
async def execute_many(self, statement: "Any", parameters: "Sequence[dict[str, Any]]") -> "Any":
return await async_(self._driver.execute_many, executor=self._executor)(statement, parameters)
async def execute_script(self, statement: "str") -> "Any":
return await async_(self._driver.execute_script, executor=self._executor)(statement)
async def load_from_records(self, table_name: "str", records: "Sequence[dict[str, Any]]") -> "Any":
return await async_(self._driver.load_from_records, executor=self._executor)(table_name, records)
async def select(self, statement: "Any", *parameters: "Any", **kwargs: "Any") -> "list[Any]":
return cast(
"list[Any]", await async_(self._driver.select, executor=self._executor)(statement, *parameters, **kwargs)
)
async def select_one_or_none(self, statement: "Any", *parameters: "Any", **kwargs: "Any") -> "Any | None":
return await async_(self._driver.select_one_or_none, executor=self._executor)(statement, *parameters, **kwargs)
async def select_stream(self, statement: "Any", *, chunk_size: "int | None" = None) -> "AsyncIterator[Any]":
del chunk_size
for row in await self.select(statement):
yield row
def __getattr__(self, name: "str") -> "Any":
attr = getattr(self._driver, name)
if callable(attr):
return async_(attr, executor=self._executor)
return attr
@asynccontextmanager
async def _bridge_session(
sqlspec_manager: "SQLSpecManager",
sqlspec_config: "SQLSpecSessionConfig",
*,
skip_explicit_begin: "bool" = False,
skip_cleanup_rollback: "bool" = False,
executor: "ThreadPoolExecutor | None" = None,
thread_name_prefix: "str" = "litestar-queues-sqlspec-sync",
) -> "AsyncIterator[SQLSpecDriver]":
"""Yield a SQLSpec driver regardless of sync/async config.
Sync SQLSpec configs (``SqliteConfig``, ``DuckDBConfig``, ``MysqlConnectorSyncConfig``, etc.)
return sync context managers and sync drivers. They are bridged with
``sqlspec.utils.sync_tools.async_`` so blocking operations use SQLSpec's
managed executor and honor ``SQLSPEC_ASYNC_THREAD_LIMIT``.
Yields:
A SQLSpec driver whose methods can be awaited regardless of whether the
underlying config is sync or async.
"""
session_cm = sqlspec_manager.provide_session(sqlspec_config)
if sqlspec_config.is_async:
async with session_cm as driver:
yield cast("SQLSpecDriver", driver)
else:
owns_executor = executor is None
sync_executor = executor or ThreadPoolExecutor(max_workers=1, thread_name_prefix=thread_name_prefix)
try:
driver = await async_(session_cm.__enter__, executor=sync_executor)()
managed_driver = _ManagedAsyncDriver(driver, sync_executor, skip_explicit_begin=skip_explicit_begin)
try:
yield managed_driver
except BaseException as exc:
if not managed_driver.transaction_finalized and not skip_cleanup_rollback:
await _rollback_sync_session(driver, executor=sync_executor)
if not await async_(session_cm.__exit__, executor=sync_executor)(type(exc), exc, exc.__traceback__):
raise
else:
if not managed_driver.transaction_finalized and not skip_cleanup_rollback:
await _rollback_sync_session(driver, executor=sync_executor)
await async_(session_cm.__exit__, executor=sync_executor)(None, None, None)
finally:
if owns_executor:
sync_executor.shutdown(wait=True)
async def _rollback_sync_session(driver: "object", *, executor: "ThreadPoolExecutor | None" = None) -> "None":
"""Best-effort cleanup for sync SQLSpec sessions before pool return."""
rollback = getattr(driver, "rollback", None)
if callable(rollback):
with suppress(Exception):
await async_(rollback, executor=executor)()
async def _select_stream(
driver: "SQLSpecDriver", statement: "Any", *parameters: "Any", chunk_size: "int | None" = None
) -> "AsyncIterator[Any]":
"""Yield rows from SQLSpec async and sync stream implementations.
Yields:
Rows returned by the SQLSpec statement.
"""
if isinstance(driver, _ManagedAsyncDriver):
rows = await driver.select(statement, *parameters)
for row in rows:
yield row
else:
if chunk_size is None:
stream = driver.select_stream(statement, *parameters)
else:
stream = driver.select_stream(statement, *parameters, chunk_size=chunk_size)
if isawaitable(stream):
stream = await stream
async for row in stream:
yield row
def _utc_now() -> "datetime":
return datetime.now(timezone.utc)
def _rows_affected(result: "Any", adapter_name: "str | None" = None) -> "int":
"""Return the reported affected-row count.
Normalized to ``-1`` (the existing "unknown, verify" sentinel) when
``adapter_name`` is one of :data:`_UNRELIABLE_ROWCOUNT_ADAPTERS`, whose
driver can report a genuine ``0`` and an unparsable result identically.
"""
rows_affected = int(getattr(result, "rows_affected", 0) or 0)
if rows_affected == 0 and adapter_name in _UNRELIABLE_ROWCOUNT_ADAPTERS:
return -1
return rows_affected
def _is_unique_violation(exc: "BaseException") -> "bool":
current: "BaseException | None" = exc
while current is not None:
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
if sqlstate in {"23000", "23505"}:
return True
message = str(current).lower()
if any(
token in message
for token in ("duplicate entry", "duplicate key", "unique constraint", "unique violation", "ora-00001")
):
return True
current = current.__cause__ or current.__context__
return False
def _is_serialization_conflict(exc: "BaseException") -> "bool":
current: "BaseException | None" = exc
while current is not None:
if isinstance(current, SerializationConflictError):
return True
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
if sqlstate == "40001":
return True
message = str(current).lower()
if "restart transaction" in message or "writetooold" in message or "serialization" in message:
return True
current = current.__cause__ or current.__context__
return False
def _coerce_record_args(value: "Any") -> "tuple[Any, ...]":
if isinstance(value, (list, tuple)):
return tuple(value)
msg = f"SQLSpec queue backend expected args_json to decode to a JSON array, got {type(value).__name__}"
raise ValueError(msg)
def _coerce_record_mapping(canonical: "str", value: "Any") -> "dict[str, Any]":
if isinstance(value, dict):
return value
msg = f"SQLSpec queue backend expected {canonical} to decode to a JSON object, got {type(value).__name__}"
raise ValueError(msg)
@overload
def _serialize_datetime(value: "datetime") -> "datetime": ...
@overload
def _serialize_datetime(value: "None") -> "None": ...
def _serialize_datetime(value: "datetime | None") -> "datetime | None":
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _deserialize_datetime(value: "Any") -> "datetime | None":
if value is None:
return None
value_text = str(value)
try:
parsed = datetime.fromisoformat(value_text)
except ValueError:
parsed = datetime.strptime(value_text.upper(), "%d-%b-%y").replace(tzinfo=timezone.utc)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _coerce_status(value: "Any") -> "TaskStatus":
status = str(value)
if status not in {"cancelled", "completed", "expired", "failed", "pending", "running", "scheduled"}:
msg = f"Unknown queued task status from SQLSpec queue backend: {status!r}"
raise ValueError(msg)
return cast("TaskStatus", status)
def _reservation_from_row(row: "dict[str, Any]") -> "TaskReservation":
return TaskReservation(
key=str(row["identity_key"]),
task_id=UUID(str(row["task_id"])),
task_name=str(row["task_name"]),
created_at=_deserialize_datetime(row["created_at"]) or _utc_now(),
)
def _queue_extension_settings(sqlspec_config: "SQLSpecStoreConfig | None") -> "dict[str, Any]":
if sqlspec_config is None:
return {}
extension_config = sqlspec_config.extension_config or {}
return dict(extension_config.get(QUEUE_EXTENSION_NAME, {}) or {})
async def _create_schema_statements(store: "SQLSpecQueueStore", driver: "SQLSpecDriver") -> "list[str]":
create_for_driver = getattr(store, "create_statements_for_driver", None)
if callable(create_for_driver):
result = create_for_driver(driver)
if isawaitable(result):
return cast("list[str]", await result)
return cast("list[str]", result)
return store.create_statements()
def _resolve_wakeup_transport(*, explicit_transport: "str | None", sqlspec_config: "SQLSpecConfig") -> "str":
"""Resolve the effective wakeup transport from typed config and adapter capabilities.
Explicit worker-wakeup configuration wins over the per-adapter capability gate.
Returns:
A canonical wakeup transport name.
"""
if explicit_transport is not None:
return explicit_transport
return _adapter_wakeup_transport(resolve_adapter_name(sqlspec_config))
def resolve_events_migration_backend(
backend_config: "SQLSpecBackendConfig", sqlspec_config: "SQLSpecConfig"
) -> "str | None":
"""Return the durable events-table transport to register for migrations.
Mirrors the runtime notification decision so a capability-native adapter
provisions its events queue table through SQLSpec migrations with zero
configuration. Returns the transport name (``notify_queue`` / ``poll_queue``)
when the events queue table must exist, or ``None`` when notifications are
opted out, transient (``notify``), Oracle AQ (provisioned separately), or the
adapter polls.
Returns:
The durable events-table transport name, or ``None``.
"""
if backend_config.worker_wakeups is None or backend_config.worker_wakeups.channel is not None:
return None
transport = _resolve_wakeup_transport(
explicit_transport=backend_config.worker_wakeups.transport, sqlspec_config=sqlspec_config
)
return transport if transport in _EVENTS_TABLE_BACKENDS else None
def _events_queue_create_statements(sqlspec_config: "SQLSpecConfig") -> "list[str]":
"""Return the DDL provisioning the durable events queue table for this adapter.
Resolves the adapter's :class:`~sqlspec.extensions.events.BaseEventQueueStore`
the same way SQLSpec's events extension migration does and returns its
dialect-correct ``CREATE TABLE``/``CREATE INDEX`` statements. Emitting these
alongside the queue table makes the durable ``notify_queue`` / ``poll_queue``
wakeup transports work on a fresh database with no separate migration step.
Returns:
The ``CREATE`` statements for the events queue table and its index.
"""
from sqlspec.utils.module_loader import import_string
config_class = type(sqlspec_config)
adapter_name = config_class.__module__.split(".")[2]
store_class_name = config_class.__name__.replace("Config", "EventQueueStore")
store_class = import_string(f"sqlspec.adapters.{adapter_name}.events.store.{store_class_name}")
return cast("list[str]", store_class(sqlspec_config).create_statements())
def _events_extension_settings(sqlspec_config: "SQLSpecStoreConfig | None") -> "dict[str, Any]":
if sqlspec_config is None:
return {}
extension_config = sqlspec_config.extension_config or {}
return dict(extension_config.get(_EVENT_EXTENSION_NAME, {}) or {})
async def _invoke_event_channel_method(event_channel: "Any", method_name: "str", *args: "Any") -> "Any":
"""Invoke a SQLSpec sync or async event-channel method without blocking the loop.
Returns:
The event-channel method result.
"""
method = getattr(event_channel, method_name)
if iscoroutinefunction(method):
return await method(*args)
result = await async_(method)(*args)
if isawaitable(result):
return await result
return result
async def _next_event(stream: "Any") -> "Any":
"""Read from a SQLSpec sync or async event iterator.
Returns:
The next event message.
"""
if hasattr(stream, "__anext__"):
return await anext(stream)
has_event, event = await async_(_next_sync_event)(stream)
if not has_event:
raise StopAsyncIteration
return event
def _next_sync_event(stream: "Iterator[Any]") -> "tuple[bool, Any]":
"""Read one sync event without leaking ``StopIteration`` through a future.
Returns:
A pair indicating whether an event was read and the event value.
"""
try:
return True, next(stream)
except StopIteration:
return False, None
def _setting(queue_settings: "dict[str, Any]", *names: "str") -> "Any":
for name in names:
if name in queue_settings:
return queue_settings[name]
return None
def _normalize_wakeup_channel(channel: "str") -> "str":
try:
return str(normalize_event_channel_name(channel))
except Exception as exc:
msg = f"Invalid SQLSpec queue notification channel: {channel!r}"
raise QueueConfigurationError(msg) from exc