solid_queue 1.4.0 → 1.6.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.
@@ -3,10 +3,14 @@
3
3
  module SolidQueue
4
4
  class Configuration
5
5
  include ActiveModel::Model
6
+ include ActiveModel::Validations::Callbacks
6
7
 
7
- validate :ensure_configured_processes
8
- validate :ensure_valid_recurring_tasks
9
- validate :ensure_correctly_sized_thread_pool
8
+ validate :ensure_configured_processes, :ensure_valid_recurring_tasks
9
+ validate :ensure_valid_worker_execution_options
10
+ validate :ensure_fiber_workers_have_required_dependency, :ensure_fiber_workers_use_supported_isolation_level
11
+ validate :warn_about_incorrectly_sized_database_pool, :warn_about_missing_config_files
12
+
13
+ before_validation { warnings.clear }
10
14
 
11
15
  class Process < Struct.new(:kind, :attributes)
12
16
  def instantiate
@@ -35,38 +39,48 @@ module SolidQueue
35
39
 
36
40
  DEFAULT_CONFIG_FILE_PATH = "config/queue.yml"
37
41
  DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml"
42
+ FIBER_QUERY_SCOPED_CONNECTIONS_VERSION = Gem::Version.new("7.2.0")
38
43
 
39
44
  def initialize(**options)
40
45
  @options = options.with_defaults(default_options)
41
46
  end
42
47
 
43
48
  def configured_processes
44
- if only_work? then workers
49
+ if only_work?
50
+ workers
51
+ elsif only_recurring?
52
+ schedulers
45
53
  else
46
54
  dispatchers + workers + schedulers
47
55
  end
48
56
  end
49
57
 
50
- def error_messages
51
- if configured_processes.none?
52
- "No workers or processed configured. Exiting..."
53
- else
54
- error_messages = invalid_tasks.map do |task|
55
- all_messages = task.errors.full_messages.map { |msg| "\t#{msg}" }.join("\n")
56
- "#{task.key}:\n#{all_messages}"
57
- end
58
- .join("\n")
58
+ def mode
59
+ options[:mode].to_s.inquiry
60
+ end
59
61
 
60
- "Invalid processes configured:\n#{error_messages}"
61
- end
62
+ def standalone?
63
+ mode.fork? || options[:standalone]
62
64
  end
63
65
 
64
- def mode
65
- @options[:mode].to_s.inquiry
66
+ def warnings
67
+ @warnings ||= ActiveModel::Errors.new(self)
66
68
  end
67
69
 
68
- def standalone?
69
- mode.fork? || @options[:standalone]
70
+ def check
71
+ if valid?
72
+ warnings.full_messages.each { |warning| $stderr.puts warning }
73
+ $stdout.puts "Solid Queue configuration is valid."
74
+
75
+ true
76
+ else
77
+ $stderr.puts "Solid Queue configuration is invalid:"
78
+ (warnings.full_messages + errors.full_messages).each do |message|
79
+ message.each_line { |line| $stderr.puts " #{line.chomp}" }
80
+ end
81
+
82
+ false
83
+ end
70
84
  end
71
85
 
72
86
  private
@@ -88,10 +102,53 @@ module SolidQueue
88
102
  end
89
103
  end
90
104
 
91
- def ensure_correctly_sized_thread_pool
92
- if (db_pool_size = SolidQueue::Record.connection_pool&.size) && db_pool_size < estimated_number_of_threads
93
- errors.add(:base, "Solid Queue is configured to use #{estimated_number_of_threads} threads but the " +
94
- "database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
105
+ def warn_about_incorrectly_sized_database_pool
106
+ db_pool_size = SolidQueue::Record.connection_pool&.size
107
+
108
+ if db_pool_size && db_pool_size < estimated_database_pool_size
109
+ warnings.add(:base, "Warning: Solid Queue needs at least #{estimated_database_pool_size} database connections " \
110
+ "for the configured workers but the database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
111
+ end
112
+ rescue ActiveRecord::ActiveRecordError
113
+ # No usable database connection. Skip the pool-size warning in that case.
114
+ end
115
+
116
+ def warn_about_missing_config_files
117
+ files = [ options[:config_file] ]
118
+ files << options[:recurring_schedule_file] unless skip_recurring_tasks?
119
+
120
+ files.compact.each do |file|
121
+ unless Pathname.new(file).exist?
122
+ warnings.add(:base, "Warning: provided configuration file '#{file}' does not exist. Falling back to default configuration.")
123
+ end
124
+ end
125
+ end
126
+
127
+ def ensure_valid_worker_execution_options
128
+ workers_options.each do |options|
129
+ if options.key?(:threads) && options.key?(:fibers)
130
+ errors.add(:base, "Workers can specify either `threads` or `fibers`, but not both.")
131
+ end
132
+ end
133
+ end
134
+
135
+ def ensure_fiber_workers_have_required_dependency
136
+ return unless workers_options.any? { |options| fiber_worker?(options) }
137
+
138
+ require "async"
139
+ require "async/semaphore"
140
+ rescue LoadError
141
+ errors.add(:base, "Fiber workers require the `async` gem. " \
142
+ "Add `gem \"async\"` to your Gemfile to configure workers with `fibers`.")
143
+ end
144
+
145
+ def ensure_fiber_workers_use_supported_isolation_level
146
+ return unless workers_options.any? { |options| fiber_worker?(options) }
147
+
148
+ unless ActiveSupport::IsolatedExecutionState.isolation_level == :fiber
149
+ errors.add(:base, "Fiber workers require fiber-scoped isolated execution state. " \
150
+ "Set `config.active_support.isolation_level = :fiber` in your Rails configuration " \
151
+ "(or `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` outside Rails).")
95
152
  end
96
153
  end
97
154
 
@@ -103,6 +160,7 @@ module SolidQueue
103
160
  recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH),
104
161
  only_work: false,
105
162
  only_dispatch: false,
163
+ only_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_ONLY_RECURRING"]),
106
164
  skip_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_SKIP_RECURRING"])
107
165
  }
108
166
  end
@@ -119,6 +177,10 @@ module SolidQueue
119
177
  options[:only_dispatch]
120
178
  end
121
179
 
180
+ def only_recurring?
181
+ options[:only_recurring]
182
+ end
183
+
122
184
  def skip_recurring_tasks?
123
185
  options[:skip_recurring] || only_work?
124
186
  end
@@ -131,7 +193,8 @@ module SolidQueue
131
193
  1
132
194
  end
133
195
 
134
- processes.times.map { Process.new(:worker, worker_options.with_defaults(WORKER_DEFAULTS)) }
196
+ defaults = worker_defaults_for(worker_options)
197
+ processes.times.map { Process.new(:worker, worker_options.with_defaults(defaults)) }
135
198
  end
136
199
  end
137
200
 
@@ -221,15 +284,46 @@ module SolidQueue
221
284
  if file.exist?
222
285
  ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys
223
286
  else
224
- puts "[solid_queue] WARNING: Provided configuration file '#{file}' does not exist. Falling back to default configuration."
225
287
  {}
226
288
  end
227
289
  end
228
290
 
229
- def estimated_number_of_threads
230
- # At most "threads" in each worker + 1 thread for the worker + 1 thread for the heartbeat task
231
- thread_count = workers_options.map { |options| options.fetch(:threads, WORKER_DEFAULTS[:threads]) }.max
232
- (thread_count || 1) + 2
291
+ def estimated_database_pool_size
292
+ worker_pool_size = workers_options.map { |options| estimated_database_pool_size_for_worker(options) }.max
293
+ worker_pool_size || 1
294
+ end
295
+
296
+ def estimated_database_pool_size_for_worker(options)
297
+ # Connections used to execute jobs + 1 for the worker's polling thread + 1 for the heartbeat task
298
+ estimated_execution_connections_for_worker(options) + 2
299
+ end
300
+
301
+ def worker_capacity(options)
302
+ options[:fibers] || options[:threads] || WORKER_DEFAULTS[:threads]
303
+ end
304
+
305
+ def estimated_execution_connections_for_worker(options)
306
+ fiber_worker?(options) ? fiber_execution_connections_for_worker(options) : worker_capacity(options)
307
+ end
308
+
309
+ def fiber_execution_connections_for_worker(options)
310
+ fiber_jobs_release_connections_between_queries? ? 1 : worker_capacity(options)
311
+ end
312
+
313
+ def fiber_jobs_release_connections_between_queries?
314
+ ActiveRecord.gem_version >= FIBER_QUERY_SCOPED_CONNECTIONS_VERSION
315
+ end
316
+
317
+ def fiber_worker?(options)
318
+ options.key?(:fibers)
319
+ end
320
+
321
+ def worker_defaults_for(options)
322
+ if fiber_worker?(options)
323
+ WORKER_DEFAULTS.except(:threads)
324
+ else
325
+ WORKER_DEFAULTS
326
+ end
233
327
  end
234
328
  end
235
329
  end
@@ -16,6 +16,12 @@ module SolidQueue
16
16
  end
17
17
  end
18
18
 
19
+ initializer "solid_queue.time_zone" do |app|
20
+ unless config.solid_queue.key?(:time_zone)
21
+ SolidQueue.time_zone = app.config.time_zone
22
+ end
23
+ end
24
+
19
25
  initializer "solid_queue.app_executor", before: :run_prepare_callbacks do |app|
20
26
  config.solid_queue.app_executor ||= app.executor
21
27
  config.solid_queue.on_thread_error ||= ->(exception) { Rails.error.report(exception, handled: false) }
@@ -37,5 +43,9 @@ module SolidQueue
37
43
  include ActiveJob::ConcurrencyControls
38
44
  end
39
45
  end
46
+
47
+ initializer "solid_queue.deprecator" do |app|
48
+ app.deprecators[:solid_queue] = SolidQueue.deprecator
49
+ end
40
50
  end
41
51
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class FiberPool < Pool
5
+ def initialize(size, on_idle: nil)
6
+ super
7
+
8
+ @state_mutex = Mutex.new
9
+ @shutdown = false
10
+ @fatal_error = nil
11
+ @boot_queue = Thread::Queue.new
12
+ @pending_executions = Thread::Queue.new
13
+ @reactor_thread = nil
14
+ end
15
+
16
+ def post(execution)
17
+ raise_if_fatal_error!
18
+ raise RuntimeError, "Execution pool is shutting down" if shutdown?
19
+
20
+ super
21
+ end
22
+
23
+ def available_capacity
24
+ raise_if_fatal_error!
25
+ super
26
+ end
27
+
28
+ def shutdown
29
+ state_mutex.synchronize do
30
+ next false if @shutdown
31
+
32
+ @shutdown = true
33
+ end.tap do |shut_down|
34
+ # Wake the reactor: already-queued executions are drained before the
35
+ # blocked pop in +wait_for_executions+ returns nil
36
+ pending_executions.close if shut_down
37
+ end
38
+ end
39
+
40
+ def shutdown?
41
+ state_mutex.synchronize { @shutdown }
42
+ end
43
+
44
+ def wait_for_termination(timeout)
45
+ reactor_thread&.join(timeout)
46
+ end
47
+
48
+ private
49
+ attr_reader :boot_queue, :pending_executions, :reactor_thread, :state_mutex
50
+
51
+ def name
52
+ @name ||= "solid_queue-fiber-pool-#{object_id}"
53
+ end
54
+
55
+ def schedule(execution)
56
+ start_reactor_if_needed
57
+ pending_executions << execution
58
+ end
59
+
60
+ # The reactor thread is started lazily, when the first execution is posted,
61
+ # so that the pool can be safely built before forking: in the default fork
62
+ # supervisor mode, workers are instantiated in the supervisor process, and
63
+ # a thread started there wouldn't survive the fork. The async gem is also
64
+ # required lazily here, so that setups without fiber workers never load it.
65
+ def start_reactor_if_needed
66
+ @reactor_thread ||= begin
67
+ require "async"
68
+ require "async/semaphore"
69
+
70
+ start_reactor.tap do
71
+ boot_result = boot_queue.pop
72
+ raise boot_result if boot_result.is_a?(Exception)
73
+ end
74
+ end
75
+ end
76
+
77
+ def start_reactor
78
+ create_thread do
79
+ Async do |task|
80
+ semaphore = Async::Semaphore.new(size, parent: task)
81
+ boot_queue << :ready
82
+
83
+ # The reactor exits when all in-flight execution fibers, children
84
+ # of this task, have finished
85
+ wait_for_executions(semaphore)
86
+ end
87
+ rescue Exception => error
88
+ register_fatal_error(error)
89
+ raise
90
+ end
91
+ end
92
+
93
+ def wait_for_executions(semaphore)
94
+ # Thread::Queue#pop is fiber-scheduler-aware: it suspends this fiber, letting
95
+ # execution fibers run, and wakes the reactor when the poller thread pushes new
96
+ # work or closes the queue on shutdown, after which it drains any remaining
97
+ # executions and returns nil
98
+ while execution = pending_executions.pop
99
+ semaphore.async(execution) do |_execution_task, scheduled_execution|
100
+ perform_execution(scheduled_execution)
101
+ end
102
+ end
103
+ end
104
+
105
+ def perform_execution(execution)
106
+ wrap_in_app_executor { execution.perform }
107
+ rescue Async::Stop => error
108
+ handle_thread_error(error)
109
+ register_fatal_error(error)
110
+ rescue Exception => error
111
+ handle_thread_error(error)
112
+ ensure
113
+ restore_capacity
114
+ end
115
+
116
+ def register_fatal_error(error)
117
+ state_mutex.synchronize do
118
+ @fatal_error ||= error
119
+ end
120
+
121
+ boot_queue << error if boot_queue.empty?
122
+ on_idle&.call
123
+ end
124
+
125
+ def raise_if_fatal_error!
126
+ error = state_mutex.synchronize { @fatal_error }
127
+ raise error if error
128
+ end
129
+ end
130
+ end
@@ -31,6 +31,21 @@ module SolidQueue
31
31
 
