Anti-regression for the double-cache pattern documented in CLAUDE.md (« Performance Patterns — Double Caching »). Catches the bug class that caused PR #146 (secubox-waf 17 % sustained CPU under SOC polling) before it lands. What's added ============ - scripts/check-dashboard-cache.py — AST-based audit. Walks every packages/secubox-*/api/main.py, flags hot dashboard routes (/stats, /alerts, /summary, /dashboard, /metrics, /overview, /events, /history, /recent, /bans, /sessions, /connections) whose handler body does per-request I/O (open, read_text, read_bytes, subprocess.run, Popen) AND doesn't go through a cache (module-level _cache instance + .get() call, OR asyncio.create_task(refresh_*) at startup). * --check : exit 1 on unjustified violations (CI mode) * --report : human-readable table (default) * --json : machine-readable - .cache-lint-allowlist.toml — TOML allowlist for justified exceptions. Each entry needs a daemon + route + justification (issue link expected). Seeded with the 4 current legacy cases: secubox-waf (covered by PR #146 — will drop the entries when merged), secubox-config-advisor /history (admin drill-in, not polled), secubox-netdiag /connections (real-time TCP state), secubox-tor /summary (small file read, follow-up cleanup). - .github/workflows/dashboard-cache-check.yml — mirror of license-check.yml. Runs the lint + the self-tests on PRs that touch packages/**/api/main.py, the lint script, the allowlist, or the workflow itself. - docs/CACHE-PATTERN.md — short remediation guide. Three accepted shapes (read-through, background-refresh, pure in-memory), code example, allowlist syntax, references to the canonical implementations (secubox-crowdsec for read-through, secubox-system for background-refresh). Tests (14/14 pass) ================== - StatsCache pattern detection (compliant cases) - Non-compliant detection: open, subprocess.run, multiple I/O ops - Pure in-memory handler = compliant - Background-refresh task = compliant - Non-hot route (/health) not audited - @router.get treated like @app.get - Allowlist suppresses by exact (daemon, route) key - Allowlist doesn't cross-pollinate across routes - Missing allowlist file = no error - CLI --check exits 1 on unjustified - CLI --check exits 0 when clean - CLI --json output well-formed - **Real repo audit with seeded allowlist passes --check** (sanity gate: catches regression if anyone removes an allowlist entry without fixing the daemon) Baseline against the real packages/ tree ======================================== 122 daemons audited, 118 compliant, 4 with findings (all allowlisted). When PR #146 merges, the secubox-waf entries can be removed from the allowlist — the next lint run will then prove the fix is wired correctly. Follow-up ========= - Drop secubox-waf allowlist entries when #146 merges. - Per-daemon follow-up issues for the remaining 3 (config-advisor, netdiag, tor) — not blocking, allowlist documents the rationale. - Consider extending HOT_ROUTES to /health / /status once the small daemons that call `systemctl is-active` adopt a 10 s cache. Co-authored-by: CyberMind-FR <gandalf@Gk2.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.3 KiB
Double-Cache Pattern — SecuBox dashboards
Status: enforced by
scripts/check-dashboard-cache.pyvia theDashboard Cache LintGitHub Actions workflow.
TL;DR
If your FastAPI handler in packages/secubox-*/api/main.py matches any
of the hot routes below and reads logs / runs subprocesses / opens
files, it must go through a cache. The lint will block the PR
otherwise.
Hot routes :
/stats /alerts /summary /dashboard /metrics /overview
/events /history /recent /bans /sessions /connections
Why
These endpoints are typically polled by dashboards (SOC, Hub, c3box…)
every 5 s. Without a cache, each call does the full work (log scan,
GeoIP lookup, subprocess) → CPU saturation under polling. See live-bench
on admin.gk2.secubox.in (2026-05-15) where secubox-waf sustained
17 % CPU and 1035 syscalls/sec from a single open SOC tab.
CLAUDE.md says it directly:
Toujours pour les dashboards stats (WAF, CrowdSec, bandwidth, DPI…) Toujours quand l'endpoint lit des logs ou calcule des agrégats Toujours quand la donnée peut être périmée de 60s sans impact utilisateur
How to comply
Three accepted shapes — pick one.
1. Read-through cache (preferred — simple, robust)
Mirror packages/secubox-crowdsec/api/main.py:106-138 (or
packages/secubox-waf/api/main.py after PR #146 merges):
import threading, time
from typing import Any, Dict, Optional
class StatsCache:
def __init__(self, ttl_seconds: int = 30):
self.ttl = ttl_seconds
self._cache: Dict[str, Any] = {}
self._timestamps: Dict[str, float] = {}
self._lock = threading.Lock()
def get(self, key: str) -> Optional[Any]:
with self._lock:
if key in self._cache and time.time() - self._timestamps[key] < self.ttl:
return self._cache[key]
return None
def set(self, key: str, value: Any):
with self._lock:
self._cache[key] = value
self._timestamps[key] = time.time()
def invalidate(self, key: Optional[str] = None):
with self._lock:
if key:
self._cache.pop(key, None); self._timestamps.pop(key, None)
else:
self._cache.clear(); self._timestamps.clear()
stats_cache = StatsCache(ttl_seconds=30)
@app.get("/stats")
async def get_stats():
cached = stats_cache.get("stats")
if cached is not None:
return cached
stats = compute_stats() # the expensive bit
stats_cache.set("stats", stats)
return stats
Invalidate explicitly on mutating endpoints :
@app.post("/ban")
async def ban(...):
do_the_ban()
stats_cache.invalidate()
return {...}
2. Background-refresh task
Spawn a periodic task at startup that pre-warms the cache.
async def refresh_cache():
while True:
try:
_cache.update(await compute_stats())
except Exception as e:
log.error("refresh failed: %s", e)
await asyncio.sleep(60)
@app.on_event("startup")
async def startup():
asyncio.create_task(refresh_cache())
This is more code but lets you keep handler bodies trivial. Used by
secubox-system, secubox-publish, others.
3. Pure in-memory handler
If the handler doesn't open files, run subprocesses, or read logs, the lint won't flag it. Trivial in-memory aggregations from runtime state are fine without a cache.
How to add an exception
Only if you genuinely can't comply right now :
# .cache-lint-allowlist.toml
[[entries]]
daemon = "secubox-myservice"
route = "/stats"
justification = "Reason + issue link. e.g. real-time data, caching would mislead — see #999"
Every entry is implicitly a TODO. Drop the entry when you patch the daemon.
Running locally
python3 scripts/check-dashboard-cache.py --report # full table
python3 scripts/check-dashboard-cache.py --check # CI mode, rc=1 if violations
python3 scripts/check-dashboard-cache.py --json # machine-readable
Tests :
python3 -m pytest scripts/tests/test_check_dashboard_cache.py -v
Reference implementation
- Read-through :
packages/secubox-crowdsec/api/main.py:106-138 - Read-through (after PR #146) :
packages/secubox-waf/api/main.py - Background-refresh :
packages/secubox-system/api/main.py(search forcreate_task)