From 4bc30231165a6434f34cfff4167dbd6cf574c2b4 Mon Sep 17 00:00:00 2001 From: unsiqasik Date: Fri, 29 May 2026 01:53:49 +0000 Subject: [PATCH] feat: add RedisMemoryStore backend - Add RedisMemoryStore extending MemoryStore interface - Support connection pooling, key prefixes, TTL - Graceful error handling on connection failures - Convert store module to package for clean organization - Add redis as optional dependency - Add 19 tests with fakeredis - All existing tests pass (294 total) Closes #2 --- pyproject.toml | 4 + src/memmark/{store.py => store/__init__.py} | 5 + src/memmark/store/redis_store.py | 130 +++++++++++++++++ tests/test_redis_store.py | 151 ++++++++++++++++++++ 4 files changed, 290 insertions(+) rename src/memmark/{store.py => store/__init__.py} (94%) create mode 100644 src/memmark/store/redis_store.py create mode 100644 tests/test_redis_store.py diff --git a/pyproject.toml b/pyproject.toml index fdcd1d9..5333385 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,12 +49,16 @@ Repository = "https://github.com/Carlos-Projects/memmark" Issues = "https://github.com/Carlos-Projects/memmark/issues" [project.optional-dependencies] +redis = [ + "redis>=5.0.0", +] dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.23.0", "pytest-cov>=5.0.0", "ruff>=0.4.0", "mypy>=1.10.0", + "fakeredis>=2.0.0", ] docs = [ "mkdocs>=1.6.0", diff --git a/src/memmark/store.py b/src/memmark/store/__init__.py similarity index 94% rename from src/memmark/store.py rename to src/memmark/store/__init__.py index f6103e7..0ea546d 100644 --- a/src/memmark/store.py +++ b/src/memmark/store/__init__.py @@ -89,3 +89,8 @@ def write(self, memories: list[dict[str, Any]]) -> None: def append(self, entry: dict[str, Any]) -> None: self._memories.append(entry) + + +from memmark.store.redis_store import RedisMemoryStore # noqa: E402 + +__all__ = ["MemoryStore", "FileMemoryStore", "InMemoryMemoryStore", "RedisMemoryStore"] diff --git a/src/memmark/store/redis_store.py b/src/memmark/store/redis_store.py new file mode 100644 index 0000000..8146346 --- /dev/null +++ b/src/memmark/store/redis_store.py @@ -0,0 +1,130 @@ +# Copyright (c) 2025 Carlos-Projects +# SPDX-License-Identifier: MIT + +"""Redis-backed memory store for production deployments.""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any + +from memmark.store import MemoryStore + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + +_DEFAULT_URL = "redis://localhost:6379/0" + + +class RedisMemoryStore(MemoryStore): + """Memory store backed by Redis. + + Supports connection pooling, key prefixes, TTL, + and graceful error handling on connection failures. + + Args: + url: Redis connection URL. + key_prefix: Prefix for Redis keys. + ttl: Time-to-live in seconds. + kw: Additional arguments for redis.Redis(). + """ + + def __init__( + self, + url: str | None = None, + key_prefix: str = "memmark-memories", + ttl: int | None = None, + **kw: Any, + ) -> None: + try: + import redis as rmod + except ImportError as exc: + raise RuntimeError("Install redis: pip install redis") from exc + + self._redis = rmod + self._url = url or _DEFAULT_URL + self._key = key_prefix + ":memories" + self._ttl = ttl + self._client = rmod.Redis( + url=self._url, + decode_responses=True, + **kw, + ) + try: + self._client.ping() + except (rmod.ConnectionError, rmod.TimeoutError) as exc: + logger.warning("Redis unavailable: %s", exc) + + @property + def client(self) -> Any: + return self._client + + def read(self) -> list[dict[str, Any]]: + try: + data = self._client.get(self._key) + except (self._redis.ConnectionError, self._redis.TimeoutError) as exc: + raise ConnectionError("Redis read failed") from exc + if data is None: + return [] + try: + parsed = json.loads(data) + except (json.JSONDecodeError, TypeError): + return [] + if isinstance(parsed, list): + return parsed + if isinstance(parsed, dict): + return parsed.get("memories", parsed.get("entries", [parsed])) + return [] + + def write(self, memories: list[dict[str, Any]]) -> None: + payload = json.dumps(memories, ensure_ascii=False) + try: + if self._ttl is not None: + self._client.setex(self._key, self._ttl, payload) + else: + self._client.set(self._key, payload) + except (self._redis.ConnectionError, self._redis.TimeoutError) as exc: + raise ConnectionError("Redis write failed") from exc + + def append(self, entry: dict[str, Any]) -> None: + try: + data = self._client.get(self._key) + memories: list[dict[str, Any]] = [] + if data is not None: + try: + parsed = json.loads(data) + if isinstance(parsed, list): + memories = parsed + elif isinstance(parsed, dict): + memories = parsed.get("memories", parsed.get("entries", [parsed])) + except (json.JSONDecodeError, TypeError): + pass + memories.append(entry) + payload = json.dumps(memories, ensure_ascii=False) + if self._ttl is not None: + self._client.setex(self._key, self._ttl, payload) + else: + self._client.set(self._key, payload) + except (self._redis.ConnectionError, self._redis.TimeoutError) as exc: + raise ConnectionError("Redis append failed") from exc + + def clear(self) -> None: + try: + self._client.delete(self._key) + except (self._redis.ConnectionError, self._redis.TimeoutError) as exc: + raise ConnectionError("Redis clear failed") from exc + + def size(self) -> int: + try: + data = self._client.get(self._key) + if data is None: + return 0 + parsed = json.loads(data) + if isinstance(parsed, list): + return len(parsed) + return 0 + except (self._redis.ConnectionError, self._redis.TimeoutError, json.JSONDecodeError, TypeError): + return 0 diff --git a/tests/test_redis_store.py b/tests/test_redis_store.py new file mode 100644 index 0000000..7d4af4c --- /dev/null +++ b/tests/test_redis_store.py @@ -0,0 +1,151 @@ +# Copyright (c) 2025 Carlos-Projects +# SPDX-License-Identifier: MIT + +"""Tests for RedisMemoryStore.""" + +from __future__ import annotations + +import json +import types +from unittest.mock import patch + +import pytest + +from memmark.store import MemoryStore +from memmark.store.redis_store import RedisMemoryStore + +fakeredis = pytest.importorskip("fakeredis") + + +def _make_fake_redis_module(): + """Create a fake redis module with real exception types.""" + mod = types.ModuleType("redis") + mod.ConnectionError = type("ConnectionError", (Exception,), {}) + mod.TimeoutError = type("TimeoutError", (Exception,), {}) + return mod + + +@pytest.fixture +def fake_redis(): + return fakeredis.FakeRedis(decode_responses=True) + + +@pytest.fixture +def store(fake_redis): + """Create a RedisMemoryStore with fakeredis.""" + s = object.__new__(RedisMemoryStore) + s._redis = _make_fake_redis_module() + s._client = fake_redis + s._key = "test-memories" + s._ttl = None + return s + + +class TestRedisMemoryStore: + """Tests for the Redis memory store.""" + + def test_read_empty(self, store): + assert store.read() == [] + + def test_write_and_read(self, store): + memories = [ + {"id": "m1", "content": "hello"}, + {"id": "m2", "content": "world"}, + ] + store.write(memories) + assert store.read() == memories + + def test_append(self, store): + store.write([{"id": "m1"}]) + store.append({"id": "m2"}) + result = store.read() + assert len(result) == 2 + assert result[0]["id"] == "m1" + assert result[1]["id"] == "m2" + + def test_append_to_empty(self, store): + store.append({"id": "m1"}) + result = store.read() + assert len(result) == 1 + + def test_write_overwrites(self, store): + store.write([{"id": "old"}]) + store.write([{"id": "new"}]) + assert store.read() == [{"id": "new"}] + + def test_clear(self, store): + store.write([{"id": "m1"}, {"id": "m2"}]) + store.clear() + assert store.read() == [] + + def test_size(self, store): + assert store.size() == 0 + store.write([{"id": "m1"}, {"id": "m2"}]) + assert store.size() == 2 + + def test_read_dict_memories_key(self, store): + data = json.dumps({"memories": [{"id": "m1"}]}) + store._client.set(store._key, data) + assert store.read() == [{"id": "m1"}] + + def test_read_dict_entries_key(self, store): + data = json.dumps({"entries": [{"id": "m1"}]}) + store._client.set(store._key, data) + assert store.read() == [{"id": "m1"}] + + def test_read_corrupted_data(self, store): + store._client.set(store._key, "not-json") + assert store.read() == [] + + def test_read_connection_error(self, store): + err_cls = store._redis.ConnectionError + with patch.object(store._client, "get", side_effect=err_cls("fail")), pytest.raises(ConnectionError): + store.read() + + def test_write_connection_error(self, store): + err_cls = store._redis.ConnectionError + with patch.object(store._client, "set", side_effect=err_cls("fail")), pytest.raises(ConnectionError): + store.write([{"id": "m1"}]) + + def test_append_connection_error(self, store): + err_cls = store._redis.ConnectionError + with patch.object(store._client, "get", side_effect=err_cls("fail")), pytest.raises(ConnectionError): + store.append({"id": "m1"}) + + def test_clear_connection_error(self, store): + err_cls = store._redis.ConnectionError + with patch.object(store._client, "delete", side_effect=err_cls("fail")), pytest.raises(ConnectionError): + store.clear() + + def test_size_on_error(self, store): + err_cls = store._redis.ConnectionError + with patch.object(store._client, "get", side_effect=err_cls("fail")): + assert store.size() == 0 + + def test_ttl_write(self, fake_redis): + s = object.__new__(RedisMemoryStore) + s._redis = _make_fake_redis_module() + s._client = fake_redis + s._key = "test-ttl" + s._ttl = 300 + s.write([{"id": "m1"}]) + ttl = fake_redis.ttl("test-ttl") + assert ttl is not None and ttl > 0 + + def test_append_corrupted_ignores(self, store): + store._client.set(store._key, "bad-json") + store.append({"id": "new"}) + result = store.read() + assert len(result) == 1 + assert result[0]["id"] == "new" + + def test_is_memory_store_subclass(self): + assert issubclass(RedisMemoryStore, MemoryStore) + + +class TestRedisMemoryStoreInit: + """Tests for RedisMemoryStore initialization.""" + + def test_requires_redis_package(self): + with patch.dict("sys.modules", {"redis": None}), pytest.raises(RuntimeError, match="Install redis"): + RedisMemoryStore()