32
32
  replace_fork(pid, status)
33
33
  end
34
+
35
+ check_boot_timeouts
36
+ end
37
+
38
+ def check_boot_timeouts
39
+ process_instances.each do |pid, instance|
40
+ terminate_unready_process(pid) if instance.boot_timed_out?
41
+ end
42
+ end
43
+
44
+ def terminate_unready_process(pid)
45
+ SolidQueue.instrument(:fork_boot_timeout, process: process_instances[pid], pid: pid) do
46
+ # A child stuck in boot cannot reach its run loop to stop gracefully
47
+ signal_process(pid, :KILL)
48
+ end
34
49
  end
35
50
 
36
51
  def reap_terminated_forks
@@ -38,9 +53,13 @@ module SolidQueue
38
53
  pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
39
54
  break unless pid
40
55
 
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)
56
+ if terminated_fork = process_instances.delete(pid)
57
+ terminated_fork.mark_as_reaped
58
+
59
+ if !status.exited? || status.exitstatus.to_i > 0
60
+ error = Processes::ProcessExitError.new(status)
61
+ release_claimed_jobs_by(terminated_fork, with_error: error)
62
+ end
44
63
  end
45
64
 
46
65
  configured_processes.delete(pid)
@@ -52,6 +71,7 @@ module SolidQueue
52
71
  def replace_fork(pid, status)
53
72
  SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
54
73
  if terminated_fork = process_instances.delete(pid)
74
+ terminated_fork.mark_as_reaped
55
75
  payload[:fork] = terminated_fork
56
76
  error = Processes::ProcessExitError.new(status)
57
77
  release_claimed_jobs_by(terminated_fork, with_error: error)
@@ -161,6 +161,11 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
161
161
  end
162
162
  end
163
163
 
164
+ def fork_boot_timeout(event)
165
+ process = event.payload[:process]
166
+ warn formatted_event(event, action: "Terminate #{process.kind} that failed to boot in time", **event.payload.slice(:pid).merge(hostname: process.hostname, name: process.name))
167
+ end
168
+
164
169
  private
165
170
  def formatted_event(event, action:, **attributes)
166
171
  "SolidQueue-#{SolidQueue::VERSION} #{action} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
@@ -4,51 +4,72 @@ module SolidQueue
4
4
  class Pool
5
5
  include AppExecutor
6
6
 
7
- attr_reader :size
7
+ def self.build(type:, size:, on_idle: nil)
8
+ SolidQueue.const_get("#{type.to_s.camelize}Pool").new(size, on_idle: on_idle)
9
+ end
8
10
 
