|
16 | 16 | 8. [Production Patterns](#8-production-patterns) |
17 | 17 | 9. [Performance Guide](#9-performance-guide) |
18 | 18 | 10. [Configuration Reference](#10-configuration-reference) |
| 19 | +11. [Examples](#11-examples) |
19 | 20 |
|
20 | 21 | --- |
21 | 22 |
|
@@ -590,20 +591,77 @@ async def get_user(uid: int) -> dict: ... |
590 | 591 | async def load_flags() -> dict: ... |
591 | 592 |
|
592 | 593 | stats = metrics.get_stats() |
593 | | -# { |
594 | | -# "caches": { |
595 | | -# "get_user": { |
596 | | -# "hits": 120, "misses": 5, "hit_rate_percent": 96.0, |
597 | | -# "latency_p50_ms": 0.08, "latency_p95_ms": 0.31, "latency_p99_ms": 0.85, |
598 | | -# "errors": 0 |
599 | | -# } |
600 | | -# }, |
601 | | -# "background_refresh": { |
602 | | -# "flags": {"success": 12, "failure": 0} |
603 | | -# } |
604 | | -# } |
605 | 594 | ``` |
606 | 595 |
|
| 596 | +`get_stats()` returns a structured dict — every section is keyed by `cache_name` (the decorated function's `__name__`, or the `InstrumentedStorage` label you choose): |
| 597 | + |
| 598 | +```python |
| 599 | +{ |
| 600 | + "uptime_seconds": 12.3, |
| 601 | + |
| 602 | + # per-function hit/miss counters |
| 603 | + "caches": { |
| 604 | + "get_user": { |
| 605 | + "hits": 120, "misses": 5, "sets": 5, "deletes": 0, |
| 606 | + "hit_rate_percent": 96.0 |
| 607 | + } |
| 608 | + }, |
| 609 | + |
| 610 | + # per-function, per-operation latency percentiles (ms) |
| 611 | + "latency": { |
| 612 | + "get_user.get": {"count": 125, "p50_ms": 0.01, "p95_ms": 0.05, "p99_ms": 0.12, "avg_ms": 0.02}, |
| 613 | + "get_user.set": {"count": 5, "p50_ms": 0.02, "p95_ms": 0.08, "p99_ms": 0.11, "avg_ms": 0.03} |
| 614 | + }, |
| 615 | + |
| 616 | + # errors keyed as "<cache_name>.<operation>": {"<ErrorType>": count} |
| 617 | + "errors": {}, |
| 618 | + |
| 619 | + # optional memory snapshot (if backend reports it) |
| 620 | + "memory": { |
| 621 | + "get_user": {"bytes": 4096, "entries": 5, "mb": 0.004} |
| 622 | + }, |
| 623 | + |
| 624 | + # @bg background refresh success/failure counts |
| 625 | + "background_refresh": { |
| 626 | + "flags": {"success": 12, "failure": 0} |
| 627 | + } |
| 628 | +} |
| 629 | +``` |
| 630 | + |
| 631 | +### ChainCache — per-layer metrics |
| 632 | + |
| 633 | +Wrapping the whole chain with one `InstrumentedStorage` only gives you totals. |
| 634 | +Wrap **each layer individually** to get per-tier breakdown: |
| 635 | + |
| 636 | +```python |
| 637 | +from advanced_caching import ChainCache, InMemCache, RedisCache, S3Cache, InMemoryMetrics |
| 638 | +from advanced_caching.storage.utils import InstrumentedStorage |
| 639 | + |
| 640 | +m = InMemoryMetrics() |
| 641 | + |
| 642 | +chain = ChainCache.build( |
| 643 | + InstrumentedStorage(InMemCache(), m, "L1:inmem"), # ← named per layer |
| 644 | + InstrumentedStorage(RedisCache(r), m, "L2:redis"), |
| 645 | + InstrumentedStorage(S3Cache(s3, "bkt"), m, "L3:s3"), |
| 646 | + ttls=[60, 300, 3600], |
| 647 | +) |
| 648 | + |
| 649 | +@cache(3600, key="catalog:{page}", store=chain) |
| 650 | +async def get_catalog(page: int) -> list: ... |
| 651 | +``` |
| 652 | + |
| 653 | +`m.get_stats()["caches"]` then shows hit rates per tier — so you can immediately see whether your L1 is sized correctly or whether most traffic is falling through to Redis/S3: |
| 654 | + |
| 655 | +``` |
| 656 | +Layer hits misses sets hit_rate |
| 657 | +----------- ---- ------ ---- -------- |
| 658 | +L1:inmem 87 5 5 94% |
| 659 | +L2:redis 4 1 1 80% |
| 660 | +L3:s3 1 0 0 100% |
| 661 | +``` |
| 662 | + |
| 663 | +> **Reading the table**: a healthy setup has almost all hits at L1. If L2/L3 hit rates are high it means L1 is evicting too early — raise its TTL or increase its size. |
| 664 | +
|
607 | 665 | ### Custom Metrics Collector |
608 | 666 |
|
609 | 667 | Implement the `MetricsCollector` protocol: |
@@ -691,11 +749,46 @@ async def get_user(user_id: int) -> dict: ... |
691 | 749 | @cache(60, key="order:{user_id}:{order_id}") |
692 | 750 | async def get_order(user_id: int, order_id: int) -> dict: ... |
693 | 751 |
|
694 | | -# Callable — full control |
| 752 | +# Callable — full Python, no format string limits |
695 | 753 | @cache(60, key=lambda uid, role: f"user:{role}:{uid}") |
696 | 754 | async def get_user_by_role(uid: int, role: str) -> dict: ... |
697 | 755 | ``` |
698 | 756 |
|
| 757 | +### Callable Key Patterns |
| 758 | + |
| 759 | +A callable receives the **exact same `*args, **kwargs`** as the decorated function. Use it when string templates aren't enough: |
| 760 | + |
| 761 | +```python |
| 762 | +# 1. Multi-arg tenant isolation |
| 763 | +@cache(60, key=lambda tenant, resource_id: f"{tenant}:res:{resource_id}") |
| 764 | +async def get_resource(tenant: str, resource_id: int) -> dict: ... |
| 765 | + |
| 766 | +# 2. Conditional prefix (e.g. admin vs public namespace) |
| 767 | +@cache(60, key=lambda resource_id, admin=False: ("admin" if admin else "public") + f":res:{resource_id}") |
| 768 | +async def get_protected(resource_id: int, admin: bool = False) -> dict: ... |
| 769 | + |
| 770 | +# 3. Hash long/arbitrary inputs (raw SQL, long query strings) |
| 771 | +import hashlib |
| 772 | +def _query_key(query: str) -> str: |
| 773 | + return "query:" + hashlib.sha256(query.encode()).hexdigest()[:16] |
| 774 | + |
| 775 | +@cache(30, key=_query_key) |
| 776 | +async def run_query(query: str) -> list: ... |
| 777 | + |
| 778 | +# 4. Variadic — pick value from positional or keyword |
| 779 | +@cache(300, key=lambda *a, **k: f"i18n:{k.get('lang', a[0] if a else 'en')}") |
| 780 | +async def get_translations(lang: str = "en") -> dict: ... |
| 781 | + |
| 782 | +# 5. Invalidation works identically — callable computes the key to delete |
| 783 | +@cache(60, key=lambda uid: f"u:{uid}") |
| 784 | +def get_user(uid: int) -> dict: ... |
| 785 | + |
| 786 | +get_user.invalidate(42) # deletes key "u:42" |
| 787 | +get_user.clear() # wipes entire store |
| 788 | +``` |
| 789 | + |
| 790 | +> **Performance**: a simple lambda key skips signature inspection and runs at **~4 M ops/s** — roughly 2.3× faster than a named template (`~1.7 M ops/s`). Avoid calling expensive operations (network, hashing) in the key unless necessary. |
| 791 | +
|
699 | 792 | --- |
700 | 793 |
|
701 | 794 | ## 8. Production Patterns |
@@ -875,10 +968,10 @@ def get_order(order_id: int) -> dict: |
875 | 968 |
|
876 | 969 | ```mermaid |
877 | 970 | xychart-beta horizontal |
878 | | - title "Throughput (M ops/s, Python 3.12, Apple M2)" |
879 | | - x-axis ["bg.read local", "InMemCache.get", "@cache sync static", "@cache async static", "@cache SWR stale", "@cache + metrics"] |
| 971 | + title "Throughput (M ops/s, Python 3.12, Apple M2, N=200k)" |
| 972 | + x-axis ["bg.read local", "InMemCache.get", "@cache sync static", "@cache async static", "@cache callable λ", "@cache SWR stale", "@cache + metrics"] |
880 | 973 | y-axis "M ops/s" 0 --> 12 |
881 | | - bar [9.0, 9.9, 6.0, 4.9, 2.3, 1.6] |
| 974 | + bar [7.5, 10.3, 6.0, 4.9, 3.9, 2.9, 1.6] |
882 | 975 | ``` |
883 | 976 |
|
884 | 977 | ### Hot Path Breakdown (`@cache` sync hit, 100k iterations) |
@@ -1020,3 +1113,103 @@ flowchart TD |
1020 | 1113 | SAME -- yes --> AUTO["bg.read(key) — auto-discovers store"] |
1021 | 1114 | SAME -- no --> EXPLICIT["bg.read(key, store=redis_store)"] |
1022 | 1115 | ``` |
| 1116 | + |
| 1117 | +--- |
| 1118 | + |
| 1119 | +## 11. Examples |
| 1120 | + |
| 1121 | +All runnable examples live in `examples/`. Each is self-contained and executable with: |
| 1122 | + |
| 1123 | +```bash |
| 1124 | +uv run python examples/<file>.py |
| 1125 | +``` |
| 1126 | + |
| 1127 | +### `quickstart.py` |
| 1128 | + |
| 1129 | +The fastest way to see every feature in one script. |
| 1130 | + |
| 1131 | +| Section | What it shows | |
| 1132 | +|---------|--------------| |
| 1133 | +| **TTL Cache** | `@cache(ttl, key="user:{user_id}")` — miss, hit, second key | |
| 1134 | +| **SWR** | `@cache(ttl, stale=N)` — serve stale + background refresh | |
| 1135 | +| **Background refresh** | `@bg(interval, key=)` — zero-latency reads | |
| 1136 | +| **Custom store** | `store=InMemCache()` (swap for `RedisCache` in prod) | |
| 1137 | +| **Metrics** | Shared `InMemoryMetrics`, `get_stats()` hit rates | |
| 1138 | +| **Invalidation** | `.invalidate(key)` and `.clear()` | |
| 1139 | +| **Callable keys** | 5 patterns: simple λ, multi-arg, conditional, hash, varargs | |
| 1140 | + |
| 1141 | +```bash |
| 1142 | +uv run python examples/quickstart.py |
| 1143 | +``` |
| 1144 | + |
| 1145 | +--- |
| 1146 | + |
| 1147 | +### `metrics_and_exporters.py` |
| 1148 | + |
| 1149 | +Deep dive into metrics — how to read the output, custom collectors, and per-layer ChainCache observability. |
| 1150 | + |
| 1151 | +| Section | What it shows | |
| 1152 | +|---------|--------------| |
| 1153 | +| **Shared `InMemoryMetrics`** | One collector across multiple functions; `get_stats()` table with hit rates and latency percentiles (p50/p95/p99) | |
| 1154 | +| **Custom `PrintMetrics`** | Minimal protocol implementation — logs every hit/miss to stdout | |
| 1155 | +| **`NULL_METRICS`** | Zero-overhead no-op; throughput comparison | |
| 1156 | +| **ChainCache per-layer** | Wrap each layer (L1:inmem, L2:redis, L3:s3) with `InstrumentedStorage`; watch hits/misses move up the chain as layers fill and evict | |
| 1157 | + |
| 1158 | +Sample output for the ChainCache section: |
| 1159 | + |
| 1160 | +``` |
| 1161 | +[cold start — all layers empty] |
| 1162 | +Layer hits misses sets hit_rate |
| 1163 | +----------- ----- ------ ---- -------- |
| 1164 | +L1:inmem 0 2 2 0% |
| 1165 | +L2:redis 0 2 2 0% |
| 1166 | +L3:s3 0 2 2 0% |
| 1167 | +
|
| 1168 | +[L1 evicted — requests fall through to L2] |
| 1169 | +L1:inmem 2 4 4 33% |
| 1170 | +L2:redis 2 2 2 50% |
| 1171 | +L3:s3 0 2 2 0% |
| 1172 | +``` |
| 1173 | + |
| 1174 | +```bash |
| 1175 | +uv run python examples/metrics_and_exporters.py |
| 1176 | +``` |
| 1177 | + |
| 1178 | +--- |
| 1179 | + |
| 1180 | +### `serializers_example.py` |
| 1181 | + |
| 1182 | +Benchmarks the four serializer strategies on a `LocalFileCache` backend (disk I/O — Redis/InMem would be faster, making the serializer overhead even more visible). |
| 1183 | + |
| 1184 | +| Serializer | When to use | |
| 1185 | +|-----------|------------| |
| 1186 | +| `serializers.json` (orjson) | Default — fastest for JSON-safe data | |
| 1187 | +| `serializers.pickle` | Any Python object, no schema | |
| 1188 | +| `serializers.msgpack` | Large payloads — ~2× more compact than JSON | |
| 1189 | +| Custom `MySerializer` | Protobuf, Avro, Arrow, or any `dumps`/`loads` pair | |
| 1190 | + |
| 1191 | +```bash |
| 1192 | +uv run python examples/serializers_example.py |
| 1193 | +``` |
| 1194 | + |
| 1195 | +--- |
| 1196 | + |
| 1197 | +### `writer_reader.py` |
| 1198 | + |
| 1199 | +Demonstrates the **Single-Writer / Multi-Reader** pattern for sharing data across processes (or threads) with zero per-read latency. |
| 1200 | + |
| 1201 | +``` |
| 1202 | +Writer refreshes every 100 ms; readers poll from private mirrors. |
| 1203 | +
|
| 1204 | +[writer] refreshed → {'USD': 1.0, 'EUR': 0.92, 'GBP': 0.79, 'ts': 1710...} |
| 1205 | +tick 1: fast_reader={'USD': 1.0, ...} slow_reader={'USD': 1.0, ...} |
| 1206 | +tick 2: ... |
| 1207 | +``` |
| 1208 | + |
| 1209 | +- `bg.write(interval, key=, store=redis_store)` — one writer, runs on a schedule |
| 1210 | +- `bg.read(key, interval=, store=redis_store)` — each reader gets a private local mirror, refreshed independently |
| 1211 | +- Readers **never block** — they return the last known value from their local copy |
| 1212 | + |
| 1213 | +```bash |
| 1214 | +uv run python examples/writer_reader.py |
| 1215 | +``` |
0 commit comments