Skip to content

Durable transactions on a separate application database - #153

Merged
SamuelXing merged 2 commits into
mainfrom
feat/datasource
Aug 6, 2026
Merged

Durable transactions on a separate application database#153
SamuelXing merged 2 commits into
mainfrom
feat/datasource

Conversation

@SamuelXing

Copy link
Copy Markdown
Owner

Closes #148.

All three reference SDKs support durable transactions against an application database of the user's own; durare only offered the single-database transactional step (ctx.transaction). This PR adds ctx.transaction_on(&ds, name, |conn| …) — and transaction_on_with for isolation/read-only/retry options — over a PgDataSource or SqliteDataSource built on the user's own sqlx pool.

Protocol

A single commit cannot span two databases, so the call splits durability into two commits bridged by a witness row:

  1. Application commit — the body's writes plus a transaction_completion row (workflow_id, step_id, output, serialization) commit atomically on the user's database.
  2. System commit — the ordinary step checkpoint is written to operation_outputs.

Recovery replays in layers: a system-database checkpoint wins outright; a witness row without a checkpoint (a crash landed between the two commits) replays the stored output without re-running the body and backfills the checkpoint; only when neither exists does the body run. Permanent failures are mirrored into the witness table before the system record, preserving the recovery order. Conflicts and transient database errors retry on fresh transactions without consuming the application max_retries budget — the same two-loop structure as the single-database transactional step.

Design decisions

  • Concrete sealed types, native connections. The body receives &mut sqlx::PgConnection / &mut sqlx::SqliteConnection, so existing queries, compile-time-checked sqlx macros, and DAOs work unchanged. The DataSource trait is sealed — the backend set is closed, as in every DBOS SDK (Go's Engine is a closed type-set union; Python and TS ship concrete per-engine classes). Python and TS hand the body their ecosystem's native handle (SQLAlchemy session, ORM tx client); sqlx's Executor is the Rust equivalent. Discussed with @assiotis's proposal on [Parity] Durable transactions against a separate application database (DataSource / transaction_completion model) #148 — this follows that direction.
  • Escape hatch guarded. Commit/rollback stay with the engine (a plain connection has no commit method), and on Postgres a raw COMMIT/ROLLBACK smuggled through SQL is caught by a txid_current() fingerprint check and fails the step instead of silently splitting the writes from their witness. No reference SDK detects this — Go's Tx even exposes Commit/Rollback to the body.
  • Table shape: Go's. Reference study found there is no cross-SDK wire contract: Go/TS use transaction_completion while Python uses datasource_outputs, and TS renames step_idfunction_num. The table is language-local in every SDK, so durare matches Go (name consensus + parity anchor); a Go worker sharing the application database reads the same rows. Schema is dbos by default, configurable via with_schema (plain-identifier-validated), unqualified on SQLite.
  • No automatic same-as-system-DB collapse. Go collapses onto its one-commit path via pool identity; sqlx offers no pool identity comparison. Documented instead: if the "separate" database is the system database, use ctx.transaction — the two-commit protocol is still correct there, just one commit dearer.
  • Duplicate witness insert replays. If a concurrent execution committed the step first, the canonical row is replayed instead of erroring (Go raises a conflict) — consistent with durare's canonical-outcome step semantics.

Tests

tests/datasource.rs (6): witness row + exactly-once + idempotent restart; a planted completion row replays without running the body (the crash window, and equivalently a foreign-written row); failure rollback + mirror + error replay; the application retry policy re-runs on fresh transactions with failed attempts leaving no writes; nesting rejected; hermetic-Postgres end-to-end covering the custom schema, hostile schema-name rejection, and the raw-COMMIT guard.

Docs: new "A separate application database" section in the transactions guide, full rustdoc on both datasource types and transaction_on, CHANGELOG entry under Unreleased.

…tabase

All three reference SDKs support durable transactions against an
application database of the user's own, while durare only offered the
single-database transactional step. This closes that gap with
ctx.transaction_on(&ds, name, |conn| ...) over a PgDataSource or
SqliteDataSource built on the user's sqlx pool.

A single commit cannot span two databases, so the call uses a two-commit
protocol: the body's writes and a transaction_completion witness row
commit atomically on the application database, then the ordinary step
checkpoint commits to the system database. Recovery replays in layers -
checkpoint first, then the witness row (covering a crash between the two
commits) - and runs the body only when neither exists, so the body runs
exactly once. Permanent failures are mirrored into the witness table
before the system record, keeping the recovery order; conflicts and
transient errors retry on fresh transactions without consuming the
application retry budget, the same two-loop structure as the
single-database step.

The body receives the backend's native sqlx connection rather than a
wrapper, so existing queries, sqlx macros, and data-access helpers work
unchanged; the DataSource trait is sealed (the backend set is closed, as
in every DBOS SDK). Since commit/rollback stay with the engine, a plain
connection cannot end the transaction - and on Postgres a raw COMMIT or
ROLLBACK smuggled through SQL is caught by a txid fingerprint check and
fails the step instead of silently splitting the writes from their
witness.

The witness table matches the Go SDK's shape (transaction_completion,
step_id; schema-qualified "dbos" on Postgres, configurable, unqualified
on SQLite). There is no cross-SDK contract to hold: Python names the
table datasource_outputs and TypeScript renames the step column, so the
table is language-local everywhere and Go wins as the parity anchor.

Closes #148.
Comment thread src/context.rs Outdated
Comment on lines +717 to +728
if self.in_transaction.swap(true, Ordering::SeqCst) {
return Err(Error::app(
"cannot start a transaction inside another transaction",
));
}
struct ResetOnDrop<'a>(&'a AtomicBool);
impl Drop for ResetOnDrop<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
let _reset = ResetOnDrop(&self.in_transaction);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

tiny improvement: this could be extracted out in something like a fn begin_transaction(&self) -> Result<ResetOnDrop> helper since it's the same code as transaction_with

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

I see, applied in 1564182, both methods are down to one line

@assiotis

assiotis commented Aug 6, 2026

Copy link
Copy Markdown

This LGTM. The only thing I'd mention, possibly as a future (micro?)-optimization, is to shortcut into a single commit if we're in the same database. I know callers can use the regular transaction API for that, but this new Datasource API has the benefit of giving you a native connection instead of forcing the user through the Param API which has some limitations, especially with some Postgres types

Both transaction_with and transaction_on_with carried the same 14-line
nesting guard (flag swap + a function-local reset-on-drop struct). Pull
it into begin_transaction() returning a TxFlagGuard, so the flag
protocol has one definition. Suggested in review on #153.
@SamuelXing

Copy link
Copy Markdown
Owner Author

This LGTM. The only thing I'd mention, possibly as a future (micro?)-optimization, is to shortcut into a single commit if we're in the same database. I know callers can use the regular transaction API for that, but this new Datasource API has the benefit of giving you a native connection instead of forcing the user through the Param API which has some limitations, especially with some Postgres types

I see, filed a follow-up issue #155 for this..

Maybe we can have the engine hand out the datasource, something like engine.system_datasource(), so sameness is guaranteed by construction and transaction_on can take a single commit path

@SamuelXing

Copy link
Copy Markdown
Owner Author

@assiotis I'll just merge this one anyway. thanks for the review

@SamuelXing
SamuelXing merged commit fb8c4e6 into main Aug 6, 2026
@SamuelXing

SamuelXing commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

@assiotis I got another PR for adding the shortcut, do you mind taking a look for this one too? 🙏#156

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.

[Parity] Durable transactions against a separate application database (DataSource / transaction_completion model)

2 participants