170 lines
5.4 KiB
Python
170 lines
5.4 KiB
Python
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) |