105 lines
3 KiB
Python
105 lines
3 KiB
Python
# Copyright (c) 2026 Patrick Motsch
|
|
# All rights reserved.
|
|
"""TTL-based in-memory cache for ``serviceRedmineStats`` results.
|
|
|
|
The cache key is ``(featureInstanceId, dateFrom, dateTo, bucket, sorted(trackerIds))``.
|
|
Any write through ``serviceRedmine`` (createIssue, updateIssue, deleteIssue,
|
|
addRelation, deleteRelation) MUST call :func:`invalidateInstance` to drop
|
|
all cached entries for that feature instance.
|
|
|
|
Default TTL: 90 seconds. Override at construction or via ``setTtl``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, Iterable, Optional, Tuple
|
|
|
|
|
|
_DEFAULT_TTL_SECONDS = 90.0
|
|
|
|
|
|
@dataclass
|
|
class _CacheEntry:
|
|
value: Any
|
|
expiresAt: float
|
|
|
|
|
|
CacheKey = Tuple[str, Optional[str], Optional[str], str, Tuple[int, ...]]
|
|
|
|
|
|
class RedmineStatsCache:
|
|
"""Thread-safe TTL cache."""
|
|
|
|
def __init__(self, ttlSeconds: float = _DEFAULT_TTL_SECONDS) -> None:
|
|
self._ttlSeconds = float(ttlSeconds)
|
|
self._store: Dict[CacheKey, _CacheEntry] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def setTtl(self, ttlSeconds: float) -> None:
|
|
self._ttlSeconds = float(ttlSeconds)
|
|
|
|
@staticmethod
|
|
def buildKey(
|
|
featureInstanceId: str,
|
|
dateFrom: Optional[str],
|
|
dateTo: Optional[str],
|
|
bucket: str,
|
|
trackerIds: Iterable[int],
|
|
) -> CacheKey:
|
|
return (
|
|
str(featureInstanceId),
|
|
dateFrom or None,
|
|
dateTo or None,
|
|
(bucket or "week").lower(),
|
|
tuple(sorted(int(t) for t in trackerIds or [])),
|
|
)
|
|
|
|
def get(self, key: CacheKey) -> Optional[Any]:
|
|
now = time.monotonic()
|
|
with self._lock:
|
|
entry = self._store.get(key)
|
|
if not entry:
|
|
return None
|
|
if entry.expiresAt < now:
|
|
self._store.pop(key, None)
|
|
return None
|
|
return entry.value
|
|
|
|
def set(self, key: CacheKey, value: Any, *, ttlSeconds: Optional[float] = None) -> None:
|
|
ttl = float(ttlSeconds) if ttlSeconds is not None else self._ttlSeconds
|
|
with self._lock:
|
|
self._store[key] = _CacheEntry(value=value, expiresAt=time.monotonic() + ttl)
|
|
|
|
def invalidateInstance(self, featureInstanceId: str) -> int:
|
|
"""Drop every entry whose key starts with ``featureInstanceId``.
|
|
|
|
Returns the number of entries dropped.
|
|
"""
|
|
target = str(featureInstanceId)
|
|
with self._lock:
|
|
to_drop = [k for k in self._store.keys() if k[0] == target]
|
|
for k in to_drop:
|
|
self._store.pop(k, None)
|
|
return len(to_drop)
|
|
|
|
def clear(self) -> None:
|
|
with self._lock:
|
|
self._store.clear()
|
|
|
|
def size(self) -> int:
|
|
with self._lock:
|
|
return len(self._store)
|
|
|
|
|
|
_globalCache: Optional[RedmineStatsCache] = None
|
|
|
|
|
|
def _getStatsCache() -> RedmineStatsCache:
|
|
"""Process-wide singleton."""
|
|
global _globalCache
|
|
if _globalCache is None:
|
|
_globalCache = RedmineStatsCache()
|
|
return _globalCache
|