inst-jobs 1.0.1 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
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