Skip to content

Avoid opening subtransactions when already in a transaction - #1420

Merged
brandur merged 1 commit into
masterfrom
brandur-remove-savepoints
Oct 4, 2026
Merged

brandur merged 1 commit into
masterfrom
brandur-remove-savepoints

Conversation

@brandur

@brandur brandur commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

River currently opens subtransactions when already in a transaction for
most behavior. This isn't something that's useful in the majority of
situations, but may be very occasionally in cases where a piece of a
transaction fails, but the caller still wants to commit the whole
transaction anyway.

Unfortunately, subtransactions have a real performance impact in
Postgres [1], so it's not free to have them. One of our users confirms
to have observed this IRL.

This patch modifies River's transaction approaches so as to only open a
new one unless not already in one. So Client.Insert still opens a
transaction, but Client.InsertTx won't open a new subtransaction on
top of the tx that was already passed into it.

Though not an API change, this is a behavioral change. I've been having
Codex try to give me the worst case scenario of what might break were we
to change this. So far it hasn't come up with anything convincing (in
every scenario the change improves correctness of the original state),
but it's still worth thinking about whether there might be other real
use cases where people would be broken by this.

For now I've added an env flag RIVER_USE_LEGACY_SUBTRANSACTIONS that
can be used to revert back to the previous behavior. The idea here is
that it'd give anyone broken by this a potential escape hatch, but it
would still let us roll forward with no subtransactions as the preferred
default. The env var would be removed in a future version.

[1] https://planetscale.com/blog/the-dangers-of-postgres-subtransactions

@brandur

brandur commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Here's an example from Codex of a potential problem that could result from the change:


A concrete example is a Pro user who saves an order but treats an accompanying analytics job as optional:

if err := insertOrder(ctx, tx, order); err != nil {
    return err
}

if _, err := client.InsertTx(ctx, tx, AnalyticsArgs{OrderID: order.ID}, nil); err != nil {
    logger.Error("Couldn't enqueue analytics", "error", err)
    // Intentionally continue: analytics shouldn't block checkout.
}

return tx.Commit(ctx)

Suppose the job insertion hits a PostgreSQL lock_timeout because another transaction holds a conflicting lock.

  • Before: Pro rolls back its internal savepoint. The order remains in the outer transaction, and the commit succeeds.
  • After: The SQL error leaves the entire transaction aborted. The commit cannot save the order, so an optional analytics failure now causes checkout to fail. PostgreSQL requires rollback to a savepoint—or a full rollback—to recover from that state. PostgreSQL documentation.

@brandur

brandur commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Another one:


A stronger example is a bulk import that intentionally accepts valid records and reports individual failures.

Suppose a Pro application imports 1,000 documents, calling InsertTx once per document inside a transaction. Its intended result is “999 documents queued; one rejected.”

One document contains a NUL character in its text. Go successfully encodes that as \u0000, but PostgreSQL rejects it in jsonb, so the insertion produces a database error. PostgreSQL documentation.

  • Before: Pro rolls back that insertion’s savepoint. The application records the rejected document, continues importing, and commits the other 999 jobs.
  • After: That insertion aborts the transaction. Subsequent insertions fail, and none of the jobs can be committed. Retrying the same input repeats the failure.

Here, retaining successful work is explicitly the desired behavior. There’s no order/email consistency concern: each document is independent, and one malformed input now prevents every valid document from being processed.

The application would need its own per-document savepoints, or separate transactions, to restore that behavior.

That’s a legitimate compatibility concern, although it’s still a hypothetical usage pattern, not an observed customer dependency. I’d still favor caller-owned savepoints for this case: the application knows it wants partial success, whereas most callers want the entire transaction rolled back when an insertion fails.

@brandur

brandur commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

I don't find either of these examples all that convincing in that it's definitely describing a real thing, but it'd be unusual to still want to commit the transaction in case of either failure, and in case you did want to commit it, an explicit savepoint would definitely be preferable to River deciding for you.

