poc2_worked
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .live import RedisUnavailableError, TrafficHistory
|
||||
from .store import AlertStore
|
||||
|
||||
|
||||
SUMMARY_WINDOWS = (900, 3600, 21600, 86400)
|
||||
|
||||
|
||||
class AnalyticsSnapshotCache:
|
||||
"""Redis-backed dashboard snapshots refreshed only for windows actually in use.
|
||||
|
||||
Older builds rebuilt all four windows every minute, which meant scanning and
|
||||
decoding the complete 24-hour Redis history even when the browser displayed
|
||||
only 15 minutes. This cache keeps persisted snapshots, but refreshes only
|
||||
recently requested windows and uses a slower cadence for wider ranges.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: AlertStore,
|
||||
history: TrafficHistory,
|
||||
stop_event: threading.Event,
|
||||
interval_seconds: int = 60,
|
||||
) -> None:
|
||||
# AlertStore stays in the signature for backwards compatibility with the
|
||||
# application wiring, but traffic analytics are Redis-only.
|
||||
self.store = store
|
||||
self.history = history
|
||||
self.stop_event = stop_event
|
||||
self.interval_seconds = max(15, int(interval_seconds))
|
||||
self._thread = threading.Thread(target=self._run, name="analytics-snapshots", daemon=True)
|
||||
self._wake = threading.Event()
|
||||
self._lock = threading.RLock()
|
||||
self._requested_at: dict[int, float] = {}
|
||||
self._last_refresh: dict[int, float] = {}
|
||||
self._errors = 0
|
||||
self._refreshes = 0
|
||||
# If a browser has not used a window for this long, stop rebuilding it.
|
||||
self._active_ttl_seconds = max(300, self.interval_seconds * 10)
|
||||
|
||||
def start(self) -> None:
|
||||
if not self._thread.is_alive():
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
self._wake.set()
|
||||
if self._thread.is_alive():
|
||||
self._thread.join(timeout=timeout)
|
||||
|
||||
def get(self, window_seconds: int) -> dict[str, Any]:
|
||||
window = self._normalise_window(window_seconds)
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
self._requested_at[window] = now
|
||||
try:
|
||||
cached = self.history.snapshot(window)
|
||||
except RedisUnavailableError:
|
||||
raise
|
||||
|
||||
cadence = self._refresh_interval(window)
|
||||
if cached is not None:
|
||||
cached = self._decorate(cached, "redis-cache")
|
||||
age = float(cached.get("snapshot_age_seconds") or 0)
|
||||
stale = age > cadence * 1.5
|
||||
cached["snapshot_stale"] = stale
|
||||
cached["snapshot_refreshing"] = stale
|
||||
cached["snapshot_refresh_interval_seconds"] = cadence
|
||||
self._overlay_current_throughput(cached)
|
||||
if stale:
|
||||
self._wake.set()
|
||||
return cached
|
||||
|
||||
# First request after an empty Redis volume returns a shell immediately;
|
||||
# only this requested range is built in the background.
|
||||
self._wake.set()
|
||||
now_ms = int(time.time() * 1000)
|
||||
bins_count = 60
|
||||
bin_ms = max(1000, int(window * 1000 / bins_count))
|
||||
return {
|
||||
"window_seconds": window,
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"alerts": 0,
|
||||
"blocked": 0,
|
||||
"timeline": [
|
||||
{
|
||||
"ts_ms": now_ms - window * 1000 + idx * bin_ms,
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"alerts": 0,
|
||||
"bps": 0,
|
||||
"in_bps": 0,
|
||||
"out_bps": 0,
|
||||
"other_bps": 0,
|
||||
"pps": 0,
|
||||
}
|
||||
for idx in range(bins_count)
|
||||
],
|
||||
"snapshot_source": "redis-background",
|
||||
"snapshot_age_seconds": 0,
|
||||
"snapshot_loading": True,
|
||||
"snapshot_refreshing": True,
|
||||
"snapshot_refresh_interval_seconds": cadence,
|
||||
}
|
||||
|
||||
def refresh_all(self) -> None:
|
||||
"""Explicit maintenance/test operation; normal background work is demand-driven."""
|
||||
self.refresh_windows(SUMMARY_WINDOWS)
|
||||
|
||||
def refresh_windows(self, windows: Iterable[int]) -> None:
|
||||
normalized = sorted({self._normalise_window(window) for window in windows})
|
||||
if not normalized:
|
||||
return
|
||||
try:
|
||||
# analytics_many scans only the widest requested window once.
|
||||
snapshots = self.history.analytics_many(normalized)
|
||||
now_wall = time.time()
|
||||
for window in normalized:
|
||||
self.history.save_snapshot(window, snapshots[window])
|
||||
with self._lock:
|
||||
self._last_refresh[window] = now_wall
|
||||
self._refreshes += 1
|
||||
except (RedisUnavailableError, KeyError, ValueError, OSError):
|
||||
with self._lock:
|
||||
self._errors += 1
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._errors += 1
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
persisted = self.history.snapshot_status(SUMMARY_WINDOWS)
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
active = [
|
||||
window for window, requested in self._requested_at.items()
|
||||
if now - requested <= self._active_ttl_seconds
|
||||
]
|
||||
refreshes = self._refreshes
|
||||
errors = self._errors
|
||||
return {
|
||||
"backend": "redis",
|
||||
"interval_seconds": self.interval_seconds,
|
||||
"windows": list(SUMMARY_WINDOWS),
|
||||
"persisted": persisted,
|
||||
"active_windows": sorted(active),
|
||||
"cadence_seconds": {str(window): self._refresh_interval(window) for window in SUMMARY_WINDOWS},
|
||||
"refreshes": refreshes,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
def _run(self) -> None:
|
||||
# Do not scan 24h on process startup. The first browser/API request marks
|
||||
# its selected range active and wakes this worker.
|
||||
while not self.stop_event.is_set():
|
||||
self._wake.wait(timeout=min(5.0, float(self.interval_seconds)))
|
||||
self._wake.clear()
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
due = self._due_windows()
|
||||
if due:
|
||||
self.refresh_windows(due)
|
||||
|
||||
def _due_windows(self) -> list[int]:
|
||||
now_mono = time.monotonic()
|
||||
now_wall = time.time()
|
||||
due: list[int] = []
|
||||
with self._lock:
|
||||
requests = dict(self._requested_at)
|
||||
last_refresh = dict(self._last_refresh)
|
||||
persisted = {
|
||||
int(row["window_seconds"]): row.get("generated_at")
|
||||
for row in self.history.snapshot_status(requests.keys())
|
||||
}
|
||||
for window, requested_at in requests.items():
|
||||
if now_mono - requested_at > self._active_ttl_seconds:
|
||||
continue
|
||||
cadence = self._refresh_interval(window)
|
||||
last = last_refresh.get(window, 0.0)
|
||||
if last <= 0 and window in persisted:
|
||||
last = self._generated_epoch(persisted[window])
|
||||
if last <= 0 or now_wall - last >= cadence:
|
||||
due.append(window)
|
||||
return sorted(due)
|
||||
|
||||
def _refresh_interval(self, window: int) -> int:
|
||||
base = self.interval_seconds
|
||||
if window <= 900:
|
||||
return base
|
||||
if window <= 3600:
|
||||
return max(base * 2, 120)
|
||||
if window <= 21600:
|
||||
return max(base * 5, 300)
|
||||
return max(base * 15, 900)
|
||||
|
||||
def _overlay_current_throughput(self, payload: dict[str, Any]) -> None:
|
||||
"""Keep the 'now' rate fresh without rescanning the selected history window."""
|
||||
try:
|
||||
sample = self.history.latest_throughput()
|
||||
except RedisUnavailableError:
|
||||
return
|
||||
if not sample:
|
||||
return
|
||||
now_ms = int(time.time() * 1000)
|
||||
ts_ms = int(sample.get("ts_ms") or 0)
|
||||
interval = max(float(sample.get("interval_ms") or 1000) / 1000.0, 0.001)
|
||||
age_ms = max(0, now_ms - ts_ms)
|
||||
if age_ms > max(3000, round(interval * 3000)):
|
||||
current = current_in = current_out = current_pps = 0
|
||||
else:
|
||||
current = round(max(int(sample.get("bytes_total") or 0), 0) * 8 / interval)
|
||||
current_in = round(max(int(sample.get("bytes_in") or 0), 0) * 8 / interval)
|
||||
current_out = round(max(int(sample.get("bytes_out") or 0), 0) * 8 / interval)
|
||||
current_pps = round(max(int(sample.get("packets_total") or 0), 0) / interval, 2)
|
||||
payload["current_bps"] = current
|
||||
payload["current_in_bps"] = current_in
|
||||
payload["current_out_bps"] = current_out
|
||||
payload["current_other_bps"] = max(0, current - current_in - current_out)
|
||||
payload["current_pps"] = current_pps
|
||||
|
||||
@staticmethod
|
||||
def _generated_epoch(value: Any) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.timestamp()
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _normalise_window(value: int) -> int:
|
||||
value = int(value)
|
||||
if value in SUMMARY_WINDOWS:
|
||||
return value
|
||||
return min(SUMMARY_WINDOWS, key=lambda item: abs(item - value))
|
||||
|
||||
@staticmethod
|
||||
def _decorate(payload: dict[str, Any], source: str) -> dict[str, Any]:
|
||||
result = dict(payload)
|
||||
generated = result.get("generated_at")
|
||||
age = 0.0
|
||||
if generated:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(generated).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
age = max(0.0, (datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds())
|
||||
except ValueError:
|
||||
age = 0.0
|
||||
result["snapshot_source"] = source
|
||||
result["snapshot_age_seconds"] = round(age, 1)
|
||||
return result
|
||||
Reference in New Issue
Block a user