solid_queue 1.2.4 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,11 @@ module SolidQueue
28
28
  concurrency_maintenance_interval: 600
29
29
  }
30
30
 
31
+ SCHEDULER_DEFAULTS = {
32
+ polling_interval: 5,
33
+ dynamic_tasks_enabled: false
34
+ }
35
+
31
36
  DEFAULT_CONFIG_FILE_PATH = "config/queue.yml"
32
37
  DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml"
33
38
 
@@ -56,6 +61,14 @@ module SolidQueue
56
61
  end
57
62
  end
58
63
 
64
+ def mode
65
+ @options[:mode].to_s.inquiry
66
+ end
67
+
68
+ def standalone?
69
+ mode.fork? || @options[:standalone]
70
+ end
71
+
59
72
  private
60
73
  attr_reader :options
61
74
 
@@ -84,6 +97,8 @@ module SolidQueue
84
97
 
85
98
  def default_options
86
99
  {
100
+ mode: ENV["SOLID_QUEUE_SUPERVISOR_MODE"] || :fork,
101
+ standalone: true,
87
102
  config_file: Rails.root.join(ENV["SOLID_QUEUE_CONFIG"] || DEFAULT_CONFIG_FILE_PATH),
88
103
  recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH),
89
104
  only_work: false,
@@ -110,7 +125,12 @@ module SolidQueue
110
125
 
111
126
  def workers
112
127
  workers_options.flat_map do |worker_options|
113
- processes = worker_options.fetch(:processes, WORKER_DEFAULTS[:processes])
128
+ processes = if mode.fork?
129
+ worker_options.fetch(:processes, WORKER_DEFAULTS[:processes])
130
+ else
131
+ 1
132
+ end
133
+
114
134
  processes.times.map { Process.new(:worker, worker_options.with_defaults(WORKER_DEFAULTS)) }
115
135
  end
116
136
  end
@@ -122,8 +142,10 @@ module SolidQueue
122
142
  end
123
143
 
124
144
  def schedulers
125
- if !skip_recurring_tasks? && recurring_tasks.any?
126
- [ Process.new(:scheduler, recurring_tasks: recurring_tasks) ]
145
+ return [] if skip_recurring_tasks?
146
+
147
+ if recurring_tasks.any? || dynamic_recurring_tasks_enabled?
148
+ [ Process.new(:scheduler, { recurring_tasks: recurring_tasks, **scheduler_options.with_defaults(SCHEDULER_DEFAULTS) }) ]
127
149
  else
128
150
  []
129
151
  end
@@ -139,17 +161,29 @@ module SolidQueue
139
161
  .map { |options| options.dup.symbolize_keys }
140
162
  end
141
163
 
164
+ def scheduler_options
165
+ @scheduler_options ||= processes_config.fetch(:scheduler, {}).dup.symbolize_keys
166
+ end
167
+
168
+ def dynamic_recurring_tasks_enabled?
169
+ scheduler_options.fetch(:dynamic_tasks_enabled, SCHEDULER_DEFAULTS[:dynamic_tasks_enabled])
170
+ end
171
+
142
172
  def recurring_tasks
143
173
  @recurring_tasks ||= recurring_tasks_config.map do |id, options|
144
- RecurringTask.from_configuration(id, **options) if options&.has_key?(:schedule)
174
+ RecurringTask.from_configuration(id, **options.merge(static: true)) if options&.has_key?(:schedule)
145
175
  end.compact
146
176
  end
147
177
 
148
178
  def processes_config
149
179
  @processes_config ||= config_from \
150
- options.slice(:workers, :dispatchers).presence || options[:config_file],
151
- keys: [ :workers, :dispatchers ],
152
- fallback: { workers: [ WORKER_DEFAULTS ], dispatchers: [ DISPATCHER_DEFAULTS ] }
180
+ options.slice(:workers, :dispatchers, :scheduler).presence || options[:config_file],
181
+ keys: [ :workers, :dispatchers, :scheduler ],
182
+ fallback: {
183
+ workers: [ WORKER_DEFAULTS ],
184
+ dispatchers: [ DISPATCHER_DEFAULTS ],
185
+ scheduler: SCHEDULER_DEFAULTS
186
+ }
153
187
  end
