delayed 4.0.1 → 4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2b90a867a985a3bf32841f0cadac600d9d9f076d30bb04c85845c65b0e97ba9
4
- data.tar.gz: 7b527829bd45c8f4bff4b6c62ea15ecc62aeca4461e74e2ff0b6f0c5a1800243
3
+ metadata.gz: 7af8a042ab9fe6f3e5bb5709860ab1c7544f5f13124b02cb5d181b9afa378b6d
4
+ data.tar.gz: c76388de277c68702a51aa8379b83902e9f339ec0444b41e67b663a7d6db76b7
5
5
  SHA512:
6
- metadata.gz: bbce505c6b79c92df2c69e56e288af6437304b8de87227058e03f31fbb9c20b6f86869c48936b170806e96469e744813e9674178d965ebdb8a4a69542da496ed
7
- data.tar.gz: e6a9976ea79803f45875cddbe96639a9d2d760374ce82b8ddd3d61b7bc594abe9fcae8f2610c4e4952c026dfaf7e613db7fabed19c3bb499d3f98c0bbc6c4a93
6
+ metadata.gz: bd7129db0460a29f3000c630a47b3801b4b57e42de65ac12b3c719e670f48a1d955b7fc714f56dc3b6d269db819393ca562256a1305a25b28bcfa81775db97d8
7
+ data.tar.gz: 7b81ffcbf2eca36479c7648c8c94a878d5c3c4a2f674a94ffe08f2f6fadf570a2f0c21f997688d24e342d0296a58a9a465fdc9cac6db9c572651dfde3b6b6479
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
@@ -595,6 +632,18 @@ upserting `RETURNING` clause and database-native timestamp arithmetic. You can c
595
632
  connection at runtime with `Delayed::Limit.supported?`. (Attempting to use the limiter on an
596
633
  unsupported database will raise `Delayed::Limit::UnsupportedDatabaseError`.)
597
634
 
635
+ Additionally, the limiter **must not be called inside an open database
636
+ transaction** (it raises `Delayed::Limit::TransactionOpenError`), because the
637
+ reserved row would stay locked until the transaction commits. If it must run
638
+ inside transactions, give `Delayed::Limit` a connection pool of its own (at the
639
+ cost of up to one extra connection per thread) in a Rails initializer:
640
+
641
+ ```ruby
642
+ Rails.application.config.to_prepare do
643
+ Delayed::Limit.establish_connection(ActiveRecord::Base.connection_db_config.configuration_hash)
644
+ end
645
+ ```
646
+
598
647
  #### Traffic Shaping vs Traffic Enforcement
599
648
 
600
649
  By default, the limiter will `sleep` up to 5 seconds (or a specified `wait_timeout`) before
@@ -38,6 +38,10 @@ module Delayed
38
38
  #
39
39
  # It is **not** recommended to register limits dynamically at runtime, because
40
40
  # registered limits are cached indefinitely in memory and are not thread-safe.
41
+ #
42
+ # The limiter must not be called inside an open database transaction (it
43
+ # raises `TransactionOpenError`), because the reserved row would stay locked
44
+ # until that transaction commits.
41
45
  class Limit < ActiveRecord::Base
42
46
  self.table_name = 'delayed_limits'
43
47
 
@@ -52,6 +56,12 @@ module Delayed
52
56
  # (see `.supported?`).
53
57
  class UnsupportedDatabaseError < StandardError; end
54
58
 
59
+ # Raised when `.within_limit` is called inside an open database
60
+ # transaction. The reservation query takes a row lock that would otherwise
61
+ # be held until the transaction commits, causing lock contention to pile up
62
+ # behind the caller's (potentially long-running) work.
63
+ class TransactionOpenError < StandardError; end
64
+
55
65
  class << self
56
66
  # Used only for limits registered in advance (via `.register!`):
57
67
  def limits
@@ -115,6 +125,11 @@ module Delayed
115
125
  # e.g. for a target of 100 req/min, the drain_interval would be 0.6 sec/req.
116
126
  drain_interval = config.fetch(:per).seconds / config.fetch(:max).to_d
117
127
 
128
+ if transaction_open?
129
+ raise TransactionOpenError,
130
+ 'Delayed::Limit.within_limit cannot be called inside a database transaction '
131
+ end
132
+
118
133
  # Attempt to reserve capacity via an uncached database query:
119
134
  limit = connection.uncached do
120
135
  find_by_sql(reserve_sql(purpose, drain_interval, wait_timeout.seconds)).first
@@ -144,6 +159,14 @@ module Delayed
144
159
 
145
160
  private
146
161
 
162
+ # Checks `joinable?` rather than the connection's `transaction_open?` so
163
+ # that the per-example transaction opened by Rails' transactional tests
164
+ # (which is non-joinable) does not trip the guard, while any transaction
165
+ # opened by application code (joinable by default) does.
166
+ def transaction_open?
167
+ connection.current_transaction.joinable?
168
+ end
169
+
147
170
  # We reserve capacity by pushing a 'drained_at' timestamp forward by the
