advalcache 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.
- advalcache/__init__.py +320 -0
- advalcache/__main__.py +58 -0
- advalcache/_engine.py +522 -0
- advalcache/_eviction.py +93 -0
- advalcache/_refresh.py +91 -0
- advalcache/_scoring.py +157 -0
- advalcache/adapters/__init__.py +2 -0
- advalcache/adapters/django.py +139 -0
- advalcache/adapters/fastapi.py +80 -0
- advalcache/adapters/sqlalchemy.py +83 -0
- advalcache/config.py +80 -0
- advalcache/decorators.py +211 -0
- advalcache/py.typed +0 -0
- advalcache/server.py +118 -0
- advalcache/stats.py +84 -0
- advalcache-0.1.0.dist-info/METADATA +199 -0
- advalcache-0.1.0.dist-info/RECORD +20 -0
- advalcache-0.1.0.dist-info/WHEEL +4 -0
- advalcache-0.1.0.dist-info/entry_points.txt +2 -0
- advalcache-0.1.0.dist-info/licenses/LICENSE +21 -0
advalcache/_engine.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""advalcache/_engine.py — Thread-safe AdVal in-memory cache engine.
|
|
2
|
+
|
|
3
|
+
This is the core of the SDK. It is intentionally free of any research-repo
|
|
4
|
+
dependencies (models.py, sim/, generator/, etc.) and uses only Python stdlib.
|
|
5
|
+
|
|
6
|
+
Two engines are provided:
|
|
7
|
+
- AdValEngine: synchronous, uses threading.RLock
|
|
8
|
+
- AsyncAdValEngine: asynchronous, uses asyncio.Lock
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import collections
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
import threading
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Callable, Deque, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from advalcache._scoring import (
|
|
22
|
+
compute_score,
|
|
23
|
+
estimate_size_bytes,
|
|
24
|
+
get_freshness,
|
|
25
|
+
touch_hotness,
|
|
26
|
+
)
|
|
27
|
+
from advalcache._eviction import (
|
|
28
|
+
classify_eviction_reason,
|
|
29
|
+
pick_victim,
|
|
30
|
+
sample_victim_pool,
|
|
31
|
+
)
|
|
32
|
+
from advalcache._refresh import BackgroundRefreshPool
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("advalcache.engine")
|
|
35
|
+
|
|
36
|
+
# Type alias for the refresh callback
|
|
37
|
+
RefreshCallback = Callable[[], Tuple[Any, float, int]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Cache Entry
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class CacheEntry:
|
|
46
|
+
"""A single resident entry in the AdVal cache."""
|
|
47
|
+
|
|
48
|
+
key: str
|
|
49
|
+
data: Any
|
|
50
|
+
cls: str
|
|
51
|
+
size_bytes: int
|
|
52
|
+
regen_ms: float
|
|
53
|
+
regen_usd: float
|
|
54
|
+
change_interval_s: float
|
|
55
|
+
last_sync_ts: float
|
|
56
|
+
created_ts: float = field(default_factory=time.time)
|
|
57
|
+
last_access_ts: float = field(default_factory=time.time)
|
|
58
|
+
hotness: float = 1.0
|
|
59
|
+
hit_count: int = 0
|
|
60
|
+
|
|
61
|
+
def freshness(self, now_ts: Optional[float] = None) -> float:
|
|
62
|
+
"""P(still valid) at now_ts. Complexity: O(1)."""
|
|
63
|
+
now = now_ts if now_ts is not None else time.time()
|
|
64
|
+
return get_freshness(self.last_sync_ts, now, self.change_interval_s)
|
|
65
|
+
|
|
66
|
+
def score(
|
|
67
|
+
self,
|
|
68
|
+
now_ts: Optional[float] = None,
|
|
69
|
+
p99_ms: float = 2_000.0,
|
|
70
|
+
p99_usd: float = 0.05,
|
|
71
|
+
) -> float:
|
|
72
|
+
"""AdVal value score at now_ts. Complexity: O(1)."""
|
|
73
|
+
now = now_ts if now_ts is not None else time.time()
|
|
74
|
+
return compute_score(
|
|
75
|
+
self.hotness, self.regen_ms, self.regen_usd,
|
|
76
|
+
self.last_sync_ts, now, self.change_interval_s,
|
|
77
|
+
self.size_bytes, p99_ms, p99_usd,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def touch(self, now_ts: float, decay_lambda: float) -> None:
|
|
81
|
+
"""LRFU update on access. Complexity: O(1)."""
|
|
82
|
+
self.hotness = touch_hotness(self.hotness, self.last_access_ts, now_ts, decay_lambda)
|
|
83
|
+
self.last_access_ts = now_ts
|
|
84
|
+
self.hit_count += 1
|
|
85
|
+
|
|
86
|
+
def to_dict(self, now_ts: Optional[float] = None) -> Dict[str, Any]:
|
|
87
|
+
"""Serializable snapshot of this entry. Complexity: O(1)."""
|
|
88
|
+
now = now_ts if now_ts is not None else time.time()
|
|
89
|
+
return {
|
|
90
|
+
"key": self.key,
|
|
91
|
+
"cls": self.cls,
|
|
92
|
+
"size_bytes": self.size_bytes,
|
|
93
|
+
"regen_ms": round(self.regen_ms, 2),
|
|
94
|
+
"regen_usd": round(self.regen_usd, 4),
|
|
95
|
+
"change_interval_s": self.change_interval_s,
|
|
96
|
+
"age_s": round(now - self.last_sync_ts, 1),
|
|
97
|
+
"freshness": round(self.freshness(now), 3),
|
|
98
|
+
"hotness": round(self.hotness, 3),
|
|
99
|
+
"score": round(self.score(now), 4),
|
|
100
|
+
"hit_count": self.hit_count,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Synchronous Engine
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
class AdValEngine:
|
|
109
|
+
"""Thread-safe in-memory AdVal cache engine (sync).
|
|
110
|
+
|
|
111
|
+
This class is the implementation detail behind the public ``Cache`` API.
|
|
112
|
+
It is not intended to be used directly by end-users.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
capacity_mb: float,
|
|
118
|
+
decay_lambda: float,
|
|
119
|
+
refresh_threshold: float,
|
|
120
|
+
refresh_min_score: float,
|
|
121
|
+
max_refresh_workers: int,
|
|
122
|
+
eviction_pool_fraction: float,
|
|
123
|
+
p99_ms: float,
|
|
124
|
+
p99_usd: float,
|
|
125
|
+
max_events: int = 200,
|
|
126
|
+
) -> None:
|
|
127
|
+
self._capacity_bytes = int(capacity_mb * 1024 * 1024)
|
|
128
|
+
self._decay_lambda = decay_lambda
|
|
129
|
+
self._refresh_threshold = refresh_threshold
|
|
130
|
+
self._refresh_min_score = refresh_min_score
|
|
131
|
+
self._pool_fraction = eviction_pool_fraction
|
|
132
|
+
self._p99_ms = p99_ms
|
|
133
|
+
self._p99_usd = p99_usd
|
|
134
|
+
|
|
135
|
+
self._lock = threading.RLock()
|
|
136
|
+
self._entries: Dict[str, CacheEntry] = {}
|
|
137
|
+
self._keys: List[str] = []
|
|
138
|
+
self._resident_bytes: int = 0
|
|
139
|
+
|
|
140
|
+
self._refresh_pool = BackgroundRefreshPool(max_workers=max_refresh_workers)
|
|
141
|
+
self._recent_events: Deque[Dict[str, Any]] = collections.deque(maxlen=max_events)
|
|
142
|
+
|
|
143
|
+
# Metrics
|
|
144
|
+
self.total_requests: int = 0
|
|
145
|
+
self.hits: int = 0
|
|
146
|
+
self.misses: int = 0
|
|
147
|
+
self.fresh_hits: int = 0
|
|
148
|
+
self.stale_hits: int = 0
|
|
149
|
+
self.proactive_refreshes: int = 0
|
|
150
|
+
self.total_usd_saved: float = 0.0
|
|
151
|
+
self.ram_latency_sum_ms: float = 0.0
|
|
152
|
+
self.origin_latency_sum_ms: float = 0.0
|
|
153
|
+
self.evictions: Dict[str, int] = {
|
|
154
|
+
"CAPACITY": 0, "STALE": 0, "COLD": 0, "BULKY": 0, "CHEAP": 0,
|
|
155
|
+
}
|
|
156
|
+
self.declines: int = 0
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
# Properties
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def resident_bytes(self) -> int:
|
|
164
|
+
with self._lock:
|
|
165
|
+
return self._resident_bytes
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def resident_mb(self) -> float:
|
|
169
|
+
with self._lock:
|
|
170
|
+
return round(self._resident_bytes / (1024 * 1024), 3)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def capacity_mb(self) -> float:
|
|
174
|
+
return round(self._capacity_bytes / (1024 * 1024), 2)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def count(self) -> int:
|
|
178
|
+
with self._lock:
|
|
179
|
+
return len(self._entries)
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Core get / put / invalidate
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def get(
|
|
186
|
+
self,
|
|
187
|
+
key: str,
|
|
188
|
+
refresh_callback: Optional[RefreshCallback] = None,
|
|
189
|
+
now_ts: Optional[float] = None,
|
|
190
|
+
) -> Optional[Tuple[Any, bool, float, float]]:
|
|
191
|
+
"""Look up a key. Returns (data, is_fresh, freshness, score) or None on miss.
|
|
192
|
+
|
|
193
|
+
Complexity: O(1) average — dict lookup, O(1) touch/score, and a
|
|
194
|
+
non-blocking background-refresh submit when stale but high-value.
|
|
195
|
+
"""
|
|
196
|
+
now = now_ts if now_ts is not None else time.time()
|
|
197
|
+
with self._lock:
|
|
198
|
+
self.total_requests += 1
|
|
199
|
+
if key not in self._entries:
|
|
200
|
+
self.misses += 1
|
|
201
|
+
self._log("MISS", key, "")
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
entry = self._entries[key]
|
|
205
|
+
entry.touch(now, self._decay_lambda)
|
|
206
|
+
fresh = entry.freshness(now)
|
|
207
|
+
sc = entry.score(now, self._p99_ms, self._p99_usd)
|
|
208
|
+
is_fresh = fresh >= self._refresh_threshold
|
|
209
|
+
|
|
210
|
+
self.hits += 1
|
|
211
|
+
self.total_usd_saved += entry.regen_usd
|
|
212
|
+
if is_fresh:
|
|
213
|
+
self.fresh_hits += 1
|
|
214
|
+
else:
|
|
215
|
+
self.stale_hits += 1
|
|
216
|
+
|
|
217
|
+
# Proactive background refresh for stale but high-value entries
|
|
218
|
+
if (
|
|
219
|
+
not is_fresh
|
|
220
|
+
and sc >= self._refresh_min_score
|
|
221
|
+
and refresh_callback is not None
|
|
222
|
+
and not self._refresh_pool.is_refreshing(key)
|
|
223
|
+
):
|
|
224
|
+
self._refresh_pool.submit(
|
|
225
|
+
key, refresh_callback,
|
|
226
|
+
on_done=self._on_refresh_done,
|
|
227
|
+
on_error=self._on_refresh_error,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
self._log("HIT" if is_fresh else "HIT_STALE", key,
|
|
231
|
+
f"score={sc:.3f} fresh={fresh:.2f}")
|
|
232
|
+
return entry.data, is_fresh, fresh, sc
|
|
233
|
+
|
|
234
|
+
def put(
|
|
235
|
+
self,
|
|
236
|
+
key: str,
|
|
237
|
+
data: Any,
|
|
238
|
+
cls: str = "generic",
|
|
239
|
+
size_bytes: Optional[int] = None,
|
|
240
|
+
regen_ms: float = 100.0,
|
|
241
|
+
regen_usd: float = 0.0,
|
|
242
|
+
change_interval_s: float = 0.0,
|
|
243
|
+
now_ts: Optional[float] = None,
|
|
244
|
+
) -> bool:
|
|
245
|
+
"""Admit or update an entry. Returns True if admitted, False if declined.
|
|
246
|
+
|
|
247
|
+
Complexity: O(1) average for admission control + dict store; O(e)
|
|
248
|
+
worst case when evictions are needed, where e = number of entries
|
|
249
|
+
evicted (each eviction is O(n) due to list.remove on the key list,
|
|
250
|
+
but the sampled pool itself is O(1)).
|
|
251
|
+
"""
|
|
252
|
+
now = now_ts if now_ts is not None else time.time()
|
|
253
|
+
sz = size_bytes if size_bytes is not None else estimate_size_bytes(data)
|
|
254
|
+
|
|
255
|
+
with self._lock:
|
|
256
|
+
# Admission control: refuse highly volatile, low-value objects
|
|
257
|
+
if change_interval_s > 0 and change_interval_s < 1.0 and regen_usd < 0.0005:
|
|
258
|
+
self.declines += 1
|
|
259
|
+
self._log("DECLINE", key, f"change_interval={change_interval_s:.2f}s regen_usd={regen_usd}")
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
# Update existing entry in-place
|
|
263
|
+
if key in self._entries:
|
|
264
|
+
old = self._entries[key]
|
|
265
|
+
self._resident_bytes -= old.size_bytes
|
|
266
|
+
old.data = data
|
|
267
|
+
old.size_bytes = sz
|
|
268
|
+
old.regen_ms = regen_ms
|
|
269
|
+
old.regen_usd = regen_usd
|
|
270
|
+
old.last_sync_ts = now
|
|
271
|
+
self._resident_bytes += sz
|
|
272
|
+
self._make_room(0, now)
|
|
273
|
+
self._log("UPDATE", key, f"size={sz}B")
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
# Evict until there is room for the new entry
|
|
277
|
+
self._make_room(sz, now)
|
|
278
|
+
|
|
279
|
+
entry = CacheEntry(
|
|
280
|
+
key=key, data=data, cls=cls, size_bytes=sz,
|
|
281
|
+
regen_ms=regen_ms, regen_usd=regen_usd,
|
|
282
|
+
change_interval_s=change_interval_s, last_sync_ts=now,
|
|
283
|
+
)
|
|
284
|
+
self._entries[key] = entry
|
|
285
|
+
self._keys.append(key)
|
|
286
|
+
self._resident_bytes += sz
|
|
287
|
+
self._log("ADMIT", key, f"size={sz}B cls={cls}")
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
def invalidate(self, key: str) -> bool:
|
|
291
|
+
"""Remove a single key. Returns True if it was resident.
|
|
292
|
+
|
|
293
|
+
Complexity: O(n) — list.remove(key) on the resident key list.
|
|
294
|
+
"""
|
|
295
|
+
with self._lock:
|
|
296
|
+
if key in self._entries:
|
|
297
|
+
self._evict_key(key, "INVALIDATE")
|
|
298
|
+
return True
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
def invalidate_prefix(self, prefix: str) -> int:
|
|
302
|
+
"""Remove all keys that start with prefix. Returns count removed.
|
|
303
|
+
|
|
304
|
+
Complexity: O(n * m) — n resident keys scanned, each startswith
|
|
305
|
+
check is O(m) in the prefix length; plus O(k * n) for the k
|
|
306
|
+
matched removals (list.remove per key).
|
|
307
|
+
"""
|
|
308
|
+
with self._lock:
|
|
309
|
+
targets = [k for k in list(self._keys) if k.startswith(prefix)]
|
|
310
|
+
for k in targets:
|
|
311
|
+
self._evict_key(k, "INVALIDATE")
|
|
312
|
+
return len(targets)
|
|
313
|
+
|
|
314
|
+
def clear(self) -> None:
|
|
315
|
+
"""Flush all entries and reset all metrics.
|
|
316
|
+
|
|
317
|
+
Complexity: O(n) — clearing the entries dict and key list.
|
|
318
|
+
"""
|
|
319
|
+
with self._lock:
|
|
320
|
+
self._entries.clear()
|
|
321
|
+
self._keys.clear()
|
|
322
|
+
self._resident_bytes = 0
|
|
323
|
+
self.total_requests = self.hits = self.misses = 0
|
|
324
|
+
self.fresh_hits = self.stale_hits = self.proactive_refreshes = 0
|
|
325
|
+
self.total_usd_saved = 0.0
|
|
326
|
+
self.ram_latency_sum_ms = self.origin_latency_sum_ms = 0.0
|
|
327
|
+
for k in self.evictions:
|
|
328
|
+
self.evictions[k] = 0
|
|
329
|
+
self.declines = 0
|
|
330
|
+
self._recent_events.clear()
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
# Latency telemetry helpers
|
|
334
|
+
# ------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def record_ram_latency(self, ms: float) -> None:
|
|
337
|
+
with self._lock:
|
|
338
|
+
self.ram_latency_sum_ms += ms
|
|
339
|
+
|
|
340
|
+
def record_origin_latency(self, ms: float) -> None:
|
|
341
|
+
with self._lock:
|
|
342
|
+
self.origin_latency_sum_ms += ms
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
# Stats snapshot
|
|
346
|
+
# ------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def snapshot(self) -> Dict[str, Any]:
|
|
349
|
+
"""Return a dict of current telemetry values (for CacheStats).
|
|
350
|
+
|
|
351
|
+
Complexity: O(n log n) — sorts all resident entries by score to
|
|
352
|
+
produce the top-25 list (heap-free full sort; n = resident count).
|
|
353
|
+
"""
|
|
354
|
+
with self._lock:
|
|
355
|
+
now = time.time()
|
|
356
|
+
hit_rate = self.hits / self.total_requests if self.total_requests else 0.0
|
|
357
|
+
fresh_rate = self.fresh_hits / self.hits if self.hits else 0.0
|
|
358
|
+
avg_ram = self.ram_latency_sum_ms / self.hits if self.hits else 0.15
|
|
359
|
+
avg_origin = self.origin_latency_sum_ms / self.misses if self.misses else 0.0
|
|
360
|
+
top_keys = [
|
|
361
|
+
e.to_dict(now)
|
|
362
|
+
for e in sorted(
|
|
363
|
+
self._entries.values(),
|
|
364
|
+
key=lambda e: e.score(now, self._p99_ms, self._p99_usd),
|
|
365
|
+
reverse=True,
|
|
366
|
+
)[:25]
|
|
367
|
+
]
|
|
368
|
+
return {
|
|
369
|
+
"total_requests": self.total_requests,
|
|
370
|
+
"hits": self.hits,
|
|
371
|
+
"misses": self.misses,
|
|
372
|
+
"fresh_hits": self.fresh_hits,
|
|
373
|
+
"stale_hits": self.stale_hits,
|
|
374
|
+
"declines": self.declines,
|
|
375
|
+
"proactive_refreshes": self.proactive_refreshes,
|
|
376
|
+
"hit_rate_pct": round(hit_rate * 100, 2),
|
|
377
|
+
"freshness_pct": round(fresh_rate * 100, 2),
|
|
378
|
+
"usd_saved": round(self.total_usd_saved, 4),
|
|
379
|
+
"resident_mb": self.resident_mb,
|
|
380
|
+
"capacity_mb": self.capacity_mb,
|
|
381
|
+
"resident_count": len(self._entries),
|
|
382
|
+
"avg_ram_latency_ms": round(avg_ram, 3),
|
|
383
|
+
"avg_origin_latency_ms": round(avg_origin, 1),
|
|
384
|
+
"speedup_factor": round(avg_origin / max(0.001, avg_ram), 1) if avg_origin else 0.0,
|
|
385
|
+
"evictions": dict(self.evictions),
|
|
386
|
+
"recent_events": list(self._recent_events)[-20:],
|
|
387
|
+
"top_resident_keys": top_keys,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
# Lifecycle
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def shutdown(self, wait: bool = True) -> None:
|
|
395
|
+
self._refresh_pool.shutdown(wait=wait)
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
# Internal helpers
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def _make_room(self, needed_bytes: int, now_ts: float) -> None:
|
|
402
|
+
"""Evict lowest-score entries until needed_bytes fits.
|
|
403
|
+
|
|
404
|
+
Complexity: O(e * (k + n)) where e = evictions performed,
|
|
405
|
+
k = victim pool size (bounded, <= _POOL_MAX) and the n term is
|
|
406
|
+
the list.remove(key) per eviction. With a bounded pool this is
|
|
407
|
+
O(e * n) worst case; typically e is small so it behaves ~O(n).
|
|
408
|
+
"""
|
|
409
|
+
while (self._resident_bytes + needed_bytes > self._capacity_bytes) and self._entries:
|
|
410
|
+
candidates = sample_victim_pool(self._keys, self._pool_fraction)
|
|
411
|
+
victim_key = pick_victim(
|
|
412
|
+
candidates,
|
|
413
|
+
score_fn=lambda k: self._entries[k].score(now_ts, self._p99_ms, self._p99_usd),
|
|
414
|
+
)
|
|
415
|
+
if victim_key is None:
|
|
416
|
+
break
|
|
417
|
+
victim = self._entries[victim_key]
|
|
418
|
+
reason = classify_eviction_reason(
|
|
419
|
+
victim.freshness(now_ts), victim.hotness,
|
|
420
|
+
victim.size_bytes, victim.regen_ms, victim.regen_usd,
|
|
421
|
+
)
|
|
422
|
+
self.evictions[reason] = self.evictions.get(reason, 0) + 1
|
|
423
|
+
self._evict_key(victim_key, reason)
|
|
424
|
+
|
|
425
|
+
def _evict_key(self, key: str, reason: str) -> None:
|
|
426
|
+
"""Remove one entry and update bookkeeping.
|
|
427
|
+
|
|
428
|
+
Complexity: O(n) — dominated by list.remove(key) on the key list.
|
|
429
|
+
"""
|
|
430
|
+
if key in self._entries:
|
|
431
|
+
entry = self._entries.pop(key)
|
|
432
|
+
self._resident_bytes -= entry.size_bytes
|
|
433
|
+
try:
|
|
434
|
+
self._keys.remove(key)
|
|
435
|
+
except ValueError:
|
|
436
|
+
pass
|
|
437
|
+
self._log("EVICT", key, f"reason={reason}")
|
|
438
|
+
|
|
439
|
+
def _on_refresh_done(self, key: str, data: Any, latency_ms: float, size_bytes: int) -> None:
|
|
440
|
+
now = time.time()
|
|
441
|
+
with self._lock:
|
|
442
|
+
if key in self._entries:
|
|
443
|
+
entry = self._entries[key]
|
|
444
|
+
self._resident_bytes -= entry.size_bytes
|
|
445
|
+
entry.data = data
|
|
446
|
+
entry.size_bytes = size_bytes
|
|
447
|
+
entry.regen_ms = latency_ms
|
|
448
|
+
entry.last_sync_ts = now
|
|
449
|
+
self._resident_bytes += size_bytes
|
|
450
|
+
self.proactive_refreshes += 1
|
|
451
|
+
self._log("REFRESH", key, f"latency={latency_ms:.1f}ms")
|
|
452
|
+
|
|
453
|
+
def _on_refresh_error(self, key: str, exc: Exception) -> None:
|
|
454
|
+
self._log("REFRESH_ERR", key, str(exc))
|
|
455
|
+
|
|
456
|
+
def _log(self, op: str, key: str, details: str) -> None:
|
|
457
|
+
self._recent_events.append({
|
|
458
|
+
"time": time.strftime("%H:%M:%S"),
|
|
459
|
+
"op": op,
|
|
460
|
+
"key": key,
|
|
461
|
+
"details": details,
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
# Asynchronous Engine
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
class AsyncAdValEngine:
|
|
470
|
+
"""Async in-memory AdVal cache engine using asyncio.Lock.
|
|
471
|
+
|
|
472
|
+
Wraps a synchronous engine but executes all mutations under an
|
|
473
|
+
asyncio.Lock so it is safe to use inside an async event loop without
|
|
474
|
+
blocking the thread.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
478
|
+
self._sync = AdValEngine(**kwargs)
|
|
479
|
+
self._lock = asyncio.Lock()
|
|
480
|
+
|
|
481
|
+
async def get(
|
|
482
|
+
self,
|
|
483
|
+
key: str,
|
|
484
|
+
refresh_callback: Optional[Callable[[], Any]] = None,
|
|
485
|
+
now_ts: Optional[float] = None,
|
|
486
|
+
) -> Optional[Tuple[Any, bool, float, float]]:
|
|
487
|
+
async with self._lock:
|
|
488
|
+
return self._sync.get(key, refresh_callback, now_ts)
|
|
489
|
+
|
|
490
|
+
async def put(self, key: str, data: Any, **kwargs: Any) -> bool:
|
|
491
|
+
async with self._lock:
|
|
492
|
+
return self._sync.put(key, data, **kwargs)
|
|
493
|
+
|
|
494
|
+
async def invalidate(self, key: str) -> bool:
|
|
495
|
+
async with self._lock:
|
|
496
|
+
return self._sync.invalidate(key)
|
|
497
|
+
|
|
498
|
+
async def invalidate_prefix(self, prefix: str) -> int:
|
|
499
|
+
async with self._lock:
|
|
500
|
+
return self._sync.invalidate_prefix(prefix)
|
|
501
|
+
|
|
502
|
+
async def clear(self) -> None:
|
|
503
|
+
async with self._lock:
|
|
504
|
+
self._sync.clear()
|
|
505
|
+
|
|
506
|
+
def snapshot(self) -> Dict[str, Any]:
|
|
507
|
+
return self._sync.snapshot()
|
|
508
|
+
|
|
509
|
+
@property
|
|
510
|
+
def count(self) -> int:
|
|
511
|
+
return self._sync.count
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def resident_mb(self) -> float:
|
|
515
|
+
return self._sync.resident_mb
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def capacity_mb(self) -> float:
|
|
519
|
+
return self._sync.capacity_mb
|
|
520
|
+
|
|
521
|
+
def shutdown(self, wait: bool = True) -> None:
|
|
522
|
+
self._sync.shutdown(wait)
|
advalcache/_eviction.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""advalcache/_eviction.py — Sampled victim pool eviction.
|
|
2
|
+
|
|
3
|
+
Separated from the engine so the eviction strategy can be extended or
|
|
4
|
+
replaced without touching core state management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
from typing import Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Eviction reason labels
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
REASON_STALE = "STALE"
|
|
17
|
+
REASON_COLD = "COLD"
|
|
18
|
+
REASON_BULKY = "BULKY"
|
|
19
|
+
REASON_CHEAP = "CHEAP"
|
|
20
|
+
REASON_CAPACITY = "CAPACITY"
|
|
21
|
+
|
|
22
|
+
#: Minimum pool fraction (10% of resident keys).
|
|
23
|
+
_POOL_FRACTION = 0.10
|
|
24
|
+
#: Minimum victim pool size.
|
|
25
|
+
_POOL_MIN = 4
|
|
26
|
+
#: Maximum victim pool size (keeps eviction O(log n) in practice).
|
|
27
|
+
_POOL_MAX = 64
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sample_victim_pool(
|
|
31
|
+
keys: List[str],
|
|
32
|
+
pool_fraction: float = _POOL_FRACTION,
|
|
33
|
+
pool_min: int = _POOL_MIN,
|
|
34
|
+
pool_max: int = _POOL_MAX,
|
|
35
|
+
) -> List[str]:
|
|
36
|
+
"""Return a random sample of resident keys to consider for eviction.
|
|
37
|
+
|
|
38
|
+
Size = clamp(len(keys) * pool_fraction, pool_min, pool_max).
|
|
39
|
+
Sampling is the standard practical answer when scores move continuously
|
|
40
|
+
with wall-clock time — a heap's entries go stale the moment they are pushed.
|
|
41
|
+
|
|
42
|
+
Complexity: O(k) where k = min(pool_max, n * pool_fraction) — k is a
|
|
43
|
+
bounded constant (<= _POOL_MAX), so effectively O(1) w.r.t. cache size n.
|
|
44
|
+
"""
|
|
45
|
+
n = len(keys)
|
|
46
|
+
if n == 0:
|
|
47
|
+
return []
|
|
48
|
+
k = max(pool_min, min(pool_max, int(n * pool_fraction)))
|
|
49
|
+
return random.sample(keys, min(k, n))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pick_victim(
|
|
53
|
+
candidates: List[str],
|
|
54
|
+
score_fn: Callable[[str], float],
|
|
55
|
+
) -> Optional[str]:
|
|
56
|
+
"""Return the candidate key with the lowest AdVal score.
|
|
57
|
+
|
|
58
|
+
Complexity: O(k) score evaluations over k <= _POOL_MAX candidates,
|
|
59
|
+
i.e. effectively O(1) w.r.t. total cache size n.
|
|
60
|
+
"""
|
|
61
|
+
if not candidates:
|
|
62
|
+
return None
|
|
63
|
+
return min(candidates, key=score_fn)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def classify_eviction_reason(
|
|
67
|
+
freshness: float,
|
|
68
|
+
hotness: float,
|
|
69
|
+
size_bytes: int,
|
|
70
|
+
regen_ms: float,
|
|
71
|
+
regen_usd: float,
|
|
72
|
+
*,
|
|
73
|
+
stale_threshold: float = 0.20,
|
|
74
|
+
cold_threshold: float = 1.2,
|
|
75
|
+
bulky_threshold_bytes: int = 200 * 1024,
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Classify why an entry is the weakest in its victim pool.
|
|
78
|
+
|
|
79
|
+
Priority: STALE > COLD > BULKY > CHEAP > CAPACITY.
|
|
80
|
+
The label tells the caller (and dashboard) what characteristic made
|
|
81
|
+
this entry the most disposable.
|
|
82
|
+
|
|
83
|
+
Complexity: O(1) — fixed chain of constant-time threshold checks.
|
|
84
|
+
"""
|
|
85
|
+
if freshness < stale_threshold:
|
|
86
|
+
return REASON_STALE
|
|
87
|
+
if hotness < cold_threshold:
|
|
88
|
+
return REASON_COLD
|
|
89
|
+
if size_bytes > bulky_threshold_bytes:
|
|
90
|
+
return REASON_BULKY
|
|
91
|
+
if regen_usd == 0.0 and regen_ms < 50.0:
|
|
92
|
+
return REASON_CHEAP
|
|
93
|
+
return REASON_CAPACITY
|
advalcache/_refresh.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""advalcache/_refresh.py — Background refresh worker pool.
|
|
2
|
+
|
|
3
|
+
Manages a thread pool for proactive, non-blocking refresh of stale but
|
|
4
|
+
high-value entries. Prevents double-refresh via an in-flight key set.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Any, Callable, Optional, Set, Tuple
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("advalcache.refresh")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BackgroundRefreshPool:
|
|
19
|
+
"""Thread pool that runs refresh callbacks for high-value stale entries.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
pool = BackgroundRefreshPool(max_workers=4)
|
|
23
|
+
pool.submit("mykey", fetch_fn, on_done_callback)
|
|
24
|
+
pool.shutdown()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, max_workers: int = 4) -> None:
|
|
28
|
+
self._executor = concurrent.futures.ThreadPoolExecutor(
|
|
29
|
+
max_workers=max_workers,
|
|
30
|
+
thread_name_prefix="adval-refresh",
|
|
31
|
+
)
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
self._in_flight: Set[str] = set()
|
|
34
|
+
|
|
35
|
+
def is_refreshing(self, key: str) -> bool:
|
|
36
|
+
"""True if a refresh is already running for this key.
|
|
37
|
+
|
|
38
|
+
Complexity: O(1) — set membership check.
|
|
39
|
+
"""
|
|
40
|
+
with self._lock:
|
|
41
|
+
return key in self._in_flight
|
|
42
|
+
|
|
43
|
+
def submit(
|
|
44
|
+
self,
|
|
45
|
+
key: str,
|
|
46
|
+
fetch_fn: Callable[[], Tuple[Any, float, int]],
|
|
47
|
+
on_done: Callable[[str, Any, float, int], None],
|
|
48
|
+
on_error: Optional[Callable[[str, Exception], None]] = None,
|
|
49
|
+
) -> bool:
|
|
50
|
+
"""Schedule a background refresh for key.
|
|
51
|
+
|
|
52
|
+
Returns False (no-op) if a refresh is already in-flight for key.
|
|
53
|
+
``fetch_fn`` must return (data, latency_ms, size_bytes).
|
|
54
|
+
``on_done`` is called with (key, data, latency_ms, size_bytes) on success.
|
|
55
|
+
``on_error`` is called with (key, exc) on failure.
|
|
56
|
+
|
|
57
|
+
Complexity: O(1) — set check/add plus an executor submit;
|
|
58
|
+
``fetch_fn`` itself runs on a worker thread in O(cost of fetch).
|
|
59
|
+
"""
|
|
60
|
+
with self._lock:
|
|
61
|
+
if key in self._in_flight:
|
|
62
|
+
return False
|
|
63
|
+
self._in_flight.add(key)
|
|
64
|
+
|
|
65
|
+
self._executor.submit(self._run, key, fetch_fn, on_done, on_error)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def _run(
|
|
69
|
+
self,
|
|
70
|
+
key: str,
|
|
71
|
+
fetch_fn: Callable[[], Tuple[Any, float, int]],
|
|
72
|
+
on_done: Callable[[str, Any, float, int], None],
|
|
73
|
+
on_error: Optional[Callable[[str, Exception], None]],
|
|
74
|
+
) -> None:
|
|
75
|
+
try:
|
|
76
|
+
data, latency_ms, size_bytes = fetch_fn()
|
|
77
|
+
on_done(key, data, latency_ms, size_bytes)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
logger.warning("advalcache: background refresh failed for %r: %s", key, exc)
|
|
80
|
+
if on_error is not None:
|
|
81
|
+
try:
|
|
82
|
+
on_error(key, exc)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
finally:
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._in_flight.discard(key)
|
|
88
|
+
|
|
89
|
+
def shutdown(self, wait: bool = True) -> None:
|
|
90
|
+
"""Gracefully shut down the thread pool."""
|
|
91
|
+
self._executor.shutdown(wait=wait)
|