Skip to content

feat(rivetkit): run sqlite synchronously with commit tickets for native database providers - #5883

Draft
eersnington wants to merge 8 commits into
feat/sqlite-deferred-commitsfrom
feat/sqlite-sync
Draft

eersnington wants to merge 8 commits into
feat/sqlite-deferred-commitsfrom
feat/sqlite-sync

Conversation

@eersnington

@eersnington eersnington commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Adds synchronous SQLite calls and commit tickets to the native database provider. It is for embedders whose storage API is synchronous by contract and that hold output until the writes it depends on are durable. This is the API from #5659, built so that a call never waits on the network. That is possible because of the deferred commits in #5882.

const db = await ctx.nativeDatabaseProvider.open(ctx.actorId);
await db.execute("CREATE TABLE kv (k TEXT PRIMARY KEY, v INTEGER)");

db.executeSync("INSERT INTO kv VALUES (?, ?)", ["a", 1]);

const tx = db.beginTransactionSync();
tx.executeSync("UPDATE kv SET v = v + 1 WHERE k = ?", ["a"]);
tx.commitSync();

// Hold the response until the writes it depends on are durable.
await db.whenDurable(db.commitTicket());

A synchronous call runs only where it cannot wait on the network. Otherwise it fails fast:

flowchart LR
    C[sync call] --> R{remote SQLite?}
    R -- yes --> X[sqlite.sync_unsupported]
    R -- no --> M{deferred commits on?}
    M -- no --> X
    M -- yes --> T{called from a runtime task?}
    T -- yes --> X
    T -- no --> B{backlog full?}
    B -- yes --> S[sqlite.storage_behind]
    B -- no --> RUN[run on the calling thread]
Loading

Same as #5659

  • executeSync, execSync, and a synchronous transaction handle
  • The call runs on the calling thread
  • A synchronous call fails fast instead of waiting on a transaction that only the JavaScript thread can finish
  • Wasm throws an explicit error

Different from #5659 (with #5676)

#5659 + #5676 This PR Why
Sync write, backlog full Waits until the backlog drains Fails fast with sqlite.storage_behind (retryable). Async writes wait for the flusher instead. One JavaScript thread serves every actor in the process
Sync read, cache miss Waits on depot A database of 64 MiB or less loads into memory at open. A miss gives up after 2 s and restarts the actor. Same reason. The cost is up to 64 MiB of memory per actor and a slower open for a large database.
Transaction contention Fails on any contention, including state saves Fails only on transactions owned by the JavaScript thread. Waits for internal work. Internal work cannot deadlock the JavaScript thread, so failing on it gives false errors under normal load
API surface Public c.db.executeSync, c.db.transactionSync(cb), and Drizzle Native database provider only It works only with deferred commits on, so a public API would throw for everyone else
Durability commitSeq(), waitForFlush(seq) commitTicket(), whenDurable(ticket) Same idea, one ticket per generation
Restart from durable state None abortGeneration(reason) An embedder needs to throw away state it no longer trusts
Statement errors Unchanged from main sqlite.statement_failed with the SQLite result code Callers can tell SQLITE_FULL from a constraint error
Worker threads #5663 stacks on top Not in this stack Separate change
Size +2,400 / −130 (#5659) +2,129 / −72

Errors

Code Meaning
sqlite.statement_failed A statement failed. Carries the SQLite message and result code.
sqlite.sync_unsupported The synchronous call could wait on the network, so it did not run
sqlite.storage_behind Storage is behind. Retry the write.
sqlite.durability_failed Writes can never become durable. The actor is restarting.
sqlite.invalid_commit_ticket The ticket names a commit that does not exist yet

This is part 2 of 3 in a stack:

@claude

claude Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Review (updated)

Overall this is a well-scoped, well-documented change. The fail-fast design (sync_unsupported, storage_behind) is sound, and the tests cover the main paths. Notes below, mostly about risk and edge cases.

Potential issues

  1. Read-miss deadline applies to every deferred-mode read, not just sync callers (depot-client/src/vfs.rs). Any page read that waits over 2 s on depot breaks the generation, including async callers and databases over 64 MiB. A transient depot stall then restarts the actor. Consider scoping it to host-thread sync reads, or document that all deferred-mode actors get this behavior.
  2. Per-actor 64 MiB eager load has no process-wide bound (load_small_database). Many actors with near-cap databases can use a lot of memory, and peak usage is roughly doubled during open because initial.pages is held alongside the cache. Consider a global budget or inserting pages per chunk. Pages with bytes: None are dropped silently, so a debug log would help.
  3. take_backlog_full() is a swap-on-read flag (backlog_error). A stale flag plus a genuine SQLITE_FULL can be misreported as storage_behind, which tells callers to retry forever. Tie the flag to the specific commit or clear it when the backlog drains.
  4. abort_generation silently ignores failures (sqlite/mod.rs, around line 1156). I rechecked the current code: if let Ok(config) = self.runtime_config() and if let Ok(db) = self.native_db_handle() still skip the abort with no signal. This conflicts with the fail-by-default rule in CLAUDE.md. Return a Result or at least tracing::error!.
  5. Ticket validation (resolved). commit_ticket_from_js already rejects NaN, infinite, negative, fractional and over-2^53 values.

Convention and parity

  • Confirm the native-only cache policy and eager load are out of scope for the WASM VFS (docs-internal/engine/sqlite-vfs.md parity rule) and note it there.
  • New public error codes: confirm pnpm docs:gen-api was run and docs/api/error-registry.json is updated. The artifacts are under rivetkit-rust/engine/artifacts/errors/, but CLAUDE.md names engine/artifacts/errors, so please verify the directory.
  • Mention the new internal env flags (RIVETKIT_SQLITE_OPT_READ_CACHE_MAX_BYTES, ..._READ_MISS_DEADLINE_MS) in the optimization-flags docs.

Concurrency

  • wait_on_runtime uses Handle::block_on guarded by tokio::task::try_id(). try_id returns None inside spawn_blocking, so that case passes the guard. Probably unreachable from NAPI, but worth a comment.
  • Add a test that a cancelled or timed-out begin_transaction_sync returns the host-thread lease counter to 0.

Tests to add

(a) storage_behind versus real SQLITE_FULL, (b) the spawn_blocking case, (c) async reads over the cache cap with respect to the deadline.

No security concerns.

🤖 Generated with Claude Code

@eersnington
eersnington force-pushed the feat/sqlite-deferred-commits branch from 7261035 to 1d1d6c8 Compare October 17, 2026 05:55

This branch has not been deployed

No deployments
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