154
188
 
155
189
  def recurring_tasks_config
@@ -158,7 +192,6 @@ module SolidQueue
158
192
  end
159
193
  end
160
194
 
161
-
162
195
  def config_from(file_or_hash, keys: [], fallback: {}, env: Rails.env)
163
196
  load_config_from(file_or_hash).then do |config|
164
197
  config = config[env.to_sym] ? config[env.to_sym] : config
@@ -3,6 +3,7 @@
3
3
  module SolidQueue
4
4
  class Dispatcher < Processes::Poller
5
5
  include LifecycleHooks
6
+
6
7
  attr_reader :batch_size
7
8
 
8
9
  after_boot :run_start_hooks
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class ForkSupervisor < Supervisor
5
+ private
6
+
7
+ def perform_graceful_termination
8
+ term_forks
9
+
10
+ Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? }) do
11
+ reap_terminated_forks
12
+ end
13
+ end
14
+
15
+ def perform_immediate_termination
16
+ quit_forks
17
+ end
18
+
19
+ def term_forks
20
+ signal_processes(process_instances.keys, :TERM)
21
+ end
22
+
23
+ def quit_forks
24
+ signal_processes(process_instances.keys, :QUIT)
25
+ end
26
+
27
+ def check_and_replace_terminated_processes
28
+ loop do
29
+ pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
30
+ break unless pid
31
+
32
+ replace_fork(pid, status)
33
+ end
34
+ end
35
+
36
+ def reap_terminated_forks
37
+ loop do
38
+ pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
39
+ break unless pid
40
+
41
+ if (terminated_fork = process_instances.delete(pid)) && (!status.exited? || status.exitstatus.to_i > 0)
42
+ error = Processes::ProcessExitError.new(status)
43
+ release_claimed_jobs_by(terminated_fork, with_error: error)
44
+ end
45
+
46
+ configured_processes.delete(pid)
47
+ end
48
+ rescue SystemCallError
49
+ # All children already reaped
50
+ end
51
+
52
+ def replace_fork(pid, status)
53
+ SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
54
+ if terminated_fork = process_instances.delete(pid)
55
+ payload[:fork] = terminated_fork
56
+ error = Processes::ProcessExitError.new(status)
57
+ release_claimed_jobs_by(terminated_fork, with_error: error)
58
+
59
+ start_process(configured_processes.delete(pid))
60
+ end
61
+ end
62
+ end
63
+
64
+ def all_processes_terminated?
65
+ process_instances.empty?
66
+ end
67
+ end
68
+ end
@@ -54,10 +54,14 @@ module SolidQueue::Processes
54
54
  end
55
55
 
56
56
  def heartbeat
57
- process.heartbeat
57
+ process&.heartbeat
58
58
  rescue ActiveRecord::RecordNotFound
59
59
  self.process = nil
60
60
  wake_up
61
61
  end
62
+
63
+ def reload_metadata
64
+ wrap_in_app_executor { process&.update(metadata: metadata.compact) }
65
+ end
62
66
  end
63
67
  end
@@ -7,20 +7,26 @@ module SolidQueue::Processes
7
7
  attr_writer :mode
8
8
 
9
9
  def start
10
- boot
11
-
12
- if running_async?
13
- @thread = create_thread { run }
14
- else
10
+ run_in_mode do
11
+ boot
15
12
  run
16
13
  end
17
14
  end
18
15
 
19
16
  def stop
20
17
  super
21
-
22
18
  wake_up
23
- @thread&.join
19
+
20
+ # When not supervised, block until the thread terminates for backward
21
+ # compatibility with code that expects stop to be synchronous.
22
+ # When supervised, the supervisor controls the shutdown timeout.
23
+ unless supervised?
24
+ @thread&.join
25
+ end
26
+ end
27
+
28
+ def alive?
29
+ !running_async? || @thread&.alive?
24
30
  end
