Skip to content

perf(sync): run cold-open maintenance off the boot path (0260) - #366

Merged
crs48 merged 7 commits into
mainfrom
claude/0260-compaction-off-boot-path
Jul 3, 2026
Merged

perf(sync): run cold-open maintenance off the boot path (0260)#366
crs48 merged 7 commits into
mainfrom
claude/0260-compaction-off-boot-path

Conversation

@crs48

@crs48 crs48 commented Jul 3, 2026

Copy link
Copy Markdown
Owner

The problem (exploration 0260)

After #360 shipped change-log compaction, cold-open got worse (~31.5 s vs the ~15.8 s baseline), not better. Root cause: the single OPFS SQLite worker is strictly serial (one exclusive sync-access handle → no second connection, ever), and the maintenance passes were scheduled off bare requestIdleCallback. That callback measures main-thread idle — which is idle exactly while the worker is saturated serving the cold-open landing read. So compaction fired mid-boot and its ~40 back-to-back 250 k-row DELETE chunks queued ahead of first paint. A reset hub compounded it: the rollback guard re-offered the entire 318 k-row log via two ~3 s getChangesSince(0) scans, every change then rejected INVALID_HASH.

The fix — Phase 1.1 (the full scheduling fix)

  • bootSettled() / runWhenBootSettled() (boot-timeline.ts): a gate that resolves at query:first-rows (first landing data painted), then waits a settle delay + a real idle slot, with a fallback timer for stalled boots.
  • Compaction now runs via that gate, prunes in small CHUNK=2000 passes (≤25/session) with an idle yield between each, loops-until-dry across boots, bails on the kill switch or a hidden tab, and no longer re-arms the full VACUUM every prune. Default-on with an opt-out kill switch (localStorage['xnet:compact:changes']='off'); the emergency default-off "Hotfix A" is unnecessary now that the pass is boot-safe.
  • One-time VACUUM and presence-blob cleanup are gated behind the same runWhenBootSettled (they shared the identical flaw).
  • Rollback guard (@xnetjs/runtime) only re-offers on a genuine partial rollback (0 < highWaterMark < cursor) and only while the outbound breaker is not already halted — no unbounded getChangesSince(0) flood into a reset/skewed hub.

Validation

New/updated unit tests stand in for each validation-checklist item:

  • change-log-compaction.test.ts (new): no prune before first paint; small-chunk loop-until-dry; kill-switch and hidden-tab bail; memory/no-floor skips.
  • boot-timeline.test.ts: bootSettled() stays pending until query:first-rows, resolves for late awaiters.
  • db-vacuum.test.ts / presence-blob-cleanup.test.ts: run only after the boot gate releases.
  • node-store-sync-provider.test.ts: no re-offer to a highWaterMark 0 hub or while the breaker is halted; partial rollback still re-offers.

pnpm typecheck (91/91), pnpm lint (0 errors), full apps/web/src/lib suite (214 passing) and the runtime sync provider suite (22 passing) all green locally.

Implements docs/explorations/0260_[x]_COMPACTION_STARVES_THE_COLD_OPEN_SCHEDULE_IT_OFF_THE_BOOT_PATH.md.

🤖 Generated with Claude Code

xNet Test and others added 6 commits July 3, 2026 15:57
Background maintenance passes (change-log compaction, one-time VACUUM,
presence-blob cleanup) were scheduled off `requestIdleCallback`, which
measures MAIN-thread idle — and the main thread is idle exactly while the
single SQLite worker is saturated serving the cold-open landing read. So
those idle callbacks fired mid-boot and their heavy DELETE/VACUUM ops
queued behind (and added to) first paint on the one serial connection.

`bootSettled()` resolves when `query:first-rows` is marked (first landing
data rendered). `runWhenBootSettled(task)` waits for that, then a settle
delay, then a real idle slot — with a fallback timer so a stalled boot
still eventually runs the work. This is the gate the maintenance passes
now hang off, keeping them strictly off the boot critical path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
… (0260)

#360 scheduled compaction on `requestIdleCallback` and pruned up to 250 k
rows in one pass. On the single serial SQLite worker that ~doubled the
cold-open (31.5 s): the idle callback fired mid-boot and ~40 back-to-back
DELETE chunks queued ahead of the landing read.

Rework compaction to never touch the boot critical path:

- Gate `scheduleChangeLogCompaction` behind `runWhenBootSettled` (first
  paint + settle delay + real idle) instead of a bare idle callback.
- Prune in small CHUNK=2000 passes, capped at 25 chunks/session, with an
  idle yield between each so a landing read or interaction always
  preempts, looping-until-dry across boots. The web adapter has no
  priority scheduler (ops are FIFO to the one worker), so small chunks +
  yield ARE the in-flight guard.
