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

@ -1,4 +1,5 @@
"""FastAPI application for the Real Estate Crawler API."""
import asyncio
from datetime import datetime, timedelta
import json
import logging
@ -40,6 +41,8 @@ from services.listing_cache import (
cache_features_batch_staged,
finalize_cache_population,
delete_staging_key,
is_cache_stale,
acquire_repopulation_lock,
)
from repositories.poi_repository import POIRepository
from repositories.decision_repository import DecisionRepository
@ -278,6 +281,7 @@ async def _stream_from_cache(
limit: int | None,
user_email: str | None = None,
decision_filter: str = "all",
stale: bool = False,
) -> AsyncGenerator[str, None]:
"""Stream GeoJSON features from the Redis cache (cache-hit path)."""
cached_count = get_cached_count(query_parameters)
@ -288,6 +292,7 @@ async def _stream_from_cache(
"batch_size": batch_size,
"total_expected": effective_total,
"cached": True,
"stale": stale,
}) + "\n"
# Resolve decision IDs (deferred to after metadata is sent)
@ -432,6 +437,30 @@ async def _stream_from_db(
delete_staging_key(staging_key)
async def _repopulate_cache_background(query_parameters: QueryParameters) -> None:
"""Repopulate the cache from DB in the background (fire-and-forget)."""
if not acquire_repopulation_lock(query_parameters):
logger.debug("Skipping background repopulation — already in progress")
return
try:
logger.info("Starting background cache repopulation for stale entry")
repository = ListingRepository(engine)
staging_key = begin_cache_population(query_parameters)
try:
for row in repository.stream_listings_optimized(
query_parameters, limit=None, page_size=DEFAULT_BATCH_SIZE
):
feature = convert_row_to_geojson(row, query_parameters.listing_type.value)
cache_features_batch_staged(staging_key, [feature])
finalize_cache_population(staging_key, query_parameters)
logger.info("Background cache repopulation completed")
except Exception:
delete_staging_key(staging_key)
raise
except Exception:
logger.exception("Background cache repopulation failed")
@app.get("/api/listing_geojson/stream")
async def stream_listing_geojson(
user: Annotated[User, Depends(get_current_user)],
@ -470,10 +499,15 @@ async def stream_listing_geojson(
if cached_count is not None and cached_count > 0 and not include_poi_distances:
app_metrics.geojson_cache_operations.add(1, {"result": "hit"})
stale = is_cache_stale(query_parameters)
if stale:
# Fire-and-forget background repopulation
asyncio.create_task(_repopulate_cache_background(query_parameters))
generator = _stream_from_cache(
query_parameters, batch_size, limit,
user_email=user.email,
decision_filter=decision_filter,
stale=stale,
)
else:
app_metrics.geojson_cache_operations.add(1, {"result": "miss"})