148
171
  # drain_interval, returning how long the caller must wait to avoid filling
149
172
  # the bucket beyond its capacity. (The WHERE clause significantly reduces
@@ -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.2.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
@@ -3,6 +3,8 @@
3
3
  require 'helper'
4
4
 
5
5
  RSpec::Matchers.define_negated_matcher :not_change, :change
6
+ RSpec::Matchers.define_negated_matcher :not_yield_control, :yield_control
7
+ RSpec::Matchers.define_negated_matcher :not_emit_notification, :emit_notification
6
8
 
7
9
  RSpec.describe Delayed::Limit do
8
10
  before do
@@ -128,6 +130,105 @@ RSpec.describe Delayed::Limit do
128
130
  end
129
131
  end
130
132
 
133
+ context 'when called inside a database transaction' do
134
+ it 'raises TransactionOpenError without reserving capacity or yielding' do
135
+ expect { |b| described_class.transaction { described_class.within_limit(purpose, **limit, &b) } }
136
+ .to raise_error(described_class::TransactionOpenError, /inside a database transaction/)
137
+ .and not_change { described_class.count }
138
+ .and not_yield_control
139
+ end
140
+
141
+ it 'raises before the transaction is materialized (no SQL is sent)' do
142
+ expect { described_class.transaction { described_class.within_limit(purpose, **limit) { nil } } }
143
+ .to raise_error(described_class::TransactionOpenError)
144
+ .and not_emit_notification('sql.active_record')
145
+ end
146
+ end
147
+
148
+ context 'when the only open transaction is non-joinable' do
149
+ it 'does not raise, and reserves capacity as usual' do
150
+ expect { |b| described_class.transaction(joinable: false) { described_class.within_limit(purpose, **limit, &b) } }
151
+ .to yield_control.once
152
+
153
+ expect(described_class.count).to eq(1)
154
+ end
155
+
156
+ it 'still raises when application code opens a nested transaction' do
157
+ expect { |b| described_class.transaction(joinable: false) { described_class.transaction { described_class.within_limit(purpose, **limit, &b) } } }
158
+ .to raise_error(described_class::TransactionOpenError)
159
+ .and not_yield_control
160
+ end
161
+ end
162
+
163
+ context "under Rails' transactional test harness" do
164
+ # Opens the per-example transaction the same way ActiveRecord::TestFixtures does
165
+ # (via the connection pool on Rails 7.2+, and directly on the connection before that).
166
+ around do |example|
167
+ pool = described_class.connection_pool
168
+ if pool.respond_to?(:pin_connection!)
169
+ pool.pin_connection!(true)
170
+ begin
171
+ example.run
172
+ ensure
173
+ pool.unpin_connection!
174
+ end
175
+ else
176
+ connection = described_class.connection
177
+ connection.begin_transaction(joinable: false, _lazy: false)
178
+ begin
179
+ example.run
180
+ ensure
181
+ connection.rollback_transaction
182
+ end
183
+ end
184
+ end
185
+
186
+ it 'does not raise, and reserves capacity as usual' do
187
+ expect(described_class.connection.transaction_open?).to be(true)
188
+
189
+ expect { |b| described_class.within_limit(purpose, **limit, &b) }
190
+ .to yield_control.once
191
+
192
+ expect(described_class.count).to eq(1)
193
+ end
194
+
195
+ it 'still raises when the code under test opens a transaction' do
196
+ expect { |b| described_class.transaction { described_class.within_limit(purpose, **limit, &b) } }
197
+ .to raise_error(described_class::TransactionOpenError)
198
+ .and not_yield_control
199
+ end
200
+ end
201
+
202
+ context 'when Delayed::Limit has its own connection pool' do
203
+ around do |example|
204
+ config = if ActiveRecord::Base.respond_to?(:connection_db_config)
205
+ ActiveRecord::Base.connection_db_config.configuration_hash
206
+ else
207
+ ActiveRecord::Base.connection_config
208
+ end
209
+ described_class.establish_connection(config)
210
+ example.run
211
+ ensure
212
+ described_class.remove_connection
213
+ end
214
+
215
+ it 'uses a separate pool from the application' do
216
+ expect(described_class.connection_pool).not_to equal(ActiveRecord::Base.connection_pool)
217
+ end
218
+
219
+ it 'does not treat a transaction on the application pool as an open transaction' do
220
+ expect { |b| ActiveRecord::Base.transaction { described_class.within_limit(purpose, **limit, &b) } }
221
+ .to yield_control.once
222
+ .and change { described_class.count }.by(1)
223
+ end
224
+
225
+ it 'still raises for a transaction on its own pool' do
226
+ expect { |b| described_class.transaction { described_class.within_limit(purpose, **limit, &b) } }
227
+ .to raise_error(described_class::TransactionOpenError)
228
+ .and not_yield_control
229
+ end
230
+ end
231
+
131
232
  context 'with a limit registered in advance via .register!' do
132
233
  before { allow(described_class).to receive(:limits).and_return(purpose => limit) }
133
234
 
@@ -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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Griffith