A decorator that adapts cache TTL to how often each call is actually reused.
Project description
AdaptCache
A caching decorator for Python functions that adapts each entry's TTL to how often it's actually reused, instead of one fixed TTL for everything.
Status: v0.1, early and honest about it. This release ships a simple, explainable heuristic (recent access frequency), not a trained ML model. See Roadmap for what's planned vs. what's real today.
Why
A fixed TTL=300 either wastes cache space on data nobody re-requests, or
expires popular data too soon. AdaptCache tracks how often each specific call
is reused and adjusts automatically: frequently-reused results get a
longer TTL (up to a cap you set), rarely-reused ones expire fast.
Install
pip install adaptcache # in-memory backend, zero dependencies
pip install adaptcache[redis] # + Redis backend
(Not published to PyPI yet -- for now, install from source: pip install -e ..
The name adaptcache is confirmed free on PyPI, and the package builds and
passes twine check cleanly, so it's ready whenever that's worth doing.
.github/workflows/publish.yml publishes automatically on a GitHub Release,
using PyPI's Trusted Publishing -- no API token stored anywhere. One-time
setup on PyPI's side: register this repo as a trusted publisher for the
adaptcache project name before the first release.)
Quick start
from adaptcache import AdaptCache
cache = AdaptCache(backend="memory") # or backend="redis", redis_url="redis://localhost:6379"
@cache.intelligent()
def get_user_profile(user_id: int):
return db.query(f"SELECT * FROM users WHERE id={user_id}")
get_user_profile(123) # miss: hits the DB
get_user_profile(123) # hit: served from cache
get_user_profile.invalidate(123) # force a fresh read, e.g. right after an UPDATE
print(cache.stats()) # {'hits': 1, 'misses': 1, 'hit_rate': 0.5, 'tracked_keys': 1}
cache.clear() # wipe everything and reset stats. Raises NotImplementedError
# on the Redis backend rather than silently leaving stale
# data behind -- see invalidate_tag() for a scoped version.
Run python demo.py for a 10-second illustration of how the adaptive TTL
reacts differently to a "hot" key vs. a "cold" key. Run python benchmark.py for real, measured numbers -- see Benchmark.
How the v0.1 heuristic works
For each cached call, AdaptCache keeps the last 20 access timestamps and
computes the average gap between them. TTL scales so that frequently
requested calls (short gap) trend toward max_ttl, and rarely requested
calls (long gap) trend toward min_ttl. It's a few lines of math, not a
model -- see adaptcache/core.py::_adaptive_ttl.
What's cached
Return values must be JSON-serializable: dicts, lists, and primitives. That covers the common case (API/DB-lookup functions). Arbitrary Python objects aren't supported in v0.1.
Tag-based invalidation
Group related cache entries with tags, then clear them all at once --
useful when several cached functions read from the same table:
@cache.intelligent(tags=["users"])
def get_user(user_id: int):
...
cache.invalidate_tag("users") # clears every entry tagged "users"
Tag membership is tracked in Redis (not just in-process), so this is safe
to call from a different worker/process than the one that populated the
cache -- see tests/test_redis_backend.py::test_redis_backend_invalidate_tag.
Automatic invalidation for SQLAlchemy
If you use SQLAlchemy, watch_sqlalchemy() hooks your Session so that
any committed INSERT/UPDATE/DELETE automatically invalidates the matching
tag -- no manual .invalidate() calls needed:
from adaptcache.ext.sqlalchemy import watch_sqlalchemy
watch_sqlalchemy(cache, Session) # Session = your sessionmaker(...) class
@cache.intelligent(tags=["users"])
def get_user(user_id: int):
...
Scope, honestly: this only sees writes made through that Session
class. Raw SQL run outside the ORM, or writes from another service, aren't
detected -- general DB-agnostic auto-invalidation is still on the roadmap,
not implemented today. Requires pip install adaptcache[sqlalchemy].
Benchmark
python benchmark.py replays one identical trace of 350 requests (50 keys,
Zipf-weighted access, simulated 5-15ms DB latency) against three strategies.
Real run, real wall-clock time, seeded for reproducibility:
Strategy DB calls Hit rate Avg ms p95 ms Wall s
--------------------------------------------------------------
No cache 350 0.0 9.89 14.57 8.39
Static TTL=1s 121 0.654 3.45 13.34 6.14
Adaptive TTL 97 0.723 2.73 12.86 5.88
Adaptive made ~20% fewer DB calls than a static 1s TTL on this trace (97 vs. 121), with lower average response latency as a result.
The honest caveat: that gap only shows up because 1s is a conservative static TTL -- the kind picked when a team is nervous about staleness. Re-run the same trace with a generous static TTL (e.g. 3s) and the two strategies tie. There's no headroom left for adaptation to improve on an already-generous fixed value. So the real pitch isn't "always faster than static" -- it's "you don't have to guess the right static TTL per endpoint; it finds a reasonable one automatically, which matters most when you'd otherwise play it safe with a short one."
This is one synthetic pattern, single-process, in-memory backend -- not a claim about Redis under concurrent production load.
Full example: FastAPI + SQLAlchemy
examples/fastapi_app.py is a small, real, runnable service tying
everything together -- a cached read, a write through a watched
SQLAlchemy session, and the auto-invalidation firing with no manual
.invalidate() call:
pip install fastapi uvicorn sqlalchemy
uvicorn examples.fastapi_app:app --reload
curl http://localhost:8000/users # [] -- miss
curl http://localhost:8000/users # [] -- hit
curl -X POST http://localhost:8000/users -H "Content-Type: application/json" -d '{"name": "Ana"}'
curl http://localhost:8000/users # [{"id": 1, "name": "Ana"}] -- miss again, auto-invalidated
Covered by its own end-to-end test in examples/test_fastapi_app.py, and
by CI on every push (.github/workflows/tests.yml, Python 3.9-3.12, with
a real Redis service container).
Thread safety
MemoryBackend is safe under concurrent access in standard CPython,
where the GIL serializes bytecode execution. This was stress-tested, not
assumed: 300 trials of 20-50 threads simultaneously hitting an
already-expired key (checking for a delete-after-delete race), and 50
trials of concurrent calls hammering the same cache key (checking for
lost hit/miss counts) -- zero failures across both. tests/test_concurrency.py
keeps a fast version of both as a permanent regression check.
The honest limit: this safety comes from the GIL, not from explicit
locking in this codebase. It would not hold on a free-threaded
(--disable-gil) CPython build. If you're running on one of those, treat
MemoryBackend as untested there.
RedisBackend's safety is really redis-py's to guarantee (a single
redis.Redis client is safe to share across threads per its own docs);
this project doesn't re-verify that.
Roadmap
- Tag-based invalidation, safe across processes with the Redis backend
- Automatic invalidation for SQLAlchemy sessions
- Automatic invalidation for raw SQL / other ORMs (general case is genuinely harder -- no promises on this one yet)
- A learned model in place of the heuristic, once there's real usage data to justify it
- Go and Node.js SDKs
- A small dashboard for hit-rate / latency stats
Unchecked items don't exist yet -- this README won't claim otherwise.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file adaptcache-0.1.0.tar.gz.
File metadata
- Download URL: adaptcache-0.1.0.tar.gz
- Upload date:
- Size: 17.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c3ab0ba28a3218616ec6f957f3df28e578c6ffb115065a5ea3d734cb3eb80b2
|
|
| MD5 |
76820dbeea7fa01a9916754b912c344d
|
|
| BLAKE2b-256 |
ee0e6fbeaca313e383ba333b83f2ff6dc5f5ccb62d08533649cd795d0ae78683
|
Provenance
The following attestation bundles were made for adaptcache-0.1.0.tar.gz:
Publisher:
publish.yml on Ryan5342/adaptcache
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
adaptcache-0.1.0.tar.gz -
Subject digest:
6c3ab0ba28a3218616ec6f957f3df28e578c6ffb115065a5ea3d734cb3eb80b2 - Sigstore transparency entry: 2170887430
- Sigstore integration time:
-
Permalink:
Ryan5342/adaptcache@25b88f0ab24ca2f1cbceea1479c02a2b54343a0e -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Ryan5342
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@25b88f0ab24ca2f1cbceea1479c02a2b54343a0e -
Trigger Event:
release
-
Statement type:
File details
Details for the file adaptcache-0.1.0-py3-none-any.whl.
File metadata
- Download URL: adaptcache-0.1.0-py3-none-any.whl
- Upload date:
- Size: 11.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c72bd88e635c60f3f62aa6edd2427d438f0e4075bfaae334914d36494e17fe0c
|
|
| MD5 |
4dd2e4d54ba78c341f2a84f4e2aead55
|
|
| BLAKE2b-256 |
58c40bebc7c5a98430409bb0a87e24beea75ae5263503e4fb3eaf5a765747f4c
|
Provenance
The following attestation bundles were made for adaptcache-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on Ryan5342/adaptcache
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
adaptcache-0.1.0-py3-none-any.whl -
Subject digest:
c72bd88e635c60f3f62aa6edd2427d438f0e4075bfaae334914d36494e17fe0c - Sigstore transparency entry: 2170887437
- Sigstore integration time:
-
Permalink:
Ryan5342/adaptcache@25b88f0ab24ca2f1cbceea1479c02a2b54343a0e -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Ryan5342
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@25b88f0ab24ca2f1cbceea1479c02a2b54343a0e -
Trigger Event:
release
-
Statement type: