Source code for litestar_queues.backends.sqlspec.schema

"""Schema and migration helpers for the SQLSpec queue backend."""

from hashlib import sha1
from importlib.resources import files
from pathlib import Path
from typing import TYPE_CHECKING

from sqlspec.utils.text import quote_identifier, split_qualified_identifier

from litestar_queues.exceptions import QueueConfigurationError

if TYPE_CHECKING:
    from collections.abc import Mapping

__all__ = (
    "DEFAULT_COLUMN_MAP",
    "DEFAULT_EVENT_HISTORY_TABLE_SUFFIX",
    "DEFAULT_MAINTENANCE_TABLE_SUFFIX",
    "DEFAULT_TABLE_NAME",
    "DEFAULT_TASK_RESERVATION_TABLE_SUFFIX",
    "EVENT_HISTORY_COLUMNS",
    "event_history_table_name_for",
    "maintenance_table_name_for",
    "migration_directory",
    "migration_paths",
    "resolve_column_map",
    "task_reservation_table_name_for",
    "validate_column_map",
    "validate_native_json_columns",
    "validate_table_name",
)

DEFAULT_TABLE_NAME = "queue_task"
DEFAULT_EVENT_HISTORY_TABLE_SUFFIX = "_event_history"
DEFAULT_MAINTENANCE_TABLE_SUFFIX = "_maintenance"
DEFAULT_TASK_RESERVATION_TABLE_SUFFIX = "_reservation"
DEFAULT_COLUMN_MAP = {
    "args_json": "task_args",
    "kwargs_json": "task_kwargs",
    "result_json": "result",
    "metadata_json": "metadata",
}
_CANONICAL_COLUMNS = frozenset({
    "id",
    "task_name",
    "args_json",
    "kwargs_json",
    "queue",
    "execution_backend",
    "execution_profile",
    "execution_ref",
    "worker_id",
    "status",
    "priority",
    "max_retries",
    "retry_count",
    "scheduled_at",
    "expires_at",
    "created_at",
    "queued_at",
    "started_at",
    "completed_at",
    "heartbeat_at",
    "result_json",
    "error",
    "task_key",
    "metadata_json",
})
_JSON_COLUMNS = frozenset({"args_json", "kwargs_json", "result_json", "metadata_json"})

EVENT_HISTORY_COLUMNS = (
    "event_id",
    "event_type",
    "task_id",
    "task_name",
    "queue",
    "worker_id",
    "execution_backend",
    "execution_profile",
    "actor_type",
    "actor_id",
    "stage",
    "level",
    "message",
    "detail",
    "progress_current",
    "progress_total",
    "progress_percent",
    "duration_ms",
    "sequence",
    "occurred_at",
    "created_at",
    "scope",
    "scope_key",
    "actor",
    "entity",
)
"""Physical columns the package owns on the SQLSpec event-history table."""

_EVENT_HISTORY_COLUMN_NAMES = frozenset(EVENT_HISTORY_COLUMNS)


