rollwire 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 51f4628ad7b1e91fb06fe1c63f926f5e7fb72ba9cbb868440ac3c2f8b859f59e
4
+ data.tar.gz: 4c0b9675985c3c78b9ef0c527d5d570c10ec567937c389f9c7793b19d7f1d350
5
+ SHA512:
6
+ metadata.gz: c7d2c77865c9e37b670d7e3f2cde3437ae029d27f0e836a2d51279cd87044d59973f80088a733c4f2bda6e862b8659bcbed2dca3aa785bb1dc0023a164934bf1
7
+ data.tar.gz: 6407b944791b314bb5cfefa109a57e646a7e56ef65d11b163d0281c1cac018de037ca06a1b413b9e99b1915ef94491394b497b483213a5f45afc7d05db094742
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,232 @@
1
+ # Rollwire
2
+
3
+ Rollwire detects `ActiveRecord::Rollback` exceptions that Rails
4
+ silently ignores when a nested transaction joins its parent without creating a
5
+ savepoint.
6
+
7
+ ## The problem
8
+
9
+ Both records are committed in this example:
10
+
11
+ ```ruby
12
+ User.transaction do
13
+ User.create!(name: "Kotori")
14
+
15
+ User.transaction do
16
+ User.create!(name: "Nemu")
17
+ raise ActiveRecord::Rollback
18
+ end
19
+ end
20
+ ```
21
+
22
+ The inner call joins the existing transaction. Rails catches
23
+ `ActiveRecord::Rollback`, but there is no savepoint to roll back and the
24
+ exception does not reach the outer block. This is documented behavior in
25
+ [Active Record transactions](https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#method-i-transaction).
26
+
27
+ Rollwire reports this only when the joined block executes a write
28
+ (`INSERT`, `UPDATE`, `DELETE`, or `SELECT ... FOR UPDATE`) before raising the
29
+ rollback.
30
+
31
+ ## Installation
32
+
33
+ Until version 0.1.0 is published on RubyGems, install from the repository:
34
+
35
+ ```ruby
36
+ gem "rollwire", github: "ydah/rollwire"
37
+ ```
38
+
39
+ Then run:
40
+
41
+ ```console
42
+ bundle install
43
+ ```
44
+
45
+ The gem installs its Active Record patches when required.
46
+
47
+ ## Configuration
48
+
49
+ Add an initializer:
50
+
51
+ ```ruby
52
+ Rollwire.configure do |config|
53
+ config.mode = :raise
54
+ config.min_writes = 1
55
+ config.count_writes = true
56
+ config.capture_outer_location = true
57
+ config.backtrace_lines = 10
58
+ config.strict_patching = false
59
+ config.ignore_if = nil
60
+ end
61
+ ```
62
+
63
+ The Railtie defaults to `:raise` in `test` and `:log` in other Rails
64
+ environments. Setting `mode` explicitly overrides the environment default.
65
+
66
+ | Setting | Default | Purpose |
67
+ | --- | --- | --- |
68
+ | `mode` | `:raise` in test, otherwise `:log` | Select reporting behavior |
69
+ | `min_writes` | `1` | Minimum matching SQL statements before reporting |
70
+ | `count_writes` | `true` | Disable to report even when no write was observed |
71
+ | `capture_outer_location` | `true` | Record the real outer transaction location |
72
+ | `backtrace_lines` | `10` | Control the amount of stack inspected for locations |
73
+ | `strict_patching` | `false` | Raise instead of warning and disabling on an incompatible Active Record API |
74
+ | `ignore_if` | `nil` | Callable that suppresses selected reports |
75
+
76
+ ### Modes
77
+
78
+ - `:log` writes the report to `Rails.logger` (or stderr). It re-raises the
79
+ original rollback inside Rails, so application behavior and final database
80
+ state remain unchanged.
81
+ - `:notify` emits `ignored_rollback.rollwire` with the report in
82
+ `payload[:report]`. Database behavior is unchanged.
83
+ - `:raise` replaces the ignored rollback with
84
+ `Rollwire::IgnoredNestedRollback`. This escapes the joined block
85
+ and rolls back the outer transaction.
86
+
87
+ `:raise` is intentionally test-only: it changes the final database state. In
88
+ the opening example, `:log` and `:notify` leave both Kotori and Nemu committed;
89
+ `:raise` leaves neither record committed.
90
+
91
+ Subscribe to notification mode with:
92
+
93
+ ```ruby
94
+ ActiveSupport::Notifications.subscribe(
95
+ "ignored_rollback.rollwire"
96
+ ) do |*, payload|
97
+ report = payload.fetch(:report)
98
+ ErrorTracker.notify(report.to_s)
99
+ end
100
+ ```
101
+
102
+ ### Suppression
103
+
104
+ Suppress a known-safe region:
105
+
106
+ ```ruby
107
+ Rollwire.suppress do
108
+ LegacyImporter.call
109
+ end
110
+ ```
111
+
112
+ Or ignore reports selectively:
113
+
114
+ ```ruby
115
+ Rollwire.configure do |config|
116
+ config.ignore_if = lambda do |report|
117
+ report.inner_location&.include?("lib/legacy/")
118
+ end
119
+ end
120
+ ```
121
+
122
+ `Rollwire.disable!` and `Rollwire.enable!` disable or
123
+ enable detection globally.
124
+
125
+ ## Transactional tests and fixtures
126
+
127
+ Rails transactional tests use a non-joinable outer transaction. This creates an
128
+ important difference from production:
129
+
130
+ - An application transaction directly inside the test transaction gets a
131
+ savepoint. Its rollback works, so there is no ignored rollback for this gem to
132
+ detect—even if the same single application transaction would be unsafe in
133
+ production.
134
+ - With two application transaction levels (service A calls service B), the
135
+ first application transaction is a joinable savepoint and the second joins
136
+ it. Rollwire detects a rollback in the second level.
137
+
138
+ Installing this gem therefore does not make a single-transaction fixture test
139
+ prove production behavior. It detects the problem only on execution paths where
140
+ a joined nested transaction actually occurs.
141
+
142
+ ## Reports and safety
143
+
144
+ A log report identifies the inner and real outer transaction call sites:
145
+
146
+ ```text
147
+ Rollwire::IgnoredNestedRollback
148
+
149
+ ActiveRecord::Rollback was raised inside a transaction
150
+ that joined an existing transaction.
151
+
152
+ The 2 writes executed inside this block will not be rolled back.
153
+
154
+ Inner transaction:
155
+ app/services/create_user.rb:24
156
+
157
+ Outer transaction:
158
+ app/services/import_users.rb:61
159
+
160
+ Use `requires_new: true` to roll back only the inner block,
161
+ or propagate the failure to the outer transaction.
162
+ ```
163
+
164
+ Write frames are isolated by Active Support execution context and database
165
+ connection. SQL classification uses the payload SQL, not its event name, so
166
+ `update_columns` and raw `execute` writes are included.
167
+
168
+ Reporting failures are converted to warnings. Other than the dedicated
169
+ exception in `:raise` mode, instrumentation errors do not replace the original
170
+ rollback or change transaction behavior.
171
+
172
+ ## Isolator
173
+
174
+ [Isolator](https://github.com/palkan/isolator) detects non-atomic side effects
175
+ and can limit nested transaction depth. Rollwire has a narrower
176
+ purpose: it detects a rollback that was specifically ignored because a nested
177
+ Active Record transaction joined its parent. The gems are complementary.
178
+
179
+ ## Compatibility
180
+
181
+ The CI support matrix is:
182
+
183
+ | Ruby | Active Record 7.1 | Active Record 7.2 | Active Record 8.0 |
184
+ | --- | :---: | :---: | :---: |
185
+ | 3.1 | ✓ | ✓ | — |
186
+ | 3.2 | ✓ | ✓ | ✓ |
187
+ | 3.3 | ✓ | ✓ | ✓ |
188
+ | 3.4 | ✓ | ✓ | ✓ |
189
+ | 4.0 | ✓ | ✓ | ✓ |
190
+
191
+ Ruby 3.1 with Active Record 8.0 is excluded because Active Record 8.0 requires
192
+ Ruby 3.2 or newer. PostgreSQL and MySQL each have an Active Record 8.0 smoke
193
+ job. Rails main runs as an allowed-failure forward-compatibility job.
194
+
195
+ The patches target private Active Record transaction APIs. Unsupported API
196
+ shapes produce a warning and automatically disable detection unless
197
+ `strict_patching` is enabled.
198
+
199
+ ## Performance
200
+
201
+ The non-nested path only checks whether a transaction is already open and then
202
+ immediately delegates to Active Record.
203
+
204
+ `benchmark/non_nested_transaction.rb` measured the following on Ruby 4.0.0,
205
+ Active Record 8.0.2, and SQLite3:
206
+
207
+ | Case | Iterations/second | Time/iteration |
208
+ | --- | ---: | ---: |
209
+ | Without Rollwire | 31,069.5 | 32.19 μs |
210
+ | With Rollwire | 30,310.6 | 32.99 μs |
211
+
212
+ The approximately 2.4% difference fell within `benchmark-ips` measurement
213
+ error in that run. Reproduce it with:
214
+
215
+ ```console
216
+ bundle exec appraisal rails_8_0 ruby benchmark/non_nested_transaction.rb
217
+ ```
218
+
219
+ ## Development
220
+
221
+ ```console
222
+ bundle install
223
+ bundle exec rake
224
+ bundle exec appraisal rails_7_1 rspec
225
+ bundle exec appraisal rails_7_2 rspec
226
+ bundle exec appraisal rails_8_0 rspec
227
+ bundle exec rake build
228
+ ```
229
+
230
+ ## License
231
+
232
+ Rollwire is available under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new(:rubocop)
9
+
10
+ task default: %i[spec rubocop]
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ class Configuration
5
+ MODES = %i[log notify raise].freeze
6
+
7
+ attr_reader :backtrace_lines, :capture_outer_location, :count_writes, :ignore_if,
8
+ :min_writes, :mode, :strict_patching
9
+ attr_accessor :logger
10
+
11
+ def initialize
12
+ @backtrace_lines = 10
13
+ @capture_outer_location = true
14
+ @count_writes = true
15
+ @ignore_if = nil
16
+ @min_writes = 1
17
+ @mode = :log
18
+ @mode_explicitly_configured = false
19
+ @strict_patching = false
20
+ @logger = nil
21
+ end
22
+
23
+ def mode=(value)
24
+ raise ConfigurationError, "mode must be one of: #{MODES.join(', ')}" unless MODES.include?(value)
25
+
26
+ @mode = value
27
+ @mode_explicitly_configured = true
28
+ end
29
+
30
+ def min_writes=(value)
31
+ raise ConfigurationError, "min_writes must be a non-negative Integer" unless value.is_a?(Integer) && value >= 0
32
+
33
+ @min_writes = value
34
+ end
35
+
36
+ def backtrace_lines=(value)
37
+ unless value.is_a?(Integer) && value.positive?
38
+ raise ConfigurationError, "backtrace_lines must be a positive Integer"
39
+ end
40
+
41
+ @backtrace_lines = value
42
+ end
43
+
44
+ def count_writes=(value)
45
+ @count_writes = validate_boolean(:count_writes, value)
46
+ end
47
+
48
+ def capture_outer_location=(value)
49
+ @capture_outer_location = validate_boolean(:capture_outer_location, value)
50
+ end
51
+
52
+ def strict_patching=(value)
53
+ @strict_patching = validate_boolean(:strict_patching, value)
54
+ end
55
+
56
+ def ignore_if=(callable)
57
+ raise ConfigurationError, "ignore_if must be callable or nil" unless callable.nil? || callable.respond_to?(:call)
58
+
59
+ @ignore_if = callable
60
+ end
61
+
62
+ def apply_environment_default!(test:)
63
+ @mode = :raise if test && !@mode_explicitly_configured
64
+ end
65
+
66
+ private
67
+
68
+ def validate_boolean(name, value)
69
+ return value if [true, false].include?(value)
70
+
71
+ raise ConfigurationError, "#{name} must be true or false"
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class PatchingError < Error; end
7
+
8
+ class IgnoredNestedRollback < Error
9
+ attr_reader :report
10
+
11
+ def initialize(report)
12
+ @report = report
13
+ super(report.to_s)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ module ExecutionState
5
+ KEY_PREFIX = "rollwire"
6
+
7
+ module_function
8
+
9
+ def read(key)
10
+ backend[storage_key(key)]
11
+ end
12
+
13
+ def write(key, value)
14
+ backend[storage_key(key)] = value
15
+ end
16
+
17
+ def delete(key)
18
+ backend.delete(storage_key(key))
19
+ end
20
+
21
+ def backend
22
+ ActiveSupport::IsolatedExecutionState
23
+ end
24
+
25
+ def storage_key(key)
26
+ :"#{KEY_PREFIX}.#{key}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module Rollwire
6
+ module Notifier
7
+ module_function
8
+
9
+ def call(report)
10
+ case report.mode
11
+ when :log
12
+ log(report)
13
+ when :notify
14
+ ActiveSupport::Notifications.instrument(Report::EVENT_NAME, report: report)
15
+ when :raise
16
+ raise IgnoredNestedRollback, report
17
+ end
18
+ end
19
+
20
+ def log(report)
21
+ logger = Rollwire.configuration.logger
22
+ logger ||= Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
23
+ logger ? logger.warn(report.to_s) : Kernel.warn(report.to_s)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ module OuterLocation
5
+ LOCATION_IVAR = :@rollwire_outer_location
6
+
7
+ module_function
8
+
9
+ def record(transaction, location)
10
+ transaction.instance_variable_set(LOCATION_IVAR, location)
11
+ end
12
+
13
+ def for(transaction)
14
+ transaction.instance_variable_get(LOCATION_IVAR)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ module Patcher
5
+ module_function
6
+
7
+ def install!
8
+ validate!
9
+ install_database_statements_patch
10
+ install_transaction_manager_patch
11
+ WriteCounter.start!
12
+ end
13
+
14
+ def installed?
15
+ database_statements.ancestors.include?(DatabaseStatementsPatch) &&
16
+ transaction_manager.ancestors.include?(TransactionManagerPatch)
17
+ end
18
+
19
+ def validate!
20
+ validate_method(
21
+ database_statements,
22
+ :transaction,
23
+ required_keywords: %i[requires_new isolation joinable],
24
+ requires_block: true
25
+ )
26
+ validate_method(transaction_manager, :begin_transaction, required_keywords: %i[isolation joinable])
27
+ end
28
+
29
+ def install_database_statements_patch
30
+ return if database_statements.ancestors.include?(DatabaseStatementsPatch)
31
+
32
+ database_statements.prepend(DatabaseStatementsPatch)
33
+ end
34
+ private_class_method :install_database_statements_patch
35
+
36
+ def install_transaction_manager_patch
37
+ return if transaction_manager.ancestors.include?(TransactionManagerPatch)
38
+
39
+ transaction_manager.prepend(TransactionManagerPatch)
40
+ end
41
+ private_class_method :install_transaction_manager_patch
42
+
43
+ def validate_method(owner, name, required_keywords:, requires_block: false)
44
+ raise PatchingError, "#{owner}##{name} is unavailable" unless owner.method_defined?(name)
45
+
46
+ parameters = owner.instance_method(name).parameters
47
+ keyword_names = parameters.filter_map { |type, keyword| keyword if %i[key keyreq].include?(type) }
48
+ accepts_all_keywords = parameters.any? { |type, _| type == :keyrest }
49
+ keywords_compatible = accepts_all_keywords || (required_keywords - keyword_names).empty?
50
+ block_compatible = !requires_block || parameters.any? { |type, _| type == :block }
51
+ return if keywords_compatible && block_compatible
52
+
53
+ raise PatchingError, "#{owner}##{name} has an unsupported signature: #{parameters.inspect}"
54
+ end
55
+ private_class_method :validate_method
56
+
57
+ def database_statements
58
+ ActiveRecord::ConnectionAdapters::DatabaseStatements
59
+ end
60
+ private_class_method :database_statements
61
+
62
+ def transaction_manager
63
+ ActiveRecord::ConnectionAdapters::TransactionManager
64
+ end
65
+ private_class_method :transaction_manager
66
+ end
67
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ module DatabaseStatementsPatch
5
+ def transaction(**options, &block)
6
+ joined = Rollwire.enabled? &&
7
+ open_transactions.positive? &&
8
+ !options[:requires_new] &&
9
+ current_transaction.joinable?
10
+
11
+ return super(**options, &block) unless joined && block
12
+
13
+ pushed = false
14
+ begin
15
+ inner_location = Rollwire.caller_location
16
+ WriteCounter.push_frame(self)
17
+ pushed = true
18
+ rescue StandardError => e
19
+ Rollwire.warn_internal_error(e)
20
+ return super(**options, &block)
21
+ end
22
+
23
+ super(**options) do |*arguments|
24
+ block.call(*arguments)
25
+ rescue ActiveRecord::Rollback => e
26
+ report_ignored_rollback(inner_location)
27
+ raise e
28
+ end
29
+ ensure
30
+ safely_pop_write_frame if pushed
31
+ end
32
+
33
+ private
34
+
35
+ def report_ignored_rollback(inner_location)
36
+ Rollwire.handle_ignored_rollback(
37
+ connection: self,
38
+ inner_location: inner_location,
39
+ writes: WriteCounter.current_writes(self)
40
+ )
41
+ rescue IgnoredNestedRollback
42
+ raise
43
+ rescue StandardError => e
44
+ Rollwire.warn_internal_error(e)
45
+ end
46
+
47
+ def safely_pop_write_frame
48
+ WriteCounter.pop_frame(self)
49
+ rescue StandardError => e
50
+ Rollwire.warn_internal_error(e)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ module TransactionManagerPatch
5
+ def begin_transaction(...)
6
+ location = capture_outer_location
7
+ transaction = super
8
+ record_outer_location(transaction, location)
9
+ transaction
10
+ end
11
+
12
+ private
13
+
14
+ def capture_outer_location
15
+ return unless Rollwire.enabled?
16
+ return unless Rollwire.configuration.capture_outer_location
17
+
18
+ Rollwire.caller_location
19
+ rescue StandardError => e
20
+ Rollwire.warn_internal_error(e)
21
+ nil
22
+ end
23
+
24
+ def record_outer_location(transaction, location)
25
+ OuterLocation.record(transaction, location) if location
26
+ rescue StandardError => e
27
+ Rollwire.warn_internal_error(e)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ class Railtie < Rails::Railtie
5
+ initializer "rollwire.configure" do
6
+ Rollwire.configuration.apply_environment_default!(test: Rails.env.test?)
7
+
8
+ ActiveSupport.on_load(:active_record) do
9
+ Rollwire.install!
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ class Report
5
+ EVENT_NAME = "ignored_rollback.rollwire"
6
+
7
+ attr_reader :inner_location, :mode, :outer_location, :writes
8
+
9
+ def initialize(inner_location:, mode:, outer_location:, writes:)
10
+ @inner_location = inner_location
11
+ @mode = mode
12
+ @outer_location = outer_location
13
+ @writes = writes
14
+ end
15
+
16
+ def to_s
17
+ <<~REPORT.chomp
18
+ Rollwire::IgnoredNestedRollback
19
+
20
+ ActiveRecord::Rollback was raised inside a transaction
21
+ that joined an existing transaction.
22
+
23
+ #{write_consequence}
24
+
25
+ Inner transaction:
26
+ #{inner_location || '(unknown)'}
27
+
28
+ Outer transaction:
29
+ #{outer_location || '(not captured)'}
30
+
31
+ Use `requires_new: true` to roll back only the inner block,
32
+ or propagate the failure to the outer transaction.
33
+ REPORT
34
+ end
35
+
36
+ private
37
+
38
+ def write_consequence
39
+ subject = writes == 1 ? "The 1 write" : "The #{writes} writes"
40
+ if mode == :raise
41
+ "#{subject} executed inside this block would not have been rolled back " \
42
+ "(this raise rolled back the outer transaction instead)."
43
+ else
44
+ "#{subject} executed inside this block will not be rolled back."
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollwire
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module Rollwire
6
+ module WriteCounter
7
+ Frame = Struct.new(:connection, :writes, keyword_init: true)
8
+
9
+ IGNORED_EVENT_NAMES = %w[SCHEMA TRANSACTION].freeze
10
+ MUTATION_SQL = /\A(?:INSERT|UPDATE|DELETE)\b/i
11
+ LOCKING_READ_SQL = /\ASELECT\b.*\bFOR\s+UPDATE\b/im
12
+ LEADING_COMMENTS = %r{\A(?:\s|/\*.*?\*/|--[^\n]*(?:\n|\z))*}m
13
+
14
+ module_function
15
+
16
+ def start!
17
+ return if @subscription
18
+
19
+ @subscription_mutex ||= Mutex.new
20
+ @subscription_mutex.synchronize do
21
+ return if @subscription
22
+
23
+ @subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |*arguments|
24
+ record(arguments.last)
25
+ rescue StandardError => e
26
+ Rollwire.warn_internal_error(e)
27
+ end
28
+ end
29
+ end
30
+
31
+ def push_frame(connection)
32
+ current_frames! << Frame.new(connection: connection, writes: 0)
33
+ end
34
+
35
+ def pop_frame(connection)
36
+ frames = current_frames
37
+ return unless frames
38
+
39
+ index = frames.rindex { |frame| frame.connection.equal?(connection) }
40
+ frame = frames.delete_at(index) if index
41
+ ExecutionState.delete(:write_counter_frames) if frames.empty?
42
+ frame
43
+ end
44
+
45
+ def current_writes(connection)
46
+ frame = current_frames&.reverse_each&.find { |candidate| candidate.connection.equal?(connection) }
47
+ frame&.writes || 0
48
+ end
49
+
50
+ def record(payload)
51
+ return unless Rollwire.configuration.count_writes
52
+ return unless current_frames&.any?
53
+ return unless write_event?(payload)
54
+
55
+ current_frames.each do |frame|
56
+ frame.writes += 1 if frame.connection.equal?(payload[:connection])
57
+ end
58
+ end
59
+
60
+ def reset!
61
+ ExecutionState.delete(:write_counter_frames)
62
+ end
63
+
64
+ def frame_count
65
+ current_frames&.length || 0
66
+ end
67
+
68
+ def current_frames
69
+ ExecutionState.read(:write_counter_frames)
70
+ end
71
+ private_class_method :current_frames
72
+
73
+ def current_frames!
74
+ current_frames || ExecutionState.write(:write_counter_frames, [])
75
+ end
76
+ private_class_method :current_frames!
77
+
78
+ def write_event?(payload)
79
+ return false unless payload.is_a?(Hash)
80
+ return false if IGNORED_EVENT_NAMES.include?(payload[:name])
81
+ return false unless payload[:connection]
82
+
83
+ sql = payload[:sql].to_s.sub(LEADING_COMMENTS, "")
84
+ MUTATION_SQL.match?(sql) || LOCKING_READ_SQL.match?(sql)
85
+ end
86
+ private_class_method :write_event?
87
+ end
88
+ end
data/lib/rollwire.rb ADDED
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/isolated_execution_state"
5
+ require "pathname"
6
+
7
+ require_relative "rollwire/version"
8
+ require_relative "rollwire/configuration"
9
+ require_relative "rollwire/errors"
10
+ require_relative "rollwire/execution_state"
11
+ require_relative "rollwire/outer_location"
12
+ require_relative "rollwire/report"
13
+ require_relative "rollwire/notifier"
14
+ require_relative "rollwire/write_counter"
15
+ require_relative "rollwire/patches/database_statements"
16
+ require_relative "rollwire/patches/transaction_manager"
17
+ require_relative "rollwire/patcher"
18
+
19
+ module Rollwire
20
+ class << self
21
+ attr_reader :configuration
22
+
23
+ def configure
24
+ yield configuration
25
+ configuration
26
+ end
27
+
28
+ def enable!
29
+ @enabled = true
30
+ install!
31
+ end
32
+
33
+ def disable!
34
+ @enabled = false
35
+ end
36
+
37
+ def enabled?
38
+ @enabled && !suppressed?
39
+ end
40
+
41
+ def suppress
42
+ previous_depth = suppression_depth
43
+ ExecutionState.write(:suppression_depth, previous_depth + 1)
44
+ yield
45
+ ensure
46
+ restore_suppression_depth(previous_depth)
47
+ end
48
+
49
+ def install!
50
+ Patcher.install!
51
+ @enabled = true if @enabled.nil?
52
+ true
53
+ rescue StandardError => e
54
+ raise if configuration.strict_patching
55
+
56
+ @enabled = false
57
+ warn_internal_error(e)
58
+ false
59
+ end
60
+
61
+ def handle_ignored_rollback(connection:, inner_location:, writes:)
62
+ return if configuration.count_writes && writes < configuration.min_writes
63
+
64
+ report = Report.new(
65
+ inner_location: inner_location,
66
+ mode: configuration.mode,
67
+ outer_location: OuterLocation.for(connection.current_transaction),
68
+ writes: writes
69
+ )
70
+ return if configuration.ignore_if&.call(report)
71
+
72
+ Notifier.call(report)
73
+ report
74
+ end
75
+
76
+ def caller_location
77
+ locations = caller_locations(2, [configuration.backtrace_lines * 20, 20].max)
78
+ location = locations.find { |candidate| application_location?(candidate.path) }
79
+ format_location(location)
80
+ end
81
+
82
+ def warn_internal_error(error)
83
+ message = "[Rollwire] internal error: #{error.class}: #{error.message}"
84
+ logger = configuration.logger || rails_logger
85
+ logger ? logger.warn(message) : Kernel.warn(message)
86
+ rescue StandardError
87
+ Kernel.warn(message)
88
+ rescue Exception # rubocop:disable Lint/RescueException
89
+ nil
90
+ end
91
+
92
+ def reset_configuration!
93
+ @configuration = Configuration.new
94
+ @enabled = true
95
+ end
96
+
97
+ private
98
+
99
+ def application_location?(path)
100
+ !path.include?("/active_record/") &&
101
+ !path.include?("/active_support/") &&
102
+ !path.include?("/lib/rollwire")
103
+ end
104
+
105
+ def format_location(location)
106
+ return unless location
107
+
108
+ path = Pathname.new(location.absolute_path || location.path)
109
+ root = Pathname.new(Dir.pwd)
110
+ path = path.relative_path_from(root) if path.to_s.start_with?("#{root}/")
111
+ "#{path}:#{location.lineno}"
112
+ rescue ArgumentError
113
+ "#{location.path}:#{location.lineno}"
114
+ end
115
+
116
+ def rails_logger
117
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
118
+ end
119
+
120
+ def restore_suppression_depth(depth)
121
+ if depth.zero?
122
+ ExecutionState.delete(:suppression_depth)
123
+ else
124
+ ExecutionState.write(:suppression_depth, depth)
125
+ end
126
+ end
127
+
128
+ def suppressed?
129
+ suppression_depth.positive?
130
+ end
131
+
132
+ def suppression_depth
133
+ ExecutionState.read(:suppression_depth) || 0
134
+ end
135
+ end
136
+
137
+ reset_configuration!
138
+ end
139
+
140
+ require_relative "rollwire/railtie" if defined?(Rails::Railtie)
141
+
142
+ Rollwire.install!
metadata ADDED
@@ -0,0 +1,175 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rollwire
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: appraisal
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.5'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: benchmark
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0.3'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0.3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: benchmark-ips
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.14'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.14'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rspec
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.13'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.13'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rubocop
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.70'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.70'
110
+ - !ruby/object:Gem::Dependency
111
+ name: sqlite3
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '1.7'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '1.7'
124
+ description: |
125
+ Rollwire detects ActiveRecord::Rollback exceptions that are
126
+ silently ignored when a nested transaction joins its parent without a
127
+ savepoint.
128
+ email:
129
+ - t.yudai92@gmail.com
130
+ executables: []
131
+ extensions: []
132
+ extra_rdoc_files: []
133
+ files:
134
+ - LICENSE.txt
135
+ - README.md
136
+ - Rakefile
137
+ - lib/rollwire.rb
138
+ - lib/rollwire/configuration.rb
139
+ - lib/rollwire/errors.rb
140
+ - lib/rollwire/execution_state.rb
141
+ - lib/rollwire/notifier.rb
142
+ - lib/rollwire/outer_location.rb
143
+ - lib/rollwire/patcher.rb
144
+ - lib/rollwire/patches/database_statements.rb
145
+ - lib/rollwire/patches/transaction_manager.rb
146
+ - lib/rollwire/railtie.rb
147
+ - lib/rollwire/report.rb
148
+ - lib/rollwire/version.rb
149
+ - lib/rollwire/write_counter.rb
150
+ homepage: https://github.com/ydah/rollwire
151
+ licenses:
152
+ - MIT
153
+ metadata:
154
+ allowed_push_host: https://rubygems.org
155
+ homepage_uri: https://github.com/ydah/rollwire
156
+ source_code_uri: https://github.com/ydah/rollwire
157
+ rubygems_mfa_required: 'true'
158
+ rdoc_options: []
159
+ require_paths:
160
+ - lib
161
+ required_ruby_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: 3.1.0
166
+ required_rubygems_version: !ruby/object:Gem::Requirement
167
+ requirements:
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: '0'
171
+ requirements: []
172
+ rubygems_version: 4.0.6
173
+ specification_version: 4
174
+ summary: Detect ignored ActiveRecord rollbacks in joined nested transactions
175
+ test_files: []