9
- delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor
11
+ attr_reader :size
10
12
 
11
13
  def initialize(size, on_idle: nil)
12
14
  @size = size
13
15
  @on_idle = on_idle
14
- @available_threads = Concurrent::AtomicFixnum.new(size)
16
+ @available_capacity = size
15
17
  @mutex = Mutex.new
16
18
  end
17
19
 
20
+ def type
21
+ self.class.name.demodulize.delete_suffix("Pool").underscore.to_sym
22
+ end
23
+
18
24
  def post(execution)
19
- available_threads.decrement
20
-
21
- Concurrent::Promises.future_on(executor, execution) do |thread_execution|
22
- wrap_in_app_executor do
23
- thread_execution.perform
24
- ensure
25
- available_threads.increment
26
- mutex.synchronize { on_idle.try(:call) if idle? }
27
- end
28
- end.on_rejection! do |e|
29
- handle_thread_error(e)
25
+ reserve_capacity!
26
+
27
+ begin
28
+ schedule(execution)
29
+ rescue Exception
30
+ restore_capacity
31
+ raise
30
32
  end
31
33
  end
32
34
 
33
- def idle_threads
34
- available_threads.value
35
+ def available_capacity
36
+ mutex.synchronize { @available_capacity }
35
37
  end
36
38
 
37
39
  def idle?
38
- idle_threads > 0
40
+ available_capacity.positive?
39
41
  end
40
42
 
41
43
  private
42
- attr_reader :available_threads, :on_idle, :mutex
44
+ attr_reader :mutex, :on_idle
45
+
46
+ def schedule(execution)
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def perform_execution(execution)
51
+ wrap_in_app_executor { execution.perform }
52
+ rescue Exception => error
53
+ handle_thread_error(error)
54
+ ensure
55
+ restore_capacity
56
+ end
57
+
58
+ def reserve_capacity!
59
+ mutex.synchronize do
60
+ raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0
43
61
 
44
- DEFAULT_OPTIONS = {
45
- min_threads: 0,
46
- idletime: 60,
47
- fallback_policy: :abort
48
- }
62
+ @available_capacity -= 1
63
+ end
64
+ end
65
+
66
+ def restore_capacity
67
+ should_notify = mutex.synchronize do
68
+ @available_capacity += 1
69
+ @available_capacity.positive?
70
+ end
49
71
 
50
- def executor
51
- @executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size)
72
+ on_idle&.call if should_notify
52
73
  end
53
74
  end
54
75
  end
@@ -4,7 +4,9 @@ module SolidQueue::Processes
4
4
  module Runnable
5
5
  include Supervised
6
6
 
7
- attr_writer :mode
7
+ def mode=(value)
8
+ @mode = (value || DEFAULT_MODE).to_s.inquiry
9
+ end
8
10
 
9
11
  def start
10
12
  run_in_mode do
@@ -29,21 +31,32 @@ module SolidQueue::Processes
29
31
  !running_async? || @thread&.alive?
30
32
  end
31
33
 
34
+ def boot_timed_out?
35
+ @boot_guard.timed_out?
36
+ end
37
+
38
+ def mark_as_reaped
39
+ @boot_guard.close
40
+ end
41
+
32
42
  private
33
43
  DEFAULT_MODE = :async
34
44
 
35
45
  def mode
36
- (@mode || DEFAULT_MODE).to_s.inquiry
46
+ @mode ||= DEFAULT_MODE.to_s.inquiry
37
47
  end
38
48
 
39
49
  def run_in_mode(&block)
40
50
  case
41
51
  when running_as_fork?
42
- fork(&block)
52
+ @boot_guard = BootGuards::ForkGuard.new
53
+ fork(&block).tap { @boot_guard.start }
43
54
  when running_async?
55
+ @boot_guard = BootGuards::NullGuard.new
44
56
  @thread = create_thread(&block)
45
57
  @thread.object_id
46
58
  else
59
+ @boot_guard = BootGuards::NullGuard.new
47
60
  block.call
48
61
  end
49
62
  end
@@ -57,6 +70,8 @@ module SolidQueue::Processes
57
70
  end
58
71
  end
59
72
  end
