Repository navigation
Conversation
The Active Record driver read the class's connection, which leases it to the calling thread for the thread's lifetime. Jobs complete on their own short-lived thread, so every job left a leased connection behind. When the pool ran dry, Active Record reaped each one from its dead thread and reset it: DISCARD ALL, the session SETs, and a reload of the type map, then fresh prepared statements. Borrow the connection for each call instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Px5N6n2zvu9WqL1Nk8hruU
Current master already waits on a dedicated condition for normal maintenance, with generation tracking for leadership notifications. Preserve that loop and also use the dedicated condition during error backoff, so completions and insertions cannot trigger an immediate retry. Stop still wakes both conditions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Px5N6n2zvu9WqL1Nk8hruU
Claims, completions, and state changes returned ids and then read each job again by id. They now return the rows from the UPDATE itself, as Go's JobGetAvailable and JobSetStateIfRunningMany do. Each driver decodes the returned rows with its existing decoders, and SQLite returns SQLITE_JOB_COLUMNS. The claim is a single statement, so it needs no transaction and no reads after it commits. Finishing a running job locks it by primary key in a CTE and tests the locked row's state there, like Go's JobRetry and JobUpdate. With the state test in the statement's own WHERE, Postgres planned it on the fetching index, and the scan read every running entry, live and dead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Px5N6n2zvu9WqL1Nk8hruU
Borrow connections during driver initialization and transaction handling, including the transaction-joining path added since the original drain optimization. Keep an application's existing lease and rollback behavior. Exercise initialization, insertion, claiming, completion, reads, successful transactions, rollback, and exceptions from a short-lived thread on both Postgres and file-backed SQLite. Verify that nested driver calls preserve the caller's lease, and that shared claim/completion paths return rows without rereading jobs by ID. Move the changelog entries into Unreleased and retain the notification-aware maintenance loop. The rebased completion SQL also retains current-attempt checks used by transactional completion.
Add a multi-process drain harness using riverqueue-rails' Active Job adapter and worker. Verify every serialized job is delivered exactly once and every persisted attempt counter is one. Capture pg_stat_statements and process CPU before stopping the workers, and emit the measurements as JSON. Boot workers in fresh Ruby interpreters to avoid a native libpq fork crash on macOS. Worker boot and job insertion are outside the measured drain. Use a dedicated local Postgres server because the harness resets its SQL statistics server-wide. Each run uses and removes its own migrated schema. Compared upstream e898054 with the adapted changes at 3f02b5a on an Apple M4 (10 CPU cores, 24 GiB), Ruby 4.0.2, Active Record 8.1.4, and Postgres 18.0. The dedicated server used pg_stat_statements, 128 MB shared_buffers, 500 max_connections, and fsync/synchronous_commit enabled. Each run drains 20,000 otherwise no-op Active Job deliveries with 20 worker threads per process. fetch_poll_interval stays at its 1.0 second default. Run three paired rounds for each configuration, alternating revision order: baseline/candidate, candidate/baseline, baseline/candidate. All 36 runs passed delivery and attempt checks (720,000 jobs total). Throughput in jobs/s, median [min-max]: Processes x threads | Cooldown | Upstream | This branch | Ratio 8 x 20 | 0.1 | 733 [731-752] | 740 [739-755] | 1.01x 8 x 20 | 0.01 | 2,229 [2,185-2,275] | 5,424 [5,420-5,865] | 2.43x 8 x 20 | 0.001 | 2,125 [2,067-2,126] | 8,525 [8,469-8,659] | 4.01x 16 x 20 | 0.1 | 1,426 [1,423-1,459] | 1,466 [1,461-1,506] | 1.03x 16 x 20 | 0.01 | 2,060 [2,041-2,108] | 7,222 [7,162-7,401] | 3.51x 16 x 20 | 0.001 | 2,006 [1,931-2,074] | 7,271 [7,062-7,310] | 3.62x Median costs at 8 x 20 and fetch_cooldown=0.001: Metric | Upstream | This branch SQL statements/job | 15.677 | 1.292 DISCARD ALL calls/run | 19,906 | 0 Follow-up job reads/run | 40,000 | 0 Completion buffers/call | 83.123 | 33.031 Completion mean execution (ms) | 0.553 | 0.169 Ruby CPU/job (ms) | 2.381 | 0.564 Postgres CPU/job (ms) | 1.627 | 0.383 Total CPU/job (ms) | 4.007 | 0.947 CPU figures are OS process-time estimates; Postgres includes the dedicated server's background and monitoring work. Each cell is an independent median. The per-delivery audit adds a small, identical in-memory cost to both versions. The default cooldown remains the throughput limit. At shorter cooldowns the branch uses about 76% less total CPU/job; on this machine, 8 processes reach higher peak throughput than 16. To reproduce one run, select the checkout with BUNDLE_GEMFILE while using the same harness for both revisions: BUNDLE_GEMFILE=<checkout>/ruby/rails/riverqueue-rails/Gemfile \ ruby ruby/bench/drain.rb \ --database-url postgres://localhost:55432/river_bench \ --jobs 20000 --processes 8 --threads 20 --cooldown 0.001 Validation: Ruby make test (3,055 examples, zero failures), make lint, and make type-check pass. Core and both driver suites retain 100% line and branch coverage. The benchmark matrix exercises the final harness against both revisions.
brandur
force-pushed
the
brandur-ruby-drain-scaling
branch
from
October 31, 2026 00:05
a64b3b9 to
66e5c0a
Compare
bgentry
approved these changes
Oct 31, 2026
bgentry
left a comment
Contributor
There was a problem hiding this comment.
Nice fixes.
I was gonna ask if you’d had an agent check other ports to see if they’re doing this correctly, but then it made me think we should probably do some kind of regular scans against recent changes to do parity checks. I don’t think it would be deterministic enough to be used in a CI check, gonna think about the best mechanism.
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.
This one comes by way of @jpcamara -- it turns out that we were doing some
unreasonably inefficient stuff in Ruby that was significantly impacting
possible through.
After fixes, we observe these improvements in a benchmark:
Most of the slowdown was the result of these problems:
We were using
.connectionto lease a connection a job's thread. Each jobthread was short-lived, and when ActiveRecord eventually reclaimed it, it had
to reset by issueing a whole bunch of queries:
DISCARD ALLSETstatementspg_typefor type informationThe fix is to switch to
with_transactionto return the borrowedconnection to the pool without needing a reset.
We weren't doing
RETURNING river_job.*, instead returning job informationvia ID and doing a fetch. Fix saves two reads per job.
Single statement operations were using explicit transaction commands.
Previously, claim and completion were wrapping themselves with
BEGINandCOMMIT. With the reads above removed, that can go away, saving manyoperations issued.