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

@ -18,6 +18,7 @@ import { Button } from './components/ui/button';
import { Filter, Heart } from 'lucide-react';
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
import { refreshListings, streamListingGeoJSON, fetchUserPOIs, fetchBulkPOIDistances, type StreamingProgress } from '@/services';
import { getCached, setCached, invalidateAll as invalidateListingCache } from '@/services/listingCache';
import { setOnUnauthorized } from '@/services/apiClient';
import { clearPasskeyUser } from './auth/passkeyService';
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
@ -131,6 +132,18 @@ function App() {
const loadListings = useCallback(async (parameters: ParameterValues) => {
if (!user) return;
// Check in-memory cache first
const cached = getCached(parameters);
if (cached) {
setQueryParameters(parameters);
setMobileFilterOpen(false);
accumulatedFeaturesRef.current = cached;
setListingData({ type: 'FeatureCollection', features: [...cached] });
setIsLoading(false);
setStreamingProgress(null);
return;
}
// Abort any in-flight streaming request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@ -183,6 +196,8 @@ function App() {
}
// Final flush to ensure all data is rendered
flushUpdate();
// Store successful result in frontend cache
setCached(parameters, accumulatedFeaturesRef.current);
} catch (error) {
// Silently ignore AbortError — it means we intentionally cancelled
if (error instanceof DOMException && error.name === 'AbortError') {
@ -305,6 +320,7 @@ function App() {
}, [user, loadListings]);
const handleTaskCompleted = useCallback(() => {
invalidateListingCache();
if (queryParameters) {
loadListings(queryParameters);
}
@ -326,6 +342,7 @@ function App() {
if (action === 'visualize') {
loadListings(parameters);
} else if (action === 'fetch-data') {
invalidateListingCache();
setQueryParameters(parameters);
setMobileFilterOpen(false);
setIsLoading(true);

View file

@ -0,0 +1,45 @@
/**
* In-memory LRU cache for streaming listing results.
*
* Keyed by a deterministic hash of query parameters so that repeat visits
* to the same filter combination are instant (no network request).
*/
import type { PropertyFeature } from '@/types';
import type { ParameterValues } from '@/components/FilterPanel';
interface CacheEntry {
features: PropertyFeature[];
timestamp: number;
}
const cache = new Map<string, CacheEntry>();
const MAX_ENTRIES = 5;
export function makeCacheKey(params: ParameterValues): string {
const sorted = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v instanceof Date ? v.toISOString() : v}`);
return sorted.join('&');
}
export function getCached(params: ParameterValues): PropertyFeature[] | null {
const key = makeCacheKey(params);
const entry = cache.get(key);
if (!entry) return null;
return entry.features;
}
export function setCached(params: ParameterValues, features: PropertyFeature[]): void {
const key = makeCacheKey(params);
if (cache.size >= MAX_ENTRIES && !cache.has(key)) {
// Evict oldest entry (first inserted)
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.set(key, { features, timestamp: Date.now() });
}
export function invalidateAll(): void {
cache.clear();
}