delayed 3.1.0 → 4.0.1

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: 40ac1d14d7043c15029a5e563ad4e278315f50cd99d947b97ecb5205cde483fa
4
- data.tar.gz: 0e4d7ffd516267c28a2b2ab6468cfe383a5c486aaf4875ffeb92b72797af9e29
3
+ metadata.gz: a2b90a867a985a3bf32841f0cadac600d9d9f076d30bb04c85845c65b0e97ba9
4
+ data.tar.gz: 7b527829bd45c8f4bff4b6c62ea15ecc62aeca4461e74e2ff0b6f0c5a1800243
5
5
  SHA512:
6
- metadata.gz: bfa5833f65f57e02447e6ace001d8cd0be205399ab5a108f1cc2d7ef13ce749b99b557f3bd36a79a9e4cb6e0ef37bd6c11746318013c9f084626a96b2fd4b1a4
7
- data.tar.gz: 8aa12f59cbf61b68881853351cb19af8ff8b391014e3eacafe8f78e556dcf98bbde2a918fdb0b24386ea95d72d14ec43ec4a1af98db60ee297c4d31db071139a
6
+ metadata.gz: bbce505c6b79c92df2c69e56e288af6437304b8de87227058e03f31fbb9c20b6f86869c48936b170806e96469e744813e9674178d965ebdb8a4a69542da496ed
7
+ data.tar.gz: e6a9976ea79803f45875cddbe96639a9d2d760374ce82b8ddd3d61b7bc594abe9fcae8f2610c4e4952c026dfaf7e613db7fabed19c3bb499d3f98c0bbc6c4a93
data/README.md CHANGED
@@ -54,6 +54,7 @@ migration paths where possible.
54
54
  * [Priority-based Alerting Threshholds](#priority-based-alerting-threshholds)
55
55
  * [Continuous Monitoring](#continuous-monitoring)
56
56
  * [Configuration](#configuration)
57
+ * [Rate Limiting Jobs](#rate-limiting-jobs)
57
58
  * [Migrating from other ActiveJob backends](#migrating-from-other-activejob-backends)
58
59
  * [Migrating from DelayedJob](#migrating-from-delayedjob)
59
60
  * [How to Contribute](#how-to-contribute)
@@ -211,6 +212,28 @@ ActiveJob also supports the following lifecycle hooks:
211
212
  **Read more about ActiveJob usage on the [Active Job
212
213
  Basics](https://guides.rubyonrails.org/active_job_basics.html) documentation page.**
213
214
 
215
+ #### Retries: `retry_on` vs. `max_attempts`
216
+
217
+ ActiveJob and `delayed` each provide their own retry mechanisms, which can be used
218
+ together or independently:
219
+
220
+ - **`retry_on`** is ActiveJob's retry policy. Matching errors are caught by the job
221
+ itself and re-enqueued as new jobs, without triggering any of `delayed`'s backstop
222
+ behaviors (such as incrementing `attempts` or marking the job as failed). This is
223
+ useful for handling transient/expected errors and can be monitored via the
224
+ `enqueue_retry.active_job` ActiveSupport::Notification event. (See [ActiveJob's
225
+ documentation](https://guides.rubyonrails.org/v6.1/active_support_instrumentation.html#active-job)
226
+ for more details.)
227
+ - **`max_attempts`** (25 by default) is `delayed`'s backstop. Any error that escapes
228
+ the ActiveJob layer without explicit handling will be caught by `delayed`,
229
+ incrementing the job's `attempts` counter and triggering all other
230
+ `delayed`-specific behaviors (like exponential backoff, metrics/alerting, and
231
+ eventual failure). These errors should be treated as unexpected/unhandled, and may
232
+ indicate a code bug or data issue that needs to be resolved before the job can
233
+ succeed.
234
+
235
+ Importantly, `retry_on` always takes precedence over `max_attempts`, and when a
236
+ `retry_on` policy is exhausted, the job will be failed permanently (i.e. `failed_at`).
214
237
 
215
238
  ## Operational Considerations
216
239
 
@@ -270,6 +293,9 @@ corner cases more gracefully (perhaps by no-opping). When you're ready to re-run
270
293
  Delayed::Job.find(failing_job_id).update!(failed_at: nil, attempts: 0, run_at: Time.zone.now)
271
294
  ```
272
295
 
296
+ For ActiveJob-based jobs, resetting `attempts` to 0 will also restore the job's original
297
+ `retry_on` budgets, allowing any exhausted retries to start over.
298
+
273
299
  ## Monitoring Jobs & Workers
274
300
 
275
301
  `Delayed` will emit `ActiveSupport::Notification`s at various points during job and worker
@@ -531,6 +557,138 @@ Delayed.logger = Rails.logger
531
557
  Delayed.default_log_level = 'info'
532
558
  ```
533
559
 
560
+ ## Rate Limiting Jobs
561
+
562
+ The `Delayed::Limit` class provides a database-backed **concurrency limiter/optimizer** for jobs
563
+ (via a [Generic Cell Rate Algorithm](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm)
564
+ implemented in SQL+Ruby). Use it to (e.g.) stay under a third-party API's published rate limit, or
565
+ to keep from overwhelming a downstream datastore.
566
+
567
+ The recommended interface is `with_limit`, provided via the `Delayed::Limitable` module (which is
568
+ included in all `ActiveJob` classes by default):
569
+
570
+ ```ruby
571
+ class TouchesThirdPartyApiJob < ApplicationJob
572
+ with_limit :third_party_api, max: 100, per: 1.minute
573
+
574
+ def perform
575
+ # ...
576
+ end
577
+ end
578
+ ```
579
+
580
+ The limiter will then attempt to maximize throughput without exceeding the limit. If the limit would
581
+ be exceeded within a configurable timeout, the job will immediately end and enqueue a retry attempt
582
+ with polynomial backoff. Because its state lives in the database, the limit applies across every
583
+ worker and process at once.
584
+
585
+ Plain (non-ActiveJob) classes may `include Delayed::Limitable` to use `with_limit`, but they must
586
+ define their own rescheduling/lifecycle behavior for `Delayed::Limit::LimitExceededError` errors.
587
+
588
+ #### Setup
589
+
590
+ To use this feature, make sure you have a `delayed_limits` table, or run `rake
591
+ delayed:install:migrations` to add it (see [Database Setup](#database-setup)).
592
+
593
+ As of now, **only PostgreSQL and SQLite (3.35+) are supported.** The primary SQL query relies on an
594
+ upserting `RETURNING` clause and database-native timestamp arithmetic. You can check the current
595
+ connection at runtime with `Delayed::Limit.supported?`. (Attempting to use the limiter on an
596
+ unsupported database will raise `Delayed::Limit::UnsupportedDatabaseError`.)
597
+
598
+ #### Traffic Shaping vs Traffic Enforcement
599
+
600
+ By default, the limiter will `sleep` up to 5 seconds (or a specified `wait_timeout`) before
601
+ yielding to the limited work. (This behavior is subject to the usual GIL and OS scheduling, so
602
+ treat the configured rate as a best-effort target rather than a hard guarantee.)
603
+
604
+ Use a longer `wait_timeout` for even better throughput smoothing (at the cost of blocking threads):
605
+
606
+ ```ruby
607
+ # A longer wait timeout is best for shaping outbound traffic in asynchronous contexts.
608
+ with_limit :outbound_traffic, max: 100, per: 1.minute, wait_timeout: 30.seconds
609
+ ```
610
+
611
+ Or set it to `0` to fail fast, so that the job never blocks a worker thread:
612
+
613
+ ```ruby
614
+ # A zero wait timeout is best for enforcing inbound limits and shedding excess traffic.
615
+ with_limit :inbound_traffic, max: 5, per: 1.second, wait_timeout: 0
616
+ ```
617
+
618
+ #### Customizing `with_limit`
619
+
620
+ The purpose defaults to the job's underscored class name, so it may be omitted entirely if the
621
+ limit is not shared with any other class:
622
+
623
+ ```ruby
624
+ with_limit max: 100, per: 1.minute
625
+ ```
626
+
627
+ Use `on:` to wrap one or more other instance methods instead of `perform` (e.g. if only a portion
628
+ of the job's work is subject to the limit):
629
+
630
+ ```ruby
631
+ with_limit :third_party_api, on: :deliver!
632
+ ```
633
+
634
+ **For ActiveJob classes only**, use `retry_attempts:`, `retry_wait:`, and `retry_jitter:` to
635
+ customize the retry behavior. (By default, jobs retry indefinitely with a polynomial backoff, with
636
+ the `wait_timeout` acting as a floor on the computed wait.) If `with_limit` is declared multiple
637
+ times on the same class (e.g. to apply different limits to different methods), only the first
638
+ declaration defines the job's retry behavior.
639
+
640
+ #### Manually Limiting a Block of Code
641
+
642
+ To rate limit code that doesn't belong to a job class, call `Delayed::Limit.within_limit`
643
+ directly. It accepts the same `purpose`, `max:`, `per:`, and `wait_timeout:` arguments as
644
+ `with_limit`, and wraps the limited work in a block:
645
+
646
+ ```ruby
647
+ Delayed::Limit.within_limit(:widgets_api, max: 100, per: 1.minute) do
648
+ WidgetsApi.create_widget!(...)
649
+ end
650
+ ```
651
+
652
+ The key difference is that there is no built-in retry behavior: if the limit would be exceeded
653
+ within the `wait_timeout`, the call raises `Delayed::Limit::LimitExceededError` immediately, and
654
+ it is up to the caller to rescue and/or retry.
655
+
656
+ #### Shared Limits
657
+
658
+ To avoid repeating the same purpose's `max` and `per` across multiple call sites, register a
659
+ limit in advance (e.g. in an initializer):
660
+
661
+ ```ruby
662
+ Delayed::Limit.register!(:widgets_api, max: 100, per: 1.minute)
663
+ ```
664
+
665
+ Then, reference it by just its name at each declaration:
666
+
667
+ ```ruby
668
+ with_limit :widgets_api
669
+
670
+ # or:
671
+ Delayed::Limit.within_limit(:widgets_api) { ... }
672
+ ```
673
+
674
+ Registered limits are cached indefinitely in memory and are not thread-safe on write, so avoid
675
+ registering them dynamically or at runtime!
676
+
677
+ #### Handling "Burst" Throughput
678
+
679
+ As of now, **there is no "burst" capacity.** The limiter allows one call per "drain interval" (`per
680
+ / max`), so a limit of 60-per-minute behaves identically to 1-per-second (and will not allow
681
+ more than 1 call in the first second).
682
+
683
+ This is generally acceptable for background job processing (and for traffic shaping in general), but
684
+ may be revisited in the future in order to support use cases like API traffic enforcement.
685
+
686
+ #### Monitoring Limit Usage
687
+
688
+ Each call emits an `ActiveSupport::Notification` (`delayed.limit.within_limit` or
689
+ `delayed.limit.exceeded`), so you can monitor limiting activity the same way you would any other
690
+ event (see [Monitoring Jobs & Workers](#monitoring-jobs--workers)).
691
+
534
692
  ## Migrating from other ActiveJob backends
535
693
 
536
694
  For the most part, standard ActiveJob APIs should be fully compatible. However, when migrating from
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Delayed
4
+ # A database-backed concurrency limiter/optimizer designed for use with
5
+ # `Delayed::Job`. Given a 'purpose' (stringy identifier), a limit, and a time
6
+ # interval, it will attempt to maximize throughput without exceeding the limit
7
+ # ("traffic shaping"). The `wait_timeout` parameter can also be lowered (from
8
+ # its default of `5.seconds`) in order to shed load more proactively ("traffic
9
+ # enforcement").
10
+ #
11
+ # Because the algorithm relies on database-specific timestamp arithmetic and
12
+ # an upserting `RETURNING` clause, only PostgreSQL and SQLite (3.35+) are
13
+ # supported. (See `.supported?`.)
14
+ #
15
+ # Wrap the work you want to limit in a block, and the limiter will either
16
+ # yield to the block (within the configured `wait_timeout`) or raise
17
+ # `LimitExceededError` immediately (if the `wait_timeout` would be exceeded):
18
+ #
19
+ # Delayed::Limit.within_limit(:emails, max: 100, per: 1.minute) do
20
+ # deliver_email!
21
+ # end
22
+ #
23
+ # Currently, the limiter does not support "burst" capacity. A limit of 60
24
+ # req/minute will behave identically to a limit of 1 req/second, because the
25
+ # limiter only allows a single call per "drain interval" (`per / max`). When
26
+ # combined with the `wait_timeout`, this degree of smoothing is acceptable for
27
+ # background job processing (and traffic shaping in general), but may be
28
+ # revisited in the future to support more types of workloads.
29
+ #
30
+ # To share a limit across multiple calls, register a named 'purpose' in
31
+ # advance (e.g. in an initializer) and then reference it by name later:
32
+ #
33
+ # Delayed::Limit.register!(:emails, max: 100, per: 1.minute)
34
+ #
35
+ # Delayed::Limit.within_limit(:emails, wait_timeout: 10.seconds) do
36
+ # deliver_email!
37
+ # end
38
+ #
39
+ # It is **not** recommended to register limits dynamically at runtime, because
40
+ # registered limits are cached indefinitely in memory and are not thread-safe.
41
+ class Limit < ActiveRecord::Base
42
+ self.table_name = 'delayed_limits'
43
+
44
+ # SQLite gained support for the `RETURNING` clause in version 3.35.
45
+ MINIMUM_SQLITE_VERSION = Gem::Version.new('3.35.0')
46
+ SECONDS_PER_DAY = 86_400.0
47
+
48
+ # Raised when limit adherence would require exceeding the `wait_timeout`.
49
+ class LimitExceededError < StandardError; end
50
+
51
+ # Raised when the database adapter/version does not support the limiter
52
+ # (see `.supported?`).
53
+ class UnsupportedDatabaseError < StandardError; end
54
+
55
+ class << self
56
+ # Used only for limits registered in advance (via `.register!`):
57
+ def limits
58
+ @limits ||= {}.freeze
59
+ end
60
+
61
+ # The algorithm requires an upserting `RETURNING` clause and timestamp
62
+ # arithmetic, so only certain database adapters/versions are supported:
63
+ def supported?
64
+ case connection.adapter_name
65
+ when 'PostgreSQL', 'PostGIS'
66
+ true
67
+ when 'SQLite'
68
+ Gem::Version.new(connection.select_value('SELECT sqlite_version()')) >= MINIMUM_SQLITE_VERSION
69
+ else
70
+ false
71
+ end
72
+ end
73
+
74
+ # Register a limit policy for a given 'purpose' (stringy/symbol
75
+ # identifier). This is optional and should not be used for dynamic purpose
76
+ # names or limits, for memory and thread-safety reasons.
77
+ #
78
+ # Re-registering a purpose is a no-op if the config matches exactly, and
79
+ # raises ArgumentError otherwise.
80
+ def register!(purpose, max:, per:)
81
+ config = { max: max, per: per }.freeze
82
+
83
+ if limits.key?(purpose.to_sym) && limits.fetch(purpose.to_sym) != config
84
+ raise ArgumentError, "Limit policy '#{purpose}' is already registered and does not match #{config.inspect}"
85
+ end
86
+
87
+ @limits = limits.merge(purpose.to_sym => config).freeze
88
+ end
89
+
90
+ # This method implements a leaky bucket algorithm (or, more specifically,
91
+ # a Generic Cell Rate Algorithm) to enforce a per-'purpose' work limit.
92
+ #
93
+ # It will wait up to `wait_timeout` for the caller to come within the
94
+ # configured limit before yielding to the caller, and will raise
95
+ # `LimitExceededError` if the wait time would exceed that timeout (shedding
96
+ # the caller proactively rather than sleeping).
97
+ #
98
+ # In Generic Cell Rate Algorithm (GCRA) terms:
99
+ # - TAT (theoretical arrival time) -> drained_at
100
+ # - T (emission interval) -> drain_interval
101
+ # - t0 (time of request) -> the database's current time
102
+ # - τ (bucket capacity) -> 1 call (implicitly)
103
+ def within_limit(purpose, max: nil, per: nil, wait_timeout: 5.seconds)
104
+ config = limits[purpose.to_sym]
105
+ if config && (max || per)
106
+ raise ArgumentError, "Limit policy '#{purpose}' is already registered (overriding 'max'/'per' is not supported)"
107
+ end
108
+
109
+ config ||= { max: max, per: per }.compact
110
+
111
+ # The drain_interval reflects the per-call rate at which the bucket
112
+ # empties, calculated as the overall interval (per) divided by the
113
+ # maximum number of calls allowed in that interval (max).
114
+ #
115
+ # e.g. for a target of 100 req/min, the drain_interval would be 0.6 sec/req.
116
+ drain_interval = config.fetch(:per).seconds / config.fetch(:max).to_d
117
+
118
+ # Attempt to reserve capacity via an uncached database query:
119
+ limit = connection.uncached do
120
+ find_by_sql(reserve_sql(purpose, drain_interval, wait_timeout.seconds)).first
121
+ end
122
+
123
+ # If 'limit' is nil, it means the WHERE clause prevented us from
124
+ # reserving capacity in the bucket. (This happens if the configured
125
+ # `wait_timeout` would be exceeded.) We assume the caller will back off
126
+ # and retry later, so we avoid wasting bucket capacity on a no-op.
127
+ if limit.nil?
128
+ ActiveSupport::Notifications.instrument('delayed.limit.exceeded', purpose: purpose)
129
+ raise LimitExceededError, "Concurrency limit exceeded for '#{purpose}'"
130
+ end
131
+
132
+ # If we successfully reserved capacity within the `wait_timeout`, it
133
+ # means that we've been told by the query how long to sleep in order to
134
+ # comply with the configured rate.
135
+ #
136
+ # (For best results, we MUST make a best attempt to sleep for the
137
+ # returned 'wait' duration before proceeding.)
138
+ wait = limit.wait.to_f
139
+ sleep(wait) if wait.positive?
140
+
141
+ ActiveSupport::Notifications.instrument('delayed.limit.within_limit', purpose: purpose)
142
+ yield
143
+ end
144
+
145
+ private
146
+
147
+ # We reserve capacity by pushing a 'drained_at' timestamp forward by the
148
+ # drain_interval, returning how long the caller must wait to avoid filling
149
+ # the bucket beyond its capacity. (The WHERE clause significantly reduces
150
+ # contention on the row when the bucket is full.)
151
+ def reserve_sql(purpose, drain_interval, max_wait)
152
+ case connection.adapter_name
153
+ when 'PostgreSQL', 'PostGIS'
154
+ # Postgres has native `interval` arithmetic and a statement-stable
155
+ # clock (`statement_timestamp()`), so we bind the intervals as ISO8601
156
+ # strings and let the database do the math.
157
+ binds = { purpose: purpose, drain_interval: drain_interval.iso8601, max_wait: max_wait.iso8601 }
158
+ [<<~SQL.squish, binds]
159
+ INSERT INTO delayed_limits (purpose, drained_at)
160
+ VALUES (:purpose, statement_timestamp() + :drain_interval)
161
+ ON CONFLICT (purpose) DO UPDATE SET
162
+ drained_at = GREATEST(statement_timestamp(), delayed_limits.drained_at) + :drain_interval
163
+ WHERE delayed_limits.drained_at - statement_timestamp() <= :max_wait
164
+ RETURNING EXTRACT(EPOCH FROM (drained_at - statement_timestamp() - :drain_interval)) AS wait
165
+ SQL
166
+ when 'SQLite'
167
+ raise UnsupportedDatabaseError, "Delayed::Limit requires SQLite #{MINIMUM_SQLITE_VERSION} or newer" unless supported?
168
+
169
+ # SQLite has no interval type. Instead, `julianday` yields a
170
+ # sub-second-precise number of days, and we bind the intervals as a
171
+ # fraction of a day. (The `wait` is then scaled back to seconds.)
172
+ binds = { purpose: purpose, drain_interval: drain_interval.to_f / SECONDS_PER_DAY, max_wait: max_wait.to_f / SECONDS_PER_DAY }
173
+ [<<~SQL.squish, binds]
174
+ INSERT INTO delayed_limits (purpose, drained_at)
175
+ VALUES (:purpose, strftime('%Y-%m-%d %H:%M:%f', julianday('now') + :drain_interval))
176
+ ON CONFLICT (purpose) DO UPDATE SET
177
+ drained_at = strftime('%Y-%m-%d %H:%M:%f',
178
+ MAX(julianday('now'), julianday(delayed_limits.drained_at)) + :drain_interval)
179
+ WHERE julianday(delayed_limits.drained_at) - julianday('now') <= :max_wait
180
+ RETURNING (julianday(drained_at) - julianday('now') - :drain_interval) * #{SECONDS_PER_DAY} AS wait
181
+ SQL
182
+ else
183
+ raise UnsupportedDatabaseError, "Delayed::Limit is not supported on #{connection.adapter_name.inspect}"
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,30 @@
1
+ class CreateDelayedLimits < ActiveRecord::Migration[6.0]
2
+ # The `delayed_limits` table backs the `Delayed::Limit` concurrency limiter.
3
+ # (See `Delayed::Limit` for details.)
4
+ #
5
+ # You can delete this migration if you do not intend to use `Delayed::Limit`,
6
+ # but it is safe to leave the table in place.
7
+ def up
8
+ create_table :delayed_limits, primary_key: :purpose, id: :string do |t|
9
+ t.datetime :drained_at, null: false
10
+ end
11
+
12
+ return unless connection.adapter_name == 'PostgreSQL'
13
+
14
+ # As this is a small, extremely high-churn table, we make it UNLOGGED (the
15
+ # limiter state need not survive a crash) and tune fillfactor/autovacuum to
16
+ # favor in-page [HOT updates](https://www.postgresql.org/docs/current/storage-hot.html).
17
+ execute <<~SQL
18
+ ALTER TABLE delayed_limits SET UNLOGGED;
19
+ ALTER TABLE delayed_limits SET (
20
+ fillfactor = 33,
21
+ autovacuum_vacuum_scale_factor = 0,
22
+ autovacuum_vacuum_threshold = 30
23
+ );
24
+ SQL
25
+ end
26
+
27
+ def down
28
+ drop_table :delayed_limits
29
+ end
30
+ end
@@ -78,7 +78,7 @@ module Delayed
78
78
  raise "`:run_at` is not supported. Use `:wait_until` instead." if opts.key?(:run_at)
79
79
 
80
80
  self.provider_attributes = opts.except(:wait, :wait_until, :queue, :priority)
81
- opts[:priority] = Delayed::Priority.new(opts[:priority]) if opts.key?(:priority)
81
+ opts[:priority] = Delayed::Priority.new(opts[:priority]) if opts[:priority]
82
82
  super(opts)
83
83
  end
84
84
  end
@@ -1,6 +1,8 @@
1
1
  module Delayed
2
2
  module Backend
3
3
  module Base
4
+ HOOKS = %i(before success error after failure).freeze
5
+
4
6
  def self.included(base)
5
7
  base.extend ClassMethods
6
8
  end
@@ -76,10 +78,13 @@ module Delayed
76
78
 
77
79
  def bulk_insert_all(jobs)
78
80
  now = db_time_now
79
- jobs.each { |job| job.created_at = job.updated_at = now }
81
+ jobs.each do |job|
82
+ job.created_at ||= now
83
+ job.updated_at ||= now
84
+ end
80
85
  rows = jobs.map { |job| job.attributes.compact }
81
86
  result = insert_all(rows) # rubocop:disable Rails/SkipsModelValidations
82
- return unless connection.supports_insert_returning?
87
+ return unless connection_pool.with_connection(&:supports_insert_returning?)
83
88
 
84
89
  ids = result.rows.map(&:first)
85
90
  jobs.zip(ids) { |job, id| job.id = id }
@@ -162,15 +167,9 @@ module Delayed
162
167
  end
163
168
 
164
169
  def hook(name, *args)
165
- if payload_object.respond_to?(name)
166
- if name == :enqueue
167
- raise ':enqueue hook is no longer supported'
168
- end
169
-
170
- if payload_object.is_a?(Delayed::JobWrapper)
171
- warn '[DEPRECATION] Job hook methods (`before`, `after`, `success`, etc) are deprecated. Use ActiveJob callbacks instead.'
172
- end
170
+ raise ArgumentError, "Unknown hook: #{name.inspect}" unless HOOKS.include?(name)
173
171
 
172
+ if payload_object.respond_to?(name)
174
173
  method = payload_object.method(name)
175
174
  method.arity.zero? ? method.call : method.call(self, *args)
176
175
  end
@@ -13,6 +13,7 @@ module Delayed
13
13
  set_queue_name
14
14
  set_priority
15
15
  set_run_at
16
+ set_created_at
16
17
  set_name
17
18
  handle_dst
18
19
  reject_stale_run_at
@@ -37,9 +38,14 @@ module Delayed
37
38
  end
38
39
 
39
40
  def set_run_at
41
+ options[:run_at] ||= options[:payload_object].scheduled_at if options[:payload_object].respond_to?(:scheduled_at)
40
42
  options[:run_at] ||= Job.db_time_now
41
43
  end
42
44
 
45
+ def set_created_at
46
+ options[:created_at] ||= options[:payload_object].enqueued_at if options[:payload_object].respond_to?(:enqueued_at)
47
+ end
48
+
43
49
  def set_name
44
50
  return if options[:name] || !Job.name_assignable?
45
51
 
@@ -33,18 +33,20 @@ module Delayed
33
33
  end
34
34
  end
35
35
 
36
- RETRY_EXCEPTIONS = [
37
- ActiveRecord::LockWaitTimeout,
38
- ActiveRecord::StatementTimeout,
39
- (PG::LockNotAvailable if defined?(PG::LockNotAvailable)),
40
- ].compact.freeze
36
+ def self.retry_exceptions
37
+ @retry_exceptions ||= [
38
+ ActiveRecord::LockWaitTimeout,
39
+ ActiveRecord::StatementTimeout,
40
+ (PG::LockNotAvailable if defined?(PG::LockNotAvailable)),
41
+ ].compact.freeze
42
+ end
41
43
 
42
44
  def with_retry_loop(wait_timeout: 5.minutes, **opts)
43
45
  with_timeouts(**opts) do
44
46
  loop do
45
47
  yield
46
48
  break
47
- rescue *RETRY_EXCEPTIONS => e
49
+ rescue *Migration.retry_exceptions => e
48
50
  raise if Delayed::Job.db_time_now - @migration_start > wait_timeout
49
51
 
50
52
  Delayed.logger.warn("Index creation failed for #{opts[:name]}: #{e.message}. Retrying...")
@@ -1,5 +1,17 @@
1
1
  module Delayed
2
2
  class JobWrapper # rubocop:disable Betterment/ActiveJobPerformable
3
+ module HookDeprecation
4
+ Delayed::Backend::Base::HOOKS.each do |hook|
5
+ define_method(hook) do |*args|
6
+ if respond_to_missing?(hook, false)
7
+ warn "[DEPRECATION] Job hook methods (`#{hook}`) are deprecated. Use ActiveJob callbacks instead."
8
+ super(*args)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ include HookDeprecation
14
+
3
15
  attr_accessor :job_data
4
16
 
5
17
  delegate_missing_to :job
@@ -22,20 +34,38 @@ module Delayed
22
34
 
23
35
  # If job failed to deserialize, we can't respond to delegated methods.
24
36
  # Returning false here prevents instance method checks from blocking job cleanup.
25
- # There is a (currently) unreleased Rails PR that changes the exception class in this case:
26
- # https://github.com/rails/rails/pull/53770
27
- if defined?(ActiveJob::UnknownJobClassError)
28
- def respond_to?(*, **)
29
- super
30
- rescue ActiveJob::UnknownJobClassError
31
- false
32
- end
33
- else
34
- def respond_to?(*, **)
35
- super
36
- rescue NameError
37
- false
38
- end
37
+ # Rails 8.1+ raises ActiveJob::UnknownJobClassError (rails/rails#53770).
38
+ def respond_to?(*, **)
39
+ super
40
+ rescue NameError => e
41
+ raise if defined?(ActiveJob::UnknownJobClassError) && !e.is_a?(ActiveJob::UnknownJobClassError)
42
+
43
+ false
44
+ end
45
+
46
+ def before(record)
47
+ # ActiveJob retries should use the row's current priority (it may have changed since enqueue):
48
+ self.priority = record.priority.to_i if respond_to?(:priority=)
49
+ # If a job is manually reset, we reset ActiveJob's execution log as well:
50
+ reset_execution_log! if job_data.delete('terminated_at') && record.attempts.zero?
51
+ super
52
+ end
53
+
54
+ def error(record, error)
55
+ # The error escaped retry_on (if any), so ActiveJob considers the job terminated.
56
+ job_data['terminated_at'] = record.class.db_time_now.utc.iso8601(9)
57
+ record.payload_object = self # re-serialize the handler
58
+
59
+ # If the error escaped ActiveJob's retry_on policy, we stop the Delayed::Job retries as well.
60
+ @_aj_retry_terminated = rescue_handlers.any? { |name, _| error.is_a?(name.constantize) }
61
+
62
+ super
63
+ end
64
+
65
+ def max_attempts
66
+ return 1 if @_aj_retry_terminated
67
+
68
+ super if respond_to_missing?(:max_attempts)
39
69
  end
40
70
 
41
71
  def perform
@@ -50,6 +80,13 @@ module Delayed
50
80
 
51
81
  private
52
82
 
83
+ def reset_execution_log!
84
+ job_data['executions'] = 0
85
+ job_data['exception_executions'] = {}
86
+ self.executions = job_data['executions'] if respond_to?(:executions=)
87
+ self.exception_executions = job_data['exception_executions'] if respond_to?(:exception_executions=)
88
+ end
89
+
53
90
  def job
54
91
  @job ||= ActiveJob::Base.deserialize(job_data) if job_data
55
92
  end