perf: bound lake scans and add platform Grafana dashboard
Explorer and exporter were scanning the full Parquet lake every 1–5s (~2000 files, 200MB+), driving ~2.2 CPU cores. Limit metrics to the last N flights, cache DuckDB views and the partition tree, slow live polls to 2s, and keep drones alive after seal to stop restart churn. Add node-exporter, scan-duration metrics, and a Swarm Platform Grafana dashboard for node CPU/memory and scan health.
This commit is contained in:
@@ -1,33 +1,28 @@
|
||||
"""Data-plane explorer: a web view over the Hive-partitioned Parquet lake.
|
||||
|
||||
Serves three things:
|
||||
/ single-page UI (partition tree + read-only SQL console)
|
||||
/api/tree partition hierarchy with file counts and bytes, live
|
||||
/api/query gated read-only DuckDB SQL, same statement rules as the
|
||||
peer query channel (SELECT/WITH only, single statement)
|
||||
|
||||
The point is doctrinal, not just convenient: the explorer reuses the exact
|
||||
read-only SQL contract that drones expose to each other, so "looking at the
|
||||
data plane" on the bench exercises the same path a peer would use in flight.
|
||||
"""
|
||||
"""Data-plane explorer: a web view over the Hive-partitioned Parquet lake."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import duckdb
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "monitoring"))
|
||||
from lake import flight_window, iter_parquet_files, parquet_reader # noqa: E402
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
||||
PORT = int(os.environ.get("EXPLORER_PORT", "8088"))
|
||||
ROW_LIMIT = int(os.environ.get("ROW_LIMIT", "500"))
|
||||
VIEW_REFRESH_S = float(os.environ.get("VIEW_REFRESH_S", "30"))
|
||||
TREE_CACHE_S = float(os.environ.get("TREE_CACHE_S", "15"))
|
||||
STATIC_DIR = Path(__file__).parent
|
||||
|
||||
# Same spirit as the forced-command gate on a real drone: one statement,
|
||||
# must be a read, no statement that could write, configure, or reach out.
|
||||
_ALLOWED_START = re.compile(r"^\s*(SELECT|WITH|DESCRIBE|SUMMARIZE|SHOW)\b", re.IGNORECASE)
|
||||
_FORBIDDEN = re.compile(
|
||||
r"\b(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|ATTACH|DETACH|COPY|EXPORT"
|
||||
@@ -35,9 +30,17 @@ _FORBIDDEN = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_con: duckdb.DuckDBPyConnection | None = None
|
||||
_views_at = 0.0
|
||||
_tree_cache: dict | None = None
|
||||
_tree_at = 0.0
|
||||
_last_tree_s = 0.0
|
||||
_last_query_s = 0.0
|
||||
_query_count = 0
|
||||
|
||||
|
||||
def gate(sql: str) -> str | None:
|
||||
"""Return a rejection reason, or None if the statement passes."""
|
||||
stripped = re.sub(r"--[^\n]*|/\*.*?\*/", " ", sql, flags=re.DOTALL).strip().rstrip(";")
|
||||
if not stripped:
|
||||
return "empty statement"
|
||||
@@ -50,27 +53,39 @@ def gate(sql: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def connect() -> duckdb.DuckDBPyConnection:
|
||||
"""Fresh connection with the three datasets pre-registered as views."""
|
||||
def _refresh_views() -> None:
|
||||
global _con, _views_at
|
||||
con = duckdb.connect()
|
||||
for ds in ("telemetry", "detections", "state"):
|
||||
pattern = f"{DATA_DIR}/dataset={ds}/**/*.parquet"
|
||||
reader = parquet_reader(DATA_DIR, ds)
|
||||
try:
|
||||
con.execute(
|
||||
f"CREATE VIEW {ds} AS SELECT * FROM read_parquet("
|
||||
f"'{pattern}', hive_partitioning=true, union_by_name=true)"
|
||||
)
|
||||
con.execute(f"CREATE OR REPLACE VIEW {ds} AS SELECT * FROM read_parquet({reader})")
|
||||
except duckdb.Error:
|
||||
pass # dataset not written yet; view simply won't exist
|
||||
return con
|
||||
pass
|
||||
with _lock:
|
||||
if _con is not None:
|
||||
_con.close()
|
||||
_con = con
|
||||
_views_at = time.monotonic()
|
||||
|
||||
|
||||
def connect() -> duckdb.DuckDBPyConnection:
|
||||
if _con is None or time.monotonic() - _views_at > VIEW_REFRESH_S:
|
||||
_refresh_views()
|
||||
assert _con is not None
|
||||
return _con
|
||||
|
||||
|
||||
def tree() -> dict:
|
||||
"""Partition hierarchy: dataset -> flight -> drone -> leafs, with sizes."""
|
||||
global _tree_cache, _tree_at, _last_tree_s
|
||||
now = time.monotonic()
|
||||
if _tree_cache is not None and now - _tree_at < TREE_CACHE_S:
|
||||
return _tree_cache
|
||||
started = now
|
||||
root: dict = {}
|
||||
total_bytes = 0
|
||||
total_files = 0
|
||||
for f in sorted(DATA_DIR.rglob("*.parquet")):
|
||||
for f in sorted(iter_parquet_files(DATA_DIR)):
|
||||
rel = f.relative_to(DATA_DIR)
|
||||
size = f.stat().st_size
|
||||
total_bytes += size
|
||||
@@ -80,7 +95,6 @@ def tree() -> dict:
|
||||
node = node.setdefault("children", {}).setdefault(part, {})
|
||||
leaf = node.setdefault("children", {}).setdefault(rel.parts[-1], {})
|
||||
leaf["bytes"] = size
|
||||
# roll sizes up the tree
|
||||
node = root
|
||||
node["bytes"] = node.get("bytes", 0) + size
|
||||
node["files"] = node.get("files", 0) + 1
|
||||
@@ -88,27 +102,50 @@ def tree() -> dict:
|
||||
node = node["children"][part]
|
||||
node["bytes"] = node.get("bytes", 0) + size
|
||||
node["files"] = node.get("files", 0) + 1
|
||||
return {"tree": root, "total_bytes": total_bytes, "total_files": total_files}
|
||||
_last_tree_s = time.monotonic() - started
|
||||
_tree_cache = {"tree": root, "total_bytes": total_bytes, "total_files": total_files}
|
||||
_tree_at = now
|
||||
return _tree_cache
|
||||
|
||||
|
||||
def run_query(sql: str) -> dict:
|
||||
global _last_query_s, _query_count
|
||||
reason = gate(sql)
|
||||
if reason:
|
||||
return {"error": f"rejected by read-only gate: {reason}"}
|
||||
con = connect()
|
||||
try:
|
||||
cur = con.sql(sql)
|
||||
columns = cur.columns
|
||||
rows = cur.fetchmany(ROW_LIMIT)
|
||||
return {
|
||||
"columns": columns,
|
||||
"rows": [[repr(v) if isinstance(v, bytes) else v for v in row] for row in rows],
|
||||
"truncated": len(rows) == ROW_LIMIT,
|
||||
}
|
||||
except duckdb.Error as exc:
|
||||
return {"error": str(exc)}
|
||||
finally:
|
||||
con.close()
|
||||
started = time.monotonic()
|
||||
with _lock:
|
||||
con = connect()
|
||||
try:
|
||||
cur = con.sql(sql)
|
||||
columns = cur.columns
|
||||
rows = cur.fetchmany(ROW_LIMIT)
|
||||
_query_count += 1
|
||||
_last_query_s = time.monotonic() - started
|
||||
return {
|
||||
"columns": columns,
|
||||
"rows": [[repr(v) if isinstance(v, bytes) else v for v in row] for row in rows],
|
||||
"truncated": len(rows) == ROW_LIMIT,
|
||||
}
|
||||
except duckdb.Error as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def metrics_text() -> str:
|
||||
return "\n".join([
|
||||
"# HELP swarm_explorer_query_duration_seconds Wall time of the last SQL query",
|
||||
"# TYPE swarm_explorer_query_duration_seconds gauge",
|
||||
f"swarm_explorer_query_duration_seconds {_last_query_s:.4f}",
|
||||
"# HELP swarm_explorer_tree_duration_seconds Wall time of the last partition tree build",
|
||||
"# TYPE swarm_explorer_tree_duration_seconds gauge",
|
||||
f"swarm_explorer_tree_duration_seconds {_last_tree_s:.4f}",
|
||||
"# HELP swarm_explorer_queries_total Read-only queries served",
|
||||
"# TYPE swarm_explorer_queries_total counter",
|
||||
f"swarm_explorer_queries_total {_query_count}",
|
||||
"# HELP swarm_metric_flight_window Flight partitions in DuckDB views",
|
||||
"# TYPE swarm_metric_flight_window gauge",
|
||||
f"swarm_metric_flight_window {flight_window()}",
|
||||
]) + "\n"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -116,13 +153,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
# Dev CORS: lets the prototype's live mode poll the API from another
|
||||
# origin. Everything behind this is read-only by construction.
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802 — http.server API
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
@@ -132,15 +167,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def _json(self, payload: dict, code: int = 200) -> None:
|
||||
self._send(code, json.dumps(payload, default=str).encode(), "application/json")
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 — http.server API
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path in ("/", "/index.html"):
|
||||
self._send(200, (STATIC_DIR / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif self.path == "/api/tree":
|
||||
self._json(tree())
|
||||
elif self.path == "/metrics":
|
||||
self._send(200, metrics_text().encode(), "text/plain; version=0.0.4")
|
||||
else:
|
||||
self._send(404, b"not found", "text/plain")
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 — http.server API
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if self.path != "/api/query":
|
||||
self._send(404, b"not found", "text/plain")
|
||||
return
|
||||
@@ -156,6 +193,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
|
||||
def _view_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
_refresh_views()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(VIEW_REFRESH_S)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"data-plane explorer on :{PORT}, reading {DATA_DIR}")
|
||||
_refresh_views()
|
||||
threading.Thread(target=_view_loop, daemon=True).start()
|
||||
print(
|
||||
f"data-plane explorer on :{PORT}, views refresh every {VIEW_REFRESH_S}s, "
|
||||
f"last {flight_window()} flights, reading {DATA_DIR}"
|
||||
)
|
||||
ThreadingHTTPServer(("", PORT), Handler).serve_forever()
|
||||
|
||||
Reference in New Issue
Block a user