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/_scoring.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""advalcache/_scoring.py — Pure AdVal scoring math.
|
|
2
|
+
|
|
3
|
+
All functions are stateless and dependency-free. They operate on plain
|
|
4
|
+
floats and return floats, making them trivially unit-testable and safely
|
|
5
|
+
reusable by both the sync and async engines.
|
|
6
|
+
|
|
7
|
+
The score equation:
|
|
8
|
+
score(i) = hotness(i) x pain(i) x freshness(i) / size_units(i)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Constants (tunable at the Cache level via CacheConfig)
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
#: LRFU decay constant. Half-life ≈ ln2/0.001 ≈ 693 s.
|
|
22
|
+
LAMBDA_DEFAULT: float = 0.001
|
|
23
|
+
|
|
24
|
+
#: Objects that never change: treat freshness as 1.0 regardless of age.
|
|
25
|
+
FRESH_ALWAYS_ABOVE_S: float = 1_800.0
|
|
26
|
+
|
|
27
|
+
#: Floor so sub-KB objects don't divide by near-zero.
|
|
28
|
+
SIZE_UNITS_FLOOR: float = 0.25
|
|
29
|
+
|
|
30
|
+
#: 1 KB reference unit for logarithmic size scaling.
|
|
31
|
+
_SIZE_REF_BYTES: float = 1_024.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Hotness (LRFU decayed counter)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def touch_hotness(
|
|
39
|
+
current_hotness: float,
|
|
40
|
+
last_access_ts: float,
|
|
41
|
+
now_ts: float,
|
|
42
|
+
decay_lambda: float = LAMBDA_DEFAULT,
|
|
43
|
+
) -> float:
|
|
44
|
+
"""Return updated LRFU hotness after an access at now_ts.
|
|
45
|
+
|
|
46
|
+
Formula: c <- 1 + c * exp(-lambda * dt)
|
|
47
|
+
|
|
48
|
+
At short dt the new access dominates (recency-like).
|
|
49
|
+
At long dt the exponent shrinks, so historic count dominates (frequency-like).
|
|
50
|
+
|
|
51
|
+
Complexity: O(1) — one exp() call.
|
|
52
|
+
"""
|
|
53
|
+
dt = max(0.0, now_ts - last_access_ts)
|
|
54
|
+
return 1.0 + current_hotness * math.exp(-decay_lambda * dt)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Freshness (Poisson staleness probability)
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def get_freshness(
|
|
62
|
+
last_sync_ts: float,
|
|
63
|
+
now_ts: float,
|
|
64
|
+
change_interval_s: float,
|
|
65
|
+
) -> float:
|
|
66
|
+
"""P(copy still valid) = exp(-dt / T) for a Poisson mutation process.
|
|
67
|
+
|
|
68
|
+
Returns 1.0 for immutable objects (change_interval_s <= 0 or >= FRESH_ALWAYS_ABOVE_S).
|
|
69
|
+
|
|
70
|
+
Complexity: O(1) — one exp() call.
|
|
71
|
+
"""
|
|
72
|
+
if change_interval_s <= 0.0 or change_interval_s >= FRESH_ALWAYS_ABOVE_S:
|
|
73
|
+
return 1.0
|
|
74
|
+
dt = max(0.0, now_ts - last_sync_ts)
|
|
75
|
+
return math.exp(-dt / change_interval_s)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Pain (p99-normalised regeneration cost)
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def pain_normalized(
|
|
83
|
+
regen_ms: float,
|
|
84
|
+
regen_usd: float,
|
|
85
|
+
p99_ms: float = 2_000.0,
|
|
86
|
+
p99_usd: float = 0.05,
|
|
87
|
+
w_lat: float = 0.5,
|
|
88
|
+
w_usd: float = 0.5,
|
|
89
|
+
) -> float:
|
|
90
|
+
"""Blended regeneration pain, normalised to (0, 1].
|
|
91
|
+
|
|
92
|
+
Both latency and dollar cost are normalised by p99 (not max) so
|
|
93
|
+
milliseconds don't drown out dollars.
|
|
94
|
+
|
|
95
|
+
Complexity: O(1).
|
|
96
|
+
"""
|
|
97
|
+
pain_lat = min(1.0, max(0.01, regen_ms / max(1e-9, p99_ms)))
|
|
98
|
+
pain_usd = min(1.0, max(0.0, regen_usd / max(1e-9, p99_usd)))
|
|
99
|
+
return w_lat * pain_lat + w_usd * pain_usd
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Size units (logarithmic scaling)
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def size_units(size_bytes: int) -> float:
|
|
107
|
+
"""log1p(bytes / 1 KB), floored at SIZE_UNITS_FLOOR.
|
|
108
|
+
|
|
109
|
+
Logarithmic so a 5 MB image is penalised ~12x vs a 1 KB record, not 5000x.
|
|
110
|
+
|
|
111
|
+
Complexity: O(1) — one log1p() call.
|
|
112
|
+
"""
|
|
113
|
+
return max(SIZE_UNITS_FLOOR, math.log1p(size_bytes / _SIZE_REF_BYTES))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Full score
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def compute_score(
|
|
121
|
+
hotness: float,
|
|
122
|
+
regen_ms: float,
|
|
123
|
+
regen_usd: float,
|
|
124
|
+
last_sync_ts: float,
|
|
125
|
+
now_ts: float,
|
|
126
|
+
change_interval_s: float,
|
|
127
|
+
size_bytes: int,
|
|
128
|
+
p99_ms: float = 2_000.0,
|
|
129
|
+
p99_usd: float = 0.05,
|
|
130
|
+
w_lat: float = 0.5,
|
|
131
|
+
w_usd: float = 0.5,
|
|
132
|
+
) -> float:
|
|
133
|
+
"""Full AdVal value score: hotness x pain x freshness / size_units.
|
|
134
|
+
|
|
135
|
+
Higher score = more valuable. Lower-score entries are evicted first.
|
|
136
|
+
|
|
137
|
+
Complexity: O(1) — three O(1) components combined.
|
|
138
|
+
"""
|
|
139
|
+
pain = pain_normalized(regen_ms, regen_usd, p99_ms, p99_usd, w_lat, w_usd)
|
|
140
|
+
fresh = get_freshness(last_sync_ts, now_ts, change_interval_s)
|
|
141
|
+
su = size_units(size_bytes)
|
|
142
|
+
return (hotness * pain * fresh) / su
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Size estimation (when caller omits size_bytes)
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def estimate_size_bytes(value: Any) -> int:
|
|
150
|
+
"""Shallow sys.getsizeof estimate, good enough for eviction decisions.
|
|
151
|
+
|
|
152
|
+
Complexity: O(1) — shallow sizeof, does not traverse nested objects.
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
return max(1, sys.getsizeof(value))
|
|
156
|
+
except Exception:
|
|
157
|
+
return 256
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""advalcache/adapters/django.py — Django cache backend adapter.
|
|
2
|
+
|
|
3
|
+
Implements Django's BaseCache interface backed by AdVal.
|
|
4
|
+
|
|
5
|
+
Usage in settings.py:
|
|
6
|
+
CACHES = {
|
|
7
|
+
"default": {
|
|
8
|
+
"BACKEND": "advalcache.adapters.django.AdValDjangoCache",
|
|
9
|
+
"CAPACITY_MB": 512,
|
|
10
|
+
"COST_MAP": {
|
|
11
|
+
# key prefix -> cost parameters
|
|
12
|
+
"session:": {"regen_ms": 5, "regen_usd": 0.0, "change_interval_s": 1800},
|
|
13
|
+
"api:": {"regen_ms": 800, "regen_usd": 0.015, "change_interval_s": 300},
|
|
14
|
+
# "_default" is used when no prefix matches
|
|
15
|
+
"_default": {"regen_ms": 50, "regen_usd": 0.0, "change_interval_s": 600},
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import pickle
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any, Dict, Iterable, Optional
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from django.core.cache.backends.base import BaseCache, DEFAULT_CACHE_ALIAS
|
|
29
|
+
_HAS_DJANGO = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
_HAS_DJANGO = False
|
|
32
|
+
|
|
33
|
+
from advalcache import Cache, CacheConfig
|
|
34
|
+
|
|
35
|
+
_DEFAULT_COST = {"regen_ms": 50.0, "regen_usd": 0.0, "change_interval_s": 600.0}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_cost(key: str, cost_map: Dict[str, Any]) -> Dict[str, float]:
|
|
39
|
+
for prefix, params in cost_map.items():
|
|
40
|
+
if prefix == "_default":
|
|
41
|
+
continue
|
|
42
|
+
if key.startswith(prefix):
|
|
43
|
+
return params
|
|
44
|
+
return cost_map.get("_default", _DEFAULT_COST)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if _HAS_DJANGO:
|
|
48
|
+
class AdValDjangoCache(BaseCache):
|
|
49
|
+
"""Django cache backend backed by AdVal in-memory engine."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, server: str = "", params: Optional[Dict[str, Any]] = None) -> None:
|
|
52
|
+
# Django passes LOCATION as server; params contains all CACHE keys
|
|
53
|
+
super().__init__(params or {})
|
|
54
|
+
p = params or {}
|
|
55
|
+
capacity_mb = float(p.get("CAPACITY_MB", 256.0))
|
|
56
|
+
self._cost_map: Dict[str, Any] = p.get("COST_MAP", {"_default": _DEFAULT_COST})
|
|
57
|
+
self._cache = Cache(capacity_mb=capacity_mb)
|
|
58
|
+
# Expiry tracking: Django has timeout semantics, AdVal uses change_interval
|
|
59
|
+
self._expiry: Dict[str, float] = {}
|
|
60
|
+
|
|
61
|
+
def _serialize(self, value: Any) -> bytes:
|
|
62
|
+
return pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
|
|
63
|
+
|
|
64
|
+
def _deserialize(self, raw: bytes) -> Any:
|
|
65
|
+
return pickle.loads(raw) # noqa: S301
|
|
66
|
+
|
|
67
|
+
def get_backend_timeout(self, timeout: Optional[int] = None) -> Optional[float]:
|
|
68
|
+
if timeout is None:
|
|
69
|
+
return self.default_timeout
|
|
70
|
+
return timeout
|
|
71
|
+
|
|
72
|
+
def add(self, key: str, value: Any, timeout: Optional[int] = None, version: Optional[int] = None) -> bool:
|
|
73
|
+
k = self.make_key(key, version)
|
|
74
|
+
if self._cache.get(k) is not None:
|
|
75
|
+
return False
|
|
76
|
+
return self.set(key, value, timeout, version)
|
|
77
|
+
|
|
78
|
+
def get(self, key: str, default: Any = None, version: Optional[int] = None) -> Any:
|
|
79
|
+
k = self.make_key(key, version)
|
|
80
|
+
# Check Django-level expiry
|
|
81
|
+
exp = self._expiry.get(k)
|
|
82
|
+
if exp is not None and time.time() > exp:
|
|
83
|
+
self._cache.invalidate(k)
|
|
84
|
+
self._expiry.pop(k, None)
|
|
85
|
+
return default
|
|
86
|
+
result = self._cache.get(k)
|
|
87
|
+
if result is None:
|
|
88
|
+
return default
|
|
89
|
+
raw, _fresh, _freshness, _score = result
|
|
90
|
+
try:
|
|
91
|
+
return self._deserialize(raw)
|
|
92
|
+
except Exception:
|
|
93
|
+
return default
|
|
94
|
+
|
|
95
|
+
def set(self, key: str, value: Any, timeout: Optional[int] = None, version: Optional[int] = None) -> bool:
|
|
96
|
+
k = self.make_key(key, version)
|
|
97
|
+
raw = self._serialize(value)
|
|
98
|
+
cost = _resolve_cost(k, self._cost_map)
|
|
99
|
+
t = self.get_backend_timeout(timeout)
|
|
100
|
+
change_interval = t if t is not None else cost.get("change_interval_s", 600.0)
|
|
101
|
+
if t is not None:
|
|
102
|
+
self._expiry[k] = time.time() + t
|
|
103
|
+
return self._cache.put(
|
|
104
|
+
key=k, value=raw,
|
|
105
|
+
regen_ms=cost.get("regen_ms", 50.0),
|
|
106
|
+
regen_usd=cost.get("regen_usd", 0.0),
|
|
107
|
+
change_interval_s=change_interval,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def delete(self, key: str, version: Optional[int] = None) -> bool:
|
|
111
|
+
k = self.make_key(key, version)
|
|
112
|
+
self._expiry.pop(k, None)
|
|
113
|
+
return self._cache.invalidate(k)
|
|
114
|
+
|
|
115
|
+
def has_key(self, key: str, version: Optional[int] = None) -> bool:
|
|
116
|
+
return self.get(key, version=version) is not None
|
|
117
|
+
|
|
118
|
+
def clear(self) -> bool:
|
|
119
|
+
self._expiry.clear()
|
|
120
|
+
self._cache.clear()
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
def get_many(self, keys: Iterable[str], version: Optional[int] = None) -> Dict[str, Any]:
|
|
124
|
+
return {k: v for k in keys if (v := self.get(k, version=version)) is not None}
|
|
125
|
+
|
|
126
|
+
def set_many(self, mapping: Dict[str, Any], timeout: Optional[int] = None, version: Optional[int] = None) -> list:
|
|
127
|
+
return [k for k, v in mapping.items() if not self.set(k, v, timeout, version)]
|
|
128
|
+
|
|
129
|
+
def delete_many(self, keys: Iterable[str], version: Optional[int] = None) -> None:
|
|
130
|
+
for k in keys:
|
|
131
|
+
self.delete(k, version)
|
|
132
|
+
|
|
133
|
+
else:
|
|
134
|
+
class AdValDjangoCache: # type: ignore[no-redef]
|
|
135
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
136
|
+
raise ImportError(
|
|
137
|
+
"advalcache Django adapter requires 'django'. "
|
|
138
|
+
"Install with: pip install advalcache[django]"
|
|
139
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""advalcache/adapters/fastapi.py — FastAPI middleware + dependency injection.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from advalcache.adapters.fastapi import AdValCacheMiddleware, get_cache
|
|
5
|
+
|
|
6
|
+
app.add_middleware(AdValCacheMiddleware, capacity_mb=256)
|
|
7
|
+
|
|
8
|
+
@app.get("/quote/{ticker}")
|
|
9
|
+
async def get_quote(ticker: str, cache=Depends(get_cache)):
|
|
10
|
+
hit = cache.get(f"quote:{ticker}")
|
|
11
|
+
if hit:
|
|
12
|
+
return hit[0]
|
|
13
|
+
data = await fetch_live(ticker)
|
|
14
|
+
cache.put(f"quote:{ticker}", data, regen_ms=200, regen_usd=0.01, change_interval_s=30)
|
|
15
|
+
return data
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from fastapi import Request
|
|
24
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
25
|
+
_HAS_FASTAPI = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
_HAS_FASTAPI = False
|
|
28
|
+
|
|
29
|
+
from advalcache import Cache, CacheConfig
|
|
30
|
+
|
|
31
|
+
# Module-level singleton — populated by middleware
|
|
32
|
+
_cache_instance: Optional[Cache] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_cache() -> Cache:
|
|
36
|
+
"""FastAPI dependency that returns the shared AdVal cache instance.
|
|
37
|
+
|
|
38
|
+
Must use AdValCacheMiddleware (or call init_cache()) first.
|
|
39
|
+
"""
|
|
40
|
+
if _cache_instance is None:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"advalcache: no cache instance found. "
|
|
43
|
+
"Add AdValCacheMiddleware to your app or call advalcache.adapters.fastapi.init_cache()."
|
|
44
|
+
)
|
|
45
|
+
return _cache_instance
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def init_cache(capacity_mb: float = 256.0, config: Optional[CacheConfig] = None) -> Cache:
|
|
49
|
+
"""Manually initialise the shared cache (without middleware)."""
|
|
50
|
+
global _cache_instance
|
|
51
|
+
_cache_instance = Cache(capacity_mb=capacity_mb, config=config)
|
|
52
|
+
return _cache_instance
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if _HAS_FASTAPI:
|
|
56
|
+
class AdValCacheMiddleware(BaseHTTPMiddleware):
|
|
57
|
+
"""Starlette/FastAPI middleware that creates and stores a shared AdVal cache.
|
|
58
|
+
|
|
59
|
+
The cache is accessible in any route via ``Depends(get_cache)``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
app: Any,
|
|
65
|
+
capacity_mb: float = 256.0,
|
|
66
|
+
config: Optional[CacheConfig] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(app)
|
|
69
|
+
global _cache_instance
|
|
70
|
+
_cache_instance = Cache(capacity_mb=capacity_mb, config=config)
|
|
71
|
+
|
|
72
|
+
async def dispatch(self, request: Request, call_next: Any) -> Any:
|
|
73
|
+
return await call_next(request)
|
|
74
|
+
else:
|
|
75
|
+
class AdValCacheMiddleware: # type: ignore[no-redef]
|
|
76
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
77
|
+
raise ImportError(
|
|
78
|
+
"advalcache FastAPI adapter requires 'fastapi'. "
|
|
79
|
+
"Install with: pip install advalcache[fastapi]"
|
|
80
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""advalcache/adapters/sqlalchemy.py — SQLAlchemy query result caching decorator.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from advalcache.adapters.sqlalchemy import cache_query
|
|
5
|
+
from advalcache import Cache
|
|
6
|
+
|
|
7
|
+
cache = Cache(capacity_mb=256)
|
|
8
|
+
|
|
9
|
+
@cache_query(cache, regen_ms=50, regen_usd=0.0, change_interval_s=60)
|
|
10
|
+
def get_user(session, user_id: int):
|
|
11
|
+
return session.query(User).filter_by(id=user_id).first()
|
|
12
|
+
|
|
13
|
+
# Works with any ORM query function — session arg is NOT part of the cache key.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import functools
|
|
19
|
+
import reprlib
|
|
20
|
+
from typing import Any, Callable, Optional
|
|
21
|
+
|
|
22
|
+
from advalcache import Cache
|
|
23
|
+
|
|
24
|
+
_repr = reprlib.Repr()
|
|
25
|
+
_repr.maxstring = 64
|
|
26
|
+
_repr.maxother = 32
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cache_query(
|
|
30
|
+
cache: Cache,
|
|
31
|
+
regen_ms: float = 50.0,
|
|
32
|
+
regen_usd: float = 0.0,
|
|
33
|
+
change_interval_s: float = 300.0,
|
|
34
|
+
cls: str = "db_query",
|
|
35
|
+
key_prefix: str = "sql:",
|
|
36
|
+
) -> Callable:
|
|
37
|
+
"""Decorator factory for caching SQLAlchemy query results with AdVal.
|
|
38
|
+
|
|
39
|
+
The first argument to the decorated function is assumed to be a SQLAlchemy
|
|
40
|
+
session and is excluded from the cache key. All remaining args and kwargs
|
|
41
|
+
are used to build the key.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
cache: An AdVal Cache instance.
|
|
45
|
+
regen_ms: Estimated DB query latency in milliseconds.
|
|
46
|
+
regen_usd: DB query cost in USD (0 for most queries).
|
|
47
|
+
change_interval_s: How often this query's result may change (seconds).
|
|
48
|
+
cls: Object class label for telemetry.
|
|
49
|
+
key_prefix: Prefix prepended to all auto-generated cache keys.
|
|
50
|
+
"""
|
|
51
|
+
def decorator(func: Callable) -> Callable:
|
|
52
|
+
@functools.wraps(func)
|
|
53
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
54
|
+
# args[0] is assumed to be the SQLAlchemy session — exclude from key
|
|
55
|
+
cache_args = args[1:] if len(args) > 1 else ()
|
|
56
|
+
arg_part = _repr.repr(cache_args)
|
|
57
|
+
kw_part = _repr.repr(sorted(kwargs.items()))
|
|
58
|
+
key = f"{key_prefix}{func.__module__}.{func.__qualname__}:{arg_part}:{kw_part}"
|
|
59
|
+
|
|
60
|
+
result = cache.get(key)
|
|
61
|
+
if result is not None:
|
|
62
|
+
value, _fresh, _freshness, _score = result
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
value = func(*args, **kwargs)
|
|
66
|
+
cache.put(
|
|
67
|
+
key=key, value=value, cls=cls,
|
|
68
|
+
regen_ms=regen_ms, regen_usd=regen_usd,
|
|
69
|
+
change_interval_s=change_interval_s,
|
|
70
|
+
)
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
def invalidate(*args: Any, **kwargs: Any) -> bool:
|
|
74
|
+
cache_args = args[1:] if len(args) > 1 else ()
|
|
75
|
+
arg_part = _repr.repr(cache_args)
|
|
76
|
+
kw_part = _repr.repr(sorted(kwargs.items()))
|
|
77
|
+
key = f"{key_prefix}{func.__module__}.{func.__qualname__}:{arg_part}:{kw_part}"
|
|
78
|
+
return cache.invalidate(key)
|
|
79
|
+
|
|
80
|
+
wrapper.invalidate = invalidate # type: ignore[attr-defined]
|
|
81
|
+
return wrapper
|
|
82
|
+
|
|
83
|
+
return decorator
|
advalcache/config.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""advalcache/config.py — CacheConfig: validated configuration dataclass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CacheConfig:
|
|
11
|
+
"""All tuning knobs for an AdVal cache instance.
|
|
12
|
+
|
|
13
|
+
All parameters have sensible defaults so ``Cache(capacity_mb=256)`` works
|
|
14
|
+
out of the box with no further configuration required.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
capacity_mb: Maximum resident memory in megabytes. Default 256 MB.
|
|
18
|
+
decay_lambda: LRFU hotness decay constant. Half-life ≈ ln2/lambda seconds.
|
|
19
|
+
Default 0.001 (half-life ≈ 693 s ≈ 11.5 min).
|
|
20
|
+
refresh_threshold: Freshness level below which a background refresh is
|
|
21
|
+
considered. 0.30 means "refresh when copy is <30% likely to be valid".
|
|
22
|
+
refresh_min_score: Minimum AdVal score an entry must have to be worth
|
|
23
|
+
a background refresh. Prevents refreshing junk that is about to be evicted.
|
|
24
|
+
max_refresh_workers: Thread pool size for background refresh callbacks.
|
|
25
|
+
eviction_pool_fraction: Fraction of resident keys sampled for each eviction.
|
|
26
|
+
10% balances quality and O(n) cost.
|
|
27
|
+
p99_ms: Catalog p99 regeneration latency (ms) used to normalise the pain term.
|
|
28
|
+
Defaults to 2000 ms if not set.
|
|
29
|
+
p99_usd: Catalog p99 regeneration cost (USD) used to normalise the pain term.
|
|
30
|
+
Defaults to $0.05 if not set.
|
|
31
|
+
max_events: Maximum number of recent cache events kept in memory for the
|
|
32
|
+
built-in event log / observability server.
|
|
33
|
+
obs_port: If set, the built-in FastAPI observability server starts on this port
|
|
34
|
+
when the cache is created. Requires ``advalcache[fastapi]`` extra.
|
|
35
|
+
key_prefix: Optional global prefix prepended to all auto-generated cache keys.
|
|
36
|
+
Useful for multi-tenant isolation (e.g. ``key_prefix="tenant_42:"``).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
capacity_mb: float = 256.0
|
|
40
|
+
decay_lambda: float = 0.001
|
|
41
|
+
refresh_threshold: float = 0.30
|
|
42
|
+
refresh_min_score: float = 0.35
|
|
43
|
+
max_refresh_workers: int = 4
|
|
44
|
+
eviction_pool_fraction: float = 0.10
|
|
45
|
+
p99_ms: float = 2_000.0
|
|
46
|
+
p99_usd: float = 0.05
|
|
47
|
+
max_events: int = 200
|
|
48
|
+
obs_port: Optional[int] = None
|
|
49
|
+
key_prefix: str = ""
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
errors = []
|
|
53
|
+
if self.capacity_mb <= 0:
|
|
54
|
+
errors.append(f"capacity_mb must be > 0, got {self.capacity_mb}")
|
|
55
|
+
if not 0.0 < self.decay_lambda < 1.0:
|
|
56
|
+
errors.append(f"decay_lambda must be in (0, 1), got {self.decay_lambda}")
|
|
57
|
+
if not 0.0 <= self.refresh_threshold <= 1.0:
|
|
58
|
+
errors.append(f"refresh_threshold must be in [0, 1], got {self.refresh_threshold}")
|
|
59
|
+
if not 0.0 <= self.refresh_min_score <= 10.0:
|
|
60
|
+
errors.append(f"refresh_min_score must be in [0, 10], got {self.refresh_min_score}")
|
|
61
|
+
if self.max_refresh_workers < 1:
|
|
62
|
+
errors.append(f"max_refresh_workers must be >= 1, got {self.max_refresh_workers}")
|
|
63
|
+
if not 0.0 < self.eviction_pool_fraction <= 1.0:
|
|
64
|
+
errors.append(f"eviction_pool_fraction must be in (0, 1], got {self.eviction_pool_fraction}")
|
|
65
|
+
if errors:
|
|
66
|
+
raise ValueError("CacheConfig validation errors:\n " + "\n ".join(errors))
|
|
67
|
+
|
|
68
|
+
def engine_kwargs(self) -> dict:
|
|
69
|
+
"""Return kwargs suitable for constructing AdValEngine."""
|
|
70
|
+
return {
|
|
71
|
+
"capacity_mb": self.capacity_mb,
|
|
72
|
+
"decay_lambda": self.decay_lambda,
|
|
73
|
+
"refresh_threshold": self.refresh_threshold,
|
|
74
|
+
"refresh_min_score": self.refresh_min_score,
|
|
75
|
+
"max_refresh_workers": self.max_refresh_workers,
|
|
76
|
+
"eviction_pool_fraction": self.eviction_pool_fraction,
|
|
77
|
+
"p99_ms": self.p99_ms,
|
|
78
|
+
"p99_usd": self.p99_usd,
|
|
79
|
+
"max_events": self.max_events,
|
|
80
|
+
}
|