sidekiq-transaction_guard 1.1.1 → 1.1.3

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: de06ea42809df15bffadb4ee4c00387dbf0dfd8cabb14130b98a0400b4502cee
4
- data.tar.gz: fe97b6610a93aacfb5917ea80bdee919b9587d39e92cfe78ef6eddd16e9648ca
3
+ metadata.gz: 5c0d2035a276a481304f3b833e10119704d05583e63eda918a34da4a83823ab1
4
+ data.tar.gz: beb93e6793e86312f448d536eb4ba51b4b0b5290c5b45ebfe2a5916e37a00b5e
5
5
  SHA512:
6
- metadata.gz: 56d18cbdb4a57f1ef5b32f333c30f36ed1c68889b596abaeeb62185264f5cc3cd2ace8d1b01f049183f461649372b7f11d2c1b5c55e1b48fae42a6ba83012f44
7
- data.tar.gz: 5125a0ea08ccd991f0386a11e5a32cd63ab70ebe20d2594363fced7c34a37d01debd16c637a3fa8e77eb6821b2009255bac18a58136317c0b24e599e603a75df
6
+ metadata.gz: 1de9763227c618c2ecd3e4c7af601f20db01b388eac1632678ac3521f4b1d7baa93669de29e00d8cfe718a4ef9502967c593e17e4b8fec47f46804b2b9453de2
7
+ data.tar.gz: 97732ce309894eafc91fa5e4e2f1ff89cee2d1aeb8ed33da0e6e9496e3c635fe724b28c449c7dff54e3d3d16c1568d8b8c64f961466782b5d2643139a98d8594
data/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.1.3
8
+
9
+ ### Fixed
10
+
11
+ - Transactions opened by the connection pool pinning machinery used by transactional tests in Rails 7.2+ are no longer counted as application transactions. Rails 7.2 pins a single connection wrapped in a non-joinable transaction and shares it with every thread, but the transaction level baseline captured by the test integrations is stored per thread. Threads other than the test runner thread (e.g. Capybara server threads, ActionCable workers, or inline Sidekiq jobs in system tests) had no baseline, so every job they enqueued was falsely flagged as inside a transaction. The allowed transaction level is now derived from the pool's pinned connection state as well, which is visible from all threads. Transactions opened by application code, including non-joinable ones, are still counted. The pinned connection state is only consulted once a test integration has started a testing context, so pinning a connection outside of tests does not bypass the guard.
12
+
13
+ ## 1.1.2
14
+
15
+ ### Fixed
16
+
17
+ - `Sidekiq::TransactionGuard.init` now registers the client middleware in the Sidekiq server process as well. Previously jobs enqueued from within other jobs bypassed the transaction guard entirely because `Sidekiq.configure_client` is a no-op in server processes.
18
+ - Fixed the Minitest helper so the transaction tracking set up by `Sidekiq::TransactionGuard.testing` remains active for the duration of the test. Previously the testing context was torn down when `setup` returned, so the transaction level snapshot was discarded before the test body ran and tests using transactional fixtures would raise false positive errors on every enqueue. The helper now uses Minitest's `before_setup`/`after_teardown` lifecycle hooks and takes the snapshot after all other setup hooks (including transactional fixtures) have run.
19
+ - The Minitest helper no longer permanently changes the global mode when it is included and no longer defines `setup`/`teardown` methods that could be silently overridden by test classes defining their own.
20
+ - `Sidekiq::TransactionGuard.disable` and the RSpec/Minitest test integrations now use a thread local mode override instead of mutating the global mode. This fixes race conditions where disabling the guard in one thread would disable it for all threads, and where concurrent save/restore of the global mode could leave it in the wrong state.
21
+ - The RSpec integration now registers its per-example hooks at the example group level so the transaction level snapshot is taken after rspec-rails opens the transactional fixture transaction instead of before it.
22
+ - Worker-level `transaction_guard` sidekiq options are now honored when the value is a string as well as a symbol.
23
+ - Connection errors from registered connection classes without an established connection no longer raise from `in_transaction?`.
24
+ - Use `lease_connection` instead of the deprecated `connection` method on ActiveRecord 7.2+.
25
+
26
+ ### Added
27
+
28
+ - `Sidekiq::TransactionGuard.thread_local_mode` and `thread_local_mode=` to override the mode for the current thread only.
29
+ - `Sidekiq::TransactionGuard.default_mode` to read the globally configured mode, ignoring any thread local override.
30
+
7
31
  ## 1.1.1
