perf: bound lake scans and add platform Grafana dashboard
CI & Release / Verify simulator (push) Successful in 11s
CI & Release / Trivy scan (push) Successful in 9s
CI & Release / Semantic Release (push) Successful in 5s

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:
2026-07-08 21:19:53 +01:00
parent bcd956d11a
commit ea062af86a
11 changed files with 478 additions and 94 deletions
+55 -40
View File
@@ -1,13 +1,14 @@
"""Prometheus exporter over the simulator's Parquet output.
Periodically scans DATA_DIR with DuckDB and exposes fleet statistics as
/metrics. Zero dependencies beyond duckdb: the exposition format is plain
text, served with the standard library HTTP server.
/metrics. Scans only the most recent flight partitions by default so CPU
stays bounded as the lake grows across pod restarts.
"""
from __future__ import annotations
import os
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -15,27 +16,29 @@ from pathlib import Path
import duckdb
sys.path.insert(0, str(Path(__file__).resolve().parent))
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("EXPORTER_PORT", "9105"))
SCAN_INTERVAL_S = float(os.environ.get("SCAN_INTERVAL_S", "5"))
SCAN_INTERVAL_S = float(os.environ.get("SCAN_INTERVAL_S", "15"))
_lock = threading.Lock()
_payload = "# swarm exporter starting\n"
_last_scan_s = 0.0
def _q(con: duckdb.DuckDBPyConnection, sql: str) -> list[tuple]:
try:
return con.sql(sql).fetchall()
except duckdb.Error:
return [] # partitions may not exist yet while the swarm warms up
return []
def collect() -> str:
global _last_scan_s
started = time.monotonic()
con = duckdb.connect()
# union_by_name: sensors have different schemas under one dataset glob
glob = lambda ds: ( # noqa: E731
f"'{DATA_DIR}/dataset={ds}/**/*.parquet', hive_partitioning=true, union_by_name=true"
)
lines: list[str] = []
def metric(name: str, help_text: str, mtype: str, rows: list[str]) -> None:
@@ -43,27 +46,38 @@ def collect() -> str:
lines.append(f"# TYPE {name} {mtype}")
lines.extend(rows)
metric(
"swarm_rows_total", "Telemetry rows written per drone and sensor", "gauge",
[f'swarm_rows_total{{drone="{d}",sensor="{s}"}} {n}'
for d, s, n in _q(con, f"SELECT drone, sensor, count(*) FROM read_parquet({glob('telemetry')}) GROUP BY 1,2")],
)
metric(
"swarm_detections_total", "Detection events per drone and class", "gauge",
[f'swarm_detections_total{{drone="{d}",cls="{c}"}} {n}'
for d, c, n in _q(con, f"SELECT drone, cls, count(*) FROM read_parquet({glob('detections')}) GROUP BY 1,2")],
)
metric(
"swarm_state_frames_total", "Pose broadcast frames per drone and direction", "gauge",
[f'swarm_state_frames_total{{drone="{d}",direction="{dr}"}} {n}'
for d, dr, n in _q(con, f"SELECT drone, direction, count(*) FROM read_parquet({glob('state')}) GROUP BY 1,2")],
)
for ds, name in (
("telemetry", "swarm_rows_total"),
("detections", "swarm_detections_total"),
("state", "swarm_state_frames_total"),
):
reader = parquet_reader(DATA_DIR, ds)
if ds == "telemetry":
metric(
name, f"Rows in dataset={ds} (recent {flight_window()} flights)", "gauge",
[f'{name}{{drone="{d}",sensor="{s}"}} {n}'
for d, s, n in _q(con, f"SELECT drone, sensor, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
)
elif ds == "detections":
metric(
name, "Detection events per drone and class", "gauge",
[f'{name}{{drone="{d}",cls="{c}"}} {n}'
for d, c, n in _q(con, f"SELECT drone, cls, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
)
else:
metric(
name, "Pose broadcast frames per drone and direction", "gauge",
[f'{name}{{drone="{d}",direction="{dr}"}} {n}'
for d, dr, n in _q(con, f"SELECT drone, direction, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
)
telem = parquet_reader(DATA_DIR, "telemetry")
metric(
"swarm_battery_pct", "Latest battery level per drone", "gauge",
[f'swarm_battery_pct{{drone="{d}"}} {v}'
for d, v in _q(con, f"""
SELECT drone, arg_max(level_pct, ts_ns)
FROM read_parquet({glob('telemetry')})
FROM read_parquet({telem})
WHERE sensor='battery' GROUP BY drone""")],
)
metric(
@@ -71,26 +85,24 @@ def collect() -> str:
[f'swarm_rssi_dbm{{drone="{d}",peer="{p}"}} {v}'
for d, p, v in _q(con, f"""
SELECT drone, peer_id, arg_max(rssi_dbm, ts_ns)
FROM read_parquet({glob('telemetry')})
WHERE sensor='rssi' GROUP BY drone, peer_id""")],
)
metric(
"swarm_peer_distance_m", "Latest inter-drone distance estimate", "gauge",
[f'swarm_peer_distance_m{{drone="{d}",peer="{p}"}} {v}'
for d, p, v in _q(con, f"""
SELECT drone, peer_id, arg_max(distance_m, ts_ns)
FROM read_parquet({glob('telemetry')})
FROM read_parquet({telem})
WHERE sensor='rssi' GROUP BY drone, peer_id""")],
)
files = list(DATA_DIR.rglob("*.parquet"))
files = iter_parquet_files(DATA_DIR)
metric(
"swarm_parquet_bytes", "Bytes on disk per dataset", "gauge",
"swarm_parquet_bytes", "Bytes on disk per dataset (recent flights)", "gauge",
[f'swarm_parquet_bytes{{dataset="{ds}"}} {sum(f.stat().st_size for f in files if f"dataset={ds}" in str(f))}'
for ds in ("telemetry", "detections", "state")],
)
metric("swarm_parquet_files", "Parquet files on disk", "gauge",
metric("swarm_parquet_files", "Parquet files scanned (recent flights)", "gauge",
[f"swarm_parquet_files {len(files)}"])
metric("swarm_metric_flight_window", "Flight partitions included per dataset", "gauge",
[f"swarm_metric_flight_window {flight_window()}"])
_last_scan_s = time.monotonic() - started
metric("swarm_exporter_scan_duration_seconds", "Wall time of the last metrics scan", "gauge",
[f"swarm_exporter_scan_duration_seconds {_last_scan_s:.4f}"])
con.close()
return "\n".join(lines) + "\n"
@@ -101,11 +113,11 @@ def scanner() -> None:
started = time.monotonic()
try:
payload = collect()
except Exception as exc: # keep serving stale metrics over dying
except Exception as exc:
payload = f"# collect error: {exc}\n"
with _lock:
_payload = payload
time.sleep(max(0.5, SCAN_INTERVAL_S - (time.monotonic() - started)))
time.sleep(max(1.0, SCAN_INTERVAL_S - (time.monotonic() - started)))
class Handler(BaseHTTPRequestHandler):
@@ -123,10 +135,13 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body)
def log_message(self, *_args: object) -> None:
pass # scrapes every few seconds; keep the log quiet
pass
if __name__ == "__main__":
threading.Thread(target=scanner, daemon=True).start()
print(f"swarm exporter on :{PORT}/metrics, scanning {DATA_DIR} every {SCAN_INTERVAL_S}s")
print(
f"swarm exporter on :{PORT}/metrics, scanning last {flight_window()} flights "
f"every {SCAN_INTERVAL_S}s under {DATA_DIR}"
)
ThreadingHTTPServer(("", PORT), Handler).serve_forever()
@@ -0,0 +1,90 @@
{
"uid": "swarm-platform",
"title": "Swarm Platform — CPU & scan health",
"tags": ["swarm", "platform"],
"timezone": "utc",
"schemaVersion": 39,
"version": 1,
"refresh": "10s",
"time": { "from": "now-30m", "to": "now" },
"panels": [
{
"id": 1, "type": "timeseries", "title": "Node CPU %",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[1m])) * 100)",
"legendFormat": "{{instance}}", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] }
},
{
"id": 2, "type": "timeseries", "title": "Node memory used %",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
"legendFormat": "used", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] }
},
{
"id": 3, "type": "timeseries", "title": "Exporter scan duration",
"gridPos": { "h": 7, "w": 8, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "swarm_exporter_scan_duration_seconds",
"legendFormat": "scan seconds", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"id": 4, "type": "timeseries", "title": "Explorer query duration",
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 8 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "swarm_explorer_query_duration_seconds",
"legendFormat": "last query", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"id": 5, "type": "timeseries", "title": "Explorer tree build duration",
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 8 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{
"expr": "swarm_explorer_tree_duration_seconds",
"legendFormat": "tree seconds", "refId": "A"
}],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"id": 6, "type": "stat", "title": "Parquet files scanned",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 15 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{ "expr": "swarm_parquet_files", "instant": true, "refId": "A" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"id": 7, "type": "stat", "title": "Flight window",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 15 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{ "expr": "swarm_metric_flight_window", "instant": true, "refId": "A" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"id": 8, "type": "stat", "title": "Telemetry rows (window)",
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 15 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{ "expr": "sum(swarm_rows_total)", "instant": true, "refId": "A" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"id": 9, "type": "stat", "title": "Min fleet battery %",
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 15 },
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
"targets": [{ "expr": "min(swarm_battery_pct)", "instant": true, "refId": "A" }],
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] }
}
]
}
+43
View File
@@ -0,0 +1,43 @@
"""Scan the Hive-partitioned lake without re-reading every historical flight."""
from __future__ import annotations
import os
from pathlib import Path
def flight_window() -> int:
return max(1, int(os.environ.get("METRIC_FLIGHT_WINDOW", "5")))
def recent_flights(data_dir: Path, dataset: str, limit: int | None = None) -> list[Path]:
"""Most recently modified flight= partitions for a dataset."""
limit = limit or flight_window()
base = data_dir / f"dataset={dataset}"
if not base.is_dir():
return []
flights = [p for p in base.iterdir() if p.is_dir() and p.name.startswith("flight=")]
flights.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return flights[:limit]
def parquet_reader(data_dir: Path, dataset: str, *, limit: int | None = None) -> str:
"""DuckDB read_parquet() source limited to recent flights."""
flights = recent_flights(data_dir, dataset, limit)
if not flights:
path = data_dir / f"dataset={dataset}" / "**" / "*.parquet"
return f"'{path}', hive_partitioning=true, union_by_name=true"
if len(flights) == 1:
return f"'{flights[0]}/**/*.parquet', hive_partitioning=true, union_by_name=true"
inner = ", ".join(f"'{f}/**/*.parquet'" for f in flights)
return f"[{inner}], hive_partitioning=true, union_by_name=true"
def iter_parquet_files(data_dir: Path, *, flight_limit: int | None = None) -> list[Path]:
"""Parquet paths under recent flights only — avoids full-lake rglob."""
limit = flight_limit or flight_window()
out: list[Path] = []
for ds in ("telemetry", "detections", "state"):
for flight in recent_flights(data_dir, ds, limit):
out.extend(flight.rglob("*.parquet"))
return out
+4 -1
View File
@@ -1,7 +1,10 @@
global:
scrape_interval: 5s
scrape_interval: 15s
scrape_configs:
- job_name: swarm
static_configs:
- targets: ["exporter:9105"]
- job_name: explorer
static_configs:
- targets: ["explorer:8088"]