Files
python-repositories/python_repositories/adapters/memory_queue_adapter.py
T
Brian Bjarke JensenandCursor a768474892 Add QueueRepositoryInterface with memory and file-backed adapters.
Provide a generic disk-backed FIFO queue with configurable path, retention, and dedup keys so consumers can buffer items across restarts without optional extras.

Co-authored-by: Cursor <[email protected]>
2026-07-16 20:18:00 +02:00

127 lines
4.4 KiB
Python

"""In-memory queue adapter with optional dedup and age-based eviction."""
from __future__ import annotations
from collections import OrderedDict
from collections.abc import Hashable
from datetime import UTC, datetime
import threading
from typing import Any
from python_repositories.interfaces.queue_repository_interface import (
QueueRepositoryInterface,
)
def _parse_age(value: object) -> datetime:
"""Normalize an age field to a timezone-aware UTC datetime."""
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
if isinstance(value, str):
normalized = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
raise ValueError(f"age value must be datetime or ISO-8601 str, got {type(value)!r}")
class MemoryQueueAdapter(QueueRepositoryInterface):
"""Thread-safe in-process FIFO queue with optional deduplication."""
def __init__(
self,
*,
dedup_keys: tuple[str, ...] = (),
age_key: str | None = None,
) -> None:
self._dedup_keys = dedup_keys
self._age_key = age_key
self._lock = threading.RLock()
self._items: OrderedDict[Hashable, dict[str, Any]] = OrderedDict()
self._seq = 0
def enqueue(self, items: list[dict[str, Any]]) -> None:
"""Append items; skip duplicates when ``dedup_keys`` is configured."""
self.enqueue_and_return_added(items)
def enqueue_and_return_added(
self, items: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Enqueue items and return the subset that was newly stored."""
if not items:
return []
added: list[dict[str, Any]] = []
with self._lock:
for raw in items:
item = self._normalize_item(raw)
key = self._make_key(item)
if self._dedup_keys and key in self._items:
continue
self._items[key] = item
added.append(item)
return added
def dequeue_batch(self, *, max_items: int = 1000) -> list[dict[str, Any]]:
"""Remove and return up to ``max_items`` items in FIFO order."""
if max_items < 0:
raise ValueError("max_items must be >= 0")
with self._lock:
batch: list[dict[str, Any]] = []
for _ in range(min(max_items, len(self._items))):
_key, item = self._items.popitem(last=False)
batch.append(item)
return batch
def size(self) -> int:
"""Return the number of items currently in the queue."""
with self._lock:
return len(self._items)
def evict_older_than(self, cutoff: datetime) -> int:
"""Remove items whose age field is strictly older than ``cutoff``."""
if self._age_key is None:
return 0
cutoff_utc = _parse_age(cutoff)
removed = 0
with self._lock:
to_remove = [
key
for key, item in self._items.items()
if _parse_age(item[self._age_key]) < cutoff_utc
]
for key in to_remove:
del self._items[key]
removed += 1
return removed
def clear(self) -> None:
"""Remove all items from the queue."""
with self._lock:
self._items.clear()
def snapshot(self) -> list[dict[str, Any]]:
"""Return a shallow copy of queued items in FIFO order."""
with self._lock:
return [dict(item) for item in self._items.values()]
def _normalize_item(self, raw: dict[str, Any]) -> dict[str, Any]:
item = dict(raw)
if self._dedup_keys:
missing = [key for key in self._dedup_keys if key not in item]
if missing:
raise ValueError(f"item missing dedup key(s): {missing}")
if self._age_key is not None:
if self._age_key not in item:
raise ValueError(f"item missing age key: {self._age_key!r}")
item[self._age_key] = _parse_age(item[self._age_key])
return item
def _make_key(self, item: dict[str, Any]) -> Hashable:
if not self._dedup_keys:
self._seq += 1
return self._seq
return tuple(item[key] for key in self._dedup_keys)