Skip to content

Commit

Permalink
Enhance Redis cache service with improved error handling, logging, an…
Browse files Browse the repository at this point in the history
…d connection validation; update cache service tests and Justfile
  • Loading branch information
eduzen committed Jan 4, 2025
1 parent 3f37a17 commit c7955d6
Show file tree
Hide file tree
Showing 5 changed files with 66 additions and 49 deletions.
90 changes: 48 additions & 42 deletions django_fast/services/cache/cache_service.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# services/caches.py
import abc
import datetime
import logging
from typing import Any

import redis
from django.core.cache import caches

logger = logging.getLogger(__name__)


class AbstractCacheService(abc.ABC):
"""Interface for cache services, enforcing the methods all backends must implement."""
Expand Down Expand Up @@ -32,56 +36,58 @@ def get_stats(self) -> dict[str, Any]:
class RedisCacheService(AbstractCacheService):
"""Redis-specific implementation."""

def __init__(self, alias: str, redis_connection=None):
def __init__(self, alias: str, redis_connection: redis.Redis):
super().__init__(alias)
if not redis_connection:
raise ValueError("A valid Redis connection must be provided for RedisCacheService.")
self.redis_connection = redis_connection

def ping(self) -> bool:
if self.redis_connection:
try:
return self.redis_connection.ping() is True
except Exception:
return False
else:
# Fallback: do a simple set/get using Django’s cache if supported
try:
self.cache.set("redis_ping_check", "ok", timeout=5)
return self.cache.get("redis_ping_check") == "ok"
except Exception:
return False
"""Check if Redis is alive by pinging the server."""
try:
return self.redis_connection.ping()
except redis.ConnectionError:
logger.error("Error pinging Redis.")
return False
except redis.RedisError:
logger.error("Redis error.")
return False

def clear_cache(self) -> None:
self.cache.clear()
"""Flush the entire Redis cache."""
try:
self.redis_connection.flushdb()
except redis.RedisError as e:
raise RuntimeError(f"Failed to clear Redis cache: {e}")

def get_stats(self) -> dict[str, Any]:
stats = {}
if self.redis_connection:
# Example: gather memory and keyspace info from direct Redis connection
try:
mem_info = self.redis_connection.info("memory")
stats["used_memory_human"] = mem_info.get("used_memory_human", "N/A")
stats["used_memory_peak_human"] = mem_info.get("used_memory_peak_human", "N/A")

keyspace_info = self.redis_connection.info("keyspace")
readable_keyspace = {}
for db, db_stats in keyspace_info.items():
readable_keyspace[db] = db_stats.copy()
if "avg_ttl" in db_stats:
readable_keyspace[db]["avg_ttl"] = self._format_ttl(db_stats["avg_ttl"])

stats["keyspace"] = readable_keyspace
# total keys
stats["dbsize"] = self.redis_connection.dbsize()
except Exception as exc:
stats["error"] = str(exc)
else:
# Fallback using Django’s cache interface (limited: .keys, .ttl)
try:
all_keys = self.cache.keys("*")
stats["keys_count"] = len(all_keys)
stats["sample_ttl"] = {k: self.cache.ttl(k) for k in all_keys[:5]}
except AttributeError:
stats["message"] = "Backend does not support .keys() or .ttl()."
try:
mem_info = self.redis_connection.info("memory")
stats["used_memory_human"] = mem_info.get("used_memory_human", "N/A")
stats["used_memory_peak_human"] = mem_info.get("used_memory_peak_human", "N/A")

keyspace_info = self.redis_connection.info("keyspace")
readable_keyspace = {}
for db, db_stats in keyspace_info.items():
readable_keyspace[db] = db_stats.copy()
if "avg_ttl" in db_stats:
readable_keyspace[db]["avg_ttl"] = self._format_ttl(db_stats["avg_ttl"])

stats["keyspace"] = readable_keyspace
stats["dbsize"] = self.redis_connection.dbsize()

# Additional Stats: Keys Count and Sample TTL
# Caution: The KEYS command can be slow on large databases
all_keys = self.redis_connection.keys("*")
stats["keys_count"] = len(all_keys)
# Fetch TTLs for a sample of keys
sample_keys = all_keys[:5]
stats["sample_ttl"] = {key.decode("utf-8"): self.redis_connection.ttl(key) for key in sample_keys}
except Exception as exc:
logger.error("Error fetching Redis stats.")
stats["error"] = str(exc)

return stats

def _format_ttl(self, ttl_ms: int) -> str:
Expand Down Expand Up @@ -111,7 +117,7 @@ def get_stats(self) -> dict[str, Any]:
# self.cache._cache.get_stats()
# But this is very library-specific.
try:
mem_stats = self.cache._cache.get_stats()
mem_stats = self.cache._cache.get_stats() # type: ignore
# parse results
return {"raw_stats": mem_stats}
except Exception:
Expand Down
18 changes: 14 additions & 4 deletions django_fast/services/cache/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,32 @@ def get_cache_service(alias: str) -> AbstractCacheService:
db=db,
socket_timeout=socket_timeout,
)
except Exception:
logger.exception("Error creating redis client.")
r_client = None
return RedisCacheService(alias, redis_connection=r_client)
# Test the connection
if not r_client.ping():
logger.error(f"Redis server at '{url}' is not responding to PING.")
raise ConnectionError(f"Cannot connect to Redis server at '{url}'.")

return RedisCacheService(alias, redis_connection=r_client)
except Exception as e:
logger.exception(f"Error creating Redis client for alias '{alias}': {e}")
raise RuntimeError(f"Failed to initialize RedisCacheService for alias '{alias}': {e}") from e

elif "MemcachedCache" in backend:
logger.info(f"Creating MemcachedService for alias '{alias}'")
return MemcachedService(alias)

elif "DatabaseCache" in backend:
logger.info(f"Creating DatabaseCacheService for alias '{alias}'")
return DatabaseCacheService(alias)

elif "FileBasedCache" in backend:
logger.info(f"Creating FileBasedCacheService for alias '{alias}'")
return FileBasedCacheService(alias)

elif "DummyCache" in backend:
logger.info(f"Creating DummyCacheService for alias '{alias}'")
return DummyCacheService(alias)

# Fallback if unrecognized
logger.warning(f"Unrecognized cache backend '{backend}' for alias '{alias}'. Falling back to DummyCacheService.")
return DummyCacheService(alias)
3 changes: 2 additions & 1 deletion django_fast/tests/services/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ def test_access_for_staff(self):
def test_access_for_non_staff(self):
self.client.login(username="regular", password="password")
response = self.client.get(self.url)
self.assertEqual(response.status_code, 403) # Forbidden
self.assertEqual(response.status_code, 302) # Redirect to login page
self.assertRedirects(response, f"{reverse('admin:login')}?next={self.url}")

def test_render_cache_settings(self):
self.client.login(username="staff", password="password")
Expand Down
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ shell: copy-env
dockershell: shell

mypy:
{{MANAGE}} mypy .
{{UV}} mypy .

check:
{{MANAGE}} check --deploy
Expand Down
2 changes: 1 addition & 1 deletion website/settings/prod.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
if (not DEBUG) and SENTRY_DSN:
sentry_sdk.init(
dsn=SENTRY_DSN, # type: ignore
release=RELEASE, # type: ignore\
release=RELEASE, # type: ignore
enable_tracing=True,
traces_sample_rate=0.20,
profiles_sample_rate=0.20,
Expand Down

0 comments on commit c7955d6

Please sign in to comment.