Skip to content

Commit 24bad6f

Browse files
authored
Merge pull request #410 from rails/fix-queue-selection
Fix queue order when combining multiple prefixes or prefixes and names
2 parents 42ce2ac + 6276b4e commit 24bad6f

File tree

15 files changed

+361
-68
lines changed

15 files changed

+361
-68
lines changed

.github/workflows/main.yml

-2
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,3 @@ jobs:
5353
bin/rails db:setup
5454
- name: Run tests
5555
run: bin/rails test
56-
- name: Run tests with separate connection
57-
run: SEPARATE_CONNECTION=1 bin/rails test

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
/tmp/
66
/test/dummy/db/*.sqlite3
77
/test/dummy/db/*.sqlite3-*
8-
/test/dummy/log/*.log
8+
/test/dummy/log/*.log*
99
/test/dummy/tmp/
1010

1111
# Folder for JetBrains IDEs

.rubocop.yml

+1
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ AllCops:
77
TargetRubyVersion: 3.0
88
Exclude:
99
- "test/dummy/db/schema.rb"
10+
- "test/dummy/db/queue_schema.rb"

README.md

+64
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ Here's an overview of the different options:
149149
This will create a worker fetching jobs from all queues starting with `staging`. The wildcard `*` is only allowed on its own or at the end of a queue name; you can't specify queue names such as `*_some_queue`. These will be ignored.
150150

151151
Finally, you can combine prefixes with exact names, like `[ staging*, background ]`, and the behaviour with respect to order will be the same as with only exact names.
152+
153+
Check the sections below on [how queue order behaves combined with priorities](#queue-order-and-priorities), and [how the way you specify the queues per worker might affect performance](#queues-specification-and-performance).
154+
152155
- `threads`: this is the max size of the thread pool that each worker will have to run jobs. Each worker will fetch this number of jobs from their queue(s), at most and will post them to the thread pool to be run. By default, this is `3`. Only workers have this setting.
153156
- `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.
154157
- `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.
@@ -164,6 +167,67 @@ This is useful when you run jobs with different importance or urgency in the sam
164167

165168
We recommend not mixing queue order with priorities but either choosing one or the other, as that will make job execution order more straightforward for you.
166169

170+
### Queues specification and performance
171+
172+
To keep polling performant and ensure a covering index is always used, Solid Queue only does two types of polling queries:
173+
```sql
174+
-- No filtering by queue
175+
SELECT job_id
176+
FROM solid_queue_ready_executions
177+
ORDER BY priority ASC, job_id ASC
178+
LIMIT ?
179+
FOR UPDATE SKIP LOCKED;
180+
181+
-- Filtering by a single queue
182+
SELECT job_id
183+
FROM solid_queue_ready_executions
184+
WHERE queue_name = ?
185+
ORDER BY priority ASC, job_id ASC
186+
LIMIT ?
187+
FOR UPDATE SKIP LOCKED;
188+
```
189+
190+
The first one (no filtering by queue) is used when you specify
191+
```yml
192+
queues: *
193+
```
194+
and there aren't any queues paused, as we want to target all queues.
195+
196+
In other cases, we need to have a list of queues to filter by, in order, because we can only filter by a single queue at a time to ensure we use an index to sort. This means that if you specify your queues as:
197+
```yml
198+
queues: beta*
199+
```
200+
201+
we'll need to get a list of all existing queues matching that prefix first, with a query that would look like this:
202+
```sql
203+
SELECT DISTINCT(queue_name)
204+
FROM solid_queue_ready_executions
205+
WHERE queue_name LIKE 'beta%';
206+
```
207+
208+
This type of `DISTINCT` query on a column that's the leftmost column in an index can be performed very fast in MySQL thanks to a technique called [Loose Index Scan](https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html#loose-index-scan). PostgreSQL and SQLite, however, don't implement this technique, which means that if your `solid_queue_ready_executions` table is very big because your queues get very deep, this query will get slow. Normally your `solid_queue_ready_executions` table will be small, but it can happen.
209+
210+
Similarly to using prefixes, the same will happen if you have paused queues, because we need to get a list of all queues with a query like
211+
```sql
212+
SELECT DISTINCT(queue_name)
213+
FROM solid_queue_ready_executions
214+
```
215+
216+
and then remove the paused ones. Pausing in general should be something rare, used in special circumstances, and for a short period of time. If you don't want to process jobs from a queue anymore, the best way to do that is to remove it from your list of queues.
217+
218+
💡 To sum up, **if you want to ensure optimal performance on polling**, the best way to do that is to always specify exact names for them, and not have any queues paused.
219+
220+
Do this:
221+
222+
```yml
223+
queues: background, backend
224+
```
225+
226+
instead of this:
227+
```yml
228+
queues: back*
229+
```
230+
167231

168232
### Threads, processes and signals
169233

app/models/solid_queue/queue_selector.rb

+35-5
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,20 @@ def queue_names
3434
def eligible_queues
3535
if include_all_queues? then all_queues
3636
else
37-
exact_names + prefixed_names
37+
in_raw_order(exact_names + prefixed_names)
3838
end
3939
end
4040

4141
def include_all_queues?
4242
"*".in? raw_queues
4343
end
4444

45+
def all_queues
46+
relation.distinct(:queue_name).pluck(:queue_name)
47+
end
48+
4549
def exact_names
46-
raw_queues.select { |queue| !queue.include?("*") }
50+
raw_queues.select { |queue| exact_name?(queue) }
4751
end
4852

4953
def prefixed_names
@@ -54,15 +58,41 @@ def prefixed_names
5458
end
5559

5660
def prefixes
57-
@prefixes ||= raw_queues.select { |queue| queue.ends_with?("*") }.map { |queue| queue.tr("*", "%") }
61+
@prefixes ||= raw_queues.select { |queue| prefixed_name?(queue) }.map { |queue| queue.tr("*", "%") }
5862
end
5963

60-
def all_queues
61-
relation.distinct(:queue_name).pluck(:queue_name)
64+
def exact_name?(queue)
65+
!queue.include?("*")
66+
end
67+
68+
def prefixed_name?(queue)
69+
queue.ends_with?("*")
6270
end
6371

6472
def paused_queues
6573
@paused_queues ||= Pause.all.pluck(:queue_name)
6674
end
75+
76+
def in_raw_order(queues)
77+
# Only need to sort if we have prefixes and more than one queue name.
78+
# Exact names are selected in the same order as they're found
79+
if queues.one? || prefixes.empty?
80+
queues
81+
else
82+
queues = queues.dup
83+
raw_queues.flat_map { |raw_queue| delete_in_order(raw_queue, queues) }.compact
84+
end
85+
end
86+
87+
def delete_in_order(raw_queue, queues)
88+
if exact_name?(raw_queue)
89+
queues.delete(raw_queue)
90+
elsif prefixed_name?(raw_queue)
91+
prefix = raw_queue.tr("*", "")
92+
queues.select { |queue| queue.start_with?(prefix) }.tap do |matches|
93+
queues -= matches
94+
end
95+
end
96+
end
6797
end
6898
end

bin/setup

+11-3
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22
set -eu
33
cd "$(dirname "${BASH_SOURCE[0]}")"
44

5-
docker-compose up -d --remove-orphans
6-
docker-compose ps
5+
if docker compose version &> /dev/null; then
6+
DOCKER_COMPOSE_CMD="docker compose"
7+
else
8+
DOCKER_COMPOSE_CMD="docker-compose"
9+
fi
10+
11+
$DOCKER_COMPOSE_CMD up -d --remove-orphans
12+
$DOCKER_COMPOSE_CMD ps
713

814
bundle
915

1016
echo "Creating databases..."
1117

12-
rails db:reset
18+
rails db:reset TARGET_DB=sqlite
19+
rails db:reset TARGET_DB=mysql
20+
rails db:reset TARGET_DB=postgres

test/dummy/bin/jobs

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env ruby
2+
3+
require_relative "../config/environment"
4+
require "solid_queue/cli"
5+
6+
SolidQueue::Cli.start(ARGV)

test/dummy/config/application.rb

-4
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,5 @@ class Application < Rails::Application
2828
# config.eager_load_paths << Rails.root.join("extras")
2929

3030
config.active_job.queue_adapter = :solid_queue
31-
32-
if ENV["SEPARATE_CONNECTION"] && ENV["TARGET_DB"] != "sqlite"
33-
config.solid_queue.connects_to = { database: { writing: :primary, reading: :replica } }
34-
end
3531
end
3632
end

test/dummy/config/database.yml

+8-8
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,19 @@ default: &default
3535
development:
3636
primary:
3737
<<: *default
38-
database: <%= database_name_from("solid_queue_development") %>
39-
replica:
38+
database: <%= database_name_from("development") %>
39+
queue:
4040
<<: *default
41-
database: <%= database_name_from("solid_queue_development") %>
42-
replica: true
41+
database: <%= database_name_from("development_queue") %>
42+
migrations_paths: db/queue_migrate
4343

4444
test:
4545
primary:
4646
<<: *default
4747
pool: 20
48-
database: <%= database_name_from("solid_queue_test") %>
49-
replica:
48+
database: <%= database_name_from("test") %>
49+
queue:
5050
<<: *default
5151
pool: 20
52-
database: <%= database_name_from("solid_queue_test") %>
53-
replica: true
52+
database: <%= database_name_from("test_queue") %>
53+
migrations_paths: db/queue_migrate

test/dummy/config/environments/development.rb

+4
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
# Raises error for missing translations.
5252
# config.i18n.raise_on_missing_translations = true
5353

54+
# Replace the default in-process and non-durable queuing backend for Active Job.
55+
config.active_job.queue_adapter = :solid_queue
56+
config.solid_queue.connects_to = { database: { writing: :queue } }
57+
5458
# Annotate rendered view with file names.
5559
# config.action_view.annotate_rendered_view_with_filenames = true
5660

test/dummy/config/environments/production.rb

+4-2
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@
4444
# Use a different cache store in production.
4545
# config.cache_store = :mem_cache_store
4646

47-
# Use a real queuing backend for Active Job (and separate queues per environment).
48-
# config.active_job.queue_adapter = :resque
47+
# Replace the default in-process and non-durable queuing backend for Active Job.
48+
config.active_job.queue_adapter = :solid_queue
49+
config.solid_queue.connects_to = { database: { writing: :queue } }
50+
4951
# config.active_job.queue_name_prefix = "dummy_production"
5052

5153
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to

test/dummy/config/environments/test.rb

+4
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
# Raises error for missing translations.
4848
# config.i18n.raise_on_missing_translations = true
4949

50+
# Replace the default in-process and non-durable queuing backend for Active Job.
51+
config.active_job.queue_adapter = :solid_queue
52+
config.solid_queue.connects_to = { database: { writing: :queue } }
53+
5054
# Annotate rendered view with file names.
5155
# config.action_view.annotate_rendered_view_with_filenames = true
5256

0 commit comments

Comments
 (0)