delayed 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +560 -0
  4. data/Rakefile +35 -0
  5. data/lib/delayed.rb +72 -0
  6. data/lib/delayed/active_job_adapter.rb +65 -0
  7. data/lib/delayed/backend/base.rb +166 -0
  8. data/lib/delayed/backend/job_preparer.rb +43 -0
  9. data/lib/delayed/exceptions.rb +14 -0
  10. data/lib/delayed/job.rb +250 -0
  11. data/lib/delayed/lifecycle.rb +85 -0
  12. data/lib/delayed/message_sending.rb +65 -0
  13. data/lib/delayed/monitor.rb +134 -0
  14. data/lib/delayed/performable_mailer.rb +22 -0
  15. data/lib/delayed/performable_method.rb +47 -0
  16. data/lib/delayed/plugin.rb +15 -0
  17. data/lib/delayed/plugins/connection.rb +13 -0
  18. data/lib/delayed/plugins/instrumentation.rb +39 -0
  19. data/lib/delayed/priority.rb +164 -0
  20. data/lib/delayed/psych_ext.rb +135 -0
  21. data/lib/delayed/railtie.rb +7 -0
  22. data/lib/delayed/runnable.rb +46 -0
  23. data/lib/delayed/serialization/active_record.rb +18 -0
  24. data/lib/delayed/syck_ext.rb +42 -0
  25. data/lib/delayed/tasks.rb +40 -0
  26. data/lib/delayed/worker.rb +233 -0
  27. data/lib/delayed/yaml_ext.rb +10 -0
  28. data/lib/delayed_job.rb +1 -0
  29. data/lib/delayed_job_active_record.rb +1 -0
  30. data/lib/generators/delayed/generator.rb +7 -0
  31. data/lib/generators/delayed/migration_generator.rb +28 -0
  32. data/lib/generators/delayed/next_migration_version.rb +14 -0
  33. data/lib/generators/delayed/templates/migration.rb +22 -0
  34. data/spec/autoloaded/clazz.rb +6 -0
  35. data/spec/autoloaded/instance_clazz.rb +5 -0
  36. data/spec/autoloaded/instance_struct.rb +6 -0
  37. data/spec/autoloaded/struct.rb +7 -0
  38. data/spec/database.yml +25 -0
  39. data/spec/delayed/active_job_adapter_spec.rb +267 -0
  40. data/spec/delayed/job_spec.rb +953 -0
  41. data/spec/delayed/monitor_spec.rb +276 -0
  42. data/spec/delayed/plugins/instrumentation_spec.rb +49 -0
  43. data/spec/delayed/priority_spec.rb +154 -0
  44. data/spec/delayed/serialization/active_record_spec.rb +15 -0
  45. data/spec/delayed/tasks_spec.rb +116 -0
  46. data/spec/helper.rb +196 -0
  47. data/spec/lifecycle_spec.rb +77 -0
  48. data/spec/message_sending_spec.rb +149 -0
  49. data/spec/performable_mailer_spec.rb +68 -0
  50. data/spec/performable_method_spec.rb +123 -0
  51. data/spec/psych_ext_spec.rb +94 -0
  52. data/spec/sample_jobs.rb +117 -0
  53. data/spec/worker_spec.rb +235 -0
  54. data/spec/yaml_ext_spec.rb +48 -0
  55. metadata +326 -0
