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:
@@ -181,15 +181,16 @@ Responsibilities, one line each:
|
||||
`offline_uncorrectable`, hex-packed vendor raws are never decoded, and an attribute row is
|
||||
read from its **RAW_VALUE column** rather than the end of the line because this binary
|
||||
annotates that column — `35 (Min/Max 34/44)`, `43894h+43m+32.519s` — so its last token is
|
||||
not the value), 5 s per device /
|
||||
20 s per pass / 240 s cache, attached as `disk_io.devices[].smart`. Missing binary →
|
||||
not the value), 5 s per device / a pass budget whose 20 s floor scales to 5 s × the disk count
|
||||
/ 240 s cache, attached as `disk_io.devices[].smart` (gap 10: a 16-drive shelf takes longer
|
||||
than the floor, and truncating the pass would null the tail drives). Missing binary →
|
||||
**one** `errors` line for the pass (the word `not found` in it is what
|
||||
`sources/disks.py` reads to answer "is smartctl installed", and
|
||||
`tests/test_agent.py` pins that the line still says it); one denied/wedged device → that
|
||||
disk's `smart` is `null` with its own line; `--no-smart` opts out silently. Never fails
|
||||
the snapshot. **An expired cache refreshes on a background thread while the request is
|
||||
served the previous result** — only the first pass after startup runs inline — because 20 s
|
||||
of wedged disk on the request path blows `HOST_TIMEOUT_SECONDS` and would turn the
|
||||
served the previous result** — only the first pass after startup runs inline — because a wedged
|
||||
pass on the request path blows `HOST_TIMEOUT_SECONDS` and would turn the
|
||||
*system* source red every four minutes over an add-on that page never draws. The refresher
|
||||
is single-flight and keeps the old cache on failure, so a bad pass retries on the next
|
||||
expiry instead of hammering the disk.
|
||||
@@ -230,7 +231,7 @@ Responsibilities, one line each:
|
||||
that was dialled, because they become health records and event rows. `fetch_snapshot()` is
|
||||
the one agent `/snapshot` read that `system` and `disks` share, so a dead host says the
|
||||
same sentence on both pages and only the `timeout` differs (the disks poll has to outlast
|
||||
the agent's 20 s smartctl pass, and `None` is translated into httpx's sentinel because a
|
||||
the agent's smartctl pass, and `None` is translated into httpx's sentinel because a
|
||||
literal `None` means *no timeout at all*). Agent `errors`
|
||||
become the payload's `degraded` **except** the `smart:` add-on lines — a host without
|
||||
smartmontools or device read access is unconfigured, not broken (invariant 3); they stay
|
||||
@@ -397,7 +398,57 @@ Responsibilities, one line each:
|
||||
call, because a reset that deletes a row it did not name deletes the operator's credential).
|
||||
`stats()` reads `last_maintain` through `get_meta()` too — outside its own lock, which
|
||||
`get_meta` takes itself and `threading.Lock` is not reentrant — while `maintain()`'s write
|
||||
stays inline inside that method's transaction.
|
||||
stays inline inside that method's transaction. **No per-request path may pay a whole-table
|
||||
scan** (phase 14 task 02): `series_names()` (1.4 s of `SCAN sample` on a production-sized store,
|
||||
behind `/api/metrics`) and the two `COUNT`s behind `stats()` (1.25 s, run by `app.js`'s
|
||||
`/api/health` poll on every tab every 15 s) answer from short-TTL caches — 60 s and 30 s, the
|
||||
series list additionally invalidated **only by a write that can change it** (task 04: an insert
|
||||
cannot remove a name, so `_note_series_write()` ends the cache on a `(source, host, metric)` the
|
||||
store has never written and leaves a poll of an existing series alone — invalidating on every
|
||||
sample was task 02's defect, and it put the 572 ms scan back on nearly every `/api/metrics` call
|
||||
on a store nine pollers write to, behind a cache that never served; `delete_series()`/`maintain()`
|
||||
invalidate unconditionally because a `DELETE` can take a name, `delete_series()` also forgetting
|
||||
the triples it emptied so a re-created series counts as new again), through a generation counter so
|
||||
a write racing a read cannot park a stale answer under a fresh timestamp, with the name cache
|
||||
bounded to 256 `(source, host)` keys because `/api/metrics` takes both as free text and the
|
||||
existence tracking capped at `SERIES_TRACKED_SERIES_MAX`, below which it falls back to
|
||||
invalidating on every write. A tracking set made wrong by *another* process deleting rows costs at
|
||||
most one TTL window, which is the reason the cache has a TTL and not only the tracking. `last_maintain` and `size_bytes` are never
|
||||
cached: a `last_maintain` that stops moving is how an operator spots the invariant-4 failure
|
||||
mode, and it costs a three-row lookup. The resolution decision is one compound statement of
|
||||
per-`(host, metric, res)` branches whose `COUNT` is capped by `LIMIT max_points + 1` — exact,
|
||||
because the decision only ever asks whether a count is `0`, `≤ max_points`, or neither — and
|
||||
`pick_res_many()`/`history_many()` are the batched door the N × M endpoints go through. The rows
|
||||
stay a per-series seek **measuredly**: a `ROW_NUMBER()` window or a compound of the 27 tail
|
||||
selects both cost more than 27 index seeks on the seeded store (`data/logs/NOTES-store-perf.md`
|
||||
records the table), so batching the *decision* is the fix and batching the *fetch* would be a
|
||||
round trip traded for work.
|
||||
**The write side is windowed too** (phase 14 task 03): `maintain()` reads `last_maintain` through
|
||||
`get_meta()` *before* its transaction (same non-reentrant-lock rule as `stats()`) and bounds each
|
||||
rollup step to what became due since, instead of averaging every row the resolution retains —
|
||||
3.97 M rows of 1-minute history per minute, a 5.4 s write transaction that made a poller's
|
||||
`add_many` outlive `busy_timeout=5000` (gap 4's `store-error` line). Two properties make that a
|
||||
cost bound and not a data change, and both are pinned: the lower bound reaches **past** the
|
||||
previous pass (one bucket plus slack, so ten quiet minutes are a catch-up scan rather than ten
|
||||
minutes of empty charts), and it is snapped **down to a bucket boundary** — the step's ceiling is
|
||||
a row bound, so the newest bucket is partial and a later pass re-rolls it as its rows land, and a
|
||||
floor that cut into a bucket would overwrite the fuller average with the mean of its tail rows
|
||||
(2.5 becoming 4.5 on a six-sample minute: rule 2 with a chart's clothes on). A window is only a
|
||||
cost bound if the plan can seek to it, and two things were in the way. The prune keeps its full
|
||||
retention bound — a rollup may window because it only adds rows, a prune must reach anything that
|
||||
expired — but it is **one `DELETE` per resolution**, because one
|
||||
`WHERE (res=? AND ts<?) OR (res=? AND ts<?) OR (…)` is unreadable to the planner and read the
|
||||
whole 6.4 M-row table to delete nothing (554 ms of a 562 ms pass; 0.3 ms per statement now). And
|
||||
the store had no query statistics, so every window was priced as a scan however few rows were
|
||||
inside it: `maintain()` ends with `PRAGMA optimize` and `__init__` sets `automatic_analyze=0`,
|
||||
because SQLite's own refresh lands inside whichever poller's commit trips its counter — the exact
|
||||
path this shortens — while a tick a minute costs ~100 ms once and 0.0 ms afterwards. Measured on
|
||||
the seeded store: **5,368 ms → 2.7 ms** steady, 48 ms for a ten-minute catch-up, and the
|
||||
two-connection race at the app's cadence goes from 5 of 9 inserts raised to 100 of 100 landing
|
||||
with a 21 ms worst wait. `__init__` also sets `wal_autocheckpoint` (SQLite's own 1 000 pages,
|
||||
written down so the knob is visible) plus `journal_size_limit = 64 MB`, the cap that truncates a
|
||||
runaway WAL at the next checkpoint. `busy_timeout=5000` is unchanged: the fix is a shorter write,
|
||||
not a longer wait.
|
||||
- `api.py` — GETs everywhere plus the one POST that dials an upstream, and the `auth_router()`
|
||||
that creates, describes, **re-credentials**, and ends a session (`POST /api/auth/login` public;
|
||||
`GET /api/auth/status`, `POST /api/auth/logout` and `POST /api/auth/password` protected). Those
|
||||
@@ -441,6 +492,14 @@ Responsibilities, one line each:
|
||||
write of its own — a fourth dashboard must not quietly become a second one, so the app's
|
||||
only POST stays `/api/ups/command` (invariant 1, pinned by enumerating every served route's
|
||||
verbs).
|
||||
All four `history` routes reach the store through one door, `api._history_batch()` →
|
||||
`store.history_many()` (phase 14 task 02). That is a cost change and nothing else: the series
|
||||
list is host-major and metric-minor in request order, each entry the same
|
||||
`{source, host, metric, range_seconds, points}` document the per-series loop built, with the
|
||||
same empty-series answer for a name that has no rows. `tests/test_api.py` pins it for all four
|
||||
routes against points read straight out of the store with `query()` — a third path, in neither
|
||||
the loop nor the batch — so a future "optimisation" that moves a point, an order, or a key
|
||||
fails a test instead of redrawing a chart wrong.
|
||||
`/api/summary` is the overview's single call and the cheapest whole-fleet probe there is:
|
||||
the page renders only `alerts` plus `system.fleet`/`system.hosts`, while `disks.hosts`,
|
||||
`llm.engines`, `ups.ups`, the `states` maps and `recent_events` stay for programmatic
|
||||
@@ -567,10 +626,11 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
`--host 10.1.0.126 --port 9120 --token-file /etc/dashboards-agent.token` (`640
|
||||
root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) with `User=dashboards-agent`,
|
||||
and `getent group disk` → `disk:x:6:dashboards-agent`.
|
||||
**Two things about that install are not what this repo writes, and both cost SMART data on
|
||||
`/disks` (see the SMART block below): `systemctl show -p DevicePolicy dashboards-agent` answers
|
||||
`auto` with no `DeviceAllow` lines** (the installer's `strict` + node allow-list is not what is
|
||||
running there), **and its `smartctl` cannot read drives as that user.**
|
||||
**The deployed unit has since diverged from the template in the three ways the SMART
|
||||
block below records** (the `DevicePolicy`/`DeviceAllow` lines removed, `User=root`,
|
||||
`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`) — re-running `deploy/install-agent.sh`
|
||||
on driveripper would clobber the working unit, because it rewrites the unit unconditionally
|
||||
(gap 10).
|
||||
`/api/health` today reads **all nine sources `ok`**: `host:driveripper`, `host:deskwork`,
|
||||
`host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`,
|
||||
`llm:vllm`.
|
||||
@@ -657,15 +717,29 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
`nvme0n1`…`nvme7n1` + `sda`…`sdh`) and `smart` is non-null for the 8 NVMes, but every one of
|
||||
them carries only identity — `health`, `temp_c`, `power_on_hours`, `percentage_used` and the
|
||||
counters are all `null` — while the 8 spinning drives come back `smart: null` each with the
|
||||
agent's own line `smart: sda: Try an additional '-d ata' or '-d sat' argument.` Measured
|
||||
read-only on 2026-10-19: the same `smartctl -i -A -H /dev/nvme0n1` **as root** prints
|
||||
`PASSED`, 55 °C and 14 % used, so the drives answer and the *agent user* is what is locked out
|
||||
— `/dev/nvme0`, `/dev/ng0n1` and `/dev/bsg/*` (the nodes NVMe admin and SATA pass-through
|
||||
actually go through) are `crw------- root root`, and only `/dev/nvme0n1` / `/dev/sg0` carry the
|
||||
`disk` group the agent is in. So `/disks` shows driveripper's drive inventory with `n/a` values
|
||||
and a why-line, which is the designed degradation, and **lighting it up is a new phase, not a
|
||||
token**: a udev rule granting those nodes, and/or `host_agent.smart()` learning the
|
||||
`-d sat` / `/dev/ngXnY` forms smartctl needs when auto-detection fails.
|
||||
agent's own line `smart: sda: Try an additional '-d ata' or '-d sat' argument.` Pinned on
|
||||
2026-10-19 with read-only probes (`data/logs/NOTES-driveripper-agent.md`): the `disk`-group
|
||||
**open of the block nodes works** (agent user, `O_RDWR`), there is no SELinux denier (zero
|
||||
AVCs with `auditd` running), and the install's `DevicePolicy=strict`/`DeviceAllow` lines were
|
||||
removed from the unit (they matched nothing useful anyway — systemd resolves `DeviceAllow`
|
||||
globs by name against `/proc/devices`, where this kernel lists NVMe as `blkext`, so
|
||||
`nvme*` matches nothing). What locks the agent out is **kernel 7.2's unprivileged
|
||||
passthrough gate**: NVMe admin via the block node is unprivileged-only for five identify
|
||||
types; the SMART-log read needs the drive's ACEL to mark it supported (the Samsung 980
|
||||
PRO's 2020-era `5B2QGXA7` does not — `NVME_IOCTL_ADMIN_CMD: Permission denied`) or
|
||||
`CAP_SYS_ADMIN`, and SATA SMART behind a SAT layer is ATA pass-through, which the
|
||||
unprivileged `SG_IO` allow-list (`scsi_cmd_allowed()`: read-safe opcodes only) does not
|
||||
include — explicit `-d sat`/`-d ata` fails identically, so no `host_agent.smart()` retry
|
||||
form can reach it. `deskwork` reads live with the same user/unit because its drive's
|
||||
firmware does advertise the page. So `/disks` shows driveripper's drive inventory with
|
||||
`n/a` values and a why-line — the designed degradation, **resolved the same day (gap 10):
|
||||
the unit now runs `User=root` with
|
||||
`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`, and all 16 drives read live** (the
|
||||
980 PRO's 59 °C / 6328 h / 14 % row, `nvme1n1` at 58 % wear, the red `13 uncorrectable`
|
||||
alarm chip on `nvme5n1`; verified in `data/logs/disks-driveripper-live.png`). A root
|
||||
process with an **empty** capability bounding set is still capability-less (`CapEff: 0`)
|
||||
because the kernel gates check capabilities, not uid — the named bounding set is what
|
||||
unblocked it.
|
||||
**Real data is in the repo now:** `tests/fixtures/smartctl_driveripper_<dev>.txt` is all 16
|
||||
drives (8 NVMe, 8 spinning) read through that sudo rule, and `tests/test_agent.py` parses
|
||||
every one — which is what found the parser's one real bug: this binary annotates the
|
||||
@@ -681,9 +755,8 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
*not* a sudo rule, because the agent must read the device itself — on an `agent/host_agent.py`
|
||||
from phase 09 or later (never redeploy without the grant: 16 permission-denied lines would age
|
||||
nothing but are pure noise, while with the grant they are 16 drives' worth of temperature,
|
||||
hours and sector counters). **The agent and the `disk` group are now both in place on
|
||||
`driveripper`, and it still reads no SMART numbers** — the group only covers nodes that carry
|
||||
it (see above), so what is left is the node grant / `-d sat` work, not an install. `gateway` needs smartmontools first and will still read no SMART
|
||||
hours and sector counters). **The agent and the `disk` group are both in place on
|
||||
`driveripper`** — the `disk` group alone is not enough on kernel 7.2 (the passthrough gate, see above), so the unit additionally runs as root with the two gate capabilities bounded (gap 10) — all 16 drives now read live. `gateway` needs smartmontools first and will still read no SMART
|
||||
after that, being a VM with no physical disk behind `vda`. A host that cannot read its drives
|
||||
renders the designed degradation: one dimmed card per physical drive, every number `n/a`, and
|
||||
the one-line why.
|
||||
@@ -711,9 +784,26 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no
|
||||
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
|
||||
./scripts/dev-server.sh logs
|
||||
|
||||
uv run pytest # hermetic: 1195 passed + 11 live-skipped, no network
|
||||
uv run pytest --cov # coverage gate (98.7%; floor 94%)
|
||||
uv run pytest # hermetic: 1267 passed + 15 skipped (11 live,
|
||||
# 4 store_perf), no network
|
||||
uv run pytest --cov # coverage gate (98.75%; floor 94%)
|
||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||
uv run pytest -m store_perf --store-perf # opt-in: the store hot-path timings over the seeded
|
||||
# store, priced against `store_perf.BUDGETS` (the cached
|
||||
# reads report cold *and* warm, so a before/after never
|
||||
# books a cache hit as a speed-up)
|
||||
uv run python scripts/watch-store-perf.py
|
||||
# ... and the live proof: 25 signed-in minutes against the
|
||||
# running dev server, timing the page paths while counting
|
||||
# `store-error` rows and the WAL size (DASH_WATCH_SECONDS
|
||||
# shortens it; writes store-perf-watch.{log,tsv,json})
|
||||
uv run python scripts/store_perf_probe.py # ... as a report (data/logs/store-perf-baseline.{md,json},
|
||||
# then store-perf-task03.* for the write side and
|
||||
# store-perf-task04.* for the budget table);
|
||||
# --rebuild re-seeds, --dev-db <file> also times a real
|
||||
# store read-only, DASH_PERF_STORE_DIR moves the cache, and
|
||||
# every pass that *writes* runs on a scratch copy so the
|
||||
# seeded baseline never moves under the next measurement
|
||||
uv run ruff check src tests agent && uv run pyright
|
||||
./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS + gufo /slots)
|
||||
SMART_HOSTS="deskwork-ops driveripper-ops" ./scripts/capture-fixtures.sh
|
||||
@@ -837,6 +927,28 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
|
||||
verbatim, the four config rows, the `DASH_COOKIE_SECURE=1` deployment note, the three auth
|
||||
gaps). No server starts and no request is made: a doc that stops naming the reset command is a
|
||||
locked-out admin, and that is worth a test even though it asserts nothing about Python.
|
||||
- **A cost claim is pinned as a shape and a control, never as a wall clock** (phase 14). The default
|
||||
run keeps the seeded-store tests hermetic and asserts **shape and determinism** — counts,
|
||||
retention windows, the statements a plan is taken from, idempotence — while the timings live behind
|
||||
`-m store_perf --store-perf` and in `scripts/store_perf_probe.py`, because a budget asserted on
|
||||
first writing is a number fitted to the machine it was written on. Two rules came out of it.
|
||||
(1) A cheaper query is proven *equal*, not just faster:
|
||||
`test_windowed_rollups_answer_exactly_what_one_full_window_rollup_answers` compares every stored
|
||||
row of a windowed store against a second store rolled the old way, and
|
||||
`test_a_re_rolled_bucket_only_ever_gains_rows` pins the one bug that equivalence alone misses (a
|
||||
window floor that cuts into a bucket re-rolls it from its tail rows). (2) A race that comes back
|
||||
clean has to be shown capable of going dirty: `store_perf` runs the pre-fix whole-window
|
||||
`maintain()` as a **control** beside the windowed one and `tests/test_store.py` fails if the control
|
||||
stops contending, because "0 `database is locked`" from a race that never met is the invariant-4
|
||||
failure mode with a green checkmark attached. (3) **A budget is a table, not a test body**
|
||||
(task 04): the 14 ceilings live in `store_perf.BUDGETS` with the measured figure and the margin in
|
||||
each row's `basis`, the probe renders that table, and two default-run tests fail if a budget stops
|
||||
being priced or a ceiling contradicts its own ratio partner — a ceiling nobody measures is a
|
||||
comment. (4) A cache's cost claim needs the *write* cadence in the measurement: task 02's series
|
||||
cache reported 0.0 ms on a quiet seeded store and served almost nothing on a live one, because
|
||||
every sample write invalidated it. The pinned version is statement-counting, not timing — a poll of
|
||||
existing series issues no `SELECT`, a write of a name nobody wrote ends the cache — and the live
|
||||
version is `scripts/watch-store-perf.py`, which is where the defect showed up.
|
||||
- Coverage gate: 80% is the repo floor, the phase gate in `.agents/validate.sh` enforces
|
||||
94% (`--cov-fail-under`), and ~98% is where we are; keep it there. New/modified Python
|
||||
still has to clear >90% on its own. API tests use `create_app()` with an in-memory DB,
|
||||
@@ -1221,7 +1333,7 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
field map is written.
|
||||
3. **UPS history depth.** Events and the numeric series are stored; long-window battery
|
||||
health (capacity fade over months) has the rollups but no dedicated view.
|
||||
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~640 MB on
|
||||
4. **The dev DB's `database is locked` line is fixed (phase 14); a cold-cache tail is left.** The dev store (~640 MB on
|
||||
2026-10-19, its WAL grown past 140 MB at its worst) logged `store-error: OperationalError:
|
||||
database is locked` a few times an hour across `system`/`llm`/`ups` through 2026-10-18; the
|
||||
poller handles it exactly as invariant 4 requires — the source goes red with the real reason,
|
||||
@@ -1232,8 +1344,52 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
phase 12's re-check on 2026-10-19 found the 190 stored `store-error` rows all older than
|
||||
2026-10-18 06:33 with none in the previous 24 h of the running dev store — the absence of a
|
||||
sample, not the absence of the pressure, since the file only keeps growing.
|
||||
Retention/`wal_checkpoint` tuning for a long-running store is its own phase, not a
|
||||
phase-09 follow-up.
|
||||
Retention/`wal_checkpoint` tuning for a long-running store was its own phase rather than a
|
||||
phase-09 follow-up — that phase is `14_store_performance`, and the two paragraphs below are
|
||||
what it measured and changed.
|
||||
**Phase 14 has since split this in two, and the read half is closed.** The in-process half was
|
||||
never a SQLite lock at all: `SampleStore._lock` serialises everything, so a 1.25 s `stats()`
|
||||
from a browser tab's `/api/health` poll was a poller waiting up to 1.45 s to insert 30 rows
|
||||
(measured, `data/logs/NOTES-store-perf.md`). Task 02 took the two whole-table reads off the
|
||||
per-request path, and `/api/health` now answers in ~1.2 ms instead of ~1.27 s, so the store
|
||||
mutex is no longer held by a reader for seconds at a time. The **write half is closed too**
|
||||
(task 03): `maintain()` rolls up only what became due since `last_maintain`, its prune is one
|
||||
`DELETE` per resolution instead of one `OR` the planner could not use, and `PRAGMA optimize` runs
|
||||
there (with SQLite's automatic ANALYZE switched off, so no poller's commit pays for it). On the
|
||||
seeded 6.4 M-row store that is 5,368 ms → **2.7 ms** steady / 48 ms for a ten-minute catch-up, and
|
||||
the two-connection race at the app's cadence is **100 of 100 inserts, zero `database is locked`,
|
||||
max wait 21 ms** where the pre-fix control still waits 4.76 s (task 04's re-run of the same
|
||||
budget: 6.8 ms steady / 46 ms catch-up / 8.5 ms worst wait, so the row is a range, not a
|
||||
digit). The WAL follows the same shape: a steady pass leaves ~0 MB and a catch-up tick 4 MB
|
||||
instead of 38 MB that never comes back down, and `journal_size_limit=64 MB` caps what a
|
||||
long-lived reader can make it grow to (`data/logs/NOTES-store-perf.md` has the tables).
|
||||
**The live proof is in** (task 04, 25 minutes / 74 passes / 296 signed-in requests against the
|
||||
running dev server on the operator's own 636 MB store): **0 new `store-error` rows** (still 190,
|
||||
newest 2026-10-18 06:33:30, i.e. 39 h before the watch), **WAL 6.5 → 7.77 MB** against the 67.1 MB
|
||||
cap and a main file that did not move, 0 non-200 responses, and page paths at 1.8-3.3 ms median
|
||||
(`/api/health`, `/api/summary`, `/api/metrics`, a 3-host × 9-metric 90-day `/api/system/history`).
|
||||
The watch also caught a defect in task 02's fix — the series cache was invalidated by *every*
|
||||
sample write, so on a store the hub writes to every few seconds it never served and `/api/metrics`
|
||||
paid the 572 ms scan on nearly every call (measured: 535 ms per call pre-fix, 1.8 ms median
|
||||
after) — and that is what the store bullet above now describes.
|
||||
What is **left** is the cost of the two cold answers themselves, measured on that store: 599 ms
|
||||
for `series_names('host')`, 729 ms for `stats()`' counts, now once per cache window (~1.3 s of
|
||||
store mutex per minute) instead of once per call — which is why the watch's medians are 1.8-3.3 ms
|
||||
and its p95s are 620/729 ms: the p95 is the window boundary, and 37 of 74 passes had one request
|
||||
over 250 ms, every one of them a cold re-derivation. No cache policy reaches *that*; the fix is
|
||||
the bounded `(source, metric)` structure or a counts table named in task 03's note. Two cheaper
|
||||
knobs exist and were deliberately not pulled, because both change what `/api/health` answers and
|
||||
move rather than remove the mutex hold: raising `STATS_CACHE_TTL_SECONDS`, and refreshing both
|
||||
caches from the maintenance tick so the miss lands on a poller instead of a page load. That is a
|
||||
phase, not a cleanup. Two servers on one file can still contend, and `busy_timeout=5000` is
|
||||
unchanged on purpose so a second one stays visible as a slower write; what they contend with is
|
||||
now milliseconds instead of 5.4 s.
|
||||
One consequence worth naming, because two other gaps pointed here for it: the **~10 s
|
||||
process-wide stall** (gap 9's `Δ`-window dilution, `poller`'s catch-up gaps) is gone with the
|
||||
mutex holds — coincident multi-second gaps in the 2 s `llm` and 5 s `ups` raw streams went from
|
||||
393 and 198 in the two halves of 2026-10-19 to **0** across the 110 minutes the phase-14 store
|
||||
has run (re-measured in this phase's verification pass from the `sample` table; the signature
|
||||
separates a process stall from one slow engine).
|
||||
5. **The login throttle keys on the socket peer address** (phase 11), and the one
|
||||
`LoginThrottle` instance now holds two keys per address: the bare one for
|
||||
`POST /api/auth/login` and `password:<address>` for `POST /api/auth/password`. Behind nginx
|
||||
@@ -1315,7 +1471,14 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
pass line and what the watch above stayed under. The `Δ<window>` label stayed honest throughout —
|
||||
both before and after the fix it names the window actually used; do not "fix" anything
|
||||
here by hiding or rewriting it. The ~10–15 s process-wide **stall** that triggers a
|
||||
refused poll is still gap 4's and `14_store_performance`'s problem; the estimator no longer
|
||||
refused poll was gap 4's and `14_store_performance`'s problem, and **phase 14 closed it**
|
||||
(measured on the dev store from the sample table: coincident multi-second gaps in the 2 s `llm`
|
||||
and 5 s `ups` raw streams — the signature of a process-wide stall, not a slow engine — were
|
||||
393 in 00:00–12:00 and 198 in 12:00–21:36 on 2026-10-19, and **0** across the 110 minutes the
|
||||
phase-14 store has been running, worst `llm` gap 7.1 s and that one the recorded gufo timeout).
|
||||
The mechanism is the one phase 14 removed: a whole-table `stats()` count held the store mutex
|
||||
for ~1.25 s on every browser tab's `/api/health` poll, and `maintain()` held the write lock for
|
||||
5.4 s. The estimator no longer
|
||||
turns a slow fetch into 0.2 s arithmetic, and one `n/a` per stall is the designed degradation
|
||||
(the residual the verification pass measured is the paragraph below).
|
||||
**The verification pass found one residual path and closed it** (same day, live rows in the
|
||||
@@ -1341,11 +1504,47 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
35.6 once the freed slot settled — every value labelled, **0 of 540 above the 300 pass line**,
|
||||
and no stored raw row over it since the restart either. A collapsed window is what makes a
|
||||
burst *look* like a rate, so the fix for this family is the ~10 s stall itself (gap 4 /
|
||||
`14_store_performance`), not a fresh threshold invented in the estimator: task 03 pinned
|
||||
`14_store_performance` — which has since removed it, see gap 4 and the paragraph above, so what
|
||||
is left of this family is the sub-8× skew itself), not a fresh threshold invented in the
|
||||
estimator: task 03 pinned
|
||||
`MIN_WINDOW_SECONDS` as an absolute floor and pinned that an in-bounds diluted row publishes
|
||||
(`test_a_fallback_inside_the_documented_ceiling_still_publishes`), so a span-relative floor
|
||||
would be a re-decision of that phase, not a bug fix. Do not "fix" the residual by hiding the
|
||||
short window or its label.
|
||||
10. **`driveripper`'s 16 drives read `n/a`: kernel 7.2's unprivileged-passthrough gate (2026-10-19).**
|
||||
The agent is installed, the `disk` group grant is in place, the block nodes open, and the
|
||||
page renders all 16 real drive rows — but kernel 7.2.11 blocks the *agent user's* `smartctl`
|
||||
at the command level (not the open; the probe matrix is in
|
||||
`data/logs/NOTES-driveripper-agent.md`): the 8 SATA drives' SMART is ATA pass-through
|
||||
behind a SAT layer, and the kernel's unprivileged `SG_IO` allow-list
|
||||
(`scsi_cmd_allowed()`, read-safe opcodes only) does not include it — `CAP_SYS_RAWIO` or
|
||||
root unblocks it, with or without `-d sat`/`-d ata`; the 8 Samsung/WD NVMes' SMART-log
|
||||
read needs the drive's ACEL to advertise the page (the 980 PRO's `5B2QGXA7`, 2020-era,
|
||||
does not: `NVME_IOCTL_ADMIN_CMD: Permission denied`) or `CAP_SYS_ADMIN`; `deskwork` reads
|
||||
live because its firmware does. Options, in order of narrowness: (a) accept the designed
|
||||
degradation (what `/disks` renders now — the identity is real, the values are `n/a` with a
|
||||
why-line); (b) `AmbientCapabilities=CAP_SYS_RAWIO` (+ `CapabilityBoundingSet`) on the unit
|
||||
— unblocks exactly the 8 HDDs, is the capability the kernel names for the gate, but a
|
||||
compromised agent could then write raw block I/O — test it in a `systemd-run --scope`
|
||||
before committing; (c) `User=root` with the empty capability set — unblocks all 16 but
|
||||
hands a token-guarded process root's file-read DAC (not recommended); (d) a Samsung
|
||||
firmware update — the only clean fix for the NVMe side. **Resolved the same day with a
|
||||
tighter variant of (c): `User=root` + `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`
|
||||
— all 16 drives read live** (root DAC is the price; the bounding set keeps the grant
|
||||
named rather than full). Two empirical notes: a root process under the unit's *empty*
|
||||
bounding set is capability-less (`CapEff: 0`), because the kernel gates check
|
||||
capabilities, not uid — that is why the first `User=root` attempt still read `n/a`; and
|
||||
`CapEff` came back `0000000000220000` (SYS_ADMIN + systemd's auto CHROOT; RAWIO in the
|
||||
bounding set but not effective) — the 8 SATA drives' reads were unblocked by SYS_ADMIN
|
||||
alone, so the effective grant is {SYS_ADMIN}; the RAWIO in the line stays as the named
|
||||
intent for the `SG_IO` gate. The agent also gained the pass-budget scale-up (20 s floor
|
||||
→ 5 s × disk count; an explicit override still wins, the budget test is untouched),
|
||||
because a 16-disk pass takes more than 20 s when the drives answer. The `deploy/install-agent.sh`
|
||||
template still ships the `DevicePolicy`/`DeviceAllow` lines that misbehaved on this host
|
||||
(a deny-all filter; the `nvme*` glob matches nothing on this kernel because
|
||||
`/proc/devices` lists NVMe as `blkext`) — the template change is its own phase.
|
||||
`tests/fixtures/smartctl_driveripper_*.txt` (captured via `sudo`) remain the ground truth
|
||||
for the drives' shape.
|
||||
|
||||
## Locked decisions (and why)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user