Skip to content

engine: gate self-host SQLite writes so a raw write can't deadlock a transaction#257

Merged
willwashburn merged 3 commits into
mainfrom
selfhost-write-gate
Jul 11, 2026
Merged

engine: gate self-host SQLite writes so a raw write can't deadlock a transaction#257
willwashburn merged 3 commits into
mainfrom
selfhost-write-gate

Conversation

@willwashburn

@willwashburn willwashburn commented Jul 10, 2026

Copy link
Copy Markdown
Member

Fixes #250.

What this is

On the self-host, file-backed better-sqlite3 adapter, every write — raw statement writes on the main connection and withTransaction bodies alike — is serialized through a single async write gate. No raw write executes while a transaction holds the write lock, so writes never contend for the file lock and busy_timeout is not load-bearing. Reads stay ungated; WAL readers never block the single writer. The :memory: adapter and the Cloudflare D1/DO paths are unchanged.

Why

withTransaction runs an interactive transaction on a dedicated connection: it takes BEGIN IMMEDIATE's write lock, then awaits the async transaction body. better-sqlite3 is synchronous, so a raw, non-transactional write on the main connection that finds the file locked busy-waits and freezes the Node event loop for up to busy_timeout. While frozen, the lock-holding transaction can never run to COMMIT, so the raw write times out with SQLITE_BUSY and its losing statement rolls back — with the error swallowed by the caller.

Under two nodes registering their providers at the same instant (with the node event-queue poll issuing raw writes on the main connection), this deadlock drops one node's broker capabilities from the node aggregate, alternating which node loses. It is self-host only: production per-node Durable Object storage has no cross-connection writer contention. The per-node serialization from #251 is correct for the production D1-latency aggregate race and is retained.

Test

twoNodeWriteContention registers both nodes' broker providers concurrently over the real attachNodeSocket/handleMessage paths on file-backed storage, with concurrent event-queue poll raw-write traffic, looped. It fails on current main (SqliteError: database is locked from the poll's raw db.update, each run frozen ~5.4s on busy_timeout) and passes with the gate (~210ms). The existing single-node providerAttachRace suite is unaffected. Full engine (424) and types (161) suites pass.

🤖 Generated with Claude Code

Review in cubic

