delayed 0.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.
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