longcache 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.
- longcache/__init__.py +11 -0
- longcache/backends/__init__.py +21 -0
- longcache/backends/async_redis.py +274 -0
- longcache/backends/base.py +167 -0
- longcache/backends/memory.py +243 -0
- longcache/backends/redis.py +374 -0
- longcache/backends/sqlite.py +521 -0
- longcache/cli.py +216 -0
- longcache/config.py +127 -0
- longcache/core/__init__.py +8 -0
- longcache/core/base_embedder.py +43 -0
- longcache/core/cache.py +872 -0
- longcache/core/embedder.py +155 -0
- longcache/core/entry.py +122 -0
- longcache/core/index.py +272 -0
- longcache/integrations/__init__.py +32 -0
- longcache/integrations/langchain.py +316 -0
- longcache/integrations/langgraph.py +150 -0
- longcache/integrations/llamaindex.py +103 -0
- longcache/integrations/openai.py +146 -0
- longcache/middleware.py +366 -0
- longcache-0.1.0.dist-info/METADATA +942 -0
- longcache-0.1.0.dist-info/RECORD +26 -0
- longcache-0.1.0.dist-info/WHEEL +4 -0
- longcache-0.1.0.dist-info/entry_points.txt +2 -0
- longcache-0.1.0.dist-info/licenses/LICENSE +21 -0
longcache/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LongCache — Semantic similarity cache for LLM/RAG pipelines.
|
|
3
|
+
|
|
4
|
+
Cut repeat query costs by 40%+ by caching semantically similar queries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from longcache.core.cache import LongCache
|
|
8
|
+
from longcache.config import CacheConfig
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["LongCache", "CacheConfig"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Cache storage backends."""
|
|
2
|
+
|
|
3
|
+
from longcache.backends.base import AbstractCacheBackend
|
|
4
|
+
from longcache.backends.sqlite import SQLiteCacheBackend
|
|
5
|
+
from longcache.backends.memory import InMemoryCacheBackend
|
|
6
|
+
|
|
7
|
+
__all__ = ["AbstractCacheBackend", "SQLiteCacheBackend", "InMemoryCacheBackend"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Lazy import for Redis backend — only available when redis extra is installed
|
|
11
|
+
def get_redis_backend():
|
|
12
|
+
"""Import and return RedisCacheBackend. Raises ImportError if redis is not installed."""
|
|
13
|
+
try:
|
|
14
|
+
from longcache.backends.redis import RedisCacheBackend
|
|
15
|
+
|
|
16
|
+
return RedisCacheBackend
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"Redis backend requires the 'redis' extra. "
|
|
20
|
+
"Install with: uv pip install longcache[redis]"
|
|
21
|
+
)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async Redis cache backend using redis.asyncio.
|
|
3
|
+
|
|
4
|
+
Drop-in async replacement for RedisCacheBackend.
|
|
5
|
+
Use this in async FastAPI/Starlette applications.
|
|
6
|
+
|
|
7
|
+
backend = AsyncRedisCacheBackend(redis_url="redis://localhost:6379/0")
|
|
8
|
+
entry_id = await backend.store(entry)
|
|
9
|
+
result = await backend.retrieve(entry_id)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
import asyncio
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from longcache.core.entry import CacheEntry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncRedisCacheBackend:
|
|
26
|
+
"""
|
|
27
|
+
Async Redis-based cache backend for distributed deployments.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
KEY_PREFIX = "longcache:entry"
|
|
31
|
+
NAMESPACE_SET = "longcache:namespaces"
|
|
32
|
+
ID_COUNTER = "longcache:id_counter"
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
redis_url: str = "redis://localhost:6379/0",
|
|
37
|
+
max_entries: int = 10_000,
|
|
38
|
+
):
|
|
39
|
+
try:
|
|
40
|
+
from redis.asyncio import Redis
|
|
41
|
+
except ImportError:
|
|
42
|
+
raise ImportError(
|
|
43
|
+
"Async Redis backend requires the 'redis' extra. "
|
|
44
|
+
"Install with: uv pip install longcache[redis]"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self._redis_url = redis_url
|
|
48
|
+
self._max_entries = max_entries
|
|
49
|
+
self._lock = asyncio.Lock()
|
|
50
|
+
self._client = Redis.from_url(redis_url, decode_responses=True)
|
|
51
|
+
|
|
52
|
+
def _entry_key(self, entry_id: int) -> str:
|
|
53
|
+
return f"{self.KEY_PREFIX}:{entry_id}"
|
|
54
|
+
|
|
55
|
+
def _namespace_key(self, namespace: str) -> str:
|
|
56
|
+
return f"longcache:ns:{namespace}"
|
|
57
|
+
|
|
58
|
+
def _vector_to_str(self, vector: np.ndarray) -> str:
|
|
59
|
+
return base64.b64encode(vector.tobytes()).decode("ascii")
|
|
60
|
+
|
|
61
|
+
def _str_to_vector(self, vector_str: str) -> np.ndarray:
|
|
62
|
+
return np.frombuffer(base64.b64decode(vector_str), dtype=np.float32).copy()
|
|
63
|
+
|
|
64
|
+
async def store(self, entry: CacheEntry) -> int:
|
|
65
|
+
async with self._lock:
|
|
66
|
+
entry_id = await self._client.incr(self.ID_COUNTER)
|
|
67
|
+
|
|
68
|
+
vector_str = self._vector_to_str(entry.vector) if entry.vector is not None else ""
|
|
69
|
+
response_json = json.dumps(entry.response, default=str)
|
|
70
|
+
|
|
71
|
+
entry_data = {
|
|
72
|
+
"id": entry_id,
|
|
73
|
+
"namespace": entry.namespace,
|
|
74
|
+
"query": entry.query,
|
|
75
|
+
"vector": vector_str,
|
|
76
|
+
"response": response_json,
|
|
77
|
+
"created_at": entry.created_at,
|
|
78
|
+
"expires_at": entry.expires_at or "",
|
|
79
|
+
"hit_count": entry.hit_count,
|
|
80
|
+
"model": entry.model,
|
|
81
|
+
"system_prompt": entry.system_prompt,
|
|
82
|
+
"temperature": entry.temperature,
|
|
83
|
+
"tags": json.dumps(list(entry.tags or [])),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
key = self._entry_key(entry_id)
|
|
87
|
+
|
|
88
|
+
pipe = self._client.pipeline()
|
|
89
|
+
pipe.hset(key, mapping=entry_data)
|
|
90
|
+
|
|
91
|
+
pipe.sadd(self._namespace_key(entry.namespace), entry_id)
|
|
92
|
+
pipe.sadd(self.NAMESPACE_SET, entry.namespace)
|
|
93
|
+
|
|
94
|
+
for tag in entry.tags or []:
|
|
95
|
+
pipe.sadd(f"longcache:tag:{entry.namespace}:{tag}", entry_id)
|
|
96
|
+
|
|
97
|
+
if entry.expires_at is not None:
|
|
98
|
+
ttl_seconds = max(1, int(entry.expires_at - time.time()))
|
|
99
|
+
pipe.expire(key, ttl_seconds)
|
|
100
|
+
|
|
101
|
+
await pipe.execute()
|
|
102
|
+
|
|
103
|
+
# Note: outside the lock — _enforce_max_entries() calls evict_oldest()
|
|
104
|
+
# which calls delete(), and self._lock is not reentrant.
|
|
105
|
+
await self._enforce_max_entries(entry.namespace)
|
|
106
|
+
return entry_id
|
|
107
|
+
|
|
108
|
+
async def retrieve(self, entry_id: int) -> Optional[CacheEntry]:
|
|
109
|
+
key = self._entry_key(entry_id)
|
|
110
|
+
data = await self._client.hgetall(key)
|
|
111
|
+
|
|
112
|
+
if not data:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
entry = self._data_to_entry(data)
|
|
116
|
+
if entry.is_expired():
|
|
117
|
+
await self.delete(entry_id)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
return entry
|
|
121
|
+
|
|
122
|
+
def _data_to_entry(self, data: dict) -> CacheEntry:
|
|
123
|
+
vector = None
|
|
124
|
+
if data.get("vector"):
|
|
125
|
+
vector = self._str_to_vector(data["vector"])
|
|
126
|
+
|
|
127
|
+
response = data.get("response", "")
|
|
128
|
+
if isinstance(response, str):
|
|
129
|
+
try:
|
|
130
|
+
response = json.loads(response)
|
|
131
|
+
except (json.JSONDecodeError, TypeError):
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
expires_at = data.get("expires_at")
|
|
135
|
+
if expires_at == "" or expires_at is None:
|
|
136
|
+
expires_at = None
|
|
137
|
+
else:
|
|
138
|
+
expires_at = float(expires_at)
|
|
139
|
+
|
|
140
|
+
tags_raw = data.get("tags")
|
|
141
|
+
tags = []
|
|
142
|
+
if tags_raw:
|
|
143
|
+
try:
|
|
144
|
+
tags = json.loads(tags_raw)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
temp_val = data.get("temperature")
|
|
149
|
+
temperature = float(temp_val) if temp_val not in (None, "") else -1.0
|
|
150
|
+
|
|
151
|
+
return CacheEntry(
|
|
152
|
+
id=int(data["id"]),
|
|
153
|
+
namespace=data["namespace"],
|
|
154
|
+
query=data["query"],
|
|
155
|
+
vector=vector,
|
|
156
|
+
response=response,
|
|
157
|
+
created_at=float(data["created_at"]),
|
|
158
|
+
expires_at=expires_at,
|
|
159
|
+
hit_count=int(data.get("hit_count", 0)),
|
|
160
|
+
model=data.get("model", ""),
|
|
161
|
+
system_prompt=data.get("system_prompt", ""),
|
|
162
|
+
temperature=temperature,
|
|
163
|
+
tags=tags,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def delete(self, entry_id: int) -> bool:
|
|
167
|
+
async with self._lock:
|
|
168
|
+
key = self._entry_key(entry_id)
|
|
169
|
+
namespace = await self._client.hget(key, "namespace")
|
|
170
|
+
|
|
171
|
+
pipe = self._client.pipeline()
|
|
172
|
+
pipe.delete(key)
|
|
173
|
+
if namespace:
|
|
174
|
+
pipe.srem(self._namespace_key(namespace), entry_id)
|
|
175
|
+
await pipe.execute()
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
async def delete_namespace(self, namespace: str) -> int:
|
|
179
|
+
async with self._lock:
|
|
180
|
+
ns_key = self._namespace_key(namespace)
|
|
181
|
+
entry_ids = await self._client.smembers(ns_key)
|
|
182
|
+
|
|
183
|
+
if not entry_ids:
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
pipe = self._client.pipeline()
|
|
187
|
+
for entry_id in entry_ids:
|
|
188
|
+
pipe.delete(self._entry_key(int(entry_id)))
|
|
189
|
+
pipe.delete(ns_key)
|
|
190
|
+
pipe.srem(self.NAMESPACE_SET, namespace)
|
|
191
|
+
await pipe.execute()
|
|
192
|
+
|
|
193
|
+
return len(entry_ids)
|
|
194
|
+
|
|
195
|
+
async def delete_by_tag(self, tag: str, namespace: str) -> int:
|
|
196
|
+
"""
|
|
197
|
+
Delete all entries in a namespace carrying the given tag.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
tag: The tag string to match.
|
|
201
|
+
namespace: Scope the deletion to this namespace.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The number of entries deleted.
|
|
205
|
+
"""
|
|
206
|
+
tag_key = f"longcache:tag:{namespace}:{tag}"
|
|
207
|
+
async with self._lock:
|
|
208
|
+
entry_ids = await self._client.smembers(tag_key)
|
|
209
|
+
if not entry_ids:
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
pipe = self._client.pipeline()
|
|
213
|
+
for entry_id in entry_ids:
|
|
214
|
+
pipe.delete(self._entry_key(int(entry_id)))
|
|
215
|
+
pipe.srem(self._namespace_key(namespace), entry_id)
|
|
216
|
+
pipe.delete(tag_key)
|
|
217
|
+
await pipe.execute()
|
|
218
|
+
return len(entry_ids)
|
|
219
|
+
|
|
220
|
+
async def clear(self) -> int:
|
|
221
|
+
# Note: delete_namespace() acquires self._lock itself, so we must
|
|
222
|
+
# NOT hold the lock here too — asyncio.Lock is not reentrant and
|
|
223
|
+
# doing so would deadlock.
|
|
224
|
+
namespaces = await self._client.smembers(self.NAMESPACE_SET)
|
|
225
|
+
total = 0
|
|
226
|
+
for ns in namespaces:
|
|
227
|
+
total += await self.delete_namespace(ns)
|
|
228
|
+
return total
|
|
229
|
+
|
|
230
|
+
async def count(self, namespace: str) -> int:
|
|
231
|
+
ns_key = self._namespace_key(namespace)
|
|
232
|
+
return await self._client.scard(ns_key)
|
|
233
|
+
|
|
234
|
+
async def list_entries(self, namespace: str) -> list[CacheEntry]:
|
|
235
|
+
ns_key = self._namespace_key(namespace)
|
|
236
|
+
entry_ids = await self._client.smembers(ns_key)
|
|
237
|
+
|
|
238
|
+
entries = []
|
|
239
|
+
for entry_id in entry_ids:
|
|
240
|
+
entry = await self.retrieve(int(entry_id))
|
|
241
|
+
if entry is not None and not entry.is_expired():
|
|
242
|
+
entries.append(entry)
|
|
243
|
+
|
|
244
|
+
return entries
|
|
245
|
+
|
|
246
|
+
async def update_hit_count(self, entry_id: int) -> None:
|
|
247
|
+
key = self._entry_key(entry_id)
|
|
248
|
+
await self._client.hincrby(key, "hit_count", 1)
|
|
249
|
+
|
|
250
|
+
async def evict_oldest(self, namespace: str, count: int) -> int:
|
|
251
|
+
# Note: delete() acquires self._lock itself, so we must not hold
|
|
252
|
+
# the lock here too — asyncio.Lock is not reentrant.
|
|
253
|
+
entries = await self.list_entries(namespace)
|
|
254
|
+
entries.sort(key=lambda e: e.created_at)
|
|
255
|
+
|
|
256
|
+
evicted = 0
|
|
257
|
+
for entry in entries[:count]:
|
|
258
|
+
if await self.delete(entry.id):
|
|
259
|
+
evicted += 1
|
|
260
|
+
|
|
261
|
+
return evicted
|
|
262
|
+
|
|
263
|
+
async def get_expired(self, namespace: str) -> list[int]:
|
|
264
|
+
entries = await self.list_entries(namespace)
|
|
265
|
+
return [e.id for e in entries if e.is_expired()]
|
|
266
|
+
|
|
267
|
+
async def _enforce_max_entries(self, namespace: str) -> None:
|
|
268
|
+
count = await self.count(namespace)
|
|
269
|
+
if count > self._max_entries:
|
|
270
|
+
excess = count - self._max_entries
|
|
271
|
+
await self.evict_oldest(namespace, excess)
|
|
272
|
+
|
|
273
|
+
def __repr__(self) -> str:
|
|
274
|
+
return f"AsyncRedisCacheBackend(redis_url={self._redis_url!r})"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstract base class for cache backends.
|
|
3
|
+
|
|
4
|
+
All backends must implement this interface to provide
|
|
5
|
+
consistent storage semantics regardless of the underlying
|
|
6
|
+
storage engine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from longcache.core.entry import CacheEntry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AbstractCacheBackend(ABC):
|
|
18
|
+
"""
|
|
19
|
+
Abstract interface for LongCache storage backends.
|
|
20
|
+
|
|
21
|
+
All methods operate on CacheEntry objects, which encapsulate
|
|
22
|
+
the query, vector, response, and metadata. Backends are
|
|
23
|
+
responsible for serialization/deserialization as needed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def store(self, entry: CacheEntry) -> int:
|
|
28
|
+
"""
|
|
29
|
+
Store a cache entry and return its unique ID.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
entry: The CacheEntry to store. May not have an ID assigned yet.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The unique integer ID assigned to the stored entry.
|
|
36
|
+
"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def retrieve(self, entry_id: int) -> Optional[CacheEntry]:
|
|
41
|
+
"""
|
|
42
|
+
Retrieve a cache entry by its ID.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
entry_id: The unique ID of the entry to retrieve.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
CacheEntry if found, None otherwise.
|
|
49
|
+
"""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def delete(self, entry_id: int) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
Delete a cache entry by its ID.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
entry_id: The unique ID of the entry to delete.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if the entry was deleted, False if not found.
|
|
62
|
+
"""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def delete_namespace(self, namespace: str) -> int:
|
|
67
|
+
"""
|
|
68
|
+
Delete all entries in a namespace.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
namespace: The namespace to clear.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The number of entries deleted.
|
|
75
|
+
"""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def delete_by_tag(self, tag: str, namespace: str) -> int:
|
|
80
|
+
"""
|
|
81
|
+
Delete all entries in a namespace carrying the given tag.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
tag: The tag string to match.
|
|
85
|
+
namespace: Scope the deletion to this namespace.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The number of entries deleted.
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def clear(self) -> int:
|
|
94
|
+
"""
|
|
95
|
+
Clear all entries across all namespaces.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The total number of entries deleted.
|
|
99
|
+
"""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def count(self, namespace: str) -> int:
|
|
104
|
+
"""
|
|
105
|
+
Count entries in a namespace.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
namespace: The namespace to count entries in.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The number of entries in the namespace.
|
|
112
|
+
"""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def list_entries(self, namespace: str) -> list[CacheEntry]:
|
|
117
|
+
"""
|
|
118
|
+
List all non-expired entries in a namespace.
|
|
119
|
+
|
|
120
|
+
Used primarily for rebuilding the FAISS index on startup.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
namespace: The namespace to list entries from.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List of CacheEntry objects with vectors populated.
|
|
127
|
+
"""
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def update_hit_count(self, entry_id: int) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Increment the hit count for a cache entry.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
entry_id: The ID of the entry to update.
|
|
137
|
+
"""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def evict_oldest(self, namespace: str, count: int) -> int:
|
|
142
|
+
"""
|
|
143
|
+
Evict the oldest entries in a namespace.
|
|
144
|
+
|
|
145
|
+
Used for LRU-style eviction when max_entries is exceeded.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
namespace: The namespace to evict entries from.
|
|
149
|
+
count: The number of entries to evict.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The number of entries actually evicted.
|
|
153
|
+
"""
|
|
154
|
+
...
|
|
155
|
+
|
|
156
|
+
@abstractmethod
|
|
157
|
+
def get_expired(self, namespace: str) -> list[int]:
|
|
158
|
+
"""
|
|
159
|
+
Get IDs of all expired entries in a namespace.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
namespace: The namespace to check for expired entries.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
List of entry IDs that have expired.
|
|
166
|
+
"""
|
|
167
|
+
...
|