25
31
 
26
32
  private
@@ -30,6 +36,18 @@ module SolidQueue::Processes
30
36
  (@mode || DEFAULT_MODE).to_s.inquiry
31
37
  end
32
38
 
39
+ def run_in_mode(&block)
40
+ case
41
+ when running_as_fork?
42
+ fork(&block)
43
+ when running_async?
44
+ @thread = create_thread(&block)
45
+ @thread.object_id
46
+ else
47
+ block.call
48
+ end
49
+ end
50
+
33
51
  def boot
34
52
  SolidQueue.instrument(:start_process, process: self) do
35
53
  run_callbacks(:boot) do
@@ -74,16 +92,5 @@ module SolidQueue::Processes
74
92
  def running_as_fork?
75
93
  mode.fork?
76
94
  end
77
-
78
-
79
- def create_thread(&block)
80
- Thread.new do
81
- Thread.current.name = name
82
- block.call
83
- rescue Exception => exception
84
- handle_thread_error(exception)
85
- raise
86
- end
87
- end
88
95
  end
89
96
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ module Processes
5
+ class ThreadTerminatedError < RuntimeError
6
+ def initialize(name)
7
+ super("Thread #{name} terminated unexpectedly")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -4,21 +4,28 @@ module SolidQueue
4
4
  class Scheduler::RecurringSchedule
5
5
  include AppExecutor
6
6
 
7
- attr_reader :configured_tasks, :scheduled_tasks
7
+ attr_reader :scheduled_tasks
8
+
9
+ def initialize(static_tasks, dynamic_tasks_enabled: false)
10
+ @static_tasks = Array(static_tasks).map { |task| RecurringTask.wrap(task) }.select(&:valid?)
11
+ @dynamic_tasks_enabled = dynamic_tasks_enabled
8
12
 
9
- def initialize(tasks)
10
- @configured_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?)
11
13
  @scheduled_tasks = Concurrent::Hash.new
12
14
  end
13
15
 
16
+ def configured_tasks
17
+ static_tasks + dynamic_tasks
18
+ end
19
+
14
20
  def empty?
15
- configured_tasks.empty?
21
+ scheduled_tasks.empty? && dynamic_tasks.empty?
16
22
  end
17
23
 
18
24
  def schedule_tasks
19
25
  wrap_in_app_executor do
20
- persist_tasks
21
- reload_tasks
26
+ persist_static_tasks
27
+ reload_static_tasks
28
+ reload_dynamic_tasks
22
29
  end
23
30
 
24
31
  configured_tasks.each do |task|
@@ -39,14 +46,57 @@ module SolidQueue
39
46
  configured_tasks.map(&:key)
40
47
  end
41
48
 
49
+ def reschedule_dynamic_tasks
50
+ wrap_in_app_executor do
51
+ reload_dynamic_tasks
52
+ schedule_created_dynamic_tasks
53
+ unschedule_deleted_dynamic_tasks
54
+ end
55
+ end
56
+
42
57
  private
43
- def persist_tasks
44
- SolidQueue::RecurringTask.static.where.not(key: task_keys).delete_all
45
- SolidQueue::RecurringTask.create_or_update_all configured_tasks
58
+ attr_reader :static_tasks
59
+
60
+ def static_task_keys
61
+ static_tasks.map(&:key)
62
+ end
63
+
64
+ def dynamic_tasks
65
+ @dynamic_tasks ||= load_dynamic_tasks
66
+ end
67
+
68
+ def dynamic_tasks_enabled?
69
+ @dynamic_tasks_enabled
70
+ end
71
+
72
+ def schedule_created_dynamic_tasks
73
+ RecurringTask.dynamic.where.not(key: scheduled_tasks.keys).each do |task|
74
+ schedule_task(task)
75
+ end
76
+ end
77
+
78
+ def unschedule_deleted_dynamic_tasks
79
+ (scheduled_tasks.keys - RecurringTask.pluck(:key)).each do |key|
80
+ scheduled_tasks[key].cancel
81
+ scheduled_tasks.delete(key)
82
+ end
83
+ end
84
+
85
+ def persist_static_tasks
86
+ RecurringTask.static.where.not(key: static_task_keys).delete_all
87
+ RecurringTask.create_or_update_all static_tasks
88
+ end
89
+
90
+ def reload_static_tasks
91
+ @static_tasks = RecurringTask.static.where(key: static_task_keys).to_a
92
+ end
93
+
94
+ def reload_dynamic_tasks
95
+ @dynamic_tasks = load_dynamic_tasks
46
96
  end
