inst-jobs 1.0.1 → 2.1.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: 2dad83034c6ca9ae9a7f7129581ecd36f64d1d51f93aa1126582ff2141068020
4
- data.tar.gz: 8b155f2ed6ce13b956d8383bc484ef19ed3b661502bf2aed734a4618befebf40
3
+ metadata.gz: b789a390da84dd32ede51b6d8655853e3f20efa55144fe19cf0605d9e5083e62
4
+ data.tar.gz: bd263366443698e93572f83a6f0e2b3d747c9e72acd851fbc0aa3f920c413e02
5
5
  SHA512:
6
- metadata.gz: 783af3d9f654e07a55a6be57691216a255bb548a720ae31d7c085ba0c70be20164a2a2c6a7b36767976468e034b5bafc6de427f577a05c2c70d0a6621301e86d
7
- data.tar.gz: cbc4ff85dea6bc2f12d8eb2cecb25cecae4dc47e5e5155e46c63ebdbc4884e15f43f41b69b8611c44cbb42f4539843ecffe8cf5e96cab852bf9ace06a7a6b6fa
6
+ metadata.gz: b902bbdb3d4d676b984a22c2df5d4382960219de7a9a3d639df4fd6f958816ac77a3e2e00e881d7afbc240fd6043eef21f5362885301ccf1c9fb1ae790a21e2f
7
+ data.tar.gz: 3dbb3fe818c20be76f6f07f61893014bc881a2f82a7c44139e62642230ff49a2acd0d3e2ba746b4bcdf1834fed0b5cb1ebcbb6ea3431b9fe46ffc29d6956200b
@@ -178,6 +178,10 @@ module Delayed
178
178
  expires_at && (self.class.db_time_now >= expires_at)
179
179
  end
180
180
 
181
+ def inferred_max_attempts
182
+ self.max_attempts || Delayed::Settings.max_attempts
183
+ end
184
+
181
185
  # Reschedule the job in the future (when a job fails).
182
186
  # Uses an exponential scale depending on the number of failed attempts.
183
187
  def reschedule(error = nil, time = nil)
@@ -190,7 +194,7 @@ module Delayed
190
194
 
191
195
  self.attempts += 1 unless return_code == :unlock
192
196
 
193
- if self.attempts >= (self.max_attempts || Delayed::Settings.max_attempts)
197
+ if self.attempts >= self.inferred_max_attempts
194
198
  permanent_failure error || "max attempts reached"
195
199
  elsif expired?
196
200
  permanent_failure error || "job has expired"
@@ -12,6 +12,7 @@ module Delayed
12
12
  :loop => [:worker],
13
13
  :perform => [:worker, :job],
14
14
  :pop => [:worker],
15
+ :retry => [:worker, :job, :exception],
15
16
  :work_queue_pop => [:work_queue, :worker_config],
16
17
  :check_for_work => [:work_queue],
17
18
  }
@@ -7,32 +7,45 @@ end
7
7
  module Delayed
8
8
  module MessageSending
9
9
  class DelayProxy < BasicObject
10
- def initialize(object, synchronous: false, public_send: false, **enqueue_args)
10
+ def initialize(object, synchronous: false, sender: nil, **enqueue_args)
11
11
  @object = object
12
12
  @enqueue_args = enqueue_args
13
13
  @synchronous = synchronous
14
- @public_send = public_send
14
+ @sender = sender
15
15
  end
16
16
 
17
17
  def method_missing(method, *args, **kwargs)
18
+ # method doesn't exist? must be method_missing; assume private access
19
+ @sender = nil if !@sender.nil? &&
20
+ !@object.methods.include?(method) &&
21
+ !@object.protected_methods.include?(method) &&
22
+ !@object.private_methods.include?(method)
23
+
24
+ sender_is_object = @sender == @object
25
+ sender_is_class = @sender.is_a?(@object.class)
26
+
27
+ # even if the call is async, if the call is _going_ to generate an error, we make it synchronous
28
+ # so that the error is generated immediately, instead of waiting for it to fail in a job,
29
+ # which might go unnoticed
30
+ if !@sender.nil? && !@synchronous
31
+ @synchronous = true if !sender_is_object && @object.private_methods.include?(method)
32
+ @synchronous = true if !sender_is_class && @object.protected_methods.include?(method)
33
+ end
34
+
18
35
  if @synchronous
