Skip to content

Commit a0e9444

Browse files
committed
Make async DB pool guidance Rails-version aware
1 parent 74e443e commit a0e9444

3 files changed

Lines changed: 52 additions & 14 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ Here's an overview of the different options:
282282
- `threads`: this is the execution capacity for a worker in `thread` mode. It is the max size of the thread pool. By default, this is `3`. Only workers have this setting.
283283
It is recommended to set this value less than or equal to the queue database's connection pool size minus 2, as each worker uses connections for polling and heartbeat and thread mode may use additional connections for job execution.
284284
- `capacity`: an alias for worker execution capacity. This is the clearer name when `execution_mode: async`, because it refers to in-flight execution capacity rather than operating system threads.
285+
Async workers reject `threads`; use `capacity` or `fibers` instead. On Rails 7.2 and later, a practical starting point is usually `3-5` queue database connections per worker process rather than `capacity`, because ordinary Active Record query paths can release connections between async waits. On Rails 7.1, size the queue database pool more conservatively, as in-flight async jobs may still retain connections roughly in proportion to `capacity`.
285286
- `fibers`: an alias for `capacity` when `execution_mode: async`.
286287
- `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. This works with both `execution_mode: thread` and `execution_mode: async` as long as the supervisor is running in the default `fork` mode. **Note**: this option is ignored only when the supervisor itself is [running in `async` mode](#fork-vs-async-mode).
287288
- `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else.
@@ -380,6 +381,10 @@ Async worker execution is best suited for cooperative, mostly I/O-bound jobs. Bl
380381

381382
Because async workers run multiple fibers on a single thread, Rails must also isolate execution state per fiber rather than per thread. If your app keeps the default thread-scoped isolation level, Solid Queue will raise a boot-time error instead of running async workers with shared Active Record state.
382383

