Skip to content

fix(sqlite): retry OPFS-SAHPool instead of silently falling to in-memory - #202

Merged
crs48 merged 2 commits into
mainfrom
fix/opfs-sahpool-inmemory-fallback
Jun 18, 2026
Merged

fix(sqlite): retry OPFS-SAHPool instead of silently falling to in-memory#202
crs48 merged 2 commits into
mainfrom
fix/opfs-sahpool-inmemory-fallback

Conversation

@crs48

@crs48 crs48 commented Jun 18, 2026

Copy link
Copy Markdown
Owner

The actual root cause behind "renders nothing until the hub dot goes green"

Console logs from a real session pinned it down — this is more severe than the 0204 exploration's primary hypotheses:

opfs-sahpool: NoModificationAllowedError: ... Access Handles cannot be created if there
   is another open Access Handle or Writable stream associated with the same file.
[WebSQLiteAdapter] OPFS-SAHPool not available, trying OPFS direct mode: NoModificationAllowedError...
[WebSQLiteAdapter] OPFS direct mode unavailable, using in-memory database
[WebSQLiteProxy] getStorageMode() returned: memory

On a page reload, the new worker's installOpfsSAHPoolVfs runs before the previous worker has released its OPFS sync access handles, so it throws NoModificationAllowedError. The adapter caught it once and silently fell back to a :memory: database — so nothing persisted across reloads, every cold load started empty, and data could only appear once the hub re-synced. That is "lags until synced." (The Missing SharedArrayBuffer/COOP-COEP line is a separate, benign warning — OPFS-SAHPool doesn't need SAB.)

Fix

  • withOpfsLockRetry retries only the SAH-contention error with linear backoff (5×, 150ms→600ms) so the previous worker can release its handles and we stay on durable OPFS. Non-lock failures (OPFS unsupported, private window, quota) still fall through immediately — no added latency there.
  • When we genuinely do land in-memory, emit one loud, actionable console.error (close other tabs + reload) instead of a quiet warn lost in init noise.
  • Retry/detection logic extracted to opfs-retry.ts and unit-tested (8 tests); web.ts's WASM/OPFS paths can't run under node.

Tests

8 new unit tests (opfs-retry.test.ts); full sqlite suite (94) green; @xnetjs/sqlite typecheck/eslint/prettier clean.

Separately observed in the same log (not fixed here — follow-ups)

  • Error: First change for node social:enrichment:… must include schemaId — an incoming remote change whose first change lacks schemaId throws and aborts the rest of that node-sync-response batch. Worth making defensive (skip+warn vs throw).
  • SQLITE_CONSTRAINT_FOREIGNKEY on yjs_state insert — a knock-on of the above (doc content written for a node row that never got created).

Happy to take those on next; this PR fixes the confirmed cause of the empty-until-synced symptom.

🤖 Generated with Claude Code

xNet Test and others added 2 commits June 18, 2026 16:03
…memory

Root cause of "the app renders nothing until the hub dot goes green": on a
page reload the new worker's installOpfsSAHPoolVfs runs before the previous
worker has released its OPFS sync access handles, so it throws
NoModificationAllowedError ("Access Handles cannot be created..."). The
adapter caught this once and silently fell back to an in-memory database —
so nothing persisted across reloads and the workspace looked empty until the
hub re-synced (getStorageMode() === 'memory').

Fix: withOpfsLockRetry retries only the SAH contention error with linear
backoff (5×, 150ms→600ms) so the previous worker can release its handles and
we stay on durable OPFS. Non-lock failures (OPFS unsupported, private window)
still fall through immediately. When we do land in-memory, emit one loud,
actionable console.error (close other tabs + reload) instead of a quiet warn
buried in init noise.

Retry/detection logic is extracted to opfs-retry.ts and unit-tested (8 tests);
web.ts WASM/OPFS paths can't run under node.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@crs48
crs48 temporarily deployed to pr-202 June 18, 2026 23:04 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Preview removed for PR #202.

github-actions Bot added a commit that referenced this pull request Jun 18, 2026
@crs48
crs48 merged commit dae6652 into main Jun 18, 2026
13 checks passed
@crs48
crs48 deleted the fix/opfs-sahpool-inmemory-fallback branch June 18, 2026 23:17
github-actions Bot added a commit that referenced this pull request Jun 18, 2026
crs48 added a commit that referenced this pull request Jun 19, 2026
…208)

Implements
[`docs/explorations/0206`](docs/explorations/0206_%5Bx%5D_SYNC_FLOOD_RATE_LIMIT_AND_CHANGE_LOG_REPLAY.md).

## Root cause
After the OPFS fix (#202) made the local DB durable, a debug log showed
the client publishing **thousands** of `node-change` messages on every
load → `WebSocket closed, code: 1008 reason: Rate limit exceeded` →
reconnect → re-flood. `NodeStoreSyncProvider.lastSyncedLamport` was an
**in-memory cursor reset to 0 on every load**, so `syncLocalChanges()`
replayed the **entire change log** (`getChangesSince(0)`), one WebSocket
message per change. The OPFS fix is what exposed it — before, the
in-memory DB was empty each load, hiding the replay.

## What shipped (doc order A → C → throttle → D → E)
- **A — persisted cursor.** `getSyncCursor`/`setSyncCursor` in
`sync_state` (key `nodeSync:hwm:<room>`, monotonic). The provider loads
it on connect and advances/persists it **only** from the hub's
`node-sync-response` `highWaterMark` (the durable confirmation); an
in-memory `pushedThrough` handles within-session dedup.
- **C — request-sync-first.** On connect we send `node-sync-request` and
wait for the hub's response (4 s timeout fallback) before pushing, so
when the hub is already ahead we push **nothing**.
- **Throttle.** Outbound node-changes go through a rate-limited queue
(≤40 / 1000 ms, deduped by hash) so even a genuine first-ever backlog
can't exceed the hub's 100 msg/sec limit. The queue is dropped on
disconnect and rebuilt from `pushedThrough` on reconnect (no loss, no
double-send).
- **D — 1008 backpressure.** A `1008` close now triggers a long jittered
backoff (`rateLimitBackoffMs`, default 15 s) instead of the normal short
cadence, so a rate-limit close can't drive a reconnect-and-reflood
storm.
- **E — robustness.** `deserializeChange` (client + hub) falls back to
the redundant top-level `schemaId`; `applyRemoteChanges` (and the
single-change path) skip+warn on one un-appliable change instead of
aborting the batch; the client now handles `node-error` (log, don't
re-flood).

## Tests
24 new/updated unit tests across `node-store-sync-provider`,
`connection-manager`, `sqlite-adapter`, and `store`. Full `runtime`
(112), data store (195), and hub (371) suites green;
typecheck/eslint/prettier clean on the changed set.

## Deferred by design (in the doc)
- A new `node-sync-batch` wire type + hub support — the client-side
throttle already keeps a backlog under the limit without a protocol
change.
- Excluding `SocialEnrichment` from sync — with the persisted cursor it
syncs once (not re-replayed); dropping cross-device enrichment is a
separate product call.
- Optional change-log pruning.

🤖 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