Want to be careful on this one because although the obvious answer would be to put this behind a configuration flag, it seems to me that the overwhelmingly more "correct" behavior would be the opposite of what River does right now. So it'd be nice to work our way to a point where we could get back to that.

River currently opens subtransactions when already in a transaction for
most behavior. This isn't something that's useful in the majority of
situations, but may be very occasionally in cases where a piece of a
transaction fails, but the caller still wants to commit the whole
transaction anyway.

Unfortunately, subtransactions have a real performance impact in
Postgres [1], so it's not free to have them. One of our users confirms
to have observed this IRL.

This patch modifies River's transaction approaches so as to only open a
new one unless not already in one. So `Client.Insert` still opens a
transaction, but `Client.InsertTx` won't open a new subtransaction on
top of the `tx` that was already passed into it.

Though not an API change, this is a behavioral change. I've been having
Codex try to give me the worst case scenario of what might break were we
to change this. So far it hasn't come up with anything convincing (in
every scenario the change improves correctness of the original state),
but it's still worth thinking about whether there might be other real
use cases where people would be broken by this.

For now I've added an env flag `RIVER_USE_LEGACY_SUBTRANSACTIONS` that
can be used to revert back to the previous behavior. The idea here is
that it'd give anyone broken by this a potential escape hatch, but it
would still let us roll forward with no subtransactions as the preferred
default. The env var would be removed in a future version.

[1] https://planetscale.com/blog/the-dangers-of-postgres-subtransactions
@brandur
brandur force-pushed the brandur-remove-savepoints branch from 04ade8e to 5f9acf4 Compare October 2, 2026 21:06

@bgentry bgentry 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.

