"""Advanced Alchemy queue persistence service."""
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4
from advanced_alchemy.operations import OnConflictUpsert
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.utils.serialization import decode_json as _decode_json
from advanced_alchemy.utils.serialization import encode_json as _encode_json
from sqlalchemy import and_, case, delete, desc, func, literal, or_, select, update
from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy.orm.exc import UnmappedColumnError
from litestar_queues.backends.advanced_alchemy.repository import (
QueueEventLogRepository,
QueueTaskRepository,
QueueTaskReservationRepository,
)
from litestar_queues.backends.base import (
EXTERNAL_DISPATCH_RESERVATION_PREFIX,
STALE_HEARTBEAT_ERROR,
STALE_REQUEUE_PRIORITY,
attempts_consumed,
interruption_count,
record_matches_filters,
retry_schedule,
stale_requeue_error,
stale_requeue_priority,
)
from litestar_queues.events import QueueEventLogRecord
from litestar_queues.events._log_records import optional_float
from litestar_queues.models import (
HeartbeatTouchResult,
QueuedTaskRecord,
QueueStatistics,
StaleTaskRecoveryResult,
TaskStatus,
)
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from litestar_queues.backends.advanced_alchemy.mixins import (
QueueEventHistoryModelMixin,
QueueTaskModelMixin,
QueueTaskReservationModelMixin,
)
from litestar_queues.config import StaleRequeuePriority
from litestar_queues.events import QueueEventQuery, QueueEventStageSummary
from litestar_queues.models import HeartbeatTouch, TaskRequest
__all__ = ("QueueEventLogService", "QueueTaskReservationService", "QueueTaskService")
_DUE_STATUSES = ("pending", "scheduled")
_TERMINAL_STATUSES = ("completed", "failed", "cancelled", "expired")
_SKIP_LOCKED_CLAIM_DIALECTS = frozenset({"oracle", "postgresql"})
_NATIVE_KEYED_ENQUEUE_DIALECTS = frozenset({"mariadb", "mysql", "oracle", "postgresql"})
_ORACLE_CLAIM_CANDIDATE_LIMIT = 10
_CAS_CLAIM_BATCH_SIZE = 10
[docs]
class QueueEventLogService(SQLAlchemyAsyncRepositoryService[Any]):
"""Persistence operations for Advanced Alchemy queue event-history records."""
[docs]
@classmethod
def for_model(cls, model_class: "type[QueueEventHistoryModelMixin]") -> 'type["QueueEventLogService"]':
"""Return a service subclass bound to ``model_class``."""
repository_type = QueueEventLogRepository.for_model(model_class)
return cast(
"type[QueueEventLogService]",
type(f"QueueEventLogServiceFor{model_class.__name__}", (cls,), {"repository_type": repository_type}),
)
[docs]
async def add_records(self, records: "Sequence[QueueEventLogRecord]") -> "None":
"""Persist event-history records."""
self.repository.session.add_all([self.model_from_record(record) for record in records])
def _criteria(self, query: "QueueEventQuery") -> "list[Any]":
model = self.model_type
criteria = []
if query.task_id is not None:
criteria.append(model.task_id == query.task_id)
if query.task_name is not None:
criteria.append(model.task_name == query.task_name)
if query.event_type is not None:
criteria.append(model.event_type == query.event_type)
if query.level is not None:
criteria.append(model.level == query.level)
if query.scope is not None:
criteria.append(model.scope == query.scope)
if query.scope_key is not None:
criteria.append(model.scope_key == query.scope_key)
if query.entity is not None:
criteria.append(model.entity == query.entity)
return criteria
[docs]
async def query_events(self, query: "QueueEventQuery") -> "tuple[int, list[QueueEventLogRecord]]":
model_type = self.model_type
criteria = self._criteria(query)
statement = select(model_type).where(*criteria)
if query.order == "asc":
statement = statement.order_by(
model_type.occurred_at.asc(), model_type.sequence.asc(), model_type.event_id.asc()
)
else:
statement = statement.order_by(
model_type.occurred_at.desc(), model_type.sequence.desc(), model_type.event_id.desc()
)
if query.offset:
statement = statement.offset(query.offset)
if query.limit:
statement = statement.limit(query.limit + 1)
models = await self.get_many(statement=statement)
records = [self.record_from_model(model) for model in models]
total = await self.count(*criteria)
return total, records
[docs]
async def summarize_stages(self, query: "QueueEventQuery | None" = None) -> "list[QueueEventStageSummary]":
from litestar_queues.events import QueueEventStageSummary
from litestar_queues.exceptions import QueueConfigurationError
if query and (query.limit is not None or query.offset > 0):
msg = "Pagination is not supported for stage summaries."
raise QueueConfigurationError(msg)
model_type = self.model_type
criteria = self._criteria(query) if query else []
# Aggregate query
agg_stmt = (
select(
model_type.stage,
func.count().label("event_count"),
func.sum(model_type.duration_ms).label("total_duration_ms"),
func.min(model_type.occurred_at).label("first_event_at"),
func.max(model_type.occurred_at).label("last_event_at"),
)
.where(*criteria)
.group_by(model_type.stage)
)
agg_results = (await self.repository.session.execute(agg_stmt)).all()
if not agg_results:
return []
# Get latest message, sequence, worst level for each stage using partition/window or separate query per stage
summaries = []
for row in agg_results:
stage_criteria = list(criteria)
stage_criteria.append(model_type.stage == row.stage)
# Fetch newest row to get sequence and message
newest_stmt = (
select(model_type)
.where(*stage_criteria)
.order_by(model_type.occurred_at.desc(), model_type.sequence.desc(), model_type.event_id.desc())
.limit(1)
)
newest_row = (await self.repository.session.execute(newest_stmt)).scalars().first()
# Find worst level - rank levels, but for now we just do a simple approach.
# Wait, the spec says "Highest-ranked level present in the stage"
# In test_memory_event_query it checks if worst_level is 'error' when 'info' 'error' exist.
# I will use the Python ranking logic from QueueEventStageSummary if needed,
# or just query distinct levels.
levels_stmt = select(model_type.level).where(*stage_criteria, model_type.level.is_not(None)).distinct()
levels = (await self.repository.session.execute(levels_stmt)).scalars().all()
# RANK_MAP logic:
level_ranks = {"debug": 10, "info": 20, "warning": 30, "error": 40, "critical": 50}
worst_level = None
if levels:
worst_level = max(levels, key=lambda lvl: level_ranks.get(str(lvl).lower(), 0))
summaries.append(
QueueEventStageSummary(
stage=row.stage,
event_count=row.event_count,
total_duration_ms=row.total_duration_ms or 0.0,
first_event_at=_coerce_datetime(row.first_event_at) if row.first_event_at else None,
last_event_at=_coerce_datetime(row.last_event_at) if row.last_event_at else None,
latest_sequence=int(newest_row.sequence)
if newest_row and newest_row.sequence is not None
else None,
latest_message=newest_row.message if newest_row else None,
worst_level=worst_level,
)
)
return summaries
[docs]
async def cleanup_events(
self,
before: "datetime",
*,
limit: "int | None" = None,
match: "QueueEventQuery | None" = None,
exclude: "tuple[QueueEventQuery, ...] | None" = None,
) -> "int":
"""Delete event-history records older than ``before``.
``limit`` bounds one batch, deleting the oldest matching rows first
(oldest ``occurred_at``, then id).
Returns:
Number of deleted event-history rows.
"""
model_type = self.model_type
criteria = [model_type.occurred_at < before]
if match:
criteria.extend(self._criteria(match))
if exclude:
for ex in exclude:
ex_criteria = self._criteria(ex)
if ex_criteria:
criteria.append(~and_(*ex_criteria))
if limit is not None:
bounded_query = (
select(model_type.event_id)
.where(*criteria)
.order_by(model_type.occurred_at, model_type.sequence, model_type.event_id)
.limit(limit)
)
raw_result = await self.repository.session.execute(bounded_query)
target_ids = list(raw_result.scalars().all())
if not target_ids:
return 0
statement = delete(model_type).where(model_type.event_id.in_(target_ids))
else:
statement = delete(model_type).where(*criteria)
result = await self.repository.session.execute(statement)
return int(result.rowcount or 0)
[docs]
def model_from_record(self, record: "QueueEventLogRecord") -> "Any":
"""Convert a backend-neutral event-history record into an ORM model.
Returns:
Advanced Alchemy event-history model.
"""
detail = dict(record.detail)
if record.extra:
detail["__extra__"] = record.extra
return self.model_type(
event_id=record.event_id,
event_type=record.event_type,
task_id=record.task_id,
task_name=record.task_name,
queue=record.queue,
worker_id=record.worker_id,
execution_backend=record.execution_backend,
execution_profile=record.execution_profile,
actor_type=record.actor_type,
actor_id=record.actor_id,
stage=record.stage,
scope=record.scope,
scope_key=record.scope_key,
actor=record.actor,
entity=record.entity,
level=record.level,
message=record.message,
detail_json=_serialize_json(detail),
progress_current=record.progress_current,
progress_total=record.progress_total,
progress_percent=record.progress_percent,
duration_ms=record.duration_ms,
sequence=record.sequence,
occurred_at=record.occurred_at,
created_at=record.created_at,
)
[docs]
@staticmethod
def record_from_model(model: "Any") -> "QueueEventLogRecord":
"""Convert an ORM model into a backend-neutral event-history record.
Returns:
Backend-neutral event-history record.
"""
detail = _deserialize_json(model.detail_json)
if not isinstance(detail, dict):
detail = {}
extra = dict(detail.pop("__extra__", None) or {})
return QueueEventLogRecord(
event_id=str(model.event_id),
event_type=str(model.event_type),
task_id=cast("str | None", model.task_id),
task_name=cast("str | None", model.task_name),
queue=cast("str | None", model.queue),
worker_id=cast("str | None", model.worker_id),
execution_backend=cast("str | None", model.execution_backend),
execution_profile=cast("str | None", model.execution_profile),
actor_type=cast("str | None", model.actor_type),
actor_id=cast("str | None", model.actor_id),
stage=cast("str | None", model.stage),
scope=cast("str | None", model.scope),
scope_key=cast("str | None", model.scope_key),
actor=cast("str | None", model.actor),
entity=cast("str | None", model.entity),
level=cast("str | None", model.level),
message=cast("str | None", model.message),
detail=detail,
progress_current=optional_float(model.progress_current),
progress_total=optional_float(model.progress_total),
progress_percent=optional_float(model.progress_percent),
duration_ms=optional_float(model.duration_ms),
sequence=int(model.sequence) if model.sequence is not None else None,
occurred_at=cast("datetime", _coerce_datetime(model.occurred_at)),
created_at=cast("datetime", _coerce_datetime(model.created_at)),
extra=extra,
)
[docs]
class QueueTaskReservationService(SQLAlchemyAsyncRepositoryService[Any]):
"""Persistence operations for forever-uniqueness reservations."""
[docs]
@classmethod
def for_model(cls, model_class: "type[QueueTaskReservationModelMixin]") -> 'type["QueueTaskReservationService"]':
"""Return a service subclass bound to ``model_class``."""
repository_type = QueueTaskReservationRepository.for_model(model_class)
return cast(
"type[QueueTaskReservationService]",
type(f"QueueTaskReservationServiceFor{model_class.__name__}", (cls,), {"repository_type": repository_type}),
)
[docs]
async def reserve(self, key: "str", *, task_id: "UUID", task_name: "str") -> "Any | None":
"""Reserve ``key`` by select-then-insert within the caller's transaction.
Returns:
``None`` when the reservation was inserted; otherwise the existing
owner model.
"""
existing = await self.repository.get_one_or_none(identity_key=key)
if existing is not None:
return existing
model = self.repository.model_type(identity_key=key, task_id=str(task_id), task_name=task_name)
await self.repository.add(model, auto_commit=False, auto_refresh=False)
return None
[docs]
async def get_owner(self, key: "str") -> "Any | None":
"""Return the reservation model owning ``key``, if any."""
return await self.repository.get_one_or_none(identity_key=key)
[docs]
async def delete_by_key(self, key: "str", *, expected_task_id: "UUID | None" = None) -> "bool":
"""Delete the reservation for ``key`` with optional owner fencing.
Args:
key: The exact effective identity key.
expected_task_id: Optional task owner required for deletion.
Returns:
``True`` when a reservation row was removed.
"""
model_type = self.repository.model_type
predicates = [model_type.identity_key == key]
if expected_task_id is not None:
predicates.append(model_type.task_id == str(expected_task_id))
result = await self.repository.session.execute(delete(model_type).where(*predicates))
return int(result.rowcount or 0) > 0
[docs]
class QueueTaskService(SQLAlchemyAsyncRepositoryService[Any]):
"""Persistence operations for Advanced Alchemy queue records."""
[docs]
@classmethod
def for_model(cls, model_class: "type[QueueTaskModelMixin]") -> 'type["QueueTaskService"]':
"""Return a service subclass bound to ``model_class``."""
repository_type = QueueTaskRepository.for_model(model_class)
return cast(
"type[QueueTaskService]",
type(f"QueueTaskServiceFor{model_class.__name__}", (cls,), {"repository_type": repository_type}),
)
[docs]
async def enqueue(
self,
task_name: "str",
*,
args: "tuple[Any, ...]",
kwargs: "dict[str, Any]",
queue: "str",
priority: "int",
max_retries: "int",
scheduled_at: "datetime | None",
expires_at: "datetime | None" = None,
key: "str | None",
execution_backend: "str",
execution_profile: "str | None",
metadata: "dict[str, Any]",
id: "UUID | None" = None, # noqa: A002
) -> "QueuedTaskRecord":
if key is not None:
existing = await self._select_task_by_key(key)
if existing is not None:
existing_record = self.record_from_model(existing)
if not existing_record.is_terminal:
return existing_record
existing.task_key = None
await self.repository.session.flush()
now = _utc_now()
record = QueuedTaskRecord(
task_name=task_name,
args=args,
kwargs=dict(kwargs),
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),
created_at=now,
queued_at=now,
)
if id is not None:
record.id = id
return await self._insert_task_record(record, key=key)
[docs]
async def enqueue_many(self, requests: "Sequence[TaskRequest]") -> "list[QueuedTaskRecord]":
"""Persist many task requests in the current repository transaction.
Returns:
Queue task records in input order.
"""
records: "list[QueuedTaskRecord]" = []
for request in requests:
records.append(
await self.enqueue(
request.task_name,
args=request.args,
kwargs=dict(request.kwargs or {}),
queue=request.queue,
priority=request.priority,
max_retries=request.max_retries,
scheduled_at=request.scheduled_at,
expires_at=request.expires_at,
key=request.key,
execution_backend=request.execution_backend,
execution_profile=request.execution_profile,
metadata=dict(request.metadata or {}),
)
)
return records
[docs]
async def get_task(self, task_id: "UUID") -> "QueuedTaskRecord | None":
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def get_task_by_key(self, key: "str") -> "QueuedTaskRecord | None":
model = await self._select_task_by_key(key)
return self.record_from_model(model) if model is not None else None
[docs]
async def list_pending(
self, *, limit: "int", queue: "str | None", execution_backend: "str | None"
) -> "list[QueuedTaskRecord]":
statement = self._pending_statement(queue=queue, execution_backend=execution_backend).limit(limit)
models = await self.get_many(statement=statement)
return [self.record_from_model(model) for model in models]
[docs]
async def next_scheduled_at(self, *, queues: "Sequence[str]" = ()) -> "datetime | None":
"""Return the earliest not-yet-due ``scheduled_at`` among pending/scheduled records.
Used to bound the worker's adaptive polling wait so a scheduled or
retried task is never discovered later than its own due time.
Returns:
The earliest future ``scheduled_at``, or ``None`` when there is
no upcoming scheduled work.
"""
model_type = self.model_type
now = _utc_now()
criteria = [
model_type.status.in_(_DUE_STATUSES),
model_type.scheduled_at > now,
or_(model_type.expires_at.is_(None), model_type.expires_at > now),
]
if queues:
criteria.append(model_type.queue.in_(list(queues)))
statement = select(func.min(model_type.scheduled_at)).where(*criteria)
result = await self.repository.session.execute(statement)
return _coerce_datetime(result.scalar())
[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."""
now = _utc_now()
model_type = self.model_type
criteria = [
model_type.id == task_id,
model_type.status.in_(_DUE_STATUSES),
or_(model_type.scheduled_at.is_(None), model_type.scheduled_at <= now),
or_(model_type.expires_at.is_(None), model_type.expires_at > now, model_type.execution_ref.is_not(None)),
or_(
model_type.execution_ref.is_(None),
model_type.execution_ref.not_like(f"{EXTERNAL_DISPATCH_RESERVATION_PREFIX}%"),
),
]
if expected_retry_count is not None:
criteria.append(model_type.retry_count == expected_retry_count)
if expected_execution_ref is not None:
criteria.append(model_type.execution_ref == expected_execution_ref)
result = await self.repository.session.execute(
update(model_type)
.where(*criteria)
.values(_update_values(model_type, {"status": "running", "started_at": now, "heartbeat_at": now}, now=now))
)
if result.rowcount != 1:
ownership_token = f"__litestar_queues_expiry__:{uuid4()}"
expire_result = await self.repository.session.execute(
update(model_type)
.where(
model_type.id == task_id,
model_type.status.in_(_DUE_STATUSES),
model_type.execution_ref.is_(None),
model_type.expires_at.is_not(None),
model_type.expires_at <= now,
)
.values(
_update_values(
model_type,
{
"status": "expired",
"completed_at": now,
"heartbeat_at": None,
"execution_ref": ownership_token,
},
now=now,
)
)
.execution_options(synchronize_session=False)
)
if getattr(expire_result, "rowcount", None) == 0:
return None, None
model = (
(
await self.repository.session.execute(
select(model_type)
.where(model_type.id == task_id, model_type.execution_ref == ownership_token)
.execution_options(populate_existing=True)
)
)
.scalars()
.first()
)
if model is None:
return None, None
await self.repository.session.execute(
update(model_type)
.where(model_type.id == task_id, model_type.execution_ref == ownership_token)
.values(execution_ref=None)
.execution_options(synchronize_session=False)
)
expired = self.record_from_model(model)
expired.execution_ref = None
return None, expired
model = await self._select_task(task_id)
return (self.record_from_model(model) if model is not None else None), None
[docs]
async def claim_next(self, *, queue: "str | None", execution_backend: "str | None") -> "QueuedTaskRecord | None":
if _supports_skip_locked_claim(self._dialect_name()):
return await self._claim_next_skip_locked(queue=queue, execution_backend=execution_backend)
skipped_ids: "set[UUID]" = set()
pending_limit = _CAS_CLAIM_BATCH_SIZE
while True:
pending = await self.list_pending(limit=pending_limit, queue=queue, execution_backend=execution_backend)
candidates = [record for record in pending if record.id not in skipped_ids]
if not candidates:
return None
for record in candidates:
claimed = await self.claim_task(record.id)
if claimed is not None:
return claimed
skipped_ids.add(record.id)
if len(pending) < pending_limit:
return None
pending_limit += _CAS_CLAIM_BATCH_SIZE
[docs]
async def claim_many(
self, *, limit: "int", queue: "str | None", execution_backend: "str | None"
) -> "list[QueuedTaskRecord]":
"""Claim up to ``limit`` records in the current repository transaction.
Returns:
Claimed task records.
"""
if limit <= 0:
return []
if _supports_batch_claim(self._dialect_name()):
return await self._claim_many_skip_locked(limit=limit, queue=queue, execution_backend=execution_backend)
records: "list[QueuedTaskRecord]" = []
for _ in range(limit):
claimed = await self.claim_next(queue=queue, execution_backend=execution_backend)
if claimed is None:
break
records.append(claimed)
return records
[docs]
async def claim_many_with_expired(
self, *, limit: "int", queue: "str | None", execution_backend: "str | None"
) -> "tuple[list[QueuedTaskRecord], list[QueuedTaskRecord]]":
"""Claim records and report all expiry transitions owned by this transaction."""
if limit <= 0:
return [], []
expired = await self.expire_overdue()
claimed: "list[QueuedTaskRecord]" = []
seen: "set[UUID]" = set()
while len(claimed) < limit:
pending_limit = max(_CAS_CLAIM_BATCH_SIZE, limit - len(claimed))
pending = await self.list_pending(limit=pending_limit, queue=queue, execution_backend=execution_backend)
candidates = [record for record in pending if record.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(pending) < pending_limit:
break
expired.extend(await self.expire_overdue())
unique_expired = {record.id: record for record in expired}
return claimed, list(unique_expired.values())
async def _claim_next_skip_locked(
self, *, queue: "str | None", execution_backend: "str | None"
) -> "QueuedTaskRecord | None":
now = _utc_now()
dialect_name = self._dialect_name()
if dialect_name == "oracle":
statement = _build_claim_candidate_statement(
self.model_type,
queue=queue,
execution_backend=execution_backend,
now=now,
limit=_ORACLE_CLAIM_CANDIDATE_LIMIT,
skip_locked=False,
)
candidates = (await self.repository.session.execute(statement)).scalars().all()
for candidate in candidates:
lock_statement = _build_claim_lock_statement(self.model_type, UUID(str(candidate.id)))
locked = (await self.repository.session.execute(lock_statement)).scalars().first()
if locked is None:
continue
return await self.claim_task(UUID(str(locked.id)))
return None
statement = _build_claim_candidate_statement(
self.model_type, queue=queue, execution_backend=execution_backend, now=now, limit=1, skip_locked=True
)
row = (await self.repository.session.execute(statement)).scalars().first()
if row is None:
return None
return await self.claim_task(UUID(str(row.id)))
async def _claim_many_skip_locked(
self, *, limit: "int", queue: "str | None", execution_backend: "str | None"
) -> "list[QueuedTaskRecord]":
now = _utc_now()
model_type = self.model_type
statement = _build_claim_candidate_statement(
model_type, queue=queue, execution_backend=execution_backend, now=now, limit=limit, skip_locked=True
)
candidates = (await self.repository.session.execute(statement)).scalars().all()
task_ids = [UUID(str(candidate.id)) for candidate in candidates]
if not task_ids:
return []
await self.repository.session.execute(
update(model_type)
.where(
model_type.id.in_(task_ids),
model_type.status.in_(_DUE_STATUSES),
or_(model_type.scheduled_at.is_(None), model_type.scheduled_at <= now),
or_(model_type.expires_at.is_(None), model_type.expires_at > now),
or_(
model_type.execution_ref.is_(None),
model_type.execution_ref.not_like(f"{EXTERNAL_DISPATCH_RESERVATION_PREFIX}%"),
),
)
.values(_update_values(model_type, {"status": "running", "started_at": now, "heartbeat_at": now}, now=now))
.execution_options(synchronize_session=False)
)
models = (
(
await self.repository.session.execute(
select(model_type).where(model_type.id.in_(task_ids)).execution_options(populate_existing=True)
)
)
.scalars()
.all()
)
by_id = {UUID(str(model.id)): self.record_from_model(model) for model in models}
return [by_id[task_id] for task_id in task_ids if task_id in by_id and by_id[task_id].status == "running"]
[docs]
async def expire_overdue(self, *, limit: "int | None" = None) -> "list[QueuedTaskRecord]":
now = _utc_now()
model_type = self.model_type
statement = (
select(model_type.id)
.where(
model_type.status.in_(_DUE_STATUSES),
model_type.execution_ref.is_(None),
model_type.expires_at.is_not(None),
model_type.expires_at <= now,
)
.order_by(model_type.expires_at, model_type.created_at, model_type.id)
)
if limit is not None:
statement = statement.limit(limit)
task_ids = list((await self.repository.session.execute(statement)).scalars().all())
if not task_ids:
return []
ownership_token = f"__litestar_queues_expiry__:{uuid4()}"
update_result = await self.repository.session.execute(
update(model_type)
.where(
model_type.id.in_(task_ids),
model_type.status.in_(_DUE_STATUSES),
model_type.execution_ref.is_(None),
model_type.expires_at <= now,
)
.values(status="expired", completed_at=now, heartbeat_at=None, execution_ref=ownership_token)
.execution_options(synchronize_session=False)
)
if getattr(update_result, "rowcount", None) == 0:
return []
models = (
(
await self.repository.session.execute(
select(model_type).where(
model_type.id.in_(task_ids),
model_type.status == "expired",
model_type.execution_ref == ownership_token,
)
)
)
.scalars()
.all()
)
if models:
await self.repository.session.execute(
update(model_type)
.where(model_type.id.in_([model.id for model in models]), model_type.execution_ref == ownership_token)
.values(execution_ref=None)
.execution_options(synchronize_session=False)
)
by_id = {model.id: self.record_from_model(model) for model in models}
expired = [by_id[task_id] for task_id in task_ids if task_id in by_id]
for record in expired:
record.execution_ref = None
return expired
[docs]
async def complete_task(
self, task_id: "UUID", *, result: "Any" = None, expected_retry_count: "int | None" = None
) -> "QueuedTaskRecord | None":
now = _utc_now()
model_type = self.model_type
criteria = [model_type.id == task_id]
if expected_retry_count is not None:
criteria.extend((model_type.status == "running", model_type.retry_count == expected_retry_count))
update_result = await self.repository.session.execute(
update(model_type)
.where(*criteria)
.values(
_update_values(
model_type,
{
"status": "completed",
"completed_at": now,
"heartbeat_at": now,
"result_json": _serialize_json(result),
"error": None,
},
now=now,
)
)
)
if update_result.rowcount != 1:
return None
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def fail_task(
self,
task_id: "UUID",
error: "str",
*,
retry: "bool",
expected_retry_count: "int | None" = None,
retry_at: "datetime | None" = None,
queued_at: "datetime | None" = None,
) -> "QueuedTaskRecord | None":
model = await self._select_task(task_id)
if model is None:
return None
if expected_retry_count is not None and (
str(model.status) != "running" or int(model.retry_count) != expected_retry_count
):
return None
if str(model.status) != "running":
return None
model_type = self.model_type
retry_fence = expected_retry_count if expected_retry_count is not None else int(model.retry_count)
criteria = [model_type.id == task_id, model_type.status == "running", model_type.retry_count == retry_fence]
if retry and attempts_consumed(self.record_from_model(model)) < int(model.max_retries):
now = queued_at or _utc_now()
update_result = await self.repository.session.execute(
update(model_type)
.where(*criteria)
.values(
_update_values(
model_type,
{
"status": "scheduled" if retry_at is not None else "pending",
"queued_at": now,
"scheduled_at": retry_at,
"started_at": None,
"heartbeat_at": None,
"retry_count": int(model.retry_count) + 1,
"error": error,
},
)
)
)
else:
now = _utc_now()
update_result = await self.repository.session.execute(
update(model_type)
.where(*criteria)
.values(
_update_values(
model_type,
{"status": "failed", "completed_at": now, "heartbeat_at": now, "error": error},
now=now,
)
)
)
if update_result.rowcount != 1:
return None
updated = await self._select_task(task_id)
return self.record_from_model(updated) if updated is not None else None
[docs]
async def assign_worker(
self, task_id: "UUID", *, worker_id: "str", expected_retry_count: "int"
) -> "QueuedTaskRecord | None":
"""Persist running-record ownership behind a status/generation fence.
Returns:
The owned record, or ``None`` when the fence was lost.
"""
model_type = self.model_type
result = await self.repository.session.execute(
update(model_type)
.where(
model_type.id == task_id, model_type.status == "running", model_type.retry_count == expected_retry_count
)
.values(_update_values(model_type, {"worker_id": worker_id}))
.execution_options(synchronize_session=False)
)
if int(result.rowcount or 0) != 1:
return None
self.repository.session.expire_all()
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def interrupt_task(
self, task_id: "UUID", *, expected_retry_count: "int", worker_id: "str", queued_at: "datetime"
) -> "QueuedTaskRecord | None":
"""Return an owned running attempt to pending behind an owner/generation fence.
Returns:
The requeued record, or ``None`` when the fence was lost.
"""
model_type = self.model_type
current = await self._select_task(task_id)
if current is None:
return None
metadata = _deserialize_json(current.metadata_json)
record = self.record_from_model(current)
metadata["interruptions"] = interruption_count(record) + 1
result = await self.repository.session.execute(
update(model_type)
.where(
model_type.id == task_id,
model_type.status == "running",
model_type.retry_count == expected_retry_count,
model_type.worker_id == worker_id,
)
.values(
_update_values(
model_type,
{
"status": "pending",
"queued_at": queued_at,
"scheduled_at": None,
"started_at": None,
"heartbeat_at": None,
"completed_at": None,
"execution_ref": None,
"worker_id": None,
"retry_count": int(current.retry_count) + 1,
"metadata_json": _serialize_json(metadata),
},
)
)
.execution_options(synchronize_session=False)
)
if int(result.rowcount or 0) != 1:
return None
self.repository.session.expire_all()
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def cancel_task(
self, task_id: "UUID", *, include_running: "bool" = False, expected_retry_count: "int | None" = None
) -> "bool":
model_type = self.model_type
now = _utc_now()
cancellable_statuses = (*_DUE_STATUSES, "running") if include_running else _DUE_STATUSES
stmt = update(model_type).where(model_type.id == task_id, model_type.status.in_(cancellable_statuses))
if expected_retry_count is not None:
stmt = stmt.where(model_type.retry_count == expected_retry_count)
result = await self.repository.session.execute(
stmt.values(
_update_values(model_type, {"status": "cancelled", "completed_at": now, "heartbeat_at": None}, now=now)
)
)
return int(result.rowcount or 0) == 1
[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":
model_type = self.model_type
cancellable_statuses = (*_DUE_STATUSES, "running") if include_running else _DUE_STATUSES
criteria = [model_type.status.in_(cancellable_statuses)]
if task_name is not None:
criteria.append(model_type.task_name == task_name)
if queue is not None:
criteria.append(model_type.queue == queue)
models = (await self.repository.session.execute(select(model_type).where(*criteria))).scalars().all()
cancelled = 0
for model in models:
record = self.record_from_model(model)
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
model_type = self.model_type
now = _utc_now()
groups: "dict[int | None, dict[UUID, HeartbeatTouch]]" = {}
for touch in touches:
groups.setdefault(touch.expected_retry_count, {})[touch.task_id] = touch
dialect_name = self._dialect_name()
for expected_retry_count, grouped_touches in groups.items():
task_ids = set(grouped_touches)
criteria = [model_type.id.in_(task_ids), model_type.status == "running"]
if expected_retry_count is not None:
criteria.append(model_type.retry_count == expected_retry_count)
models = (await self.repository.session.execute(select(model_type).where(*criteria))).scalars().all()
models_by_id = {UUID(str(model.id)): model for model in models}
touched_task_ids = set(models_by_id)
result.missed_task_ids.update(task_ids - touched_task_ids)
if not touched_task_ids:
continue
if dialect_name == "oracle" and any(
grouped_touches[task_id].metadata_patch for task_id in touched_task_ids
):
await self._touch_oracle_heartbeats(
grouped_touches=grouped_touches,
models_by_id=models_by_id,
expected_retry_count=expected_retry_count,
now=now,
result=result,
)
continue
values: "dict[str, Any]" = {"heartbeat_at": now}
metadata_column = _mapped_column(model_type, "metadata_json")
metadata_cases: "list[tuple[Any, Any]]" = []
for task_id, model in models_by_id.items():
metadata_patch = grouped_touches[task_id].metadata_patch
if not metadata_patch:
continue
metadata = dict(_deserialize_json(model.metadata_json) or {})
metadata.update(metadata_patch)
metadata_cases.append((
model_type.id == task_id,
literal(_serialize_json(metadata), type_=metadata_column.type),
))
if metadata_cases:
values["metadata_json"] = case(*metadata_cases, else_=metadata_column)
update_criteria = [model_type.id.in_(touched_task_ids), model_type.status == "running"]
if expected_retry_count is not None:
update_criteria.append(model_type.retry_count == expected_retry_count)
execution_result = await self.repository.session.execute(
update(model_type)
.where(*update_criteria)
.values(_update_values(model_type, values, now=now))
.execution_options(synchronize_session=False)
)
rowcount = int(execution_result.rowcount or 0)
if rowcount == len(touched_task_ids) or rowcount < 0:
result.touched_task_ids.update(touched_task_ids)
else:
result.missed_task_ids.update(touched_task_ids)
return result
async def _touch_oracle_heartbeats(
self,
*,
grouped_touches: "dict[UUID, HeartbeatTouch]",
models_by_id: "dict[UUID, Any]",
expected_retry_count: "int | None",
now: "datetime",
result: "HeartbeatTouchResult",
) -> "None":
"""Touch Oracle JsonB metadata without CASE expressions over BLOB JSON."""
patched_task_ids = {task_id for task_id in models_by_id if grouped_touches[task_id].metadata_patch}
heartbeat_only_task_ids = set(models_by_id) - patched_task_ids
if heartbeat_only_task_ids:
await self._touch_heartbeat_rows(
heartbeat_only_task_ids,
values={"heartbeat_at": now},
expected_retry_count=expected_retry_count,
now=now,
result=result,
)
for task_id in patched_task_ids:
model = models_by_id[task_id]
metadata = dict(_deserialize_json(model.metadata_json) or {})
metadata_patch = grouped_touches[task_id].metadata_patch
if metadata_patch:
metadata.update(metadata_patch)
await self._touch_heartbeat_rows(
{task_id},
values={"heartbeat_at": now, "metadata_json": _serialize_json(metadata)},
expected_retry_count=expected_retry_count,
now=now,
result=result,
)
async def _touch_heartbeat_rows(
self,
task_ids: "set[UUID]",
*,
values: "dict[str, Any]",
expected_retry_count: "int | None",
now: "datetime",
result: "HeartbeatTouchResult",
) -> "None":
if not task_ids:
return
model_type = self.model_type
update_criteria = [model_type.id.in_(task_ids), model_type.status == "running"]
if expected_retry_count is not None:
update_criteria.append(model_type.retry_count == expected_retry_count)
execution_result = await self.repository.session.execute(
update(model_type)
.where(*update_criteria)
.values(_update_values(model_type, values, now=now))
.execution_options(synchronize_session=False)
)
rowcount = int(execution_result.rowcount or 0)
if rowcount == len(task_ids) or rowcount < 0:
result.touched_task_ids.update(task_ids)
else:
result.missed_task_ids.update(task_ids)
[docs]
async def null_heartbeats(self, task_ids: "list[UUID]", *, expected_retry_count: "int | None" = None) -> "None":
if not task_ids:
return
model_type = self.model_type
criteria = [model_type.id.in_(task_ids)]
if expected_retry_count is not None:
criteria.append(model_type.retry_count == expected_retry_count)
await self.repository.session.execute(
update(model_type).where(*criteria).values(_update_values(model_type, {"heartbeat_at": None}))
)
[docs]
async def requeue_stale_running(
self,
*,
stale_after: "timedelta",
limit: "int | None" = None,
priority_policy: "StaleRequeuePriority" = STALE_REQUEUE_PRIORITY,
) -> "StaleTaskRecoveryResult":
cutoff = _utc_now() - stale_after
model_type = self.model_type
stale_heartbeat = or_(model_type.heartbeat_at.is_(None), model_type.heartbeat_at <= cutoff)
select_criteria = [model_type.status == "running"]
use_heartbeat_cutoff = stale_after.total_seconds() > 0
if use_heartbeat_cutoff:
select_criteria.append(stale_heartbeat)
# Order oldest-heartbeat-first (coalescing NULL heartbeats to created_at
# so never-heartbeated rows sort first) then by id for a stable bound.
statement = (
select(model_type)
.where(*select_criteria)
.order_by(func.coalesce(model_type.heartbeat_at, model_type.created_at), model_type.id)
)
if limit is not None:
statement = statement.limit(limit)
models = (await self.repository.session.execute(statement)).scalars().all()
result = StaleTaskRecoveryResult()
for model in models:
metadata = _deserialize_json(model.metadata_json)
requeue_on_stale = metadata.get("requeue_on_stale", True) is not False
update_criteria = [
model_type.id == model.id,
model_type.status == "running",
model_type.retry_count == int(model.retry_count),
]
if use_heartbeat_cutoff:
update_criteria.append(stale_heartbeat)
if requeue_on_stale and attempts_consumed(self.record_from_model(model)) < int(model.max_retries):
queued_at, retry_at = retry_schedule(self.record_from_model(model))
update_result = await self.repository.session.execute(
update(model_type)
.where(*update_criteria)
.values(
_update_values(
model_type,
{
"status": "scheduled" if retry_at is not None else "pending",
"queued_at": queued_at,
"scheduled_at": retry_at,
"started_at": None,
"heartbeat_at": None,
"retry_count": int(model.retry_count) + 1,
"priority": stale_requeue_priority(int(model.priority), priority_policy),
"error": stale_requeue_error(model.error),
},
)
)
.execution_options(synchronize_session=False)
)
if update_result.rowcount == 1:
result.requeued += 1
else:
result.skipped += 1
else:
now = _utc_now()
update_result = await self.repository.session.execute(
update(model_type)
.where(*update_criteria)
.values(
_update_values(
model_type,
{
"status": "failed",
"completed_at": now,
"heartbeat_at": now,
"error": STALE_HEARTBEAT_ERROR,
},
now=now,
)
)
.execution_options(synchronize_session=False)
)
if update_result.rowcount == 1:
result.failed += 1
task_id = UUID(str(model.id))
result.failed_task_ids.append(task_id)
if not requeue_on_stale:
result.handler_needed += 1
result.handler_needed_task_ids.append(task_id)
else:
result.skipped += 1
return result
[docs]
async def set_execution_ref(
self, task_id: "UUID", execution_backend: "str", execution_ref: "str", *, execution_profile: "str | None"
) -> "QueuedTaskRecord | None":
model_type = self.model_type
result = await self.repository.session.execute(
update(model_type)
.where(model_type.id == task_id)
.values(
_update_values(
model_type,
{
"execution_backend": execution_backend,
"execution_profile": execution_profile,
"execution_ref": execution_ref,
},
)
)
)
if result.rowcount != 1:
return None
model = await self._select_task(task_id)
return self.record_from_model(model) if model 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",
expected_retry_count: "int | None" = None,
) -> "QueuedTaskRecord | None":
now = _utc_now()
model_type = self.model_type
criteria = [
model_type.id == task_id,
model_type.status.in_(_DUE_STATUSES),
or_(model_type.scheduled_at.is_(None), model_type.scheduled_at <= now),
or_(model_type.expires_at.is_(None), model_type.expires_at > now),
model_type.execution_ref.is_(None),
]
if expected_retry_count is not None:
criteria.append(model_type.retry_count == expected_retry_count)
result = await self.repository.session.execute(
update(model_type)
.where(*criteria)
.values(
_update_values(
model_type,
{
"execution_backend": execution_backend,
"execution_profile": execution_profile,
"execution_ref": reservation_ref,
},
now=now,
)
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
return None
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def clear_execution_ref(
self, task_id: "UUID", expected_retry_count: "int", expected_execution_ref: "str"
) -> "QueuedTaskRecord | None":
model_type = self.model_type
result = await self.repository.session.execute(
update(model_type)
.where(
model_type.id == task_id,
model_type.status.in_(_DUE_STATUSES),
model_type.retry_count == expected_retry_count,
model_type.execution_ref == expected_execution_ref,
)
.values(_update_values(model_type, {"execution_ref": None}))
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
return None
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def replace_execution_ref(
self, task_id: "UUID", expected_retry_count: "int", expected_execution_ref: "str", execution_ref: "str"
) -> "QueuedTaskRecord | None":
model_type = self.model_type
result = await self.repository.session.execute(
update(model_type)
.where(
model_type.id == task_id,
model_type.status.in_(_DUE_STATUSES),
model_type.retry_count == expected_retry_count,
model_type.execution_ref == expected_execution_ref,
)
.values(_update_values(model_type, {"execution_ref": execution_ref}))
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
return None
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def release_external_dispatch(
self, task_id: "UUID", reservation_ref: "str", execution_backend: "str", *, execution_profile: "str | None"
) -> "QueuedTaskRecord | None":
model_type = self.model_type
result = await self.repository.session.execute(
update(model_type)
.where(model_type.id == task_id, model_type.execution_ref == reservation_ref)
.values(
_update_values(
model_type,
{
"execution_backend": execution_backend,
"execution_profile": execution_profile,
"execution_ref": None,
},
)
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
return None
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def finalize_external_dispatch(
self,
task_id: "UUID",
reservation_ref: "str",
execution_backend: "str",
execution_ref: "str",
*,
execution_profile: "str | None",
) -> "QueuedTaskRecord | None":
model_type = self.model_type
result = await self.repository.session.execute(
update(model_type)
.where(
model_type.id == task_id,
model_type.execution_ref == reservation_ref,
model_type.status.in_(_DUE_STATUSES),
)
.values(
_update_values(
model_type,
{
"execution_backend": execution_backend,
"execution_profile": execution_profile,
"execution_ref": execution_ref,
},
)
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
return None
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def set_execution_backend(
self, task_id: "UUID", execution_backend: "str", *, execution_profile: "str | None"
) -> "QueuedTaskRecord | None":
model_type = self.model_type
result = await self.repository.session.execute(
update(model_type)
.where(model_type.id == task_id)
.values(
_update_values(
model_type,
{
"execution_backend": execution_backend,
"execution_profile": execution_profile,
"execution_ref": None,
},
)
)
)
if result.rowcount != 1:
return None
model = await self._select_task(task_id)
return self.record_from_model(model) if model is not None else None
[docs]
async def list_running_external(self, *, limit: "int | None" = None) -> "list[QueuedTaskRecord]":
model_type = self.model_type
statement = (
select(model_type)
.where(model_type.status.in_(("pending", "scheduled", "running")), model_type.execution_ref.is_not(None))
.order_by(func.coalesce(model_type.started_at, model_type.created_at), model_type.id)
)
if limit is not None:
statement = statement.limit(limit)
models = await self.get_many(statement=statement)
return [self.record_from_model(model) for model in models]
[docs]
async def get_statistics(self, *, queue: "str | None" = None) -> "QueueStatistics":
model_type = self.model_type
statement = select(model_type.status, func.count()).group_by(model_type.status)
if queue is not None:
statement = statement.where(model_type.queue == queue)
result = await self.repository.session.execute(statement)
statistics = QueueStatistics()
for status, count in result.all():
coerced = _coerce_status(status)
setattr(statistics, coerced, int(count))
return statistics
[docs]
async def list_completed_by_task(
self, task_name: "str", *, since: "datetime | None", limit: "int"
) -> "list[QueuedTaskRecord]":
model_type = self.model_type
criteria = [model_type.task_name == task_name, model_type.status == "completed"]
if since is not None:
criteria.append(model_type.completed_at >= since)
statement = select(model_type).where(and_(*criteria)).order_by(desc(model_type.completed_at)).limit(limit)
models = await self.get_many(statement=statement)
return [self.record_from_model(model) for model in models]
[docs]
async def cleanup_terminal(self, before: "datetime", *, limit: "int | None" = None) -> "int":
model_type = self.model_type
terminal_criteria = (
model_type.status.in_(_TERMINAL_STATUSES),
model_type.completed_at.is_not(None),
model_type.completed_at < before,
)
if limit is None:
result = await self.repository.session.execute(delete(model_type).where(*terminal_criteria))
return int(result.rowcount or 0)
# DELETE ... LIMIT is not portable, so select the oldest bounded id set
# (oldest completed_at, then id) and delete exactly those rows.
id_statement = (
select(model_type.id)
.where(*terminal_criteria)
.order_by(model_type.completed_at, model_type.id)
.limit(limit)
)
ids = (await self.repository.session.execute(id_statement)).scalars().all()
if not ids:
return 0
result = await self.repository.session.execute(
delete(model_type).where(model_type.id.in_(ids)).execution_options(synchronize_session=False)
)
return int(result.rowcount or 0)
[docs]
def model_from_record(self, record: "QueuedTaskRecord") -> "Any":
"""Convert a backend-neutral record into an Advanced Alchemy model.
Returns:
The Advanced Alchemy queue task model.
"""
return self.model_type(
id=record.id,
task_name=record.task_name,
args_json=_serialize_json(list(record.args)),
kwargs_json=_serialize_json(record.kwargs),
queue=record.queue,
execution_backend=record.execution_backend,
execution_profile=record.execution_profile,
execution_ref=record.execution_ref,
worker_id=record.worker_id,
status=record.status,
priority=record.priority,
max_retries=record.max_retries,
retry_count=record.retry_count,
scheduled_at=record.scheduled_at,
expires_at=record.expires_at,
created_at=record.created_at,
queued_at=record.queued_at,
started_at=record.started_at,
completed_at=record.completed_at,
heartbeat_at=record.heartbeat_at,
result_json=_serialize_json(record.result),
error=record.error,
task_key=record.key,
metadata_json=_serialize_json(record.metadata),
)
[docs]
@staticmethod
def record_from_model(model: "Any") -> "QueuedTaskRecord":
"""Convert an Advanced Alchemy model into a backend-neutral record.
Returns:
The backend-neutral queued task record.
"""
args = _deserialize_json(model.args_json)
kwargs = _deserialize_json(model.kwargs_json)
metadata = _deserialize_json(model.metadata_json)
return QueuedTaskRecord(
id=UUID(str(model.id)),
task_name=model.task_name,
args=tuple(args),
kwargs=kwargs,
queue=model.queue,
execution_backend=model.execution_backend,
execution_profile=model.execution_profile,
execution_ref=model.execution_ref,
worker_id=model.worker_id,
status=_coerce_status(model.status),
priority=int(model.priority),
max_retries=int(model.max_retries),
retry_count=int(model.retry_count),
scheduled_at=_coerce_datetime(model.scheduled_at),
expires_at=_coerce_datetime(model.expires_at),
created_at=cast("datetime", _coerce_datetime(model.created_at)),
queued_at=cast("datetime", _coerce_datetime(model.queued_at)),
started_at=_coerce_datetime(model.started_at),
completed_at=_coerce_datetime(model.completed_at),
heartbeat_at=_coerce_datetime(model.heartbeat_at),
result=_deserialize_json(model.result_json),
error=model.error,
key=model.task_key,
metadata=metadata,
)
async def _select_task(self, task_id: "UUID") -> "Any | None":
return await self.repository.get_one_or_none(id=task_id)
async def _select_task_by_key(self, key: "str") -> "Any | None":
return await self.repository.get_one_or_none(task_key=key)
async def _insert_task_record(self, record: "QueuedTaskRecord", *, key: "str | None") -> "QueuedTaskRecord":
model = self.model_from_record(record)
dialect_name = self._dialect_name()
if key is not None and dialect_name is not None and _supports_native_keyed_enqueue(dialect_name):
values = _model_insert_values(model, self.model_type)
statement, params = _build_keyed_enqueue_upsert(
self.model_type.__table__,
values,
dialect_name=dialect_name,
key_column=_mapped_column(self.model_type, "task_key").name,
)
if params:
await self.repository.session.execute(statement, params)
else:
await self.repository.session.execute(statement)
inserted = await self._select_task(record.id)
if inserted is not None:
return self.record_from_model(inserted)
existing = await self._select_task_by_key(key)
if existing is not None:
return self.record_from_model(existing)
return record
await self.repository.add(model, auto_commit=False, auto_refresh=False)
return record
def _dialect_name(self) -> "str | None":
bind = self.repository.session.get_bind()
return bind.dialect.name if bind is not None else None
def _pending_statement(self, *, queue: "str | None", execution_backend: "str | None") -> "Any":
return _build_claim_candidate_statement(
self.model_type,
queue=queue,
execution_backend=execution_backend,
now=_utc_now(),
limit=None,
skip_locked=False,
)
def _supports_skip_locked_claim(dialect_name: "str | None") -> "bool":
return dialect_name in _SKIP_LOCKED_CLAIM_DIALECTS
def _supports_native_keyed_enqueue(dialect_name: "str | None") -> "bool":
return dialect_name in _NATIVE_KEYED_ENQUEUE_DIALECTS
def _supports_batch_claim(dialect_name: "str | None") -> "bool":
return dialect_name == "postgresql"
def _build_claim_candidate_statement(
model_type: "type[Any]",
*,
queue: "str | None",
execution_backend: "str | None",
now: "datetime",
limit: "int | None",
skip_locked: "bool",
) -> "Any":
criteria = [
model_type.status.in_(_DUE_STATUSES),
or_(model_type.scheduled_at.is_(None), model_type.scheduled_at <= now),
or_(model_type.expires_at.is_(None), model_type.expires_at > now),
or_(
model_type.execution_ref.is_(None),
model_type.execution_ref.not_like(f"{EXTERNAL_DISPATCH_RESERVATION_PREFIX}%"),
),
]
if queue is not None:
criteria.append(model_type.queue == queue)
if execution_backend is not None:
criteria.append(model_type.execution_backend == execution_backend)
statement = (
select(model_type)
.where(and_(*criteria))
.order_by(desc(model_type.priority), model_type.queued_at, model_type.created_at, model_type.id)
)
if limit is not None:
statement = statement.limit(limit)
if skip_locked:
statement = statement.with_for_update(skip_locked=True)
return statement
def _build_claim_lock_statement(model_type: "type[Any]", task_id: "UUID") -> "Any":
return (
select(model_type)
.where(model_type.id == task_id, model_type.status.in_(_DUE_STATUSES))
.with_for_update(skip_locked=True)
)
def _build_keyed_enqueue_upsert(
table: "Any", values: "dict[str, Any]", *, dialect_name: "str", key_column: "str | None" = None
) -> "tuple[Any, dict[str, Any]]":
key_column = key_column or ("task_key" if "task_key" in table.c else "key")
if dialect_name == "oracle":
return OnConflictUpsert.create_merge_upsert(
table=table,
values=values,
conflict_columns=[key_column],
update_columns=[],
dialect_name=dialect_name,
validate_identifiers=True,
)
update_columns = [key_column]
return (
OnConflictUpsert.create_upsert(
table=table,
values=values,
conflict_columns=[key_column],
update_columns=update_columns,
dialect_name=dialect_name,
validate_identifiers=True,
),
{},
)
def _mapped_column(model_type: "type[Any]", attribute_name: "str") -> "Any":
return sqlalchemy_inspect(model_type).column_attrs[attribute_name].columns[0]
def _update_values(
model_type: "type[Any]", values: "dict[str, Any]", *, now: "datetime | None" = None
) -> "dict[str, Any]":
if hasattr(model_type, "updated_at") and "updated_at" not in values:
values = {**values, "updated_at": now or _utc_now()}
return values
def _model_insert_values(model: "Any", model_type: "type[Any]") -> "dict[str, Any]":
values: "dict[str, Any]" = {}
mapper = sqlalchemy_inspect(model_type)
table = model_type.__table__
for column in table.columns:
try:
attribute_name = mapper.get_property_by_column(column).key
except UnmappedColumnError:
attribute_name = column.key or column.name
if not hasattr(model, attribute_name):
continue
value = getattr(model, attribute_name)
if value is None and attribute_name == "updated_at":
value = _utc_now()
if value is None and (column.default is not None or column.server_default is not None):
continue
values[column.name] = value
return values
def _utc_now() -> "datetime":
return datetime.now(timezone.utc)
def _serialize_json(value: "Any") -> "Any":
return _decode_json(str(_encode_json(value)))
def _deserialize_json(value: "Any") -> "Any":
if value is None:
return None
if isinstance(value, bytes | bytearray | memoryview):
return _decode_json(bytes(value))
return value
def _coerce_datetime(value: "Any") -> "datetime | None":
if value is None:
return None
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
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 Advanced Alchemy queue backend: {status!r}"
raise ValueError(msg)
return cast("TaskStatus", status)