- Bail the pass on the kill switch or a hidden tab (throttled idle /
  possible tab discard); resume next boot.
- Stop re-arming the full VACUUM every prune (`xnet:db-vacuumed:v1` is no
  longer cleared) — that made the NEXT boot pay a whole-file rewrite.

Compaction stays default-on with an opt-out kill switch
(`localStorage['xnet:compact:changes'] = 'off'`); the emergency
default-off "Hotfix A" is unnecessary now that the pass is boot-safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
…ed (0260)

`scheduleOneTimeVacuum` and `scheduleStalePresenceCleanup` had the same
`requestIdleCallback` flaw as compaction: a heavy DELETE + whole-file
VACUUM fired mid-boot, queuing behind the cold-open landing read on the
single serial worker. Both now run via `runWhenBootSettled` (first paint
+ idle), so file reclaim happens strictly after the workspace is
interactive. The one-shot VACUUM is the boot-settled space-reclaim step
that pairs with compaction no longer re-arming it every prune.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
…260)

On a cold boot against a reset hub, `node-sync-response` reported
`highWaterMark 0` while the local confirmed cursor was ~318 k. The
rollback guard read that as a hub rollback and re-offered the ENTIRE
local change log via `getChangesSince(0)` — two ~3 s full-table scans on
the single worker (adding to cold-open), every change then rejected
INVALID_HASH, tripping the outbound breaker anyway.

Gate the guard: only re-offer for a genuine PARTIAL rollback
(`0 < highWaterMark < cursor`) and only while the outbound breaker is not
already halted. A fresh/empty/reset hub (`highWaterMark === 0`) and a
tripped INVALID_HASH breaker both short-circuit, so there is no unbounded
`getChangesSince(0)` re-offer flood.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
…e it off the boot path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: xNet Test <test@xnet.dev>
@crs48
crs48 temporarily deployed to pr-366 July 3, 2026 23:03 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

🖼️ UI changes in this PR

No UI changes detected in this PR.

github-actions Bot added a commit that referenced this pull request Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Preview removed for PR #366.