I was a bit confused until I realized this was mainly a sqlite-specific PR (its title doesn't say so, nor does the changelog entry). Aside from making that clearer, I think this is fine to merge. I assume you've also verified the other drivers don't need such changes?

@brandur

brandur commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Ah, it actually does apply to all databases. The key piece is in db_util.go here:

// executor preserves transaction identity when a method is invoked through an
// embedded Executor, so transaction helpers can reuse it without a savepoint.
func (e *Executor) executor() riverdriver.Executor {
	if e.execTx != nil {
		return e.execTx
	}
	return e
}

SQLite's driver needed a little extra code too, but this change fixes all DBs.

@brandur
brandur merged commit 6cdaf38 into master Oct 4, 2026
15 checks passed
@brandur
brandur deleted the brandur-remove-savepoints branch October 4, 2026 19:53
bgentry added a commit that referenced this pull request Oct 25, 2026
The Ruby scheduler opens a savepoint for each due job, up to 1,000 per
pass. It uses the savepoint to catch a unique key conflict, roll back
that one row, and mark the row discarded. In Postgres, each savepoint
that writes gets its own transaction ID. A backend caches only 64 of
these IDs. Past that, the cache overflows. Until the transaction ends,
other sessions must look up `pg_subtrans` to check whether recent rows
are visible. This can slow queries across the server.

The driver `transaction` helpers have the same problem. Inside an
application transaction, the Sequel helper passes `savepoint: true` and
the Active Record helper passes `requires_new: true`. Each River write
can open a savepoint, and an insert opens two.

Port Go's `JobSchedule` instead. On Postgres, one statement locks the
due rows, finds conflicts with `jobs_with_rownum` and
`unique_conflicts`, and sets each row to `available` or `discarded`.
SQLite cannot update rows in a CTE, so the SQLite path checks each
unique job for a conflict before it writes, as Go's SQLite driver
does. No write fails, so no savepoint is needed. The Postgres query
keeps `FOR UPDATE SKIP LOCKED`. The Ruby leader runs the scheduler in
the loop that renews leadership, so it must not wait on an
application's row lock. The pass now notifies every queue it changes,
as Go does.

Make both helpers join an open transaction, as Go's `WithTxV` does
since #1420. Active Record ignores `ActiveRecord::Rollback` in a joined
block. So the Active Record helper yields directly, and the rollback
reaches the transaction's owner. A rollback raised in
`Driver#transaction` now rolls back the caller's whole transaction.
The SQLite batch insert uses the same helper.
`RIVER_USE_LEGACY_SUBTRANSACTIONS=1` (or `true`) restores the
savepoints, like Go's temporary fallback.

New specs run a scheduler pass with both kinds of conflict inside a
caller's transaction. They assert the outcome that Go produces and
that no savepoint opens. On Postgres, they also check that each changed
row has the caller's transaction ID as its `xmin`. Specs that relied on
River's savepoints now use one that the application owns.
bgentry added a commit that referenced this pull request Oct 25, 2026
The Ruby scheduler opens a savepoint for each due job, up to 1,000 per
pass. It uses the savepoint to catch a unique key conflict, roll back
that one row, and mark the row discarded. In Postgres, each savepoint
that writes gets its own transaction ID. A backend caches only 64 of
these IDs. Past that, the cache overflows. Until the transaction ends,
other sessions must look up `pg_subtrans` to check whether recent rows
are visible. This can slow queries across the server.

The driver `transaction` helpers have the same problem. Inside an
application transaction, the Sequel helper passes `savepoint: true` and
the Active Record helper passes `requires_new: true`. Each River write
can open a savepoint, and an insert opens two.

Port Go's `JobSchedule` instead. On Postgres, one statement locks the
due rows with `FOR UPDATE`, finds conflicts with `jobs_with_rownum` and
`unique_conflicts`, and sets each row to `available` or `discarded`.
The query matches Go's, including its locking, so the scheduler now
waits for a row that another transaction has locked instead of
skipping it. SQLite cannot update rows in a CTE, so the SQLite path
checks each unique job for a conflict before it writes, as Go's SQLite
driver does. No write fails, so no savepoint is needed. The pass now
notifies every queue it changes, as Go does.

Make both helpers join an open transaction, as Go's `WithTxV` does
since #1420. Active Record ignores `ActiveRecord::Rollback` in a joined
block. So the Active Record helper yields directly, and the rollback
reaches the transaction's owner. A rollback raised in
`Driver#transaction` now rolls back the caller's whole transaction.
The SQLite batch insert uses the same helper.
`RIVER_USE_LEGACY_SUBTRANSACTIONS=1` (or `true`) restores the
savepoints, like Go's temporary fallback.

New specs run a scheduler pass with both kinds of conflict inside a
caller's transaction. They assert the outcome that Go produces and
that no savepoint opens. On Postgres, they also check that each changed
row has the caller's transaction ID as its `xmin`. Specs that relied on
River's savepoints now use one that the application owns.
bgentry added a commit that referenced this pull request Oct 25, 2026
The Ruby scheduler opens a savepoint for each due job, up to 1,000 per
pass. It uses the savepoint to catch a unique key conflict, roll back
that one row, and mark the row discarded. In Postgres, a savepoint gets
its own transaction ID when it first writes or locks a row. Each backend
caches at most 64 of these IDs. A released savepoint keeps its entry
until the top-level transaction ends, so a pass that makes more than 64
jobs available overflows the cache. Until that transaction ends,
snapshots that any session takes are marked as overflowed. Their
visibility checks on rows that recent transactions wrote must then look
up `pg_subtrans`. This can slow queries across the server.

The driver `transaction` helpers have the same problem. The Sequel
helper passes `savepoint: true` and the Active Record helper passes
`requires_new: true`, so inside an application transaction each River
write can open a savepoint. An insert opens two.

Replace the scheduler's per-job savepoints with a port of Go's
`JobSchedule`. On Postgres, one statement locks the due rows with
`FOR UPDATE`, finds conflicts with `jobs_with_rownum` and
`unique_conflicts`, and sets each row to `available` or `discarded`. The
query is equivalent to Go's and locks rows the same way, so the
scheduler now waits for a row that another transaction has locked
instead of skipping it. On SQLite, the pass follows Go's SQLite driver
and checks each unique job for a conflict before it writes. Neither path
relies on a failed write to find a conflict, so neither needs a
savepoint. The pass now also notifies a queue whose only changed job is
discarded, as Go does.

Make both helpers join an open transaction, as Go's `WithTxV` has done
since #1420. Active Record's `transaction` swallows an
`ActiveRecord::Rollback` raised in a joined block and rolls nothing
back. The Active Record helper therefore yields directly, so the
rollback reaches the transaction's owner. Inside an open transaction, a
`rollback_exception` raised in `Driver#transaction` now reaches the
caller's enclosing transaction block, as a rollback raised in the
caller's own code would. The SQLite batch insert uses the same helper.
`RIVER_USE_LEGACY_SUBTRANSACTIONS=1` (or `true`) restores the
savepoints, matching Go's temporary fallback of the same name.

New specs run a scheduler pass with both kinds of conflict inside a
caller's transaction. They assert the outcome that Go produces and that
no savepoint opens. On Postgres, they also check that each changed row
has the caller's transaction ID as its `xmin`. Specs that relied on
River's savepoints now use one that the application owns.
bgentry added a commit that referenced this pull request Oct 25, 2026
The Ruby scheduler opens a savepoint for each due job, up to 1,000 per
pass. It uses the savepoint to catch a unique key conflict, roll back
that one row, and mark the row discarded. In Postgres, a savepoint gets
its own transaction ID when it first writes or locks a row. Each backend
caches at most 64 of these IDs. A released savepoint keeps its entry
until the top-level transaction ends, so a pass that makes more than 64
jobs available overflows the cache. Until that transaction ends,
snapshots that any session takes are marked as overflowed. Their
visibility checks on rows that recent transactions wrote must then look
up `pg_subtrans`. This can slow queries across the server.

The driver `transaction` helpers have the same problem. The Sequel
helper passes `savepoint: true` and the Active Record helper passes
`requires_new: true`, so inside an application transaction each River
write can open a savepoint. An insert opens two.

Replace the scheduler's per-job savepoints with a port of Go's
`JobSchedule`. On Postgres, one statement locks the due rows with
`FOR UPDATE`, finds conflicts with `jobs_with_rownum` and
`unique_conflicts`, and sets each row to `available` or `discarded`. The
query is equivalent to Go's and locks rows the same way, so the
scheduler now waits for a row that another transaction has locked
instead of skipping it. On SQLite, the pass follows Go's SQLite driver
and checks each unique job for a conflict before it writes. Neither path
relies on a failed write to find a conflict, so neither needs a
savepoint. The pass now also notifies a queue whose only changed job is
discarded, as Go does.

Make both helpers join an open transaction, as Go's `WithTxV` has done
since #1420. Active Record's `transaction` swallows an
`ActiveRecord::Rollback` raised in a joined block and rolls nothing
back. The Active Record helper therefore yields directly, so the
rollback reaches the transaction's owner. Inside an open transaction, a
`rollback_exception` raised in `Driver#transaction` now reaches the
caller's enclosing transaction block, as a rollback raised in the
caller's own code would. The SQLite batch insert uses the same helper.
`RIVER_USE_LEGACY_SUBTRANSACTIONS=1` (or `true`) restores the
savepoints, matching Go's temporary fallback of the same name.

New specs run a scheduler pass with both kinds of conflict inside a
caller's transaction. They assert the outcome that Go produces and that
no savepoint opens. On Postgres, they also check that each changed row
has the caller's transaction ID as its `xmin`. Specs that relied on
River's savepoints now use one that the application owns.
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.

2 participants