Skip to content

[ruby] open no savepoints inside a transaction - #1544

Merged
bgentry merged 1 commit into
masterfrom
bg/ruby-subtransaction-free-schedule
Oct 25, 2026
Merged

bgentry merged 1 commit into
masterfrom
bg/ruby-subtransaction-free-schedule

Conversation

@bgentry

@bgentry bgentry commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

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 JobSchedule query 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 to available or discarded, 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 get unique_key_conflict: scheduler_discarded metadata. The pass now also notifies a queue whose only changed job is discarded, as Go does.

The driver transaction helpers also open a savepoint inside an application transaction: Sequel with savepoint: true and Active Record with requires_new: true. An insert opens two. Both helpers now join the open transaction, like Go's WithTxV. If a River call raises, the application must roll back, or wrap the call in its own savepoint to recover and continue. 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. Active Record swallows an ActiveRecord::Rollback raised 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_savepoint still gets a savepoint, as any nested block would. RIVER_USE_LEGACY_SUBTRANSACTIONS=1 (or true) 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.

@bgentry
bgentry force-pushed the bg/ruby-subtransaction-free-schedule branch 2 times, most recently from 9b5eeab to 1b1b997 Compare October 25, 2026 17:31

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

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
bgentry force-pushed the bg/ruby-subtransaction-free-schedule branch from 1b1b997 to b345099 Compare October 25, 2026 20:07
@bgentry bgentry changed the title [ruby] Open no savepoints inside a transaction [ruby] open no savepoints inside a transaction Oct 25, 2026
@bgentry
bgentry marked this pull request as ready for review October 25, 2026 20:12
@bgentry
bgentry merged commit b3a0089 into master Oct 25, 2026
33 checks passed
@bgentry
bgentry deleted the bg/ruby-subtransaction-free-schedule branch October 25, 2026 20:30
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