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 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollgeist
4
+ class Railtie < Rails::Railtie
5
+ initializer "rollgeist.executor" do |application|
6
+ ExecutionState.install!(application.executor)
7
+ end
8
+ end
9
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rollgeist
4
+ VERSION = "0.1.0"
5
+ 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: []