Perf: 9 Performance-Fixes — SW by-v1072

Backend:
- DB: 3 neue Indizes (forum_posts thread+user, routes user) — Forum/Routen-Queries
- Caching: cache.py (TTL-Cache ohne neue Dependency) für 5 statische Listen
  (training_exercises, pflege_tipps, wiki_stats, wiki_gruppen, help_articles)
- diary.py + breeder_photos.py: Bildverarbeitung (ffmpeg/PIL/EXIF) per
  run_in_executor → blockiert Event-Loop nicht mehr
- scheduler.py: 11 kollidierende Jobs auf 5-Min-Intervalle gestaggert, coalesce=True
- social.py: ORDER BY RANDOM() ohne LIMIT in 2 Stellen gefixt
- alerts.py: Haversine-Loop bekommt SQL-Bounding-Box-Vorfilter

Frontend:
- sw.js: Tile-Cache mit LRU-Eviction (max 500 Einträge)
- admin.js: Event-Listener-Leak — Tab-Klicks per Delegation statt N Listener
- api.js: compressImage() Helper — Client-seitiges Resize auf max 2000px
  (HEIC/Videos/<500KB unverändert), integriert in 8 Upload-Stellen
  (diary, dog-profile×2, walks, poison, lost, health×2)

Bump APP_VER 1071 → 1072 (sw.js, app.js, main.py, index.html)
This commit is contained in:
rene 2026-05-26 06:30:36 +02:00
parent 3abf974d29
commit c03884cb81
23 changed files with 461 additions and 120 deletions

103
backend/cache.py Normal file
View file

@ -0,0 +1,103 @@
"""BAN YARO — In-Memory TTL-Cache für statische DB-Daten.
Hintergrund:
Routes wie /api/training/exercises, /api/help, /api/wiki/stats laden bei
jedem Request statische Daten aus der DB. Das ist verschwendete Energie.
Diese Daten ändern sich nur durch Admin-Aktionen.
Verwendung:
from cache import ttl_cache
@ttl_cache(ttl=3600)
def my_func(arg1, arg2):
...
API:
@ttl_cache(ttl=3600) Decorator cached pro Argumenten-Signatur
my_func.cache_clear() Komplett leeren (z.B. nach Admin-Update)
Hinweis:
Diese Implementierung ist absichtlich klein und ohne externe Dependency
(kein cachetools nötig). Thread-safe via Lock. Reicht für Read-Only-
Listen, die sich selten ändern. Niemals für user-spezifische Daten
verwenden!
"""
from __future__ import annotations
import functools
import threading
import time
from typing import Any, Callable
def ttl_cache(ttl: int = 3600, maxsize: int = 128) -> Callable:
"""Decorator: cached Rückgabewert pro Argumenten-Signatur für `ttl` Sek.
- ttl: Time-to-live in Sekunden (Default: 1 Stunde)
- maxsize: max. Anzahl Einträge im Cache (FIFO-Eviction bei Überlauf)
Die dekorierte Funktion bekommt zusätzlich:
.cache_clear() leert den gesamten Cache
.cache_info() {hits, misses, size, ttl, maxsize}
"""
def decorator(func: Callable) -> Callable:
store: dict[tuple, tuple[float, Any]] = {}
lock = threading.Lock()
stats = {"hits": 0, "misses": 0}
def _make_key(args: tuple, kwargs: dict) -> tuple:
# kwargs als sortiertes Tuple in den Key packen
if kwargs:
return args + tuple(sorted(kwargs.items()))
return args
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = _make_key(args, kwargs)
now = time.monotonic()
with lock:
cached = store.get(key)
if cached is not None:
expires_at, value = cached
if expires_at > now:
stats["hits"] += 1
return value
# abgelaufen → raus
del store[key]
stats["misses"] += 1
# Außerhalb des Locks ausführen (kann DB-Calls machen)
value = func(*args, **kwargs)
with lock:
# FIFO-Eviction, wenn maxsize überschritten
if len(store) >= maxsize:
try:
oldest_key = next(iter(store))
del store[oldest_key]
except StopIteration:
pass
store[key] = (now + ttl, value)
return value
def cache_clear() -> None:
with lock:
store.clear()
stats["hits"] = 0
stats["misses"] = 0
def cache_info() -> dict:
with lock:
return {
"hits": stats["hits"],
"misses": stats["misses"],
"size": len(store),
"ttl": ttl,
"maxsize": maxsize,
}
wrapper.cache_clear = cache_clear # type: ignore[attr-defined]
wrapper.cache_info = cache_info # type: ignore[attr-defined]
return wrapper
return decorator