# 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