Skip to content

feat(do)!: support sqlx's begin(), commit() and rollback() - #13

Closed
iamralch wants to merge 1 commit into
mainfrom
do-begin-commit
Closed

iamralch wants to merge 1 commit into
mainfrom
do-begin-commit

Conversation

@iamralch

Copy link
Copy Markdown
Contributor

sqlx's own begin() / commit() / rollback() now work on Durable Object storage, as with any sqlx driver:

use sqlx::Connection;

let mut tx = conn.begin().await?;
sqlx::query("UPDATE accounts SET balance = balance - 10 WHERE id = 1").execute(&mut *tx).await?;
sqlx::query("UPDATE accounts SET balance = balance + 10 WHERE id = 2").execute(&mut *tx).await?;
tx.commit().await?; // dropping `tx` instead rolls both back

How

sql.exec rejects BEGIN, and Storage::transaction only wraps a callback. So begin() parks one open: it opens Storage::transaction in its own task, with a callback that only waits for a commit or rollback signal. Meanwhile the statements run on the connection as usual.

A spike measured this before the implementation, with identical results over three rounds:

  • statements run outside the callback are part of the transaction;
  • the request can await timers and sub-requests while the transaction is open;
  • other requests are held off until it ends.

Details

  • commit / rollback signal the parked transaction and wait for it to finish.
  • Drop rolls back. sqlx's start_rollback is synchronous, so it signals the rollback without waiting. Every query on the connection then waits for that rollback to land first. Without this wait, the spike showed a write made right after the drop was rolled back too.
  • Refused nested begin(). sqlx drops a guard, calling start_rollback, whenever begin() fails. A refused nested begin() records a phantom level so that drop can't roll back the outer transaction. The Worker scenario caught this bug; the outer transaction's writes from before and after the refusal now both commit.
  • Refused rather than deadlocking: nested begin() (savepoints), a custom BEGIN statement, and batches or transaction(callback) while a transaction is open.
  • DoConnection::transaction(callback) stays, as the form that can't be left open. Its docs note that on a &mut DoConnection with sqlx::Connection in scope, conn.transaction(…) resolves to sqlx's own Connection::transaction, which now works too.

Breaking change

DoConnection::into_storage returns Rc<worker::Storage>. The parked transaction needs its own handle to the storage, and worker::Storage can't be cloned. Method calls still work through auto-deref; only code that names the type changes. The commit is marked feat(do)!, so release-please proposes 0.3.0.

New dependencies (DO crate)

  • futures-channel, for one-shot signals.
  • futures-util's std feature, for Shared: every query waits on a pending rollback.

Both are wasm-safe.

Tests

  • In-object Worker scenarios:
    • commit and explicit rollback;
    • drop-then-write;
    • read-your-writes across an await, with a RETURNING value;
    • the nested refusal keeping the outer transaction's writes;
    • batches and callbacks refused while a transaction is open.
  • A Worker-level scenario: a second request is held off while a begin() transaction is open, and its write survives the rollback. It now runs for both transaction styles.
  • The old begin_fails scenario is removed.
  • The facade test checks that begin() and commit() futures are Send.

Verification

  • Passed make lint (host and wasm32, zero warnings), make test including the new begin() doctest, and make doc-check.
  • Passed make test-worker: 29 of 29 D1 scenarios and 42 of 42 DO scenarios.

`sql.exec` rejects `BEGIN`, and `Storage::transaction` wraps a callback, so
`begin()` parks one open: it opens `Storage::transaction` in a task of its
own with a callback that only waits for a commit or rollback signal, while
the statements run on the connection through `&mut *tx` as with any sqlx
driver. A spike measured that this works: statements outside the callback
are part of the transaction, the request can await timers and sub-requests
while it is open, and other requests are held off until it ends.

- `commit()` / `rollback()` signal the parked transaction and wait for it.
- A dropped `Transaction` signals the rollback without waiting (sqlx's
  `start_rollback` is synchronous), and every query on the connection first
  waits for it to land -- a write right after the drop was measured to be
  rolled back with it otherwise.
- sqlx drops a guard, calling `start_rollback`, when `begin()` fails; a
  refused nested `begin()` records a phantom level so that drop cannot roll
  back the outer transaction.
- Nested `begin()` (savepoints), a custom `BEGIN` statement, and batches or
  `transaction()` while one is open are refused rather than deadlocking.
- `DoConnection::transaction(callback)` stays, as the form that cannot be
  left open; its docs note that sqlx's own `Connection::transaction` wins
  method resolution on a `&mut DoConnection`, and works too now.

Worker scenarios cover commit, rollback, drop-then-write, read-your-writes
across an await, the nested refusal keeping the outer's writes, the
refusals while open, and a second request held off while a `begin()`
transaction is open.

BREAKING CHANGE: `DoConnection::into_storage` returns `Rc<worker::Storage>`
instead of `worker::Storage`: the parked transaction needs its own handle to
the storage, which cannot be cloned. Method calls still work through
auto-deref; only code naming the type changes.
@iamralch iamralch self-assigned this Sep 26, 2026
@iamralch iamralch added the enhancement New feature or request label Sep 26, 2026
@iamralch

Copy link
Copy Markdown
Contributor Author

Closing without merging. The implementation works, and all 42 DO scenarios pass, but it isn't worth carrying for now:

  • It relies on undocumented runtime behaviour. It works only because statements run outside the Storage::transaction callback join the transaction, and because the object holds off other requests meanwhile. Both were measured on local workerd, but Cloudflare doesn't document either. If a runtime update changed the first, rollbacks would silently stop undoing writes in production, and our CI would only notice on a local wrangler bump.
  • It adds complexity. That's a state machine with a phantom-level counter, a background task, one-shot channels, a shared future every query waits on, two new dependencies, and a breaking change to into_storage. The scenarios already caught one subtle bug: a refused nested begin() rolled back the outer transaction.
  • The callback API already gives real transactions. DoConnection::transaction has read-your-writes, RETURNING and rollback, and uses Storage::transaction the way Cloudflare documents it.

If code that has to call sqlx's begin() comes up, this PR is a working starting point, ideally behind an opt-in cargo feature.

@iamralch iamralch closed this Sep 26, 2026
@iamralch
iamralch deleted the do-begin-commit branch September 26, 2026 09:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant