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

@ -8,11 +8,16 @@ import redis
from models.listing import ListingType, QueryParameters
from services.listing_cache import (
CACHE_PREFIX,
CACHE_TTL_SECONDS,
STALE_AFTER_SECONDS,
_get_redis_client,
acquire_repopulation_lock,
cache_features_batch,
get_cache_age,
get_cached_count,
get_cached_features,
invalidate_cache,
is_cache_stale,
make_cache_key,
)
@ -227,3 +232,127 @@ class TestInvalidateCache:
invalidate_cache()
mock_client.pipeline.assert_not_called()
class TestCacheTTLConstants:
"""Tests for cache TTL constants."""
def test_cache_ttl_is_24_hours(self):
"""CACHE_TTL_SECONDS should be 24 hours."""
assert CACHE_TTL_SECONDS == 24 * 60 * 60
def test_stale_after_is_4_hours(self):
"""STALE_AFTER_SECONDS should be 4 hours."""
assert STALE_AFTER_SECONDS == 4 * 60 * 60
def test_stale_after_less_than_ttl(self):
"""Stale threshold must be less than the hard TTL."""
assert STALE_AFTER_SECONDS < CACHE_TTL_SECONDS
class TestGetCacheAge:
"""Tests for get_cache_age()."""
@mock.patch("services.listing_cache._get_redis_client")
def test_returns_none_when_key_missing(self, mock_get_client):
"""Returns None when key does not exist (ttl returns -2)."""
mock_client = mock.MagicMock()
mock_client.ttl.return_value = -2
mock_get_client.return_value = mock_client
result = get_cache_age(_make_query())
assert result is None
@mock.patch("services.listing_cache._get_redis_client")
def test_returns_none_when_no_expiry(self, mock_get_client):
"""Returns None when key has no TTL set (ttl returns -1)."""
mock_client = mock.MagicMock()
mock_client.ttl.return_value = -1
mock_get_client.return_value = mock_client
result = get_cache_age(_make_query())
assert result is None
@mock.patch("services.listing_cache._get_redis_client")
def test_computes_age_from_ttl(self, mock_get_client):
"""Age = CACHE_TTL_SECONDS - remaining TTL."""
mock_client = mock.MagicMock()
remaining = CACHE_TTL_SECONDS - 3600 # 1 hour old
mock_client.ttl.return_value = remaining
mock_get_client.return_value = mock_client
result = get_cache_age(_make_query())
assert result == 3600
@mock.patch("services.listing_cache._get_redis_client")
def test_returns_none_on_redis_error(self, mock_get_client):
"""Returns None when Redis raises an error."""
mock_get_client.side_effect = redis.RedisError("connection refused")
result = get_cache_age(_make_query())
assert result is None
class TestIsCacheStale:
"""Tests for is_cache_stale()."""
@mock.patch("services.listing_cache.get_cache_age")
def test_not_stale_when_young(self, mock_age):
"""Returns False when cache is younger than STALE_AFTER_SECONDS."""
mock_age.return_value = 100 # 100 seconds old
assert is_cache_stale(_make_query()) is False
@mock.patch("services.listing_cache.get_cache_age")
def test_stale_when_old(self, mock_age):
"""Returns True when cache is older than STALE_AFTER_SECONDS."""
mock_age.return_value = STALE_AFTER_SECONDS + 1
assert is_cache_stale(_make_query()) is True
@mock.patch("services.listing_cache.get_cache_age")
def test_not_stale_when_missing(self, mock_age):
"""Returns False when cache does not exist."""
mock_age.return_value = None
assert is_cache_stale(_make_query()) is False
@mock.patch("services.listing_cache.get_cache_age")
def test_not_stale_at_exact_threshold(self, mock_age):
"""Returns False when cache age equals STALE_AFTER_SECONDS exactly."""
mock_age.return_value = STALE_AFTER_SECONDS
assert is_cache_stale(_make_query()) is False
class TestAcquireRepopulationLock:
"""Tests for acquire_repopulation_lock()."""
@mock.patch("services.listing_cache._get_redis_client")
def test_acquires_lock_successfully(self, mock_get_client):
"""Returns True when lock is acquired (SETNX succeeds)."""
mock_client = mock.MagicMock()
mock_client.set.return_value = True
mock_get_client.return_value = mock_client
result = acquire_repopulation_lock(_make_query())
assert result is True
mock_client.set.assert_called_once()
# Verify nx=True and ex=60 were passed
call_kwargs = mock_client.set.call_args[1]
assert call_kwargs["nx"] is True
assert call_kwargs["ex"] == 60
@mock.patch("services.listing_cache._get_redis_client")
def test_returns_false_when_locked(self, mock_get_client):
"""Returns False when lock already held (SETNX returns None)."""
mock_client = mock.MagicMock()
mock_client.set.return_value = None
mock_get_client.return_value = mock_client
result = acquire_repopulation_lock(_make_query())
assert result is False
@mock.patch("services.listing_cache._get_redis_client")
def test_returns_false_on_redis_error(self, mock_get_client):
"""Returns False when Redis raises an error."""
mock_get_client.side_effect = redis.RedisError("connection refused")
result = acquire_repopulation_lock(_make_query())
assert result is False