Add multi-layer caching: 24h Redis TTL, stale-while-revalidate, frontend LRU cache

- Increase Redis cache TTL from 30 minutes to 24 hours
- Add stale-while-revalidate: serve stale cache (>4h) immediately while
  repopulating in background with SETNX lock to prevent concurrent rebuilds
- Add in-memory frontend LRU cache (5 entries) so repeat filter visits
  are instant without network requests
- Invalidate frontend cache on listing refresh and task completion
- Add unit tests for get_cache_age, is_cache_stale, acquire_repopulation_lock
This commit is contained in:
Viktor Barzin 2026-02-23 20:09:36 +00:00
parent 04bda8c127
commit 1ae00b7cbf
No known key found for this signature in database
GPG key ID: 0EB088298288D958
5 changed files with 270 additions and 1 deletions

View file

@ -15,7 +15,9 @@ logger = logging.getLogger(__name__)
CACHE_PREFIX = "listings:geojson:"
STAGING_PREFIX = "listings:geojson:staging:"
CACHE_TTL_SECONDS = 30 * 60 # 30 minutes
CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
STALE_AFTER_SECONDS = 4 * 60 * 60 # 4 hours — serve stale, revalidate in background
REPOPULATING_PREFIX = "listings:geojson:repopulating:"
STAGING_TTL_SECONDS = 5 * 60 # 5 minutes safety net for orphaned staging keys
CACHE_DB = 2
@ -153,3 +155,45 @@ def invalidate_cache() -> None:
logger.info(f"Invalidated {deleted} listing cache entries")
except redis.RedisError as e:
logger.warning(f"Redis cache invalidation error: {e}")
def get_cache_age(query_params: QueryParameters) -> int | None:
"""Return the age in seconds of a cache entry, or None if not cached."""
try:
client = _get_redis_client()
key = make_cache_key(query_params)
ttl = client.ttl(key)
if ttl < 0:
# -2 = key doesn't exist, -1 = no expiry
return None
return CACHE_TTL_SECONDS - ttl
except redis.RedisError as e:
logger.warning(f"Redis cache age check error: {e}")
return None
def is_cache_stale(query_params: QueryParameters) -> bool:
"""Return True if the cache entry exists but is older than STALE_AFTER_SECONDS."""
age = get_cache_age(query_params)
if age is None:
return False
return age > STALE_AFTER_SECONDS
def acquire_repopulation_lock(query_params: QueryParameters) -> bool:
"""Try to acquire a lock to prevent concurrent repopulations.
Returns True if the lock was acquired, False if another repopulation
is already in progress for the same query.
"""
try:
client = _get_redis_client()
key = make_cache_key(query_params)
hash_suffix = key.removeprefix(CACHE_PREFIX)
lock_key = f"{REPOPULATING_PREFIX}{hash_suffix}"
# SETNX with 60-second TTL
acquired: bool = bool(client.set(lock_key, "1", nx=True, ex=60))
return acquired
except redis.RedisError as e:
logger.warning(f"Redis repopulation lock error: {e}")
return False