diff --git a/src/queue/__init__.py b/src/queue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/queue/handler.py b/src/queue/handler.py new file mode 100644 index 0000000..da5c9bf --- /dev/null +++ b/src/queue/handler.py @@ -0,0 +1,97 @@ +"""QueueHandler mit Redis-Streams – Idempotenz und Dedup via message_id.""" + +from __future__ import annotations + +from typing import Any, cast + +import redis +import redis.exceptions + +from src.models.submission import Submission + + +class QueueHandler: + """Redis-Stream-basierte Warteschlange für Submissions.""" + + def __init__(self, redis_host: str = "localhost", redis_port: int = 6379) -> None: + """Verbindung zu Redis (echt oder fakeredis).""" + self.redis: redis.Redis[Any] = redis.Redis( # type: ignore[type-arg] + host=redis_host, port=redis_port, decode_responses=True + ) + self.stream_key: str = "bsn:submissions" + self.processed_set: str = "bsn:processed" + self.stream_ids_hash: str = "bsn:stream_ids" + self.group_name: str = "bsn-workers" + self._ensure_consumer_group() + + def _ensure_consumer_group(self) -> None: + """Erstellt die Consumer-Group, falls nicht vorhanden.""" + try: + self.redis.xgroup_create(self.stream_key, self.group_name, id="0", mkstream=True) + except redis.exceptions.ResponseError as exc: + if "BUSYGROUP" not in str(exc): + raise + + def enqueue(self, submission: Submission) -> str | None: + """Reiht eine Submission in den Stream ein (idempotent via message_id). + + Returns: + stream_id bei Erfolg, None wenn bereits verarbeitet (Duplikat). + """ + if self.redis.sismember(self.processed_set, submission.message_id): + return None + + data: dict[str, str] = {"submission": submission.model_dump_json()} + stream_id: str = cast(str, self.redis.xadd(self.stream_key, data)) # type: ignore[arg-type] + + # Dedup-Merkmal setzen + 24h TTL + self.redis.sadd(self.processed_set, submission.message_id) + self.redis.expire(self.processed_set, 86400) + + # Mapping für späteres XACK ablegen + self.redis.hset(self.stream_ids_hash, submission.message_id, stream_id) + + return stream_id + + def dequeue(self, count: int = 5) -> list[Submission]: + """Holt bis zu *count* Submissions aus dem Stream (Consumer-Group). + + Returns: + Liste von Submission-Objekten. + """ + results: Any = self.redis.xreadgroup( + self.group_name, + "bsn-consumer", + {self.stream_key: ">"}, + count=count, + block=1000, + ) + submissions: list[Submission] = [] + if not results: + return submissions + + for _stream_name, messages in results: + for _msg_id, fields in messages: + sub_json: str = cast(str, fields.get("submission", "{}")) + submissions.append(Submission.model_validate_json(sub_json)) + + return submissions + + def mark_done(self, submission: Submission) -> None: + """Bestätigt Verarbeitung: XACK und Eintrag im processed_set.""" + # Stream-ID aus Redis-Hash holen + stream_id: str | None = cast( + str | None, self.redis.hget(self.stream_ids_hash, submission.message_id) + ) + if stream_id: + self.redis.xack(self.stream_key, self.group_name, stream_id) + # Bereinige das Mapping + self.redis.hdel(self.stream_ids_hash, submission.message_id) + + # Idempotent in processed_set (falls noch nicht drin) + self.redis.sadd(self.processed_set, submission.message_id) + self.redis.expire(self.processed_set, 86400) + + def queue_size(self) -> int: + """Gibt die Anzahl der Nachrichten im Stream zurück.""" + return self.redis.xlen(self.stream_key) diff --git a/tests/test_queue_handler.py b/tests/test_queue_handler.py new file mode 100644 index 0000000..6b859a7 --- /dev/null +++ b/tests/test_queue_handler.py @@ -0,0 +1,102 @@ +"""Tests für QueueHandler mit fakeredis.""" + +from __future__ import annotations + +from collections.abc import Generator +from datetime import datetime + +import fakeredis +import pytest + +from src.models.submission import Submission +from src.queue.handler import QueueHandler + + +@pytest.fixture +def handler() -> Generator[QueueHandler]: + """QueueHandler mit fakeredis-Backend.""" + fake_redis = fakeredis.FakeRedis(decode_responses=True) + qh = QueueHandler.__new__(QueueHandler) + qh.redis = fake_redis + qh.stream_key = "bsn:submissions" + qh.processed_set = "bsn:processed" + qh.stream_ids_hash = "bsn:stream_ids" + qh.group_name = "bsn-workers" + qh._ensure_consumer_group() + yield qh + fake_redis.flushall() + + +def _make_submission(message_id: str = "msg-001") -> Submission: + """Erzeugt eine Test-Submission.""" + return Submission( + message_id=message_id, + plattform="whatsapp", + absender="+491234567890", + text="Testnachricht für QueueHandler.", + zeitstempel=datetime(2025, 1, 1, 12, 0, 0), + ) + + +def test_enqueue_neue_submission_gibt_stream_id(handler: QueueHandler) -> None: + """Eine neue Submission wird eingereiht und liefert eine stream_id zurück.""" + sub = _make_submission("msg-001") + stream_id = handler.enqueue(sub) + assert stream_id is not None + assert isinstance(stream_id, str) + assert len(stream_id) > 0 + + +def test_enqueue_duplikat_gibt_none(handler: QueueHandler) -> None: + """Eine doppelt eingereihte Submission ergibt None.""" + sub = _make_submission("msg-dup") + first = handler.enqueue(sub) + assert first is not None + + second = handler.enqueue(sub) + assert second is None + + +def test_dequeue_holt_eingereihte_submission(handler: QueueHandler) -> None: + """dequeue liefert zuvor enqueuete Submissions zurück.""" + sub = _make_submission("msg-003") + handler.enqueue(sub) + + results = handler.dequeue(count=10) + assert len(results) == 1 + assert results[0].message_id == "msg-003" + assert results[0].plattform == "whatsapp" + assert results[0].text == "Testnachricht für QueueHandler." + + +def test_mark_done_entfernt_aus_queue(handler: QueueHandler) -> None: + """Nach mark_done wird die Submission aus dem Stream-Pending entfernt.""" + sub = _make_submission("msg-004") + handler.enqueue(sub) + + # Aus der Queue holen (Pending-Eintrag) + results = handler.dequeue(count=10) + assert len(results) == 1 + + # Als erledigt markieren + handler.mark_done(sub) + + # Nach XACK kein erneutes Dequeue (keine pending messages mehr) + results2 = handler.dequeue(count=10) + assert len(results2) == 0 + + +def test_queue_size_gibt_korrekte_anzahl(handler: QueueHandler) -> None: + """queue_size gibt die Anzahl der Nachrichten im Stream korrekt zurück.""" + assert handler.queue_size() == 0 + + for i in range(5): + handler.enqueue(_make_submission(f"msg-size-{i}")) + + assert handler.queue_size() == 5 + + # Weitere 3 + for i in range(5, 8): + handler.enqueue(_make_submission(f"msg-size-{i}")) + + assert handler.queue_size() == 8