[docs] def validate_table_name(table_name: "str") -> "str": """Validate a SQL identifier used for the queue table name. Returns: The validated table name, normalized to unquoted SQLSpec identifier parts. Raises: QueueConfigurationError: If the table name is not a valid SQL identifier. """ cleaned = table_name.strip() parts = split_qualified_identifier(cleaned) if ( not parts or cleaned.count(".") != len(parts) - 1 or any(not _is_unquoted_identifier_part(part) for part in parts) or split_qualified_identifier(".".join(quote_identifier(part) for part in parts)) != parts ): msg = f"Invalid SQLSpec queue table name: {table_name!r}" raise QueueConfigurationError(msg) return ".".join(parts)
[docs] def validate_column_map(column_map: "Mapping[str, str]") -> "dict[str, str]": """Validate a canonical-to-adopter column map. Returns: A defensive copy of the validated map. Raises: QueueConfigurationError: If a canonical name is unknown or a mapped name is not a valid SQL identifier. """ resolved: "dict[str, str]" = {} for canonical, mapped in column_map.items(): if canonical not in _CANONICAL_COLUMNS: msg = f"Unknown canonical column in column_map: {canonical!r}" raise QueueConfigurationError(msg) if not _is_unquoted_identifier_part(mapped): msg = f"Invalid SQL identifier in column_map: {mapped!r}" raise QueueConfigurationError(msg) resolved[canonical] = mapped physical_to_canonical: "dict[str, str]" = {} for canonical in sorted(_CANONICAL_COLUMNS): mapped = resolved.get(canonical, canonical) previous = physical_to_canonical.get(mapped) if previous is not None: msg = f"Duplicate physical column in column_map: {mapped!r} is used for {previous!r} and {canonical!r}." raise QueueConfigurationError(msg) physical_to_canonical[mapped] = canonical return resolved
[docs] def resolve_column_map(column_map: "Mapping[str, str] | None" = None) -> "dict[str, str]": """Return the default physical column map with adopter overrides applied.""" return validate_column_map({**DEFAULT_COLUMN_MAP, **dict(column_map or {})})
[docs] def validate_native_json_columns(columns: "frozenset[str]") -> "frozenset[str]": """Validate native JSON passthrough columns. Returns: The validated column set. Raises: QueueConfigurationError: If any column is not a canonical JSON column. """ unknown = columns - _JSON_COLUMNS if unknown: msg = f"native_json_columns contains non-JSON canonical names: {sorted(unknown)!r}" raise QueueConfigurationError(msg) return columns
[docs] def event_history_table_name_for(table_name: "str") -> "str": """Return the default event-history table for a queue table name. Schema-qualified names keep their schema and append :data:`DEFAULT_EVENT_HISTORY_TABLE_SUFFIX` to the table part. """ validated = validate_table_name(table_name) parts = validated.rsplit(".", maxsplit=1) if len(parts) == 1: return validate_table_name(f"{validated}{DEFAULT_EVENT_HISTORY_TABLE_SUFFIX}") schema, table = parts return validate_table_name(f"{schema}.{table}{DEFAULT_EVENT_HISTORY_TABLE_SUFFIX}")
# Smallest common identifier limit across supported dialects (PostgreSQL is 63, # MySQL is 64). Derived coordination tables are deterministically shortened so # the same name is produced by the packaged migration and the runtime backend. _MAX_IDENTIFIER_LENGTH = 63 def _bounded_table_part(table: "str", suffix: "str") -> "str": candidate = f"{table}{suffix}" if len(candidate) <= _MAX_IDENTIFIER_LENGTH: return candidate digest = sha1(table.encode()).hexdigest()[:8] # noqa: S324 - non-cryptographic name shortening. keep = _MAX_IDENTIFIER_LENGTH - len(suffix) - len(digest) - 1 return f"{table[:keep]}_{digest}{suffix}"
[docs] def maintenance_table_name_for(table_name: "str") -> "str": """Return the default maintenance table for a queue table name. Schema-qualified names keep their schema and append :data:`DEFAULT_MAINTENANCE_TABLE_SUFFIX` to the table part. When the derived name would exceed the portable identifier limit it is deterministically shortened (truncated table part plus a stable hash) so the packaged migration and the runtime backend agree on one name. """ validated = validate_table_name(table_name) parts = validated.rsplit(".", maxsplit=1) if len(parts) == 1: if validated == DEFAULT_TABLE_NAME: return "queue_maintenance" return validate_table_name(_bounded_table_part(validated, DEFAULT_MAINTENANCE_TABLE_SUFFIX)) schema, table = parts if table == DEFAULT_TABLE_NAME: return validate_table_name(f"{schema}.queue_maintenance") return validate_table_name(f"{schema}.{_bounded_table_part(table, DEFAULT_MAINTENANCE_TABLE_SUFFIX)}")
[docs] def task_reservation_table_name_for(table_name: "str") -> "str": """Return the default forever-uniqueness reservation table for a queue table. Schema-qualified names keep their schema and append :data:`DEFAULT_TASK_RESERVATION_TABLE_SUFFIX` to the table part. Long derived names use the same portable deterministic shortening as maintenance tables. """ validated = validate_table_name(table_name) parts = validated.rsplit(".", maxsplit=1) if len(parts) == 1: return validate_table_name(_bounded_table_part(validated, DEFAULT_TASK_RESERVATION_TABLE_SUFFIX)) schema, table = parts return validate_table_name(f"{schema}.{_bounded_table_part(table, DEFAULT_TASK_RESERVATION_TABLE_SUFFIX)}")
[docs] def migration_paths() -> "tuple[str, ...]": """Return packaged SQLSpec migration file paths.""" directory = migration_directory() return (str(directory.joinpath("0001_create_queue_tasks.py")),)
[docs] def migration_directory() -> "Path": """Return the packaged SQLSpec queue extension migration directory.""" return Path(str(files("litestar_queues.backends.sqlspec").joinpath("migrations")))
def _is_unquoted_identifier_part(identifier: "str") -> "bool": """Return whether a SQLSpec-split identifier part is safe unquoted text.""" return ( identifier.isascii() and bool(identifier) and (identifier[0].isalpha() or identifier[0] == "_") and all(character.isalnum() or character == "_" for character in identifier) )