73
+
74
+ @boot_guard.complete
60
75
  end
61
76
 
62
77
  def shutting_down?
@@ -93,4 +108,76 @@ module SolidQueue::Processes
93
108
  mode.fork?
94
109
  end
95
110
  end
111
+
112
+ module BootGuards
113
+ # Tracks a process that shares memory with its supervisor, whose boot time
114
+ # doesn't need monitoring.
115
+ class NullGuard
116
+ def complete
117
+ @completed = true
118
+ end
119
+
120
+ def start
121
+ end
122
+
123
+ def completed?
124
+ @completed
125
+ end
126
+
127
+ def timed_out?
128
+ false
129
+ end
130
+
131
+ def close
132
+ end
133
+ end
134
+
135
+ # Tracks a forked process from the moment it's started until its boot
136
+ # callbacks finish, over a pipe that survives forking: the forked process
137
+ # writes to it when it's done booting, and its supervisor reads from it to
138
+ # decide whether the process is taking too long to boot and needs replacing.
139
+ class ForkGuard
140
+ def initialize
141
+ @reader, @writer = IO.pipe
142
+ @created_at = SolidQueue::Timer.monotonic_time_now
143
+ end
144
+
145
+ # Runs in the forked process when it has finished booting
146
+ def complete
147
+ reader.close
148
+ writer.write(".")
149
+ rescue Errno::EPIPE
150
+ # The supervisor stopped waiting while this process finished booting
151
+ ensure
152
+ writer.close
153
+ end
154
+
155
+ # Runs in the parent process right after forking
156
+ def start
157
+ writer.close
158
+ end
159
+
160
+ # A byte means boot completed; EOF means the process exited before
161
+ # finishing its boot, and will be replaced when it's reaped
162
+ def completed?
163
+ @completed ||= begin
164
+ completed = reader.read_nonblock(1, exception: false) != :wait_readable
165
+ reader.close if completed
166
+ completed
167
+ end
168
+ end
169
+
170
+ def timed_out?
171
+ !completed? && SolidQueue::Timer.monotonic_time_now - created_at >= SolidQueue.fork_boot_timeout
172
+ end
173
+
174
+ def close
175
+ reader.close unless reader.closed?
176
+ writer.close unless writer.closed?
177
+ end
178
+
179
+ private
180
+ attr_reader :reader, :writer, :created_at
181
+ end
182
+ end
96
183
  end
@@ -33,8 +33,8 @@ module SolidQueue
33
33
  end
34
34
  end
35
35
 
36
- def schedule_task(task)
37
- scheduled_tasks[task.key] = schedule(task)
36
+ def schedule_task(task, run_at: task.next_time)
37
+ scheduled_tasks[task.key] = schedule(task, run_at: run_at)
38
38
  end
39
39
 
40
40
  def unschedule_tasks
@@ -99,9 +99,11 @@ module SolidQueue
99
99
  dynamic_tasks_enabled? ? RecurringTask.dynamic.to_a : []
100
100
  end
101
101
 
102
- def schedule(task)
103
- scheduled_task = Concurrent::ScheduledTask.new(task.delay_from_now, args: [ self, task, task.next_time ]) do |thread_schedule, thread_task, thread_task_run_at|
104
- thread_schedule.schedule_task(thread_task)
102
+ def schedule(task, run_at: task.next_time)
103
+ delay = [ (run_at - Time.current).to_f, 0.1 ].max
104
+
105
+ scheduled_task = Concurrent::ScheduledTask.new(delay, args: [ self, task, run_at ]) do |thread_schedule, thread_task, thread_task_run_at|
106
+ thread_schedule.schedule_task(thread_task, run_at: thread_task.next_time_after(thread_task_run_at))
105
107
 
106
108
  wrap_in_app_executor do
107
109
  thread_task.enqueue(at: thread_task_run_at)
@@ -11,7 +11,7 @@ module SolidQueue
11
11
  end
12
12
 
13
13
  private
14
- SIGNALS = %i[ QUIT INT TERM ]
14
+ SIGNALS = Gem.win_platform? ? %i[ INT TERM ] : %i[ QUIT INT TERM ]
15
15
 
16
16
  def register_signal_handlers
17
17
  SIGNALS.each do |signal|