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.
@@ -0,0 +1,243 @@
1
+ """
2
+ In-memory cache backend for LongCache.
3
+
4
+ Primarily used for testing and benchmarking. Entries are stored
5
+ in a Python dictionary and are lost when the process exits.
6
+ Supports TTL expiry and namespace isolation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from typing import Optional
14
+
15
+ from longcache.backends.base import AbstractCacheBackend
16
+ from longcache.core.entry import CacheEntry
17
+
18
+
19
+ class InMemoryCacheBackend(AbstractCacheBackend):
20
+ """
21
+ In-memory cache backend using a Python dictionary.
22
+
23
+ Fastest backend since there is no I/O overhead, but entries
24
+ do not survive process restarts. Ideal for testing, CI/CD,
25
+ and short-lived applications.
26
+
27
+ Usage:
28
+ backend = InMemoryCacheBackend()
29
+ entry_id = backend.store(entry)
30
+ result = backend.retrieve(entry_id)
31
+ """
32
+
33
+ def __init__(self, max_entries: int = 10_000):
34
+ """
35
+ Initialize the in-memory backend.
36
+
37
+ Args:
38
+ max_entries: Maximum entries per namespace.
39
+ """
40
+ self._max_entries = max_entries
41
+ self._entries: dict[int, CacheEntry] = {}
42
+ self._next_id = 1
43
+ self._lock = threading.RLock()
44
+
45
+ def store(self, entry: CacheEntry) -> int:
46
+ """
47
+ Store a cache entry in memory.
48
+
49
+ Args:
50
+ entry: The CacheEntry to store.
51
+
52
+ Returns:
53
+ The auto-generated integer ID.
54
+ """
55
+ with self._lock:
56
+ entry_id = self._next_id
57
+ entry.id = entry_id
58
+ self._entries[entry_id] = entry
59
+ self._next_id += 1
60
+ return entry_id
61
+
62
+ def retrieve(self, entry_id: int) -> Optional[CacheEntry]:
63
+ """
64
+ Retrieve a cache entry by its ID.
65
+
66
+ Args:
67
+ entry_id: The unique ID of the entry.
68
+
69
+ Returns:
70
+ CacheEntry if found and not expired, None otherwise.
71
+ """
72
+ with self._lock:
73
+ entry = self._entries.get(entry_id)
74
+ if entry is None:
75
+ return None
76
+
77
+ if entry.is_expired():
78
+ self.delete(entry_id)
79
+ return None
80
+
81
+ return entry
82
+
83
+ def delete(self, entry_id: int) -> bool:
84
+ """
85
+ Delete a cache entry.
86
+
87
+ Args:
88
+ entry_id: The ID of the entry to delete.
89
+
90
+ Returns:
91
+ True if the entry was deleted, False if not found.
92
+ """
93
+ with self._lock:
94
+ return self._entries.pop(entry_id, None) is not None
95
+
96
+ def delete_namespace(self, namespace: str) -> int:
97
+ """
98
+ Delete all entries in a namespace.
99
+
100
+ Args:
101
+ namespace: The namespace to clear.
102
+
103
+ Returns:
104
+ The number of entries deleted.
105
+ """
106
+ with self._lock:
107
+ to_delete = [
108
+ eid for eid, entry in self._entries.items() if entry.namespace == namespace
109
+ ]
110
+ for eid in to_delete:
111
+ del self._entries[eid]
112
+ return len(to_delete)
113
+
114
+ def delete_by_tag(self, tag: str, namespace: str) -> int:
115
+ """
116
+ Delete all entries in a namespace carrying the given tag.
117
+
118
+ Args:
119
+ tag: The tag string to match.
120
+ namespace: Scope the deletion to this namespace.
121
+
122
+ Returns:
123
+ The number of entries deleted.
124
+ """
125
+ with self._lock:
126
+ to_delete = [
127
+ eid
128
+ for eid, entry in self._entries.items()
129
+ if entry.namespace == namespace and tag in (entry.tags or [])
130
+ ]
131
+ for eid in to_delete:
132
+ del self._entries[eid]
133
+ return len(to_delete)
134
+
135
+ def clear(self) -> int:
136
+ """
137
+ Clear all entries.
138
+
139
+ Returns:
140
+ The number of entries deleted.
141
+ """
142
+ with self._lock:
143
+ count = len(self._entries)
144
+ self._entries.clear()
145
+ return count
146
+
147
+ def count(self, namespace: str) -> int:
148
+ """
149
+ Count non-expired entries in a namespace.
150
+
151
+ Args:
152
+ namespace: The namespace to count.
153
+
154
+ Returns:
155
+ The number of non-expired entries.
156
+ """
157
+ with self._lock:
158
+ now = time.time()
159
+ return sum(
160
+ 1
161
+ for entry in self._entries.values()
162
+ if entry.namespace == namespace
163
+ and (entry.expires_at is None or entry.expires_at > now)
164
+ )
165
+
166
+ def list_entries(self, namespace: str) -> list[CacheEntry]:
167
+ """
168
+ List all non-expired entries in a namespace.
169
+
170
+ Args:
171
+ namespace: The namespace to list.
172
+
173
+ Returns:
174
+ List of CacheEntry objects.
175
+ """
176
+ with self._lock:
177
+ now = time.time()
178
+ return [
179
+ entry
180
+ for entry in self._entries.values()
181
+ if entry.namespace == namespace
182
+ and (entry.expires_at is None or entry.expires_at > now)
183
+ ]
184
+
185
+ def update_hit_count(self, entry_id: int) -> None:
186
+ """
187
+ Increment the hit count for a cache entry.
188
+
189
+ Args:
190
+ entry_id: The ID of the entry to update.
191
+ """
192
+ with self._lock:
193
+ entry = self._entries.get(entry_id)
194
+ if entry is not None:
195
+ entry.increment_hit()
196
+
197
+ def evict_oldest(self, namespace: str, count: int) -> int:
198
+ """
199
+ Evict the oldest entries in a namespace.
200
+
201
+ Args:
202
+ namespace: The namespace to evict from.
203
+ count: The number of entries to evict.
204
+
205
+ Returns:
206
+ The number of entries actually evicted.
207
+ """
208
+ with self._lock:
209
+ ns_entries = [
210
+ (eid, entry) for eid, entry in self._entries.items() if entry.namespace == namespace
211
+ ]
212
+ # Sort by created_at ascending (oldest first)
213
+ ns_entries.sort(key=lambda x: x[1].created_at)
214
+
215
+ evicted = 0
216
+ for eid, _ in ns_entries[:count]:
217
+ del self._entries[eid]
218
+ evicted += 1
219
+
220
+ return evicted
221
+
222
+ def get_expired(self, namespace: str) -> list[int]:
223
+ """
224
+ Get IDs of all expired entries in a namespace.
225
+
226
+ Args:
227
+ namespace: The namespace to check.
228
+
229
+ Returns:
230
+ List of expired entry IDs.
231
+ """
232
+ with self._lock:
233
+ now = time.time()
234
+ return [
235
+ eid
236
+ for eid, entry in self._entries.items()
237
+ if entry.namespace == namespace
238
+ and entry.expires_at is not None
239
+ and entry.expires_at <= now
240
+ ]
241
+
242
+ def __repr__(self) -> str:
243
+ return f"InMemoryCacheBackend(entries={len(self._entries)})"
@@ -0,0 +1,374 @@
1
+ """
2
+ Redis cache backend for LongCache.
3
+
4
+ For production multi-instance deployments where multiple processes
5
+ or servers need to share the same cache. Uses Redis hashes for
6
+ namespace-scoped storage with TTL support via Redis key expiry.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import time
14
+ import threading
15
+ from typing import Optional
16
+
17
+ import numpy as np
18
+
19
+ from longcache.backends.base import AbstractCacheBackend
20
+ from longcache.core.entry import CacheEntry
21
+
22
+
23
+ class RedisCacheBackend(AbstractCacheBackend):
24
+ """
25
+ Redis-based cache backend for distributed deployments.
26
+
27
+ Stores each cache entry as a Redis hash with fields for query,
28
+ vector (base64-encoded), response (JSON), and metadata.
29
+ Namespaces are isolated using Redis key prefixes.
30
+
31
+ Requires the 'redis' extra: uv pip install longcache[redis]
32
+
33
+ Usage:
34
+ backend = RedisCacheBackend(redis_url="redis://localhost:6379/0")
35
+ entry_id = backend.store(entry)
36
+ result = backend.retrieve(entry_id)
37
+ """
38
+
39
+ KEY_PREFIX = "longcache:entry"
40
+ NAMESPACE_SET = "longcache:namespaces"
41
+ ID_COUNTER = "longcache:id_counter"
42
+
43
+ def __init__(
44
+ self,
45
+ redis_url: str = "redis://localhost:6379/0",
46
+ max_entries: int = 10_000,
47
+ ):
48
+ """
49
+ Initialize the Redis backend.
50
+
51
+ Args:
52
+ redis_url: Redis connection URL.
53
+ max_entries: Maximum entries per namespace.
54
+ """
55
+ try:
56
+ import redis
57
+ except ImportError:
58
+ raise ImportError(
59
+ "Redis backend requires the 'redis' extra. "
60
+ "Install with: uv pip install longcache[redis]"
61
+ )
62
+
63
+ self._redis_url = redis_url
64
+ self._max_entries = max_entries
65
+ self._lock = threading.RLock()
66
+ self._client = redis.Redis.from_url(redis_url, decode_responses=True)
67
+
68
+ def _entry_key(self, entry_id: int) -> str:
69
+ """Get the Redis key for a cache entry."""
70
+ return f"{self.KEY_PREFIX}:{entry_id}"
71
+
72
+ def _namespace_key(self, namespace: str) -> str:
73
+ """Get the Redis key for a namespace's entry set."""
74
+ return f"longcache:ns:{namespace}"
75
+
76
+ def _vector_to_str(self, vector: np.ndarray) -> str:
77
+ """Encode a numpy vector as base64 string for Redis storage."""
78
+ return base64.b64encode(vector.tobytes()).decode("ascii")
79
+
80
+ def _str_to_vector(self, vector_str: str) -> np.ndarray:
81
+ """Decode a base64 string back to a numpy vector."""
82
+ return np.frombuffer(base64.b64decode(vector_str), dtype=np.float32).copy()
83
+
84
+ def store(self, entry: CacheEntry) -> int:
85
+ """
86
+ Store a cache entry in Redis.
87
+
88
+ Args:
89
+ entry: The CacheEntry to store.
90
+
91
+ Returns:
92
+ The auto-generated integer ID.
93
+ """
94
+ with self._lock:
95
+ entry_id = self._client.incr(self.ID_COUNTER)
96
+
97
+ vector_str = self._vector_to_str(entry.vector) if entry.vector is not None else ""
98
+ response_json = json.dumps(entry.response, default=str)
99
+
100
+ entry_data = {
101
+ "id": entry_id,
102
+ "namespace": entry.namespace,
103
+ "query": entry.query,
104
+ "vector": vector_str,
105
+ "response": response_json,
106
+ "created_at": entry.created_at,
107
+ "expires_at": entry.expires_at or "",
108
+ "hit_count": entry.hit_count,
109
+ "model": entry.model,
110
+ "system_prompt": entry.system_prompt,
111
+ "temperature": entry.temperature,
112
+ "tags": json.dumps(list(entry.tags or [])),
113
+ }
114
+
115
+ key = self._entry_key(entry_id)
116
+
117
+ # Use pipeline for atomicity
118
+ pipe = self._client.pipeline()
119
+ pipe.hset(key, mapping=entry_data)
120
+
121
+ # Add to namespace set
122
+ pipe.sadd(self._namespace_key(entry.namespace), entry_id)
123
+ pipe.sadd(self.NAMESPACE_SET, entry.namespace)
124
+
125
+ # Add to tag sets
126
+ for tag in entry.tags or []:
127
+ pipe.sadd(f"longcache:tag:{entry.namespace}:{tag}", entry_id)
128
+
129
+ # Set TTL on the Redis key if entry has expiry
130
+ if entry.expires_at is not None:
131
+ ttl_seconds = max(1, int(entry.expires_at - time.time()))
132
+ pipe.expire(key, ttl_seconds)
133
+
134
+ pipe.execute()
135
+ return entry_id
136
+
137
+ def retrieve(self, entry_id: int) -> Optional[CacheEntry]:
138
+ """
139
+ Retrieve a cache entry by its ID.
140
+
141
+ Args:
142
+ entry_id: The unique ID of the entry.
143
+
144
+ Returns:
145
+ CacheEntry if found, None otherwise.
146
+ """
147
+ key = self._entry_key(entry_id)
148
+ data = self._client.hgetall(key)
149
+
150
+ if not data:
151
+ return None
152
+
153
+ entry = self._data_to_entry(data)
154
+ if entry.is_expired():
155
+ self.delete(entry_id)
156
+ return None
157
+
158
+ return entry
159
+
160
+ def _data_to_entry(self, data: dict) -> CacheEntry:
161
+ """Convert Redis hash data to a CacheEntry."""
162
+ vector = None
163
+ if data.get("vector"):
164
+ vector = self._str_to_vector(data["vector"])
165
+
166
+ response = data.get("response", "")
167
+ if isinstance(response, str):
168
+ try:
169
+ response = json.loads(response)
170
+ except (json.JSONDecodeError, TypeError):
171
+ pass
172
+
173
+ expires_at = data.get("expires_at")
174
+ if expires_at == "" or expires_at is None:
175
+ expires_at = None
176
+ else:
177
+ expires_at = float(expires_at)
178
+
179
+ tags_raw = data.get("tags")
180
+ tags = []
181
+ if tags_raw:
182
+ try:
183
+ tags = json.loads(tags_raw)
184
+ except Exception:
185
+ pass
186
+
187
+ temp_val = data.get("temperature")
188
+ temperature = float(temp_val) if temp_val not in (None, "") else -1.0
189
+
190
+ return CacheEntry(
191
+ id=int(data["id"]),
192
+ namespace=data["namespace"],
193
+ query=data["query"],
194
+ vector=vector,
195
+ response=response,
196
+ created_at=float(data["created_at"]),
197
+ expires_at=expires_at,
198
+ hit_count=int(data.get("hit_count", 0)),
199
+ model=data.get("model", ""),
200
+ system_prompt=data.get("system_prompt", ""),
201
+ temperature=temperature,
202
+ tags=tags,
203
+ )
204
+
205
+ def delete(self, entry_id: int) -> bool:
206
+ """
207
+ Delete a cache entry.
208
+
209
+ Args:
210
+ entry_id: The ID of the entry to delete.
211
+
212
+ Returns:
213
+ True if the entry was deleted.
214
+ """
215
+ with self._lock:
216
+ key = self._entry_key(entry_id)
217
+
218
+ # Get namespace before deleting
219
+ namespace = self._client.hget(key, "namespace")
220
+
221
+ pipe = self._client.pipeline()
222
+ pipe.delete(key)
223
+ if namespace:
224
+ pipe.srem(self._namespace_key(namespace), entry_id)
225
+ pipe.execute()
226
+ return True
227
+
228
+ def delete_namespace(self, namespace: str) -> int:
229
+ """
230
+ Delete all entries in a namespace.
231
+
232
+ Args:
233
+ namespace: The namespace to clear.
234
+
235
+ Returns:
236
+ The number of entries deleted.
237
+ """
238
+ with self._lock:
239
+ ns_key = self._namespace_key(namespace)
240
+ entry_ids = self._client.smembers(ns_key)
241
+
242
+ if not entry_ids:
243
+ return 0
244
+
245
+ pipe = self._client.pipeline()
246
+ for entry_id in entry_ids:
247
+ pipe.delete(self._entry_key(int(entry_id)))
248
+ pipe.delete(ns_key)
249
+ pipe.srem(self.NAMESPACE_SET, namespace)
250
+ pipe.execute()
251
+
252
+ return len(entry_ids)
253
+
254
+ def delete_by_tag(self, tag: str, namespace: str) -> int:
255
+ """
256
+ Delete all entries in a namespace carrying the given tag.
257
+
258
+ Args:
259
+ tag: The tag string to match.
260
+ namespace: Scope the deletion to this namespace.
261
+
262
+ Returns:
263
+ The number of entries deleted.
264
+ """
265
+ tag_key = f"longcache:tag:{namespace}:{tag}"
266
+ with self._lock:
267
+ entry_ids = self._client.smembers(tag_key)
268
+ if not entry_ids:
269
+ return 0
270
+
271
+ pipe = self._client.pipeline()
272
+ for entry_id in entry_ids:
273
+ pipe.delete(self._entry_key(int(entry_id)))
274
+ pipe.srem(self._namespace_key(namespace), entry_id)
275
+ pipe.delete(tag_key)
276
+ pipe.execute()
277
+ return len(entry_ids)
278
+
279
+ def clear(self) -> int:
280
+ """
281
+ Clear all entries across all namespaces.
282
+
283
+ Returns:
284
+ The number of entries deleted.
285
+ """
286
+ with self._lock:
287
+ namespaces = self._client.smembers(self.NAMESPACE_SET)
288
+ total = 0
289
+ for ns in namespaces:
290
+ total += self.delete_namespace(ns)
291
+ return total
292
+
293
+ def count(self, namespace: str) -> int:
294
+ """
295
+ Count entries in a namespace.
296
+
297
+ Args:
298
+ namespace: The namespace to count.
299
+
300
+ Returns:
301
+ The number of entries in the namespace.
302
+ """
303
+ ns_key = self._namespace_key(namespace)
304
+ return self._client.scard(ns_key)
305
+
306
+ def list_entries(self, namespace: str) -> list[CacheEntry]:
307
+ """
308
+ List all entries in a namespace.
309
+
310
+ Args:
311
+ namespace: The namespace to list.
312
+
313
+ Returns:
314
+ List of CacheEntry objects.
315
+ """
316
+ ns_key = self._namespace_key(namespace)
317
+ entry_ids = self._client.smembers(ns_key)
318
+
319
+ entries = []
320
+ for entry_id in entry_ids:
321
+ entry = self.retrieve(int(entry_id))
322
+ if entry is not None and not entry.is_expired():
323
+ entries.append(entry)
324
+
325
+ return entries
326
+
327
+ def update_hit_count(self, entry_id: int) -> None:
328
+ """
329
+ Increment the hit count for a cache entry.
330
+
331
+ Args:
332
+ entry_id: The ID of the entry to update.
333
+ """
334
+ key = self._entry_key(entry_id)
335
+ self._client.hincrby(key, "hit_count", 1)
336
+
337
+ def evict_oldest(self, namespace: str, count: int) -> int:
338
+ """
339
+ Evict the oldest entries in a namespace.
340
+
341
+ Args:
342
+ namespace: The namespace to evict from.
343
+ count: The number of entries to evict.
344
+
345
+ Returns:
346
+ The number of entries actually evicted.
347
+ """
348
+ with self._lock:
349
+ entries = self.list_entries(namespace)
350
+ # Sort by created_at ascending (oldest first)
351
+ entries.sort(key=lambda e: e.created_at)
352
+
353
+ evicted = 0
354
+ for entry in entries[:count]:
355
+ if self.delete(entry.id):
356
+ evicted += 1
357
+
358
+ return evicted
359
+
360
+ def get_expired(self, namespace: str) -> list[int]:
361
+ """
362
+ Get IDs of all expired entries in a namespace.
363
+
364
+ Args:
365
+ namespace: The namespace to check.
366
+
367
+ Returns:
368
+ List of expired entry IDs.
369
+ """
370
+ entries = self.list_entries(namespace)
371
+ return [e.id for e in entries if e.is_expired()]
372
+
373
+ def __repr__(self) -> str:
374
+ return f"RedisCacheBackend(redis_url={self._redis_url!r})"