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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue