Skip to content

feat(engine): atomic write batches for D1/hosted#179

Merged
willwashburn merged 9 commits into
mainfrom
feature/d1-batch-atomicity
Jun 10, 2026
Merged

feat(engine): atomic write batches for D1/hosted#179
willwashburn merged 9 commits into
mainfrom
feature/d1-batch-atomicity

Conversation

@willwashburn

Copy link
Copy Markdown
Member

Important

Stacked on #174 (feature/transactional-write-paths). Do not merge before #174; this PR will be retargeted to main once #174 merges.

Problem

#174 gave the five multi-statement write paths transactional atomicity, but only where the adapter can attach withTransaction — i.e. the Node better-sqlite3 self-host path. The hosted Cloudflare deployment runs on D1, which has no interactive transactions, so cloud kept the non-atomic sequential path: a crash between the messages insert and the deliveries inserts still leaves a message with no delivery rows.

D1 does execute db.batch([...]) atomically, and drizzle's DrizzleD1Database exposes batch() natively (verified against drizzle-orm 0.45.2 typings and runtime).

Design

Capability detectionrunAtomicWrites(db, statements) resolves in priority order:

  1. withTransaction (explicit TransactionCapability, attached by the Node adapter) — statements run sequentially inside one transaction.
  2. batch (D1-style BatchCapability, detected structurally via typeof db.batch === 'function') — statements run as one atomic batch.
  3. Neither — plain sequential statements, the engine's historical bare-handle behavior.

Duck-typing was chosen for batch deliberately: the hosted handle is constructed in the cloud repo as a plain drizzle(env.DB, { schema }), so structural detection gives the hosted engine atomicity with zero cloud-side changes. It cannot misfire: drizzle implements batch() per driver and only for drivers whose backend executes batches atomically — the better-sqlite3 drizzle instance has no batch member at all (and Node attaches withTransaction, which takes priority anyway). BatchCapability is exported so a non-drizzle adapter can attach an implementation explicitly later.

Write-path restructure — each of the five paths (channel send, DM send, group DM send, thread reply, markRead) now does all reads (member lists, agent names, attachment details, auth checks) before the writes, then hands runAtomicWrites a pure list of built-but-unexecuted drizzle statements (AtomicWrite). Drizzle builders are lazy thenables, so the same list works under a transaction, a batch, or sequential execution.

All five paths batch fully — no fallbacks needed. No write depends on a prior write's DB-returned value: IDs are app-generated snowflakes, and the logMessage body equals the request text. The two places that looked dependent were restructured:

  • message.ts read attachment details by joining the message_attachments junction rows it had just inserted; it now reads files by id directly (same data, order preserved), since the junction rows don't exist yet mid-batch.
  • .returning() rows (used for created_at in responses) are recovered from the per-statement batch results by index — D1's batch returns each statement's mapped result.

runAtomic(fn) is removed in favor of runAtomicWrites; it had no callers outside the five paths and a callback shape can't be batched. Business logic, fanout (still outside the atomic unit, in routes), and the EventQueue port are untouched. The Node adapter is unchanged apart from a comment.

Tests

atomicity.test.ts now covers all three handle shapes (13 tests, all passing):

  • Transaction (Node) — the four feat(engine): atomic multi-statement write paths via optional transaction capability #174 scenarios, with failure injection moved from statement build time to statement execution time so failures land mid-transaction (the restructure builds all statements before executing any, so a build-time throw would no longer exercise rollback).
  • Batch (fake D1-style handle) — strips withTransaction, attaches a batch() that records each batch's SQL and executes it all-or-nothing inside one underlying SQLite transaction. Asserts: each of the five paths issues exactly one batch with the expected statement kinds; .returning() rows flow back through batch results; a mid-batch failure leaves no orphan message/delivery/log rows.
  • Neither — sends and markRead still work sequentially; mid-send failure still leaves the orphan message (documented historical behavior).
Test Files  8 passed (8)
     Tests  68 passed (68)

Full engine suite + dependents via turbo test --filter=...@relaycast/engine (4 tasks), tsc --noEmit, and eslint src/ all pass.

🤖 Generated with Claude Code

willwashburn and others added 6 commits June 9, 2026 15:59
…tion capability

The database port gains an optional TransactionCapability that adapters
attach when their driver supports interactive transactions, plus a
runAtomic(db, fn) helper that uses it when present and falls back to
plain sequential statements otherwise (unchanged D1 behavior).

The Node better-sqlite3 adapter implements the capability with manual
BEGIN IMMEDIATE / COMMIT / ROLLBACK, serialized through a promise queue
so concurrent requests on the shared connection cannot interleave with
an open transaction.

