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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +232 -0
- data/Rakefile +10 -0
- data/lib/rollwire/configuration.rb +74 -0
- data/lib/rollwire/errors.rb +16 -0
- data/lib/rollwire/execution_state.rb +29 -0
- data/lib/rollwire/notifier.rb +26 -0
- data/lib/rollwire/outer_location.rb +17 -0
- data/lib/rollwire/patcher.rb +67 -0
- data/lib/rollwire/patches/database_statements.rb +53 -0
- data/lib/rollwire/patches/transaction_manager.rb +30 -0
- data/lib/rollwire/railtie.rb +13 -0
- data/lib/rollwire/report.rb +48 -0
- data/lib/rollwire/version.rb +5 -0
- data/lib/rollwire/write_counter.rb +88 -0
- data/lib/rollwire.rb +142 -0
- metadata +175 -0
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,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,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: []
|