8
32
 
9
33
  ### Changed
data/README.md CHANGED
@@ -70,9 +70,11 @@ For non-Rails applications, you need to manually add the middleware in your appl
70
70
  ```ruby
71
71
  require 'sidekiq/transaction_guard'
72
72
 
73
- Sidekiq::TransactionGuard::Middleware.init
73
+ Sidekiq::TransactionGuard.init
74
74
  ```
75
75
 
76
+ This registers the client middleware for both client processes and the Sidekiq server process, so jobs enqueued from within other jobs are checked as well.
77
+
76
78
  ### Mode
77
79
 
78
80
  You can set the mode at any time. The mode can be one of `[:warn, :stderr, :error, :disabled]`.
@@ -94,7 +96,13 @@ Sidekiq::TransactionGuard.mode = :disabled
94
96
  You can set the mode when initializing the middleware:
95
97
 
96
98
  ```ruby
97
- Sidekiq::TransactionGuard::Middleware.init(mode: :error)
99
+ Sidekiq::TransactionGuard.init(mode: :error)
100
+ ```
101
+
102
+ You can also override the mode for the current thread only. This does not affect jobs being enqueued concurrently in other threads. Set it to `nil` to remove the override.
103
+
104
+ ```ruby
105
+ Sidekiq::TransactionGuard.thread_local_mode = :disabled
98
106
  ```
99
107
 
100
108
  You can also set the mode on individual worker classes with `sidekiq_options transaction_guard: mode`. The worker-specific mode will override the global mode.
@@ -175,7 +183,9 @@ If you're using RSpec, you can use the built-in RSpec helper to automatically se
175
183
  require 'sidekiq/transaction_guard/rspec'
176
184
  ```
177
185
 
178
- This will also add support for adding a metadata tag to your specs to control the transaction guard mode on a per-spec basis. For example:
186
+ Require this file after `rspec-rails` (or anything else that opens transactions in its setup hooks) so that the transaction level snapshot is taken after those transactions are opened.
187
+
188
+ Examples run with the `:error` mode by default. You can control the mode on a per-spec basis with a metadata tag. The value can be a mode symbol, `false` to disable the guard, or `:default` to use the globally configured mode. For example:
179
189
 
180
190
  ```ruby
181
191
  RSpec.describe "Some feature", sidekiq_transaction_guard: :disabled do
@@ -203,7 +213,7 @@ If you're using Minitest with `ActiveSupport::TestCase` (Rails default), you can
203
213
  require 'sidekiq/transaction_guard/minitest'
204
214
  ```
205
215
 
206
- This will automatically wrap each test in the appropriate `testing` block and handle transactional fixtures.
216
+ Tests run with the `:error` mode. The guard is disabled while `setup do` callbacks and teardown hooks run, and the transaction level snapshot is taken after all other setup hooks (including transactional fixtures) have completed.
207
217
 
208
218
  If you're using plain Minitest (without `ActiveSupport::TestCase`), you can manually include the helper module:
209
219
 
@@ -223,7 +233,7 @@ Alternatively, you can manually use the `testing` method with minitest-hooks:
223
233
  class MyTests < Minitest::Test
224
234
  # Using minitest-hooks gem
225
235
  def around(&block)
226
- Sidekiq::TransactionGuard.testing(base_transaction_level: 1) do
236
+ Sidekiq::TransactionGuard.testing do
227
237
  block.call
228
238
  end
229
239
  end
@@ -240,6 +250,8 @@ Sidekiq::TransactionGuard.disable do
240
250
  end
241
251
  ```
242
252
 
253
+ The guard is only disabled for the current thread, so jobs enqueued concurrently in other threads are still checked.
254
+
243
255
  ## Installation
244
256
 
245
257
  Add this line to your application's Gemfile:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.1
1
+ 1.1.3
@@ -24,7 +24,9 @@ module Sidekiq
24
24
  private
25
25
 
26
26
  def worker_mode(job)
27
- read_sidekiq_option(job, :transaction_guard) || Sidekiq::TransactionGuard.mode
27
+ mode = read_sidekiq_option(job, :transaction_guard)
28
+ mode = mode.to_sym if mode.is_a?(String)
29
+ mode || Sidekiq::TransactionGuard.mode
28
30
  end
29
31
 
30
32
  def in_transaction?
@@ -47,7 +49,6 @@ module Sidekiq
47
49
  end
48
50
 
49
51
  def read_sidekiq_option(job, option_name)
50
- # options = worker_class.sidekiq_options_hash
51
52
  job[option_name.to_s]
52
53
  end
53
54
 
@@ -2,12 +2,22 @@
2
2
 
3
3
  require "minitest"
4
4
 
5
+ require "sidekiq/transaction_guard"
6
+
5
7
  module Sidekiq
6
8
  module TransactionGuard
7
9
  # Minitest helper module for testing with Sidekiq::TransactionGuard.
8
10
  #
9
- # Include this module in your test class to automatically wrap tests in the
10
- # Sidekiq::TransactionGuard.testing block and handle transactional fixtures.
11
+ # Include this module in your test class to automatically set up transaction
12
+ # tracking for each test. If `ActiveSupport::TestCase` is defined when this
13
+ # file is required, the module is included there automatically.
14
+ #
15
+ # The integration uses Minitest's `before_setup`/`after_teardown` lifecycle
16
+ # hooks. The transaction level snapshot is taken after all other setup hooks
17
+ # have run (including transactional fixtures), so transactions opened by test
18
+ # setup are ignored, and the snapshot stays in effect for the entire test.
19
+ # The guard runs in `:error` mode during the test and is disabled while
20
+ # setup and teardown hooks run.
11
21
  #
12
22
  # @example
13
23
  # class MyTest < Minitest::Test
@@ -18,46 +28,41 @@ module Sidekiq
18
28
  # end
19
29
  # end
20
30
  module MinitestHelper
21
- def self.included(base)
22
- base.class_eval do
23
- # Save the original mode before the test suite runs
24
- @@sidekiq_transaction_guard_mode = Sidekiq::TransactionGuard.mode
25
- Sidekiq::TransactionGuard.mode = :disabled
31
+ def before_setup
32
+ @sidekiq_transaction_guard_saved_mode = Sidekiq::TransactionGuard.thread_local_mode
33
+ @sidekiq_transaction_guard_saved_state = Sidekiq::TransactionGuard.begin_testing
34
+ # Keep the guard disabled while other before_setup hooks (like
35
+ # transactional fixtures) and setup callbacks run.
36
+ Sidekiq::TransactionGuard.thread_local_mode = :disabled
37
+ super
38
+ # Take the snapshot now that all other setup hooks have run so that
39
+ # transactions opened during setup are treated as the baseline.
40
+ Sidekiq::TransactionGuard.set_allowed_transaction_level(:all)
41
+ Sidekiq::TransactionGuard.thread_local_mode = :error
42
+ end
26
43
 
27
- def setup
28
- @sidekiq_transaction_guard_saved_mode = Sidekiq::TransactionGuard.mode
29
- Sidekiq::TransactionGuard.mode = :error
30
- Sidekiq::TransactionGuard.testing do
31
- @sidekiq_transaction_guard_testing_block = true
32
- super
33
- end
34
- end
44
+ def before_teardown
45
+ Sidekiq::TransactionGuard.thread_local_mode = :disabled
46
+ super
47
+ end
35
48
 
36
- def teardown
37
- super
38
- Sidekiq::TransactionGuard.mode = @sidekiq_transaction_guard_saved_mode
39
- end
49
+ def after_teardown
50
+ super
51
+ ensure
52
+ if defined?(@sidekiq_transaction_guard_saved_state)
53
+ Sidekiq::TransactionGuard.end_testing(@sidekiq_transaction_guard_saved_state)
54
+ end
55
+ if defined?(@sidekiq_transaction_guard_saved_mode)
56
+ Sidekiq::TransactionGuard.thread_local_mode = @sidekiq_transaction_guard_saved_mode
40
57
  end
41
58
  end
42
59
  end
43
60
  end
44
61
  end
45
62
 
46
- # If using ActiveSupport::TestCase, automatically include the helper
63
+ # If using ActiveSupport::TestCase, automatically include the helper.
47
64
  if defined?(ActiveSupport::TestCase)
48
- ActiveSupport::TestCase.class_eval do
49
- def setup
50
- @sidekiq_transaction_guard_saved_mode = Sidekiq::TransactionGuard.mode
51
- Sidekiq::TransactionGuard.mode = :error
52
-
53
- Sidekiq::TransactionGuard.testing do
54
- super
55
- end
56
- end
57
-
58
- def teardown
59
- super
60
- Sidekiq::TransactionGuard.mode = @sidekiq_transaction_guard_saved_mode
61
- end
62
- end
65
+ # Included with `send` so that YARD doesn't try to statically resolve the
66
+ # ActiveSupport::TestCase namespace when generating documentation.
67
+ ActiveSupport::TestCase.send(:include, Sidekiq::TransactionGuard::MinitestHelper)
63
68
  end
@@ -1,46 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- RSpec.configure do |config|
4
- global_sidekiq_transaction_guard_mode = nil
3
+ require "sidekiq/transaction_guard"
4
+
5
+ module Sidekiq
6
+ module TransactionGuard
7
+ # RSpec integration. Requiring this file wraps every example in a
8
+ # `Sidekiq::TransactionGuard.testing` block and manages the transaction guard
9
+ # mode with a thread local override so parallel test threads can't interfere
10
+ # with each other.
11
+ #
12
+ # The per example mode can be controlled with the `:sidekiq_transaction_guard`
13
+ # metadata tag. The value can be a mode symbol, `false` to disable the guard,
14
+ # or `:default` to use the globally configured mode. Untagged examples run
15
+ # with the `:error` mode.
16
+ #
17
+ # This file should be required after rspec-rails (or any other library that
18
+ # opens transactions in its setup hooks) so that the transaction level
19
+ # snapshot taken before each example runs after those transactions are opened.
20
+ #
21
+ # @api private
22
+ module RSpecIntegration
23
+ def self.included(example_group)
24
+ # Nested example groups inherit hooks from their parent group, so only
25
+ # register the hooks on the outermost group that includes this module.
26
+ return if example_group.is_a?(Class) && example_group.superclass.include?(RSpecIntegration)
27
+
28
+ # These are registered as group level hooks so they run after hooks added
29
+ # by modules included earlier — in particular after rspec-rails has opened
30
+ # the transactional fixture transaction — so the snapshot taken here
31
+ # treats transactions opened by test setup as the baseline.
32
+ example_group.before do |example|
33
+ Sidekiq::TransactionGuard::RSpecIntegration.apply_example_mode(example)
34
+ Sidekiq::TransactionGuard.set_allowed_transaction_level(:all)
35
+ end
36
+
37
+ example_group.after do
38
+ # Disable the guard before fixture rollback and other teardown hooks run.
39
+ Sidekiq::TransactionGuard.thread_local_mode = :disabled
40
+ end
41
+ end
42
+
43
+ def self.apply_example_mode(example)
44
+ metadata = example.metadata
45
+ if metadata.key?(:sidekiq_transaction_guard)
46
+ mode = metadata[:sidekiq_transaction_guard]
47
+ mode = :disabled if mode == false
48
+ mode = nil if mode == :default
49
+ mode = :error unless mode.nil? || mode.is_a?(Symbol)
50
+ Sidekiq::TransactionGuard.thread_local_mode = mode
51
+ elsif Sidekiq::TransactionGuard.thread_local_mode == :disabled
52
+ # Apply the default mode only if an earlier hook hasn't already set one.
53
+ Sidekiq::TransactionGuard.thread_local_mode = :error
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
5
59
 
6
- # Disable by default to avoid raising errors in test setup and teardown.
60
+ RSpec.configure do |config|
61
+ # Disable the guard for code running outside of examples, such as suite level
62
+ # setup and teardown.
7
63
  config.before(:suite) do
8
- global_sidekiq_transaction_guard_mode = Sidekiq::TransactionGuard.mode
9
- Sidekiq::TransactionGuard.mode = :disabled
64
+ Sidekiq::TransactionGuard.thread_local_mode = :disabled
10
65
  end
11
66
 
12
67
  config.after(:suite) do
13
- Sidekiq::TransactionGuard.mode = global_sidekiq_transaction_guard_mode
68
+ Sidekiq::TransactionGuard.thread_local_mode = nil
14
69
  end
15
70
 
16
- # Wrap each example in a block that sets up the testing block.
71
+ # Wrap each example in a testing block and keep the guard disabled except
72
+ # while the example itself is running.
17
73
  config.around do |example|
18
- save_val = Sidekiq::TransactionGuard.mode
74
+ saved_mode = Sidekiq::TransactionGuard.thread_local_mode
19
75
  begin
20
- # Set mode to disabled to avoid errors in setup and teardown.
21
- Sidekiq::TransactionGuard.mode = :disabled
22
- Sidekiq::TransactionGuard.testing do
23
- example.run
24
- end
76
+ Sidekiq::TransactionGuard.thread_local_mode = :disabled
77
+ Sidekiq::TransactionGuard.testing { example.run }
25
78
  ensure
26
- Sidekiq::TransactionGuard.mode = save_val
79
+ Sidekiq::TransactionGuard.thread_local_mode = saved_mode
27
80
  end
28
81
  end
29
82
 
30
- # Re-snapshot the allowed transaction level after all setup (including
31
- # transactional fixtures) has run so that setup transactions are ignored.
32
- config.before(:each) do |example|
33
- mode = example.metadata[:sidekiq_transaction_guard]
34
- mode = :disabled if mode == false
35
- mode = global_sidekiq_transaction_guard_mode if mode == :default
36
- mode = :error unless mode.is_a?(Symbol)
37
- Sidekiq::TransactionGuard.mode = mode if Sidekiq::TransactionGuard.mode == :disabled
38
-
39
- Sidekiq::TransactionGuard.set_allowed_transaction_level(:all)
40
- end
41
-
42
- # Restore the disabled mode after example so test teardown doesn't raise errors about transaction levels.
43
- config.after(:each) do
44
- Sidekiq::TransactionGuard.mode = :disabled
45
- end
83
+ config.include Sidekiq::TransactionGuard::RSpecIntegration
46
84
  end
@@ -15,22 +15,27 @@ module Sidekiq
15
15
  @connection_classes = Set.new
16
16
  @notify = nil
17
17
  @mode = :warn
18
+ @testing_context = false
18
19
 
19
20
  class << self
20
21
  VALID_MODES = [:warn, :stderr, :error, :disabled].freeze
21
22
 
22
- # Helper method to add the client middleware to Sidekiq.
23
+ # Initialize Sidekiq::TransactionGuard by adding its client middleware to
24
+ # Sidekiq. The middleware is added to both the client and server
25
+ # configurations since jobs can also be enqueued from within other jobs
26
+ # running in the Sidekiq server process.
23
27
  #
28
+ # @param mode [Symbol, nil] optionally set the global mode (see `mode=`)
24
29
  # @return [void]
25
30
  def init(mode: nil)
26
31
  self.mode = mode if mode
27
32
 
28
33
  Sidekiq.configure_client do |config|
29
- config.client_middleware do |chain|
30
- unless chain.exists?(Sidekiq::TransactionGuard::Middleware)
31
- chain.add Sidekiq::TransactionGuard::Middleware
32
- end
33
- end
34
+ add_client_middleware(config)
35
+ end
36
+
37
+ Sidekiq.configure_server do |config|
38
+ add_client_middleware(config)
34
39
  end
35
40
  end
36
41
 
@@ -46,16 +51,48 @@ module Sidekiq
46
51
  # @return [Symbol] the mode that was set
47
52
  def mode=(symbol)
48
53
  if VALID_MODES.include?(symbol)
49
- @mode = symbol
54
+ @lock.synchronize { @mode = symbol }
50
55
  else
51
56
  raise ArgumentError.new("mode must be one of #{VALID_MODES.inspect}")
52
57
  end
53
58
  end
54
59
 
55
- # Return the current mode.
60
+ # Return the mode in effect for the current thread. This is the thread local
61
+ # mode if one has been set, otherwise the global mode.
56
62
  #
57
63
  # @return [Symbol]
58
- attr_reader :mode
64
+ def mode
65
+ thread_local_mode || default_mode
66
+ end
67
+
68
+ # Return the globally configured mode, ignoring any thread local override.
69
+ #
70
+ # @return [Symbol]
71
+ def default_mode
72
+ @lock.synchronize { @mode }
73
+ end
74
+
75
+ # Set a mode override for the current thread only. This is used by `disable`
76
+ # and the test integrations so that changing the mode in one thread cannot
77
+ # affect jobs being enqueued concurrently in other threads. Set to `nil` to
78
+ # remove the override and fall back to the global mode.
79
+ #
80
+ # @param symbol [Symbol, nil] one of `:warn`, `:stderr`, `:error`, `:disabled`, or nil
81
+ # @return [Symbol, nil] the mode that was set
82
+ def thread_local_mode=(symbol)
83
+ if symbol.nil? || VALID_MODES.include?(symbol)
84
+ Thread.current[:sidekiq_transaction_guard_mode] = symbol
85
+ else
86
+ raise ArgumentError.new("mode must be one of #{VALID_MODES.inspect}")
87
+ end
88
+ end
89
+
90
+ # Return the mode override for the current thread, or nil if none is set.
91
+ #
92
+ # @return [Symbol, nil]
93
+ def thread_local_mode
94
+ Thread.current[:sidekiq_transaction_guard_mode]
95
+ end
59
96
 
60
97
  # Define the global notify block. This block will be called with a Sidekiq
61
98
  # job hash for all jobs enqueued inside transactions if the mode is `:warn`
@@ -64,14 +101,14 @@ module Sidekiq
64
101
  # @yield [Hash] the Sidekiq job hash
65
102
  # @return [void]
66
103
  def notify(&block)
67
- @notify = block
104
+ @lock.synchronize { @notify = block }
68
105
  end
69
106
 
70
107
  # Return the block set as the notify handler with a call to `notify`.
71
108
  #
72
109
  # @return [Proc, nil] the notify block, or nil if none has been set
73
110
  def notify_block
74
- @notify
111
+ @lock.synchronize { @notify }
75
112
  end
76
113
 
77
114
  # Add a class that maintains its own connection pool to the connections
@@ -97,28 +134,29 @@ module Sidekiq
97
134
  # @return [Boolean]
98
135
  def in_transaction?
99
136
  connection_classes.any? do |connection_class|