47
97
 
48
- def reload_tasks
49
- @configured_tasks = SolidQueue::RecurringTask.where(key: task_keys).to_a
98
+ def load_dynamic_tasks
99
+ dynamic_tasks_enabled? ? RecurringTask.dynamic.to_a : []
50
100
  end
51
101
 
52
102
  def schedule(task)
@@ -5,7 +5,7 @@ module SolidQueue
5
5
  include Processes::Runnable
6
6
  include LifecycleHooks
7
7
 
8
- attr_reader :recurring_schedule
8
+ attr_reader :recurring_schedule, :polling_interval
9
9
 
10
10
  after_boot :run_start_hooks
11
11
  after_boot :schedule_recurring_tasks
@@ -14,7 +14,10 @@ module SolidQueue
14
14
  after_shutdown :run_exit_hooks
15
15
 
16
16
  def initialize(recurring_tasks:, **options)
17
- @recurring_schedule = RecurringSchedule.new(recurring_tasks)
17
+ options = options.dup.with_defaults(SolidQueue::Configuration::SCHEDULER_DEFAULTS)
18
+ @dynamic_tasks_enabled = options[:dynamic_tasks_enabled]
19
+ @polling_interval = options[:polling_interval]
20
+ @recurring_schedule = RecurringSchedule.new(recurring_tasks, dynamic_tasks_enabled: @dynamic_tasks_enabled)
18
21
 
19
22
  super(**options)
20
23
  end
@@ -24,13 +27,16 @@ module SolidQueue
24
27
  end
25
28
 
26
29
  private
27
- SLEEP_INTERVAL = 60 # Right now it doesn't matter, can be set to 1 in the future for dynamic tasks
30
+
31
+ STATIC_SLEEP_INTERVAL = 60
28
32
 
29
33
  def run
30
34
  loop do
31
35
  break if shutting_down?
32
36
 
33
- interruptible_sleep(SLEEP_INTERVAL)
37
+ reload_dynamic_schedule if dynamic_tasks_enabled?
38
+
39
+ interruptible_sleep(sleep_interval)
34
40
  end
35
41
  ensure
36
42
  SolidQueue.instrument(:shutdown_process, process: self) do
@@ -46,10 +52,23 @@ module SolidQueue
46
52
  recurring_schedule.unschedule_tasks
47
53
  end
48
54
 
55
+ def reload_dynamic_schedule
56
+ recurring_schedule.reschedule_dynamic_tasks
57
+ reload_metadata
58
+ end
59
+
60
+ def dynamic_tasks_enabled?
61
+ @dynamic_tasks_enabled
62
+ end
63
+
49
64
  def all_work_completed?
50
65
  recurring_schedule.empty?
51
66
  end
52
67
 
68
+ def sleep_interval
69
+ dynamic_tasks_enabled? ? polling_interval : STATIC_SLEEP_INTERVAL
70
+ end
71
+
53
72
  def set_procline
54
73
  procline "scheduling #{recurring_schedule.task_keys.join(",")}"
55
74
  end
@@ -32,5 +32,16 @@ module SolidQueue
32
32
  ClaimedExecution.orphaned.fail_all_with(Processes::ProcessMissingError.new)
33
33
  end
