"""Bounded, backend-neutral queue maintenance.
The maintenance service runs a small, predictable amount of repair and
retention work under token-fenced distributed coordination and a wall-clock time
budget, then returns. It never starts a worker, executes due work, or loops to
drain a backlog. Phases always run in the fixed order external-execution
reconciliation, stale-running recovery, terminal-task retention, and
durable-event retention.
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from math import isfinite
from time import perf_counter
from typing import TYPE_CHECKING, Literal, cast
from uuid import uuid4
from litestar_queues.events import QueueEventLog, QueueEventRetentionRule
from litestar_queues.exceptions import QueueConfigurationError
if TYPE_CHECKING:
from collections.abc import Callable, Collection
from litestar_queues.service import QueueService
__all__ = (
"MaintenancePhase",
"MaintenancePhaseStatus",
"QueueMaintenanceConfig",
"QueueMaintenancePhaseResult",
"QueueMaintenanceService",
"QueueMaintenanceSummary",
)
MaintenancePhase = Literal["external", "stale", "terminal", "events"]
"""One bounded maintenance phase, in fixed execution order."""
MaintenancePhaseStatus = Literal["completed", "skipped", "failed", "partial"]
"""Outcome of a single maintenance phase."""
MaintenanceOutcome = Literal["completed", "failed", "partial", "already_running"]
"""Outcome of a whole maintenance run."""
PHASE_ORDER: "tuple[MaintenancePhase, ...]" = ("external", "stale", "terminal", "events")
"""Stable phase order. Never reordered; drift gates depend on this."""
MAINTENANCE_NAME = "queue-maintenance"
"""Distributed maintenance coordination name shared by every process."""
PHASE_ERROR_CODE = "maintenance_phase_failed"
"""Package-owned error code prefix for a failed phase.
A failed phase records ``maintenance_phase_failed:<ExceptionType>`` only. The
exception message, arguments, results, DSNs, and credentials are never included.
"""
[docs]
@dataclass(slots=True)
class QueueMaintenanceConfig:
"""Bounded maintenance thresholds and limits.
Durations and retention values are seconds. Every limit and duration must be
positive and ``coordination_timeout`` must exceed ``time_budget`` so ownership outlives
the whole run. ``stale_after``,
``terminal_retention``, and ``event_retention_rules`` default to ``None`` (or empty) which
disables their phase; there are no destructive defaults.
"""
time_budget: "float" = 300.0
"""Maximum wall-clock duration of one maintenance run in seconds."""
coordination_timeout: "float" = 360.0
"""Distributed ownership duration in seconds; must exceed ``time_budget``."""
external_limit: "int" = 100
"""Maximum external executions reconciled in one run."""
stale_after: "float | None" = None
"""Running-task age threshold in seconds; ``None`` disables stale recovery."""
stale_limit: "int" = 100
"""Maximum stale running tasks recovered in one run."""
terminal_retention: "float | None" = None
"""Terminal-task retention age in seconds; ``None`` disables deletion."""
terminal_limit: "int" = 1000
"""Maximum expired terminal tasks deleted in one run."""
event_retention_rules: "tuple[QueueEventRetentionRule, ...]" = ()
"""Ordered event-history retention rules; empty disables the events phase.
Rules are evaluated in order and the first rule whose ``match`` matches a
record decides that record's retention age. A record matching no rule is
never deleted.
"""
event_limit: "int" = 1000
"""Maximum expired task-event records deleted in one run."""
def __post_init__(self) -> "None":
"""Validate durations, retention thresholds, and limits.
Raises:
QueueConfigurationError: If a duration, retention threshold, or limit
is not positive, or ``coordination_timeout`` does not exceed ``time_budget``.
"""
for name, value in (("time_budget", self.time_budget), ("coordination_timeout", self.coordination_timeout)):
if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value) or value <= 0:
msg = f"QueueMaintenanceConfig.{name} must be a finite number greater than 0."
raise QueueConfigurationError(msg)
for name, limit in (
("external_limit", self.external_limit),
("stale_limit", self.stale_limit),
("terminal_limit", self.terminal_limit),
("event_limit", self.event_limit),
):
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
msg = f"QueueMaintenanceConfig.{name} must be a positive integer."
raise QueueConfigurationError(msg)
for name, retention in (("stale_after", self.stale_after), ("terminal_retention", self.terminal_retention)):
if retention is not None and (
isinstance(retention, bool)
or not isinstance(retention, (int, float))
or not isfinite(retention)
or retention <= 0
):
msg = f"QueueMaintenanceConfig.{name} must be a finite number greater than 0 when set."
raise QueueConfigurationError(msg)
if not isinstance(self.event_retention_rules, (tuple, list)):
msg = "QueueMaintenanceConfig.event_retention_rules must be a tuple of QueueEventRetentionRule." # type: ignore[unreachable]
raise QueueConfigurationError(msg)
rules = tuple(self.event_retention_rules)
for rule in rules:
if not isinstance(rule, QueueEventRetentionRule):
msg = "QueueMaintenanceConfig.event_retention_rules must be a tuple of QueueEventRetentionRule." # type: ignore[unreachable]
raise QueueConfigurationError(msg)
object.__setattr__(self, "event_retention_rules", rules)
if self.coordination_timeout <= self.time_budget:
msg = (
"QueueMaintenanceConfig.coordination_timeout must be greater than time_budget "
"so ownership outlives the run."
)
raise QueueConfigurationError(msg)
[docs]
@dataclass(slots=True)
class QueueMaintenancePhaseResult:
"""Result of one bounded maintenance phase."""
phase: "MaintenancePhase"
status: "MaintenancePhaseStatus"
changed: "int" = 0
duration_ms: "float" = 0.0
error: "str | None" = None
[docs]
def to_payload(self) -> "dict[str, object]":
"""Return a JSON-native mapping of this phase result."""
return {
"phase": self.phase,
"status": self.status,
"changed": self.changed,
"duration_ms": self.duration_ms,
"error": self.error,
}
[docs]
@dataclass(slots=True)
class QueueMaintenanceSummary:
"""Result of a whole maintenance run."""
outcome: "MaintenanceOutcome"
acquired: "bool"
duration_ms: "float"
phases: "list[QueueMaintenancePhaseResult]" = field(default_factory=list)
[docs]
def to_payload(self) -> "dict[str, object]":
"""Return a JSON-native mapping of the whole summary."""
return {
"outcome": self.outcome,
"acquired": self.acquired,
"duration_ms": self.duration_ms,
"phases": [phase.to_payload() for phase in self.phases],
}
def _default_utcnow() -> "datetime":
return datetime.now(timezone.utc)
[docs]
class QueueMaintenanceService:
"""Run bounded maintenance phases under token-fenced coordination and a time budget."""
__slots__ = ("_config", "_monotonic", "_service", "_utcnow")
[docs]
def __init__(
self,
service: "QueueService",
config: "QueueMaintenanceConfig",
*,
monotonic: "Callable[[], float]" = perf_counter,
utcnow: "Callable[[], datetime]" = _default_utcnow,
) -> "None":
"""Initialize the maintenance service.
Args:
service: An opened queue service whose backend advertises
``supports_maintenance``.
config: Bounded maintenance thresholds and limits.
monotonic: Injected monotonic clock for budget/duration accounting.
utcnow: Injected UTC clock used to compute stable retention cutoffs.
"""
self._service = service
self._config = config
self._monotonic = monotonic
self._utcnow = utcnow
[docs]
async def run(self, phases: "Collection[MaintenancePhase] | None" = None) -> "QueueMaintenanceSummary":
"""Claim maintenance ownership and run each selected phase once.
Args:
phases: Optional narrowing of the phases to run. Filtering only
narrows configuration; it never enables a disabled retention
threshold. ``None`` considers every phase in the fixed order.
Returns:
A summary whose outcome is ``already_running`` when ownership is denied,
``failed`` when any phase failed, ``partial`` when the budget skipped
an enabled phase, else ``completed``.
Raises:
QueueConfigurationError: If a requested phase name is unknown or the
backend does not support distributed maintenance coordination.
"""
selected = self._select_phases(phases)
started_monotonic = self._monotonic()
started_at = self._utcnow()
backend = self._service.get_queue_backend()
if not backend.capabilities.supports_maintenance:
msg = (
f"{type(backend).__name__} does not support distributed maintenance coordination; "
"use a persistent backend (Redis/Valkey, SQLSpec, or Advanced Alchemy) for cross-process maintenance."
)
raise QueueConfigurationError(msg)
token = uuid4().hex
maintenance_name = getattr(getattr(self._service, "config", None), "maintenance_name", MAINTENANCE_NAME)
acquired = await backend.acquire_maintenance(
maintenance_name, token, ttl=timedelta(seconds=self._config.coordination_timeout)
)
if not acquired:
results = [QueueMaintenancePhaseResult(phase=phase, status="skipped") for phase in selected]
return QueueMaintenanceSummary(
outcome="already_running",
acquired=False,
duration_ms=self._elapsed_ms(started_monotonic),
phases=results,
)
results = []
try:
cutoffs = self._cutoffs(started_at)
budget_exhausted = False
for phase in selected:
if not self._phase_enabled(phase):
results.append(QueueMaintenancePhaseResult(phase=phase, status="skipped"))
continue
if budget_exhausted or (self._monotonic() - started_monotonic) >= self._config.time_budget:
budget_exhausted = True
results.append(QueueMaintenancePhaseResult(phase=phase, status="partial"))
continue
results.append(await self._run_phase(phase, cutoffs, started_at))
finally:
await backend.release_maintenance(maintenance_name, token)
return QueueMaintenanceSummary(
outcome=self._final_outcome(results),
acquired=True,
duration_ms=self._elapsed_ms(started_monotonic),
phases=results,
)
def _select_phases(self, phases: "Collection[MaintenancePhase] | None") -> "tuple[MaintenancePhase, ...]":
if phases is None:
return PHASE_ORDER
requested = set(phases)
unknown = requested - set(PHASE_ORDER)
if unknown:
valid = ", ".join(PHASE_ORDER)
msg = f"Unknown maintenance phase(s): {sorted(unknown)!r}; expected any of: {valid}."
raise QueueConfigurationError(msg)
return tuple(phase for phase in PHASE_ORDER if phase in requested)
def _phase_enabled(self, phase: "MaintenancePhase") -> "bool":
if phase == "external":
return self._service.get_execution_backend().is_external
if phase == "stale":
return self._config.stale_after is not None
if phase == "terminal":
return self._config.terminal_retention is not None
return bool(self._config.event_retention_rules) and self._service.get_event_log() is not None
def _cutoffs(self, started_at: "datetime") -> "dict[str, datetime]":
cutoffs: "dict[str, datetime]" = {}
if self._config.terminal_retention is not None:
cutoffs["terminal"] = started_at - timedelta(seconds=self._config.terminal_retention)
return cutoffs
async def _run_phase(
self, phase: "MaintenancePhase", cutoffs: "dict[str, datetime]", started_at: "datetime"
) -> "QueueMaintenancePhaseResult":
phase_start = self._monotonic()
try:
changed = await self._execute_phase(phase, cutoffs, started_at)
except Exception as exc: # noqa: BLE001 - phase failures are contained and sanitized.
return QueueMaintenancePhaseResult(
phase=phase,
status="failed",
changed=0,
duration_ms=self._elapsed_ms(phase_start),
error=f"{PHASE_ERROR_CODE}:{type(exc).__name__}",
)
return QueueMaintenancePhaseResult(
phase=phase, status="completed", changed=changed, duration_ms=self._elapsed_ms(phase_start)
)
async def _execute_phase(
self, phase: "MaintenancePhase", cutoffs: "dict[str, datetime]", started_at: "datetime"
) -> "int":
if phase == "external":
return await self._service.reconcile_external(limit=self._config.external_limit)
if phase == "stale":
result = await self._service.recover_stale_tasks(
stale_after=timedelta(seconds=cast("float", self._config.stale_after)), limit=self._config.stale_limit
)
return result.requeued + result.failed
if phase == "terminal":
return await self._service.get_queue_backend().cleanup_terminal(
cutoffs["terminal"], limit=self._config.terminal_limit
)
event_log = self._service.get_event_log()
if event_log is None: # pragma: no cover - guarded by _phase_enabled.
return 0
return await self._run_event_retention_rules(event_log, started_at)
async def _run_event_retention_rules(self, event_log: "QueueEventLog", started_at: "datetime") -> "int":
"""Apply ordered retention rules under one shared row budget.
Rule ``i`` deletes records matching its own filter and matching none of
the filters of rules ``0..i-1``, so the first matching rule owns the
record. Deletion is oldest-first and bounded, so repeated runs converge
and never touch a record no rule matched.
Returns:
Total rows deleted across every rule.
"""
rules = self._config.event_retention_rules
remaining = self._config.event_limit
deleted = 0
for index, rule in enumerate(rules):
if remaining <= 0:
break
removed = await event_log.cleanup_events(
before=started_at - timedelta(seconds=rule.max_age),
match=rule.match,
exclude=tuple(earlier.match for earlier in rules[:index]),
limit=remaining,
)
deleted += removed
remaining -= removed
return deleted
def _elapsed_ms(self, start: "float") -> "float":
return (self._monotonic() - start) * 1000.0
@staticmethod
def _final_outcome(results: "list[QueueMaintenancePhaseResult]") -> "MaintenanceOutcome":
if any(result.status == "failed" for result in results):
return "failed"
if any(result.status == "partial" for result in results):
return "partial"
return "completed"