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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4227961278ab7f1ed435e8357cbf83998ee6d1d597f981fc4d1ab92a0d1069fa
4
- data.tar.gz: fd108cb072de3ec495d2713326821b231cfe8ffa153782a80f84da06d272ecfa
3
+ metadata.gz: 6d365ce7fa45762c6122da4a21a7c96e30893c15534f76bb26cbb4e3f721f7c6
4
+ data.tar.gz: ca6deb755821f865e354a4ff62375d7c918c81e0b9b4be5f665aa9a347ee90e9
5
5
  SHA512:
6
- metadata.gz: 36fd83967d57e3ae7d58a2edfceb790d35bcf3f9a49a9bf3de9e34c15528ba2c3ae36468c7847e8e49d7d90ca8b03ef1e39a62620257fb06cb8f321a5af0909e
7
- data.tar.gz: df0443f6ca1f859e5b4155a0b38aa72310e0607f57c30ba31dcd0fda39825e0ae81903d0b019886c7405e607d1817f3733c47fbfed81b25cb897440cb2be8da8
6
+ metadata.gz: 936fabfd27c2ae4ee68c5f999f8b10e778a171b7f438e191a6bdadfc4c3b16da455e5f1620ff0044fc7efa257308fe0679fc74ec879342d0c5c5a248a6d657d0
7
+ data.tar.gz: 5cb3cdb9e392ed6ae999d768fe910d0587fa1c5cb652cd5a2cd2f5ce2fc88a8598c105a199e831e0c3185af47cc310e82ada88bc862862fe4c9ebd291166a958
@@ -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(arel_table[:attempts].gt(0)).merge(unscoped.live) }
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(arel_table[:run_at].gt(as_of)) }
18
- scope :pending, ->(as_of = db_time_now) { merge(unscoped.live).where(arel_table[:run_at].lteq(as_of)) }
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(arel_table[:locked_at].gteq(db_time_now - lock_timeout))
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(locked_at: nil)
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,
@@ -14,4 +14,6 @@ module Delayed
14
14
  class FatalBackendError < RuntimeError; end
15
15
 
16
16
  class DeserializationError < StandardError; end
17
+
18
+ class StaleEnqueueError < StandardError; end
17
19
  end
@@ -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
- def grouped_count(scope)
72
- Delayed::Job.from(scope.select('priority, queue, COUNT(*) AS count'))
73
- .group(priority_case_statement, :queue).sum(:count)
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 grouped_min(scope, column)
77
- Delayed::Job.from(scope.select("priority, queue, MIN(#{column}) AS #{column}"))
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
- if Job.connection.supports_partial_index?
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
- grouped_count(jobs.live)
126
+ live_counts.transform_values(&:count)
91
127
  end
92
128
 
93
129
  def future_count_grouped
94
- grouped_count(jobs.future)
130
+ live_counts.transform_values(&:future_count)
95
131
  end
96
132
 
97
- def locked_count_grouped
98
- @memo[:locked_count_grouped] ||= grouped_count(jobs.claimed)
133
+ def erroring_count_grouped
134
+ live_counts.transform_values(&:erroring_count)
99
135
  end
100
136
 
101
- def erroring_count_grouped
102
- grouped_count(jobs.erroring)
137
+ def locked_count_grouped
138
+ pending_counts.transform_values(&:claimed_count)
103
139
  end
104
140
 
105
141
  def failed_count_grouped
106
- @memo[:failed_count_grouped] ||= grouped_count(jobs.failed)
142
+ failed_counts.transform_values(&:count)
107
143
  end
108
144
 
109
145
  def max_lock_age_grouped
110
- oldest_locked_job_grouped.transform_values { |locked_at| Job.db_time_now - locked_at }
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
- oldest_workable_job_grouped.transform_values { |run_at| Job.db_time_now - run_at }
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
- oldest_workable_job_grouped.each_with_object({}) do |((priority, queue), run_at), metrics|
119
- max_age = Job.db_time_now - run_at
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
- grouped_count(jobs.claimable)
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
- grouped_min(jobs.claimed, :locked_at)
168
+ pending_counts.transform_values(&:locked_at).compact
133
169
  end
134
170
 
135
171
  def oldest_workable_job_grouped
136
- @memo[:oldest_workable_job_grouped] ||= grouped_min(jobs.claimable, :run_at)
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
- ("AND priority < #{range.last.to_i}" unless range.last.infinite?),
146
- "THEN #{range.first.to_i}",
147
- ].compact
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 < 6.1 overrides eager_load to 'false' inside of rake tasks, which is not ideal in production environments.
14
- # Additionally, the classic Rails autoloader is not threadsafe, so we do not want any autoloading after we start the worker.
15
- # While the zeitwork autoloader technically does not need this workaround, we will still eager load for consistency's sake.
16
- # We will use the cache_classes config as a proxy for determining if we should eager load before booting workers.
17
- if !Rails.application.config.respond_to?(:rake_eager_load) && Rails.application.config.cache_classes
18
- Rails.application.config.eager_load = true
19
- Rails::Application::Finisher.initializers
20
- .find { |i| i.name == :eager_load! }
21
- .bind(Rails.application)
22
- .run
23
- end
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'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Delayed
4
- VERSION = '2.0.3'
4
+ VERSION = '2.2.0'
5
5
  end
@@ -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