19
- if @public_send
20
- if kwargs.empty?
21
- return @object.public_send(method, *args)
22
- else
23
- return @object.public_send(method, *args, **kwargs)
24
- end
25
- else
36
+ if @sender.nil? || sender_is_object || sender_is_class && @object.protected_methods.include?(method)
26
37
  if kwargs.empty?
27
38
  return @object.send(method, *args)
28
39
  else
29
40
  return @object.send(method, *args, **kwargs)
30
41
  end
31
42
  end
32
- end
33
-
34
- if @public_send && @object.private_methods.include?(method)
35
- ::Kernel.raise ::NoMethodError.new("undefined method `#{method}' for #{@object}", method)
43
+
44
+ if kwargs.empty?
45
+ return @object.public_send(method, *args)
46
+ else
47
+ return @object.public_send(method, *args, **kwargs)
48
+ end
36
49
  end
37
50
 
38
51
  ignore_transaction = @enqueue_args.delete(:ignore_transaction)
@@ -50,7 +63,8 @@ module Delayed
50
63
  ::Delayed::Job.enqueue(::Delayed::PerformableMethod.new(@object, method,
51
64
  args: args, kwargs: kwargs,
52
65
  on_failure: on_failure,
53
- on_permanent_failure: on_permanent_failure),
66
+ on_permanent_failure: on_permanent_failure,
67
+ sender: @sender),
54
68
  **@enqueue_args)
55
69
  end
56
70
  return nil
@@ -61,14 +75,15 @@ module Delayed
61
75
  args: args,
62
76
  kwargs: kwargs,
63
77
  on_failure: on_failure,
64
- on_permanent_failure: on_permanent_failure),
78
+ on_permanent_failure: on_permanent_failure,
79
+ sender: @sender),
65
80
  **@enqueue_args)
66
81
  result = nil unless ignore_transaction
67
82
  result
68
83
  end
69
84
  end
70
85
 
71
- def delay(public_send: nil, **enqueue_args)
86
+ def delay(sender: nil, **enqueue_args)
72
87
  # support procs/methods as enqueue arguments
73
88
  enqueue_args.each do |k,v|
74
89
  if v.respond_to?(:call)
@@ -76,21 +91,15 @@ module Delayed
76
91
  end
77
92
  end
78
93
 
79
- public_send ||= __calculate_public_send_for_delay
94
+ sender ||= __calculate_sender_for_delay
80
95
 
81
- DelayProxy.new(self, public_send: public_send, **enqueue_args)
96
+ DelayProxy.new(self, sender: sender, **enqueue_args)
82
97
  end
83
98
 
84
- def __calculate_public_send_for_delay
99
+ def __calculate_sender_for_delay
85
100
  # enforce public send in dev and test, but not prod (since it uses
86
101
  # debug APIs, it's expensive)
87
- public_send = if ::Rails.env.test? || ::Rails.env.development?
88
- sender = self.sender(1)
89
- # if the caller isn't self, use public_send; i.e. enforce method visibility
90
- sender != self
91
- else
92
- false
93
- end
102
+ return sender(1) if ::Rails.env.test? || ::Rails.env.development?
94
103
  end
95
104
 
96
105
  module ClassMethods
@@ -114,7 +123,7 @@ module Delayed
114
123
  if synchronous
115
124
  super(*args, **kwargs)
116
125
  else
117
- delay(**enqueue_args).method_missing(method_name, *args, synchronous: true, **kwargs)
126
+ delay(sender: __calculate_sender_for_delay, **enqueue_args).method_missing(method_name, *args, synchronous: true, **kwargs)
118
127
  end
119
128
  end)
120
129
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Delayed
4
- class PerformableMethod < Struct.new(:object, :method, :args, :kwargs, :fail_cb, :permanent_fail_cb, :public_send)
5
- def initialize(object, method, args: [], kwargs: {}, on_failure: nil, on_permanent_failure: nil, public_send: true)
4
+ class PerformableMethod < Struct.new(:object, :method, :args, :kwargs, :fail_cb, :permanent_fail_cb, :sender)
5
+ def initialize(object, method, args: [], kwargs: {}, on_failure: nil, on_permanent_failure: nil, sender: nil)
6
6
  raise NoMethodError, "undefined method `#{method}' for #{object.inspect}" unless object.respond_to?(method, true)