@@ -0,0 +1,85 @@
1
+ module Delayed
2
+ class InvalidCallback < RuntimeError; end
3
+
4
+ class Lifecycle
5
+ EVENTS = {
6
+ execute: [nil],
7
+ enqueue: [:job],
8
+ perform: %i(worker job),
9
+ error: %i(worker job),
10
+ failure: %i(worker job),
11
+ thread: %i(worker job),
12
+ invoke_job: [:job],
13
+ }.freeze
14
+
15
+ def initialize
16
+ @callbacks = EVENTS.keys.each_with_object({}) do |e, hash|
17
+ hash[e] = Callback.new
18
+ end
19
+ end
20
+
21
+ def before(event, &block)
22
+ add(:before, event, &block)
23
+ end
24
+
25
+ def after(event, &block)
26
+ add(:after, event, &block)
27
+ end
28
+
29
+ def around(event, &block)
30
+ add(:around, event, &block)
31
+ end
32
+
33
+ def run_callbacks(event, *args, &block)
34
+ missing_callback(event) unless @callbacks.key?(event)
35
+
36
+ unless EVENTS[event].size == args.size
37
+ raise ArgumentError, "Callback #{event} expects #{EVENTS[event].size} parameter(s): #{EVENTS[event].join(', ')}"
38
+ end
39
+
40
+ @callbacks[event].execute(*args, &block)
41
+ end
42
+
43
+ private
44
+
45
+ def add(type, event, &block)
46
+ missing_callback(event) unless @callbacks.key?(event)
47
+ @callbacks[event].add(type, &block)
48
+ end
49
+
50
+ def missing_callback(event)
51
+ raise InvalidCallback, "Unknown callback event: #{event}"
52
+ end
53
+ end
54
+
55
+ class Callback
56
+ def initialize
57
+ @before = []
58
+ @after = []
59
+
60
+ # Identity proc. Avoids special cases when there is no existing around chain.
61
+ @around = lambda { |*args, &block| block.call(*args) }
62
+ end
63
+
64
+ def execute(*args, &block)
65
+ @before.each { |c| c.call(*args) }
66
+ result = @around.call(*args, &block)
67
+ @after.each { |c| c.call(*args) }
68
+ result
69
+ end
70
+
71
+ def add(type, &callback)
72
+ case type
73
+ when :before
74
+ @before << callback
75
+ when :after
76
+ @after << callback
77
+ when :around
78
+ chain = @around # use a local variable so that the current chain is closed over in the following lambda
79
+ @around = lambda { |*a, &block| chain.call(*a) { |*b| callback.call(*b, &block) } }
80
+ else
81
+ raise InvalidCallback, "Invalid callback type: #{type}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,65 @@
1
+ require 'active_support/proxy_object'
2
+
3
+ module Delayed
4
+ class DelayProxy < ActiveSupport::ProxyObject
5
+ def initialize(payload_class, target, options)
6
+ @payload_class = payload_class
7
+ @target = target
8
+ @options = options
9
+ end
10
+
11
+ def method_missing(method, *args)
12
+ Job.enqueue({ payload_object: @payload_class.new(@target, method.to_sym, args) }.merge(@options))
13
+ end
14
+ end
15
+
16
+ module MessageSending
17
+ def delay(options = {})
18
+ DelayProxy.new(PerformableMethod, self, options)
19
+ end
20
+ alias __delay__ delay
21
+
22
+ def send_later(method, *args)
23
+ warn '[DEPRECATION] `object.send_later(:method)` is deprecated. Use `object.delay.method'
24
+ __delay__.__send__(method, *args)
25
+ end
26
+
27
+ def send_at(time, method, *args)
28
+ warn '[DEPRECATION] `object.send_at(time, :method)` is deprecated. Use `object.delay(:run_at => time).method'
29
+ __delay__(run_at: time).__send__(method, *args)
30
+ end
31
+ end
32
+
33
+ module MessageSendingClassMethods
34
+ def handle_asynchronously(method, opts = {}) # rubocop:disable Metrics/PerceivedComplexity
35
+ aliased_method = method.to_s.sub(/([?!=])$/, '')
36
+ punctuation = $1 # rubocop:disable Style/PerlBackrefs
37
+ with_method = "#{aliased_method}_with_delay#{punctuation}"
38
+ without_method = "#{aliased_method}_without_delay#{punctuation}"
39
+ define_method(with_method) do |*args|
40
+ curr_opts = opts.clone
41
+ curr_opts.each_key do |key|
42
+ next unless (val = curr_opts[key]).is_a?(Proc)
43
+
44
+ curr_opts[key] = if val.arity == 1
45
+ val.call(self)
46
+ else
47
+ val.call
48
+ end
49
+ end
50
+ delay(curr_opts).__send__(without_method, *args)
51
+ end
52
+
53
+ alias_method without_method, method
54
+ alias_method method, with_method
55
+
56
+ if public_method_defined?(without_method)
57
+ public method
58
+ elsif protected_method_defined?(without_method)
59
+ protected method
60
+ elsif private_method_defined?(without_method)
61
+ private method
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,134 @@
1
+ module Delayed
2
+ class Monitor
3
+ include Runnable
4
+
5
+ METRICS = %w(
6
+ count
7
+ future_count
8
+ locked_count
9
+ erroring_count
10
+ failed_count
11
+ max_lock_age
12
+ max_age
13
+ working_count
14
+ workable_count
15
+ alert_age_percent
16
+ ).freeze
17
+
18
+ cattr_accessor :sleep_delay, instance_writer: false, default: 60
19
+
20
+ def initialize
21
+ @jobs = Job.group(priority_case_statement).group(:queue)
22
+ @jobs = @jobs.where(queue: Worker.queues) if Worker.queues.any?
23
+ end
24
+
25
+ def run!
26
+ ActiveSupport::Notifications.instrument('delayed.monitor.run', default_tags) do
27
+ METRICS.each { |metric| emit_metric!(metric) }
28
+ end
29
+ interruptable_sleep(sleep_delay)
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :jobs
35
+
36
+ def emit_metric!(metric)
37
+ send("#{metric}_grouped").reverse_merge(default_results).each do |(priority, queue), value|
38
+ ActiveSupport::Notifications.instrument(
39
+ "delayed.job.#{metric}",
40
+ default_tags.merge(priority: Priority.new(priority).to_s, queue: queue, value: value),
41
+ )
42
+ end
43
+ end
44
+
45
+ def default_results
46
+ @default_results ||= Priority.names.values.flat_map { |priority|
47
+ (Worker.queues.presence || [Worker.default_queue_name]).map do |queue|
48
+ [[priority.to_i, queue], 0]
49
+ end
50
+ }.to_h
51
+ end
52
+
53
+ def say(message)
54
+ Delayed.say(message)
55
+ end
56
+
57
+ def default_tags
58
+ @default_tags ||= {
59
+ table: Job.table_name,
60
+ database: Job.database_name,
61
+ database_adapter: Job.database_adapter_name,
62
+ }
63
+ end
64
+
65
+ def count_grouped
66
+ jobs.count
67
+ end
68
+
69
+ def future_count_grouped
70
+ jobs.where("run_at > ?", Job.db_time_now).count
71
+ end
72
+
73
+ def locked_count_grouped
74
+ jobs.locked.count
75
+ end
76
+
77
+ def erroring_count_grouped
78
+ jobs.erroring.count
79
+ end
80
+
81
+ def failed_count_grouped
82
+ jobs.failed.count
83
+ end
84
+
85
+ def max_lock_age_grouped
86
+ oldest_locked_job_grouped.each_with_object({}) do |job, metrics|
87
+ metrics[[job.priority.to_i, job.queue]] = Job.db_time_now - job.locked_at
88
+ end
89
+ end
90
+
91
+ def max_age_grouped
92
+ oldest_workable_job_grouped.each_with_object({}) do |job, metrics|
93
+ metrics[[job.priority.to_i, job.queue]] = Job.db_time_now - job.run_at
94
+ end
95
+ end
96
+
97
+ def alert_age_percent_grouped
98
+ oldest_workable_job_grouped.each_with_object({}) do |job, metrics|
99
+ max_age = Job.db_time_now - job.run_at
100
+ metrics[[job.priority.to_i, job.queue]] = [max_age / job.priority.alert_age * 100, 100].min if job.priority.alert_age
101
+ end
102
+ end
103
+
104
+ def workable_count_grouped
105
+ jobs.workable(Job.db_time_now).count
106
+ end
107
+
108
+ def working_count_grouped
109
+ jobs.working.count
110
+ end
111
+
112
+ def oldest_locked_job_grouped
113
+ jobs.working.select("#{priority_case_statement} AS priority, queue, MIN(locked_at) AS locked_at")
114
+ end
115
+
116
+ def oldest_workable_job_grouped
117
+ jobs.workable(Job.db_time_now).select("(#{priority_case_statement}) AS priority, queue, MIN(run_at) AS run_at")
118
+ end
119
+
120
+ def priority_case_statement
121
+ [
122
+ 'CASE',
123
+ Priority.ranges.values.map do |range|
124
+ [
125
+ "WHEN priority >= #{range.first.to_i}",
126
+ ("AND priority < #{range.last.to_i}" unless range.last.infinite?),
127
+ "THEN #{range.first.to_i}",
128
+ ].compact
129
+ end,
130
+ 'END',
131
+ ].flatten.join(' ')
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,22 @@
1
+ require 'mail'
2
+
3
+ module Delayed
4
+ class PerformableMailer < PerformableMethod
5
+ def perform
6
+ mailer = object.send(method_name, *args)
7
+ mailer.respond_to?(:deliver_now) ? mailer.deliver_now : mailer.deliver
8
+ end
9
+ end
10
+
11
+ module DelayMail
12
+ def delay(options = {})
13
+ DelayProxy.new(PerformableMailer, self, options)
14
+ end
15
+ end
16
+ end
17
+
18
+ Mail::Message.class_eval do
19
+ def delay(*_args)
20
+ raise 'Use MyMailer.delay.mailer_action(args) to delay sending of emails.'
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ module Delayed
2
+ class PerformableMethod
3
+ attr_accessor :object, :method_name, :args
4
+
5
+ def initialize(object, method_name, args)
6
+ raise NoMethodError, "undefined method `#{method_name}' for #{object.inspect}" unless object.respond_to?(method_name, true)
7
+
8
+ if !her_model?(object) && object.respond_to?(:persisted?) && !object.persisted?
9
+ raise(ArgumentError, "job cannot be created for non-persisted record: #{object.inspect}")
10
+ end
11
+
12
+ self.object = object
13
+ self.args = args
14
+ self.method_name = method_name.to_sym
15
+ end
16
+
17
+ def display_name
18
+ if object.is_a?(Class)
19
+ "#{object}.#{method_name}"
20
+ else
21
+ "#{object.class}##{method_name}"
22
+ end
23
+ end
24
+
25
+ def perform
26
+ object.send(method_name, *args) if object
27
+ end
28
+
29
+ def method(sym)
30
+ object.method(sym)
31
+ end
32
+
33
+ def method_missing(symbol, *args)
34
+ object.send(symbol, *args)
35
+ end
36
+
37
+ def respond_to?(symbol, include_private = false)
38
+ super || object.respond_to?(symbol, include_private)
39
+ end
40
+
41
+ private
42
+
43
+ def her_model?(object)
44
+ object.class.respond_to?(:save_existing)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ require 'active_support/core_ext/class/attribute'
2
+
3
+ module Delayed
4
+ class Plugin
5
+ class_attribute :callback_block
6
+
7
+ def self.callbacks(&block)
8
+ self.callback_block = block
9
+ end
10
+
11
+ def initialize
12
+ self.class.callback_block.call(Delayed.lifecycle) if self.class.callback_block
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ module Delayed
2
+ module Plugins
3
+ class Connection < Plugin
4
+ callbacks do |lifecycle|
5
+ lifecycle.around(:thread) do |worker, job, &block|
6
+ Job.connection_pool.with_connection do
7
+ block.call(worker, job)
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,39 @@
1
+ module Delayed
2
+ module Plugins
3
+ class Instrumentation < Plugin
4
+ callbacks do |lifecycle|
5
+ lifecycle.around(:enqueue) do |job, *args, &block|
6
+ ActiveSupport::Notifications.instrument('delayed.job.enqueue', active_support_notifications_tags(job)) do
7
+ block.call(job, *args)
8
+ end
9
+ end
10
+
11
+ lifecycle.around(:invoke_job) do |job, *args, &block|
12
+ ActiveSupport::Notifications.instrument('delayed.job.run', active_support_notifications_tags(job)) do
13
+ block.call(job, *args)
14
+ end
15
+ end
16
+
17
+ lifecycle.after(:error) do |_worker, job, *_args|
18
+ ActiveSupport::Notifications.instrument('delayed.job.error', active_support_notifications_tags(job))
19
+ end
20
+
21
+ lifecycle.after(:failure) do |_worker, job, *_args|
22
+ ActiveSupport::Notifications.instrument('delayed.job.failure', active_support_notifications_tags(job))
23
+ end
24
+ end
25
+
26
+ def self.active_support_notifications_tags(job)
27
+ {
28
+ job_name: job.name,
29
+ priority: job.priority,
30
+ queue: job.queue,
31
+ table: job.class.table_name,
32
+ database: job.class.database_name,
33
+ database_adapter: job.class.database_adapter_name,
34
+ job: job,
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,164 @@
1
+ module Delayed
2
+ class Priority < Numeric
3
+ # A Delayed::Priority represents a value that exists within a named range.
4
+ # Here are the default ranges and their names:
5
+ #
6
+ # 0-9: interactive
7
+ # 10-19: user_visible
8
+ # 20-29: eventual
9
+ # 30+: reporting
10
+ #
11
+ # Ranges can be customized. They must be positive and must include a name for priority >= 0.
12
+ # The following config will produce ranges 0-99 (high), 100-499 (medium) and 500+ (low):
13
+ #
14
+ # > Delayed::Priority.names = { high: 0, medium: 100, low: 500 }
15
+
16
+ DEFAULT_NAMES = {
17
+ interactive: 0, # These jobs will actively hinder end-user interactions until they are complete, e.g. work behind a loading spinner
18
+ user_visible: 10, # These jobs have end-user-visible side effects that will not obviously impact customers, e.g. welcome emails
19
+ eventual: 20, # These jobs affect business process that are tolerant to some degree of queue backlog, e.g. syncing with other services
20
+ reporting: 30, # These jobs are for processes that can complete on a slower timeline, e.g. daily report generation
21
+ }.freeze
22
+
23
+ # Priorities can be mapped to alerting thresholds for job age (time since run_at), runtime, and attempts.
24
+ # These thresholds can be used to emit events or metrics. Here are the default values (for the default priorities):
25
+ #
26
+ # === Age Alerts ==========
27
+ # interactive: 1 minute
28
+ # user_visible: 3 minutes
29
+ # eventual: 1.5 hours
30
+ # reporting: 4 hours
31
+ #
32
+ # === Run Time Alerts ======
33
+ # interactive: 30 seconds
34
+ # user_visible: 90 seconds
35
+ # eventual: 5 minutes
36
+ # reporting: 10 minutes
37
+ #
38
+ # === Attempts Alerts =====
39
+ # interactive: 3 attempts
40
+ # user_visible: 5 attempts
41
+ # eventual: 8 attempts
42
+ # reporting: 8 attempts
43
+ #
44
+ # Alerting thresholds can be customized. The keys must match `Delayed::Priority.names`.
45
+ #
46
+ # Delayed::Priority.alerts = {
47
+ # high: { age: 30.seconds, run_time: 15.seconds, attempts: 3 },
48
+ # medium: { age: 2.minutes, run_time: 1.minute, attempts: 6 },
49
+ # low: { age: 10.minutes, run_time: 2.minutes, attempts: 9 },
50
+ # }
51
+
52
+ DEFAULT_ALERTS = {
53
+ interactive: { age: 1.minute, run_time: 30.seconds, attempts: 3 },
54
+ user_visible: { age: 3.minutes, run_time: 90.seconds, attempts: 5 },
55
+ eventual: { age: 1.5.hours, run_time: 5.minutes, attempts: 8 },
56
+ reporting: { age: 4.hours, run_time: 10.minutes, attempts: 8 },
57
+ }.freeze
58
+
59
+ class << self
60
+ def names
61
+ @names || default_names
62
+ end
63
+
64
+ def alerts
65
+ @alerts || default_alerts
66
+ end
67
+
68
+ def names=(names)
69
+ raise "must include a name for priority >= 0" if names && !names.value?(0)
70
+
71
+ @ranges = nil
72
+ @alerts = nil
73
+ @names = names&.sort_by(&:last)&.to_h&.transform_values { |v| new(v) }
74
+ end
75
+
76
+ def alerts=(alerts)
77
+ if alerts
78
+ unknown_names = alerts.keys - names.keys
79
+ raise "unknown priority name(s): #{unknown_names}" if unknown_names.any?
80
+ end
81
+
82
+ @alerts = alerts&.sort_by { |k, _| names.keys.index(k) }&.to_h
83
+ end
84
+
85
+ def ranges
86
+ @ranges ||= names.zip(names.except(names.keys.first)).each_with_object({}) do |((name, lower), (_, upper)), obj|
87
+ obj[name] = (lower...(upper || Float::INFINITY))
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def default_names
94
+ @default_names ||= DEFAULT_NAMES.transform_values { |v| new(v) }
95
+ end
96
+
97
+ def default_alerts
98
+ @names ? {} : DEFAULT_ALERTS
99
+ end
100
+
101
+ def respond_to_missing?(method_name, include_private = false)
102
+ names.key?(method_name) || super
103
+ end
104
+
105
+ def method_missing(method_name, *args)
106
+ if names.key?(method_name) && args.none?
107
+ names[method_name]
108
+ else
109
+ super
110
+ end
111
+ end
112
+ end
113
+
114
+ attr_reader :value
115
+
116
+ delegate :to_i, to: :value
117
+ delegate :to_s, to: :name
118
+
119
+ def initialize(value)
120
+ super()
121
+ value = self.class.names[value] if value.is_a?(Symbol)
122
+ @value = value.to_i
123
+ end
124
+
125
+ def name
126
+ @name ||= self.class.ranges.find { |(_, r)| r.include?(to_i) }&.first
127
+ end
128
+
129
+ def alert_age
130
+ self.class.alerts.dig(name, :age)
131
+ end
132
+
133
+ def alert_run_time
134
+ self.class.alerts.dig(name, :run_time)
135
+ end
136
+
137
+ def alert_attempts
138
+ self.class.alerts.dig(name, :attempts)
139
+ end
140
+
141
+ def coerce(other)
142
+ [self.class.new(other), self]
143
+ end
144
+
145
+ def <=>(other)
146
+ other = other.to_i if other.is_a?(self.class)
147
+ to_i <=> other
148
+ end
149
+
150
+ private
151
+
152
+ def respond_to_missing?(method_name, include_private = false)
153
+ method_name.to_s.end_with?('?') && self.class.names.key?(method_name.to_s[0..-2].to_sym) || super
154
+ end
155
+
156
+ def method_missing(method_name, *args)
157
+ if method_name.to_s.end_with?('?') && self.class.names.key?(method_name.to_s[0..-2].to_sym)
158
+ method_name.to_s[0..-2] == to_s
159
+ else
160
+ super
161
+ end
162
+ end
163
+ end
164
+ end