delayed 3.1.0 → 4.0.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.
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'helper'
4
+
5
+ RSpec.describe Delayed::Limitable do
6
+ let(:purpose) { :limitable_spec }
7
+
8
+ around do |example|
9
+ original_limits = Delayed::Limit.limits
10
+ example.run
11
+ ensure
12
+ Delayed::Limit.instance_variable_set(:@limits, original_limits)
13
+ end
14
+
15
+ before do
16
+ allow(Delayed::Limit).to receive(:within_limit).and_yield
17
+ end
18
+
19
+ it 'is automatically included in ActiveJob classes' do
20
+ expect(ActiveJob::Base).to respond_to(:with_limit)
21
+ end
22
+
23
+ describe '.with_limit' do
24
+ context 'when on: is not specified' do
25
+ let(:job_class) do
26
+ p = purpose
27
+ Class.new(ActiveJob::Base) do
28
+ with_limit p, max: 4, per: 1.second
29
+
30
+ define_method(:perform) { :result }
31
+ end
32
+ end
33
+
34
+ it 'wraps perform with within_limit' do
35
+ job_class.new.perform
36
+ expect(Delayed::Limit).to have_received(:within_limit)
37
+ .with(purpose, wait_timeout: 5.seconds).once
38
+ end
39
+
40
+ it 'registers the limit policy with Delayed::Limit' do
41
+ job_class
42
+ expect(Delayed::Limit.limits.fetch(purpose)).to eq(max: 4, per: 1.second)
43
+ end
44
+ end
45
+
46
+ context 'when purpose is not specified' do
47
+ let(:job_class) do
48
+ Class.new(ActiveJob::Base) do
49
+ define_method(:perform) { :result }
50
+ end
51
+ end
52
+
53
+ before do
54
+ stub_const('LimitableDefaultPurposeJob', job_class)
55
+ job_class.with_limit(max: 4, per: 1.second)
56
+ end
57
+
58
+ it 'defaults the purpose to the underscored class name' do
59
+ job_class.new.perform
60
+ expect(Delayed::Limit).to have_received(:within_limit)
61
+ .with(:limitable_default_purpose_job, wait_timeout: 5.seconds).once
62
+ end
63
+ end
64
+
65
+ context 'when wait_timeout: is specified' do
66
+ let(:job_class) do
67
+ p = purpose
68
+ Class.new(ActiveJob::Base) do
69
+ with_limit p, max: 4, per: 1.second, wait_timeout: 30.seconds
70
+
71
+ define_method(:perform) { :result }
72
+ end
73
+ end
74
+
75
+ it 'passes the wait_timeout through to within_limit' do
76
+ job_class.new.perform
77
+ expect(Delayed::Limit).to have_received(:within_limit)
78
+ .with(purpose, wait_timeout: 30.seconds).once
79
+ end
80
+ end
81
+
82
+ context 'when on: is a single method name' do
83
+ let(:job_class) do
84
+ p = purpose
85
+ Class.new(ActiveJob::Base) do
86
+ with_limit p, max: 4, per: 1.second, on: :do_work
87
+
88
+ define_method(:perform) { do_work && dont_do_work }
89
+ define_method(:do_work) { :result_b }
90
+ define_method(:dont_do_work) { :result_a }
91
+ end
92
+ end
93
+
94
+ it 'wraps the specified method with within_limit' do
95
+ job_class.new.do_work
96
+ expect(Delayed::Limit).to have_received(:within_limit)
97
+ .with(purpose, wait_timeout: 5.seconds).once
98
+ end
99
+
100
+ it 'does not add an extra within_limit call when perform delegates to the wrapped method' do
101
+ job_class.new.perform
102
+ expect(Delayed::Limit).to have_received(:within_limit).once
103
+ end
104
+ end
105
+
106
+ context 'when on: is an array of method names' do
107
+ let(:job_class) do
108
+ p = purpose
109
+ Class.new(ActiveJob::Base) do
110
+ with_limit p, max: 4, per: 1.second, on: %i(do_work_a do_work_b)
111
+
112
+ def perform; end
113
+ define_method(:do_work_a) { :result_a }
114
+ define_method(:do_work_b) { :result_b }
115
+ end
116
+ end
117
+
118
+ it 'wraps each named method with within_limit' do
119
+ job_class.new.tap do |job|
120
+ job.perform
121
+ job.do_work_a
122
+ job.do_work_b
123
+ end
124
+ expect(Delayed::Limit).to have_received(:within_limit)
125
+ .with(purpose, wait_timeout: 5.seconds).twice
126
+ end
127
+ end
128
+
129
+ context 'when the purpose was registered in advance and no config is given' do
130
+ before { Delayed::Limit.register!(purpose, max: 4, per: 1.second) }
131
+
132
+ let(:job_class) do
133
+ p = purpose
134
+ Class.new(ActiveJob::Base) do
135
+ with_limit p
136
+
137
+ define_method(:perform) { :result }
138
+ end
139
+ end
140
+
141
+ it 'references the registered limit by purpose' do
142
+ job_class.new.perform
143
+ expect(Delayed::Limit).to have_received(:within_limit)
144
+ .with(purpose, wait_timeout: 5.seconds).once
145
+ end
146
+ end
147
+
148
+ context 'when the config contains an unknown key' do
149
+ it 'raises ArgumentError' do
150
+ p = purpose
151
+ expect {
152
+ Class.new(ActiveJob::Base) { with_limit p, max: 4, per: 1.second, wait_timout: 1.second }
153
+ }.to raise_error(ArgumentError, /wait_timout/)
154
+ end
155
+ end
156
+
157
+ context 'when with_limit is declared multiple times' do
158
+ let(:other_purpose) { :"#{purpose}_b" }
159
+
160
+ let(:job_class) do
161
+ p = purpose
162
+ other = other_purpose
163
+ Class.new(ActiveJob::Base) do
164
+ with_limit p, max: 4, per: 1.second, wait_timeout: 100.seconds, on: :do_work_a, retry_jitter: 0
165
+ with_limit other, max: 2, per: 1.minute, wait_timeout: 30.seconds, on: :do_work_b, retry_jitter: 0
166
+
167
+ define_method(:perform) { raise Delayed::Limit::LimitExceededError }
168
+ define_method(:do_work_a) { :result_a }
169
+ define_method(:do_work_b) { :result_b }
170
+ end
171
+ end
172
+
173
+ before { stub_const('LimitableMultipleLimitsJob', job_class) }
174
+
175
+ it 'registers each limit policy' do
176
+ job_class
177
+ expect(Delayed::Limit.limits.fetch(purpose)).to eq(max: 4, per: 1.second)
178
+ expect(Delayed::Limit.limits.fetch(other_purpose)).to eq(max: 2, per: 1.minute)
179
+ end
180
+
181
+ it 'wraps each method with its own limit' do
182
+ job_class.new.tap do |job|
183
+ job.do_work_a
184
+ job.do_work_b
185
+ end
186
+ expect(Delayed::Limit).to have_received(:within_limit)
187
+ .with(purpose, wait_timeout: 100.seconds).once
188
+ expect(Delayed::Limit).to have_received(:within_limit)
189
+ .with(other_purpose, wait_timeout: 30.seconds).once
190
+ end
191
+
192
+ it 'defines only a single retry handler' do
193
+ handlers = job_class.rescue_handlers.select do |class_name, _|
194
+ class_name == Delayed::Limit::LimitExceededError.name
195
+ end
196
+ expect(handlers.size).to eq(1)
197
+ end
198
+
199
+ it 'retries according to the first declaration (its wait_timeout floors the retry wait)' do
200
+ job = job_class.new
201
+ allow(job).to receive(:retry_job)
202
+ job.perform_now
203
+ expect(job).to have_received(:retry_job)
204
+ .with(a_hash_including(wait: 100.seconds))
205
+ end
206
+ end
207
+
208
+ context 'when included in a plain (non-ActiveJob) class' do
209
+ let(:klass) do
210
+ p = purpose
211
+ Class.new do
212
+ include Delayed::Limitable
213
+
214
+ with_limit p, max: 4, per: 1.second, on: :do_work
215
+
216
+ define_method(:do_work) { :result }
217
+ end
218
+ end
219
+
220
+ it 'wraps the specified method with within_limit' do
221
+ expect(klass.new.do_work).to eq(:result)
222
+ expect(Delayed::Limit).to have_received(:within_limit)
223
+ .with(purpose, wait_timeout: 5.seconds).once
224
+ end
225
+
226
+ it 'registers the limit policy with Delayed::Limit' do
227
+ klass
228
+ expect(Delayed::Limit.limits.fetch(purpose)).to eq(max: 4, per: 1.second)
229
+ end
230
+
231
+ it 'leaves LimitExceededError handling to the caller' do
232
+ allow(Delayed::Limit).to receive(:within_limit).and_raise(Delayed::Limit::LimitExceededError)
233
+ expect { klass.new.do_work }.to raise_error(Delayed::Limit::LimitExceededError)
234
+ end
235
+ end
236
+ end
237
+
238
+ describe 'retry behavior' do
239
+ let(:job_class) do
240
+ p = purpose
241
+ Class.new(ActiveJob::Base) do
242
+ with_limit p, max: 4, per: 1.second, retry_jitter: 0
243
+
244
+ define_method(:perform) { raise Delayed::Limit::LimitExceededError }
245
+ end
246
+ end
247
+
248
+ before { stub_const('LimitableRetryJob', job_class) }
249
+
250
+ it 'retries the job when the limit is exceeded' do
251
+ job = job_class.new
252
+ allow(job).to receive(:retry_job)
253
+ job.perform_now
254
+ expect(job).to have_received(:retry_job)
255
+ .with(a_hash_including(wait: a_value > 0, error: an_instance_of(Delayed::Limit::LimitExceededError)))
256
+ end
257
+
258
+ it 'floors the retry wait at the wait_timeout' do
259
+ job = job_class.new
260
+ allow(job).to receive(:retry_job)
261
+ job.perform_now
262
+ expect(job).to have_received(:retry_job)
263
+ .with(a_hash_including(wait: 5.seconds))
264
+ end
265
+
266
+ context 'when a custom wait: is specified' do
267
+ let(:job_class) do
268
+ p = purpose
269
+ Class.new(ActiveJob::Base) do
270
+ with_limit p, max: 4, per: 1.second, retry_wait: ->(_attempts) { 123 }
271
+
272
+ define_method(:perform) { raise Delayed::Limit::LimitExceededError }
273
+ end
274
+ end
275
+
276
+ it 'uses the custom wait' do
277
+ job = job_class.new
278
+ allow(job).to receive(:retry_job)
279
+ job.perform_now
280
+ expect(job).to have_received(:retry_job)
281
+ .with(a_hash_including(wait: 123))
282
+ end
283
+ end
284
+
285
+ context 'when retry_attempts: is specified' do
286
+ let(:job_class) do
287
+ p = purpose
288
+ Class.new(ActiveJob::Base) do
289
+ with_limit p, max: 4, per: 1.second, retry_attempts: 1
290
+
291
+ define_method(:perform) { raise Delayed::Limit::LimitExceededError }
292
+ end
293
+ end
294
+
295
+ it 'stops retrying once the attempts are exhausted' do
296
+ job = job_class.new
297
+ allow(job).to receive(:retry_job)
298
+ expect { job.perform_now }.to raise_error(Delayed::Limit::LimitExceededError)
299
+ expect(job).not_to have_received(:retry_job)
300
+ end
301
+ end
302
+ end
303
+ end
data/spec/helper.rb CHANGED
@@ -73,8 +73,10 @@ Dir['db/migrate/*.rb'].each { |f| require_relative("../#{f}") }
73
73
  ActiveRecord::Schema.define do
