delayed 4.0.1 → 4.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 +4 -4
- data/README.md +43 -6
- data/lib/delayed/monitor.rb +40 -13
- data/lib/delayed/version.rb +1 -1
- data/spec/delayed/__snapshots__/job_spec.rb.snap +2 -2
- data/spec/delayed/monitor_spec.rb +91 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70e3822f06e3d5b6a663f08998abf783ae91137a96cf154dded1487548ab9544
|
|
4
|
+
data.tar.gz: e166f6ba77ff5e268059dc32de8e72e619b56106e69a96b884c1c5e31e22542a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 86379222dec057f27b106602cc91ce90800861f705e16fb53857590f6b730a6e523780be0eeec09c6a9dd8d9f9b1760b0c15793c73fcfac5173bf2ba17354f4b
|
|
7
|
+
data.tar.gz: 4ce59e9dbec2d10339cf3979ff5c49a97adc337fba6119d69662e65ff2407f8fc1ab32cac09ab3ad83044e795384f2c36ae478fa5763a3578b87178cdb624123
|
data/README.md
CHANGED
|
@@ -416,14 +416,16 @@ QUEUE=tracking rake delayed:monitor
|
|
|
416
416
|
QUEUES=mailers,tasks rake delayed:monitor
|
|
417
417
|
```
|
|
418
418
|
|
|
419
|
-
The following events will be emitted, grouped by priority name (e.g. "interactive")
|
|
420
|
-
and the
|
|
421
|
-
|
|
422
|
-
|
|
419
|
+
The following events will be emitted, grouped by priority name (e.g. "interactive"), queue name,
|
|
420
|
+
and the values of any columns configured via [tag_columns](#tagging-metrics-with-additional-columns) (none, by default). The metric's
|
|
421
|
+
"`:value`" will be available in the event's payload. **This means that there will be one value
|
|
422
|
+
_per_ unique combination of queue, priority, and tag column values**, and totals must be computed
|
|
423
|
+
via downstream aggregation (e.g. as a StatsD "gauge" metric, summed or maxed by tag).
|
|
423
424
|
|
|
424
425
|
- **delayed.job.count** - the total number of jobs
|
|
425
426
|
- **delayed.job.future_count** - jobs where run_at is in the future
|
|
426
427
|
- **delayed.job.working_count** - jobs that are currently being worked off (excludes failed jobs)
|
|
428
|
+
- **delayed.job.locked_count** - jobs that are currently locked by a worker (equivalent to working_count)
|
|
427
429
|
- **delayed.job.workable_count** - jobs that are waiting to be worked off
|
|
428
430
|
- **delayed.job.erroring_count** - jobs where attempts > 0
|
|
429
431
|
- **delayed.job.failed_count** - jobs where failed_at is not nil
|
|
@@ -432,7 +434,8 @@ downstream aggregation (e.g. as a StatsD "gauge" metric).
|
|
|
432
434
|
|
|
433
435
|
An additional _experimental_ metric is available, intended for use with application autoscaling:
|
|
434
436
|
|
|
435
|
-
- **delayed.job.alert_age_percent** - the _percent_ to which the oldest job has reached the "age alert"
|
|
437
|
+
- **delayed.job.alert_age_percent** - the _percent_ to which the oldest job has reached the "age alert"
|
|
438
|
+
threshold. (See the [Alerting Threshholds](#priority-based-alerting-threshholds) section above.)
|
|
436
439
|
|
|
437
440
|
All of these events may be subscribed to via a single regular expression (again, in your application
|
|
438
441
|
config or in an initializer):
|
|
@@ -448,7 +451,7 @@ ActiveSupport::Notifications.subscribe(/delayed\.job\..*_(count|age|percent)/) d
|
|
|
448
451
|
end
|
|
449
452
|
```
|
|
450
453
|
|
|
451
|
-
Additionally, the monitor process
|
|
454
|
+
Additionally, the monitor process will emit a **delayed.monitor.run** event with a duration
|
|
452
455
|
attached, so that you can monitor the time it takes to emit these aggregate metrics.
|
|
453
456
|
|
|
454
457
|
```ruby
|
|
@@ -458,6 +461,40 @@ ActiveSupport::Notifications.subscribe('delayed.monitor.run') do |*args|
|
|
|
458
461
|
end
|
|
459
462
|
```
|
|
460
463
|
|
|
464
|
+
#### Tagging metrics with additional columns
|
|
465
|
+
|
|
466
|
+
By default, the monitor only groups events by `priority` and `queue`. To add additional columns
|
|
467
|
+
to the query's `GROUP BY` clause, declare them in an initializer config:
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
Delayed::Monitor.tag_columns = %i(name owner)
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Tagged series are only emitted while matching jobs exist, and an untagged zero is always emitted
|
|
474
|
+
per (priority, queue) as a baseline. With `Delayed::Monitor.tag_columns = %i(name)` and one
|
|
475
|
+
enqueued job, `delayed.job.count` emits:
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
{ priority: 'interactive', queue: 'default', name: 'SimpleJob', value: 1 }
|
|
479
|
+
{ priority: 'interactive', queue: 'default', value: 0 }
|
|
480
|
+
{ priority: 'user_visible', queue: 'default', value: 0 }
|
|
481
|
+
{ priority: 'eventual', queue: 'default', value: 0 }
|
|
482
|
+
{ priority: 'reporting', queue: 'default', value: 0 }
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Tag columns must already exist on the jobs table — the monitor validates this at startup and
|
|
486
|
+
raises an `ArgumentError` if any are missing, so migrate a new column before adding it here.
|
|
487
|
+
|
|
488
|
+
`NULL` values are emitted as `nil` tags. Your notification subscriber can decide how to represent
|
|
489
|
+
these. Expect `nil`s for jobs enqueued before a newly added column was populated.
|
|
490
|
+
|
|
491
|
+
**Avoid** choosing high-cardinality columns like `id` as this will result in very poor query
|
|
492
|
+
performance (and may essentially turn every row into its own metric result!). Prefer low-cardinality
|
|
493
|
+
columns like `name` (the name of the job class) that are worth the query performance trade-off.
|
|
494
|
+
|
|
495
|
+
**You are strongly encouraged to add new indexes** incorporating those columns. When adding `tag_columns`,
|
|
496
|
+
`idx_delayed_jobs_live` and `idx_delayed_jobs_failed` will no longer fully cover the monitor's queries.
|
|
497
|
+
|
|
461
498
|
## Configuration
|
|
462
499
|
|
|
463
500
|
`Delayed` is highly configurable, but ships with opinionated defaults. If you need to change any
|
data/lib/delayed/monitor.rb
CHANGED
|
@@ -17,7 +17,17 @@ module Delayed
|
|
|
17
17
|
|
|
18
18
|
cattr_accessor :sleep_delay, instance_writer: false, default: 60
|
|
19
19
|
|
|
20
|
+
def self.tag_columns
|
|
21
|
+
@tag_columns ||= [].freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.tag_columns=(columns)
|
|
25
|
+
@tag_columns = columns.map(&:to_sym).freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
20
28
|
def initialize
|
|
29
|
+
validate_tag_columns!
|
|
30
|
+
@tag_columns = self.class.tag_columns
|
|
21
31
|
@jobs = Job.group(:priority, :queue)
|
|
22
32
|
@jobs = @jobs.where(queue: Worker.queues) if Worker.queues.any?
|
|
23
33
|
@memo = {}
|
|
@@ -64,13 +74,23 @@ module Delayed
|
|
|
64
74
|
|
|
65
75
|
private
|
|
66
76
|
|
|
67
|
-
attr_reader :jobs
|
|
77
|
+
attr_reader :jobs, :tag_columns
|
|
78
|
+
|
|
79
|
+
def validate_tag_columns!
|
|
80
|
+
if self.class.tag_columns.any? { |column| Job.column_names.exclude?(column.to_s) }
|
|
81
|
+
raise ArgumentError, "Delayed::Monitor.tag_columns includes columns missing from #{Job.table_name}. " \
|
|
82
|
+
"Available columns: #{Job.column_names.join(', ')}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
68
85
|
|
|
69
86
|
def emit_metric!(metric)
|
|
70
|
-
query_for(metric)
|
|
87
|
+
query_for(metric)
|
|
88
|
+
.merge!(default_results) { |_key, existing, _default| existing }
|
|
89
|
+
.each do |(priority, queue, *column_values), value|
|
|
90
|
+
tags = column_values.zip(tag_columns).to_h { |val, column| [column, val] }
|
|
71
91
|
ActiveSupport::Notifications.instrument(
|
|
72
92
|
"delayed.job.#{metric}",
|
|
73
|
-
default_tags.merge(priority: Priority.new(priority).to_s, queue: queue, value: value),
|
|
93
|
+
default_tags.merge(priority: Priority.new(priority).to_s, queue: queue, **tags, value: value),
|
|
74
94
|
)
|
|
75
95
|
end
|
|
76
96
|
end
|
|
@@ -96,24 +116,30 @@ module Delayed
|
|
|
96
116
|
end
|
|
97
117
|
|
|
98
118
|
# This method generates a query that scans the specified scope, groups by
|
|
99
|
-
# priority and queue, and calculates the
|
|
100
|
-
# query is executed for priority bucketing
|
|
101
|
-
# avoid running these computations for each
|
|
119
|
+
# priority and queue (plus any tag_columns), and calculates the
|
|
120
|
+
# specified aggregates. An outer query is executed for priority bucketing
|
|
121
|
+
# and appending db_now_utc (to avoid running these computations for each
|
|
122
|
+
# tuple in the inner query).
|
|
102
123
|
def grouped_query(scope, include_db_time: false, **kwargs)
|
|
103
124
|
inner_selects = kwargs.map { |key, (agg, expr)| as_expression(agg, expr, key) }
|
|
104
125
|
outer_selects = kwargs.map { |key, (agg, _)| as_expression(agg == :count ? :sum : agg, key, key) }
|
|
105
126
|
outer_selects << "#{self.class.sql_now_in_utc} AS db_now_utc" if include_db_time
|
|
106
127
|
|
|
107
128
|
Delayed::Job
|
|
108
|
-
.from(scope.select(:priority, :queue, *inner_selects).group(:priority, :queue))
|
|
109
|
-
.group(priority_case_statement, :queue).select(
|
|
129
|
+
.from(scope.select(:priority, :queue, *tag_columns, *inner_selects).group(:priority, :queue, *tag_columns))
|
|
130
|
+
.group(priority_case_statement, :queue, *tag_columns).select(
|
|
110
131
|
*outer_selects,
|
|
111
132
|
"#{priority_case_statement} AS priority",
|
|
112
133
|
'queue AS queue',
|
|
113
|
-
|
|
134
|
+
*tag_columns,
|
|
135
|
+
).group_by { |j| result_key(j) }
|
|
114
136
|
.transform_values(&:first)
|
|
115
137
|
end
|
|
116
138
|
|
|
139
|
+
def result_key(record)
|
|
140
|
+
[record.priority.to_i, record.queue, *tag_columns.map { |column| record[column] }]
|
|
141
|
+
end
|
|
142
|
+
|
|
117
143
|
def as_expression(aggregate_function, aggregate_expression, column_name)
|
|
118
144
|
"#{aggregate_function.to_s.upcase}(#{aggregate_expression}) AS #{column_name}"
|
|
119
145
|
end
|
|
@@ -151,10 +177,10 @@ module Delayed
|
|
|
151
177
|
end
|
|
152
178
|
|
|
153
179
|
def alert_age_percent_grouped
|
|
154
|
-
pending_counts.each_with_object({}) do |(
|
|
180
|
+
pending_counts.each_with_object({}) do |(key, j), metrics|
|
|
155
181
|
max_age = time_ago(db_now(j), j.run_at)
|
|
156
|
-
alert_age = Priority.new(
|
|
157
|
-
metrics[
|
|
182
|
+
alert_age = Priority.new(key.first).alert_age
|
|
183
|
+
metrics[key] = [max_age / alert_age * 100, 100].min if alert_age
|
|
158
184
|
end
|
|
159
185
|
end
|
|
160
186
|
|
|
@@ -193,7 +219,8 @@ module Delayed
|
|
|
193
219
|
end
|
|
194
220
|
|
|
195
221
|
def failed_counts
|
|
196
|
-
@memo[:failed_counts] ||=
|
|
222
|
+
@memo[:failed_counts] ||=
|
|
223
|
+
grouped_query(jobs.failed, count: [:count, '*'])
|
|
197
224
|
end
|
|
198
225
|
|
|
199
226
|
def db_now(record)
|
data/lib/delayed/version.rb
CHANGED
|
@@ -53,8 +53,8 @@ SNAP
|
|
|
53
53
|
snapshots["generates a postgresql query plan for multiple queues 1"] = <<-SNAP
|
|
54
54
|
Index Scan using idx_delayed_jobs_live on public.delayed_jobs (cost=...)
|
|
55
55
|
Output: id, priority, attempts, handler, last_error, run_at, locked_at, failed_at, locked_by, queue, created_at, updated_at, name
|
|
56
|
-
Index Cond: (delayed_jobs.run_at <= '2025-11-10 17:20:13'::timestamp without time zone)
|
|
57
|
-
Filter: ((
|
|
56
|
+
Index Cond: ((delayed_jobs.run_at <= '2025-11-10 17:20:13'::timestamp without time zone) AND ((delayed_jobs.queue)::text = ANY ('{default,mailers,tracking}'::text[])))
|
|
57
|
+
Filter: ((delayed_jobs.locked_at IS NULL) OR (delayed_jobs.locked_at < '2025-11-10 16:59:43'::timestamp without time zone) OR (((delayed_jobs.locked_by)::text = 'worker1'::text) AND (delayed_jobs.locked_at >= '2025-11-10 16:59:43'::timestamp without time zone)))
|
|
58
58
|
SNAP
|
|
59
59
|
|
|
60
60
|
snapshots["[legacy index] generates the expected postgresql query plan 1"] = <<-SNAP
|
|
@@ -14,6 +14,12 @@ RSpec.describe Delayed::Monitor do
|
|
|
14
14
|
}
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
describe '.tag_columns' do
|
|
18
|
+
it 'defaults to no tag columns' do
|
|
19
|
+
expect(described_class.tag_columns).to eq []
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
17
23
|
describe '#run!' do
|
|
18
24
|
let(:app_local_db_time) { false }
|
|
19
25
|
|
|
@@ -201,6 +207,91 @@ RSpec.describe Delayed::Monitor do
|
|
|
201
207
|
.and emit_notification("delayed.job.max_age").with_payload(p30_payload.merge(queue: 'banana')).approximately.with_value(4.hours)
|
|
202
208
|
end
|
|
203
209
|
|
|
210
|
+
context 'when tag_columns is set to name' do
|
|
211
|
+
around do |example|
|
|
212
|
+
described_class.tag_columns = %i(name)
|
|
213
|
+
example.run
|
|
214
|
+
ensure
|
|
215
|
+
described_class.tag_columns = []
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
let(:named_payload) { p0_payload.merge(name: 'SimpleJob') }
|
|
219
|
+
|
|
220
|
+
it 'tags each series with the job name' do
|
|
221
|
+
expect { subject.run! }
|
|
222
|
+
.to emit_notification("delayed.job.max_age").with_payload(named_payload).approximately.with_value(30.seconds)
|
|
223
|
+
.and emit_notification("delayed.job.failed_count").with_payload(named_payload).with_value(1)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
context 'when multiple job names share a priority and queue' do
|
|
227
|
+
let!(:other_named_job) { Delayed::Job.create! p0_attributes.merge(name: 'OtherJob', run_at: now - 10.minutes) }
|
|
228
|
+
|
|
229
|
+
it 'emits a separate series per name' do
|
|
230
|
+
expect { subject.run! }
|
|
231
|
+
.to emit_notification("delayed.job.max_age").with_payload(named_payload).approximately.with_value(30.seconds)
|
|
232
|
+
.and emit_notification("delayed.job.max_age").with_payload(named_payload.merge(name: 'OtherJob')).approximately.with_value(10.minutes)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
context 'when a job predates the name column' do
|
|
237
|
+
around do |example|
|
|
238
|
+
ValidateRunAtAndNameNotNull.migrate(:down)
|
|
239
|
+
AddRunAtAndNameNotNullCheck.migrate(:down)
|
|
240
|
+
example.run
|
|
241
|
+
ensure
|
|
242
|
+
Delayed::Job.delete_all
|
|
243
|
+
AddRunAtAndNameNotNullCheck.migrate(:up)
|
|
244
|
+
ValidateRunAtAndNameNotNull.migrate(:up)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
let!(:unnamed_job) { Delayed::Job.create! p0_attributes.merge(name: nil, run_at: now - 10.minutes) }
|
|
248
|
+
|
|
249
|
+
it 'emits metrics with a nil name' do
|
|
250
|
+
expect { subject.run! }
|
|
251
|
+
.to emit_notification("delayed.job.max_age").with_payload(named_payload.merge(name: nil)).approximately.with_value(10.minutes)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
context 'when tag_columns includes a custom column' do
|
|
257
|
+
around do |example|
|
|
258
|
+
Delayed::Job.connection.add_column :delayed_jobs, :owner, :string
|
|
259
|
+
Delayed::Job.reset_column_information
|
|
260
|
+
described_class.tag_columns = %i(name owner)
|
|
261
|
+
example.run
|
|
262
|
+
ensure
|
|
263
|
+
described_class.tag_columns = []
|
|
264
|
+
Delayed::Job.connection.remove_column :delayed_jobs, :owner
|
|
265
|
+
Delayed::Job.reset_column_information
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
let(:named_payload) { p0_payload.merge(name: 'SimpleJob') }
|
|
269
|
+
let!(:team_a_job) { Delayed::Job.create! p0_attributes.merge(owner: 'team_a', run_at: now - 10.minutes) }
|
|
270
|
+
let!(:team_b_job) { Delayed::Job.create! p0_attributes.merge(owner: 'team_b', run_at: now - 20.minutes) }
|
|
271
|
+
|
|
272
|
+
it "tags each series with the column's value, passing NULLs through as nil" do
|
|
273
|
+
expect { subject.run! }
|
|
274
|
+
.to emit_notification("delayed.job.max_age").with_payload(named_payload.merge(owner: 'team_a')).approximately.with_value(10.minutes)
|
|
275
|
+
.and emit_notification("delayed.job.max_age").with_payload(named_payload.merge(owner: 'team_b')).approximately.with_value(20.minutes)
|
|
276
|
+
.and emit_notification("delayed.job.max_age").with_payload(named_payload.merge(owner: nil)).approximately.with_value(30.seconds)
|
|
277
|
+
.and emit_notification("delayed.job.failed_count").with_payload(named_payload.merge(owner: nil)).with_value(1)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
context 'when tag_columns names a column that does not exist' do
|
|
282
|
+
around do |example|
|
|
283
|
+
described_class.tag_columns = %i(name missing_column)
|
|
284
|
+
example.run
|
|
285
|
+
ensure
|
|
286
|
+
described_class.tag_columns = []
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it 'raises loudly at monitor startup rather than skipping the column' do
|
|
290
|
+
expect { described_class.new }
|
|
291
|
+
.to raise_error(ArgumentError, /tag_columns includes columns missing from delayed_jobs\. Available columns: .*\bname\b/)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
204
295
|
context 'when named priorities are customized' do
|
|
205
296
|
around do |example|
|
|
206
297
|
Delayed::Priority.names = { high: 0, low: 20 }
|