34
34
  end
35
+
36
+ # When a supervised process crashes or exits we need to mark all the
37
+ # executions it had claimed as failed so that they can be retried
38
+ # by some other worker.
39
+ def release_claimed_jobs_by(terminated_process, with_error:)
40
+ wrap_in_app_executor do
41
+ if registered_process = SolidQueue::Process.find_by(name: terminated_process.name)
42
+ registered_process.fail_all_claimed_executions_with(with_error)
43
+ end
44
+ end
45
+ end
35
46
  end
36
47
  end
@@ -6,8 +6,8 @@ module SolidQueue
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
- before_boot :register_signal_handlers
10
- after_shutdown :restore_default_signal_handlers
9
+ before_boot :register_signal_handlers, if: :standalone?
10
+ after_shutdown :restore_default_signal_handlers, if: :standalone?
11
11
  end
12
12
 
13
13
  private
@@ -13,17 +13,21 @@ module SolidQueue
13
13
  configuration = Configuration.new(**options)
14
14
 
15
15
  if configuration.valid?
16
- new(configuration).tap(&:start)
16
+ klass = configuration.mode.fork? ? ForkSupervisor : AsyncSupervisor
17
+ klass.new(configuration).tap(&:start)
17
18
  else
18
19
  abort configuration.errors.full_messages.join("\n") + "\nExiting..."
19
20
  end
20
21
  end
21
22
  end
22
23
 
24
+ delegate :mode, :standalone?, to: :configuration
25
+
23
26
  def initialize(configuration)
24
27
  @configuration = configuration
25
- @forks = {}
28
+
26
29
  @configured_processes = {}
30
+ @process_instances = {}
27
31
 
28
32
  super
29
33
  end
@@ -43,8 +47,12 @@ module SolidQueue
43
47
  run_stop_hooks
44
48
  end
45
49
 
50
+ def kind
51
+ "Supervisor(#{mode})"
52
+ end
53
+
46
54
  private
47
- attr_reader :configuration, :forks, :configured_processes
55
+ attr_reader :configuration, :configured_processes, :process_instances
48
56
 
49
57
  def boot
50
58
  SolidQueue.instrument(:start_process, process: self) do
@@ -62,11 +70,13 @@ module SolidQueue
62
70
  loop do
63
71
  break if stopped?
64
72
 
65
- set_procline
66
- process_signal_queue
73
+ if standalone?
74
+ set_procline
75
+ process_signal_queue
76
+ end
67
77
 
68
78
  unless stopped?
69
- reap_and_replace_terminated_forks
79
+ check_and_replace_terminated_processes
70
80
  interruptible_sleep(1.second)
71
81
  end
72
82
  end
@@ -77,30 +87,23 @@ module SolidQueue
77
87
  def start_process(configured_process)
78
88
  process_instance = configured_process.instantiate.tap do |instance|
79
89
  instance.supervised_by process
80
- instance.mode = :fork
90
+ instance.mode = mode
81
91
  end
82
92
 
83
- pid = fork do
84
- process_instance.start
85
- end
93
+ process_id = process_instance.start
86
94
 
87
- configured_processes[pid] = configured_process
88
- forks[pid] = process_instance
95
+ configured_processes[process_id] = configured_process
96
+ process_instances[process_id] = process_instance
89
97
  end
90
98
 
91
- def set_procline
92
- procline "supervising #{supervised_processes.join(", ")}"
99
+ def check_and_replace_terminated_processes
93
100
  end
94
101
 
95
102
  def terminate_gracefully
96
- SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do |payload|
97
- term_forks
98
-
99
- Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_forks_terminated? }) do
100
- reap_terminated_forks
101
- end
103
+ SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: configured_processes.keys) do |payload|
104
+ perform_graceful_termination
102
105
 
103
- unless all_forks_terminated?
106
+ unless all_processes_terminated?
104
107
  payload[:shutdown_timeout_exceeded] = true
105
108
  terminate_immediately
106
109
  end
