Source code for litestar_queues.backends.redis.event_log

"""Redis-protocol queue event history."""

# ruff: noqa: SLF001

import asyncio
import hashlib
import inspect
import json
import logging
import time
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, cast

from litestar_queues.events._log_records import (
    event_log_record_from_event,
    event_log_record_sort_key,
    optional_float,
    optional_int,
    optional_str,
    parse_datetime,
)
from litestar_queues.events.history import QueueEventLogRecord

if TYPE_CHECKING:
    from collections.abc import Mapping, Sequence

    from litestar_queues.backends._protocol import ClientLike, PipelineLike
    from litestar_queues.backends.redis.backend import RedisQueueBackend
    from litestar_queues.events import EventHistoryConfig, QueueEvent, QueueEventStageSummary
    from litestar_queues.events.query import QueueEventQuery
    from litestar_queues.events.typing import OffsetPagination

__all__ = ("RedisQueueEventLog",)

logger = logging.getLogger(__name__)


[docs] class RedisQueueEventLog: """Buffered Redis-protocol event-history writer and query interface.""" __slots__ = ("_backend", "_config", "_flush_lock", "_last_flush", "_logger", "_pending")
[docs] def __init__(self, *, backend: "RedisQueueBackend", config: "EventHistoryConfig") -> "None": self._backend = backend self._config = config self._pending: "list[dict[str, str]]" = [] self._last_flush = time.monotonic() self._flush_lock = asyncio.Lock() self._logger = backend._logger
[docs] async def publish_event(self, event: "QueueEvent") -> "None": """Buffer a queue event and flush when configured thresholds are reached.""" should_flush = False async with self._flush_lock: self._pending.append( self._mapping_from_record(event_log_record_from_event(event, extra_columns=self._config.extra_columns)) ) should_flush = len(self._pending) >= max(1, self._config.batch_size) or self._flush_interval_elapsed() if should_flush: await self.flush_events()
[docs] async def flush_events(self) -> "None": """Flush buffered queue events through a Redis pipeline.""" async with self._flush_lock: if not self._pending: return batch = list(self._pending) try: client = await self._backend._get_client() await self._write_batch(client, batch) except Exception: if self._config.strict: raise self._logger.warning("Redis queue event history flush failed", exc_info=True) return del self._pending[: len(batch)] self._last_flush = time.monotonic()
[docs] async def query_events( self, query: "QueueEventQuery | None" = None, *, extra: "Mapping[str, str] | None" = None ) -> "OffsetPagination[QueueEventLogRecord]": """Return a filtered, ordered page of event history records. Returns: The matching page. """ from litestar_queues.events.history import event_extra_filter_matches, validate_event_extra_filter from litestar_queues.events.query import match_event_record, paginate_event_records, sort_event_records resolved_extra = validate_event_extra_filter(extra, self._config.extra_columns) await self.flush_events() client = await self._backend._get_client() index_key = self._select_index_key(query) event_ids = await client.zrangebyscore(index_key, "-inf", "+inf") records = [ record for record in await self._records_from_ids(client, event_ids) if match_event_record(record, query) ] if resolved_extra: records = [record for record in records if event_extra_filter_matches(record, resolved_extra)] ordered = sort_event_records(records, order="asc" if query is None else query.order) return paginate_event_records(ordered, query)
[docs] async def summarize_stages(self, query: "QueueEventQuery | None" = None) -> "list[QueueEventStageSummary]": """Return per-stage event history aggregates.""" from litestar_queues.events.query import match_event_record, require_unpaginated_query, summarize_event_records require_unpaginated_query(query) await self.flush_events() client = await self._backend._get_client() index_key = self._select_index_key(query) event_ids = await client.zrangebyscore(index_key, "-inf", "+inf") records = [ record for record in await self._records_from_ids(client, event_ids) if match_event_record(record, query) ] return summarize_event_records(records)
[docs] async def cleanup_events( # noqa: C901 self, *, before: "datetime", match: "QueueEventQuery | None" = None, exclude: "Sequence[QueueEventQuery]" = (), limit: "int | None" = None, ) -> "int": """Delete event history older than ``before``. Returns: Number of removed event-history records. """ from litestar_queues.events.query import match_event_record await self.flush_events() client = await self._backend._get_client() index_key = self._select_index_key(match) max_score = f"({_score_datetime(before)}" # We read the entire expired window into memory, decode mappings, and filter, # then apply the limit. Trade-off: the read window is unbounded but the write is bounded. event_ids = await client.zrangebyscore(index_key, "-inf", max_score) mappings = await self._mappings_from_ids(client, event_ids) # Identify valid records vs orphans valid_records = [] orphans = [] for event_id, mapping in zip(event_ids, mappings, strict=True): if not mapping: orphans.append(_decode(event_id)) else: record = _record_from_mapping(mapping) if record.occurred_at < before: valid_records.append((record, mapping)) # Filter valid records filtered = [] for record, mapping in valid_records: if match and not match_event_record(record, match): continue if exclude and any(match_event_record(record, ex) for ex in exclude): continue filtered.append((record, mapping)) # Sort ascending by stable key filtered.sort(key=lambda item: event_log_record_sort_key(item[0])) if limit is not None: filtered = filtered[:limit] pipeline = _create_pipeline(client) removed = 0 # Cleanup orphans for decoded_event_id in orphans: if pipeline is not None: pipeline.zrem(self._backend._event_log_global_key(), str(decoded_event_id)) else: await client.zrem(self._backend._event_log_global_key(), str(decoded_event_id)) # Cleanup valid records global_key = self._backend._event_log_global_key() for record, mapping in filtered: index_keys = _json_loads(mapping.get("index_keys"), []) event_key = self._backend._event_log_event_key(record.event_id) if pipeline is not None: pipeline.delete(event_key) pipeline.zrem(global_key, record.event_id) for i_key in index_keys: if str(i_key) != global_key: pipeline.zrem(str(i_key), record.event_id) else: await client.delete(event_key) await client.zrem(global_key, record.event_id) for i_key in index_keys: if str(i_key) != global_key: await client.zrem(str(i_key), record.event_id) removed += 1 if pipeline is not None: await _execute_pipeline(pipeline) return removed
async def _write_batch(self, client: "ClientLike", batch: "list[dict[str, str]]") -> "None": pipeline = _create_pipeline(client) if pipeline is not None: for mapping in batch: self._queue_write(pipeline, mapping) await _execute_pipeline(pipeline) return for mapping in batch: event_id = mapping["event_id"] await client.hset(self._backend._event_log_event_key(event_id), mapping=mapping) score = _score_datetime(parse_datetime(mapping["occurred_at"])) for index_key in _json_loads(mapping["index_keys"], []): await client.zadd(str(index_key), {event_id: score}) def _queue_write(self, pipeline: "PipelineLike", mapping: "dict[str, str]") -> "None": event_id = mapping["event_id"] pipeline.hset(self._backend._event_log_event_key(event_id), mapping=mapping) score = _score_datetime(parse_datetime(mapping["occurred_at"])) for index_key in _json_loads(mapping["index_keys"], []): pipeline.zadd(str(index_key), {event_id: score}) def _mapping_from_record(self, record: "QueueEventLogRecord") -> "dict[str, str]": index_keys = [self._backend._event_log_global_key(), self._backend._event_log_event_type_key(record.event_type)] if record.task_id is not None: index_keys.append(self._backend._event_log_task_key(record.task_id)) if record.task_name is not None: index_keys.append(self._backend._event_log_task_name_key(record.task_name)) if record.scope_key is not None: index_keys.append(self._backend._event_log_scope_key_key(record.scope_key)) if record.entity is not None: index_keys.append(self._backend._event_log_entity_key(record.entity)) result_mapping = { "event_id": record.event_id, "event_type": record.event_type, "task_id": record.task_id or "", "task_name": record.task_name or "", "queue": record.queue or "", "worker_id": record.worker_id or "", "execution_backend": record.execution_backend or "", "execution_profile": record.execution_profile or "", "actor_type": record.actor_type or "", "actor_id": record.actor_id or "", "level": record.level or "", "message": record.message or "", "detail": _json_dumps(record.detail), "progress_current": _optional_number(record.progress_current), "progress_total": _optional_number(record.progress_total), "progress_percent": _optional_number(record.progress_percent), "sequence": "" if record.sequence is None else str(record.sequence), "occurred_at": _serialize_datetime(record.occurred_at), "created_at": _serialize_datetime(record.created_at), "scope": record.scope or "", "scope_key": record.scope_key or "", "actor": record.actor or "", "entity": record.entity or "", "index_keys": _json_dumps(index_keys), } for extra_key, extra_val in record.extra.items(): result_mapping[f"extra:{extra_key}"] = str(extra_val) return result_mapping async def _records_from_ids(self, client: "ClientLike", event_ids: "list[Any]") -> "list[QueueEventLogRecord]": return [ _record_from_mapping(mapping) for mapping in await self._mappings_from_ids(client, event_ids) if mapping ] async def _mappings_from_ids(self, client: "ClientLike", event_ids: "list[Any]") -> "list[dict[str, Any]]": event_keys = [self._backend._event_log_event_key(str(_decode(event_id))) for event_id in event_ids] if not event_keys: return [] pipeline = _create_pipeline(client) if pipeline is None: return [_decode_mapping(await client.hgetall(key)) for key in event_keys] for key in event_keys: pipeline.hgetall(key) return [_decode_mapping(cast("dict[Any, Any]", result)) for result in await _execute_pipeline(pipeline)] def _select_index_key(self, query: "QueueEventQuery | None") -> "str": if query is None: return self._backend._event_log_global_key() if query.task_id is not None: return self._backend._event_log_task_key(query.task_id) if query.entity is not None: return self._backend._event_log_entity_key(query.entity) if query.scope_key is not None: return self._backend._event_log_scope_key_key(query.scope_key) if query.task_name is not None: return self._backend._event_log_task_name_key(query.task_name) if query.event_type is not None: return self._backend._event_log_event_type_key(query.event_type) return self._backend._event_log_global_key() def _flush_interval_elapsed(self) -> "bool": return self._config.flush_interval <= 0 or time.monotonic() - self._last_flush >= self._config.flush_interval
def _record_from_mapping(mapping: "dict[str, Any]") -> "QueueEventLogRecord": detail = _json_loads(mapping.get("detail"), {}) if not isinstance(detail, dict): detail = {} extra = {key[6:]: str(val) for key, val in mapping.items() if key.startswith("extra:")} return QueueEventLogRecord( event_id=str(mapping["event_id"]), event_type=str(mapping["event_type"]), task_id=_optional_mapping_str(mapping.get("task_id")), task_name=_optional_mapping_str(mapping.get("task_name")), queue=_optional_mapping_str(mapping.get("queue")), worker_id=_optional_mapping_str(mapping.get("worker_id")), execution_backend=_optional_mapping_str(mapping.get("execution_backend")), execution_profile=_optional_mapping_str(mapping.get("execution_profile")), actor_type=_optional_mapping_str(mapping.get("actor_type")), actor_id=_optional_mapping_str(mapping.get("actor_id")), stage=optional_str(detail.get("stage")), level=_optional_mapping_str(mapping.get("level")), message=_optional_mapping_str(mapping.get("message")), detail=detail, progress_current=optional_float(_json_loads(mapping.get("progress_current"), None)), progress_total=optional_float(_json_loads(mapping.get("progress_total"), None)), progress_percent=optional_float(_json_loads(mapping.get("progress_percent"), None)), duration_ms=optional_float(detail.get("duration_ms")), sequence=optional_int(mapping.get("sequence") or None), occurred_at=parse_datetime(mapping["occurred_at"]), created_at=parse_datetime(mapping["created_at"]), scope=_optional_mapping_str(mapping.get("scope")), scope_key=_optional_mapping_str(mapping.get("scope_key")), actor=_optional_mapping_str(mapping.get("actor")), entity=_optional_mapping_str(mapping.get("entity")), extra=extra, ) def _optional_number(value: "float | None") -> "str": return "" if value is None else _json_dumps(value) def _optional_mapping_str(value: "Any") -> "str | None": if value in {None, ""}: return None return optional_str(value) def _serialize_datetime(value: "datetime") -> "str": if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.astimezone(timezone.utc).isoformat() def _score_datetime(value: "datetime") -> "float": if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.astimezone(timezone.utc).timestamp() def _json_dumps(value: "Any") -> "str": return json.dumps(value, separators=(",", ":"), sort_keys=True) def _json_loads(value: "Any", default: "Any") -> "Any": value = _decode(value) if value in {None, ""}: return default return json.loads(str(value)) def _decode(value: "Any") -> "Any": if isinstance(value, bytes): return value.decode() return value def _decode_mapping(mapping: "dict[Any, Any]") -> "dict[str, Any]": return {str(_decode(key)): _decode(value) for key, value in mapping.items()} def _create_pipeline(client: "ClientLike") -> "PipelineLike | None": pipeline_factory = getattr(client, "pipeline", None) if pipeline_factory is None: return None try: return cast("PipelineLike", pipeline_factory(transaction=False)) except TypeError: return cast("PipelineLike", pipeline_factory()) async def _execute_pipeline(pipeline: "PipelineLike") -> "list[Any]": result = pipeline.execute() if inspect.isawaitable(result): return list(await result) return list(cast("list[Any]", result)) def hashed_index_value(value: "str") -> "str": """Return a stable Redis-key-safe index value.""" return hashlib.sha256(value.encode()).hexdigest()