delayed 2.0.3 → 2.2.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 +4 -4
- data/app/models/delayed/job.rb +26 -10
- data/lib/delayed/backend/job_preparer.rb +13 -0
- data/lib/delayed/exceptions.rb +2 -0
- data/lib/delayed/monitor.rb +103 -30
- data/lib/delayed/tasks.rb +31 -11
- data/lib/delayed/version.rb +1 -1
- data/lib/delayed/worker.rb +2 -0
- data/spec/delayed/__snapshots__/monitor_spec.rb.snap +447 -1170
- data/spec/delayed/job_spec.rb +37 -6
- data/spec/delayed/monitor_spec.rb +309 -240
- data/spec/helper.rb +24 -13
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d365ce7fa45762c6122da4a21a7c96e30893c15534f76bb26cbb4e3f721f7c6
|
|
4
|
+
data.tar.gz: ca6deb755821f865e354a4ff62375d7c918c81e0b9b4be5f665aa9a347ee90e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 936fabfd27c2ae4ee68c5f999f8b10e778a171b7f438e191a6bdadfc4c3b16da455e5f1620ff0044fc7efa257308fe0679fc74ec879342d0c5c5a248a6d657d0
|
|
7
|
+
data.tar.gz: 5cb3cdb9e392ed6ae999d768fe910d0587fa1c5cb652cd5a2cd2f5ce2fc88a8598c105a199e831e0c3185af47cc310e82ada88bc862862fe4c9ebd291166a958
|
data/app/models/delayed/job.rb
CHANGED
|
@@ -10,26 +10,22 @@ module Delayed
|
|
|
10
10
|
|
|
11
11
|
# high-level queue states (live => erroring => failed)
|
|
12
12
|
scope :live, -> { where(failed_at: nil) }
|
|
13
|
-
scope :erroring, -> { where(
|
|
13
|
+
scope :erroring, -> { where(erroring_clause).merge(unscoped.live) }
|
|
14
14
|
scope :failed, -> { where.not(failed_at: nil) }
|
|
15
15
|
|
|
16
16
|
# live queue states (future vs pending)
|
|
17
|
-
scope :future, ->(as_of = db_time_now) { merge(unscoped.live).where(
|
|
18
|
-
scope :pending, ->(as_of = db_time_now) { merge(unscoped.live).where(
|
|
17
|
+
scope :future, ->(as_of = db_time_now) { merge(unscoped.live).where(future_clause(as_of)) }
|
|
18
|
+
scope :pending, ->(as_of = db_time_now) { merge(unscoped.live).where(pending_clause(as_of)) }
|
|
19
19
|
|
|
20
20
|
# pending queue states (claimed vs claimable)
|
|
21
21
|
scope :claimed, ->(as_of = db_time_now) {
|
|
22
|
-
where(
|
|
23
|
-
.merge(unscoped.pending(as_of))
|
|
22
|
+
where(claimed_clause(as_of)).merge(unscoped.pending(as_of))
|
|
24
23
|
}
|
|
25
24
|
scope :claimed_by, ->(worker, as_of = db_time_now) {
|
|
26
|
-
where(locked_by: worker.name)
|
|
27
|
-
.claimed(as_of)
|
|
25
|
+
where(locked_by: worker.name).claimed(as_of)
|
|
28
26
|
}
|
|
29
27
|
scope :claimable, ->(as_of = db_time_now) {
|
|
30
|
-
where(
|
|
31
|
-
.or(where(arel_table[:locked_at].lt(db_time_now - lock_timeout)))
|
|
32
|
-
.merge(unscoped.pending(as_of))
|
|
28
|
+
where(claimable_clause(as_of)).merge(unscoped.pending(as_of))
|
|
33
29
|
}
|
|
34
30
|
scope :claimable_by, ->(worker, as_of = db_time_now) {
|
|
35
31
|
claimable(as_of)
|
|
@@ -40,6 +36,26 @@ module Delayed
|
|
|
40
36
|
.by_priority
|
|
41
37
|
}
|
|
42
38
|
|
|
39
|
+
def self.erroring_clause
|
|
40
|
+
arel_table[:attempts].gt(0)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.future_clause(as_of = db_time_now)
|
|
44
|
+
arel_table[:run_at].gt(as_of)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.pending_clause(as_of = db_time_now)
|
|
48
|
+
arel_table[:run_at].lteq(as_of)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.claimed_clause(as_of = db_time_now)
|
|
52
|
+
arel_table[:locked_at].gteq(as_of - lock_timeout)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.claimable_clause(as_of = db_time_now)
|
|
56
|
+
arel_table[:locked_at].eq(nil).or arel_table[:locked_at].lt(as_of - lock_timeout)
|
|
57
|
+
end
|
|
58
|
+
|
|
43
59
|
before_save :set_default_run_at, :set_name
|
|
44
60
|
|
|
45
61
|
REENQUEUE_BUFFER = 30.seconds
|
|
@@ -13,6 +13,7 @@ module Delayed
|
|
|
13
13
|
set_queue_name
|
|
14
14
|
set_priority
|
|
15
15
|
handle_dst
|
|
16
|
+
reject_stale_run_at
|
|
16
17
|
handle_deprecation
|
|
17
18
|
options
|
|
18
19
|
end
|
|
@@ -51,6 +52,18 @@ module Delayed
|
|
|
51
52
|
end
|
|
52
53
|
end
|
|
53
54
|
|
|
55
|
+
def reject_stale_run_at
|
|
56
|
+
return unless Delayed::Worker.deny_stale_enqueues
|
|
57
|
+
return unless options[:run_at]
|
|
58
|
+
|
|
59
|
+
threshold = Job.db_time_now - Job.lock_timeout
|
|
60
|
+
return unless options[:run_at] < threshold
|
|
61
|
+
|
|
62
|
+
raise StaleEnqueueError,
|
|
63
|
+
"Cannot enqueue a job in the distant past (run_at: #{options[:run_at].iso8601}," \
|
|
64
|
+
" threshold: #{threshold.iso8601}). This is usually a bug."
|
|
65
|
+
end
|
|
66
|
+
|
|
54
67
|
def handle_deprecation
|
|
55
68
|
unless options[:payload_object].respond_to?(:perform)
|
|
56
69
|
raise ArgumentError,
|
data/lib/delayed/exceptions.rb
CHANGED
data/lib/delayed/monitor.rb
CHANGED
|
@@ -35,6 +35,33 @@ module Delayed
|
|
|
35
35
|
send(:"#{metric}_grouped")
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def self.sql_now_in_utc
|
|
39
|
+
case ActiveRecord::Base.connection.adapter_name
|
|
40
|
+
when 'PostgreSQL'
|
|
41
|
+
"TIMEZONE('UTC', STATEMENT_TIMESTAMP())"
|
|
42
|
+
when 'MySQL', 'Mysql2'
|
|
43
|
+
"UTC_TIMESTAMP()"
|
|
44
|
+
else
|
|
45
|
+
"CURRENT_TIMESTAMP"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.parse_utc_time(string)
|
|
50
|
+
# Depending on Rails version & DB adapter, this will be either a String or a DateTime.
|
|
51
|
+
# If it's a DateTime, and if connection is running with the `:local` time zone config,
|
|
52
|
+
# then by default Rails incorrectly assumes it's in local time instead of UTC.
|
|
53
|
+
# We use `strftime` to strip the encoded TZ info and re-parse it as UTC.
|
|
54
|
+
#
|
|
55
|
+
# Example:
|
|
56
|
+
# - "2026-02-05 10:01:23" -> DB-returned string
|
|
57
|
+
# - "2026-02-05 10:01:23 -0600" -> Rails-parsed DateTime with incorrect TZ
|
|
58
|
+
# - "2026-02-05 10:01:23" -> `strftime` output
|
|
59
|
+
# - "2026-02-05 04:01:23 -0600" -> Re-parsed as UTC and converted to local time
|
|
60
|
+
string = string.strftime('%Y-%m-%d %H:%M:%S') if string.respond_to?(:strftime)
|
|
61
|
+
|
|
62
|
+
ActiveSupport::TimeZone.new("UTC").parse(string)
|
|
63
|
+
end
|
|
64
|
+
|
|
38
65
|
private
|
|
39
66
|
|
|
40
67
|
attr_reader :jobs
|
|
@@ -68,83 +95,129 @@ module Delayed
|
|
|
68
95
|
}
|
|
69
96
|
end
|
|
70
97
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
# This method generates a query that scans the specified scope, groups by
|
|
99
|
+
# priority and queue, and calculates the specified aggregates. An outer
|
|
100
|
+
# query is executed for priority bucketing and appending db_now_utc (to
|
|
101
|
+
# avoid running these computations for each tuple in the inner query).
|
|
102
|
+
def grouped_query(scope, include_db_time: false, **kwargs)
|
|
103
|
+
inner_selects = kwargs.map { |key, (agg, expr)| as_expression(agg, expr, key) }
|
|
104
|
+
outer_selects = kwargs.map { |key, (agg, _)| as_expression(agg == :count ? :sum : agg, key, key) }
|
|
105
|
+
outer_selects << "#{self.class.sql_now_in_utc} AS db_now_utc" if include_db_time
|
|
106
|
+
|
|
107
|
+
Delayed::Job
|
|
108
|
+
.from(scope.select(:priority, :queue, *inner_selects).group(:priority, :queue))
|
|
109
|
+
.group(priority_case_statement, :queue).select(
|
|
110
|
+
*outer_selects,
|
|
111
|
+
"#{priority_case_statement} AS priority",
|
|
112
|
+
'queue AS queue',
|
|
113
|
+
).group_by { |j| [j.priority.to_i, j.queue] }
|
|
114
|
+
.transform_values(&:first)
|
|
74
115
|
end
|
|
75
116
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
.group(priority_case_statement, :queue).minimum(column)
|
|
117
|
+
def as_expression(aggregate_function, aggregate_expression, column_name)
|
|
118
|
+
"#{aggregate_function.to_s.upcase}(#{aggregate_expression}) AS #{column_name}"
|
|
79
119
|
end
|
|
80
120
|
|
|
81
121
|
def count_grouped
|
|
82
|
-
|
|
83
|
-
failed_count_grouped.merge(live_count_grouped) { |_, l, f| l + f }
|
|
84
|
-
else
|
|
85
|
-
grouped_count(jobs)
|
|
86
|
-
end
|
|
122
|
+
failed_count_grouped.merge(live_count_grouped) { |_, l, f| l + f }
|
|
87
123
|
end
|
|
88
124
|
|
|
89
125
|
def live_count_grouped
|
|
90
|
-
|
|
126
|
+
live_counts.transform_values(&:count)
|
|
91
127
|
end
|
|
92
128
|
|
|
93
129
|
def future_count_grouped
|
|
94
|
-
|
|
130
|
+
live_counts.transform_values(&:future_count)
|
|
95
131
|
end
|
|
96
132
|
|
|
97
|
-
def
|
|
98
|
-
|
|
133
|
+
def erroring_count_grouped
|
|
134
|
+
live_counts.transform_values(&:erroring_count)
|
|
99
135
|
end
|
|
100
136
|
|
|
101
|
-
def
|
|
102
|
-
|
|
137
|
+
def locked_count_grouped
|
|
138
|
+
pending_counts.transform_values(&:claimed_count)
|
|
103
139
|
end
|
|
104
140
|
|
|
105
141
|
def failed_count_grouped
|
|
106
|
-
|
|
142
|
+
failed_counts.transform_values(&:count)
|
|
107
143
|
end
|
|
108
144
|
|
|
109
145
|
def max_lock_age_grouped
|
|
110
|
-
|
|
146
|
+
pending_counts.transform_values { |j| time_ago(db_now(j), j.locked_at) }
|
|
111
147
|
end
|
|
112
148
|
|
|
113
149
|
def max_age_grouped
|
|
114
|
-
|
|
150
|
+
live_counts.transform_values { |j| time_ago(db_now(j), j.run_at) }
|
|
115
151
|
end
|
|
116
152
|
|
|
117
153
|
def alert_age_percent_grouped
|
|
118
|
-
|
|
119
|
-
max_age =
|
|
154
|
+
live_counts.each_with_object({}) do |((priority, queue), j), metrics|
|
|
155
|
+
max_age = time_ago(db_now(j), j.run_at)
|
|
120
156
|
alert_age = Priority.new(priority).alert_age
|
|
121
157
|
metrics[[priority, queue]] = [max_age / alert_age * 100, 100].min if alert_age
|
|
122
158
|
end
|
|
123
159
|
end
|
|
124
160
|
|
|
125
161
|
def workable_count_grouped
|
|
126
|
-
|
|
162
|
+
pending_counts.transform_values(&:claimable_count)
|
|
127
163
|
end
|
|
128
164
|
|
|
129
165
|
alias working_count_grouped locked_count_grouped
|
|
130
166
|
|
|
131
167
|
def oldest_locked_job_grouped
|
|
132
|
-
|
|
168
|
+
pending_counts.transform_values(&:locked_at).compact
|
|
133
169
|
end
|
|
134
170
|
|
|
135
171
|
def oldest_workable_job_grouped
|
|
136
|
-
|
|
172
|
+
live_counts.transform_values(&:run_at).compact
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def live_counts
|
|
176
|
+
@memo[:live_counts] ||= grouped_query(
|
|
177
|
+
jobs.live,
|
|
178
|
+
include_db_time: true,
|
|
179
|
+
count: [:count, '*'],
|
|
180
|
+
future_count: [:sum, case_when(Job.future_clause.to_sql)],
|
|
181
|
+
erroring_count: [:sum, case_when(Job.erroring_clause.to_sql)],
|
|
182
|
+
run_at: [:min, case_when(Job.pending_clause.to_sql, 'run_at')],
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def pending_counts
|
|
187
|
+
@memo[:pending_counts] ||= grouped_query(
|
|
188
|
+
jobs.pending,
|
|
189
|
+
include_db_time: true,
|
|
190
|
+
claimed_count: [:sum, case_when(Job.claimed_clause.to_sql)],
|
|
191
|
+
claimable_count: [:sum, case_when(Job.claimable_clause.to_sql)],
|
|
192
|
+
locked_at: [:min, case_when(Job.claimed_clause.to_sql, 'locked_at')],
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def failed_counts
|
|
197
|
+
@memo[:failed_counts] ||= grouped_query(jobs.failed, count: [:count, '*'])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def db_now(record)
|
|
201
|
+
self.class.parse_utc_time(record.db_now_utc)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def time_ago(now, value)
|
|
205
|
+
[now - (value || now), 0].max
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def case_when(condition, true_val = 1)
|
|
209
|
+
"CASE WHEN #{condition} THEN #{true_val} ELSE #{true_val == 1 ? 0 : 'NULL'} END"
|
|
137
210
|
end
|
|
138
211
|
|
|
139
212
|
def priority_case_statement
|
|
140
213
|
[
|
|
141
214
|
'CASE',
|
|
142
215
|
Priority.ranges.values.map do |range|
|
|
143
|
-
|
|
144
|
-
"WHEN priority >= #{range.first.to_i}"
|
|
145
|
-
|
|
146
|
-
"THEN #{range.first.to_i}"
|
|
147
|
-
|
|
216
|
+
if range.last.infinite?
|
|
217
|
+
"WHEN priority >= #{range.first.to_i} THEN #{range.first.to_i}"
|
|
218
|
+
else
|
|
219
|
+
"WHEN priority < #{range.last.to_i} THEN #{range.first.to_i}"
|
|
220
|
+
end
|
|
148
221
|
end,
|
|
149
222
|
'END',
|
|
150
223
|
].flatten.join(' ')
|
data/lib/delayed/tasks.rb
CHANGED
|
@@ -10,17 +10,37 @@ namespace :delayed do
|
|
|
10
10
|
|
|
11
11
|
next unless defined?(Rails.application.config)
|
|
12
12
|
|
|
13
|
-
# By default, Rails
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
# By default, Rails wants to disable eager loading inside of `rake`
|
|
14
|
+
# commands, even if `eager_load` is set to true. This is done to speed up
|
|
15
|
+
# the boot time of rake tasks that don't need the entire application loaded.
|
|
16
|
+
#
|
|
17
|
+
# The problem is that long-lived processes like `delayed` **do** want to
|
|
18
|
+
# eager load the application before spawning any threads or forks.
|
|
19
|
+
# (Especially if in a production environment where we want full load-order
|
|
20
|
+
# parity with the `rails server` processes!)
|
|
21
|
+
#
|
|
22
|
+
# When a Rails app boots, it chooses whether to eager load based on its
|
|
23
|
+
# `eager_load` config and whether or not it was initiated by a `rake`
|
|
24
|
+
# command. If it did eager load, we don't want to eager load again, but if
|
|
25
|
+
# it was initiated by a `rake` command, it sets `eager_load` to false before
|
|
26
|
+
# the point at which `delayed` starts setting up _its_ rake environment.
|
|
27
|
+
#
|
|
28
|
+
# So we cannot rely on that config to know whether or not to eager load --
|
|
29
|
+
# instead we must make an inference:
|
|
30
|
+
# - Newer rails versions (~7.0+) have a `config.rake_eager_load` option,
|
|
31
|
+
# which tells us whether the app has already eager loaded in a `rake`
|
|
32
|
+
# context.
|
|
33
|
+
# - If `rake_eager_loading` is not defined or `false`, we will then check
|
|
34
|
+
# `cache_classes` & explicitly eager load if true.
|
|
35
|
+
|
|
36
|
+
eager_loaded = Rails.application.config.respond_to?(:rake_eager_load) && Rails.application.config.rake_eager_load
|
|
37
|
+
next if eager_loaded || !Rails.application.config.cache_classes
|
|
38
|
+
|
|
39
|
+
Rails.application.config.eager_load = true
|
|
40
|
+
Rails::Application::Finisher.initializers
|
|
41
|
+
.find { |i| i.name == :eager_load! }
|
|
42
|
+
.bind(Rails.application)
|
|
43
|
+
.run
|
|
24
44
|
end
|
|
25
45
|
|
|
26
46
|
desc 'start a delayed worker'
|
data/lib/delayed/version.rb
CHANGED
data/lib/delayed/worker.rb
CHANGED
|
@@ -22,6 +22,8 @@ module Delayed
|
|
|
22
22
|
cattr_accessor :read_ahead, instance_writer: false, default: 5
|
|
23
23
|
cattr_accessor :destroy_failed_jobs, instance_writer: false, default: false
|
|
24
24
|
|
|
25
|
+
cattr_accessor :deny_stale_enqueues, instance_writer: false, default: false
|
|
26
|
+
|
|
25
27
|
cattr_accessor :min_priority, :max_priority, instance_writer: false
|
|
26
28
|
|
|
27
29
|
# TODO: Remove this and rely on ActiveJob.queue_name when no queue is specified
|