384+
On Rails 7.2 and later, async workers can often use a much smaller queue database pool than an equivalent thread pool. A practical starting point is `3-5` queue database connections per worker process: one for job execution, one for polling, one for heartbeats, plus some headroom. In the default `fork` supervisor mode, that guidance applies per worker process. In supervisor `async` mode, all workers share one process, so add together the requirements for the workers running there.
385+
386+
That lower-pool guidance depends on job code not holding connections open across async waits. APIs such as `ActiveRecord::Base.connection`, `lease_connection`, `connection_pool.checkout`, or long-lived `with_connection` / transaction blocks can pin connections and push async workers back toward thread-like pool usage. On Rails 7.1, plan conservatively and assume async capacity can still grow queue database connection usage.
387+
383388
The supervisor is in charge of managing these processes, and it responds to the following signals when running in its own process via `bin/jobs` or with [the Puma plugin](#puma-plugin) with the default `fork` mode:
384389
- `TERM`, `INT`: starts graceful termination. The supervisor will send a `TERM` signal to its supervised processes, and it'll wait up to `SolidQueue.shutdown_timeout` time until they're done. If any supervised processes are still around by then, it'll send a `QUIT` signal to them to indicate they must exit.
385390
- `QUIT`: starts immediate termination. The supervisor will send a `QUIT` signal to its supervised processes, causing them to exit immediately.

lib/solid_queue/configuration.rb

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ class Configuration
66

77
validate :ensure_configured_processes
88
validate :ensure_valid_recurring_tasks
9-
validate :ensure_correctly_sized_thread_pool
9+
validate :ensure_correctly_sized_database_pool
1010
validate :ensure_valid_worker_execution_modes
1111
validate :ensure_async_workers_use_capacity_aliases
1212
validate :ensure_async_workers_have_required_dependency
@@ -40,6 +40,7 @@ def instantiate
4040

4141
DEFAULT_CONFIG_FILE_PATH = "config/queue.yml"
4242
DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml"
43+
ASYNC_QUERY_SCOPED_CONNECTIONS_VERSION = Gem::Version.new("7.2.0")
4344

4445
def initialize(**options)
4546
@options = options.with_defaults(default_options)
@@ -93,10 +94,11 @@ def ensure_valid_recurring_tasks
9394
end
9495
end
9596

96-
def ensure_correctly_sized_thread_pool
97-
if (db_pool_size = SolidQueue::Record.connection_pool&.size) && db_pool_size < estimated_number_of_threads
98-
errors.add(:base, "Solid Queue is configured to use #{estimated_number_of_threads} threads but the " +
99-
"database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
97+
def ensure_correctly_sized_database_pool
98+
if (db_pool_size = SolidQueue::Record.connection_pool&.size) && db_pool_size < estimated_database_pool_size
99+
errors.add(:base, "Solid Queue requires at least #{estimated_database_pool_size} database connections " +
100+
"for the configured workers, but the queue database connection pool is #{db_pool_size}. " +
101+
"Increase it in `config/database.yml`")
100102
end
101103
end
102104

@@ -264,11 +266,13 @@ def load_config_from_file(file)
264266
end
265267
end
266268

267-
def estimated_number_of_threads
268-
# At most one execution thread for async workers, or "threads" for thread workers,
269-
# plus 1 thread for the worker loop and 1 thread for the heartbeat task.
270-
thread_count = workers_options.map { |options| execution_threads_for_pool(options) }.max
271-
(thread_count || 1) + 2
269+
def estimated_database_pool_size
270+
worker_pool_size = workers_options.map { |options| estimated_database_pool_size_for_worker(options) }.max
271+
worker_pool_size || 1
272+
end
273+
274+
def estimated_database_pool_size_for_worker(options)
275+
estimated_execution_connections_for_worker(options) + 2
272276
end
273277

274278
def normalize_worker_options(options)
@@ -289,8 +293,16 @@ def normalized_worker_execution_mode(options)
289293
options[:execution_mode] || WORKER_DEFAULTS[:execution_mode]
290294
end
291295

292-
def execution_threads_for_pool(options)
293-
async_worker?(options) ? 1 : worker_capacity(options)
296+
def estimated_execution_connections_for_worker(options)
297+
async_worker?(options) ? async_execution_connections_for_worker(options) : worker_capacity(options)
298+
end
299+
300+
def async_execution_connections_for_worker(options)
301+
async_jobs_release_connections_between_queries? ? 1 : worker_capacity(options)
302+
end
303+
304+
def async_jobs_release_connections_between_queries?
305+
ActiveRecord.gem_version >= ASYNC_QUERY_SCOPED_CONNECTIONS_VERSION
294306
end
295307

296308
def async_worker?(options)

test/unit/configuration_test.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,24 @@ class ConfigurationTest < ActiveSupport::TestCase
9090
end
9191
end
9292

93-
test "async worker capacity does not inflate required database pool size" do
93+
test "async worker capacity inflates required database pool size on Rails 7.1" do
94+
skip if async_workers_release_connections_between_queries?
95+
96+
with_execution_isolation(:fiber) do
97+
configuration = SolidQueue::Configuration.new(
98+
workers: [ { queues: "llm*", execution_mode: :async, capacity: 1000 } ],
99+
dispatchers: [],
100+
skip_recurring: true
101+
)
102+
103+
assert_not configuration.valid?
104+
assert_match /requires at least 1002 database connections/, configuration.errors.full_messages.first
105+
end
106+
end
107+
108+
test "async worker capacity does not inflate required database pool size on Rails 7.2+" do
109+
skip unless async_workers_release_connections_between_queries?
110+
94111
with_execution_isolation(:fiber) do
95112
configuration = SolidQueue::Configuration.new(
96113
workers: [ { queues: "llm*", execution_mode: :async, capacity: 1000 } ],
@@ -227,11 +244,15 @@ class ConfigurationTest < ActiveSupport::TestCase
227244
# Not enough DB connections
228245
configuration = SolidQueue::Configuration.new(workers: [ { queues: "background", threads: 50, polling_interval: 10 } ])
229246
assert_not configuration.valid?
230-
assert_match /Solid Queue is configured to use \d+ threads but the database connection pool is \d+. Increase it in `config\/database.yml`/,
247+
assert_match /Solid Queue requires at least \d+ database connections for the configured workers, but the queue database connection pool is \d+. Increase it in `config\/database.yml`/,
231248
configuration.errors.full_messages.first
232249
end
233250

234251
private
252+
def async_workers_release_connections_between_queries?
253+
ActiveRecord.gem_version >= SolidQueue::Configuration::ASYNC_QUERY_SCOPED_CONNECTIONS_VERSION
254+
end
255+
235256
def assert_processes(configuration, kind, count, **attributes)
236257
processes = configuration.configured_processes.select { |p| p.kind == kind }
237258
assert_equal count, processes.size

0 commit comments

Comments
 (0)