74
74
  if ActiveRecord::VERSION::MAJOR >= 7
75
75
  drop_table :delayed_jobs, if_exists: true
76
- elsif ActiveRecord::Base.connection.table_exists?(:delayed_jobs)
77
- drop_table :delayed_jobs
76
+ drop_table :delayed_limits, if_exists: true
77
+ else
78
+ drop_table :delayed_jobs if ActiveRecord::Base.connection.table_exists?(:delayed_jobs)
79
+ drop_table :delayed_limits if ActiveRecord::Base.connection.table_exists?(:delayed_limits)
78
80
  end
79
81
 
80
82
  # Let's prove reversibility when we set up our test DB:
@@ -94,6 +96,7 @@ ActiveRecord::Schema.define do
94
96
  run_migration(RemoveLegacyIndex)
95
97
  run_migration(AddRunAtAndNameNotNullCheck)
96
98
  run_migration(ValidateRunAtAndNameNotNull)
99
+ run_migration(CreateDelayedLimits)
97
100
 
98
101
  # Test that these index migrations can be re-applied idempotently.
99
102
  # (In case identical indexes had been manually applied previously.)
@@ -296,6 +299,8 @@ QueryUnderTest = Struct.new(:sql, :connection) do
296
299
  .gsub(/AS ("|`)?(\w+)("|`)?/) { "AS #{Regexp.last_match(2)[0...63]}" }
297
300
  # newline and indent when aliased columns are listed
298
301
  .gsub(/AS (\w+),/) { "AS #{Regexp.last_match(1)},\n " }
302
+ # normalize 'FROM (...) AS' subqueries (changes across Rails versions)
303
+ .gsub(") AS subquery") { ") subquery" }
299
304
  # remove quotes around column names in aggregate functions
300
305
  .gsub(/(MIN|MAX|COUNT|SUM)\(("|`)(\w+)("|`)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(3)})" }
301
306
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: delayed
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Griffith
@@ -16,10 +16,9 @@ authors:
16
16
  - Matt Griffin
17
17
  - Steve Richert
18
18
  - Tobias Lütke
19
- autorequire:
20
19
  bindir: bin
21
20
  cert_chain: []
22
- date: 2026-07-13 00:00:00.000000000 Z
21
+ date: 1980-01-02 00:00:00.000000000 Z
23
22
  dependencies:
24
23
  - !ruby/object:Gem::Dependency
25
24
  name: activerecord
@@ -35,6 +34,20 @@ dependencies:
35
34
  - - ">="
36
35
  - !ruby/object:Gem::Version
37
36
  version: '6.0'
37
+ - !ruby/object:Gem::Dependency
38
+ name: benchmark
39
+ requirement: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
38
51
  - !ruby/object:Gem::Dependency
39
52
  name: concurrent-ruby
40
53
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +79,7 @@ files:
66
79
  - README.md
67
80
  - Rakefile
68
81
  - app/models/delayed/job.rb
82
+ - app/models/delayed/limit.rb
69
83
  - db/migrate/01_create_delayed_jobs.rb
70
84
  - db/migrate/02_add_name_to_delayed_jobs.rb
71
85
  - db/migrate/03_add_index_to_delayed_jobs_name.rb
@@ -75,6 +89,7 @@ files:
75
89
  - db/migrate/07_remove_legacy_index.rb
76
90
  - db/migrate/08_add_run_at_and_name_not_null_check.rb
77
91
  - db/migrate/09_validate_run_at_and_name_not_null.rb
92
+ - db/migrate/10_create_delayed_limits.rb
78
93
  - lib/delayed.rb
79
94
  - lib/delayed/active_job_adapter.rb
80
95
  - lib/delayed/backend/base.rb
@@ -84,6 +99,7 @@ files:
84
99
  - lib/delayed/helpers/migration.rb
85
100
  - lib/delayed/job_wrapper.rb
86
101
  - lib/delayed/lifecycle.rb
102
+ - lib/delayed/limitable.rb
87
103
  - lib/delayed/message_sending.rb
88
104
  - lib/delayed/monitor.rb
89
105
  - lib/delayed/performable_mailer.rb
@@ -112,6 +128,8 @@ files:
112
128
  - spec/delayed/active_job_adapter_spec.rb
113
129
  - spec/delayed/helpers/migration_spec.rb
114
130
  - spec/delayed/job_spec.rb
131
+ - spec/delayed/limit_spec.rb
132
+ - spec/delayed/limitable_spec.rb
115
133
  - spec/delayed/monitor_spec.rb
116
134
  - spec/delayed/plugins/instrumentation_spec.rb
117
135
  - spec/delayed/priority_spec.rb
@@ -134,7 +152,6 @@ metadata:
134
152
  bug_tracker_uri: https://github.com/betterment/delayed/issues
135
153
  source_code_uri: https://github.com/betterment/delayed
136
154
  rubygems_mfa_required: 'true'
137
- post_install_message:
138
155
  rdoc_options: []
139
156
  require_paths:
140
157
  - lib
@@ -149,8 +166,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
149
166
  - !ruby/object:Gem::Version
150
167
  version: '0'
151
168
  requirements: []
152
- rubygems_version: 3.4.19
153
- signing_key:
169
+ rubygems_version: 4.0.16
154
170
  specification_version: 4
155
171
  summary: a multi-threaded, SQL-driven ActiveJob backend used at Betterment to process
156
172
  millions of background jobs per day
@@ -165,6 +181,8 @@ test_files:
165
181
  - spec/delayed/active_job_adapter_spec.rb
166
182
  - spec/delayed/helpers/migration_spec.rb
167
183
  - spec/delayed/job_spec.rb
184
+ - spec/delayed/limit_spec.rb
185
+ - spec/delayed/limitable_spec.rb
168
186
  - spec/delayed/monitor_spec.rb
169
187
  - spec/delayed/plugins/instrumentation_spec.rb
170
188
  - spec/delayed/priority_spec.rb