Skip to main content

Multi-Tier Caching with Separate Stores

Not all cached data is the same. User session data changes every few seconds. Reference data like country codes changes once a quarter. Product listings change hourly. Pricing tables change weekly.

Using one MemoryStore for everything means a burst of session traffic can evict reference data that rarely changes but is expensive to recompute. The solution: separate stores with different configurations, each tuned to its data's access pattern.

The three-tier pattern​

A common pattern is hot, warm, and cold stores.

from inhouse import MemoryStore, inhouse_cache

hot_store = MemoryStore(max_size=512, default_ttl=30, sliding=True)
warm_store = MemoryStore(max_size=2048, default_ttl=300)
cold_store = MemoryStore(max_size=4096, default_ttl=3600)
Tiermax_sizedefault_ttlslidingUse case
Hot51230sYesUser sessions, rate limit counters
Warm20485minNoProduct listings, search results
Cold40961hrNoCountry codes, tax tables, config

Assigning functions to tiers​

Each cached function picks the store that matches its data's volatility.

# Hot tier - user session data, frequently accessed, small footprint
@inhouse_cache(store=hot_store, exclude=("request",))
def load_user_session(request, session_id: str) -> dict | None:
...

# Warm tier - catalog data, moderate TTL
@inhouse_cache(store=warm_store, exclude=("db",))
def load_product_list(db, category: str, page: int = 1) -> list[dict]:
...

# Cold tier - reference data, rarely changes, expensive to compute
@inhouse_cache(store=cold_store, exclude=("db",))
def load_tax_rates(db, country_code: str) -> dict:
...

A traffic spike on sessions evicts only other session entries. Product listings and tax rates are untouched.

Monitoring each tier independently​

With separate stores, you see exactly how each tier is performing.

from prometheus_client import Gauge

def report_tier_stats():
for name, store in [("hot", hot_store), ("warm", warm_store), ("cold", cold_store)]:
s = store.stats()
total = s["hits"] + s["misses"]
ratio = s["hits"] / total if total else 0
print(f"{name}: {s['size']} entries, {ratio:.1%} hit ratio, {s['evictions']} evictions")

if s["evictions"] > s["sets"] * 0.1:
print(f" Warning: {name} store eviction rate exceeds 10%")

If the hot store shows high evictions, bump max_size. If the cold store shows a low hit ratio, increase default_ttl.

Dynamic rebalancing​

Adjust store sizes at runtime based on observed traffic. No restart needed.

def rebalance_stores():
hot_stats = hot_store.stats()
cold_stats = cold_store.stats()

hot_eviction_rate = hot_stats["evictions"] / max(hot_stats["sets"], 1)
cold_hit_ratio = cold_stats["hits"] / max(cold_stats["hits"] + cold_stats["misses"], 1)

if hot_eviction_rate > 0.1:
# Grow hot store, shrink cold store
new_hot_size = hot_store.size * 2
print(f"Growing hot store to {new_hot_size} (eviction rate {hot_eviction_rate:.1%})")

if cold_hit_ratio < 0.5 and cold_stats["size"] < cold_store.max_size * 0.3:
# Cold store is underutilized - reduce its max_size
print(f"Cold store is cold ({cold_hit_ratio:.1%} hit ratio) - consider reducing capacity")

Run rebalance_stores() periodically or from a management command.

Shared TTL but isolated capacity​

You can configure two stores with the same TTL but different max_size to isolate traffic.

# Same TTL, different capacity
anon_store = MemoryStore(max_size=256, default_ttl=60)
auth_store = MemoryStore(max_size=2048, default_ttl=60)

@inhouse_cache(store=anon_store)
def load_public_page(path: str) -> str:
...

@inhouse_cache(store=auth_store)
def load_dashboard(user_id: int) -> dict:
...

A viral article driving anonymous traffic evicts only public page cache entries. Authenticated user dashboards are unaffected.

Copy-on-read for sensitive tiers​

For the hot store with frequently mutated data, enable copy_on_read to prevent callers from corrupting cached values.

hot_store = MemoryStore(
max_size=512,
default_ttl=30,
sliding=True,
copy_on_read=True, # prevent mutation of cached session data
)

Each get() returns a deep copy. Callers can modify the returned dict without affecting the cache.

The recipe in practice​

from inhouse import MemoryStore, inhouse_cache
from inhouse.fastapi import create_lifespan
from inhouse.sqlite import query_store, rows_to_dicts

# Three tiers
hot_store = MemoryStore(max_size=512, default_ttl=30, sliding=True)
warm_store = query_store(max_size=2048, default_ttl=300) # copy_on_read=True
cold_store = MemoryStore(max_size=4096, default_ttl=3600)

@inhouse_cache(store=hot_store, exclude=("db",))
def current_session(db, session_token: str) -> dict | None:
...

@inhouse_cache(store=warm_store, exclude=("db",))
def team_dashboard(db, team_id: int, period: str) -> dict:
...

@inhouse_cache(store=cold_store, exclude=("db",))
def compliance_rules(db, jurisdiction: str) -> list[dict]:
...

# Health check
@app.get("/system/cache-tiers")
async def cache_tiers():
return {
"hot": hot_store.stats(),
"warm": warm_store.stats(),
"cold": cold_store.stats(),
}

Each tier is sized and timed for its data. The hot store is small and fast with sliding TTL for sessions. The warm store has copy-on-read for SQLite results. The cold store is large with a long TTL for reference data. Hit ratios and eviction rates are visible per-tier.