@crs48
crs48 temporarily deployed to pr-366 July 3, 2026 23:14 — with GitHub Actions Inactive
@crs48
crs48 merged commit 32bc20f into main Jul 3, 2026
17 checks passed
@crs48
crs48 deleted the claude/0260-compaction-off-boot-path branch July 3, 2026 23:20
github-actions Bot added a commit that referenced this pull request Jul 3, 2026
crs48 added a commit that referenced this pull request Jul 4, 2026
…-vacuum (0260) (#369)

## Why

The durable half of the cold-open fix that
[#366](#366) set up but deferred (the
"REMAINING" item in exploration 0260).

Change-log compaction (0254) DELETEs superseded history, but the OPFS
database opened with the default `auto_vacuum = NONE`, so those pages
only re-entered SQLite's **freelist** — the file never shrank. Since the
~16 s cold-open read is the OPFS page-fault of that file's working set,
pruning rows did nothing for cold-open until the file physically shrank.
The only thing that returned space to the OS was the **one-time boot
VACUUM**, which latches after a single run and so typically rewrote a
still-fat file and never ran again.

## What

Convert the OPFS working set to **incremental auto-vacuum**, so freed
pages are handed back to the filesystem per boot:

- **[`web.ts`](packages/sqlite/src/adapters/web.ts)** — open OPFS
databases with `PRAGMA auto_vacuum = INCREMENTAL`. Fresh databases are
incremental from birth (never bloat); the mode only *converts* an
existing `NONE` database at a `VACUUM`.
- **[`db-vacuum.ts`](apps/web/src/lib/db-vacuum.ts)** — the existing
one-time boot-settled VACUUM now doubles as that conversion for
pre-existing databases (comment only — a `VACUUM` applies the pending
mode change automatically).
-
**[`change-log-compaction.ts`](apps/web/src/lib/change-log-compaction.ts)**
— after each prune pass, run `PRAGMA incremental_vacuum` to return the
freed pages to the OS. A harmless no-op until the DB is converted;
per-boot reclaim afterwards. The file shrinks a little every idle boot
as the log drains, instead of only on the single one-time VACUUM.

## Convergence

- First boot after deploy (existing fat DB): one-time VACUUM converts
`NONE → INCREMENTAL` and reclaims whatever compaction has deleted so
far.
- Each subsequent idle boot: compaction deletes ≤50k rows and
`incremental_vacuum` reclaims them — the file shrinks steadily until the
~318k-row backlog is drained (~7 boots), and it **keeps** reclaiming for
the workspace's whole life instead of latching once.

## Validation

New `packages/sqlite/src/adapters/auto-vacuum-reclaim.test.ts` proves
the exact SQLite semantics against a real engine (better-sqlite3):
1. `NONE` (default) → a DELETE does **not** shrink the file and
`incremental_vacuum` is a no-op.
2. A fresh `INCREMENTAL` DB → `incremental_vacuum` drops both
`page_count` **and** on-disk bytes.
3. A pre-existing `NONE` DB → one `VACUUM` converts it (and compacts),
then `incremental_vacuum` reclaims per call.

Plus `change-log-compaction.test.ts` gains cases asserting `PRAGMA
incremental_vacuum` fires only when rows were actually pruned (not on a
dry log, kill-switch, or memory DB).

Local: `pnpm typecheck` (91/91), `pnpm lint` (0 errors), sqlite unit
suite (158 pass / 3 skip), apps/web lib (216 pass).

Follow-up to 0260 / [#366](#366).
Note: pairs with deploying #366 — that stops the compaction-on-boot
regression and the reset-hub flood; this makes the pruning actually
shrink the file that gates cold-open.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
crs48 added a commit that referenced this pull request Jul 4, 2026
…y Site (#370)

## Why the live site is still serving old code

Every **Deploy Site to GitHub Pages** run since 2026-07-03 18:06 UTC has
**failed** — 7 in a row — at the "Install site dependencies" step:

```
ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with package.json
* 2 dependencies were added: @types/qrcode@^1.5.6, qrcode@^1.5.4
```

PR #363 (Run on Mobile / Expo Go QR page) added `qrcode` +
`@types/qrcode` to `site/package.json` without regenerating
`site/pnpm-lock.yaml` (the site is a standalone `--ignore-workspace`
install with its own lockfile, so the root lockfile check didn't catch
it). Result: the production site — including the `/app/` PWA bundle —
has been pinned at `1ed5ab82`, which predates the cold-open fixes #366
and #369. That is exactly why every cold-boot capture since still shows
the pre-#366 signature (`hub high-water mark 0 … re-offering`).

## Fix

Regenerate `site/pnpm-lock.yaml` (Node 23 / pnpm 10). Diff is 220
additions, zero removals: `qrcode`, `@types/qrcode`, their transitive
deps (`dijkstrajs`, `pngjs`, yargs CLI chain), plus inert `libc`
metadata pnpm 10 adds.

## Verified locally

- `pnpm install --ignore-workspace --frozen-lockfile` in `site/` —
passes (CI's exact failing command).
- Full Astro site build — passes, 110 pages (the QR page has never made
it past install in this workflow, so the build step was proven too).

Merging touches `site/**`, which triggers the production deploy —
shipping everything accumulated since #363, including #366 + #369.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
crs48 added a commit that referenced this pull request Jul 4, 2026
## The second deploy-pipeline bug (stacked on #370)

After #370 unblocked Deploy Site, run `28709576907` went green — but the
live app **still served pre-#366 code**. The publish commit (`e99fa1de`)
tells the story: all the chunk renames landed (`index-DppD45_e.js →
index-BuveDUTb.js`, `web-worker-BIYC6Nw7.js → web-worker-BFqMZy2X.js`,
…) but **`app/index.html`, `app/404.html`, and `app/sw.js` are absent
from the diffstat** — gh-pages ended up with a stale `index.html`
pointing at a deleted entry chunk.

## Root cause

`publish-gh-pages` syncs with `rsync -a`, whose quick check skips files
whose **size and mtime** both match:

- **Size never changes for these files.** A rebuild substitutes 8-char
content hashes for 8-char content hashes, so
`index.html`/`404.html`/`sw.js` are byte-for-byte the *same length*
across builds.
- **mtime carries no signal.** Both sides are freshly generated — `cp
-R` build output (prepare step) vs a just-`git worktree add`-ed checkout
(publish step) — every mtime is "now". This run's prepare-copy and
checkout landed in the **same second**, so the quick check called them
identical.

Every prior deploy dodged this by a one-second timing margin; it's a
latent race in every publish (production, PR previews, branch previews —
all use this action).

## Fix

`rsync -a --checksum` — decide by content, deterministically. `git add
-A` already ignores genuinely unchanged files, so the only cost is
reading both trees.

## After merge

`.github/**` isn't in Deploy Site's path filters, so I'll
`workflow_dispatch` a production deploy, then verify
`gh-pages:app/index.html` references the live entry chunk and the
deployed bundle carries the #366/#369 markers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
crs48 added a commit that referenced this pull request Jul 5, 2026
…um (0260) (#380)

## Problem

Live capture from the newly-deployed build (post #366/#369) showed
change-log compaction running but the OPFS file never shrinking. Root
cause: the one-time VACUUM from exploration 0233 latched its
`xnet:db-vacuumed:v1` localStorage flag on long-lived profiles
**before** incremental auto-vacuum existed (#369). For those profiles:

- the database is still `auto_vacuum = NONE` (the pending `INCREMENTAL`
mode set at open in web.ts only applies to a fresh DB or at a VACUUM),
- so every per-prune `PRAGMA incremental_vacuum` (#369) is a **silent
no-op**,
- and the file can never shrink, no matter how many rows compaction
deletes.

## Fix

Gate the one-time VACUUM on the database's **actual** `PRAGMA
auto_vacuum` mode, not the flag alone:

- flag latched **and** mode = INCREMENTAL (2) → skip (steady state: one
idle PRAGMA read per boot)
- flag latched **but** mode = NONE (0) → run the conversion VACUUM
anyway (the 0233-era profile case)
- no flag → vacuum + latch as before

Scheduling is unchanged — still boot-settled + idle via
`runWhenBootSettled` (#366), so the whole-file rewrite never races the
cold-open read burst. Bonus: the conversion VACUUM immediately returns
the freelist accumulated by prior prunes (~250k rows) to the OS.

## Testing

- `db-vacuum.test.ts` extended: latched+INCREMENTAL skips, latched+NONE
converts, unflagged vacuums+latches, memory mode skips — 4/4 pass
- full `apps/web/src/lib/` suite: 217/217 pass
- `pnpm --filter xnet-web typecheck` + eslint clean
- full `pnpm test`: 10,020 passed (pre-push devkit git-integration
failures are hook-env-only; suite is green outside the hook)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
crs48 added a commit that referenced this pull request Jul 5, 2026
…page per pass (#381)

## How this was found

Local end-to-end validation of the cold-open fixes (#366/#369/#380):
seeded a **464 MB replica** of the user's pathological profile straight
into a real browser's OPFS database (300k-row `changes` log, 250k rows
deleted into the freelist → 81% dead pages, devolved to
`auto_vacuum=NONE`, latched 0233-era `xnet:db-vacuumed:v1` flag), then
booted the shipped code against it repeatedly.

**What validated clean:**
- Boot 1: #380's conversion VACUUM fired post-settle — **464 MB → 80
MB**, `auto_vacuum` 0 → 2, first paint 1823 ms → 1090 ms on the
following boot.
- Boot 2: vacuum correctly skipped (flag + mode 2); compaction pruned
**exactly the 47,000 prunable rows** (K1/K2/K3 predicate verified by
dry-run count beforehand).
- Boot 3: steady state — compaction dry, vacuum skip, one idle PRAGMA
read.

**What didn't:** boot 2 ended with `{deleted: 47000, reclaimed: true}` —
and the file still at 80 MB with 9,248 freelist pages. Direct probing
showed every `exec('PRAGMA incremental_vacuum')` (with or without a
page-count argument) freed **exactly one page**.

## Root cause

A WASM *binding* quirk, not engine semantics: SQLite frees one freelist
page per `sqlite3_step` of `PRAGMA incremental_vacuum`, and
sqlite-wasm's oo1 `exec` steps a row-less statement exactly once.
better-sqlite3's `pragma()` steps to completion — which is why #369's
engine-level test passed while the production reclaim was a near-no-op
(at one page per boot, a 75 MB backlog would take ~9,000 boots).

## Fix

- `SQLiteAdapter.incrementalVacuum(maxPages?)` — new **optional**
interface method returning pages freed.
- Web adapter: `stepIncrementalVacuumToCompletion` steps the pragma
until done (or the cap); wired through the worker (write lane) + proxy.
- Electron adapter: via `db.pragma` + freelist delta (better-sqlite3
already steps fully).
- `change-log-compaction.ts` prefers the method (falls back to `exec` on
adapters without it), and a **dry pass now reclaims a stranded freelist
backlog** (≥1024 pages ≈ 8 MiB) — healing profiles that pruned under the
buggy build or bailed mid-pass on a hidden tab.

## Re-validation (same seeded profile, fixed build)

- Backlog branch: first boot with the fix returned all 9,244 stranded
pages — **80 MB → 8 MB** in one pass.
- Integrated path: seeded 6,000 fresh prunable rows → pass reported
`{deleted: 5400, freedPages: 403, reclaimed: true}` (5,400 = exact
predicted count) and the file returned to baseline.

## Testing

- NEW `incremental-vacuum-stepping.test.ts` — pins the one-page-per-exec
bug **against the real @sqlite.org/sqlite-wasm oo1 API** and the fix
(drains freelist, honours cap): 3/3.
- `change-log-compaction.test.ts` extended (method preferred, exec
fallback, dry-small-freelist no-op, backlog reclaim): 9/9.
- Full suite 10,025 passed; typecheck 91/91; eslint clean.
- Changeset: `@xnetjs/sqlite` minor.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant