rollgeist 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 +204 -0
- data/Rakefile +8 -0
- data/docs/rails_internals.md +76 -0
- data/lib/rollgeist/configuration.rb +83 -0
- data/lib/rollgeist/errors.rb +16 -0
- data/lib/rollgeist/execution_state.rb +90 -0
- data/lib/rollgeist/formatter.rb +62 -0
- data/lib/rollgeist/mark.rb +19 -0
- data/lib/rollgeist/notifier.rb +97 -0
- data/lib/rollgeist/patches/persistence.rb +51 -0
- data/lib/rollgeist/patches/transactions.rb +26 -0
- data/lib/rollgeist/railtie.rb +9 -0
- data/lib/rollgeist/record_watchpoints.rb +145 -0
- data/lib/rollgeist/report.rb +87 -0
- data/lib/rollgeist/version.rb +5 -0
- data/lib/rollgeist.rb +192 -0
- metadata +103 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 169d2323578ba1e2e133c2c0baf070eaf1f92d35e2ca062ffef14d365c116522
|
|
4
|
+
data.tar.gz: 185259990e46f5ae1a3380330b79fb8270701aafcd988ec2bace09c1637d6b61
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 618f2cf0dc83a44c444b7b64a79bc3989961f8d365390870576c381a37b8c80bc696af1e6d2b80506a82f5e3910c63aa6c9ba832d457d9b1e9e1e046f5aae7c3
|
|
7
|
+
data.tar.gz: 9a517a2906d9bd8271d9cdad5a9f5a2b58eb280a18f1415fbf4b786d2fae73fdc6f7a91ac2e8b3cc8cc573235a2325d34dfd382bcc2c562544a4819055e46b54
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
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,204 @@
|
|
|
1
|
+
# Rollgeist
|
|
2
|
+
|
|
3
|
+
Rollgeist detects Active Record objects whose database writes were
|
|
4
|
+
rolled back while their in-memory attributes remained changed.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
user = User.find(1) # plan: "free"
|
|
8
|
+
|
|
9
|
+
ActiveRecord::Base.transaction do
|
|
10
|
+
user.update!(plan: "premium")
|
|
11
|
+
charge!(user) # raises; the transaction rolls back
|
|
12
|
+
rescue ChargeError
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The database still says "free", but this object says "premium".
|
|
17
|
+
NotificationMailer.upgraded(user).deliver_later if user.plan == "premium"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The gem marks records after Rails finishes `rolledback!`, then reports risky
|
|
21
|
+
externalization through `as_json`, `serializable_hash`, and `to_global_id`.
|
|
22
|
+
It does not restore attributes or change Rails transaction behavior.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
Add the gem to the application:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
gem "rollgeist", "~> 0.1.0"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then run `bundle install`.
|
|
33
|
+
|
|
34
|
+
Rollgeist requires Ruby 3.2 or newer and Active Record 7.1 or newer.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Create `config/initializers/rollgeist.rb`:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
Rollgeist.configure do |config|
|
|
42
|
+
# Defaults to :raise in test and :log elsewhere.
|
|
43
|
+
# :raise is intentionally restricted to the test environment.
|
|
44
|
+
config.mode = :log
|
|
45
|
+
|
|
46
|
+
config.warn_on_serialization = true
|
|
47
|
+
config.warn_on_global_id = true
|
|
48
|
+
config.warn_on_resave = false
|
|
49
|
+
config.warn_once = true
|
|
50
|
+
config.max_reports_per_request = 5
|
|
51
|
+
config.ignore_if = nil
|
|
52
|
+
config.enabled_environments = %w[development test]
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Production is disabled by default because the gem depends on Active Record
|
|
57
|
+
transaction internals and reports application behavior. Enable it only after
|
|
58
|
+
observing the signal quality in development and test:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
config.enabled_environments = %w[development test production]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## What is reported
|
|
65
|
+
|
|
66
|
+
| Operation | Detected after rollback? | Notes |
|
|
67
|
+
|---|---:|---|
|
|
68
|
+
| Successful create/save | Yes | Includes exception-driven rollbacks |
|
|
69
|
+
| Successful update/save | Yes | In-memory changed values are reported |
|
|
70
|
+
| Destroy | Yes | Rails restores destroyed/frozen state first |
|
|
71
|
+
| `requires_new` savepoint | Yes | Mark survives the outer commit |
|
|
72
|
+
| Joined nested `ActiveRecord::Rollback` | No | No database rollback occurs |
|
|
73
|
+
| `update_columns` / `update_column` | No | No transaction record is registered |
|
|
74
|
+
| `touch` | No | Deliberately outside the 0.1 scope |
|
|
75
|
+
| `update_all`, `delete_all`, raw SQL | No | No model transaction record is registered |
|
|
76
|
+
| Ordinary attribute reads | No | Only externalization watchpoints are monitored |
|
|
77
|
+
|
|
78
|
+
This is a detector for specific unsafe paths, not a proof that an application
|
|
79
|
+
is free of rollback-related stale state.
|
|
80
|
+
|
|
81
|
+
## Clearing a mark
|
|
82
|
+
|
|
83
|
+
A mark is cleared when the object is known to agree with the database again:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
user.reload # clears after a successful reload
|
|
87
|
+
user.save! # clears after a successful save
|
|
88
|
+
user.destroy! # clears after a successful destroy
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A later rollback attaches a fresh mark. A successful `committed!` is also a
|
|
92
|
+
cleanup point.
|
|
93
|
+
|
|
94
|
+
## Resave reports are opt-in
|
|
95
|
+
|
|
96
|
+
`warn_on_resave` defaults to `false`. Rails calls `rolledback!` for deadlocks,
|
|
97
|
+
serialization failures, and other exception-driven rollbacks, so a legitimate
|
|
98
|
+
retry often looks like this:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
begin
|
|
102
|
+
User.transaction { user.save! }
|
|
103
|
+
rescue ActiveRecord::SerializationFailure
|
|
104
|
+
retry
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The successful retry makes the database agree with the object. Reporting it by
|
|
109
|
+
default would be noisy and would flag a normal recovery pattern. Set
|
|
110
|
+
`warn_on_resave = true` only when that stricter signal is useful.
|
|
111
|
+
|
|
112
|
+
## Suppression and filtering
|
|
113
|
+
|
|
114
|
+
Suppress a known-safe block:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
Rollgeist.suppress do
|
|
118
|
+
audit_payload = user.as_json
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Or filter using the structured report:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
config.ignore_if = lambda do |report|
|
|
126
|
+
report.model_name == "AuditSnapshot" ||
|
|
127
|
+
report.changed_attributes.all? { |name| name == "updated_at" }
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Reports are limited per Rails executor. At the default limit, a request that
|
|
132
|
+
would emit 100 reports logs five reports followed by:
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
Rollgeist: +95 more suppressed
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Outside an executor, a process-wide one-minute fallback window prevents
|
|
139
|
+
unbounded reporting.
|
|
140
|
+
|
|
141
|
+
## Notifications and reports
|
|
142
|
+
|
|
143
|
+
Every emitted report publishes `ghost_access.rollgeist` through
|
|
144
|
+
`ActiveSupport::Notifications`:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
ActiveSupport::Notifications.subscribe("ghost_access.rollgeist") do |event|
|
|
148
|
+
report = event.payload.fetch(:report)
|
|
149
|
+
ErrorTracker.notify(report.to_h)
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Reports contain the model name, id, rollback action, changed attribute names
|
|
154
|
+
and in-memory values, rollback location, and access location. Subscriber,
|
|
155
|
+
filter, and logger failures are reduced to warnings and do not change the
|
|
156
|
+
serialization result. Only configured test `:raise` mode intentionally stops
|
|
157
|
+
the watched operation.
|
|
158
|
+
|
|
159
|
+
## Supported versions
|
|
160
|
+
|
|
161
|
+
| Active Record | Ruby 3.2 | Ruby 3.3 | Ruby 3.4 | Ruby 4.0 |
|
|
162
|
+
|---|---:|---:|---:|---:|
|
|
163
|
+
| 7.1 | CI | CI | — | — |
|
|
164
|
+
| 7.2 | CI | CI | CI | — |
|
|
165
|
+
| 8.0 | CI | CI | CI | CI |
|
|
166
|
+
|
|
167
|
+
Rails main runs in CI as an allowed-failure early-warning job because
|
|
168
|
+
`rolledback!`, `committed!`, and transaction-record registration are internal
|
|
169
|
+
APIs. See [the internal API probe](docs/rails_internals.md) for the fixed
|
|
170
|
+
behavioral assumptions.
|
|
171
|
+
|
|
172
|
+
## Performance
|
|
173
|
+
|
|
174
|
+
Records that have never been marked use Active Record's original serialization
|
|
175
|
+
method directly. Rollgeist installs serialization and GlobalID
|
|
176
|
+
watchpoints only on a record after its write is rolled back, then removes them
|
|
177
|
+
when the record is normalized.
|
|
178
|
+
|
|
179
|
+
A local sample on Ruby 3.4.9, Active Record 8.0.5, and a 14-column model
|
|
180
|
+
measured 140,978 baseline `as_json` calls/s and 140,412 guarded calls/s:
|
|
181
|
+
0.40% overhead, within benchmark error and the +2% target. The benchmark uses
|
|
182
|
+
two unmarked instances of the same class and asserts that both resolve
|
|
183
|
+
`serializable_hash` to Active Record's original method owner.
|
|
184
|
+
|
|
185
|
+
Reproduce the benchmark with:
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile \
|
|
189
|
+
bundle exec ruby -Ilib benchmark/serialization.rb
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Development
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
bundle install
|
|
196
|
+
bundle exec appraisal rails_7_1 rspec
|
|
197
|
+
bundle exec appraisal rails_7_2 rspec
|
|
198
|
+
bundle exec appraisal rails_8_0 rspec
|
|
199
|
+
gem build rollgeist.gemspec
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
Rollgeist is available under the [MIT License](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Active Record transaction internals
|
|
2
|
+
|
|
3
|
+
This document fixes the internal API assumptions used by
|
|
4
|
+
`rollgeist` 0.1. Re-run the probe with:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
bundle exec appraisal rails_7_1 ruby script/rails_internals_probe.rb
|
|
8
|
+
bundle exec appraisal rails_7_2 ruby script/rails_internals_probe.rb
|
|
9
|
+
bundle exec appraisal rails_8_0 ruby script/rails_internals_probe.rb
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The results below were captured on Ruby 4.0.0 with Active Record 7.1.6,
|
|
13
|
+
7.2.3, and 8.0.5. The behavioral spec suite repeats the assumptions on every
|
|
14
|
+
supported Appraisal.
|
|
15
|
+
|
|
16
|
+
## Rollback callbacks
|
|
17
|
+
|
|
18
|
+
| Scenario | Rails 7.1 | Rails 7.2 | Rails 8.0 |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| Explicit `ActiveRecord::Rollback` | `rolledback!` called | same | same |
|
|
21
|
+
| Exception escaping the transaction | `rolledback!` called | same | same |
|
|
22
|
+
| Outer transaction rollback arguments | `force_restore_state: true`, `should_run_callbacks: true` | same | same |
|
|
23
|
+
| `requires_new` savepoint rollback arguments | `force_restore_state: false`, `should_run_callbacks: true` | same | same |
|
|
24
|
+
| Joined nested `ActiveRecord::Rollback` | no rollback; outer transaction commits | same | same |
|
|
25
|
+
| `after_rollback` ordering | runs inside `rolledback!` before it returns | same | same |
|
|
26
|
+
|
|
27
|
+
The guard therefore captures dirty metadata before `super` and attaches the
|
|
28
|
+
mark only after `super` returns. Application `after_rollback` callbacks cannot
|
|
29
|
+
observe the mark.
|
|
30
|
+
|
|
31
|
+
## State restored by `rolledback!`
|
|
32
|
+
|
|
33
|
+
| Write | Before `super` | After `super` | Attribute values |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| Create | `new_record? == false`, assigned id | `new_record? == true`, id cleared | submitted values remain in memory |
|
|
36
|
+
| Update | persisted, not frozen | persisted, not frozen | updated values remain in memory |
|
|
37
|
+
| Destroy | destroyed and frozen | not destroyed and not frozen | values remain in memory |
|
|
38
|
+
|
|
39
|
+
For update and create, `saved_changes` still contains the attributes written
|
|
40
|
+
when `rolledback!` starts. `changes_to_save` is already empty. The guard uses
|
|
41
|
+
the former, captured before Rails restores its transaction state.
|
|
42
|
+
|
|
43
|
+
## Transaction-record registration
|
|
44
|
+
|
|
45
|
+
| Operation | Added as a transaction record? | v0.1 marking behavior |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| Successful create/save | yes | marked on rollback |
|
|
48
|
+
| Successful update/save | yes | marked on rollback |
|
|
49
|
+
| Destroy | yes | marked on rollback; a later successful destroy also clears an existing mark |
|
|
50
|
+
| `update_columns` | no | not detected |
|
|
51
|
+
| `touch` | yes | deliberately not marked; direct/timestamp-only writes are outside v0.1 scope |
|
|
52
|
+
| Raw SQL | no model transaction record | not detected |
|
|
53
|
+
|
|
54
|
+
`touch` registration is an important implementation detail: relying only on
|
|
55
|
+
the presence of a `rolledback!` callback would broaden the documented scope.
|
|
56
|
+
The guard records successful `create_or_update` calls separately so a
|
|
57
|
+
touch-only rollback remains unmarked.
|
|
58
|
+
|
|
59
|
+
## Commit behavior
|
|
60
|
+
|
|
61
|
+
A record rolled back by a `requires_new` savepoint is not later passed to
|
|
62
|
+
`committed!` merely because the outer transaction commits. Its mark therefore
|
|
63
|
+
survives the outer commit. Joined nested transactions behave differently:
|
|
64
|
+
swallowing `ActiveRecord::Rollback` does not roll back the joined work, and the
|
|
65
|
+
record receives `committed!` from the outer transaction.
|
|
66
|
+
|
|
67
|
+
Successful reload, save, or destroy clears a mark. A later `committed!` is an
|
|
68
|
+
additional cleanup point. If that save or destroy is itself rolled back,
|
|
69
|
+
`rolledback!` creates a fresh mark after Rails restores the record.
|
|
70
|
+
|
|
71
|
+
## Compatibility policy
|
|
72
|
+
|
|
73
|
+
`rolledback!`, `committed!`, and transaction-record registration are internal
|
|
74
|
+
APIs. The supported Rails matrix is mandatory CI; Rails main runs as an
|
|
75
|
+
allowed-failure compatibility signal so signature or callback-order changes
|
|
76
|
+
are visible before the next release.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rollgeist
|
|
4
|
+
class Configuration
|
|
5
|
+
MODES = %i[log raise].freeze
|
|
6
|
+
|
|
7
|
+
attr_accessor :enabled_environments,
|
|
8
|
+
:ignore_if,
|
|
9
|
+
:logger,
|
|
10
|
+
:max_reports_per_request,
|
|
11
|
+
:warn_once,
|
|
12
|
+
:warn_on_global_id,
|
|
13
|
+
:warn_on_resave,
|
|
14
|
+
:warn_on_serialization
|
|
15
|
+
attr_writer :mode
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@enabled_environments = %w[development test]
|
|
19
|
+
@ignore_if = nil
|
|
20
|
+
@logger = nil
|
|
21
|
+
@max_reports_per_request = 5
|
|
22
|
+
@mode = nil
|
|
23
|
+
@warn_once = true
|
|
24
|
+
@warn_on_global_id = true
|
|
25
|
+
@warn_on_resave = false
|
|
26
|
+
@warn_on_serialization = true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def mode
|
|
30
|
+
@mode || (Rollgeist.environment == "test" ? :raise : :log)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def enabled_in?(environment)
|
|
34
|
+
enabled_environments.map(&:to_s).include?(environment.to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def watchpoint_enabled?(watchpoint)
|
|
38
|
+
case watchpoint
|
|
39
|
+
when :serialization then warn_on_serialization
|
|
40
|
+
when :global_id then warn_on_global_id
|
|
41
|
+
when :resave then warn_on_resave
|
|
42
|
+
else false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate!
|
|
47
|
+
validate_mode!
|
|
48
|
+
validate_limit!
|
|
49
|
+
validate_ignore_predicate!
|
|
50
|
+
validate_environments!
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_mode!
|
|
57
|
+
unless MODES.include?(mode)
|
|
58
|
+
raise ConfigurationError, "mode must be one of: #{MODES.join(", ")}"
|
|
59
|
+
end
|
|
60
|
+
return unless mode == :raise && Rollgeist.environment != "test"
|
|
61
|
+
|
|
62
|
+
raise ConfigurationError, "raise mode is only available in the test environment"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_limit!
|
|
66
|
+
return if max_reports_per_request.is_a?(Integer) && max_reports_per_request.positive?
|
|
67
|
+
|
|
68
|
+
raise ConfigurationError, "max_reports_per_request must be a positive Integer"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_ignore_predicate!
|
|
72
|
+
return if ignore_if.nil? || ignore_if.respond_to?(:call)
|
|
73
|
+
|
|
74
|
+
raise ConfigurationError, "ignore_if must be callable or nil"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_environments!
|
|
78
|
+
return if enabled_environments.is_a?(Array)
|
|
79
|
+
|
|
80
|
+
raise ConfigurationError, "enabled_environments must be an Array"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rollgeist
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
|
|
8
|
+
class GhostRecordAccess < 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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module Rollgeist
|
|
6
|
+
module ExecutionState
|
|
7
|
+
COUNTER_KEY = :__rollgeist_execution_counter
|
|
8
|
+
FALLBACK_WINDOW_SECONDS = 60
|
|
9
|
+
Counter = Struct.new(:reported, :suppressed, :started_at, keyword_init: true)
|
|
10
|
+
Decision = Struct.new(:allowed, :suppressed_to_flush, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def install!(executor = ActiveSupport::Executor)
|
|
14
|
+
@installed_executors ||= {}
|
|
15
|
+
return if @installed_executors[executor.object_id]
|
|
16
|
+
|
|
17
|
+
executor.to_run { Rollgeist::ExecutionState.start! }
|
|
18
|
+
executor.to_complete { Rollgeist::ExecutionState.complete! }
|
|
19
|
+
@installed_executors[executor.object_id] = true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start!
|
|
23
|
+
ActiveSupport::IsolatedExecutionState[COUNTER_KEY] = new_counter
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def complete!
|
|
27
|
+
counter = ActiveSupport::IsolatedExecutionState[COUNTER_KEY]
|
|
28
|
+
ActiveSupport::IsolatedExecutionState[COUNTER_KEY] = nil
|
|
29
|
+
Notifier.report_suppressed(counter.suppressed) if counter&.suppressed.to_i.positive?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def claim(limit)
|
|
33
|
+
counter = ActiveSupport::IsolatedExecutionState[COUNTER_KEY]
|
|
34
|
+
return claim_from(counter, limit) if counter
|
|
35
|
+
|
|
36
|
+
fallback_claim(limit)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset!
|
|
40
|
+
ActiveSupport::IsolatedExecutionState[COUNTER_KEY] = nil
|
|
41
|
+
fallback_mutex.synchronize { @fallback_counter = new_counter }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def claim_from(counter, limit)
|
|
47
|
+
if counter.reported < limit
|
|
48
|
+
counter.reported += 1
|
|
49
|
+
Decision.new(allowed: true)
|
|
50
|
+
else
|
|
51
|
+
counter.suppressed += 1
|
|
52
|
+
Decision.new(allowed: false)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fallback_claim(limit)
|
|
57
|
+
fallback_mutex.synchronize do
|
|
58
|
+
now = monotonic_time
|
|
59
|
+
counter = fallback_counter
|
|
60
|
+
suppressed_to_flush = nil
|
|
61
|
+
|
|
62
|
+
if now - counter.started_at >= FALLBACK_WINDOW_SECONDS
|
|
63
|
+
suppressed_to_flush = counter.suppressed if counter.suppressed.positive?
|
|
64
|
+
counter = @fallback_counter = new_counter(now)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
decision = claim_from(counter, limit)
|
|
68
|
+
decision.suppressed_to_flush = suppressed_to_flush
|
|
69
|
+
decision
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fallback_counter
|
|
74
|
+
@fallback_counter ||= new_counter
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fallback_mutex
|
|
78
|
+
@fallback_mutex ||= Mutex.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def new_counter(started_at = monotonic_time)
|
|
82
|
+
Counter.new(reported: 0, suppressed: 0, started_at: started_at)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def monotonic_time
|
|
86
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rollgeist
|
|
4
|
+
class Formatter
|
|
5
|
+
ACCESS_DESCRIPTIONS = {
|
|
6
|
+
global_id: "was converted to a GlobalID",
|
|
7
|
+
resave: "was saved or destroyed again",
|
|
8
|
+
serialization: "was serialized"
|
|
9
|
+
}.freeze
|
|
10
|
+
ACTION_DESCRIPTIONS = {
|
|
11
|
+
create: "creation",
|
|
12
|
+
destroy: "destruction",
|
|
13
|
+
update: "update"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(report)
|
|
17
|
+
@report = report
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_s
|
|
21
|
+
<<~MESSAGE.chomp
|
|
22
|
+
Rollgeist::GhostRecordAccess
|
|
23
|
+
|
|
24
|
+
#{record_label} #{ACCESS_DESCRIPTIONS.fetch(report.watchpoint)} after its #{ACTION_DESCRIPTIONS.fetch(report.action)} was rolled back.
|
|
25
|
+
|
|
26
|
+
The in-memory object still carries:
|
|
27
|
+
#{formatted_values}
|
|
28
|
+
|
|
29
|
+
Rolled back at:
|
|
30
|
+
#{report.rollback_location || "(location unavailable)"} (#{formatted_age} ago)
|
|
31
|
+
|
|
32
|
+
Accessed at:
|
|
33
|
+
#{report.access_location || "(location unavailable)"}
|
|
34
|
+
|
|
35
|
+
Call `record.reload` before using this object,
|
|
36
|
+
or move the usage inside the transaction.
|
|
37
|
+
MESSAGE
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :report
|
|
43
|
+
|
|
44
|
+
def record_label
|
|
45
|
+
identifier = report.record_id.nil? ? "new" : report.record_id
|
|
46
|
+
"#{report.model_name}##{identifier}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def formatted_values
|
|
50
|
+
return " (no attribute changes captured)" if report.values.empty?
|
|
51
|
+
|
|
52
|
+
report.values.map do |attribute, value|
|
|
53
|
+
" #{attribute}: #{value.inspect} (database: rolled back)"
|
|
54
|
+
end.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def formatted_age
|
|
58
|
+
seconds = report.accessed_at - report.rolled_back_at
|
|
59
|
+
format("%.1fs", [seconds, 0].max)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rollgeist
|
|
4
|
+
class Mark
|
|
5
|
+
ACTIONS = %i[create update destroy].freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :action, :changed_attributes, :rollback_location, :rolled_back_at
|
|
8
|
+
|
|
9
|
+
def initialize(action:, changed_attributes:, rollback_location:, rolled_back_at:)
|
|
10
|
+
raise ArgumentError, "unsupported rollback action: #{action}" unless ACTIONS.include?(action)
|
|
11
|
+
|
|
12
|
+
@action = action
|
|
13
|
+
@changed_attributes = changed_attributes.map(&:to_s).uniq.freeze
|
|
14
|
+
@rollback_location = rollback_location
|
|
15
|
+
@rolled_back_at = rolled_back_at
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rollgeist
|
|
4
|
+
module Notifier
|
|
5
|
+
EVENT_NAME = "ghost_access.rollgeist"
|
|
6
|
+
WATCHPOINT_BITS = {
|
|
7
|
+
serialization: 1,
|
|
8
|
+
global_id: 2,
|
|
9
|
+
resave: 4
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def notify(record, watchpoint)
|
|
14
|
+
return unless Rollgeist.enabled?
|
|
15
|
+
return if Rollgeist.suppressed?
|
|
16
|
+
|
|
17
|
+
configuration = Rollgeist.configuration
|
|
18
|
+
return unless configuration.watchpoint_enabled?(watchpoint)
|
|
19
|
+
|
|
20
|
+
mark = Rollgeist.mark_for(record)
|
|
21
|
+
return unless mark
|
|
22
|
+
|
|
23
|
+
report = Report.build(record: record, mark: mark, watchpoint: watchpoint)
|
|
24
|
+
return if ignored?(configuration, report)
|
|
25
|
+
return if configuration.warn_once && !claim_watchpoint(record, watchpoint)
|
|
26
|
+
|
|
27
|
+
decision = ExecutionState.claim(configuration.max_reports_per_request)
|
|
28
|
+
report_suppressed(decision.suppressed_to_flush) if decision.suppressed_to_flush
|
|
29
|
+
return unless decision.allowed
|
|
30
|
+
|
|
31
|
+
emit(report, configuration)
|
|
32
|
+
rescue GhostRecordAccess
|
|
33
|
+
raise
|
|
34
|
+
rescue StandardError => error
|
|
35
|
+
warn_safely("Rollgeist notifier failure: #{error.class}: #{error.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def report_suppressed(count)
|
|
39
|
+
return unless count.to_i.positive?
|
|
40
|
+
|
|
41
|
+
log_safely("Rollgeist: +#{count} more suppressed")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def ignored?(configuration, report)
|
|
47
|
+
configuration.ignore_if&.call(report)
|
|
48
|
+
rescue StandardError => error
|
|
49
|
+
warn_safely("Rollgeist ignore_if failure: #{error.class}: #{error.message}")
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def claim_watchpoint(record, watchpoint)
|
|
54
|
+
bit = WATCHPOINT_BITS.fetch(watchpoint)
|
|
55
|
+
reported = if record.instance_variable_defined?(REPORTED_IVAR)
|
|
56
|
+
record.instance_variable_get(REPORTED_IVAR)
|
|
57
|
+
else
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
return false unless (reported & bit).zero?
|
|
61
|
+
|
|
62
|
+
record.instance_variable_set(REPORTED_IVAR, reported | bit)
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def emit(report, configuration)
|
|
67
|
+
instrument_safely(report)
|
|
68
|
+
|
|
69
|
+
if configuration.mode == :raise
|
|
70
|
+
raise GhostRecordAccess, report
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
log_safely(report.to_s)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def instrument_safely(report)
|
|
77
|
+
ActiveSupport::Notifications.instrument(EVENT_NAME, report: report)
|
|
78
|
+
rescue StandardError => error
|
|
79
|
+
warn_safely("Rollgeist notification failure: #{error.class}: #{error.message}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def log_safely(message)
|
|
83
|
+
logger = Rollgeist.configuration.logger
|
|
84
|
+
logger ||= Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
|
|
85
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
86
|
+
rescue StandardError => error
|
|
87
|
+
warn_safely("Rollgeist logger failure: #{error.class}: #{error.message}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def warn_safely(message)
|
|
91
|
+
Kernel.warn(message)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rollgeist
|
|
4
|
+
module Patches
|
|
5
|
+
module Persistence
|
|
6
|
+
def reload(...)
|
|
7
|
+
result = super
|
|
8
|
+
Rollgeist.clear_mark!(self)
|
|
9
|
+
result
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def destroy(...)
|
|
13
|
+
return super unless defined?(@__rollgeist_mark)
|
|
14
|
+
|
|
15
|
+
Rollgeist::Notifier.notify(self, :resave)
|
|
16
|
+
mark = instance_variable_get(MARK_IVAR)
|
|
17
|
+
reported = instance_variable_get(REPORTED_IVAR) if instance_variable_defined?(REPORTED_IVAR)
|
|
18
|
+
Rollgeist.clear_mark!(self)
|
|
19
|
+
result = super
|
|
20
|
+
restore_mark(mark, reported) unless result
|
|
21
|
+
result
|
|
22
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
23
|
+
restore_mark(mark, reported) if mark && !frozen?
|
|
24
|
+
raise
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def create_or_update(...)
|
|
30
|
+
if defined?(@__rollgeist_mark)
|
|
31
|
+
Rollgeist::Notifier.notify(self, :resave)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
result = super
|
|
35
|
+
if result
|
|
36
|
+
Rollgeist.clear_mark!(self)
|
|
37
|
+
Rollgeist.mark_saved!(self)
|
|
38
|
+
end
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def restore_mark(mark, reported)
|
|
43
|
+
instance_variable_set(MARK_IVAR, mark)
|
|
44
|
+
instance_variable_set(REPORTED_IVAR, reported) if reported
|
|
45
|
+
Rollgeist::RecordWatchpoints.install!(self)
|
|
46
|
+
rescue StandardError => error
|
|
47
|
+
Rollgeist.tracking_failure("mark restoration", error)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rollgeist
|
|
4
|
+
module Patches
|
|
5
|
+
module Transactions
|
|
6
|
+
def committed!(*args, **kwargs)
|
|
7
|
+
result = super
|
|
8
|
+
Rollgeist.clear_record_state!(self)
|
|
9
|
+
result
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def rolledback!(*args, **kwargs)
|
|
13
|
+
snapshot = Rollgeist.rollback_snapshot(self)
|
|
14
|
+
result = super
|
|
15
|
+
|
|
16
|
+
if snapshot
|
|
17
|
+
Rollgeist.attach_mark!(self, snapshot)
|
|
18
|
+
else
|
|
19
|
+
Rollgeist.clear_transaction_write_state!(self)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
result
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rollgeist
|
|
4
|
+
module RecordWatchpoints
|
|
5
|
+
INSTALLED_IVAR = :@__rollgeist_watchpoints
|
|
6
|
+
SERIALIZATION_BIT = 1
|
|
7
|
+
GLOBAL_ID_BIT = 2
|
|
8
|
+
|
|
9
|
+
SERIALIZATION_ALIAS = :__rollgeist_original_serializable_hash
|
|
10
|
+
SERIALIZATION_STATE = :@__rollgeist_serialization_visibility
|
|
11
|
+
GLOBAL_ID_ALIAS = :__rollgeist_original_to_global_id
|
|
12
|
+
GLOBAL_ID_STATE = :@__rollgeist_global_id_visibility
|
|
13
|
+
INHERITED = :inherited
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def install!(record)
|
|
17
|
+
return if record.instance_variable_defined?(INSTALLED_IVAR)
|
|
18
|
+
|
|
19
|
+
mask = install_serialization(record)
|
|
20
|
+
mask |= install_global_id(record)
|
|
21
|
+
record.instance_variable_set(INSTALLED_IVAR, mask)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
uninstall_mask(record, mask.to_i)
|
|
24
|
+
raise
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def uninstall!(record)
|
|
28
|
+
return unless record.instance_variable_defined?(INSTALLED_IVAR)
|
|
29
|
+
|
|
30
|
+
mask = record.instance_variable_get(INSTALLED_IVAR)
|
|
31
|
+
uninstall_mask(record, mask)
|
|
32
|
+
record.remove_instance_variable(INSTALLED_IVAR)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def install_serialization(record)
|
|
38
|
+
return 0 unless record.respond_to?(:serializable_hash, true)
|
|
39
|
+
|
|
40
|
+
install_method(
|
|
41
|
+
record,
|
|
42
|
+
:serializable_hash,
|
|
43
|
+
SERIALIZATION_ALIAS,
|
|
44
|
+
SERIALIZATION_STATE,
|
|
45
|
+
:serialization
|
|
46
|
+
)
|
|
47
|
+
SERIALIZATION_BIT
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def install_global_id(record)
|
|
51
|
+
return 0 unless record.respond_to?(:to_global_id, true)
|
|
52
|
+
|
|
53
|
+
install_method(
|
|
54
|
+
record,
|
|
55
|
+
:to_global_id,
|
|
56
|
+
GLOBAL_ID_ALIAS,
|
|
57
|
+
GLOBAL_ID_STATE,
|
|
58
|
+
:global_id
|
|
59
|
+
)
|
|
60
|
+
GLOBAL_ID_BIT
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def install_method(record, method_name, alias_name, state_ivar, watchpoint)
|
|
64
|
+
singleton_class = record.singleton_class
|
|
65
|
+
ensure_alias_available!(singleton_class, alias_name)
|
|
66
|
+
original_visibility = own_visibility(singleton_class, method_name)
|
|
67
|
+
inherited_visibility = visibility(singleton_class, method_name)
|
|
68
|
+
|
|
69
|
+
preserve_original(singleton_class, method_name, alias_name) if original_visibility
|
|
70
|
+
singleton_class.instance_variable_set(state_ivar, original_visibility || INHERITED)
|
|
71
|
+
define_watchpoint(singleton_class, method_name, alias_name, state_ivar, watchpoint)
|
|
72
|
+
singleton_class.send(inherited_visibility, method_name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def define_watchpoint(singleton_class, method_name, alias_name, state_ivar, watchpoint)
|
|
76
|
+
singleton_class.define_method(method_name) do |*args, **kwargs, &block|
|
|
77
|
+
Rollgeist::Notifier.notify(self, watchpoint) if defined?(@__rollgeist_mark)
|
|
78
|
+
state = self.singleton_class.instance_variable_get(state_ivar)
|
|
79
|
+
|
|
80
|
+
if state == INHERITED
|
|
81
|
+
kwargs.empty? ? super(*args, &block) : super(*args, **kwargs, &block)
|
|
82
|
+
elsif kwargs.empty?
|
|
83
|
+
__send__(alias_name, *args, &block)
|
|
84
|
+
else
|
|
85
|
+
__send__(alias_name, *args, **kwargs, &block)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def preserve_original(singleton_class, method_name, alias_name)
|
|
91
|
+
singleton_class.alias_method(alias_name, method_name)
|
|
92
|
+
singleton_class.send(:private, alias_name)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def uninstall_mask(record, mask)
|
|
96
|
+
singleton_class = record.singleton_class
|
|
97
|
+
uninstall_method(
|
|
98
|
+
singleton_class,
|
|
99
|
+
:to_global_id,
|
|
100
|
+
GLOBAL_ID_ALIAS,
|
|
101
|
+
GLOBAL_ID_STATE
|
|
102
|
+
) if (mask & GLOBAL_ID_BIT).positive?
|
|
103
|
+
uninstall_method(
|
|
104
|
+
singleton_class,
|
|
105
|
+
:serializable_hash,
|
|
106
|
+
SERIALIZATION_ALIAS,
|
|
107
|
+
SERIALIZATION_STATE
|
|
108
|
+
) if (mask & SERIALIZATION_BIT).positive?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def uninstall_method(singleton_class, method_name, alias_name, state_ivar)
|
|
112
|
+
original_visibility = singleton_class.instance_variable_get(state_ivar)
|
|
113
|
+
singleton_class.remove_method(method_name)
|
|
114
|
+
|
|
115
|
+
unless original_visibility == INHERITED
|
|
116
|
+
singleton_class.alias_method(method_name, alias_name)
|
|
117
|
+
singleton_class.send(original_visibility, method_name)
|
|
118
|
+
singleton_class.remove_method(alias_name)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
singleton_class.remove_instance_variable(state_ivar)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def ensure_alias_available!(singleton_class, alias_name)
|
|
125
|
+
return unless own_visibility(singleton_class, alias_name)
|
|
126
|
+
|
|
127
|
+
raise Error, "record already defines reserved method #{alias_name}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def own_visibility(singleton_class, method_name)
|
|
131
|
+
return :public if singleton_class.public_instance_methods(false).include?(method_name)
|
|
132
|
+
return :protected if singleton_class.protected_instance_methods(false).include?(method_name)
|
|
133
|
+
return :private if singleton_class.private_instance_methods(false).include?(method_name)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def visibility(singleton_class, method_name)
|
|
137
|
+
return :public if singleton_class.public_method_defined?(method_name)
|
|
138
|
+
return :protected if singleton_class.protected_method_defined?(method_name)
|
|
139
|
+
return :private if singleton_class.private_method_defined?(method_name)
|
|
140
|
+
|
|
141
|
+
raise Error, "record does not define #{method_name}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rollgeist
|
|
4
|
+
class Report
|
|
5
|
+
attr_reader :access_location,
|
|
6
|
+
:accessed_at,
|
|
7
|
+
:action,
|
|
8
|
+
:changed_attributes,
|
|
9
|
+
:model_name,
|
|
10
|
+
:record_id,
|
|
11
|
+
:rollback_location,
|
|
12
|
+
:rolled_back_at,
|
|
13
|
+
:values,
|
|
14
|
+
:watchpoint
|
|
15
|
+
|
|
16
|
+
def self.build(record:, mark:, watchpoint:)
|
|
17
|
+
changed_attributes = mark.changed_attributes
|
|
18
|
+
values = changed_attributes.to_h do |attribute|
|
|
19
|
+
[attribute, safely_read(record, attribute)]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
new(
|
|
23
|
+
access_location: Rollgeist.capture_location,
|
|
24
|
+
accessed_at: Time.now,
|
|
25
|
+
action: mark.action,
|
|
26
|
+
changed_attributes: changed_attributes,
|
|
27
|
+
model_name: record.class.name || record.class.to_s,
|
|
28
|
+
record_id: record.id,
|
|
29
|
+
rollback_location: mark.rollback_location,
|
|
30
|
+
rolled_back_at: mark.rolled_back_at,
|
|
31
|
+
values: values,
|
|
32
|
+
watchpoint: watchpoint
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.safely_read(record, attribute)
|
|
37
|
+
record.read_attribute_for_serialization(attribute)
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
"<unavailable: #{error.class}>"
|
|
40
|
+
end
|
|
41
|
+
private_class_method :safely_read
|
|
42
|
+
|
|
43
|
+
def initialize(
|
|
44
|
+
access_location:,
|
|
45
|
+
accessed_at:,
|
|
46
|
+
action:,
|
|
47
|
+
changed_attributes:,
|
|
48
|
+
model_name:,
|
|
49
|
+
record_id:,
|
|
50
|
+
rollback_location:,
|
|
51
|
+
rolled_back_at:,
|
|
52
|
+
values:,
|
|
53
|
+
watchpoint:
|
|
54
|
+
)
|
|
55
|
+
@access_location = access_location
|
|
56
|
+
@accessed_at = accessed_at
|
|
57
|
+
@action = action
|
|
58
|
+
@changed_attributes = changed_attributes
|
|
59
|
+
@model_name = model_name
|
|
60
|
+
@record_id = record_id
|
|
61
|
+
@rollback_location = rollback_location
|
|
62
|
+
@rolled_back_at = rolled_back_at
|
|
63
|
+
@values = values
|
|
64
|
+
@watchpoint = watchpoint
|
|
65
|
+
freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_h
|
|
69
|
+
{
|
|
70
|
+
access_location: access_location,
|
|
71
|
+
accessed_at: accessed_at,
|
|
72
|
+
action: action,
|
|
73
|
+
changed_attributes: changed_attributes,
|
|
74
|
+
model_name: model_name,
|
|
75
|
+
record_id: record_id,
|
|
76
|
+
rollback_location: rollback_location,
|
|
77
|
+
rolled_back_at: rolled_back_at,
|
|
78
|
+
values: values,
|
|
79
|
+
watchpoint: watchpoint
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_s
|
|
84
|
+
Formatter.new(self).to_s
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/rollgeist.rb
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/executor"
|
|
5
|
+
require "active_support/isolated_execution_state"
|
|
6
|
+
require "active_support/lazy_load_hooks"
|
|
7
|
+
require "active_support/notifications"
|
|
8
|
+
|
|
9
|
+
require_relative "rollgeist/version"
|
|
10
|
+
require_relative "rollgeist/configuration"
|
|
11
|
+
require_relative "rollgeist/errors"
|
|
12
|
+
require_relative "rollgeist/execution_state"
|
|
13
|
+
require_relative "rollgeist/formatter"
|
|
14
|
+
require_relative "rollgeist/mark"
|
|
15
|
+
require_relative "rollgeist/notifier"
|
|
16
|
+
require_relative "rollgeist/record_watchpoints"
|
|
17
|
+
require_relative "rollgeist/report"
|
|
18
|
+
require_relative "rollgeist/patches/persistence"
|
|
19
|
+
require_relative "rollgeist/patches/transactions"
|
|
20
|
+
|
|
21
|
+
module Rollgeist
|
|
22
|
+
MARK_IVAR = :@__rollgeist_mark
|
|
23
|
+
REPORTED_IVAR = :@__rollgeist_reported
|
|
24
|
+
SAVED_IVAR = :@__rollgeist_saved
|
|
25
|
+
SUPPRESSION_KEY = :__rollgeist_suppression_depth
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def configuration
|
|
29
|
+
@configuration ||= Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def configure
|
|
33
|
+
yield(configuration)
|
|
34
|
+
configuration.validate!
|
|
35
|
+
configuration
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset!
|
|
39
|
+
@configuration = Configuration.new
|
|
40
|
+
ExecutionState.reset!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def environment
|
|
44
|
+
return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
|
|
45
|
+
|
|
46
|
+
ENV.fetch("RAILS_ENV", ENV.fetch("RACK_ENV", "development"))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def enabled?
|
|
50
|
+
configuration.enabled_in?(environment)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def suppress
|
|
54
|
+
previous_depth = ActiveSupport::IsolatedExecutionState[SUPPRESSION_KEY]
|
|
55
|
+
ActiveSupport::IsolatedExecutionState[SUPPRESSION_KEY] = previous_depth.to_i + 1
|
|
56
|
+
yield
|
|
57
|
+
ensure
|
|
58
|
+
ActiveSupport::IsolatedExecutionState[SUPPRESSION_KEY] = previous_depth
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def suppressed?
|
|
62
|
+
ActiveSupport::IsolatedExecutionState[SUPPRESSION_KEY].to_i.positive?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def install!(active_record_base)
|
|
66
|
+
active_record_base.prepend(Patches::Transactions) unless active_record_base < Patches::Transactions
|
|
67
|
+
active_record_base.prepend(Patches::Persistence) unless active_record_base < Patches::Persistence
|
|
68
|
+
ExecutionState.install!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def mark_for(record)
|
|
72
|
+
record.instance_variable_get(MARK_IVAR)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def mark_saved!(record)
|
|
76
|
+
record.instance_variable_set(SAVED_IVAR, true) if enabled?
|
|
77
|
+
rescue StandardError => error
|
|
78
|
+
tracking_failure("save tracking", error)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def rollback_snapshot(record)
|
|
82
|
+
return unless enabled?
|
|
83
|
+
|
|
84
|
+
action = rollback_action(record)
|
|
85
|
+
return unless action
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
action: action,
|
|
89
|
+
changed_attributes: rollback_changed_attributes(record, action),
|
|
90
|
+
rolled_back_at: Time.now,
|
|
91
|
+
rollback_location: capture_location
|
|
92
|
+
}
|
|
93
|
+
rescue StandardError => error
|
|
94
|
+
tracking_failure("rollback snapshot", error)
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def attach_mark!(record, attributes)
|
|
99
|
+
mark = Mark.new(**attributes)
|
|
100
|
+
RecordWatchpoints.install!(record)
|
|
101
|
+
record.instance_variable_set(MARK_IVAR, mark)
|
|
102
|
+
remove_ivar(record, REPORTED_IVAR)
|
|
103
|
+
clear_transaction_write_state!(record)
|
|
104
|
+
rescue StandardError => error
|
|
105
|
+
tracking_failure("mark attachment", error)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def clear_mark!(record)
|
|
109
|
+
RecordWatchpoints.uninstall!(record)
|
|
110
|
+
remove_ivar(record, MARK_IVAR)
|
|
111
|
+
remove_ivar(record, REPORTED_IVAR)
|
|
112
|
+
rescue StandardError => error
|
|
113
|
+
tracking_failure("mark cleanup", error)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def clear_record_state!(record)
|
|
117
|
+
clear_mark!(record)
|
|
118
|
+
clear_transaction_write_state!(record)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def clear_transaction_write_state!(record)
|
|
122
|
+
remove_ivar(record, SAVED_IVAR)
|
|
123
|
+
rescue StandardError => error
|
|
124
|
+
tracking_failure("transaction-state cleanup", error)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def capture_location
|
|
128
|
+
location = caller_locations(2, 50).find do |candidate|
|
|
129
|
+
application_location?(candidate.path)
|
|
130
|
+
end
|
|
131
|
+
location && "#{location.path}:#{location.lineno}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def tracking_failure(context, error)
|
|
135
|
+
Kernel.warn("Rollgeist #{context} failure: #{error.class}: #{error.message}")
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def rollback_action(record)
|
|
143
|
+
return :destroy if record.destroyed?
|
|
144
|
+
return unless record.instance_variable_defined?(SAVED_IVAR)
|
|
145
|
+
|
|
146
|
+
record.previously_new_record? ? :create : :update
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def rollback_changed_attributes(record, action)
|
|
150
|
+
attributes = if action == :destroy
|
|
151
|
+
record.changes_to_save.keys
|
|
152
|
+
else
|
|
153
|
+
record.saved_changes.keys
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
attributes.map(&:to_s).uniq
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def application_location?(path)
|
|
160
|
+
return false unless path
|
|
161
|
+
return false if path.start_with?(__dir__)
|
|
162
|
+
return false if path.include?("/active_record/")
|
|
163
|
+
return false if path.include?("/active_support/")
|
|
164
|
+
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def remove_ivar(record, ivar)
|
|
169
|
+
return unless record.instance_variable_defined?(ivar)
|
|
170
|
+
|
|
171
|
+
record.remove_instance_variable(ivar)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
begin
|
|
177
|
+
require "global_id"
|
|
178
|
+
rescue LoadError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
ActiveSupport.on_load(:active_record) do
|
|
183
|
+
Rollgeist.install!(self)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
require "rails/railtie"
|
|
188
|
+
rescue LoadError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
require_relative "rollgeist/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rollgeist
|
|
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
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: activesupport
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '7.1'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '7.1'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9'
|
|
52
|
+
description: Marks Active Record instances whose writes were rolled back and reports
|
|
53
|
+
unsafe serialization, GlobalID conversion, or optional resave attempts.
|
|
54
|
+
email:
|
|
55
|
+
- t.yudai92@gmail.com
|
|
56
|
+
executables: []
|
|
57
|
+
extensions: []
|
|
58
|
+
extra_rdoc_files: []
|
|
59
|
+
files:
|
|
60
|
+
- LICENSE.txt
|
|
61
|
+
- README.md
|
|
62
|
+
- Rakefile
|
|
63
|
+
- docs/rails_internals.md
|
|
64
|
+
- lib/rollgeist.rb
|
|
65
|
+
- lib/rollgeist/configuration.rb
|
|
66
|
+
- lib/rollgeist/errors.rb
|
|
67
|
+
- lib/rollgeist/execution_state.rb
|
|
68
|
+
- lib/rollgeist/formatter.rb
|
|
69
|
+
- lib/rollgeist/mark.rb
|
|
70
|
+
- lib/rollgeist/notifier.rb
|
|
71
|
+
- lib/rollgeist/patches/persistence.rb
|
|
72
|
+
- lib/rollgeist/patches/transactions.rb
|
|
73
|
+
- lib/rollgeist/railtie.rb
|
|
74
|
+
- lib/rollgeist/record_watchpoints.rb
|
|
75
|
+
- lib/rollgeist/report.rb
|
|
76
|
+
- lib/rollgeist/version.rb
|
|
77
|
+
homepage: https://github.com/ydah/rollgeist
|
|
78
|
+
licenses:
|
|
79
|
+
- MIT
|
|
80
|
+
metadata:
|
|
81
|
+
allowed_push_host: https://rubygems.org
|
|
82
|
+
homepage_uri: https://rubygems.org/gems/rollgeist
|
|
83
|
+
source_code_uri: https://github.com/ydah/rollgeist
|
|
84
|
+
changelog_uri: https://github.com/ydah/rollgeist/blob/main/CHANGELOG.md
|
|
85
|
+
rubygems_mfa_required: 'true'
|
|
86
|
+
rdoc_options: []
|
|
87
|
+
require_paths:
|
|
88
|
+
- lib
|
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: 3.2.0
|
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '0'
|
|
99
|
+
requirements: []
|
|
100
|
+
rubygems_version: 4.0.6
|
|
101
|
+
specification_version: 4
|
|
102
|
+
summary: Detect Active Record objects left stale after a transaction rollback
|
|
103
|
+
test_files: []
|