Initial commit
Dieser Commit ist enthalten in:
330
infrastructure/services/fingerprint_cache_service.py
Normale Datei
330
infrastructure/services/fingerprint_cache_service.py
Normale Datei
@ -0,0 +1,330 @@
|
||||
"""
|
||||
Fingerprint Cache Service - Thread-safe caching for race condition prevention
|
||||
Non-intrusive caching layer that enhances existing fingerprint logic
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from typing import Optional, Dict, Any, Callable, TypeVar, Generic
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from domain.entities.browser_fingerprint import BrowserFingerprint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class ThreadSafeCache(Generic[T]):
|
||||
"""
|
||||
Thread-sichere Cache-Implementierung mit TTL und LRU-Features
|
||||
"""
|
||||
|
||||
def __init__(self, max_size: int = 1000, default_ttl: timedelta = timedelta(hours=1)):
|
||||
self.max_size = max_size
|
||||
self.default_ttl = default_ttl
|
||||
self._cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._access_times: Dict[str, datetime] = {}
|
||||
self._locks: Dict[str, threading.RLock] = {}
|
||||
self._main_lock = threading.RLock()
|
||||
|
||||
# Weak references für automatische Cleanup
|
||||
self._cleanup_callbacks: Dict[str, Callable] = {}
|
||||
|
||||
def get(self, key: str) -> Optional[T]:
|
||||
"""Holt einen Wert aus dem Cache"""
|
||||
with self._main_lock:
|
||||
if key not in self._cache:
|
||||
return None
|
||||
|
||||
cache_entry = self._cache[key]
|
||||
|
||||
# TTL prüfen
|
||||
if self._is_expired(cache_entry):
|
||||
self._remove_key(key)
|
||||
return None
|
||||
|
||||
# Access time aktualisieren für LRU
|
||||
self._access_times[key] = datetime.now()
|
||||
return cache_entry['value']
|
||||
|
||||
def put(self, key: str, value: T, ttl: Optional[timedelta] = None) -> None:
|
||||
"""Speichert einen Wert im Cache"""
|
||||
with self._main_lock:
|
||||
# Cache-Größe prüfen und ggf. LRU entfernen
|
||||
if len(self._cache) >= self.max_size and key not in self._cache:
|
||||
self._evict_lru()
|
||||
|
||||
expiry = datetime.now() + (ttl or self.default_ttl)
|
||||
|
||||
self._cache[key] = {
|
||||
'value': value,
|
||||
'created_at': datetime.now(),
|
||||
'expires_at': expiry
|
||||
}
|
||||
self._access_times[key] = datetime.now()
|
||||
|
||||
def get_or_compute(self, key: str, compute_func: Callable[[], T],
|
||||
ttl: Optional[timedelta] = None) -> T:
|
||||
"""
|
||||
Holt Wert aus Cache oder berechnet ihn thread-safe
|
||||
"""
|
||||
# Fast path - Cache hit ohne Locks
|
||||
cached_value = self.get(key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# Slow path - mit per-key Lock
|
||||
with self._main_lock:
|
||||
# Per-key Lock erstellen falls nicht vorhanden
|
||||
if key not in self._locks:
|
||||
self._locks[key] = threading.RLock()
|
||||
key_lock = self._locks[key]
|
||||
|
||||
# Außerhalb des Main-Locks arbeiten für bessere Parallelität
|
||||
with key_lock:
|
||||
# Double-checked locking - könnte zwischenzeitlich gesetzt worden sein
|
||||
cached_value = self.get(key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# Wert berechnen
|
||||
logger.debug(f"Computing value for cache key: {key}")
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
computed_value = compute_func()
|
||||
computation_time = time.time() - start_time
|
||||
|
||||
# Nur cachen wenn Berechnung erfolgreich
|
||||
if computed_value is not None:
|
||||
self.put(key, computed_value, ttl)
|
||||
logger.debug(f"Cached value for key {key} (computation took {computation_time:.3f}s)")
|
||||
|
||||
return computed_value
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compute value for cache key {key}: {e}")
|
||||
raise
|
||||
|
||||
def invalidate(self, key: str) -> bool:
|
||||
"""Entfernt einen Schlüssel aus dem Cache"""
|
||||
with self._main_lock:
|
||||
if key in self._cache:
|
||||
self._remove_key(key)
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Leert den gesamten Cache"""
|
||||
with self._main_lock:
|
||||
self._cache.clear()
|
||||
self._access_times.clear()
|
||||
self._locks.clear()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Gibt Cache-Statistiken zurück"""
|
||||
with self._main_lock:
|
||||
total_entries = len(self._cache)
|
||||
expired_entries = sum(1 for entry in self._cache.values() if self._is_expired(entry))
|
||||
|
||||
return {
|
||||
'total_entries': total_entries,
|
||||
'active_entries': total_entries - expired_entries,
|
||||
'expired_entries': expired_entries,
|
||||
'max_size': self.max_size,
|
||||
'active_locks': len(self._locks),
|
||||
'cache_keys': list(self._cache.keys())
|
||||
}
|
||||
|
||||
def _is_expired(self, cache_entry: Dict[str, Any]) -> bool:
|
||||
"""Prüft ob ein Cache-Eintrag abgelaufen ist"""
|
||||
return datetime.now() > cache_entry['expires_at']
|
||||
|
||||
def _evict_lru(self) -> None:
|
||||
"""Entfernt den am längsten nicht verwendeten Eintrag"""
|
||||
if not self._access_times:
|
||||
return
|
||||
|
||||
lru_key = min(self._access_times.keys(), key=lambda k: self._access_times[k])
|
||||
self._remove_key(lru_key)
|
||||
logger.debug(f"Evicted LRU cache entry: {lru_key}")
|
||||
|
||||
def _remove_key(self, key: str) -> None:
|
||||
"""Entfernt einen Schlüssel und alle zugehörigen Daten"""
|
||||
self._cache.pop(key, None)
|
||||
self._access_times.pop(key, None)
|
||||
|
||||
# Lock nicht sofort entfernen - könnte noch in Verwendung sein
|
||||
# Wird durch schwache Referenzen automatisch bereinigt
|
||||
|
||||
|
||||
class FingerprintCache:
|
||||
"""
|
||||
Spezialisierter Cache für Browser-Fingerprints mit Account-Binding
|
||||
"""
|
||||
|
||||
def __init__(self, max_size: int = 500, fingerprint_ttl: timedelta = timedelta(hours=24)):
|
||||
self.cache = ThreadSafeCache[BrowserFingerprint](max_size, fingerprint_ttl)
|
||||
self.generation_stats = {
|
||||
'cache_hits': 0,
|
||||
'cache_misses': 0,
|
||||
'generations': 0,
|
||||
'race_conditions_prevented': 0
|
||||
}
|
||||
self._stats_lock = threading.RLock()
|
||||
|
||||
def get_account_fingerprint(self, account_id: str,
|
||||
generator_func: Callable[[], BrowserFingerprint],
|
||||
ttl: Optional[timedelta] = None) -> BrowserFingerprint:
|
||||
"""
|
||||
Holt oder erstellt Account-gebundenen Fingerprint thread-safe
|
||||
"""
|
||||
cache_key = f"account_{account_id}"
|
||||
|
||||
def generate_and_track():
|
||||
with self._stats_lock:
|
||||
self.generation_stats['generations'] += 1
|
||||
self.generation_stats['cache_misses'] += 1
|
||||
|
||||
logger.info(f"Generating new fingerprint for account {account_id}")
|
||||
return generator_func()
|
||||
|
||||
# Cache-Zugriff mit Statistik-Tracking
|
||||
fingerprint = self.cache.get(cache_key)
|
||||
if fingerprint is not None:
|
||||
with self._stats_lock:
|
||||
self.generation_stats['cache_hits'] += 1
|
||||
logger.debug(f"Cache hit for account fingerprint: {account_id}")
|
||||
return fingerprint
|
||||
|
||||
# Thread-safe generation
|
||||
return self.cache.get_or_compute(cache_key, generate_and_track, ttl)
|
||||
|
||||
def get_anonymous_fingerprint(self, session_id: str,
|
||||
generator_func: Callable[[], BrowserFingerprint],
|
||||
ttl: Optional[timedelta] = None) -> BrowserFingerprint:
|
||||
"""
|
||||
Holt oder erstellt Session-gebundenen anonymen Fingerprint
|
||||
"""
|
||||
cache_key = f"session_{session_id}"
|
||||
return self.cache.get_or_compute(cache_key, generator_func, ttl)
|
||||
|
||||
def get_platform_fingerprint(self, platform: str, profile_type: str,
|
||||
generator_func: Callable[[], BrowserFingerprint],
|
||||
ttl: Optional[timedelta] = None) -> BrowserFingerprint:
|
||||
"""
|
||||
Holt oder erstellt platform-spezifischen Fingerprint
|
||||
"""
|
||||
cache_key = f"platform_{platform}_{profile_type}"
|
||||
return self.cache.get_or_compute(cache_key, generator_func, ttl)
|
||||
|
||||
def invalidate_account_fingerprint(self, account_id: str) -> bool:
|
||||
"""Invalidiert Account-Fingerprint im Cache"""
|
||||
return self.cache.invalidate(f"account_{account_id}")
|
||||
|
||||
def invalidate_session_fingerprint(self, session_id: str) -> bool:
|
||||
"""Invalidiert Session-Fingerprint im Cache"""
|
||||
return self.cache.invalidate(f"session_{session_id}")
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Gibt detaillierte Cache-Statistiken zurück"""
|
||||
with self._stats_lock:
|
||||
stats = self.generation_stats.copy()
|
||||
|
||||
cache_stats = self.cache.get_stats()
|
||||
|
||||
# Hit rate berechnen
|
||||
total_requests = stats['cache_hits'] + stats['cache_misses']
|
||||
hit_rate = stats['cache_hits'] / total_requests if total_requests > 0 else 0
|
||||
|
||||
return {
|
||||
**stats,
|
||||
**cache_stats,
|
||||
'hit_rate': hit_rate,
|
||||
'total_requests': total_requests
|
||||
}
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""Manuelle Bereinigung abgelaufener Einträge"""
|
||||
initial_size = len(self.cache._cache)
|
||||
|
||||
with self.cache._main_lock:
|
||||
expired_keys = [
|
||||
key for key, entry in self.cache._cache.items()
|
||||
if self.cache._is_expired(entry)
|
||||
]
|
||||
|
||||
for key in expired_keys:
|
||||
self.cache._remove_key(key)
|
||||
|
||||
removed_count = len(expired_keys)
|
||||
if removed_count > 0:
|
||||
logger.info(f"Cleaned up {removed_count} expired fingerprint cache entries")
|
||||
|
||||
return removed_count
|
||||
|
||||
|
||||
# Global Cache Instance - Singleton Pattern
|
||||
_global_fingerprint_cache: Optional[FingerprintCache] = None
|
||||
_cache_init_lock = threading.RLock()
|
||||
|
||||
|
||||
def get_fingerprint_cache() -> FingerprintCache:
|
||||
"""
|
||||
Holt die globale Fingerprint-Cache-Instanz (Singleton)
|
||||
"""
|
||||
global _global_fingerprint_cache
|
||||
|
||||
if _global_fingerprint_cache is None:
|
||||
with _cache_init_lock:
|
||||
if _global_fingerprint_cache is None:
|
||||
_global_fingerprint_cache = FingerprintCache()
|
||||
logger.info("Initialized global fingerprint cache")
|
||||
|
||||
return _global_fingerprint_cache
|
||||
|
||||
|
||||
def reset_fingerprint_cache() -> None:
|
||||
"""
|
||||
Setzt den globalen Cache zurück (für Tests)
|
||||
"""
|
||||
global _global_fingerprint_cache
|
||||
with _cache_init_lock:
|
||||
if _global_fingerprint_cache is not None:
|
||||
_global_fingerprint_cache.cache.clear()
|
||||
_global_fingerprint_cache = None
|
||||
logger.info("Reset global fingerprint cache")
|
||||
|
||||
|
||||
class CachedFingerprintMixin:
|
||||
"""
|
||||
Mixin-Klasse die zu bestehenden Fingerprint-Services hinzugefügt werden kann
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._cache = get_fingerprint_cache()
|
||||
|
||||
def get_cached_account_fingerprint(self, account_id: str) -> Optional[BrowserFingerprint]:
|
||||
"""Holt Account-Fingerprint aus Cache ohne Generation"""
|
||||
return self._cache.cache.get(f"account_{account_id}")
|
||||
|
||||
def create_cached_account_fingerprint(self, account_id: str) -> BrowserFingerprint:
|
||||
"""
|
||||
Erstellt Account-Fingerprint mit Cache-Integration
|
||||
Muss von Subklassen implementiert werden
|
||||
"""
|
||||
def generator():
|
||||
# Delegiert an Original-Implementierung
|
||||
if hasattr(super(), 'create_account_fingerprint'):
|
||||
return super().create_account_fingerprint(account_id)
|
||||
else:
|
||||
raise NotImplementedError("Subclass must implement create_account_fingerprint")
|
||||
|
||||
return self._cache.get_account_fingerprint(account_id, generator)
|
||||
|
||||
def get_cache_statistics(self) -> Dict[str, Any]:
|
||||
"""Gibt Cache-Statistiken zurück"""
|
||||
return self._cache.get_cache_stats()
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren