Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/memmark/store.py → src/memmark/store/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
130 changes: 130 additions & 0 deletions src/memmark/store/redis_store.py
Original file line number Diff line number Diff line change
@@ -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
151 changes: 151 additions & 0 deletions tests/test_redis_store.py
Original file line number Diff line number Diff line change
@@ -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()