inst-jobs 3.3.2 → 3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66e746a245b7b6285d8be89085060e790b35b1890f886bce7ccd8fe69aba6123
4
- data.tar.gz: 2e537eab47f0c65a4670f3a5230274d20068687a968739d88f01eb5cfb1b0037
3
+ metadata.gz: a4ab079657570b1e9ce94abdd938367ba912afa077ccad380f38f81862fe5a43
4
+ data.tar.gz: a0366132ef9d5cced326cdecef3446217857e668760fae47a72f664060fc380a
5
5
  SHA512:
6
- metadata.gz: daae66931ebd4d17f2c578f2a85ee793fad01cdf048ae43332c5884b2596d800d250c503ca4431d036cc4ff95edd7b885394c8a7745a46ea6861260e1437b98c
7
- data.tar.gz: 787c0db4e6dc7e14188dfbe3be2b9603275e89a50372ca93b49d302573cf6cec2e67ff9982e472107dc6b4510d7d631686781839e3f60433c1088d9dfcaec52d
6
+ metadata.gz: 04d51cfd152ee4a6e5145df09d1f8fec52d60c4eb74287e49d693a29f62498ca9a09655ac0dbb5ce6137817e309a7a47fb5918b737d41a2f78f64c6c8c43ff4e
7
+ data.tar.gz: ed4b8778c404ea2a5f57644e71b5b5bdb8be9f135056acd8eba46dcb7fec58eb39cc5bf5deb05cd4bf3e6754334d7fedb9fe5239291b276bebc1595946d97b45
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCancelledAtToDelayedJobs < ActiveRecord::Migration[7.1]
4
+ def change
5
+ add_column :delayed_jobs, :cancelled_at, :datetime
6
+ add_column :failed_jobs, :cancelled_at, :datetime
7
+ end
8
+ end
@@ -215,6 +215,12 @@ module Delayed
215
215
  where(locked_by: worker_name).update_all(locked_by: nil, locked_at: nil)
216
216
  end
217
217
 
218
+ def self.cancel!
219
+ deleted = where("locked_by IS NULL OR locked_by=?", ON_HOLD_LOCKED_BY).delete_all
220
+ marked = update_all(cancelled_at: db_time_now)
221
+ [deleted, marked]
222
+ end
223
+
218
224
  def self.strand_size(strand)
219
225
  where(strand:).count
220
226
  end
@@ -613,6 +619,18 @@ module Delayed
613
619
  single_step_create
614
620
  end
615
621
 
622
+ # Delete unlocked jobs; if the job is locked mark it for the job worker
623
+ # to cancel within a reasonable amount of time
624
+ def cancel!
625
+ if self.class.where(id:).where("locked_by IS NULL OR locked_by=?", ON_HOLD_LOCKED_BY).delete_all == 1
626
+ @destroyed = true
627
+ return true
628
+ end
629
+
630
+ update!(cancelled_at: self.class.db_time_now)
631
+ false
632
+ end
633
+
616
634
  def fail!
617
635
  attrs = attributes
618
636
  attrs["original_job_id"] = attrs.delete("id") if Failed.columns_hash.key?("original_job_id")
@@ -646,6 +664,7 @@ module Delayed
646
664
  "last_error",
647
665
  "locked_at",
648
666
  "failed_at",
667
+ "cancelled_at",
649
668
  "locked_by",
650
669
  "original_job_id",
651
670
  "requeued_job_id")
@@ -234,6 +234,10 @@ module Delayed
234
234
  expires_at && (self.class.db_time_now >= expires_at)
235
235
  end
236
236
 
237
+ def cancelled?
238
+ cancelled_at && (self.class.db_time_now >= cancelled_at)
239
+ end
240
+
237
241
  def inferred_max_attempts
238
242
  max_attempts || Delayed::Settings.max_attempts
239
243
  end
@@ -25,6 +25,7 @@ module Delayed
25
25
  :silence_periodic_log,
26
26
  :sleep_delay,
27
27
  :sleep_delay_stagger,
28
+ :cancellation_check_interval,
28
29
  :slow_exit_timeout,
29
30
  :stranded_run_at_grace_period,
30
31
  :worker_health_check_type,
@@ -130,6 +131,7 @@ module Delayed
130
131
  self.max_attempts = 1
131
132
  self.sleep_delay = 2.0
132
133
  self.sleep_delay_stagger = 2.0
134
+ self.cancellation_check_interval = 10.0
133
135
  self.fetch_batch_size = 5
134
136
  self.select_random_from_batch = false
135
137
  self.silence_periodic_log = false
