Skip to content

Commit 0aa1644

Browse files
committed
Separate recurring tasks configuration into their own file
`config/recurring.yml`. Run them using the least busy dispatcher (largest polling interval) or a new default dispatcher if none. This also introduces some new options for the CLI, to allow for `--dispatch_only` and `--work_only`, and to skip recurring tasks.
1 parent d7955da commit 0aa1644

File tree

10 files changed

+179
-72
lines changed

10 files changed

+179
-72
lines changed

lib/solid_queue/cli.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,19 @@
44

55
module SolidQueue
66
class Cli < Thor
7-
class_option :config_file, type: :string, aliases: "-c", default: Configuration::DEFAULT_CONFIG_FILE_PATH, desc: "Path to config file"
7+
class_option :config_file, type: :string, aliases: "-c",
8+
default: Configuration::DEFAULT_CONFIG_FILE_PATH,
9+
desc: "Path to config file",
10+
banner: "SOLID_QUEUE_CONFIG"
11+
12+
class_option :recurring_schedule_file, type: :string,
13+
default: Configuration::DEFAULT_RECURRING_SCHEDULE_FILE_PATH,
14+
desc: "Path to recurring schedule definition",
15+
banner: "SOLID_QUEUE_RECURRING_SCHEDULE"
16+
17+
class_option :dispatch_only, type: :boolean, default: false
18+
class_option :work_only, type: :boolean, default: false
19+
class_option :skip_recurring, type: :boolean, default: false
820

921
def self.exit_on_failure?
1022
true
@@ -14,7 +26,7 @@ def self.exit_on_failure?
1426
default_command :start
1527

1628
def start
17-
SolidQueue::Supervisor.start(config_file: options["config_file"])
29+
SolidQueue::Supervisor.start(**options.symbolize_keys)
1830
end
1931
end
2032
end

lib/solid_queue/configuration.rb

Lines changed: 78 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,23 @@ def instantiate
1919
batch_size: 500,
2020
polling_interval: 1,
2121
concurrency_maintenance: true,
22-
concurrency_maintenance_interval: 600,
23-
recurring_tasks: []
22+
concurrency_maintenance_interval: 600
2423
}
2524

26-
DEFAULT_CONFIG = {
27-
workers: [ WORKER_DEFAULTS ],
28-
dispatchers: [ DISPATCHER_DEFAULTS ]
29-
}
25+
DEFAULT_CONFIG_FILE_PATH = "config/solid_queue.yml"
26+
DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml"
3027

31-
def initialize(config_file: nil, **options)
32-
@raw_config = config_from(config_file || options.presence)
28+
def initialize(**options)
29+
@options = options.with_defaults(default_options)
3330
end
3431

3532
def configured_processes
36-
dispatchers + workers
33+
case
34+
when only_work? then workers
35+
when only_dispatch? then dispatchers
36+
else
37+
dispatchers + workers
38+
end
3739
end
3840

3941
def max_number_of_threads
@@ -42,9 +44,29 @@ def max_number_of_threads
4244
end
4345

4446
private
45-
attr_reader :raw_config
47+
attr_reader :options
48+
49+
def default_options
50+
{
51+
config_file: Rails.root.join(ENV["SOLID_QUEUE_CONFIG"] || DEFAULT_CONFIG_FILE_PATH),
52+
recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH),
53+
only_work: false,
54+
only_dispatch: false,
55+
skip_recurring: false
56+
}
57+
end
58+
59+
def only_work?
60+
options[:only_work]
61+
end
4662

47-
DEFAULT_CONFIG_FILE_PATH = "config/solid_queue.yml"
63+
def only_dispatch?
64+
options[:only_dispatch]
65+
end
66+
67+
def skip_recurring_tasks?
68+
options[:skip_recurring] || only_work?
69+
end
4870

4971
def workers
5072
workers_options.flat_map do |worker_options|
@@ -55,71 +77,84 @@ def workers
5577