@@ -108,84 +111,37 @@ module SolidQueue
108
111
  end
109
112
 
110
113
  def terminate_immediately
111
- SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do
112
- quit_forks
113
- end
114
- end
115
-
116
- def shutdown
117
- SolidQueue.instrument(:shutdown_process, process: self) do
118
- run_callbacks(:shutdown) do
119
- stop_maintenance_task
120
- end
114
+ SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: configured_processes.keys) do
115
+ perform_immediate_termination
121
116
  end
122
117
  end
123
118
 
124
- def sync_std_streams
125
- STDOUT.sync = STDERR.sync = true
119
+ def perform_graceful_termination
120
+ raise NotImplementedError
126
121
  end
127
122
 
128
- def supervised_processes
129
- forks.keys
123
+ def perform_immediate_termination
124
+ raise NotImplementedError
130
125
  end
131
126
 
132
- def term_forks
133
- signal_processes(forks.keys, :TERM)
127
+ def all_processes_terminated?
128
+ raise NotImplementedError
134
129
  end
135
130
 
136
- def quit_forks
137
- signal_processes(forks.keys, :QUIT)
138
- end
139
-
140
- def reap_and_replace_terminated_forks
141
- loop do
142
- pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
143
- break unless pid
144
-
145
- replace_fork(pid, status)
146
- end
147
- end
148
-
149
- def reap_terminated_forks
150
- loop do
151
- pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
152
- break unless pid
153
-
154
- if (terminated_fork = forks.delete(pid)) && (!status.exited? || status.exitstatus > 0)
155
- handle_claimed_jobs_by(terminated_fork, status)
156
- end
157
-
158
- configured_processes.delete(pid)
159
- end
160
- rescue SystemCallError
161
- # All children already reaped
162
- end
163
-
164
- def replace_fork(pid, status)
165
- SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
166
- if terminated_fork = forks.delete(pid)
167
- payload[:fork] = terminated_fork
168
- handle_claimed_jobs_by(terminated_fork, status)
169
-
170
- start_process(configured_processes.delete(pid))
131
+ def shutdown
132
+ SolidQueue.instrument(:shutdown_process, process: self) do
133
+ run_callbacks(:shutdown) do
134
+ stop_maintenance_task
171
135
  end
172
136
  end
173
137
  end
174
138
 
175
- # When a supervised fork crashes or exits we need to mark all the
176
- # executions it had claimed as failed so that they can be retried
177
- # by some other worker.
178
- def handle_claimed_jobs_by(terminated_fork, status)
179
- wrap_in_app_executor do
180
- if registered_process = SolidQueue::Process.find_by(name: terminated_fork.name)
181
- error = Processes::ProcessExitError.new(status)
182
- registered_process.fail_all_claimed_executions_with(error)
183
- end
184
- end
139
+ def set_procline
140
+ procline "supervising #{configured_processes.keys.join(", ")}"
185
141
  end
186
142
 
187
- def all_forks_terminated?
188
- forks.empty?
143
+ def sync_std_streams
144
+ STDOUT.sync = STDERR.sync = true
189
145
  end
190
146
  end
191
147
  end
@@ -4,18 +4,18 @@ module SolidQueue
4
4
  module Timer
5
5
  extend self
6
6
 
7
- def wait_until(timeout, condition, &block)
7
+ def wait_until(timeout, condition)
8
8
  if timeout > 0
9
9
  deadline = monotonic_time_now + timeout
10
10
 
11
11
  while monotonic_time_now < deadline && !condition.call
12
12
  sleep 0.1
13
- block.call
13
+ yield if block_given?
14
14
  end
15
15
  else
16
16
  while !condition.call
17
17
  sleep 0.5
18
- block.call
18
+ yield if block_given?
19
19
  end
20
20
  end
21
21
  end
@@ -1,3 +1,3 @@
1
1
  module SolidQueue
2
- VERSION = "1.2.4"
2
+ VERSION = "1.4.0"
3
3
  end