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 +4 -4
- data/README.md +158 -0
- data/app/models/delayed/limit.rb +188 -0
- data/db/migrate/10_create_delayed_limits.rb +30 -0
- data/lib/delayed/active_job_adapter.rb +1 -1
- data/lib/delayed/backend/base.rb +9 -10
- data/lib/delayed/backend/job_preparer.rb +6 -0
- data/lib/delayed/helpers/migration.rb +8 -6
- data/lib/delayed/job_wrapper.rb +51 -14
- data/lib/delayed/limitable.rb +83 -0
- data/lib/delayed/version.rb +1 -1
- data/lib/delayed.rb +3 -0
- data/spec/delayed/active_job_adapter_spec.rb +306 -2
- data/spec/delayed/job_spec.rb +62 -2
- data/spec/delayed/limit_spec.rb +182 -0
- data/spec/delayed/limitable_spec.rb +303 -0
- data/spec/delayed_spec.rb +26 -0
- data/spec/helper.rb +7 -2
- metadata +26 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2b90a867a985a3bf32841f0cadac600d9d9f076d30bb04c85845c65b0e97ba9
|
|
4
|
+
data.tar.gz: 7b527829bd45c8f4bff4b6c62ea15ecc62aeca4461e74e2ff0b6f0c5a1800243
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
81
|
+
opts[:priority] = Delayed::Priority.new(opts[:priority]) if opts[:priority]
|
|
82
82
|
super(opts)
|
|
83
83
|
end
|
|
84
84
|
end
|
data/lib/delayed/backend/base.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 *
|
|
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...")
|
data/lib/delayed/job_wrapper.rb
CHANGED
|
@@ -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
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|