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 +4 -4
- data/README.md +55 -6
- data/app/models/delayed/limit.rb +23 -0
- 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/limit_spec.rb +101 -0
- 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: 7af8a042ab9fe6f3e5bb5709860ab1c7544f5f13124b02cb5d181b9afa378b6d
|
|
4
|
+
data.tar.gz: c76388de277c68702a51aa8379b83902e9f339ec0444b41e67b663a7d6db76b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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")
|
|
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
|
|
@@ -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
|
data/app/models/delayed/limit.rb
CHANGED
|
@@ -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
|
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
|
data/spec/delayed/limit_spec.rb
CHANGED
|
@@ -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 }
|