7
7
 
8
8
  self.object = object
@@ -11,7 +11,13 @@ module Delayed
11
11
  self.method = method.to_sym
12
12
  self.fail_cb = on_failure
13
13
  self.permanent_fail_cb = on_permanent_failure
14
- self.public_send = public_send
14
+ self.sender = sender
15
+ begin
16
+ YAML.load(YAML.dump(sender))
17
+ rescue
18
+ # if for some reason you can't dump the sender, just drop it
19
+ self.sender = nil
20
+ end
15
21
  end
16
22
 
17
23
  def display_name
@@ -25,17 +31,21 @@ module Delayed
25
31
 
26
32
  def perform
27
33
  kwargs = self.kwargs || {}
28
- if public_send
34
+
35
+ sender_is_object = sender == object
36
+ sender_is_class = sender.is_a?(object.class)
37
+
38
+ if sender.nil? || sender_is_object || sender_is_class && object.protected_methods.include?(method)
29
39
  if kwargs.empty?
30
- object.public_send(method, *args)
40
+ object.send(method, *args)
31
41
  else
32
- object.public_send(method, *args, **kwargs)
42
+ object.send(method, *args, **kwargs)
33
43
  end
34
44
  else
35
45
  if kwargs.empty?
36
- object.send(method, *args)
46
+ object.public_send(method, *args)
37
47
  else
38
- object.send(method, *args, **kwargs)
48
+ object.public_send(method, *args, **kwargs)
39
49
  end
40
50
  end
41
51
  end
@@ -63,7 +73,8 @@ module Delayed
63
73
 
64
74
  def full_name
65
75
  obj_name = object.is_a?(ActiveRecord::Base) ? "#{object.class}.find(#{object.id}).#{method}" : display_name
66
- kwargs_str = kwargs.map { |(k, v)| ", #{k}: #{deep_de_ar_ize(v)}"}.join("")
76
+ kgs = kwargs || {}
77
+ kwargs_str = kgs.map { |(k, v)| ", #{k}: #{deep_de_ar_ize(v)}"}.join("")
67
78
  "#{obj_name}(#{args.map { |a| deep_de_ar_ize(a) }.join(', ')}#{kwargs_str})"
68
79
  end
69
80
  end
@@ -49,10 +49,20 @@ class Periodic
49
49
  end
50
50
 
51
51
  def enqueue
52
- Delayed::Job.enqueue(self, **@job_args.merge(:max_attempts => 1,
53
- :run_at => @cron.next_time(Delayed::Periodic.now).utc.to_time,
54
- :singleton => tag,
55
- on_conflict: :patient))
52
+ Delayed::Job.enqueue(self, **enqueue_args)
53
+ end
54
+
55
+ def enqueue_args
56
+ inferred_args = {
57
+ max_attempts: 1,
58
+ run_at: @cron.next_time(Delayed::Periodic.now).utc.to_time,
59
+ singleton: (@job_args[:singleton] == false ? nil : tag),
60
+ # yes, checking for whether it is actually the boolean literal false,
61
+ # which means the consuming code really does not want this job to be
62
+ # a singleton at all.
63
+ on_conflict: :patient
64
+ }
65
+ @job_args.merge(inferred_args)
56
66
  end
57
67
 
58
68
  def perform
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Delayed
4
- VERSION = "1.0.1"
4
+ VERSION = "2.1.0"
5
5
  end
@@ -3,6 +3,17 @@
3
3
  module Delayed
4
4
 
5
5
  class TimeoutError < RuntimeError; end
6
+ class RetriableError < RuntimeError
7
+ # this error is a special case. You _should_ raise
8
+ # it from inside the rescue block for another error,
9
+ # because it indicates: "something made this job fail
10
+ # but we're pretty sure it's transient and it's safe to try again".
11
+ # the workflow is still the same (retry will happen unless
12
+ # retries are exhausted), but it won't call the :error
13
+ # callback unless it can't retry anymore. It WILL call the
14
+ # separate ":retry" callback, which is ONLY activated
15
+ # for this kind of error.
16
+ end
6
17
 
