phase: 14_store_performance

**Phase 14 `store_performance` — final verification pass (all 4 tasks already in `complete/`)**

- Re-verified every task's work in the tree: read caches (`series_names`/`stats`), capped compound `pick_res_many`, `history_many` + `api._history_batch`, windowed `maintain()` + per-resolution prune + `PRAGMA optimize`/`automatic_analyze=0`/WAL pragmas, `store_perf.BUDGETS` (14 ceilings), probe, live watch.
- **Completion criteria:** baseline+after side by side in `data/logs/NOTES-store-perf.md` ✅ · budget tests green (`uv run pytest -m store_perf --store-perf` → **4 passed**, 14/14 inside) ✅ · 25-min dev-server watch re-run **independently** (74 passes, `scripts/watch-store-perf.py`): **0 `store-error`** (total still 190, newest 2026‑10‑18 06:33:30), WAL 7.77 MB ≤ 67.1 MB, medians 1.7–3.2 ms, p95 720/613 ms = the documented cold-cache window; a 3rd 27-pass watch on a freshly restarted server reproduced it (3.1/708, 1.8/572) ✅ · `maintain()` 5,368 ms → 2.7–6.8 ms and WAL bounded across two cycles (test + notes) ✅ · no behaviour change (all four history routes pinned against `query()`) ✅
- **Full gates:** `uv run pytest` → **1267 passed, 15 skipped**; `bash .agents/validate.sh` → **exit 0**, coverage **98.75%** (floor 94), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` ok, repo hygiene ok; `uv run pytest -m live --live` → **11 passed**; dev server up, 9/9 sources `ok`, pages 200 in 1.5–3.3 ms.
- **Docs fixed:** README/AGENTS still claimed a flat "20 s per pass" while `host_agent.py` (in this same uncommitted change) scales the budget to 5 s × disk count — corrected in both plus the two illustrative "20 s of wedged disk" sentences; broken bold (`` `driveripper** ``) repaired; stale gap-4 title + "tuning is its own phase" wording updated to say phase 14 ran; README's absolute "nothing … is answered by a whole-table scan" qualified to *per call* with the cold-window cost named; "third opt-in marker" → second; conftest `--store-perf` help named the real marker; **`data/logs/watch-store-perf.py` moved to `scripts/watch-store-perf.py`** (both docs told a clone to run a gitignored path) with root-anchored paths, `DASH_WATCH_OUT`, and dead code removed.
- **New finding recorded:** gap 9's "~10 s process-wide stall is still gap 4's / `14_store_performance`'s problem" is now closed — coincident >8 s `llm` + >12 s `ups` raw-stream gaps went 393 (00:00–12:00) and 198 (12:00–21:36) to **0** across 110+ min of the phase-14 store; written into AGENTS.md gaps 4 and 9 and the notes table.
- **Deviations:** did *not* pull either deferred cost knob (`STATS_CACHE_TTL_SECONDS`, warming caches from the maintenance tick) — AGENTS.md records that decision and its reasoning; `uv pip list --outdated` still reports certifi/fastapi/starlette/websockets (pre-existing advisory, no dependency touched this phase, gate treats it as advisory). Overwrote nothing: task 04's watch preserved as `store-perf-watch.task04.*`, my two runs as `.verify25.*` / `.current-code.*`.
- **Next pending phase:** `15_agent_mtls`, then `16_agent_smart_pass_budget` (owns the scaled-budget tests + its doc criterion, now partly pre-satisfied by this pass), `17_installer_device_filter`.
This commit is contained in:
2026-10-19 23:44:35 -04:00
parent 1bbd868401
commit 569ca25fdb
49 changed files with 5072 additions and 124 deletions
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Measure the store's hot paths against a production-shaped SQLite file.
Phase 14 exists because `TODO.md:7` says "pages take forever to load and seem to lock up the
database". Task 01's job is the **before**: one run of this script is the baseline in
`data/logs/NOTES-store-perf.md`, and task 04 runs it again as the "after". Nothing here changes
behaviour — it reads, times, `EXPLAIN`s, and cleans up after itself.
uv run python scripts/store_perf_probe.py # baseline (builds the store once)
uv run python scripts/store_perf_probe.py --rebuild # force a fresh seed
uv run python scripts/store_perf_probe.py --dir /tmp/perf # measure on tmpfs (RAM: faster)
uv run python scripts/store_perf_probe.py --out data/logs/store-perf-after
It writes two files beside the numbers it prints: `<out>.json` (everything, machine-readable)
and `<out>.md` (the same tables as markdown, to paste into the NOTES). The seeded store itself
lives in `data/perf/` (gitignored, like the dev DB) and is reused while it is fresh — the same
physical file has to be the one timed before and after the fixes, or the two numbers describe
two databases.
The shape, the timings and the machine specs all come from `tests/store_perf.py`, which is also
what the pytest budget tests of this phase use: one definition of "production-shaped", because
two of those drift.
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
for entry in (ROOT / "tests", ROOT / "src"):
# `tests/store_perf.py` is not a package (pytest imports it the same way via conftest), and
# a bare checkout has no installed `dashboards` either. Both paths, or nothing works.
if str(entry) not in sys.path:
sys.path.insert(0, str(entry))
import store_perf # noqa: E402 - after the sys.path fixup above, by design
def log(message: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--dir", default=None,
help="where the seeded store lives (default: $DASH_PERF_STORE_DIR "
"or data/perf — a real block device, unlike pytest's tmpfs)")
parser.add_argument("--rebuild", action="store_true",
help="throw the cached store away and seed a new one")
parser.add_argument("--out", default=str(ROOT / "data" / "logs" / "store-perf-baseline"),
help="prefix for the report files (default: data/logs/store-perf-baseline)")
parser.add_argument("--race-iterations", type=int, default=100,
help="how many 30-sample inserts to race against stats()/maintain() "
"(task 01 says ~100)")
parser.add_argument("--race-seconds", type=float, default=store_perf.MAX_RACE_SECONDS,
help=f"wall-clock budget per race pass (default "
f"{store_perf.MAX_RACE_SECONDS:.0f} s — one stats() pass costs "
"seconds on this store, so the pass is bounded by time as well as "
"by the insert count)")
parser.add_argument("--dev-db", default=None,
help="also time the three hot reads against this *real* database file, "
"opened read-only (e.g. data/dashboards.sqlite3). Nothing here "
"writes to it and no test ever passes this flag — the hermetic "
"suite never opens the operator's store.")
parser.add_argument("--no-http", action="store_true",
help="skip the TestClient pass (/api/health, /api/metrics, "
"/api/system/history) — store-only numbers")
args = parser.parse_args(argv)
path = store_perf.ensure_seeded(args.dir, force=args.rebuild, log=log)
store = store_perf.open_store(path)
try:
report = store_perf.measure(store, race_iterations=args.race_iterations,
race_max_seconds=args.race_seconds,
with_http=not args.no_http, log=log)
finally:
store.close()
if args.dev_db:
log(f"read-only pass over the real store: {args.dev_db}")
report["dev_db"] = store_perf.measure_dev_db(args.dev_db)
written = store_perf.write_report(report, Path(args.out))
print()
print(store_perf.render_markdown(report))
print()
print(f"report: {written['md']}\n {written['json']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Phase 14 task 04: watch the running dev server for `database is locked`.
Sampled, not exhaustive: every ~20 s a signed-in pass hits the paths a page actually loads
(``/api/health``, ``/api/summary``, ``/api/metrics``, a 3-host x 9-metric 90-day
``/api/system/history``) and records the wall time beside the store's own evidence — the
``store-error`` rows in the ``event`` table, the WAL file size, the database size.
Run it in the background while the dev server keeps polling the fleet:
set -a; . ./.env.dev; set +a
setsid nohup uv run python scripts/watch-store-perf.py > /tmp/watch.out 2>&1 < /dev/null &
Writes ``data/logs/store-perf-watch.log`` (human), ``store-perf-watch.tsv`` (machine) and
``store-perf-watch.json`` (the summary every path ran against). ``data/`` is gitignored, so those
are local evidence for ``data/logs/NOTES-store-perf.md``, never tracked artifacts.
"""
from __future__ import annotations
import json
import os
import sqlite3
import statistics
import sys
import time
from pathlib import Path
import httpx
#: Repo root, so the paths below mean the same tree wherever the watch is invoked from
#: (the same anchor ``scripts/store_perf_probe.py`` uses).
ROOT = Path(__file__).resolve().parents[1]
BASE_URL = os.environ.get("DASH_WATCH_URL", "http://127.0.0.1:8090")
ADMIN_PASSWORD = os.environ.get("DASH_ADMIN_PASSWORD", "")
DB_PATH = Path(os.environ.get("DASH_WATCH_DB", ROOT / "data" / "dashboards.sqlite3"))
OUT = Path(os.environ.get("DASH_WATCH_OUT", ROOT / "data" / "logs"))
DURATION_SECONDS = float(os.environ.get("DASH_WATCH_SECONDS", "1500"))
INTERVAL_SECONDS = float(os.environ.get("DASH_WATCH_INTERVAL", "20"))
#: What a page load may cost. Everything here is a panel the operator is looking at.
PAGE_BUDGET_MS = 250.0
METRICS = [
"cpu.usage_pct", "cpu.temp_c", "cpu.load1", "mem.used_pct", "disk.used_pct@/",
"disk.used_pct@/home", "net.rx_bps", "net.tx_bps", "gpu.busy_pct@0",
]
def store_evidence(db: sqlite3.Connection, since: float) -> dict[str, float | int | str]:
"""The store's own record, read on a separate connection while the server runs."""
total = db.execute("SELECT COUNT(*) FROM event WHERE kind='store-error'").fetchone()[0]
recent = db.execute(
"SELECT COUNT(*) FROM event WHERE kind='store-error' AND ts>=?", (since,)
).fetchone()[0]
last = db.execute(
"SELECT message FROM event WHERE kind='store-error' ORDER BY ts DESC LIMIT 1"
).fetchone()
return {
"store_errors_total": total,
"store_errors_in_window": recent,
"last_store_error": (last[0] if last else ""),
}
def timed(client: httpx.Client, path: str) -> tuple[float, int]:
started = time.perf_counter()
try:
response = client.get(path)
code = response.status_code
except httpx.HTTPError: # a refused request is a sample too
return (time.perf_counter() - started) * 1000.0, -1
return (time.perf_counter() - started) * 1000.0, code
def main() -> int:
started = time.perf_counter()
OUT.mkdir(parents=True, exist_ok=True)
log_path = OUT / "store-perf-watch.log"
tsv_path = OUT / "store-perf-watch.tsv"
json_path = OUT / "store-perf-watch.json"
samples: list[dict[str, float | int | str]] = []
client = httpx.Client(base_url=BASE_URL, timeout=60.0, follow_redirects=False)
login = client.post("/api/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
if login.status_code != 200:
print(f"login failed: {login.status_code} {login.text[:200]}", flush=True)
return 1
client.headers["X-CSRF-Token"] = login.json()["csrf"]
hosts = [t["name"] for t in client.get("/api/config").json()["system"]["targets"]]
host_query = "".join(f"hosts={h}&" for h in hosts) or "hosts=driveripper&"
metrics_query = "".join(f"metrics={m}&" for m in METRICS)
paths = {
"health": "/api/health",
"summary": "/api/summary",
"metrics": "/api/metrics?source=host",
"history_90d": f"/api/system/history?{host_query}{metrics_query}range=90d",
}
db = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=30.0)
started_wall = time.time()
with log_path.open("a", buffering=1) as log:
log.write(f"# watch start {time.strftime('%Y-%m-%dT%H:%M:%S')} "
f"budget={PAGE_BUDGET_MS:.0f} ms interval={INTERVAL_SECONDS:.0f} s "
f"duration={DURATION_SECONDS:.0f} s hosts={hosts}\n")
while time.perf_counter() - started < DURATION_SECONDS:
row: dict[str, float | int | str] = {
"t": round(time.time(), 1),
"elapsed_s": round(time.perf_counter() - started, 1),
}
for name, path in paths.items():
ms, code = timed(client, path)
row[f"{name}_ms"] = round(ms, 1)
row[f"{name}_code"] = code
wal = DB_PATH.with_name(DB_PATH.name + "-wal")
row["wal_mb"] = round((wal.stat().st_size if wal.exists() else 0) / 1e6, 2)
row["db_mb"] = round(DB_PATH.stat().st_size / 1e6, 1)
try:
row.update(store_evidence(db, started_wall))
except sqlite3.Error as exc:
row["store_errors_total"] = -1
row["store_errors_in_window"] = -1
row["last_store_error"] = f"read failed: {exc}"
samples.append(row)
worst = max(float(row[f"{n}_ms"]) for n in paths)
log.write(
f"{row['t']} elapsed={row['elapsed_s']:7.1f}s "
+ " ".join(f"{n}={row[f'{n}_ms']:7.1f}ms" for n in paths)
+ f" worst={worst:7.1f}ms wal={row['wal_mb']}MB db={row['db_mb']}MB"
+ f" store-error(total={row['store_errors_total']},"
f" in-window={row['store_errors_in_window']})\n"
)
time.sleep(INTERVAL_SECONDS)
# ---- summary
def stat(name: str, fn) -> float:
values = [float(s[f"{name}_ms"]) for s in samples]
return round(fn(values), 1)
summary: dict[str, object] = {
"started": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(started_wall)),
"duration_seconds": round(time.perf_counter() - started, 1),
"passes": len(samples),
"db": str(DB_PATH),
"db_mb_first": samples[0]["db_mb"],
"db_mb_last": samples[-1]["db_mb"],
"wal_mb_first": samples[0]["wal_mb"],
"wal_mb_max": max(float(s["wal_mb"]) for s in samples),
"wal_mb_last": samples[-1]["wal_mb"],
"store_errors_total": samples[-1]["store_errors_total"],
"store_errors_in_window": samples[-1]["store_errors_in_window"],
"last_store_error": samples[-1]["last_store_error"],
"paths": {
name: {
"median_ms": stat(name, statistics.median),
"p95_ms": stat(name, lambda v: sorted(v)[max(0, int(len(v) * 0.95) - 1)]),
"max_ms": stat(name, max),
}
for name in paths
},
"budget_ms": PAGE_BUDGET_MS,
"passes_over_budget": sum(
1 for s in samples if max(float(s[f"{n}_ms"]) for n in paths) > PAGE_BUDGET_MS
),
"non_200_responses": sum(
1 for s in samples for n in paths if int(s[f"{n}_code"]) not in (200, 404)
),
}
json_path.write_text(json.dumps({"summary": summary, "samples": samples}, indent=2) + "\n")
with log_path.open("a", buffering=1) as log:
log.write("# summary\n")
for name, values in summary["paths"].items(): # type: ignore[index]
log.write(f"# {name:12s} median={values['median_ms']:8.1f} ms "
f"p95={values['p95_ms']:8.1f} ms max={values['max_ms']:8.1f} ms\n")
log.write(f"# store-error rows in window: {summary['store_errors_in_window']} "
f"(total {summary['store_errors_total']})\n")
log.write(f"# WAL first/max/last MB: {summary['wal_mb_first']} / "
f"{summary['wal_mb_max']} / {summary['wal_mb_last']}\n")
log.write(f"# passes over the {PAGE_BUDGET_MS:.0f} ms budget: "
f"{summary['passes_over_budget']} of {summary['passes']}\n")
header = ["t"] + [f"{n}_ms" for n in paths] + ["wal_mb", "db_mb", "store_errors_in_window"]
with tsv_path.open("w") as tsv:
tsv.write("\t".join(header) + "\n")
for sample in samples:
tsv.write("\t".join(str(sample.get(key, "")) for key in header) + "\n")
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())