100
- connection_pool = connection_class.connection_pool
101
- connection = connection_class.connection if connection_pool.active_connection?
102
- if connection
103
- connection.open_transactions > allowed_transaction_level(connection_class)
104
- else
105
- false
106
- end
137
+ connection = active_connection(connection_class)
138
+ next false unless connection
139
+
140
+ connection.open_transactions > allowed_transaction_level(connection_class) &&
141
+ application_transaction_open?(connection)
107
142
  end
108
143
  end
109
144
 
110
145
  # Disable the transaction guard within the provided block. This is useful in test environments when you want to
111
146
  # setup data for your tests without worrying about transaction levels.
112
147
  #
148
+ # The guard is only disabled for the current thread so that jobs enqueued
149
+ # concurrently in other threads are still checked.
150
+ #
113
151
  # @yield the block to execute with the transaction guard disabled
114
152
  # @return [Object] the return value of the block
115
153
  def disable
116
- save_mode = mode
154
+ save_mode = thread_local_mode
117
155
  begin
118
- self.mode = :disabled
156
+ self.thread_local_mode = :disabled
119
157
  yield
120
158
  ensure
121
- self.mode = save_mode
159
+ self.thread_local_mode = save_mode
122
160
  end
123
161
  end
124
162
 
@@ -130,17 +168,45 @@ module Sidekiq
130
168
  # @yield the test block to execute
131
169
  # @return [Object] the return value of the block
132
170
  def testing
133
- var = :sidekiq_rails_transaction_guard
134
- save_val = Thread.current[var]
171
+ saved_state = begin_testing
135
172
  begin
136
- Thread.current[var] = (save_val ? save_val.dup : {})
137
173
  set_allowed_transaction_level(:all)
138
174
  yield
139
175
  ensure
140
- Thread.current[var] = save_val
176
+ end_testing(saved_state)
141
177
  end
142
178
  end
143
179
 
180
+ # Start a testing context on the current thread without a block. This is used
181
+ # by test framework integrations (like the Minitest helper) that cannot wrap
182
+ # the entire test in a single block. Use `testing` instead whenever a block
183
+ # can be used. The returned state must be passed to `end_testing` when the
184
+ # test finishes.
185
+ #
186
+ # This method only allocates the transaction tracking state; it does not
187
+ # capture a transaction level baseline. The caller is responsible for calling
188
+ # `set_allowed_transaction_level` once any setup that opens transactions
189
+ # (e.g. transactional fixtures) has run.
190
+ #
191
+ # @api private
192
+ # @return [Object] opaque saved state to pass to `end_testing`
193
+ def begin_testing
194
+ var = :sidekiq_rails_transaction_guard
195
+ saved_state = Thread.current[var]
196
+ Thread.current[var] = (saved_state ? saved_state.dup : {})
197
+ @testing_context = true
198
+ saved_state
199
+ end
200
+
201
+ # End a testing context started with `begin_testing`.
202
+ #
203
+ # @api private
204
+ # @param saved_state [Object] the state returned by `begin_testing`
205
+ # @return [void]
206
+ def end_testing(saved_state)
207
+ Thread.current[:sidekiq_rails_transaction_guard] = saved_state
208
+ end
209
+
144
210
  # This method needs to be called to set the allowed transaction level for a connection
145
211
  # class (see `add_connection_class` for more info). The current transaction level
146
212
  # for that class's connection will be set as the zero point. This method can only
@@ -161,7 +227,11 @@ module Sidekiq
161
227
 
162
228
  connection_classes = self.connection_classes if connection_classes == :all
163
229
  Array(connection_classes).each do |connection_class|
164
- class_count = connection_class.connection.open_transactions + base_transaction_level
230
+ class_count = begin
231
+ lease_connection(connection_class).open_transactions + base_transaction_level
232
+ rescue ActiveRecord::ConnectionNotEstablished
233
+ base_transaction_level
234
+ end
165
235
  connection_counts[connection_class.name] = class_count
166
236
  end
167
237
  end
