This commit is contained in:
Mateusz Gruszczyński
2026-03-13 11:03:13 +01:00
commit 35571df778
132 changed files with 11197 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import json
import time
from threading import Lock
from typing import Any
from urllib.parse import urlparse
from flask import current_app
from redis import Redis
from redis.exceptions import RedisError
class RedisService:
_memory_store: dict[str, tuple[float | None, str]] = {}
_lock = Lock()
_failure_logged_at = 0.0
_availability_cache: tuple[bool, float] = (False, 0.0)
@classmethod
def _config(cls, app=None):
if app is not None:
return app.config
return current_app.config
@classmethod
def enabled(cls, app=None) -> bool:
cfg = cls._config(app)
return str(cfg.get('REDIS_URL', 'memory://')).strip().lower().startswith('redis://')
@classmethod
def url(cls, app=None) -> str:
cfg = cls._config(app)
return str(cfg.get('REDIS_URL', 'memory://')).strip() or 'memory://'
@classmethod
def _logger(cls, app=None):
if app is not None:
return app.logger
return current_app.logger
@classmethod
def _log_failure_once(cls, action: str, exc: Exception, app=None) -> None:
now = time.time()
if now - cls._failure_logged_at < 60:
return
cls._failure_logged_at = now
cls._logger(app).warning(
'Redis %s niedostępny, przełączam na cache pamięciowy: %s', action, exc
)
@classmethod
def client(cls, app=None) -> Redis | None:
if not cls.enabled(app):
return None
try:
return Redis.from_url(
cls.url(app),
decode_responses=True,
socket_connect_timeout=1,
socket_timeout=1,
)
except Exception as exc:
cls._log_failure_once('client', exc, app)
return None
@classmethod
def available(cls, app=None) -> bool:
if not cls.enabled(app):
return False
cached_ok, checked_at = cls._availability_cache
if time.time() - checked_at < 15:
return cached_ok
client = cls.client(app)
if client is None:
cls._availability_cache = (False, time.time())
return False
try:
client.ping()
cls._availability_cache = (True, time.time())
return True
except RedisError as exc:
cls._log_failure_once('ping', exc, app)
cls._availability_cache = (False, time.time())
return False
except Exception as exc:
cls._log_failure_once('ping', exc, app)
cls._availability_cache = (False, time.time())
return False
@classmethod
def ping(cls, app=None) -> tuple[str, str]:
url = cls.url(app)
if not cls.enabled(app):
return 'disabled', 'Cache pamięciowy aktywny (Redis wyłączony).'
if cls.available(app):
return 'ok', f'{url} · połączenie aktywne'
parsed = urlparse(url)
hint = ''
if parsed.hostname in {'localhost', '127.0.0.1'}:
hint = ' · w Dockerze użyj nazwy usługi redis zamiast localhost'
return 'fallback', f'{url} · brak połączenia, aktywny fallback pamięciowy{hint}'
@classmethod
def _memory_get(cls, key: str) -> Any | None:
now = time.time()
with cls._lock:
payload = cls._memory_store.get(key)
if not payload:
return None
expires_at, raw = payload
if expires_at is not None and expires_at <= now:
cls._memory_store.pop(key, None)
return None
try:
return json.loads(raw)
except Exception:
return None
@classmethod
def _memory_set(cls, key: str, value: Any, ttl: int = 60) -> bool:
expires_at = None if ttl <= 0 else time.time() + ttl
with cls._lock:
cls._memory_store[key] = (expires_at, json.dumps(value, default=str))
return True
@classmethod
def get_json(cls, key: str, app=None) -> Any | None:
if not cls.enabled(app) or not cls.available(app):
return cls._memory_get(key)
client = cls.client(app)
if client is None:
return cls._memory_get(key)
try:
raw = client.get(key)
if raw:
return json.loads(raw)
return cls._memory_get(key)
except Exception as exc:
cls._log_failure_once(f'get_json({key})', exc, app)
return cls._memory_get(key)
@classmethod
def set_json(cls, key: str, value: Any, ttl: int = 60, app=None) -> bool:
cls._memory_set(key, value, ttl=ttl)
if not cls.enabled(app) or not cls.available(app):
return False
client = cls.client(app)
if client is None:
return False
try:
client.setex(key, ttl, json.dumps(value, default=str))
return True
except Exception as exc:
cls._log_failure_once(f'set_json({key})', exc, app)
return False
@classmethod
def delete(cls, key: str, app=None) -> None:
with cls._lock:
cls._memory_store.pop(key, None)
if not cls.enabled(app) or not cls.available(app):
return
client = cls.client(app)
if client is None:
return
try:
client.delete(key)
except Exception as exc:
cls._log_failure_once(f'delete({key})', exc, app)