7
18
  require 'tmpdir'
8
19
  require 'set'
@@ -216,6 +227,12 @@ class Worker
216
227
  logger.info("Completed #{log_job(job)} #{"%.0fms" % (runtime * 1000)}")
217
228
  end
218
229
  count
230
+ rescue ::Delayed::RetriableError => re
231
+ can_retry = job.attempts + 1 < job.inferred_max_attempts
232
+ callback_type = can_retry ? :retry : :error
233
+ self.class.lifecycle.run_callbacks(callback_type, self, job, re) do
234
+ handle_failed_job(job, re)
235
+ end
219
236
  rescue SystemExit => se
220
237
  # There wasn't really a failure here so no callbacks and whatnot needed,
221
238
  # still reschedule the job though.
@@ -22,31 +22,46 @@ module Delayed
22
22
 
23
23
  def reschedule_abandoned_jobs
24
24
  return if Settings.worker_health_check_type == :none
25
+ Delayed::Job.transaction do
26
+ # this job is a special case, and is not a singleton
27
+ # because if it gets wiped out suddenly during execution
28
+ # it can't go clean up it's abandoned self. Therefore,
29
+ # we try to get an advisory lock when it runs. If we succeed,
30
+ # no other job is trying to do this right now (and if we abandon the
31
+ # job, the transaction will end, releasing the advisory lock).
32
+ result = attempt_advisory_lock
33
+ return unless result
34
+ checker = Worker::HealthCheck.build(
35
+ type: Settings.worker_health_check_type,
36
+ config: Settings.worker_health_check_config,
37
+ worker_name: 'cleanup-crew'
38
+ )
39
+ live_workers = checker.live_workers
25
40
 
26
- checker = Worker::HealthCheck.build(
27
- type: Settings.worker_health_check_type,
28
- config: Settings.worker_health_check_config,
29
- worker_name: 'cleanup-crew'
30
- )
31
- live_workers = checker.live_workers
32
-
33
- Delayed::Job.running_jobs.each do |job|
34
- # prefetched jobs have their own way of automatically unlocking themselves
35
- next if job.locked_by.start_with?("prefetch:")
36
- unless live_workers.include?(job.locked_by)
37
- begin
38
- Delayed::Job.transaction do
39
- # double check that the job is still there. locked_by will immediately be reset
40
- # to nil in this transaction by Job#reschedule
41
- next unless Delayed::Job.where(id: job, locked_by: job.locked_by).update_all(locked_by: "abandoned job cleanup") == 1
42
- job.reschedule
41
+ Delayed::Job.running_jobs.each do |job|
42
+ # prefetched jobs have their own way of automatically unlocking themselves
43
+ next if job.locked_by.start_with?("prefetch:")
44
+ unless live_workers.include?(job.locked_by)
45
+ begin
46
+ Delayed::Job.transaction do
47
+ # double check that the job is still there. locked_by will immediately be reset
48
+ # to nil in this transaction by Job#reschedule
49
+ next unless Delayed::Job.where(id: job, locked_by: job.locked_by).update_all(locked_by: "abandoned job cleanup") == 1
50
+ job.reschedule
51
+ end
52
+ rescue
53
+ ::Rails.logger.error "Failure rescheduling abandoned job #{job.id} #{$!.inspect}"
43
54
  end
44
- rescue
45
- ::Rails.logger.error "Failure rescheduling abandoned job #{job.id} #{$!.inspect}"
46
55
  end
47
56
  end
48
57
  end
49
58
  end
59
+
60
+ def attempt_advisory_lock
61
+ lock_name = "Delayed::Worker::HealthCheck#reschedule_abandoned_jobs"
62
+ output = ActiveRecord::Base.connection.execute("SELECT pg_try_advisory_xact_lock(half_md5_as_bigint('#{lock_name}'));")
63
+ output.getvalue(0, 0)
64
+ end
50
65
  end
51
66
 
52
67
  attr_accessor :config, :worker_name
