feat: QueueHandler mit Redis-Streams, Idempotenz, Dedup
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user