Skip to main content

A decorator that adapts cache TTL to how often each call is actually reused.

Project description

AdaptCache

tests License: MIT Python 3.9+

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

adaptcache-0.1.0.tar.gz (17.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

adaptcache-0.1.0-py3-none-any.whl (11.6 kB view details)

Uploaded Python 3

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

Hashes for adaptcache-0.1.0.tar.gz
Algorithm Hash digest
SHA256 6c3ab0ba28a3218616ec6f957f3df28e578c6ffb115065a5ea3d734cb3eb80b2
MD5 76820dbeea7fa01a9916754b912c344d
BLAKE2b-256 ee0e6fbeaca313e383ba333b83f2ff6dc5f5ccb62d08533649cd795d0ae78683

See more details on using hashes here.

Provenance

The following attestation bundles were made for adaptcache-0.1.0.tar.gz:

Publisher: publish.yml on Ryan5342/adaptcache

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for adaptcache-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c72bd88e635c60f3f62aa6edd2427d438f0e4075bfaae334914d36494e17fe0c
MD5 4dd2e4d54ba78c341f2a84f4e2aead55
BLAKE2b-256 58c40bebc7c5a98430409bb0a87e24beea75ae5263503e4fb3eaf5a765747f4c

See more details on using hashes here.

Provenance

The following attestation bundles were made for adaptcache-0.1.0-py3-none-any.whl:

Publisher: publish.yml on Ryan5342/adaptcache

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page