Coverage for packages / core / common / cache.py: 22%
104 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
1"""Simple cache wrapper with Redis primary and in-memory dict fallback.
3Tries Redis first. Falls back to an in-memory dict with TTL if Redis
4is unavailable or disabled. Uses JSON serialization for values.
5"""
7from __future__ import annotations
9import json
10import threading
11import time
12from typing import Any
14from packages.core.common.logging import get_logger
15from packages.core.common.settings import get_settings
17logger = get_logger(__name__)
19# ---------------------------------------------------------------------------
20# In-memory fallback backend
21# ---------------------------------------------------------------------------
23_memory_store: dict[str, tuple[float | None, str]] = (
24 {}
25) # key -> (expires_at, json_value)
26_lock = threading.Lock()
29def _memory_get(key: str) -> Any | None:
30 """Get a value from the in-memory store."""
31 with _lock:
32 entry = _memory_store.get(key)
33 if entry is None:
34 return None
35 expires_at, json_value = entry
36 if expires_at is not None and time.monotonic() > expires_at:
37 del _memory_store[key]
38 return None
39 try:
40 return json.loads(json_value)
41 except json.JSONDecodeError:
42 return None
45def _memory_set(key: str, value: Any, ttl: int | None = None) -> None:
46 """Set a value in the in-memory store with optional TTL (seconds)."""
47 expires_at = (time.monotonic() + ttl) if ttl is not None else None
48 with _lock:
49 _memory_store[key] = (expires_at, json.dumps(value, default=str))
52def _memory_delete(key: str) -> None:
53 """Delete a key from the in-memory store."""
54 with _lock:
55 _memory_store.pop(key, None)
58def _memory_clear() -> None:
59 """Clear all entries from the in-memory store."""
60 with _lock:
61 _memory_store.clear()
64# ---------------------------------------------------------------------------
65# Redis backend (lazy init)
66# ---------------------------------------------------------------------------
68_redis_client: Any = None
69_redis_available: bool | None = None
72def _get_redis_client() -> Any | None:
73 """Return the global Redis client, or None if unavailable/disabled."""
74 global _redis_client, _redis_available
76 if _redis_available is False:
77 return None
79 if _redis_client is not None:
80 return _redis_client
82 settings = get_settings()
83 if not settings.redis.enabled:
84 logger.info("Redis caching is disabled via settings, using in-memory fallback")
85 _redis_available = False
86 return None
88 try:
89 import redis as _redis # type: ignore[import-untyped]
91 _redis_client = _redis.from_url(
92 settings.redis.url,
93 decode_responses=True,
94 socket_connect_timeout=2,
95 socket_timeout=2,
96 )
97 _redis_client.ping()
98 logger.info(
99 "Redis cache backend active",
100 extra={"url": settings.redis.url},
101 )
102 _redis_available = True
103 return _redis_client
104 except Exception as exc:
105 logger.warning(
106 "Redis unavailable, falling back to in-memory cache",
107 extra={"error": str(exc)},
108 )
109 _redis_available = False
110 return None
113# ---------------------------------------------------------------------------
114# Public API
115# ---------------------------------------------------------------------------
117_backend: str | None = None
120def get_cache() -> str:
121 """Return the name of the active cache backend ('redis' or 'memory')."""
122 global _backend
123 if _backend is None:
124 if _get_redis_client() is not None:
125 _backend = "redis"
126 else:
127 _backend = "memory"
128 logger.info("Cache backend resolved", extra={"backend": _backend})
129 return _backend
132def cache_get(key: str) -> Any | None:
133 """Retrieve a value from cache.
135 Args:
136 key: Cache key.
138 Returns:
139 Deserialized value or None if missing / expired.
140 """
141 client = _get_redis_client()
142 if client is not None:
143 try:
144 raw = client.get(key)
145 if raw is None:
146 return None
147 return json.loads(raw)
148 except Exception as exc:
149 logger.warning(
150 "Redis cache_get failed, falling back", extra={"error": str(exc)}
151 )
152 return _memory_get(key)
154 return _memory_get(key)
157def cache_set(key: str, value: Any, ttl: int | None = None) -> None:
158 """Store a value in cache.
160 Args:
161 key: Cache key.
162 value: Value to store (must be JSON-serializable).
163 ttl: Time-to-live in seconds. Uses ``settings.redis.ttl_seconds``
164 when *ttl* is ``None`` and Redis is the active backend.
165 """
166 if ttl is None:
167 ttl = get_settings().redis.ttl_seconds
169 client = _get_redis_client()
170 if client is not None:
171 try:
172 client.setex(key, ttl, json.dumps(value, default=str))
173 return
174 except Exception as exc:
175 logger.warning(
176 "Redis cache_set failed, falling back", extra={"error": str(exc)}
177 )
179 _memory_set(key, value, ttl)
182def cache_delete(key: str) -> None:
183 """Remove a single key from cache."""
184 client = _get_redis_client()
185 if client is not None:
186 try:
187 client.delete(key)
188 return
189 except Exception:
190 pass
191 _memory_delete(key)
194def cache_clear() -> None:
195 """Flush the entire cache (both backends if available)."""
196 client = _get_redis_client()
197 if client is not None:
198 try:
199 client.flushdb()
200 except Exception:
201 pass
202 _memory_clear()