…transaction (#250)

On the file-backed better-sqlite3 adapter, `withTransaction` holds `BEGIN
IMMEDIATE`'s write lock across `await`s on a dedicated connection while the
main connection runs raw, non-transactional writes. better-sqlite3 is
synchronous, so a raw write that finds the file locked busy-waits and freezes
the Node event loop for up to `busy_timeout` — during which the lock-holding
transaction can never progress to COMMIT. The raw write then times out with
SQLITE_BUSY and its losing statement rolls back with the error swallowed by the
caller. Under concurrent node registration this silently drops a provider's
capabilities from the node aggregate (which node loses flips run to run).

Funnel every write on the file-backed path — raw statement writes on the main
connection and `withTransaction` bodies alike — through a single async write
gate, so no raw write ever executes while a transaction holds the lock. Writes
never contend, `busy_timeout` stops being load-bearing, and the transaction
always commits. Reads stay ungated (WAL readers never block the single writer).
`:memory:` and D1/DO paths are unchanged.

Adds a two-node conformance test that registers both nodes' broker providers
concurrently over the real attach paths with concurrent event-queue poll
writes: it fails on current main with `database is locked` and passes with the
gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The SQLite adapter now serializes transactional and raw writes, using explicit transaction handling for memory databases and gated connections for file-backed databases. Tests cover gate behavior and concurrent node registration preserving broker capabilities.

Changes

SQLite contention handling

Layer / File(s) Summary
Transaction paths
packages/engine/src/adapters/node/database.ts
Memory databases use serialized BEGIN IMMEDIATE transactions; file-backed transactions use dedicated connections, rollback handling, and a shared write gate.
Raw write execution gating
packages/engine/src/adapters/node/database.ts
Raw writes, lazy Drizzle builders, and prepared statements defer SQL execution through the write gate while preventing transaction bypasses.
Contention regression coverage
packages/engine/src/adapters/node/__tests__/writeGate.test.ts, packages/engine/src/__tests__/conformance/twoNodeWriteContention.test.ts, packages/engine/CHANGELOG.md
Tests cover transaction rollback, re-entrancy, memory and file-backed writes, and concurrent node registration; the changelog records the fix.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NodeRegistration
  participant EventQueuePolling
  participant SQLiteWriteGate
  participant SQLite
  NodeRegistration->>SQLiteWriteGate: register node and provider
  EventQueuePolling->>SQLiteWriteGate: enqueue and sweep probe events
  SQLiteWriteGate->>SQLite: serialize transactional and raw writes
  SQLite-->>NodeRegistration: persist complete capabilities
Loading

Possibly related PRs

Poem

I’m a rabbit guarding the queue,
While SQLite writes march single-file through.
Two nodes register, their caps stay bright,
No provider vanishes overnight.
Hop, hop—contention’s out of sight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: serializing self-host SQLite writes to prevent transaction deadlocks.
Description check ✅ Passed The description accurately matches the change set and explains the self-host write-gating fix and its motivation.
Linked Issues check ✅ Passed The PR addresses #250 by preserving concurrent provider registrations and capabilities under file-backed self-host contention, with a matching test.
Out of Scope Changes check ✅ Passed The changes stay focused on the SQLite write gate, its tests, and a changelog note; no unrelated scope is evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch selfhost-write-gate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request resolves a deadlock issue in self-hosted environments using file-backed SQLite databases by routing all write operations through a single async write gate, preventing event-loop freezing under concurrent write contention. A new conformance test suite has been added to verify this behavior. Feedback is provided to optimize the gateWriteBuilder proxy by caching the thenability check of the builder outside of the proxy handler to avoid redundant evaluations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/engine/src/adapters/node/database.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e782575c28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/engine/src/adapters/node/database.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/engine/src/adapters/node/database.ts Outdated
Comment thread packages/engine/src/adapters/node/database.ts Outdated
Comment thread packages/engine/CHANGELOG.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/engine/src/adapters/node/database.ts (1)

107-140: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider reusing the transaction connection
configureSqlite(txSqlite) does not change WAL mode here. If this path is hot, a long-lived dedicated transaction connection would avoid the per-call open/close overhead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/adapters/node/database.ts` around lines 107 - 140, In
withFileWriteGate, avoid opening and closing a new Database connection for every
withTransaction call; create and configure a reusable dedicated transaction
connection when the gate is initialized, use it for each serialized transaction,
and close it through an appropriate lifecycle cleanup mechanism while preserving
rollback and write-gating behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/engine/src/adapters/node/database.ts`:
- Around line 142-166: Extend gateRawWrites to wrap the BaseSQLiteDatabase
methods all, get, values, and transaction so their execution is routed through
runGated(), including transaction callbacks and returned lazy builders where
applicable. Preserve target binding for non-gated methods and ensure raw SQL
with RETURNING cannot bypass the write gate.

---

Nitpick comments:
In `@packages/engine/src/adapters/node/database.ts`:
- Around line 107-140: In withFileWriteGate, avoid opening and closing a new
Database connection for every withTransaction call; create and configure a
reusable dedicated transaction connection when the gate is initialized, use it
for each serialized transaction, and close it through an appropriate lifecycle
cleanup mechanism while preserving rollback and write-gating behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aa6324bb-e6fa-4996-a8f3-4b016cb430ff

📥 Commits

Reviewing files that changed from the base of the PR and between d234fee and e782575.

📒 Files selected for processing (3)
  • packages/engine/CHANGELOG.md
  • packages/engine/src/__tests__/conformance/twoNodeWriteContention.test.ts
  • packages/engine/src/adapters/node/database.ts

Comment thread packages/engine/src/adapters/node/database.ts
…ites

Address review of the self-host write gate: the invariant "no un-gated write
runs while a transaction holds the lock" now holds structurally, not by
current-usage accident.

- Gate every raw-SQL executor on the handle (`all`/`get`/`values`/`transaction`,
  not just `run`), so a raw statement — including `RETURNING` writes — cannot
  bypass the gate.
- Gate `values` on thenable statements and wrap `prepare()`d statements so a
  prepared write routes through the gate too.
- Reject a write issued through the engine handle inside a `withTransaction`
  body (a statement bound to the main connection cannot run inside the isolated
  transaction connection) with a clear error instead of deadlocking, using an
  AsyncLocalStorage re-entrancy marker. The builder form of `runAtomicWrites`,
  which rebuilds on the transaction handle, is unaffected; `:memory:` keeps its
  shared-connection behavior.
- Evaluate builder thenability once per wrap instead of per property access.

Adds adapter tests covering the re-entrancy rejection, the builder form, the
`:memory:` prebuilt-array path, and prepared/raw writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/engine/src/adapters/node/database.ts Outdated
The AsyncLocalStorage re-entrancy marker stored a bare `true`, which propagates
to every descendant async context — so a detached async resource created inside
a transaction body would inherit it permanently and have its writes rejected
even after the transaction commits. Store a per-run token and clear it when the
task settles, so only writes issued while the enclosing gate task is still
active are rejected; a resource that outlives its transaction gates normally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/engine/src/adapters/node/database.ts (1)

146-170: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Per-transaction connection open/close on the write path.

Each withTransaction opens and closes a fresh better-sqlite3 connection (Lines 151, 165). Since runGated already serializes all writes, at most one transaction connection is live at a time, so a single reusable dedicated writer connection would avoid the per-call open/configure/close cost on the hot write path. Correctness and cleanup here are fine; this is purely an efficiency consideration for high write throughput.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/adapters/node/database.ts` around lines 146 - 170, Reuse
a single dedicated writer connection instead of opening and closing a
better-sqlite3 Database for every withTransaction call. Create and configure the
connection during database initialization, use it in withTransaction while
retaining BEGIN IMMEDIATE, commit/rollback, and runGated serialization, and
close it from the database cleanup lifecycle.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/engine/src/adapters/node/database.ts`:
- Around line 146-170: Reuse a single dedicated writer connection instead of
opening and closing a better-sqlite3 Database for every withTransaction call.
Create and configure the connection during database initialization, use it in
withTransaction while retaining BEGIN IMMEDIATE, commit/rollback, and runGated
serialization, and close it from the database cleanup lifecycle.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a0cd53a-15c7-4751-a6a4-d5900870ea55

📥 Commits

Reviewing files that changed from the base of the PR and between e782575 and f6b49f7.

📒 Files selected for processing (3)
  • packages/engine/CHANGELOG.md
  • packages/engine/src/adapters/node/__tests__/writeGate.test.ts
  • packages/engine/src/adapters/node/database.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/engine/CHANGELOG.md

@willwashburn willwashburn merged commit 0a7bdf1 into main Jul 11, 2026
5 checks passed
@willwashburn willwashburn deleted the selfhost-write-gate branch July 11, 2026 12:26
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.

Provider capability aggregation drops a provider non-deterministically when two providers attach concurrently on one node

1 participant