Wrapped write paths (DB writes only — realtime/webhook fanout stays in
routes, outside the transaction):
- channel message send (message + attachments + deliveries + message_log)
- DM send (message + attachments + delivery + message_log)
- group DM send (message + attachments + deliveries)
- thread reply (reply + deliveries)
- markRead (read receipt + delivery transition + lastReadId)

On self-host, a failure mid-send no longer leaves a message row with no
delivery rows (silent durable-delivery loss).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Write paths gained transactional atomicity on Node in the
transactional-write-paths change, but the hosted Cloudflare deployment
runs on D1, which has no interactive transactions — a crash between the
message insert and the deliveries insert still left a message with no
delivery rows. D1 does execute db.batch([...]) atomically, and drizzle's
DrizzleD1Database exposes batch() natively, so the hosted handle can get
all-or-nothing writes with zero cloud-side changes.

- ports/database.ts: add AtomicWrite (a built-but-unexecuted drizzle
  statement) and BatchCapability (D1-style atomic batch), and replace
  runAtomic(fn) with runAtomicWrites(db, statements). Resolution order:
  withTransaction (Node) -> batch (D1, detected structurally since only
  atomic-batch drivers expose the method; better-sqlite3's drizzle
  instance has no batch member) -> sequential (bare handles, historical
  behavior).
- The five multi-statement write paths (channel send, DM send, group DM
  send, thread reply, markRead) now do all reads up front and hand
  runAtomicWrites a pure statement list, so the same list runs under a
  transaction, one atomic batch, or sequentially. No write depends on a
  prior write's DB-returned value (IDs are app-generated snowflakes);
  .returning() rows are recovered from the per-statement results.
- message.ts reads attachment details directly from files by id before
  the writes (the junction rows don't exist yet mid-batch); dm.ts builds
  the message+attachment inserts via buildDmMessageWrites; console.ts
  gains buildMessageLogWrite so the log insert can join the batch.
- Tests: fake D1-style batch handle (records SQL, executes
  all-or-nothing) asserting each path issues exactly one batch with the
  expected statement kinds, batch failure leaves no orphan rows, and
  bare handles still run sequentially. Failure injection now fires at
  statement execution (mid-atomic-unit) rather than at build time.

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

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@codeant-ai

codeant-ai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@willwashburn, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 56 minutes and 4 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2108fca9-b185-45cd-bc48-d7b839801609

📥 Commits

Reviewing files that changed from the base of the PR and between 366967b and 99dec30.

📒 Files selected for processing (11)
  • packages/engine/src/__tests__/atomicity.test.ts
  • packages/engine/src/adapters/node/database.ts
  • packages/engine/src/engine/console.ts
  • packages/engine/src/engine/deliveryWrites.ts
  • packages/engine/src/engine/dm.ts
  • packages/engine/src/engine/groupDm.ts
  • packages/engine/src/engine/message.ts
  • packages/engine/src/engine/receipt.ts
  • packages/engine/src/engine/thread.ts
  • packages/engine/src/ports/database.ts
  • packages/engine/src/ports/index.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/d1-batch-atomicity

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 and usage tips.

@codeant-ai

codeant-ai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@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: 64ad3eafa2

ℹ️ 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/engine/message.ts Outdated
Comment on lines +107 to +111
db
.select({ agentId: channelMembers.agentId, agentName: agents.name })
.from(channelMembers)
.innerJoin(agents, eq(channelMembers.agentId, agents.id))
.where(eq(channelMembers.channelId, channelId)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Select recipients inside the atomic write window

When channel membership changes after this pre-read but before runAtomicWrites starts, the committed delivery rows are based on stale membership. For example, if an agent leaves the channel in that window, the later batch/transaction can still insert a delivery for them and fan out delivery.accepted for a message that was committed after they left; before this change, the Node path read members inside the transaction after BEGIN IMMEDIATE, so channel membership writes could not interleave. Consider deriving deliveries in the write itself (for example, INSERT ... SELECT from current channel_members) or otherwise acquiring the write lock before reading recipients.

Useful? React with 👍 / 👎.

@codeant-ai

codeant-ai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

Base automatically changed from feature/transactional-write-paths to main June 10, 2026 10:56
…icity

# Conflicts:
#	memory/workspace/.relay/state.json
#	packages/engine/src/__tests__/atomicity.test.ts
#	packages/engine/src/adapters/node/database.ts
#	packages/engine/src/engine/dm.ts
#	packages/engine/src/engine/groupDm.ts
#	packages/engine/src/engine/message.ts
#	packages/engine/src/engine/receipt.ts
#	packages/engine/src/engine/thread.ts
#	packages/engine/src/ports/database.ts
#	packages/engine/src/ports/index.ts
@codeant-ai

codeant-ai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@willwashburn willwashburn merged commit 577c8a3 into main Jun 10, 2026
5 checks passed
@willwashburn willwashburn deleted the feature/d1-batch-atomicity branch June 10, 2026 14:04
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