memory-reuse 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memory_reuse/__init__.py +44 -0
- memory_reuse/_utils.py +125 -0
- memory_reuse/backends/__init__.py +21 -0
- memory_reuse/backends/base.py +76 -0
- memory_reuse/backends/memory.py +170 -0
- memory_reuse/backends/redis.py +217 -0
- memory_reuse/cache/__init__.py +12 -0
- memory_reuse/cache/exact.py +194 -0
- memory_reuse/cache/tool.py +170 -0
- memory_reuse/config.py +99 -0
- memory_reuse/core.py +224 -0
- memory_reuse/exceptions.py +42 -0
- memory_reuse/integrations/__init__.py +26 -0
- memory_reuse/integrations/langgraph.py +235 -0
- memory_reuse/integrations/litellm.py +277 -0
- memory_reuse/py.typed +0 -0
- memory_reuse/stats.py +112 -0
- memory_reuse-0.1.0.dist-info/METADATA +276 -0
- memory_reuse-0.1.0.dist-info/RECORD +21 -0
- memory_reuse-0.1.0.dist-info/WHEEL +4 -0
- memory_reuse-0.1.0.dist-info/licenses/LICENSE +21 -0
memory_reuse/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""memory-reuse — execution cache layer for AI agents.
|
|
2
|
+
|
|
3
|
+
Reduces LLM and tool call costs by caching results with exact-match hashing,
|
|
4
|
+
TTL support, multi-scope isolation (global / user / session), and first-class
|
|
5
|
+
LangGraph integration.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from memory_reuse import MemoryCache, CacheConfig
|
|
10
|
+
|
|
11
|
+
cache = MemoryCache() # in-memory backend, 1-hour TTL
|
|
12
|
+
cache.set_context(user_id="alice")
|
|
13
|
+
|
|
14
|
+
# LangGraph decorator
|
|
15
|
+
from memory_reuse.integrations import cached_tool
|
|
16
|
+
|
|
17
|
+
@cached_tool(cache, scope="user", ttl=300)
|
|
18
|
+
async def search(query: str) -> list[str]:
|
|
19
|
+
...
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from memory_reuse.config import CacheConfig
|
|
23
|
+
from memory_reuse.core import MemoryCache
|
|
24
|
+
from memory_reuse.exceptions import (
|
|
25
|
+
AgentMemoryError,
|
|
26
|
+
BackendConnectionError,
|
|
27
|
+
BackendNotAvailableError,
|
|
28
|
+
InvalidTTLError,
|
|
29
|
+
ScopeViolationError,
|
|
30
|
+
)
|
|
31
|
+
from memory_reuse.stats import CacheStats
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"MemoryCache",
|
|
35
|
+
"CacheConfig",
|
|
36
|
+
"CacheStats",
|
|
37
|
+
"AgentMemoryError",
|
|
38
|
+
"BackendConnectionError",
|
|
39
|
+
"BackendNotAvailableError",
|
|
40
|
+
"ScopeViolationError",
|
|
41
|
+
"InvalidTTLError",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
__version__ = "0.1.0"
|
memory_reuse/_utils.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Internal utilities for key building, hashing, and serialisation.
|
|
2
|
+
|
|
3
|
+
These are implementation details — do not import from outside this package.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import gzip
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# Characters allowed in cache key segments (alphanumeric, dash, underscore, dot)
|
|
15
|
+
_SAFE_KEY_RE = re.compile(r"[^a-zA-Z0-9_\-.]")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sanitize_key(key: str) -> str:
|
|
19
|
+
"""Replace unsafe characters in a key segment with underscores.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
key: Raw key segment string.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A string where any character not matching ``[a-zA-Z0-9_\\-.]`` is
|
|
26
|
+
replaced with ``_``.
|
|
27
|
+
|
|
28
|
+
Example::
|
|
29
|
+
|
|
30
|
+
>>> sanitize_key("user@example.com")
|
|
31
|
+
'user_example.com'
|
|
32
|
+
"""
|
|
33
|
+
return _SAFE_KEY_RE.sub("_", key)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_cache_key(prefix: str, scope: str, scope_id: str | None, *parts: Any) -> str:
|
|
37
|
+
"""Build a namespaced, deterministic cache key.
|
|
38
|
+
|
|
39
|
+
The resulting key follows the pattern::
|
|
40
|
+
|
|
41
|
+
<prefix>:<scope>:<scope_id>:<hash_of_parts>
|
|
42
|
+
|
|
43
|
+
When ``scope`` is ``"global"`` the ``scope_id`` segment is omitted.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
prefix: Top-level namespace (e.g. ``"agentmem"``).
|
|
47
|
+
scope: One of ``"global"``, ``"user"``, or ``"session"``.
|
|
48
|
+
scope_id: Identifier for the scope (user ID or session ID).
|
|
49
|
+
Must be provided for non-global scopes.
|
|
50
|
+
*parts: Arbitrary values that together identify the cached item.
|
|
51
|
+
They are JSON-serialised and hashed.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
A colon-separated cache key string.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If a non-global scope is used without a ``scope_id``.
|
|
58
|
+
"""
|
|
59
|
+
if scope != "global" and not scope_id:
|
|
60
|
+
raise ValueError(f"scope_id is required for scope='{scope}'")
|
|
61
|
+
|
|
62
|
+
parts_hash = hash_value(list(parts))
|
|
63
|
+
safe_prefix = sanitize_key(prefix)
|
|
64
|
+
safe_scope = sanitize_key(scope)
|
|
65
|
+
|
|
66
|
+
if scope == "global":
|
|
67
|
+
return f"{safe_prefix}:{safe_scope}:{parts_hash}"
|
|
68
|
+
|
|
69
|
+
safe_scope_id = sanitize_key(scope_id) # type: ignore[arg-type]
|
|
70
|
+
return f"{safe_prefix}:{safe_scope}:{safe_scope_id}:{parts_hash}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hash_value(value: Any) -> str:
|
|
74
|
+
"""Produce a 32-character SHA-256 hex digest of a JSON-serialised value.
|
|
75
|
+
|
|
76
|
+
Keys in mappings are sorted to ensure deterministic output regardless of
|
|
77
|
+
insertion order.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
value: Any JSON-serialisable value.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The first 32 hex characters of the SHA-256 digest.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
TypeError: If ``value`` cannot be JSON-serialised.
|
|
87
|
+
"""
|
|
88
|
+
serialised = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str)
|
|
89
|
+
digest = hashlib.sha256(serialised.encode("utf-8")).hexdigest()
|
|
90
|
+
return digest[:32]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def serialize_value(value: Any) -> bytes:
|
|
94
|
+
"""Serialise and gzip-compress a value for storage in the backend.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
value: Any JSON-serialisable value.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
gzip-compressed JSON bytes.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
TypeError: If ``value`` cannot be JSON-serialised.
|
|
104
|
+
"""
|
|
105
|
+
raw = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
|
106
|
+
return gzip.compress(raw, compresslevel=6)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def deserialize_value(data: bytes) -> Any:
|
|
110
|
+
"""Decompress and deserialise bytes produced by :func:`serialize_value`.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
data: gzip-compressed JSON bytes.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The original Python object.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ValueError: If the data cannot be decompressed or parsed.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
raw = gzip.decompress(data)
|
|
123
|
+
return json.loads(raw.decode("utf-8"))
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise ValueError(f"Failed to deserialise cached value: {exc}") from exc
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Cache backend implementations for memory-reuse.
|
|
2
|
+
|
|
3
|
+
Available backends:
|
|
4
|
+
|
|
5
|
+
* :class:`~memory_reuse.backends.memory.InMemoryBackend` — zero-dependency,
|
|
6
|
+
in-process storage with LRU eviction and TTL support.
|
|
7
|
+
* :class:`~memory_reuse.backends.redis.RedisBackend` — Redis-backed storage
|
|
8
|
+
(requires ``pip install memory-reuse[redis]``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from memory_reuse.backends.base import AbstractBackend
|
|
12
|
+
from memory_reuse.backends.memory import InMemoryBackend
|
|
13
|
+
|
|
14
|
+
__all__ = ["AbstractBackend", "InMemoryBackend"]
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from memory_reuse.backends.redis import RedisBackend # noqa: F401
|
|
18
|
+
|
|
19
|
+
__all__.append("RedisBackend")
|
|
20
|
+
except ImportError:
|
|
21
|
+
pass
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Abstract base class for cache backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AbstractBackend(ABC):
|
|
9
|
+
"""Interface that all cache backends must implement.
|
|
10
|
+
|
|
11
|
+
Every method is a coroutine so that network-bound backends (e.g. Redis)
|
|
12
|
+
can be awaited without blocking the event loop, while the in-memory
|
|
13
|
+
backend simply returns immediately.
|
|
14
|
+
|
|
15
|
+
Implementors should document their own connection-lifecycle behaviour
|
|
16
|
+
(lazy vs eager connection, reconnect logic, etc.).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def get(self, key: str) -> bytes | None:
|
|
21
|
+
"""Retrieve the raw bytes stored under ``key``.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
key: The cache key to look up.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The stored bytes, or ``None`` if the key does not exist or has
|
|
28
|
+
expired.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
|
|
33
|
+
"""Store ``value`` under ``key``.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
key: The cache key.
|
|
37
|
+
value: Raw bytes to store (typically gzip-compressed JSON).
|
|
38
|
+
ttl: Time-to-live in seconds. ``None`` means the entry never
|
|
39
|
+
expires. A backend may ignore this if it does not support TTL.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def delete(self, key: str) -> None:
|
|
44
|
+
"""Remove the entry for ``key`` if it exists.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
key: The cache key to remove. A no-op if the key does not exist.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def exists(self, key: str) -> bool:
|
|
52
|
+
"""Check whether a non-expired entry exists for ``key``.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
key: The cache key to check.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
``True`` if the key exists and has not expired, ``False``
|
|
59
|
+
otherwise.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
async def flush(self) -> None:
|
|
64
|
+
"""Delete all entries managed by this backend instance.
|
|
65
|
+
|
|
66
|
+
Use with caution in production — this removes every cached value.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
async def ping(self) -> bool:
|
|
71
|
+
"""Check backend connectivity.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
``True`` if the backend is reachable and functioning, ``False``
|
|
75
|
+
otherwise. Should not raise.
|
|
76
|
+
"""
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""In-memory cache backend with TTL support and LRU eviction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from memory_reuse.backends.base import AbstractBackend
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_DEFAULT_MAX_ENTRIES = 10_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class _Entry:
|
|
20
|
+
"""Internal storage wrapper for a cached value with optional expiry."""
|
|
21
|
+
|
|
22
|
+
value: bytes
|
|
23
|
+
expires_at: float | None # Unix timestamp, or None for no expiry
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InMemoryBackend(AbstractBackend):
|
|
27
|
+
"""Fully in-memory cache backend — no external dependencies required.
|
|
28
|
+
|
|
29
|
+
Features:
|
|
30
|
+
|
|
31
|
+
* **TTL** — entries are lazily expired on access.
|
|
32
|
+
* **LRU eviction** — when ``max_entries`` is reached the least-recently-used
|
|
33
|
+
entry is dropped to make room for the new one.
|
|
34
|
+
* **Thread-safe** — an :class:`asyncio.Lock` serialises all mutations.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
max_entries: Maximum number of entries to hold before LRU eviction
|
|
38
|
+
kicks in. Defaults to 10 000.
|
|
39
|
+
|
|
40
|
+
Example::
|
|
41
|
+
|
|
42
|
+
backend = InMemoryBackend(max_entries=500)
|
|
43
|
+
await backend.set("key", b"value", ttl=60)
|
|
44
|
+
data = await backend.get("key")
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, max_entries: int = _DEFAULT_MAX_ENTRIES) -> None:
|
|
48
|
+
if max_entries <= 0:
|
|
49
|
+
raise ValueError(f"max_entries must be positive, got {max_entries}")
|
|
50
|
+
self._max_entries = max_entries
|
|
51
|
+
# OrderedDict used as an ordered map: most-recently-used at the end
|
|
52
|
+
self._store: OrderedDict[str, _Entry] = OrderedDict()
|
|
53
|
+
self._lock = asyncio.Lock()
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# AbstractBackend implementation
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async def get(self, key: str) -> bytes | None:
|
|
60
|
+
"""Return stored bytes for ``key``, or ``None`` on miss/expiry.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
key: Cache key to look up.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Stored bytes or ``None``.
|
|
67
|
+
"""
|
|
68
|
+
async with self._lock:
|
|
69
|
+
entry = self._store.get(key)
|
|
70
|
+
if entry is None:
|
|
71
|
+
return None
|
|
72
|
+
if self._is_expired(entry):
|
|
73
|
+
del self._store[key]
|
|
74
|
+
logger.debug("InMemoryBackend: key expired, removed from store")
|
|
75
|
+
return None
|
|
76
|
+
# Move to end (most recently used)
|
|
77
|
+
self._store.move_to_end(key)
|
|
78
|
+
return entry.value
|
|
79
|
+
|
|
80
|
+
async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
|
|
81
|
+
"""Store ``value`` under ``key`` with an optional TTL.
|
|
82
|
+
|
|
83
|
+
If the store is at capacity, the LRU entry is evicted first.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
key: Cache key.
|
|
87
|
+
value: Bytes to store.
|
|
88
|
+
ttl: Time-to-live in seconds. ``None`` means no expiry.
|
|
89
|
+
"""
|
|
90
|
+
expires_at: float | None = None
|
|
91
|
+
if ttl is not None:
|
|
92
|
+
expires_at = time.monotonic() + ttl
|
|
93
|
+
|
|
94
|
+
async with self._lock:
|
|
95
|
+
if key in self._store:
|
|
96
|
+
# Update in place and move to end
|
|
97
|
+
self._store[key] = _Entry(value=value, expires_at=expires_at)
|
|
98
|
+
self._store.move_to_end(key)
|
|
99
|
+
else:
|
|
100
|
+
if len(self._store) >= self._max_entries:
|
|
101
|
+
evicted_key, _ = self._store.popitem(last=False)
|
|
102
|
+
logger.debug("InMemoryBackend: LRU eviction triggered")
|
|
103
|
+
self._store[key] = _Entry(value=value, expires_at=expires_at)
|
|
104
|
+
|
|
105
|
+
async def delete(self, key: str) -> None:
|
|
106
|
+
"""Remove the entry for ``key``.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
key: Cache key to remove. No-op if the key does not exist.
|
|
110
|
+
"""
|
|
111
|
+
async with self._lock:
|
|
112
|
+
self._store.pop(key, None)
|
|
113
|
+
|
|
114
|
+
async def exists(self, key: str) -> bool:
|
|
115
|
+
"""Return ``True`` if ``key`` exists and has not expired.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
key: Cache key to check.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Boolean existence flag.
|
|
122
|
+
"""
|
|
123
|
+
async with self._lock:
|
|
124
|
+
entry = self._store.get(key)
|
|
125
|
+
if entry is None:
|
|
126
|
+
return False
|
|
127
|
+
if self._is_expired(entry):
|
|
128
|
+
del self._store[key]
|
|
129
|
+
return False
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
async def flush(self) -> None:
|
|
133
|
+
"""Remove all entries from the store."""
|
|
134
|
+
async with self._lock:
|
|
135
|
+
self._store.clear()
|
|
136
|
+
logger.debug("InMemoryBackend: store flushed")
|
|
137
|
+
|
|
138
|
+
async def ping(self) -> bool:
|
|
139
|
+
"""Always returns ``True`` — the in-memory backend is always available.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
``True``
|
|
143
|
+
"""
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Helpers
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _is_expired(self, entry: _Entry) -> bool:
|
|
151
|
+
"""Check whether an entry has passed its TTL.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
entry: The :class:`_Entry` to inspect.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
``True`` if the entry has an expiry time that is in the past.
|
|
158
|
+
"""
|
|
159
|
+
if entry.expires_at is None:
|
|
160
|
+
return False
|
|
161
|
+
return time.monotonic() >= entry.expires_at
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def size(self) -> int:
|
|
165
|
+
"""Current number of entries in the store (including not-yet-evicted expired ones).
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Integer entry count.
|
|
169
|
+
"""
|
|
170
|
+
return len(self._store)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Redis cache backend using redis.asyncio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
from memory_reuse.backends.base import AbstractBackend
|
|
10
|
+
from memory_reuse.exceptions import BackendConnectionError, BackendNotAvailableError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
# Only imported at runtime when redis is available
|
|
14
|
+
import redis.asyncio as aioredis
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_MAX_CONNECTIONS = 20
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RedisBackend(AbstractBackend):
|
|
22
|
+
"""Cache backend that stores data in Redis.
|
|
23
|
+
|
|
24
|
+
Requires the optional ``redis`` extra::
|
|
25
|
+
|
|
26
|
+
pip install memory-reuse[redis]
|
|
27
|
+
|
|
28
|
+
The connection is established lazily on the first operation.
|
|
29
|
+
Connection errors are converted to :exc:`BackendConnectionError` so
|
|
30
|
+
callers do not need to handle redis-specific exceptions.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
url: Redis connection URL (e.g. ``redis://localhost:6379/0``).
|
|
34
|
+
Prefer reading this from the ``MEMORY_REUSE_REDIS_URL``
|
|
35
|
+
environment variable rather than hardcoding it.
|
|
36
|
+
max_connections: Maximum size of the underlying connection pool.
|
|
37
|
+
Defaults to 20.
|
|
38
|
+
|
|
39
|
+
Example::
|
|
40
|
+
|
|
41
|
+
import os
|
|
42
|
+
backend = RedisBackend(url=os.environ["MEMORY_REUSE_REDIS_URL"])
|
|
43
|
+
await backend.set("key", b"value", ttl=300)
|
|
44
|
+
data = await backend.get("key")
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, url: str, max_connections: int = _MAX_CONNECTIONS) -> None:
|
|
48
|
+
self._url = url
|
|
49
|
+
self._max_connections = max_connections
|
|
50
|
+
self._client: aioredis.Redis | None = None # type: ignore[type-arg]
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# Connection management
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
async def _get_client(self) -> aioredis.Redis: # type: ignore[type-arg]
|
|
57
|
+
"""Return the Redis client, creating it on first call.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
An initialised ``redis.asyncio.Redis`` client.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
BackendNotAvailableError: If the ``redis`` package is not installed.
|
|
64
|
+
BackendConnectionError: If the connection attempt fails.
|
|
65
|
+
"""
|
|
66
|
+
if self._client is not None:
|
|
67
|
+
return self._client
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
import redis.asyncio as aioredis
|
|
71
|
+
except ImportError as exc:
|
|
72
|
+
raise BackendNotAvailableError(
|
|
73
|
+
"Redis backend requires 'redis' package. "
|
|
74
|
+
"Install it with: pip install memory-reuse[redis]"
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
pool = aioredis.ConnectionPool.from_url(
|
|
79
|
+
self._url,
|
|
80
|
+
max_connections=self._max_connections,
|
|
81
|
+
decode_responses=False,
|
|
82
|
+
)
|
|
83
|
+
self._client = aioredis.Redis(connection_pool=pool)
|
|
84
|
+
# Validate connectivity
|
|
85
|
+
await self._client.ping()
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
self._client = None
|
|
88
|
+
# Deliberately not logging self._url to avoid leaking credentials
|
|
89
|
+
logger.error("RedisBackend: failed to connect to Redis server")
|
|
90
|
+
raise BackendConnectionError(
|
|
91
|
+
"Could not connect to Redis. Check your connection URL and network."
|
|
92
|
+
) from exc
|
|
93
|
+
|
|
94
|
+
return self._client
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# AbstractBackend implementation
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
async def get(self, key: str) -> bytes | None:
|
|
101
|
+
"""Retrieve raw bytes stored under ``key``.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
key: Cache key.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Stored bytes or ``None`` on miss.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
BackendConnectionError: On Redis connectivity failure.
|
|
111
|
+
"""
|
|
112
|
+
client = await self._get_client()
|
|
113
|
+
try:
|
|
114
|
+
# The client is created with decode_responses=False, so values are
|
|
115
|
+
# always raw bytes (or None on a miss). Cast to satisfy the type
|
|
116
|
+
# checker, which infers the broader bytes | str | None union.
|
|
117
|
+
value = await client.get(key)
|
|
118
|
+
return cast("bytes | None", value)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
logger.error("RedisBackend: GET failed for key prefix '%s'", key[:8])
|
|
121
|
+
raise BackendConnectionError("Redis GET operation failed") from exc
|
|
122
|
+
|
|
123
|
+
async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
|
|
124
|
+
"""Store ``value`` under ``key``.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
key: Cache key.
|
|
128
|
+
value: Bytes to store.
|
|
129
|
+
ttl: Time-to-live in seconds. ``None`` means no expiry.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
BackendConnectionError: On Redis connectivity failure.
|
|
133
|
+
"""
|
|
134
|
+
client = await self._get_client()
|
|
135
|
+
try:
|
|
136
|
+
if ttl is not None:
|
|
137
|
+
await client.setex(key, ttl, value)
|
|
138
|
+
else:
|
|
139
|
+
await client.set(key, value)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logger.error("RedisBackend: SET failed for key prefix '%s'", key[:8])
|
|
142
|
+
raise BackendConnectionError("Redis SET operation failed") from exc
|
|
143
|
+
|
|
144
|
+
async def delete(self, key: str) -> None:
|
|
145
|
+
"""Remove the entry for ``key``.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
key: Cache key to remove.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
BackendConnectionError: On Redis connectivity failure.
|
|
152
|
+
"""
|
|
153
|
+
client = await self._get_client()
|
|
154
|
+
try:
|
|
155
|
+
await client.delete(key)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
logger.error("RedisBackend: DELETE failed for key prefix '%s'", key[:8])
|
|
158
|
+
raise BackendConnectionError("Redis DELETE operation failed") from exc
|
|
159
|
+
|
|
160
|
+
async def exists(self, key: str) -> bool:
|
|
161
|
+
"""Check whether ``key`` exists in Redis.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
key: Cache key.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
``True`` if the key exists (and has not expired in Redis).
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
BackendConnectionError: On Redis connectivity failure.
|
|
171
|
+
"""
|
|
172
|
+
client = await self._get_client()
|
|
173
|
+
try:
|
|
174
|
+
return bool(await client.exists(key))
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
logger.error("RedisBackend: EXISTS failed for key prefix '%s'", key[:8])
|
|
177
|
+
raise BackendConnectionError("Redis EXISTS operation failed") from exc
|
|
178
|
+
|
|
179
|
+
async def flush(self) -> None:
|
|
180
|
+
"""Delete all keys in the current Redis database.
|
|
181
|
+
|
|
182
|
+
Warning:
|
|
183
|
+
This calls ``FLUSHDB`` on the connected database. Use with care
|
|
184
|
+
in shared Redis environments.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
BackendConnectionError: On Redis connectivity failure.
|
|
188
|
+
"""
|
|
189
|
+
client = await self._get_client()
|
|
190
|
+
try:
|
|
191
|
+
await client.flushdb()
|
|
192
|
+
logger.debug("RedisBackend: database flushed")
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
raise BackendConnectionError("Redis FLUSHDB operation failed") from exc
|
|
195
|
+
|
|
196
|
+
async def ping(self) -> bool:
|
|
197
|
+
"""Check Redis connectivity.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
``True`` if Redis responds to PING, ``False`` on any error.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
client = await self._get_client()
|
|
204
|
+
return await client.ping()
|
|
205
|
+
except Exception:
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
async def close(self) -> None:
|
|
209
|
+
"""Close the connection pool gracefully.
|
|
210
|
+
|
|
211
|
+
Call this during application shutdown to release Redis connections.
|
|
212
|
+
"""
|
|
213
|
+
if self._client is not None:
|
|
214
|
+
with contextlib.suppress(Exception):
|
|
215
|
+
await self._client.aclose()
|
|
216
|
+
self._client = None
|
|
217
|
+
logger.debug("RedisBackend: connection pool closed")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Cache layer implementations for memory-reuse.
|
|
2
|
+
|
|
3
|
+
* :class:`~memory_reuse.cache.exact.ExactCache` — hash-keyed cache for
|
|
4
|
+
LLM responses and other deterministic lookups.
|
|
5
|
+
* :class:`~memory_reuse.cache.tool.ToolCache` — TTL-enforced cache for
|
|
6
|
+
tool/function call results.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from memory_reuse.cache.exact import ExactCache
|
|
10
|
+
from memory_reuse.cache.tool import ToolCache
|
|
11
|
+
|
|
12
|
+
__all__ = ["ExactCache", "ToolCache"]
|