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/_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)
@@ -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)