5678
def dispatchers
5779
dispatchers_options.map do |dispatcher_options|
58-
recurring_tasks = parse_recurring_tasks dispatcher_options[:recurring_tasks]
59-
Process.new :dispatcher, dispatcher_options.merge(recurring_tasks: recurring_tasks).with_defaults(DISPATCHER_DEFAULTS)
60-
end
61-
end
62-
63-
def config_from(file_or_hash, env: Rails.env)
64-
load_config_from(file_or_hash).then do |config|
65-
config = config[env.to_sym] ? config[env.to_sym] : config
66-
if (config.keys & DEFAULT_CONFIG.keys).any? then config
67-
else
68-
DEFAULT_CONFIG
69-
end
80+
Process.new :dispatcher, dispatcher_options.with_defaults(DISPATCHER_DEFAULTS)
7081
end
7182
end
7283

7384
def workers_options
74-
@workers_options ||= options_from_raw_config(:workers)
85+
@workers_options ||= processes_config.fetch(:workers, [])
7586
.map { |options| options.dup.symbolize_keys }
7687
end
7788

7889
def dispatchers_options
79-
@dispatchers_options ||= options_from_raw_config(:dispatchers)
90+
@dispatchers_options ||= processes_config.fetch(:dispatchers, [])
8091
.map { |options| options.dup.symbolize_keys }
92+
.then { |options| with_recurring_tasks(options) }
8193
end
8294

83-
def options_from_raw_config(key)
84-
Array(raw_config[key])
95+
def with_recurring_tasks(options)
96+
if !skip_recurring_tasks? && recurring_tasks.any?
97+
options.sort_by! { |attrs| attrs[:polling_interval] }
98+
99+
if least_busy_dispatcher = options.pop
100+
least_busy_dispatcher[:recurring_tasks] = recurring_tasks
101+
options.push(least_busy_dispatcher)
102+
else
103+
[ DISPATCHER_DEFAULTS.merge(recurring_tasks: recurring_tasks) ]
104+
end
105+
else
106+
options
107+
end
85108
end
86109

87-
def parse_recurring_tasks(tasks)
88-
Array(tasks).map do |id, options|
110+
def recurring_tasks
111+
@recurring_tasks ||= recurring_tasks_config.map do |id, options|
89112
RecurringTask.from_configuration(id, **options)
90113
end.select(&:valid?)
91114
end
92115

116+
def processes_config
117+
@processes_config ||= config_from \
118+
options.slice(:workers, :dispatchers).presence || options[:config_file],
119+
keys: [ :workers, :dispatchers ],
120+
fallback: { workers: [ WORKER_DEFAULTS ], dispatchers: [ DISPATCHER_DEFAULTS ] }
121+
end
122+
123+
def recurring_tasks_config
124+
@recurring_tasks ||= config_from options[:recurring_schedule_file]
125+
end
126+
127+
128+
def config_from(file_or_hash, keys: [], fallback: {}, env: Rails.env)
129+
load_config_from(file_or_hash).then do |config|
130+
config = config[env.to_sym] ? config[env.to_sym] : config
131+
config = config.slice(*keys) if keys.any?
132+
133+
if config.empty? then fallback
134+
else
135+
config
136+
end
137+
end
138+
end
139+
93140
def load_config_from(file_or_hash)
94141
case file_or_hash
95142
when Hash
96143
file_or_hash.dup
97144
when Pathname, String
98145
load_config_from_file Pathname.new(file_or_hash)
99146
when NilClass
100-
load_config_from_env_location || load_config_from_default_location
147+
{}
101148
else
102149
raise "Solid Queue cannot be initialized with #{file_or_hash.inspect}"
103150
end
104151
end
105152

106-
def load_config_from_env_location
107-
if ENV["SOLID_QUEUE_CONFIG"].present?
108-
load_config_from_file Rails.root.join(ENV["SOLID_QUEUE_CONFIG"])
109-
end
110-
end
111-
112-
def load_config_from_default_location
113-
Rails.root.join(DEFAULT_CONFIG_FILE_PATH).then do |config_file|
114-
config_file.exist? ? load_config_from_file(config_file) : {}
115-
end
116-
end
117-
118153
def load_config_from_file(file)
119154
if file.exist?
120155
ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys
121156
else
122-
raise "Configuration file for Solid Queue not found in #{file}"
157+
{}
123158
end
124159
end
125160
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
default: &default
2+
workers:
3+
<% 3.times do |i| %>
4+
- queues: queue_<%= i + 1 %>
5+
threads: <%= i + 1 %>
6+
<% end %>
7+
8+
development:
9+
<<: *default
10+
11+
test:
12+
<<: *default
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
default: &default
2+
3+
development:
4+
<<: *default
5+
6+
test:
7+
<<: *default
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
random_wrong_key: random_value