@@ -3,7 +3,9 @@
3
3
  module Delayed
4
4
  module Testing
5
5
  def self.run_job(job)
6
- Delayed::Worker.new.perform(job)
6
+ Delayed::Worker.running_job(job) do
7
+ Delayed::Worker.new.perform(job)
8
+ end
7
9
  end
8
10
 
9
11
  def self.drain
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Delayed
4
- VERSION = "3.3.2"
4
+ VERSION = "3.4.0"
5
5
  end
@@ -24,6 +24,7 @@ module Delayed
24
24
 
25
25
  @exit = false
26
26
  @self_pipe = IO.pipe
27
+ @last_cancellation_check = Delayed::Job.db_time_now
27
28
  end
28
29
 
29
30
  def connected_clients
@@ -79,6 +80,11 @@ module Delayed
79
80
  check_for_work(forced_latency:)
80
81
  end
81
82
  unlock_timed_out_prefetched_jobs
83
+
84
+ return if @last_cancellation_check + Settings.cancellation_check_interval > Delayed::Job.db_time_now
85
+
86
+ @last_cancellation_check = Delayed::Job.db_time_now
87
+ check_for_cancellations
82
88
  end
83
89
 
84
90
  def handle_read(socket)
@@ -97,7 +103,7 @@ module Delayed
97
103
  # and terminate the work queue process, to be restarted by the parent daemon.
98
104
  def handle_accept
99
105
  socket, _addr = @listen_socket.accept_nonblock
100
- @clients[socket] = ClientState.new(false, socket) if socket
106
+ @clients[socket] = ClientState.new(false, nil, socket) if socket
101
107
  rescue IO::WaitReadable
102
108
  logger.error("Server attempted to read listen_socket but failed with IO::WaitReadable")
103
109
  # ignore and just try accepting again next time through the loop
@@ -116,6 +122,7 @@ module Delayed
116
122
  worker_name, worker_config = Marshal.load(socket)
117
123
  client.name = worker_name
118
124
  client.working = false
125
+ client.active_job_id = nil
119
126
  (@waiting_clients[worker_config] ||= []) << client
120
127
  rescue SystemCallError, IOError => e
121
128
  logger.error("Receiving message from client (#{socket}) failed: #{e.inspect}")
@@ -136,6 +143,7 @@ module Delayed
136
143
  next
137
144
  end
138
145
  client.working = true
146
+ client.active_job_id = job.id
139
147
  begin
140
148
  logger.debug("Sending prefetched job #{job.id} to #{client.name}")
141
149
  client_timeout { Marshal.dump(job, client.socket) }
@@ -177,6 +185,7 @@ module Delayed
177
185
  end
178
186
  client = workers.find { |worker| worker.name == worker_name }
179
187
  client.working = true
188
+ client.active_job_id = locked_jobs.id
180
189
  jobs_to_send << [client, locked_jobs]
181
190
  end
182
191
  end
@@ -245,6 +254,36 @@ module Delayed
245
254
  end
246
255
  end
247
256
 
257
+ def check_for_cancellations
258
+ job_map = @clients.values.select(&:active_job_id).index_by(&:active_job_id)
259
+ prefetched_ids = @prefetched_jobs.values.flatten.map(&:id)
260
+ return if job_map.empty? && prefetched_ids.empty?
261
+
262
+ canceled_jobs = Delayed::Job.where(id: job_map.keys + prefetched_ids)
263
+ .where(cancelled_at: ..Delayed::Job.db_time_now)
264
+ .pluck(:id)
265
+ return if canceled_jobs.empty?
266
+
267
+ canceled_prefetched = canceled_jobs & prefetched_ids
268
+ unless canceled_prefetched.empty?
269
+ logger.debug("Deleting cancelled prefetched jobs #{canceled_prefetched.join(", ")}")
270
+ Delayed::Job.where(id: canceled_prefetched, locked_by: prefetch_owner).delete_all
271
+ @prefetched_jobs.each_value { |jobs| jobs.reject! { |job| canceled_prefetched.include?(job.id) } }
272
+ end
273
+
274
+ (canceled_jobs & job_map.keys).each do |job_id|
275
+ client = job_map[job_id]
276
+ pid = client.name.split(":").last.to_i
277
+ next unless pid.positive?
278
+
279
+ begin
280
+ Process.kill("USR1", pid)
281
+ rescue Errno::ESRCH
282
+ # ignore exited process
283
+ end
284
+ end
285
+ end
286
+
248
287
  def drop_socket(socket)
249
288
  # this socket went away
250
289
  begin
