Skip to content

Commit 5b674f5

Browse files
fix(driver-sql): reclaimSpace() returns the freed bytes from the SQLite -wal sidecar too, never waiting on another connection (#20426) (#20463)
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/20106-reclaim-space-full-freelist.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,4 @@ Clause-②: no
1414

1515
`SqliteWasmDriver` was already complete: its dialect steps every PRAGMA to the end (300 → 0 before and after this change).
1616

17-
On a file-backed database in WAL mode (the default) the database file shrinks once a checkpoint runs, and during the call the freed pages pass through the `-wal` file, which keeps its size until the last connection closes.
18-
1917
Nothing to migrate: `reclaimSpace()` keeps its signature, and a database whose `auto_vacuum` mode is not `INCREMENTAL` still reclaims nothing, as before.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): `reclaimSpace()` returns the freed bytes from the SQLite `-wal` sidecar too, and never waits on another connection (#20426)
6+
7+
Clause-②: no
8+
9+
On a file-backed SQLite database in WAL mode, the default, `reclaimSpace()` returned the whole freelist but left the freed bytes in the `-wal` sidecar. At 25,754 free pages the database file went from 103,149,568 to 16,384 bytes while the `-wal` file went from 4,255,992 to 94,430,432 bytes, and it kept that size until the last connection closed. The lifecycle sweep calls this method after every sweep that deleted rows, and it reported the datasource as reclaimed.
10+
11+
On better-sqlite3 (`SqlDriver`, and `TursoDriver` in local mode) the vacuum now runs in chunks of a quarter of the connection's page cache, 1,000 pages at the default cache size, with a `PASSIVE` checkpoint after each chunk. One `TRUNCATE` checkpoint closes the call, taken with a busy timeout of 0, so it never waits on another connection. On the same database, file plus `-wal` goes from 107,405,560 to 16,384 bytes while the driver is still open.
12+
13+
When another connection holds a read transaction, the call still returns without waiting (47 to 66 ms measured; a `TRUNCATE` checkpoint that waits blocked the process for the connection's 5-second busy timeout). The pages are off the freelist, and their bytes leave the files at a later checkpoint. The call no longer grows the pair either: 107,405,560 bytes before and after, where the single statement grew it to 197,580,000.
14+
15+
A database in rollback-journal (`delete`) mode behaves as before. The remote `TursoDriver` route and `SqliteWasmDriver` are unchanged. Nothing to migrate: `reclaimSpace()` keeps its signature.

‎packages/drivers/driver-sql/src/sql-driver-sqlite-reclaim-space.test.ts‎

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@
88
// `Statement.run()`, which steps it once, so this method used to free ONE page
99
// per call: 300 → 299 from a second connection, and the lifecycle sweep that
1010
// calls it after every bulk delete left the file at its high-water mark.
11+
//
12+
// In WAL mode — the file-backed default — the freed bytes must leave the
13+
// `-wal` sidecar too, so every size below is the database file PLUS the
14+
// `-wal` file, read while the driver is still open. One statement over the
15+
// whole freelist spilled its pages into the WAL (25,754 free pages: the file
16+
// went to 16,384 bytes and the `-wal` to 94,430,432, held until the last
17+
// connection closed), and nothing checkpointed or truncated it.
1118

1219
import { afterEach, describe, expect, it } from 'vitest';
13-
import { mkdtempSync, rmSync, statSync } from 'node:fs';
20+
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
1421
import { tmpdir } from 'node:os';
1522
import { join } from 'node:path';
1623
import knex, { type Knex } from 'knex';
@@ -28,6 +35,16 @@ function tempDb(): string {
2835
return join(dir, 'app.db');
2936
}
3037

38+
/** The `-wal` sidecar's size in bytes; 0 when there is none. */
39+
function walBytes(filename: string): number {
40+
return existsSync(`${filename}-wal`) ? statSync(`${filename}-wal`).size : 0;
41+
}
42+
43+
/** The database file and its `-wal` sidecar, as the file system reports them. */
44+
function onDisk(filename: string): { file: number; wal: number } {
45+
return { file: statSync(filename).size, wal: walBytes(filename) };
46+
}
47+
3148
/** A connected driver on `filename`, and a `close()` the cleanup then skips. */
3249
async function openDriver(
3350
filename: string,
@@ -73,19 +90,92 @@ async function secondConnection(filename: string): Promise<{ freelist: number; p
7390
}
7491

7592
describe('SqlDriver.reclaimSpace() on better-sqlite3 returns the whole freelist', () => {
76-
it('WAL (the file-backed default): every free page leaves the database, and the file shrinks once closed', async () => {
93+
it('WAL (the file-backed default): every free page leaves the database, and its bytes leave the -wal sidecar too, while the driver is open', async () => {
7794
const file = tempDb();
7895
const { driver, close } = await openDriver(file);
7996
await freePages(driver, 300);
8097
const before = await secondConnection(file);
8198
expect(before.freelist).toBeGreaterThanOrEqual(250);
99+
expect(walBytes(file)).toBeGreaterThan(0);
82100

83101
await driver.reclaimSpace();
84102

85103
const after = await secondConnection(file);
86104
expect(after).toEqual({ freelist: 0, pages: before.pages - before.freelist });
105+
expect(onDisk(file)).toEqual({ file: after.pages * PAGE_SIZE, wal: 0 });
87106
await close();
88-
expect(statSync(file).size).toBe(after.pages * PAGE_SIZE);
107+
expect(onDisk(file)).toEqual({ file: after.pages * PAGE_SIZE, wal: 0 });
108+
});
109+
110+
it('WAL, another connection holding a read transaction: the call neither waits nor fails, and writes the WAL a chunk at a time', async () => {
111+
const file = tempDb();
112+
// Fill and delete, then reopen: the last close checkpoints and removes the
113+
// WAL, so with the reader below pinning every frame, the `-wal` size after
114+
// the call is exactly what the call wrote.
115+
const first = await openDriver(file);
116+
await freePages(first.driver, 600);
117+
await first.close();
118+
const { driver } = await openDriver(file);
119+
// A page cache of about 100 pages (400 KiB): one statement over 600 free
120+
// pages outgrows it and spills them into the WAL, and so does any fixed
121+
// chunk sized for the default cache.
122+
await driver.execute('PRAGMA cache_size = -400');
123+
124+
const reader: Knex = knex({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true });
125+
cleanup.push(() => reader.destroy());
126+
const snapshot = await reader.transaction();
127+
// Runs before the destroy above: a failed assertion must not leave the
128+
// reader's connection checked out, or the destroy waits for it.
129+
cleanup.push(async () => {
130+
if (!snapshot.isCompleted()) await snapshot.rollback();
131+
});
132+
await snapshot.raw('SELECT count(*) AS n FROM bulk');
133+
const before = await secondConnection(file);
134+
expect(before.freelist).toBeGreaterThanOrEqual(550);
135+
const walBefore = walBytes(file);
136+
137+
const started = performance.now();
138+
await expect(driver.reclaimSpace()).resolves.toBeUndefined();
139+
const elapsed = performance.now() - started;
140+
141+
// Waiting on the reader would take the connection's whole busy timeout.
142+
const [{ timeout }] = (await driver.execute('PRAGMA busy_timeout')) as Array<{ timeout: number }>;
143+
expect(timeout).toBe(5000);
144+
expect(elapsed).toBeLessThan(timeout / 2);
145+
expect(await secondConnection(file)).toEqual({ freelist: 0, pages: before.pages - before.freelist });
146+
// Measured: 0.08 of the freed bytes in 25-page chunks, 0.87 for one
147+
// statement and for one 1,000-page chunk alike.
148+
expect(walBytes(file) - walBefore).toBeLessThan((before.freelist * PAGE_SIZE) / 4);
149+
150+
// Once the reader is gone, the next reclaim returns what this one left.
151+
await snapshot.commit();
152+
await freePages(driver, 10);
153+
expect((await secondConnection(file)).freelist).toBeGreaterThan(0);
154+
await driver.reclaimSpace();
155+
const settled = await secondConnection(file);
156+
expect(settled.freelist).toBe(0);
157+
expect(onDisk(file)).toEqual({ file: settled.pages * PAGE_SIZE, wal: 0 });
158+
});
159+
160+
it('control: a file whose auto_vacuum is still NONE frees nothing, and the loop stops', async () => {
161+
const file = tempDb();
162+
// Created before the driver's INCREMENTAL default: a table already exists,
163+
// so that default cannot change this file's layout.
164+
const legacy: Knex = knex({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true });
165+
await legacy.raw('CREATE TABLE legacy_marker (x INTEGER)');
166+
await legacy.destroy();
167+
const { driver } = await openDriver(file);
168+
await freePages(driver, 300);
169+
const before = await secondConnection(file);
170+
expect(before.freelist).toBeGreaterThanOrEqual(250);
171+
const diskBefore = onDisk(file);
172+
173+
await expect(driver.reclaimSpace()).resolves.toBeUndefined();
174+
175+
expect(await secondConnection(file)).toEqual(before);
176+
const disk = onDisk(file);
177+
expect(disk.file).toBe(before.pages * PAGE_SIZE);
178+
expect(disk.file + disk.wal).toBeLessThanOrEqual(diskBefore.file + diskBefore.wal);
89179
});
90180

91181
it('DELETE journal: the file shrinks while the driver is still open', async () => {
@@ -100,19 +190,21 @@ describe('SqlDriver.reclaimSpace() on better-sqlite3 returns the whole freelist'
100190

101191
const after = await secondConnection(file);
102192
expect(after).toEqual({ freelist: 0, pages: before.pages - before.freelist });
103-
expect(statSync(file).size).toBe(after.pages * PAGE_SIZE);
193+
expect(onDisk(file)).toEqual({ file: after.pages * PAGE_SIZE, wal: 0 });
104194
});
105195

106-
it('control: an empty freelist resolves, and nothing changes', async () => {
196+
it.each(['wal', 'delete'] as const)('control, %s journal: an empty freelist resolves, and nothing changes', async (mode) => {
107197
const file = tempDb();
108-
const { driver } = await openDriver(file, { sqliteJournalMode: 'delete' });
198+
const { driver } = await openDriver(file, { sqliteJournalMode: mode });
109199
await freePages(driver, 0);
110200
const before = await secondConnection(file);
111201
expect(before.freelist).toBe(0);
202+
const diskBefore = onDisk(file);
112203

113204
await expect(driver.reclaimSpace()).resolves.toBeUndefined();
114205

115206
expect(await secondConnection(file)).toEqual(before);
207+
expect(onDisk(file)).toEqual(diskBefore);
116208
});
117209

118210
it('the pooled connection is handed back: the driver answers a query after the call', async () => {

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5440,6 +5440,79 @@ function formatDuplicateGroups(duplicates: ReadonlyArray<{ key: string; rows: nu
54405440
return duplicates.length > 5 ? `${shown}; \u2026and ${duplicates.length - 5} more group(s)` : shown;
54415441
}
54425442

5443+
/** The part of a better-sqlite3 `Database` that {@link reclaimBetterSqlite3} drives. */
5444+
interface BetterSqlite3Connection {
5445+
exec(sql: string): unknown;
5446+
pragma(source: string, options?: { simple: boolean }): unknown;
5447+
}
5448+
5449+
/**
5450+
* The better-sqlite3 arm of `SqlDriver.reclaimSpace`: return the whole freelist,
5451+
* and return the bytes it passes through the `-wal` sidecar too, without ever
5452+
* waiting on another connection. Every statement runs through the binding's
5453+
* `exec()` / `pragma()`, which step to completion. Synchronous by design:
5454+
* nothing else runs on the connection between the chunks, so the freelist
5455+
* only shrinks while the loop runs.
5456+
*
5457+
* Why chunks. In WAL mode one `PRAGMA incremental_vacuum` over a large
5458+
* freelist is one transaction whose dirty pages outgrow the page cache, so
5459+
* SQLite spills them into the WAL before the commit truncates them away.
5460+
* Measured at 25,754 free pages: the database file went to 16,384 bytes and
5461+
* the `-wal` sidecar to 94,430,432, held until the last connection closed. A
5462+
* chunk that stays inside the page cache writes only the pages its commit
5463+
* keeps. The chunk is a quarter of this connection's page cache — 1,000 pages
5464+
* at better-sqlite3's default `cache_size` (-16000 KiB) and 4 KiB pages.
5465+
* Readings with a reader pinning the WAL, so every frame written stays
5466+
* visible: 2,000-page chunks left 883 frames, 4,000 left 3,779, and one
5467+
* statement 22,920; with a 2 MB cache, 250-page chunks left 1,121 and
5468+
* 1,000-page chunks 15,491.
5469+
*
5470+
* Why these two checkpoints. A `PASSIVE` checkpoint after each chunk moves
5471+
* its frames into the database and lets the next chunk restart the WAL from
5472+
* its start; it never waits. What it cannot do is shrink the sidecar, which
5473+
* keeps its high-water size until something truncates it. So one
5474+
* `TRUNCATE` checkpoint closes the call, under a busy timeout of 0 for that
5475+
* one statement and the connection's own timeout put back afterwards: a
5476+
* `TRUNCATE` checkpoint waits for other connections' readers through the
5477+
* busy handler, and on this synchronous binding that wait blocks the whole
5478+
* process — measured at 5,333 ms against a reader in the same process, the
5479+
* connection's 5,000 ms timeout. When another connection is reading, the
5480+
* `PASSIVE` checkpoints move only the frames that reader no longer needs and
5481+
* the `TRUNCATE` one answers "busy" as a result row, not as an error: the
5482+
* pages are off the freelist, and their bytes leave the files at a later
5483+
* checkpoint (the next call, SQLite's own auto-checkpoint, or the last
5484+
* connection closing). Outside WAL mode both checkpoints are no-ops.
5485+
*
5486+
* The loop stops when the freelist is empty or a chunk frees nothing: a file
5487+
* whose `auto_vacuum` is still `NONE` never shrinks its freelist through
5488+
* this statement (one full `VACUUM` adopts INCREMENTAL, see `SqlDriver.connect`).
5489+
*
5490+
* Module-local, like {@link formatDuplicateGroups}: `SqlDriver`'s `.d.ts`
5491+
* carries its non-public members too, and this helper is no entry point.
5492+
*/
5493+
function reclaimBetterSqlite3(db: BetterSqlite3Connection): void {
5494+
const scalar = (pragma: string): number => Number(db.pragma(pragma, { simple: true }));
5495+
let free = scalar('freelist_count');
5496+
if (free === 0) return;
5497+
const cacheSize = scalar('cache_size');
5498+
const cachePages = cacheSize >= 0 ? cacheSize : Math.floor((-cacheSize * 1024) / scalar('page_size'));
5499+
const chunk = Math.max(1, Math.floor(cachePages / 4));
5500+
for (;;) {
5501+
db.exec(`PRAGMA incremental_vacuum(${chunk})`);
5502+
db.exec('PRAGMA wal_checkpoint(PASSIVE)');
5503+
const left = scalar('freelist_count');
5504+
if (left === 0 || left >= free) break;
5505+
free = left;
5506+
}
5507+
const busyTimeout = scalar('busy_timeout');
5508+
db.pragma('busy_timeout = 0');
5509+
try {
5510+
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
5511+
} finally {
5512+
db.pragma(`busy_timeout = ${busyTimeout}`);
5513+
}
5514+
}
5515+
54435516
export class SqlDriver implements IDataDriver {
54445517
// IDataDriver metadata
54455518
public readonly name: string = 'com.objectstack.driver.sql';
@@ -10944,14 +11017,18 @@ export class SqlDriver implements IDataDriver {
1094411017
* `knex.raw` below). knex's node-sqlite3 client runs a raw statement with
1094511018
* `Database.all()`, which reads every row too (read from knex's source; that
1094611019
* binding is not installed in this repository).
11020+
*
11021+
* On better-sqlite3 the freed bytes also leave the `-wal` sidecar, which is
11022+
* what a file-backed database in WAL mode (the default, see
11023+
* {@link applySqliteJournalMode}) needs — see `reclaimBetterSqlite3`.
1094711024
*/
1094811025
async reclaimSpace(_options?: DriverOptions): Promise<void> {
1094911026
if (!this.isSqlite) return;
1095011027
const client = this.knex.client;
1095111028
if (client.driverName === 'better-sqlite3') {
1095211029
const connection = await client.acquireConnection();
1095311030
try {
10954-
connection.exec('PRAGMA incremental_vacuum');
11031+
reclaimBetterSqlite3(connection);
1095511032
} finally {
1095611033
await client.releaseConnection(connection);
1095711034
}

‎packages/drivers/driver-turso/src/turso-remote-inherited-members.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
* | the other 13 | unchanged, and true on this face | unchanged |
4040
*/
4141

42-
import { mkdtempSync, rmSync, statSync } from 'node:fs';
42+
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
4343
import { tmpdir } from 'node:os';
4444
import { join } from 'node:path';
4545
import { afterAll, afterEach, describe, expect, it } from 'vitest';
@@ -306,6 +306,17 @@ describe('reclaimSpace(): the local statement, run to completion on the remote d
306306
expect((await pageState(file)).freelist).toBe(0);
307307
});
308308

309+
it('local face: in WAL mode the freed bytes leave the -wal sidecar too, while the driver is still open', async () => {
310+
const { driver, file } = await withFreePages('local');
311+
const wal = () => (existsSync(`${file}-wal`) ? statSync(`${file}-wal`).size : 0);
312+
const pageSize = await secondConnection(file, 'PRAGMA page_size');
313+
expect(wal()).toBeGreaterThan(0);
314+
await driver.reclaimSpace();
315+
const after = await pageState(file);
316+
expect(after.freelist).toBe(0);
317+
expect({ file: statSync(file).size, wal: wal() }).toEqual({ file: after.pages * pageSize, wal: 0 });
318+
});
319+
309320
it('remote face: a row written after the call reaches a second connection', async () => {
310321
const { driver, file } = await withFreePages('remote');
311322
await driver.reclaimSpace();

0 commit comments

Comments
 (0)