Commit Graph
3 Commits
Author SHA1 Message Date
efitenandClaude Opus 5 a5aa3cdc41 perf(db): refresh SQLite planner statistics with a bounded ANALYZE (#2058) (#2072)
Closes #2058.

`ANALYZE` has never run against these databases, so `sqlite_stat1` does
not exist and the planner works from built-in guesses. On the channel
queries it guesses wrong: it drives from the plain
`idx_transmissions_payload_type` instead of `idx_tx_channel_hash`, the
partial index (`WHERE payload_type = 5`) the schema already carries for
that exact filter.

@anieto's report did the diagnosis and the arithmetic. This adds the
maintenance operation that was missing, at a value measured rather than
assumed.

## The diagnosis transfers, the remedy needed measuring

Measured on our 9.4 GB staging database: 1,250,489 transmissions,
14,169,329 observations, 2.7x and 7x the reported database.
Region-filtered `GetChannels` produces the identical plan reported in
#2058, down to both temp b-trees, so the problem is the same one.

Wall time is the wrong metric here. The same query and the same plan
measure **56.7s cold and 0.80s warm** on that file, so the OS page cache
dominates. Counting page-cache misses instead:

| analysis_limit | ANALYZE | driving index | page misses |
|---|---|---|---|
| none (no statistics) | - | `idx_transmissions_payload_type` | 143,442
|
| 400 | 171 ms | `idx_transmissions_payload_type` | 143,449 |
| 1000 | 171 ms | `idx_transmissions_payload_type` | 143,450 |
| **10000** | **2.0 s** | **`idx_tx_channel_hash`** | **107,429** |
| 0 (unbounded) | 242.9 s | `idx_tx_channel_hash` | 107,429 |

400, the value SQLite's documentation offers for the bounded form,
changes nothing on this data: it samples too few rows to separate the
126,336-row partial index from the 920,700-row plain one. 10000 buys the
entire plan change for 2.0 s, and the four-minute unbounded `ANALYZE`
buys nothing beyond it.

## What it is worth, as measured

25% fewer pages read per query, 143,442 to 107,429, about 147 MB less at
a 4 KB page. Warm wall time does not move: 0.80s either way. The gain
lands on the cold path, the one that measured 56.7s, so the claim here
is fewer pages read, not a warm speedup.

This is smaller and differently shaped than the 3-4x in #2058. I cannot
reproduce that ratio on a database of this size and am not claiming it.

## The change

- `Store.RefreshPlannerStats(analysisLimit)` in `cmd/ingestor/db.go`:
`PRAGMA analysis_limit=N` then `ANALYZE`, logging the duration and
whether this was the first run.
- Wired in `cmd/ingestor/main.go` next to the existing WAL checkpoint
ticker: 24h, staggered 2 minutes past startup because it takes the write
lock.
- `db.analysisLimit` in `internal/dbconfig`, default 10000, negative
disables it.

It runs in the ingestor, not the server: `cmd/server/db.go:145` opens
`mode=ro`, and `ANALYZE` writes. This respects the read/write separation
invariant in AGENTS.md.

`analysis_limit=0` means *no* limit to SQLite rather than "use a
default", so an unset config maps to 10000 and a test covers that
specific case.

## Two faults the measurement caught in my own first commit

Both are in the history rather than hidden, because the second commit is
the one that measured:

1. **`PRAGMA optimize` was the wrong statement.** It analyzes only
tables the calling connection has itself queried during the session, and
a maintenance call has queried none. Run against staging it wrote
nothing and left `sqlite_stat1` absent; `PRAGMA optimize(0x03)` returned
no statements at all. Verified on an empty database too (SQLite 3.45.1):
`ANALYZE` creates `sqlite_stat1`, `PRAGMA optimize` does not. That
difference is what makes the behavioural test a guard instead of a
no-op.
2. **`analysis_limit=400` was the wrong value**, per the table above.

## Tests

Six cases in `cmd/ingestor/refresh_planner_stats_test.go`:

- statistics are actually written (the guard against returning to
`PRAGMA optimize`)
- the pragma reaches the connection, read back through `PRAGMA
analysis_limit`
- a negative limit leaves `sqlite_stat1` absent
- two consecutive refreshes, since a ticker calls this repeatedly
- the config default and the JSON round trip
- the default is above the range measured ineffective, so lowering it
back to 400 fails

## Not done, and one caveat

- **Correction to an earlier version of this description**, which said
the Go tests could not run locally because this box has no C compiler.
That was wrong: `CGO_ENABLED=0` and `gcc` being absent from `PATH` is
not the same as no compiler, and a mingw-w64 toolchain is installed
here. Run properly, all six tests pass locally, and so does the rest of
`cmd/ingestor` apart from
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails on
`os.Symlink` with "A required privilege is not held by the client" on
Windows without elevation and lives in `stats_file_test.go`, a file this
branch does not touch. CI agrees: Go Build & Test and the ingestor race
detector are both green.
- The SQLite behaviours above were measured against 3.45.1 on the
server, not against the amalgamation `mattn/go-sqlite3` bundles.
- **No query rewrite.** #2058 explicitly left that out and so does this;
the correctness caveats it lists (per-channel most-recent-message
semantics, v2/v3 branches, `enc_` exclusion) are untouched here.
- Staging carries limit-10000 statistics, matching what this code
produces. Reversible: `DROP TABLE sqlite_stat1` was verified on a
scratch database before any of it ran.
- Whether a cold-start `ANALYZE` should also run before the 2 minute
stagger is not addressed. The first query after a restart is the
expensive one, and it can arrive first.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013YAR8fdNTzqjtsggq4xCX6

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 00:08:54 +02:00
bc1822e46c perf(load): chunked Load with early HTTP readiness (#1009) (#1596)
## What

Switches the server's startup from a synchronous full-scan
`PacketStore.Load()` to a chunked `LoadChunked(chunkSize)` that:

1. Streams transmissions+observations from SQLite in id-ordered chunks
(default `chunkSize=10000`, configurable via `db.load.chunkSize`).
2. Closes `FirstChunkReady()` after the first chunk is merged —
`main.go` binds the HTTP listener on that signal instead of blocking on
the full multi-minute load.
3. Stamps `X-CoreScope-Load-Status: loading; progress=<rows>` on every
response while LoadChunked is in flight, flipping to `ready` once it
completes (via `loadStatusMiddleware`).
4. Preserves the existing retention/`hotStartupHours`/`maxMemoryMB`
clamps and the post-load index rebuild (`pickBestObservation` /
`buildSubpathIndex` / `buildPathHopIndex` / `buildDistanceIndex`).

## Why

Per #1009: at 5M+ observations (Cascadia scale) the synchronous Load
blocked HTTP for ~80s with a 2–3× steady-state RAM peak. With chunked
load the listener binds within seconds; dashboards and probes can read
partial data and see the `loading` status header until the background
load finishes.

## Notes

- `/api/healthz` readiness gate (`readiness` atomic, init `WaitGroup`)
is unchanged — it still waits for neighbor-graph build + initial
`pickBestObservation` before reporting `ready:true`. `LoadChunked` only
changes when the listener BINDS, not when it advertises ready.
- `cmd/server/main.go` waits for `FirstChunkReady` (or the full load on
a tiny DB) before proceeding, and drains the load goroutine in the
background with a logged error path.
- Config Documentation Rule: `config.example.json` now documents
`db.load.chunkSize` with a nested `_comment` describing the trade-off.

## Tests

- `cmd/server/chunked_load_test.go` asserts:
  - (a) `FirstChunkReady` fires before `LoadChunked` returns
- (b) `X-CoreScope-Load-Status` transitions `loading; progress=...` →
`ready`
- (c) `chunkSize` honored (2500 rows @ 1000 → 3 chunks via
`OnChunkLoaded`)
  - (d) `Config.DBLoadChunkSize()` default 10000 + override
- Red commit (`102a4c84`) lands the tests with stubs that fail on
assertion — verified locally before the green commit.
- Green commit (`35cecf16`) makes all four pass; full `cmd/server` suite
green (47s locally).

Closes #1009



## TDD red-commit exemption

The original red commit `f878e15e` ("test(load): failing tests for
chunked Load + early HTTP readiness") fails to **compile** rather than
failing on an assertion, because it references symbols
(`store.LoadChunked`, `store.FirstChunkReady`, `store.OnChunkLoaded`,
`Config.DBLoadChunkSize`, `loadStatusMiddleware`) that do not exist on
master. Per `AGENTS.md` the bar is "MUST fail on an assertion ... A
compile error is NOT a valid red commit."

This is claimed under the **net-new surface** exemption with the
following justification:

- LoadChunked / FirstChunkReady / loadStatusMiddleware / DBLoadChunkSize
are all introduced by this PR — no prior implementation existed to
refactor. There is no behaviour on master that the red commit could
meaningfully assert against without first declaring the new symbols.
- The cheapest "proper" alternative (split the red into two commits:
stub-first + assertion-fail) was deferred because the test file
unambiguously fails on missing-symbol — there is no risk of the test
becoming a tautology against a pre-existing stub.
- **Behaviour gating IS proven elsewhere on this branch.** Commit
`799bde49` ("test(load): red — LoadChunked must mark indexes ready + not
flip Complete on error") is a proper assertion-fail red against the same
package, and commit `92cadd1d` is the matching green. Reviewers can
verify the red→green pattern there.

If a future reviewer wants the strict pattern, the follow-up is
mechanical: split `f878e15e` into a stub-only commit followed by the
assertion commit. Not done here to keep the rework cost proportional to
the risk (zero, in this case).

## Preflight overrides

- check-async-migrations: justified — the flagged `CREATE TABLE`/`CREATE
INDEX` statements live in `cmd/server/chunked_load_id_zero_test.go` and
`cmd/server/chunked_load_oldest_test.go` only. They run against per-test
`t.TempDir()` SQLite files (in-process, ~10 rows, lifetime = single
test) — they are NOT production schema migrations. No prod table is
touched. PREFLIGHT-MIGRATION-SCALE: <30s N=10 (per-test tempdir
fixture).

---------

Co-authored-by: CoreScope Bot <bot@corescope.local>
Co-authored-by: clawbot <bot@noreply.example.com>
Co-authored-by: Kpa-clawbot <bot@example.com>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
2026-06-07 03:43:29 -07:00
Kpa-clawbotandyou dd2f044f2b fix: cache RW SQLite connection + dedup DBConfig (closes #921) (#982)
Closes #921

## Summary

Follow-up to #920 (incremental auto-vacuum). Addresses both items from
the adversarial review:

### 1. RW connection caching

Previously, every call to `openRW(dbPath)` opened a new SQLite RW
connection and closed it after use. This happened in:
- `runIncrementalVacuum` (~4x/hour)
- `PruneOldPackets`, `PruneOldMetrics`, `RemoveStaleObservers`
- `buildAndPersistEdges`, `PruneNeighborEdges`
- All neighbor persist operations

Now a single `*sql.DB` handle (with `MaxOpenConns(1)`) is cached
process-wide via `cachedRW(dbPath)`. The underlying connection pool
manages serialization. The original `openRW()` function is retained for
one-shot test usage.

### 2. DBConfig dedup

`DBConfig` was defined identically in both `cmd/server/config.go` and
`cmd/ingestor/config.go`. Extracted to `internal/dbconfig/` as a shared
package; both binaries now use a type alias (`type DBConfig =
dbconfig.DBConfig`).

## Tests added

| Test | File |
|------|------|
| `TestCachedRW_ReturnsSameHandle` | `cmd/server/rw_cache_test.go` |
| `TestCachedRW_100Calls_SingleConnection` |
`cmd/server/rw_cache_test.go` |
| `TestGetIncrementalVacuumPages_Default` |
`internal/dbconfig/dbconfig_test.go` |
| `TestGetIncrementalVacuumPages_Configured` |
`internal/dbconfig/dbconfig_test.go` |

## Verification

```
ok  github.com/corescope/server    20.069s
ok  github.com/corescope/ingestor  47.117s
ok  github.com/meshcore-analyzer/dbconfig  0.003s
```

Both binaries build cleanly. 100 sequential `cachedRW()` calls return
the same handle with exactly 1 entry in the cache map.

---------

Co-authored-by: you <you@example.com>
2026-05-02 20:15:30 -07:00