@@ -275,7 +314,7 @@ module Delayed
275
314
  Timeout.timeout(@client_timeout, &)
276
315
  end
277
316
 
278
- ClientState = Struct.new(:working, :socket, :name)
317
+ ClientState = Struct.new(:working, :active_job_id, :socket, :name)
279
318
  end
280
319
  end
281
320
  end
@@ -3,6 +3,11 @@
3
3
  module Delayed
4
4
  class TimeoutError < RuntimeError; end
5
5
 
6
+ # Use Exception so that user code that rescues regular errors won't accidentally catch this
7
+ # rubocop:disable Lint/InheritException
8
+ class CancelledError < Exception; end
9
+ # rubocop:enable Lint/InheritException
10
+
6
11
  class RetriableError < RuntimeError
7
12
  # this error is a special case. You _should_ raise
8
13
  # it from inside the rescue block for another error,
@@ -21,7 +26,7 @@ module Delayed
21
26
  class Worker
22
27
  include Delayed::Logging
23
28
 
24
- SIGNALS = %i[INT TERM QUIT].freeze
29
+ SIGNALS = %i[INT TERM QUIT USR1].freeze
25
30
 
26
31
  attr_reader :config, :queue_name, :min_priority, :max_priority, :work_queue
27
32
 
@@ -69,6 +74,7 @@ module Delayed
69
74
  @health_check_config = Settings.worker_health_check_config
70
75
  @config = options
71
76
  @job_count = 0
77
+ @active_job_mutex = Mutex.new
72
78
 
73
79
  @signal_queue = []
74
80
 
@@ -129,14 +135,29 @@ module Delayed
129
135
  break
130
136
  when :QUIT
131
137
  @exit = true
138
+ when :USR1
139
+ # Use the explicit check in case the job changed since the signal was sent
140
+ # Use dedicated thread to avoid blocking the signal handler while making DB queries
141
+ Thread.new { cancel_active_job_if_cancelled(work_thread) }
132
142
  else
133
143
  logger.error "Unknown signal '#{sig}' received"
134
144
  end
135
145
  end
136
146
  end
137
147
 
138
- self.class.lifecycle.run_callbacks(:execute, self) do
139
- run until exit?
148
+ if work_queue.is_a?(WorkQueue::InProcess)
149
+ cancellation_checker = Thread.new do
150
+ loop do
151
+ sleep(Settings.cancellation_check_interval)
152
+ cancel_active_job_if_cancelled(work_thread)
153
+ end
154
+ end
155
+ end
156
+
157
+ Thread.handle_interrupt(CancelledError => :never) do
158
+ self.class.lifecycle.run_callbacks(:execute, self) do
159
+ run until exit?
160
+ end
140
161
  end
141
162
 
142
163
  logger.info "Stopping worker"
@@ -151,6 +172,11 @@ module Delayed
151
172
  signal_processor.join
152
173
  end
153
174
 
175
+ if cancellation_checker
176
+ cancellation_checker.kill
177
+ cancellation_checker.join
178
+ end
179
+
154
180
  @self_pipe&.each(&:close)
155
181
  @self_pipe = nil
156
182
  end
@@ -180,8 +206,11 @@ module Delayed
180
206
  end
181
207
 
182
208
  if job
209
+ @active_job_mutex.synchronize { @active_root_job = job }
183
210
  configure_for_job(job) do
184
211
  @job_count += perform(job)
212
+ @active_job_mutex.synchronize { @active_root_job = nil }
213
+ discard_pending_cancellation
185
214
 
186
215
  if @max_job_count.positive? && @job_count >= @max_job_count
187
216
  logger.debug "Max job count of #{@max_job_count} exceeded, dying"
@@ -206,21 +235,26 @@ module Delayed
206
235
  end
207
236
  end
208
237
 
209
- def perform(job)
238
+ def perform(job, in_batch: false)
210
239
  begin
211
240
  count = 1
212
241
  raise Delayed::Backend::JobExpired, "job expired at #{job.expires_at}" if job.expired?
242
+ raise CancelledError, "job #{job.id} cancelled at #{job.cancelled_at}" if job.cancelled?
213
243
 
214
244
  self.class.lifecycle.run_callbacks(:perform, self, job) do
215
245
  self.process_name = "run:#{Settings.worker_procname_prefix}#{job.id}:#{job.name}"
216
246
  logger.info("Processing #{log_job(job, :long)}")
217
247
  runtime = Benchmark.realtime do
