SQLite bank space & WAL diagnosis (physical layer)
Measured methods from a 2026-08 memory-bank audit. Complements the SQLite schema
review skill (schema semantics); this covers the physical layer — file size, WAL
mechanics and VACUUM semantics.
Diagnosis workflow (read-only first)
- Snapshot before touching anything:
sqlite3 <bank> ".backup /tmp/snap.db" — the backup preserves page layout (freelist + page counts matched the live file exactly; cross-check with read-only
PRAGMA page_count/freelist_count/page_size).
- sqlite3_analyzer (official
sqlite-tools-osx-arm64-*.zip, or
brew install sqlite-analyzer): space utilization per table — freelist %, payload efficiency, non-sequential pages, overflow. It is a SPACE tool ONLY: zero coverage of query performance, the WAL file, or FTS behavior (the doc page
contains no "wal"/"performance" mentions — grep before claiming otherwise).
- WAL check:
PRAGMA wal_checkpoint(TRUNCATE) on the SNAPSHOT (never the live bank mid-analysis). The result row busy|log|checkpointed is the signal:
0|0|0 = the whole WAL was already-checkpointed garbage and truncation is instant;
log > 0 = real checkpoint debt (unreplayed frames).
- Quantify reclaim:
VACUUM INTO '/tmp/vac.db' on the snapshot → logical content size; the freelist share of the main file is the reclaimable delta.
- Footprint math: stat the WHOLE data dir — db +
-wal + -shm. A 431 MB WAL next to a 29 MB db with log=0 is 100 % reclaimable garbage, and the analyzer cannot see it.
WAL growth mechanics (why a WAL is huge)
- Frames accumulate until a checkpoint TRUNCATEs. Passive auto-checkpoints (default 1000 pages) keep content synced (
log=0) but CANNOT truncate while any other connection holds a read lock; with connection pooling + several long-lived
processes (stdio bridges, serve) the "last connection closed" close-time truncation never fires → the WAL grows unbounded with already-checkpointed frames.
- Measured: 431 MB WAL born the second the first bridge processes started, grown in
~4 h of traffic;
0|0|0 on the snapshot; TRUNCATE → 0 bytes in under a second.
- If the app has a checkpoint only inside a feature that never ran (e.g. a cloud-sync path), that is the root cause of unbounded growth — grep the codebase for
wal_checkpoint before designing a fix.
vec0 chunk storage — the count (*) trap
vec0 stores vectors in chunk tables (vec_entries_vector_chunks00, schema
(rowid, vectors BLOB)). ONE chunk row holds capacity for 1024 vectors: for
float[384] that is exactly 1,572,864 bytes per row. So "4 rows, 1540 pages" is NOT an emptied index — it is 2 full + 2 partial chunks for 1772 vectors (43 % slot utilization; vec_entries_chunks validity bitmaps mark live slots).
Before concluding "vectors deleted/empty": check dbstat per-table page counts (SELECT name, sum(pgsize), count(*) FROM dbstat GROUP BY name), SELECT rowid, length(vectors) (capacity math must check out), and the validity rows. This misread nearly shipped as a false finding (2026-08-07).
SQLite semantics that matter here
- WAL mode: a READ transaction does NOT block VACUUM (writers and readers coexist); only the single WRITE lock does — hold
BEGIN IMMEDIATE on another connection to simulate VACUUM-busy in tests (a plain BEGIN + SELECT will not do).
- VACUUM rewrites the whole file through the WAL → a maintenance pass should checkpoint AGAIN after VACUUM; and ANALYZE must run AFTER VACUUM (VACUUM drops
sqlite_stat1).
- VACUUM under contention throws
SQLITE_BUSY (5) / SQLITE_LOCKED (6) after the busy timeout — catch and treat as defer (Warning), never an Error.
PRAGMA wal_checkpoint returns a ROW — ExecuteNonQueryAsync discards it; read the tuple (GetInt32(0) = busy).
- Per-connection PRAGMAs (
busy_timeout) survive pool return — restore the factory default before disposal or the next borrower inherits them.
TimeSpan.FromDays overflows for intervals > ~10.7 M days — clamp parse inputs.
- Absent ANALYZE → zero
sqlite_stat* tables (planner heuristics; low impact for point-lookup + FTS5/vec0 workloads — those manage their own indexes).
Maintenance fix pattern (bounded footprint)
A BackgroundService in EVERY process that opens the bank: startup checkpoint (bounds the WAL for short-lived processes — their only maintenance), StopAsync final best-effort checkpoint, periodic timer (hourly) wal_checkpoint(TRUNCATE),
and VACUUM + ANALYZE on a weekly cadence with an in-memory per-process clock (short-lived processes never vacuum). Busy → defer + retry next tick. Settings-driven intervals with safe fallbacks. Measured result: 461 MB → ~16–20 MB steady
state; TRUNCATE
~0 s, VACUUM ~160 ms on a 29 MB bank. Implementation reference: the project PR #79 (BankMaintenanceHostedService).
Gotchas
- The vec0 chunk
count(*) trap: vec0 tables count chunks, not rows — quantify reclaim with sqlite3_analyzer, not count.
- Order matters: VACUUM INTO quantifies reclaim read-only; checkpoint(TRUNCATE) only truncates WAL frames.
1---2name: sqlite-bank-space-diagnosis3description: Use when a SQLite bank file or WAL is bloated: diagnose space read-only first (snapshot backup, sqlite3_analyzer, wal_checkpoint(TRUNCATE), VACUUM INTO to quantify reclaim), explain WAL growth mechanics (checkpointed-but-untruncated frames under pooling), the vec0 chunk count(*) trap, and VACUUM/checkpoint/ANALYZE ordering.4license: MIT5---67# SQLite bank space & WAL diagnosis (physical layer)89Measured methods from a 2026-08 memory-bank audit. Complements the SQLite schema10review skill (schema semantics); this covers the physical layer — file size, WAL11mechanics and VACUUM semantics.1213## Diagnosis workflow (read-only first)14151. **Snapshot before touching anything**: `sqlite3 <bank> ".backup /tmp/snap.db"` — the backup preserves page layout (freelist + page counts matched the live file exactly; cross-check with read-only16 `PRAGMA page_count/freelist_count/page_size`).172. **sqlite3_analyzer** (official `sqlite-tools-osx-arm64-*.zip`, or18 `brew install sqlite-analyzer`): space utilization per table — freelist %, payload efficiency, non-sequential pages, overflow. It is a SPACE tool ONLY: zero coverage of query performance, the WAL file, or FTS behavior (the doc page19 contains no "wal"/"performance" mentions — grep before claiming otherwise).203. **WAL check**: `PRAGMA wal_checkpoint(TRUNCATE)` on the SNAPSHOT (never the live bank mid-analysis). The result row `busy|log|checkpointed` is the signal:21 `0|0|0` = the whole WAL was already-checkpointed garbage and truncation is instant;22 `log > 0` = real checkpoint debt (unreplayed frames).234. **Quantify reclaim**: `VACUUM INTO '/tmp/vac.db'` on the snapshot → logical content size; the freelist share of the main file is the reclaimable delta.245. **Footprint math**: stat the WHOLE data dir — db + `-wal` + `-shm`. A 431 MB WAL next to a 29 MB db with `log=0` is 100 % reclaimable garbage, and the analyzer cannot see it.2526## WAL growth mechanics (why a WAL is huge)2728- Frames accumulate until a checkpoint TRUNCATEs. Passive auto-checkpoints (default 1000 pages) keep content synced (`log=0`) but CANNOT truncate while any other connection holds a read lock; with connection pooling + several long-lived29 processes (stdio bridges, serve) the "last connection closed" close-time truncation never fires → the WAL grows unbounded with already-checkpointed frames.30- Measured: 431 MB WAL born the second the first bridge processes started, grown in31 ~4 h of traffic; `0|0|0` on the snapshot; TRUNCATE → 0 bytes in under a second.32- If the app has a checkpoint only inside a feature that never ran (e.g. a cloud-sync path), that is the root cause of unbounded growth — grep the codebase for33 `wal_checkpoint` before designing a fix.3435## vec0 chunk storage — the count (*) trap3637vec0 stores vectors in chunk tables (`vec_entries_vector_chunks00`, schema38`(rowid, vectors BLOB)`). ONE chunk row holds capacity for **1024 vectors**: for39`float[384]` that is exactly 1,572,864 bytes per row. So "4 rows, 1540 pages" is NOT an emptied index — it is 2 full + 2 partial chunks for ~1772 vectors (~43 % slot utilization; `vec_entries_chunks` validity bitmaps mark live slots).40Before concluding "vectors deleted/empty": check dbstat per-table page counts (`SELECT name, sum(pgsize), count(*) FROM dbstat GROUP BY name`), `SELECT rowid,41length(vectors)` (capacity math must check out), and the validity rows. This misread nearly shipped as a false finding (2026-08-07).4243## SQLite semantics that matter here4445- **WAL mode: a READ transaction does NOT block VACUUM** (writers and readers coexist); only the single WRITE lock does — hold `BEGIN IMMEDIATE` on another connection to simulate VACUUM-busy in tests (a plain `BEGIN` + SELECT will not do).46- VACUUM rewrites the whole file **through the WAL** → a maintenance pass should checkpoint AGAIN after VACUUM; and ANALYZE must run AFTER VACUUM (VACUUM drops47 `sqlite_stat1`).48- VACUUM under contention throws `SQLITE_BUSY` (5) / `SQLITE_LOCKED` (6) after the busy timeout — catch and treat as defer (Warning), never an Error.49- `PRAGMA wal_checkpoint` returns a ROW — `ExecuteNonQueryAsync` discards it; read the tuple (`GetInt32(0)` = busy).50- Per-connection PRAGMAs (`busy_timeout`) survive pool return — restore the factory default before disposal or the next borrower inherits them.51- `TimeSpan.FromDays` overflows for intervals > ~10.7 M days — clamp parse inputs.52- Absent ANALYZE → zero `sqlite_stat*` tables (planner heuristics; low impact for point-lookup + FTS5/vec0 workloads — those manage their own indexes).5354## Maintenance fix pattern (bounded footprint)5556A BackgroundService in EVERY process that opens the bank: startup checkpoint (bounds the WAL for short-lived processes — their only maintenance), `StopAsync` final best-effort checkpoint, periodic timer (hourly) `wal_checkpoint(TRUNCATE)`,57and VACUUM + ANALYZE on a weekly cadence with an in-memory per-process clock (short-lived processes never vacuum). Busy → defer + retry next tick. Settings-driven intervals with safe fallbacks. Measured result: 461 MB → ~16–20 MB steady58state; TRUNCATE59~0 s, VACUUM ~160 ms on a 29 MB bank. Implementation reference: the project PR #79 (`BankMaintenanceHostedService`).6061## Gotchas6263- The vec0 chunk `count(*)` trap: vec0 tables count chunks, not rows — quantify reclaim with sqlite3_analyzer, not count.64- Order matters: VACUUM INTO quantifies reclaim read-only; checkpoint(TRUNCATE) only truncates WAL frames.