delayed 4.1.0 → 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 +12 -0
- data/app/models/delayed/limit.rb +23 -0
- data/lib/delayed/version.rb +1 -1
- data/spec/delayed/limit_spec.rb +101 -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
|
@@ -632,6 +632,18 @@ upserting `RETURNING` clause and database-native timestamp arithmetic. You can c
|
|
|
632
632
|
connection at runtime with `Delayed::Limit.supported?`. (Attempting to use the limiter on an
|
|
633
633
|
unsupported database will raise `Delayed::Limit::UnsupportedDatabaseError`.)
|
|
634
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
|
+
|
|
635
647
|
#### Traffic Shaping vs Traffic Enforcement
|
|
636
648
|
|
|
637
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/version.rb
CHANGED
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
|
|