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 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
+ ...