test/dummy/config/recurring.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
periodic_store_result:
2+
class: StoreResultJob
3+
args: [ 42, { status: "custom_status" } ]
4+
schedule: every second

test/dummy/config/solid_queue.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,6 @@ default: &default
77
dispatchers:
88
- polling_interval: 1
99
batch_size: 500
10-
recurring_tasks:
11-
periodic_store_result:
12-
class: StoreResultJob
13-
args: [ 42, { status: "custom_status" } ]
14-
schedule: every second
1510

1611
development:
1712
<<: *default

test/integration/processes_lifecycle_test.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ class ProcessesLifecycleTest < ActiveSupport::TestCase
66
self.use_transactional_tests = false
77

88
setup do
9-
config_as_hash = { workers: [ { queues: :background }, { queues: :default, threads: 5 } ], dispatchers: [] }
10-
@pid = run_supervisor_as_fork(config_as_hash)
9+
@pid = run_supervisor_as_fork(workers: [ { queues: :background }, { queues: :default, threads: 5 } ])
1110

1211
wait_for_registered_processes(3, timeout: 3.second)
1312
assert_registered_workers_for(:background, :default, supervisor_pid: @pid)

test/unit/configuration_test.rb

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,23 @@
22

33
class ConfigurationTest < ActiveSupport::TestCase
44
test "default configuration to process all queues and dispatch" do
5-
configuration = stub_const(SolidQueue::Configuration, :DEFAULT_CONFIG_FILE_PATH, "non/existent/path") do
6-
SolidQueue::Configuration.new
7-
end
5+
configuration = SolidQueue::Configuration.new(config_file: nil)
86

97
assert_equal 2, configuration.configured_processes.count
108
assert_processes configuration, :worker, 1, queues: "*"
119
assert_processes configuration, :dispatcher, 1, batch_size: SolidQueue::Configuration::DISPATCHER_DEFAULTS[:batch_size]
1210
end
1311

1412
test "default configuration when config given doesn't include any configuration" do
15-
configuration = SolidQueue::Configuration.new(random_wrong_key: :random_value)
13+
configuration = SolidQueue::Configuration.new(config_file: config_file_path(:invalid_configuration))
1614

1715
assert_equal 2, configuration.configured_processes.count
1816
assert_processes configuration, :worker, 1, queues: "*"
1917
assert_processes configuration, :dispatcher, 1, batch_size: SolidQueue::Configuration::DISPATCHER_DEFAULTS[:batch_size]
2018
end
2119

2220
test "default configuration when config given is empty" do
23-
configuration = SolidQueue::Configuration.new(config_file: Rails.root.join("config/empty_configuration.yml"))
21+
configuration = SolidQueue::Configuration.new(config_file: config_file_path(:empty_configuration))
2422

2523
assert_equal 2, configuration.configured_processes.count
2624
assert_processes configuration, :worker, 1, queues: "*"
@@ -35,7 +33,7 @@ class ConfigurationTest < ActiveSupport::TestCase
3533
end
3634

3735
test "read configuration from provided file" do
38-
configuration = SolidQueue::Configuration.new(config_file: Rails.root.join("config/alternative_configuration.yml"))
36+
configuration = SolidQueue::Configuration.new(config_file: config_file_path(:alternative_configuration), only_work: true)
3937

4038
assert 3, configuration.configured_processes.count
4139
assert_processes configuration, :worker, 3, processes: 1, polling_interval: 0.1, queues: %w[ queue_1 queue_2 queue_3 ], threads: [ 1, 2, 3 ]
@@ -49,7 +47,7 @@ class ConfigurationTest < ActiveSupport::TestCase
4947
assert_processes configuration, :dispatcher, 1, polling_interval: SolidQueue::Configuration::DISPATCHER_DEFAULTS[:polling_interval], batch_size: 100
5048
assert_processes configuration, :worker, 2, queues: "background", polling_interval: 10
5149