218
- if job.batch?
219
- # each job in the batch will have perform called on it, so we don't
220
- # need a timeout around this
221
- count = perform_batch(job)
222
- else
223
- job.invoke_job
248
+ # cancellation is only delivered while the job's own code is running, so it can't
249
+ # interrupt our bookkeeping (destroying the job, or failing it in a rescue below)
250
+ Thread.handle_interrupt(CancelledError => :immediate) do
251
+ if job.batch?
252
+ # each job in the batch will have perform called on it, so we don't
253
+ # need a timeout around this
254
+ count = perform_batch(job)
255
+ else
256
+ job.invoke_job
257
+ end
224
258
  end
225
259
  job.destroy
226
260
  end
@@ -236,6 +270,16 @@ module Delayed
236
270
  # There wasn't really a failure here so no callbacks and whatnot needed,
237
271
  # still reschedule the job though.
238
272
  job.reschedule(e)
273
+ rescue CancelledError => e
274
+ logger.debug "Job #{job.id} was cancelled"
275
+ self.class.lifecycle.run_callbacks(:error, self, job, e) do
276
+ job.last_error = e.message
277
+ # Do not call on_failure, as a cancelled job must not be simply unlocked
278
+ # and thus the behavior switching that method supports must be ignored
279
+ job.permanent_failure(e)
280
+ end
281
+ # cancellation applies to the whole batch, so don't move on to the next job in it
282
+ raise if in_batch
239
283
  rescue Exception => e # rubocop:disable Lint/RescueException
240
284
  if e.is_a?(NoMemoryError)
241
285
  GC.start # try and free up some memory before reporting the error
@@ -257,12 +301,49 @@ module Delayed
257
301
  job.source = parent_job.source
258
302
  job.create_and_lock!(name)
259
303
  configure_for_job(job) do
260
- perform(job)
304
+ # we're inside the batch's :immediate block; mask again so the sub-job's own bookkeeping
305
+ # is protected. a cancellation that arrives during it is raised once this block exits,
306
+ # which cancels the rest of the batch
307
+ Thread.handle_interrupt(CancelledError => :never) do
308
+ perform(job, in_batch: true)
309
+ end
261
310
  end
262
311
  end
263
312
  batch.items.size
264
313
  end
265
314
 
315
+ def cancel_active_job_if_cancelled(work_thread)
316
+ job = @active_root_job
317
+ return unless job
318
+
319
+ # this runs on a background thread; don't hold a connection from the pool between checks
320
+ cancelled_at = Delayed::Job.connection_pool.with_connection do
321
+ Delayed::Job.where(id: job.id).pick(:cancelled_at)
322
+ end
323
+ return unless cancelled_at && cancelled_at <= Delayed::Job.db_time_now
324
+
325
+ @active_job_mutex.synchronize do
326
+ return unless @active_root_job.equal?(job)
327
+
328
+ logger.debug "Active job #{job.id} has been cancelled"
329
+ work_thread.raise(CancelledError, "job #{job.id} cancelled at #{cancelled_at}")
330
+ end
331
+ rescue => e
332
+ logger.error("Error while checking for job cancellation: #{e.inspect}")
333
+ end
334
+
335
+ # A cancellation that arrives after a job finishes is deferred by handle_interrupt,
336
+ # and would otherwise be raised as soon as the next job starts.
337
+ def discard_pending_cancellation
338
+ while Thread.pending_interrupt?(CancelledError)
339
+ begin
340
+ Thread.handle_interrupt(CancelledError => :immediate) { nil }
341
+ rescue CancelledError => e
342
+ logger.debug "Discarding cancellation for a job that already finished: #{e.message}"
343
+ end
344
+ end
345
+ end
346
+
266
347
  def handle_failed_job(job, error)
267
348
  job.last_error = "#{error.message}\n#{error.backtrace.join("\n")}"
268
349
  logger.error("Failed with #{error.class} [#{error.message}] (#{job.attempts} attempts)")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inst-jobs
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.2
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cody Cutrer
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: exe
12
12
  cert_chain: []
13
- date: 2025-10-20 00:00:00.000000000 Z
13
+ date: 2026-10-19 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activerecord
@@ -167,6 +167,7 @@ files:
167
167
  - db/migrate/20220203063200_remove_old_singleton_index.rb
168
168
  - db/migrate/20220328152900_add_failed_jobs_indicies.rb
169
169
  - db/migrate/20220519204546_add_requeued_job_id_to_failed_jobs.rb
170
+ - db/migrate/20261012154653_add_cancelled_at_to_delayed_jobs.rb
170
171
  - exe/inst_jobs
171
172
  - lib/delayed/backend/active_record.rb
172
173
  - lib/delayed/backend/base.rb