Commit 5b674f5
Fixes #20426
Clause-②: no
`reclaimSpace()` on better-sqlite3 now returns the freed bytes from the
`-wal` sidecar as well as the freelist, and it never waits on another
connection. Every size below is the database file plus its `-wal` file,
read from the file system while the driver is still open. Every freelist
and page count is read from a second connection. Measured head:
`effb34a8a` (the branch after merging `origin/main` at `b28550818`,
which carries PR #20427).
## What was wrong
With PR #20425, one `Database.exec('PRAGMA incremental_vacuum')` returns
the whole freelist in one transaction. In WAL mode, the file-backed
default, that transaction's dirty pages outgrow the page cache, so
SQLite spills them into the WAL before the commit truncates them away.
Nothing afterwards truncates the WAL, so the sidecar keeps its
high-water size until the last connection closes.
## What changed
- `packages/drivers/driver-sql/src/sql-driver.ts`: the better-sqlite3
arm of `SqlDriver.reclaimSpace` calls a module-local
`reclaimBetterSqlite3(connection)`. It is module-local, like
`formatDuplicateGroups`, because `SqlDriver`'s `.d.ts` carries its
non-public members and this helper is no entry point. The published
types are unchanged; the `.d.ts` gains one doc-comment sentence on
`reclaimSpace`. The helper:
1. reads `PRAGMA freelist_count`, and sends nothing more when it is `0`;
2. runs `PRAGMA incremental_vacuum(N)` in chunks, N being a quarter of
this connection's page cache (1,000 pages at better-sqlite3's default
`cache_size = -16000` and 4 KiB pages), with a `PASSIVE` checkpoint
after each chunk;
3. stops when the freelist is empty or a chunk frees nothing (an
`auto_vacuum = NONE` file never shrinks its freelist);
4. ends with one `PRAGMA wal_checkpoint(TRUNCATE)` under a busy timeout
of `0`, and puts the connection's own busy timeout back in a `finally`.
Every statement goes through the binding's `exec()` / `pragma()`, which
step to completion. The loop is synchronous, so nothing else runs on the
connection between chunks. Every other SQLite client stays on
`knex.raw`, as before.
- Tests in `driver-sql` and `driver-turso` (below), and
`.changeset/20426-reclaim-space-wal-sidecar.md`
(`@objectstack/driver-sql`: `patch`).
- `.changeset/20106-reclaim-space-full-freelist.md`: one paragraph
removed. It said the freed pages pass through the `-wal` file, "which
keeps its size until the last connection closes". This PR makes that
false, and that note is still pending release. **This keeps
`check-empty-changeset` red on purpose** — see "The one red gate" below.
## The dispatch's hypotheses
- **H1 — confirmed** on `origin/main` `8cdbe0c6e`, through `SqlDriver`
(25,754 free pages):
| step | database file | `-wal` | freelist / pages |
|:--|--:|--:|:--|
| after the delete | 103,149,568 | 4,255,992 | 25,754 / 25,789 |
| after `reclaimSpace()` (351 ms) | 16,384 | 94,430,432 | 0 / 4 |
| after one more write | 16,384 | 94,430,432 | 0 / 4 |
| after `disconnect()` | 16,384 | 0 | 0 / 4 |
The DELETE-journal control on the same tree: 105,631,744 → 16,384 while
open, with no `-wal` file.
- **H2 — re-measured on this tree, and the picked variant is a fourth
one.** Each variant ran on `SqlDriver`'s own pooled connection after the
real fill-and-delete path (25,754 free pages, chunk 1,000). The rows
show database file + `-wal` after the call, driver open. This is one run
per cell on a shared box, so read the ratios, not the absolute times.
| variant | no reader | reader in this process (read transaction open) |
reader in another process (open for 1.5 s) |
|:--|:--|:--|:--|
| `exec` alone (PR #20425) | 16,384 + 94,430,432 · 315 ms | 103,149,568
+ 94,430,432 · 715 ms | 103,149,568 + 94,430,432 · 273 ms |
| + `wal_checkpoint(TRUNCATE)` | 16,384 + 0 · 476 ms | 103,149,568 +
94,430,432, busy · **5,333 ms** | 16,384 + 0 · **1,526 ms** (waited out
the reader) |
| chunked + `PASSIVE` | 16,384 + 4,255,992 · 157 ms | 103,149,568 +
4,255,992 · 47 ms | 103,149,568 + 4,255,992 · 62 ms |
| **chunked + `PASSIVE` + `TRUNCATE` at busy timeout 0 (this PR)** |
**16,384 + 0** · 276 ms, 108 ms on a rerun | 103,149,568 + 4,255,992,
busy · 48 ms | 103,149,568 + 4,255,992, busy · 61 ms |
- The #20106 reading of about 210 KB for chunked + `PASSIVE` does not
hold through `SqlDriver`. `PASSIVE` never shrinks the sidecar: it stays
at whatever high-water size the sweep's own deletes left (4,255,992
here). Only a `TRUNCATE` checkpoint returns it.
- A waiting `TRUNCATE` checkpoint blocks the whole process on this
synchronous binding, for up to the connection's busy timeout (5,000 ms;
knex's better-sqlite3 client passes no `timeout`, so it is always
better-sqlite3's default). The lifecycle sweep runs in the server
process, so the triage's never-wait direction holds.
- So this PR takes the triage's chunked, never-waiting variant, plus one
`TRUNCATE` checkpoint that cannot wait. It is the only row that both
returns the space with no reader and never waits with one.
- With a reader present, no variant can shrink the database file. The
chunked rows keep the pair at its size before the call (107,405,560).
The one-statement rows grow it to 197,580,000.
**The chunk size, and why.** A chunk that outgrows the page cache spills
its pages into the WAL, just as one statement does. Frames left in the
WAL by the call, with a reader pinning every frame so none is reused:
| chunk (pages) | 100 | 250 | 500 | 1,000 | 2,000 | 4,000 | 8,000 | one
statement |
|:--|--:|--:|--:|--:|--:|--:|--:|--:|
| default cache (`-16000`) | 1,437 | 1,121 | 1,003 | 928 | 883 | 3,779 |
14,216 | 22,920 |
| 2 MB cache (`-2000`) | | 1,121 | 4,554 | 15,491 | | | | |
- The spill starts where the chunk reaches the page cache: between 2,000
and 4,000 pages at the default (`PRAGMA cache_spill` reads 3,871), and
between 250 and 500 at `-2000`.
- Below that point, larger chunks mean fewer commits and fewer frames.
- A fixed 1,000 would spill on a connection with a smaller cache or
larger pages. So N is derived from the connection's own `cache_size` and
`page_size`, and the quarter leaves room for the per-page overhead and
the b-tree pages each chunk rewrites. At the default that is 1,000 pages
(4 MB).
- **H3 — confirmed.** `resolveSqliteJournalMode()` answers `wal` for a
file-backed database unless configured otherwise, and the probe's second
connection reads `journal_mode = wal`. The DELETE-journal control is
unchanged by the fix. Before and after, the file shrinks while the
driver is open and no `-wal` file exists: 105,631,744 → 16,384, 216 ms
before and 127 ms after.
- **H4 — confirmed.** The local `TursoDriver` face uses knex's
`better-sqlite3` client, so it takes this arm through
`super.reclaimSpace()`. Its suite reached the method, but it read only
the freelist and the page count. It now has a WAL-size case. The remote
route is untouched.
- **H5 — nothing new is thrown, so the sweep logs nothing new.** Both
checkpoints report "busy" as a result row, not as an error. So a busy
checkpoint degrades to "vacuumed, not checkpointed": the call resolves,
the pages are off the freelist, and `LifecycleService.sweep()` lists the
datasource as reclaimed, as before.
- Their bytes leave the files at a later checkpoint: the next reclaim
with free pages, SQLite's auto-checkpoint at 1,000 frames, or the last
connection closing. The reader case of the new test measures the next
reclaim.
- What can still throw is unchanged. Another connection holding the
write lock (`BEGIN IMMEDIATE`) makes the vacuum statement itself wait
out the busy timeout and throw `SQLITE_BUSY`. Measured: `main` 5,021 ms
and this PR 5,014 ms, both freelist unchanged, busy timeout 5,000
afterwards.
- In that case the sweep logs its existing warning (`space reclaim on
datasource 'X' failed (database is locked)`) and does not list the
datasource.
- The busy-timeout swap comes after the loop, so a throw inside the loop
never reaches it.
## The fix through `SqlDriver`
Same 25,754-page fixture:
| condition | database file + `-wal` after the call | call | busy
timeout after |
|:--|:--|--:|--:|
| WAL, no reader (was 103,149,568 + 4,255,992) | 16,384 + 0 | 101 ms,
109 ms | 5,000 |
| DELETE journal | 16,384, no `-wal` | 127 ms | 5,000 |
| WAL, reader in this process | 103,149,568 + 4,255,992 (unchanged;
freelist 0) | 47 ms | 5,000 |
| WAL, reader in another process | 103,149,568 + 4,255,992 (unchanged;
freelist 0) | 66 ms | 5,000 |
## Tests
`driver-sql/src/sql-driver-sqlite-reclaim-space.test.ts`, 7 cases (4
before). Each size is the database file plus the `-wal` file, read while
the driver is open. Each freelist and page count is read from a second
connection.
- **WAL:** freelist 0, and `{ file: pages × 4096, wal: 0 }` while open
and again after close.
- **WAL with a reader holding a read transaction.** The reopened file
has no WAL, the cache is set to about 100 pages, and 600 pages are free.
The case asserts:
- the call resolves in under half the busy timeout;
- the busy timeout reads 5,000 afterwards;
- freelist 0;
- WAL growth under a quarter of the freed bytes. Measured: 0.08 for this
PR, and 0.87 for both one statement and a fixed 1,000-page chunk.
- Once the reader commits, the next reclaim returns everything: `{ file:
pages × 4096, wal: 0 }`.
- **The `auto_vacuum = NONE` control:**
- the call resolves, so the loop stopped;
- freelist and pages are unchanged;
- the database file equals pages × 4096;
- file + `-wal` is no larger than before.
- **DELETE journal:** freelist 0, and `{ file: pages × 4096, wal: 0 }`
while open.
- **The empty-freelist control, for both journal modes:** nothing
changes, the sizes included.
- **Unchanged:** the pooled connection is handed back.
`driver-turso/src/turso-remote-inherited-members.test.ts`: new case
"local face: in WAL mode the freed bytes leave the -wal sidecar too,
while the driver is still open".
Suites on the merged head `effb34a8a`, all through
`scripts/pm/os-verify-lock.sh`, each exit code recorded:
- `pnpm --filter @objectstack/driver-sql test`: exit 0, 195 files passed
and 11 skipped; 3,179 tests passed and 178 skipped. The count before the
merge was 3,227; the merge brought in PR #20427, which removed tests of
its own.
- `pnpm --filter @objectstack/driver-turso test`: exit 0, 74 files;
1,982 passed and 16 skipped.
- `typecheck` for `driver-sql` and `driver-turso`: exit 0 each. `tsc
--listFilesOnly` shows both changed test files are in each package's
program.
## Ablations
Every leg ran on the committed state through
`scripts/ablation-replace.mjs`. In each, the anchor went from 1 hit to
0, and the restore was proven blob-equal to HEAD with an empty `git diff
HEAD`. The `driver-sql` suite imports `./sql-driver.js` (source), so
those legs needed no build.
| leg | mutation | result |
|:--|:--|:--|
| A | final `TRUNCATE` checkpoint removed | 3 red: WAL `{16,384 +
1,334,912}` vs `{16,384 + 0}`; the reader case's follow-up `{16,384 +
296,672}`; the NONE control's pair grew 1,318,384 → 2,555,376. 4 green.
|
| B | one statement instead of chunks | 1 red: the reader case, WAL
growth 2,142,400 vs a bound of 618,496. 6 green. |
| C | fixed 1,000-page chunk instead of the derived one | 1 red: the
reader case, 2,142,400 vs 618,496. 6 green. |
| D | busy timeout not zeroed for the `TRUNCATE` | 1 red: the reader
case, elapsed 5,034.99 ms vs under 2,500. 6 green. |
| E | busy timeout not restored | 1 red: the reader case, busy timeout 0
vs 5,000. 6 green. |
| F | the no-progress stop removed | the NONE control hung in the
synchronous loop and was killed after 60 s (SIGKILL). |
| A, dist | leg A built into `driver-sql`'s `dist/`, which
`driver-turso` resolves | `ablation-dist-preflight` found the marker in
2 built files. `driver-turso`: 1 red (local face `{32,768 + 280,192}` vs
`{32,768 + 0}`), 82 green. After the restore and a rebuild, `--absent`
found the marker in none of the 6 built files, and the tree was clean. |
In the first B–E runs, the red reader case also timed out its cleanup
hook: the failed assertion left the reader's transaction open. The fixed
case rolls the transaction back first. A rerun of leg B went red in 91
ms with no hook timeout.
## Gates
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `effb34a8a` derived 63 commands. All 63
ran, and every exit code was recorded before any pipe. 62 exited 0;
`check-empty-changeset --base origin/main` exited 1 (next section).
- `--ran`: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.
- The `--ran` pass printed a STALE TREE warning: `origin/main` moved 6
commits after the merge, and `scripts/cross-package-test-inputs.mjs`
changed in that range. Of those 6 commits, only PR #20447 touches a
driver: it changes the `driver-turso` constructor, and none of this PR's
files. CI reads the merge ref.
- `check:driver-conformance`: 50 covered, 0 DEBT, 0 exempt, both before
(`8cdbe0c6e`) and after (`effb34a8a`).
- `pnpm lint` is CI's run. The narrowed run: `eslint --no-inline-config
--format json` over the 3 changed `.ts` files reports 3 files, 0 errors
and 0 warnings. `ESLint.isPathIgnored` answers false for each, so all
three are in `pnpm lint`'s population. `eslint.config.mjs` sets no
`parserOptions.project` and no typed rule, so this diff cannot move the
verdict of an untouched file.
## The one red gate: `check-empty-changeset` (a deliberate correction,
for confirmation)
This PR edits `.changeset/20106-reclaim-space-full-freelist.md`, which
exists on the merge base. The gate refuses that by name, and its own
text sets out two classes. This is the **deliberate correction** class,
not a collision.
- The removed paragraph says the `-wal` file "keeps its size until the
last connection closes". After this PR it is truncated at the end of the
call unless another connection is reading.
- That note has not been released, so restoring it from the base would
publish the false sentence.
- The gate's prescription for this class is to leave it red and get the
correction confirmed on the PR. `skip-changeset` is not applied and must
not be: this PR publishes a `patch`.
**For the seat: please confirm, or choose the other route.** The other
route is to restore the 20106 file from the merge base. The gate then
goes green, but the release would carry that sentence beside this PR's
own changeset, which describes the new behaviour.
## Acceptance notes
- **The file surface is widened by one file.** The claim names
`.changeset/20426-*.md`, and this PR also edits
`.changeset/20106-reclaim-space-full-freelist.md` (one paragraph
removed). It is the same defect, a mechanical removal, a card that has
already landed, and the same changeset gate family.
- **Behind a long reader, the bytes wait.** When a reader holds a
snapshot during the call, the database file keeps its size until a later
checkpoint. `LifecycleService.sweep()` still lists the datasource as
reclaimed. The next sweep that deletes rows returns it, and SQLite's
auto-checkpoint or the last close returns it sooner. No producer is left
worse off than on `main`, where the same reader left 197,580,000 bytes
instead of 107,405,560.
- **Partial progress is possible.** Chunks commit one by one. Another
process can take the write lock between two chunks, and then the next
chunk waits up to the busy timeout and may throw with the earlier chunks
already committed. This was not measured. A one-statement vacuum waited
and threw the same way, all or nothing.
- **Blocking is shorter, not gone.** The call still blocks the event
loop while it runs: 101 to 276 ms at 25,754 pages on this shared box,
against 315 to 351 ms for PR #20425's single statement.
- The remote `TursoDriver` route, `SqliteWasmDriver`, `LifecycleService`
and `packages/spec` are untouched.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent e956924 commit 5b674f5
5 files changed
Lines changed: 203 additions & 10 deletions
File tree
- .changeset
- packages/drivers
- driver-sql/src
- driver-turso/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
17 | | - | |
18 | | - | |
19 | 17 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
Lines changed: 98 additions & 6 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
11 | 18 | | |
12 | 19 | | |
13 | | - | |
| 20 | + | |
14 | 21 | | |
15 | 22 | | |
16 | 23 | | |
| |||
28 | 35 | | |
29 | 36 | | |
30 | 37 | | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
31 | 48 | | |
32 | 49 | | |
33 | 50 | | |
| |||
73 | 90 | | |
74 | 91 | | |
75 | 92 | | |
76 | | - | |
| 93 | + | |
77 | 94 | | |
78 | 95 | | |
79 | 96 | | |
80 | 97 | | |
81 | 98 | | |
| 99 | + | |
82 | 100 | | |
83 | 101 | | |
84 | 102 | | |
85 | 103 | | |
86 | 104 | | |
| 105 | + | |
87 | 106 | | |
88 | | - | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
89 | 179 | | |
90 | 180 | | |
91 | 181 | | |
| |||
100 | 190 | | |
101 | 191 | | |
102 | 192 | | |
103 | | - | |
| 193 | + | |
104 | 194 | | |
105 | 195 | | |
106 | | - | |
| 196 | + | |
107 | 197 | | |
108 | | - | |
| 198 | + | |
109 | 199 | | |
110 | 200 | | |
111 | 201 | | |
| 202 | + | |
112 | 203 | | |
113 | 204 | | |
114 | 205 | | |
115 | 206 | | |
| 207 | + | |
116 | 208 | | |
117 | 209 | | |
118 | 210 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5440 | 5440 | | |
5441 | 5441 | | |
5442 | 5442 | | |
| 5443 | + | |
| 5444 | + | |
| 5445 | + | |
| 5446 | + | |
| 5447 | + | |
| 5448 | + | |
| 5449 | + | |
| 5450 | + | |
| 5451 | + | |
| 5452 | + | |
| 5453 | + | |
| 5454 | + | |
| 5455 | + | |
| 5456 | + | |
| 5457 | + | |
| 5458 | + | |
| 5459 | + | |
| 5460 | + | |
| 5461 | + | |
| 5462 | + | |
| 5463 | + | |
| 5464 | + | |
| 5465 | + | |
| 5466 | + | |
| 5467 | + | |
| 5468 | + | |
| 5469 | + | |
| 5470 | + | |
| 5471 | + | |
| 5472 | + | |
| 5473 | + | |
| 5474 | + | |
| 5475 | + | |
| 5476 | + | |
| 5477 | + | |
| 5478 | + | |
| 5479 | + | |
| 5480 | + | |
| 5481 | + | |
| 5482 | + | |
| 5483 | + | |
| 5484 | + | |
| 5485 | + | |
| 5486 | + | |
| 5487 | + | |
| 5488 | + | |
| 5489 | + | |
| 5490 | + | |
| 5491 | + | |
| 5492 | + | |
| 5493 | + | |
| 5494 | + | |
| 5495 | + | |
| 5496 | + | |
| 5497 | + | |
| 5498 | + | |
| 5499 | + | |
| 5500 | + | |
| 5501 | + | |
| 5502 | + | |
| 5503 | + | |
| 5504 | + | |
| 5505 | + | |
| 5506 | + | |
| 5507 | + | |
| 5508 | + | |
| 5509 | + | |
| 5510 | + | |
| 5511 | + | |
| 5512 | + | |
| 5513 | + | |
| 5514 | + | |
| 5515 | + | |
5443 | 5516 | | |
5444 | 5517 | | |
5445 | 5518 | | |
| |||
10944 | 11017 | | |
10945 | 11018 | | |
10946 | 11019 | | |
| 11020 | + | |
| 11021 | + | |
| 11022 | + | |
| 11023 | + | |
10947 | 11024 | | |
10948 | 11025 | | |
10949 | 11026 | | |
10950 | 11027 | | |
10951 | 11028 | | |
10952 | 11029 | | |
10953 | 11030 | | |
10954 | | - | |
| 11031 | + | |
10955 | 11032 | | |
10956 | 11033 | | |
10957 | 11034 | | |
| |||
Lines changed: 12 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
| 42 | + | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| |||
306 | 306 | | |
307 | 307 | | |
308 | 308 | | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
309 | 320 | | |
310 | 321 | | |
311 | 322 | | |
| |||
0 commit comments