Repository navigation
[ruby] open no savepoints inside a transaction - #1544
Merged
Merged
Conversation
bgentry
force-pushed
the
bg/ruby-subtransaction-free-schedule
branch
2 times, most recently
from
October 25, 2026 17:31
9b5eeab to
1b1b997
Compare
brandur
approved these changes
Oct 25, 2026
brandur
left a comment
Contributor
There was a problem hiding this comment.
Nice. Yeah it's key to get rid of these with great prejudice.
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
force-pushed
the
bg/ruby-subtransaction-free-schedule
branch
from
October 25, 2026 20:07
1b1b997 to
b345099
Compare
bgentry
marked this pull request as ready for review
October 25, 2026 20:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Ruby scheduler opens one savepoint for each due job, up to 1,000 per pass. It uses the savepoint to catch a unique key conflict, roll back that row, and mark the row discarded. Go's scheduler finds conflicts without savepoints, and since #1420 Go's transaction helpers join a caller's open transaction instead of opening a savepoint. This change does the same in Ruby.
Savepoints have a cost 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. A savepoint rolled back after a conflict frees its entry. A scheduler pass that makes more than 64 jobs available therefore 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.Go finds conflicts before it writes. Its
JobSchedulequery locks the due rows, numbers the rows that share a unique key (jobs_with_rownum), finds keys that another row already holds (unique_conflicts), and sets each row toavailableordiscarded, all in one statement. Ruby now uses an equivalent query with the same row locks. A locked due row now makes the scheduler wait instead of skipping the row, as in Go. On SQLite, Ruby follows Go's SQLite driver and checks each unique job for a conflict before it writes. The outcome matches Go: the same rows become available, the same rows are discarded, and discarded rows getunique_key_conflict: scheduler_discardedmetadata. The pass now also notifies a queue whose only changed job is discarded, as Go does.The driver
transactionhelpers also open a savepoint inside an application transaction: Sequel withsavepoint: trueand Active Record withrequires_new: true. An insert opens two. Both helpers now join the open transaction, like Go'sWithTxV. If a River call raises, the application must roll back, or wrap the call in its own savepoint to recover and continue. Arollback_exceptionraised inDriver#transactionnow reaches the caller's enclosing transaction block, as a rollback raised in the caller's own code would. Active Record swallows anActiveRecord::Rollbackraised in a joined block, so the Active Record helper yields directly and the rollback reaches the transaction's owner.River still follows the application's own transaction settings. A non-joinable Active Record transaction (such as a Rails transactional test fixture) or a Sequel transaction with
auto_savepointstill gets a savepoint, as any nested block would.RIVER_USE_LEGACY_SUBTRANSACTIONS=1(ortrue) restores the old savepoints, matching Go's temporary fallback. The README states only the contract: River calls join an open transaction, and the application owns any savepoint. The changelog notes the fallback.New specs run a scheduler pass with both kinds of conflict inside a caller's transaction, for Sequel and Active Record on Postgres and SQLite. 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. Another spec runs the other transactional operations in a caller's transaction and asserts that none opens a savepoint.