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/_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,2 @@
1
+ # advalcache/adapters/__init__.py
2
+ """Framework adapters for advalcache."""
@@ -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
+ }