@@ -8,21 +8,38 @@ RSpec.describe Delayed::MessageSending do
8
8
  allow(::Rails.env).to receive(:test?).and_return(true)
9
9
  end
10
10
 
11
- let(:klass) do
12
- Class.new do
11
+ before (:all) do
12
+ class SpecClass
13
13
  def call_private(**enqueue_args)
14
14
  delay(**enqueue_args).private_method
15
15
  end
16
16
 
17
+ def call_protected(**enqueue_args)
18
+ other = self.class.new
19
+ other.delay(**enqueue_args).protected_method
20
+ end
21
+
17
22
  private
18
23
 
19
24
  def private_method
20
25
  end
26
+
27
+ protected
28
+
29
+ def protected_method
30
+ end
21
31
  end
22
32
  end
23
33
 
34
+ after(:all) do
35
+ Object.send(:remove_const, :SpecClass)
36
+ end
37
+
38
+ let(:klass) { SpecClass }
39
+
24
40
  it "allows an object to send a private message to itself" do
25
- klass.new.call_private
41
+ job = klass.new.call_private(ignore_transaction: true)
42
+ job.invoke_job
26
43
  end
27
44
 
28
45
  it "allows an object to send a private message to itself synchronouosly" do
@@ -48,4 +65,37 @@ RSpec.describe Delayed::MessageSending do
48
65
  allow(::Rails.env).to receive(:development?).and_return(false)
49
66
  klass.new.delay(synchronous: true).private_method
50
67
  end
68
+
69
+ it "allows an object to send a protected message to itself" do
70
+ job = klass.new.call_protected(ignore_transaction: true)
71
+ job.invoke_job
72
+ end
73
+
74
+ it "allows an object to send a protected message to itself synchronouosly" do
75
+ klass.new.call_protected(synchronous: true)
76
+ end
77
+
78
+ it "warns about directly sending a protected message asynchronously" do
79
+ expect { klass.new.delay.protected_method }.to raise_error(NoMethodError)
80
+ end
81
+
82
+ it "warns about directly sending a protected message synchronusly" do
83
+ expect { klass.new.delay(synchronous: true).protected_method }.to raise_error(NoMethodError)
84
+ end
85
+
86
+ it "doesn't explode if you can't dump the sender" do
87
+ klass = Class.new do
88
+ def delay_something
89
+ Kernel.delay.sleep(1)
90
+ end
91
+
92
+ def encode_with(encoder)
93
+ raise "yaml encoding failed"
94
+ end
95
+ end
96
+
97
+ obj = klass.new
98
+ expect { YAML.dump(obj) }.to raise_error("yaml encoding failed")
99
+ expect { obj.delay_something }.not_to raise_error
100
+ end
51
101
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Delayed::Periodic do
6
+ around(:each) do |block|
7
+ # make sure we can use ".cron" and
8
+ # such safely without leaking global state
9
+ prev_sched = Delayed::Periodic.scheduled
10
+ prev_ovr = Delayed::Periodic.overrides
11
+ Delayed::Periodic.scheduled = {}
12
+ Delayed::Periodic.overrides = {}
13
+ block.call
14
+ ensure
15
+ Delayed::Periodic.scheduled = prev_sched
16
+ Delayed::Periodic.overrides = prev_ovr
17
+ end
18
+
19
+ describe ".cron" do
20
+ let(:job_name){ 'just a test'}
21
+ it "provides a tag by default for periodic jobs" do
22
+ Delayed::Periodic.cron job_name, '*/10 * * * *' do
23
+ # no-op
24
+ end
25
+ instance = Delayed::Periodic.scheduled[job_name]
26
+ expect(instance).to_not be_nil
27
+ expect(instance.enqueue_args[:singleton]).to eq("periodic: just a test")
28
+ end
29
+
30
+ it "uses no singleton if told to skip" do
31
+ Delayed::Periodic.cron job_name, '*/10 * * * *', {singleton: false} do
32
+ # no-op
33
+ end
34
+ instance = Delayed::Periodic.scheduled[job_name]
35
+ expect(instance).to_not be_nil
36
+ expect(instance.enqueue_args[:singleton]).to be_nil
37
+ end
38
+ end
39
+ end