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/__init__.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""advalcache/__init__.py — Public API surface.
|
|
2
|
+
|
|
3
|
+
from advalcache import Cache
|
|
4
|
+
cache = Cache(capacity_mb=512)
|
|
5
|
+
|
|
6
|
+
@cache.cached(regen_ms=800, regen_usd=0.015, change_interval_s=60)
|
|
7
|
+
def get_stock_price(ticker: str) -> dict:
|
|
8
|
+
return api.fetch(ticker)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Callable, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from advalcache.config import CacheConfig
|
|
18
|
+
from advalcache.stats import CacheStats
|
|
19
|
+
from advalcache._engine import AdValEngine, AsyncAdValEngine, CacheEntry
|
|
20
|
+
from advalcache.decorators import make_cached_decorator
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Cache",
|
|
25
|
+
"AsyncCache",
|
|
26
|
+
"CacheConfig",
|
|
27
|
+
"CacheStats",
|
|
28
|
+
"CacheEntry",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Cache:
|
|
34
|
+
"""AdVal in-memory cache — synchronous.
|
|
35
|
+
|
|
36
|
+
The simplest usage::
|
|
37
|
+
|
|
38
|
+
from advalcache import Cache
|
|
39
|
+
|
|
40
|
+
cache = Cache(capacity_mb=256)
|
|
41
|
+
|
|
42
|
+
@cache.cached(regen_ms=800, regen_usd=0.015, change_interval_s=60)
|
|
43
|
+
def fetch_quote(ticker: str) -> dict:
|
|
44
|
+
return live_api.get(ticker)
|
|
45
|
+
|
|
46
|
+
For full configuration, pass a ``CacheConfig``::
|
|
47
|
+
|
|
48
|
+
cache = Cache(config=CacheConfig(capacity_mb=512, obs_port=9999))
|
|
49
|
+
|
|
50
|
+
Can also be used as a context manager for automatic shutdown::
|
|
51
|
+
|
|
52
|
+
with Cache(capacity_mb=64) as cache:
|
|
53
|
+
...
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
capacity_mb: float = 256.0,
|
|
59
|
+
*,
|
|
60
|
+
config: Optional[CacheConfig] = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
if config is None:
|
|
63
|
+
config = CacheConfig(capacity_mb=capacity_mb)
|
|
64
|
+
self._config = config
|
|
65
|
+
self._engine = AdValEngine(**config.engine_kwargs())
|
|
66
|
+
self.cached = make_cached_decorator(
|
|
67
|
+
self._engine, key_prefix=config.key_prefix, is_async=False
|
|
68
|
+
)
|
|
69
|
+
self._obs_server: Any = None
|
|
70
|
+
if config.obs_port is not None:
|
|
71
|
+
self._start_obs_server(config.obs_port)
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Manual get / put / invalidate
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def get(
|
|
78
|
+
self,
|
|
79
|
+
key: str,
|
|
80
|
+
refresh_callback: Optional[Callable[[], Tuple[Any, float, int]]] = None,
|
|
81
|
+
) -> Optional[Tuple[Any, bool, float, float]]:
|
|
82
|
+
"""Look up a key.
|
|
83
|
+
|
|
84
|
+
Returns ``(value, is_fresh, freshness, score)`` on a hit,
|
|
85
|
+
or ``None`` on a miss.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
key: The cache key.
|
|
89
|
+
refresh_callback: Optional callable that returns ``(data, latency_ms,
|
|
90
|
+
size_bytes)``. If the entry is stale but high-value, AdVal calls
|
|
91
|
+
this in a background thread to re-sync the entry.
|
|
92
|
+
"""
|
|
93
|
+
return self._engine.get(key, refresh_callback)
|
|
94
|
+
|
|
95
|
+
def put(
|
|
96
|
+
self,
|
|
97
|
+
key: str,
|
|
98
|
+
value: Any,
|
|
99
|
+
*,
|
|
100
|
+
regen_ms: float = 100.0,
|
|
101
|
+
regen_usd: float = 0.0,
|
|
102
|
+
change_interval_s: float = 0.0,
|
|
103
|
+
cls: str = "generic",
|
|
104
|
+
size_bytes: Optional[int] = None,
|
|
105
|
+
) -> bool:
|
|
106
|
+
"""Admit or update an entry.
|
|
107
|
+
|
|
108
|
+
Returns True if the entry was admitted, False if declined by
|
|
109
|
+
admission control (e.g. too volatile and too cheap).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
key: Cache key.
|
|
113
|
+
value: The value to cache.
|
|
114
|
+
regen_ms: Regeneration latency in milliseconds.
|
|
115
|
+
regen_usd: Regeneration cost in USD.
|
|
116
|
+
change_interval_s: How often this object changes (seconds). 0 = immutable.
|
|
117
|
+
cls: Object class label for telemetry (informational only).
|
|
118
|
+
size_bytes: Override the estimated memory footprint. If None, estimated
|
|
119
|
+
via ``sys.getsizeof(value)``.
|
|
120
|
+
"""
|
|
121
|
+
return self._engine.put(
|
|
122
|
+
key=key, data=value, cls=cls,
|
|
123
|
+
size_bytes=size_bytes,
|
|
124
|
+
regen_ms=regen_ms, regen_usd=regen_usd,
|
|
125
|
+
change_interval_s=change_interval_s,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def invalidate(self, key: str) -> bool:
|
|
129
|
+
"""Remove a key from the cache. Returns True if it was resident."""
|
|
130
|
+
return self._engine.invalidate(key)
|
|
131
|
+
|
|
132
|
+
def invalidate_prefix(self, prefix: str) -> int:
|
|
133
|
+
"""Remove all keys that start with ``prefix``. Returns count removed."""
|
|
134
|
+
return self._engine.invalidate_prefix(prefix)
|
|
135
|
+
|
|
136
|
+
def clear(self) -> None:
|
|
137
|
+
"""Flush all entries and reset all metrics."""
|
|
138
|
+
self._engine.clear()
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Telemetry helpers
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def record_ram_latency(self, ms: float) -> None:
|
|
145
|
+
"""Record a measured RAM retrieval latency for stats reporting."""
|
|
146
|
+
self._engine.record_ram_latency(ms)
|
|
147
|
+
|
|
148
|
+
def record_origin_latency(self, ms: float) -> None:
|
|
149
|
+
"""Record a measured origin (DB / API) latency for stats reporting."""
|
|
150
|
+
self._engine.record_origin_latency(ms)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Stats
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def get_stats(self) -> CacheStats:
|
|
157
|
+
"""Return a frozen snapshot of current telemetry."""
|
|
158
|
+
return CacheStats.from_snapshot(self._engine.snapshot())
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Properties
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def count(self) -> int:
|
|
166
|
+
"""Number of entries currently in the cache."""
|
|
167
|
+
return self._engine.count
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def resident_mb(self) -> float:
|
|
171
|
+
"""Current resident memory footprint in MB."""
|
|
172
|
+
return self._engine.resident_mb
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def capacity_mb(self) -> float:
|
|
176
|
+
"""Configured maximum capacity in MB."""
|
|
177
|
+
return self._engine.capacity_mb
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def config(self) -> CacheConfig:
|
|
181
|
+
return self._config
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Context manager
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def __enter__(self) -> "Cache":
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def __exit__(self, *_: Any) -> None:
|
|
191
|
+
self.shutdown()
|
|
192
|
+
|
|
193
|
+
def shutdown(self, wait: bool = True) -> None:
|
|
194
|
+
"""Gracefully stop background refresh threads."""
|
|
195
|
+
self._engine.shutdown(wait)
|
|
196
|
+
if self._obs_server is not None:
|
|
197
|
+
try:
|
|
198
|
+
self._obs_server.shutdown()
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# Built-in observability server (optional)
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def _start_obs_server(self, port: int) -> None:
|
|
207
|
+
"""Start the built-in FastAPI observability server on a background thread."""
|
|
208
|
+
try:
|
|
209
|
+
from advalcache.server import ObsServer
|
|
210
|
+
self._obs_server = ObsServer(self._engine, port=port)
|
|
211
|
+
t = threading.Thread(target=self._obs_server.run, daemon=True)
|
|
212
|
+
t.start()
|
|
213
|
+
except ImportError:
|
|
214
|
+
import warnings
|
|
215
|
+
warnings.warn(
|
|
216
|
+
"advalcache: obs_port requires 'advalcache[fastapi]' extra. "
|
|
217
|
+
"Install with: pip install advalcache[fastapi]",
|
|
218
|
+
stacklevel=3,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def __repr__(self) -> str:
|
|
222
|
+
return (
|
|
223
|
+
f"Cache(capacity_mb={self.capacity_mb}, "
|
|
224
|
+
f"resident={self.resident_mb:.2f}MB, "
|
|
225
|
+
f"count={self.count})"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class AsyncCache:
|
|
230
|
+
"""AdVal in-memory cache — async version.
|
|
231
|
+
|
|
232
|
+
Drop-in replacement for ``Cache`` when your functions are ``async def``.
|
|
233
|
+
Uses ``asyncio.Lock`` internally — safe in any event loop without blocking.
|
|
234
|
+
|
|
235
|
+
Usage::
|
|
236
|
+
|
|
237
|
+
from advalcache import AsyncCache
|
|
238
|
+
|
|
239
|
+
cache = AsyncCache(capacity_mb=256)
|
|
240
|
+
|
|
241
|
+
@cache.cached(regen_ms=800, regen_usd=0.015, change_interval_s=60)
|
|
242
|
+
async def fetch_quote(ticker: str) -> dict:
|
|
243
|
+
return await live_api.get_async(ticker)
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
capacity_mb: float = 256.0,
|
|
249
|
+
*,
|
|
250
|
+
config: Optional[CacheConfig] = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
if config is None:
|
|
253
|
+
config = CacheConfig(capacity_mb=capacity_mb)
|
|
254
|
+
self._config = config
|
|
255
|
+
self._engine = AsyncAdValEngine(**config.engine_kwargs())
|
|
256
|
+
self.cached = make_cached_decorator(
|
|
257
|
+
self._engine, key_prefix=config.key_prefix, is_async=True
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
async def get(
|
|
261
|
+
self,
|
|
262
|
+
key: str,
|
|
263
|
+
refresh_callback: Optional[Callable] = None,
|
|
264
|
+
) -> Optional[Tuple[Any, bool, float, float]]:
|
|
265
|
+
return await self._engine.get(key, refresh_callback)
|
|
266
|
+
|
|
267
|
+
async def put(
|
|
268
|
+
self,
|
|
269
|
+
key: str,
|
|
270
|
+
value: Any,
|
|
271
|
+
*,
|
|
272
|
+
regen_ms: float = 100.0,
|
|
273
|
+
regen_usd: float = 0.0,
|
|
274
|
+
change_interval_s: float = 0.0,
|
|
275
|
+
cls: str = "generic",
|
|
276
|
+
size_bytes: Optional[int] = None,
|
|
277
|
+
) -> bool:
|
|
278
|
+
return await self._engine.put(
|
|
279
|
+
key=key, data=value, cls=cls,
|
|
280
|
+
size_bytes=size_bytes,
|
|
281
|
+
regen_ms=regen_ms, regen_usd=regen_usd,
|
|
282
|
+
change_interval_s=change_interval_s,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
async def invalidate(self, key: str) -> bool:
|
|
286
|
+
return await self._engine.invalidate(key)
|
|
287
|
+
|
|
288
|
+
async def invalidate_prefix(self, prefix: str) -> int:
|
|
289
|
+
return await self._engine.invalidate_prefix(prefix)
|
|
290
|
+
|
|
291
|
+
async def clear(self) -> None:
|
|
292
|
+
await self._engine.clear()
|
|
293
|
+
|
|
294
|
+
def get_stats(self) -> CacheStats:
|
|
295
|
+
return CacheStats.from_snapshot(self._engine.snapshot())
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def count(self) -> int:
|
|
299
|
+
return self._engine.count
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def resident_mb(self) -> float:
|
|
303
|
+
return self._engine.resident_mb
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def capacity_mb(self) -> float:
|
|
307
|
+
return self._engine.capacity_mb
|
|
308
|
+
|
|
309
|
+
async def __aenter__(self) -> "AsyncCache":
|
|
310
|
+
return self
|
|
311
|
+
|
|
312
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
313
|
+
self._engine.shutdown(wait=False)
|
|
314
|
+
|
|
315
|
+
def __repr__(self) -> str:
|
|
316
|
+
return (
|
|
317
|
+
f"AsyncCache(capacity_mb={self.capacity_mb}, "
|
|
318
|
+
f"resident={self.resident_mb:.2f}MB, "
|
|
319
|
+
f"count={self.count})"
|
|
320
|
+
)
|
advalcache/__main__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""advalcache/__main__.py — CLI entry point.
|
|
2
|
+
|
|
3
|
+
python -m advalcache --help
|
|
4
|
+
advalcache serve --port 9999 --capacity-mb 256
|
|
5
|
+
advalcache version
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="advalcache",
|
|
17
|
+
description="AdVal Cache SDK — CLI tools",
|
|
18
|
+
)
|
|
19
|
+
sub = parser.add_subparsers(dest="command")
|
|
20
|
+
|
|
21
|
+
# version
|
|
22
|
+
sub.add_parser("version", help="Print the advalcache version and exit.")
|
|
23
|
+
|
|
24
|
+
# serve
|
|
25
|
+
serve_p = sub.add_parser("serve", help="Start a standalone AdVal observability server.")
|
|
26
|
+
serve_p.add_argument("--port", type=int, default=9999, help="HTTP port (default: 9999)")
|
|
27
|
+
serve_p.add_argument("--capacity-mb", type=float, default=256.0,
|
|
28
|
+
help="Cache capacity in MB (default: 256)")
|
|
29
|
+
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
if args.command == "version":
|
|
33
|
+
from advalcache import __version__
|
|
34
|
+
print(f"advalcache {__version__}")
|
|
35
|
+
|
|
36
|
+
elif args.command == "serve":
|
|
37
|
+
print(f"Starting AdVal observability server on http://127.0.0.1:{args.port}")
|
|
38
|
+
print(f"Cache capacity: {args.capacity_mb} MB")
|
|
39
|
+
print("Press Ctrl+C to stop.")
|
|
40
|
+
try:
|
|
41
|
+
from advalcache import Cache
|
|
42
|
+
from advalcache.server import ObsServer
|
|
43
|
+
cache = Cache(capacity_mb=args.capacity_mb)
|
|
44
|
+
server = ObsServer(cache._engine, port=args.port)
|
|
45
|
+
server.run()
|
|
46
|
+
except ImportError:
|
|
47
|
+
print("ERROR: 'advalcache serve' requires the fastapi extra.")
|
|
48
|
+
print("Install with: pip install advalcache[fastapi]")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
print("\nShutdown.")
|
|
52
|
+
|
|
53
|
+
else:
|
|
54
|
+
parser.print_help()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|