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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70e3822f06e3d5b6a663f08998abf783ae91137a96cf154dded1487548ab9544
4
- data.tar.gz: e166f6ba77ff5e268059dc32de8e72e619b56106e69a96b884c1c5e31e22542a
3
+ metadata.gz: 7af8a042ab9fe6f3e5bb5709860ab1c7544f5f13124b02cb5d181b9afa378b6d
4
+ data.tar.gz: c76388de277c68702a51aa8379b83902e9f339ec0444b41e67b663a7d6db76b7
5
5
  SHA512:
6
- metadata.gz: 86379222dec057f27b106602cc91ce90800861f705e16fb53857590f6b730a6e523780be0eeec09c6a9dd8d9f9b1760b0c15793c73fcfac5173bf2ba17354f4b
7
- data.tar.gz: 4ce59e9dbec2d10339cf3979ff5c49a97adc337fba6119d69662e65ff2407f8fc1ab32cac09ab3ad83044e795384f2c36ae478fa5763a3578b87178cdb624123
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Delayed
4
- VERSION = '4.1.0'
4
+ VERSION = '4.2.0'
5
5
  end
@@ -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
 
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: 4.1.0
4
+ version: 4.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Griffith