Skip to content

Commit e58e313

Browse files
committed
format
1 parent 031b661 commit e58e313

19 files changed

Lines changed: 964 additions & 266 deletions

README.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
A production-ready Python caching library built around two symbols: `cache` and `bg`.
88

9-
It supports **TTL**, **Stale-While-Revalidate**, and **Background Refresh** — all in a single decorator that works transparently with both `def` and `async def`. Backends are pluggable (InMemory, Redis, S3, GCS, LocalFile, ChainCache), serialization is swappable (orjson, msgpack, pickle, protobuf, or custom), and metrics can be exported to Prometheus, OpenTelemetry, or GCP Cloud Monitoring. The hot path is lock-free and hits **~6–9 M ops/s** with zero external dependencies on the default config.
9+
It supports **TTL**, **Stale-While-Revalidate**, and **Background Refresh** — all in a single decorator that works transparently with both `def` and `async def`. Backends are pluggable (InMemory, Redis, S3, GCS, LocalFile, ChainCache), serialization is swappable (orjson, msgpack, pickle, protobuf, or custom), and metrics can be exported to Prometheus, OpenTelemetry, or GCP Cloud Monitoring. The hot path is lock-free and hits **~6–10 M ops/s** with zero external dependencies on the default config.
1010

1111
```
1212
pip install advanced-caching
@@ -486,19 +486,36 @@ def fast_fn(x: int) -> int: ...
486486

487487
## Performance
488488

489-
Measured on Python 3.12, Apple M2, single thread.
489+
Measured on Python 3.12, Apple M2, single thread, N=200,000 iterations.
490+
491+
**Storage & decorator hot paths**
490492

491493
| Operation | Throughput | Latency |
492494
|-----------|-----------|---------|
493-
| `InMemCache.get()` raw | **9.9 M ops/s** | 0.10 µs |
495+
| `InMemCache.get()` raw | **10.3 M ops/s** | 0.10 µs |
496+
| `@cache` sync miss (ttl=0) | **7.3 M ops/s** | 0.14 µs |
497+
| `bg.read()` local hit | **7.5 M ops/s** | 0.13 µs |
494498
| `@cache` sync hit — static key | **6.0 M ops/s** | 0.17 µs |
495499
| `@cache` async hit — static key | **4.9 M ops/s** | 0.20 µs |
496-
| `@cache` sync hit — named key | **1.7 M ops/s** | 0.59 µs |
497-
| `@cache` SWR stale-serve | **2.3 M ops/s** | 0.43 µs |
498-
| `bg.read()` local hit | **9.0 M ops/s** | 0.11 µs |
499-
| `@cache` + InMemoryMetrics | **1.6 M ops/s** | 0.61 µs |
500-
501-
**Key insight:** Named key templates (`"user:{user_id}"`) are ~3.5× slower than static keys (`"feature_flags"`). Use static keys for ultra-hot paths.
500+
| `@cache` SWR stale-serve | **2.9 M ops/s** | 0.35 µs |
501+
| `@cache` ChainCache L1 hit | **2.9 M ops/s** | 0.35 µs |
502+
| `@cache` sync hit — named template key | **1.7 M ops/s** | 0.59 µs |
503+
| `@cache` sync hit + InMemoryMetrics | **1.6 M ops/s** | 0.63 µs |
504+
505+
**Callable key strategies**
506+
507+
| Key type | Throughput | Latency | Notes |
508+
|----------|-----------|---------|-------|
509+
| `key=lambda uid: f"u:{uid}"` | **3.9 M ops/s** | 0.26 µs | Fastest callable — no inspection |
510+
| `key=lambda t, uid: f"{t}:{uid}"` (async) | **2.7 M ops/s** | 0.37 µs | Multi-arg async |
511+
| `key=lambda uid: f"...{md5(uid)}"` | **1.4 M ops/s** | 0.73 µs | Hashing overhead |
512+
| `key="user:{user_id}"` template | **1.7 M ops/s** | 0.59 µs | Signature-bound template |
513+
514+
**Key insights:**
515+
- **Static key** (`"feature_flags"`) is the fastest — no key computation at all (~6 M ops/s)
516+
- **Simple lambda** (`lambda uid: f"u:{uid}"`) is **2.3× faster** than a named template — it skips signature inspection entirely
517+
- **Hashing in the key** (`md5`, `sha256`) adds ~0.5 µs per call — use only when inputs are unbounded strings
518+
- **Metrics** add ~0.4 µs per call; use `NULL_METRICS` (default) on ultra-hot paths
502519

503520
```bash
504521
uv run python tests/benchmark.py

docs/guide.md

Lines changed: 209 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
8. [Production Patterns](#8-production-patterns)
1717
9. [Performance Guide](#9-performance-guide)
1818
10. [Configuration Reference](#10-configuration-reference)
19+
11. [Examples](#11-examples)
1920

2021
---
2122

@@ -590,20 +591,77 @@ async def get_user(uid: int) -> dict: ...
590591
async def load_flags() -> dict: ...
591592

592593
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-
# }
605594
```
606595

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+
607665
### Custom Metrics Collector
608666

609667
Implement the `MetricsCollector` protocol:
@@ -691,11 +749,46 @@ async def get_user(user_id: int) -> dict: ...
691749
@cache(60, key="order:{user_id}:{order_id}")
692750
async def get_order(user_id: int, order_id: int) -> dict: ...
693751

694-
# Callable — full control
752+
# Callable — full Python, no format string limits
695753
@cache(60, key=lambda uid, role: f"user:{role}:{uid}")
696754
async def get_user_by_role(uid: int, role: str) -> dict: ...
697755
```
698756

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+
699792
---
700793

701794
## 8. Production Patterns
@@ -875,10 +968,10 @@ def get_order(order_id: int) -> dict:
875968

876969
```mermaid
877970
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"]
880973
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]
882975
```
883976

884977
### Hot Path Breakdown (`@cache` sync hit, 100k iterations)
@@ -1020,3 +1113,103 @@ flowchart TD
10201113
SAME -- yes --> AUTO["bg.read(key) — auto-discovers store"]
10211114
SAME -- no --> EXPLICIT["bg.read(key, store=redis_store)"]
10221115
```
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

Comments
 (0)