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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2b90a867a985a3bf32841f0cadac600d9d9f076d30bb04c85845c65b0e97ba9
4
- data.tar.gz: 7b527829bd45c8f4bff4b6c62ea15ecc62aeca4461e74e2ff0b6f0c5a1800243
3
+ metadata.gz: 70e3822f06e3d5b6a663f08998abf783ae91137a96cf154dded1487548ab9544
4
+ data.tar.gz: e166f6ba77ff5e268059dc32de8e72e619b56106e69a96b884c1c5e31e22542a
5
5
  SHA512:
6
- metadata.gz: bbce505c6b79c92df2c69e56e288af6437304b8de87227058e03f31fbb9c20b6f86869c48936b170806e96469e744813e9674178d965ebdb8a4a69542da496ed
7
- data.tar.gz: e6a9976ea79803f45875cddbe96639a9d2d760374ce82b8ddd3d61b7bc594abe9fcae8f2610c4e4952c026dfaf7e613db7fabed19c3bb499d3f98c0bbc6c4a93
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") and queue name,
420
- and the metric's "`:value`" will be available in the event's payload. **This means that there will
421
- be one value _per_ unique combination of queue & priority**, and totals must be computed via
422
- downstream aggregation (e.g. as a StatsD "gauge" metric).
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" threshold. (See the [Alerting Threshholds](#priority-based-alerting-threshholds) section above.)
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 with emit a **delayed.monitor.run** event with a duration
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
@@ -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).reverse_merge(default_results).each do |(priority, queue), value|
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 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).
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
- ).group_by { |j| [j.priority.to_i, j.queue] }
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 |((priority, queue), j), metrics|
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(priority).alert_age
157
- metrics[[priority, queue]] = [max_age / alert_age * 100, 100].min if alert_age
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] ||= grouped_query(jobs.failed, count: [:count, '*'])
222
+ @memo[:failed_counts] ||=
223
+ grouped_query(jobs.failed, count: [:count, '*'])
197
224
  end
198
225
 
199
226
  def db_now(record)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Delayed
4
- VERSION = '4.0.1'
4
+ VERSION = '4.1.0'
5
5
  end
@@ -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: (((delayed_jobs.queue)::text = ANY ('{default,mailers,tracking}'::text[])) AND ((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))))
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 }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: delayed
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.1
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Griffith