52-
configuration = SolidQueue::Configuration.new(workers: [ background_worker, background_worker ])
50+
configuration = SolidQueue::Configuration.new(workers: [ background_worker, background_worker ], skip_recurring: true)
5351

5452
assert_processes configuration, :dispatcher, 0
5553
assert_processes configuration, :worker, 2
@@ -64,22 +62,73 @@ class ConfigurationTest < ActiveSupport::TestCase
6462
background_worker = { queues: "background", polling_interval: 10, processes: 3 }
6563
configuration = SolidQueue::Configuration.new(workers: [ background_worker ])
6664

67-
assert_equal 3, configuration.configured_processes.count
6865
assert_processes configuration, :worker, 3, queues: "background", polling_interval: 10
6966
end
7067

68+
test "recurring tasks configuration with one dispatcher" do
69+
configuration = SolidQueue::Configuration.new(dispatchers: [ { polling_interval: 0.1 } ])
70+
71+
assert_processes configuration, :dispatcher, 1, polling_interval: 0.1
72+
73+
dispatcher = configuration.configured_processes.first.instantiate
74+
assert_has_recurring_task dispatcher, key: "periodic_store_result", class_name: "StoreResultJob", schedule: "every second"
75+
end
76+
77+
test "recurring tasks configuration with no dispatchers uses a default dispatcher" do
78+
configuration = SolidQueue::Configuration.new(dispatchers: [])
79+
80+
assert_processes configuration, :dispatcher, 1, polling_interval: 1
81+
82+
dispatcher = configuration.configured_processes.first.instantiate
83+
assert_has_recurring_task dispatcher, key: "periodic_store_result", class_name: "StoreResultJob", schedule: "every second"
84+
end
85+
86+
test "recurring tasks configuration with multiple dispatchers uses the least busy one" do
87+
configuration = SolidQueue::Configuration.new(dispatchers: [ { polling_interval: 0.1 }, { polling_interval: 0.4 }, { polling_interval: 0.2 } ])
88+
89+
assert_processes configuration, :dispatcher, 3, polling_interval: [ 0.1, 0.2, 0.4 ] # sorted by polling interval
90+
91+
dispatcher = configuration.configured_processes.last.instantiate
92+
assert_has_recurring_task dispatcher, key: "periodic_store_result", class_name: "StoreResultJob", schedule: "every second"
93+
94+
dispatchers_without_recurring_tasks = configuration.configured_processes.first(2)
95+
assert_nil dispatchers_without_recurring_tasks.map { |d| d.attributes[:recurring_tasks] }.uniq.first
96+
end
97+
98+
test "no recurring tasks configuration when explicitly excluded" do
99+
configuration = SolidQueue::Configuration.new(dispatchers: [ { polling_interval: 0.1 } ], skip_recurring: true)
100+
assert_processes configuration, :dispatcher, 1, polling_interval: 0.1, recurring_tasks: nil
101+
end
102+
71103
private
72104
def assert_processes(configuration, kind, count, **attributes)
73105
processes = configuration.configured_processes.select { |p| p.kind == kind }
74106
assert_equal count, processes.size
75107

76108
attributes.each do |attr, expected_value|
77-
value = processes.map { |p| p.attributes.fetch(attr) }
109+
value = processes.map { |p| p.attributes[attr] }
78110
unless expected_value.is_a?(Array)
79111
value = value.first
80112
end
81113

82-
assert_equal expected_value, value
114+
if expected_value.nil?
115+
assert_nil value
116+
else
117+
assert_equal expected_value, value
118+
end
83119
end
84120
end
121+
122+
def assert_has_recurring_task(dispatcher, key:, **attributes)
123+
assert_equal 1, dispatcher.recurring_schedule.configured_tasks.count
124+
task = dispatcher.recurring_schedule.configured_tasks.detect { |t| t.key == key }
125+
126+
attributes.each do |attr, value|
127+
assert_equal value, task.public_send(attr)
128+
end
129+
end
130+
131+
def config_file_path(name)
132+
Rails.root.join("config/#{name}.yml")
133+
end
85134
end

0 commit comments

Comments
 (0)