@@ -172,6 +242,71 @@ module Sidekiq
172
242
  connection_counts = Thread.current[:sidekiq_rails_transaction_guard]
173
243
  (connection_counts && connection_counts[connection_class.name]) || 0
174
244
  end
245
+
246
+ # Return true if the connection has any open transaction that was not opened by
247
+ # the connection pool pinning machinery (Rails 7.2+). Transactional tests pin a
248
+ # connection and wrap it in a non-joinable transaction that the pool then shares
249
+ # with every thread. Those wrapper transactions are test scaffolding, not
250
+ # application transactions, so they never count against the guard. The pin state
251
+ # is read from the pool rather than from a thread local so that threads without a
252
+ # baseline (e.g. web server threads in system tests) get the correct level.
253
+ def application_transaction_open?(connection)
254
+ return true unless @testing_context
255
+ return true unless connection.respond_to?(:pinned)
256
+
257
+ # The pool changes the pin state under the connection lock. Hold it so the
258
+ # transaction count and the pin state cannot be read from different states.
259
+ connection.lock.synchronize do
260
+ next true unless connection.pinned
261
+
262
+ open_transactions = connection.open_transactions
263
+ open_transactions > pinned_transaction_count(connection, open_transactions)
264
+ end
265
+ end
266
+
267
+ # Return the number of open transactions on a pinned connection that belong to
268
+ # the pinning machinery. The pool counts pins rather than transactions, so the
269
+ # count is only trusted as far as the connection state supports it: it can never
270
+ # exceed the open transactions, and if it accounts for all of them then the
271
+ # innermost one must be non-joinable, since the wrappers always are. Anything
272
+ # else means the wrappers were closed outside of the pinning machinery and
273
+ # nothing should be discounted.
274
+ def pinned_transaction_count(connection, open_transactions)
275
+ depth = connection.pool.instance_variable_get(:@pinned_connections_depth).to_i
276
+ depth = open_transactions if depth > open_transactions
277
+ return 0 if depth == open_transactions && connection.current_transaction.joinable?
278
+
279
+ depth
280
+ end
281
+
282
+ def add_client_middleware(config)
283
+ config.client_middleware do |chain|
284
+ unless chain.exists?(Sidekiq::TransactionGuard::Middleware)
285
+ chain.add Sidekiq::TransactionGuard::Middleware
286
+ end
287
+ end
288
+ end
289
+
290
+ # Return the connection for the class only if the current thread already has
291
+ # one checked out. Checking out a new connection here would be pointless (a
292
+ # freshly checked out connection can't be inside an application transaction)
293
+ # and would tie up connections from pools the code isn't even using.
294
+ def active_connection(connection_class)
295
+ connection = nil
296
+ pool = connection_class.connection_pool
297
+ connection = lease_connection(connection_class) if pool.active_connection?
298
+ connection
299
+ rescue ActiveRecord::ConnectionNotEstablished
300
+ nil
301
+ end
302
+
303
+ def lease_connection(connection_class)
304
+ if connection_class.respond_to?(:lease_connection)
305
+ connection_class.lease_connection
306
+ else
307
+ connection_class.connection
308
+ end
309
+ end
175
310
  end
176
311
  end
177
312
  end
@@ -181,6 +316,7 @@ if defined?(Rails::Railtie)
181
316
  end
182
317
 
183
318
  # Configure the default transaction guard mode for known testing environments.
319
+ # In a Rails application the Railtie initializer will override this default.
184
320
  if ENV["RAILS_ENV"] == "test" || ENV["RACK_ENV"] == "test"
185
321
  Sidekiq::TransactionGuard.mode = :stderr
186
322
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sidekiq-transaction_guard
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -94,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  requirements: []
97
- rubygems_version: 3.6.9
97
+ rubygems_version: 4.0.3
98
98
  specification_version: 4
99
99
  summary: Protect from accidentally invoking Sidekiq jobs when there are open database
100
100
  transactions