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:
parent
04bda8c127
commit
1ae00b7cbf
5 changed files with 270 additions and 1 deletions
34
api/app.py
34
api/app.py
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue