Rollgeist detects Active Record objects whose database writes were rolled back while their in-memory attributes remained changed.
user = User.find(1) # plan: "free"
ActiveRecord::Base.transaction do
user.update!(plan: "premium")
charge!(user) # raises; the transaction rolls back
rescue ChargeError
nil
end
# The database still says "free", but this object says "premium".
NotificationMailer.upgraded(user).deliver_later if user.plan == "premium"The gem marks records after Rails finishes rolledback!, then reports risky
externalization through as_json, serializable_hash, and to_global_id.
It does not restore attributes or change Rails transaction behavior.
Installation
Add the gem to the application:
gem "rollgeist", "~> 0.1.0"Then run bundle install.
Rollgeist requires Ruby 3.2 or newer and Active Record 7.1 or newer.
Configuration
Create config/initializers/rollgeist.rb:
Rollgeist.configure do |config|
# Defaults to :raise in test and :log elsewhere.
# :raise is intentionally restricted to the test environment.
config.mode = :log
config.warn_on_serialization = true
config.warn_on_global_id = true
config.warn_on_resave = false
config.warn_once = true
config.max_reports_per_request = 5
config.ignore_if = nil
config.enabled_environments = %w[development test]
endProduction is disabled by default because the gem depends on Active Record transaction internals and reports application behavior. Enable it only after observing the signal quality in development and test:
config.enabled_environments = %w[development test production]What is reported
| Operation | Detected after rollback? | Notes |
|---|---|---|
| Successful create/save | Yes | Includes exception-driven rollbacks |
| Successful update/save | Yes | In-memory changed values are reported |
| Destroy | Yes | Rails restores destroyed/frozen state first |
requires_new savepoint |
Yes | Mark survives the outer commit |
Joined nested ActiveRecord::Rollback
|
No | No database rollback occurs |
update_columns / update_column
|
No | No transaction record is registered |
touch |
No | Deliberately outside the 0.1 scope |
update_all, delete_all, raw SQL |
No | No model transaction record is registered |
| Ordinary attribute reads | No | Only externalization watchpoints are monitored |
This is a detector for specific unsafe paths, not a proof that an application is free of rollback-related stale state.
Clearing a mark
A mark is cleared when the object is known to agree with the database again:
user.reload # clears after a successful reload
user.save! # clears after a successful save
user.destroy! # clears after a successful destroyA later rollback attaches a fresh mark. A successful committed! is also a
cleanup point.
Resave reports are opt-in
warn_on_resave defaults to false. Rails calls rolledback! for deadlocks,
serialization failures, and other exception-driven rollbacks, so a legitimate
retry often looks like this:
begin
User.transaction { user.save! }
rescue ActiveRecord::SerializationFailure
retry
endThe successful retry makes the database agree with the object. Reporting it by
default would be noisy and would flag a normal recovery pattern. Set
warn_on_resave = true only when that stricter signal is useful.
Suppression and filtering
Suppress a known-safe block:
Rollgeist.suppress do
audit_payload = user.as_json
endOr filter using the structured report:
config.ignore_if = lambda do |report|
report.model_name == "AuditSnapshot" ||
report.changed_attributes.all? { |name| name == "updated_at" }
endReports are limited per Rails executor. At the default limit, a request that would emit 100 reports logs five reports followed by:
Rollgeist: +95 more suppressed
Outside an executor, a process-wide one-minute fallback window prevents unbounded reporting.
Notifications and reports
Every emitted report publishes ghost_access.rollgeist through
ActiveSupport::Notifications:
ActiveSupport::Notifications.subscribe("ghost_access.rollgeist") do |event|
report = event.payload.fetch(:report)
ErrorTracker.notify(report.to_h)
endReports contain the model name, id, rollback action, changed attribute names
and in-memory values, rollback location, and access location. Subscriber,
filter, and logger failures are reduced to warnings and do not change the
serialization result. Only configured test :raise mode intentionally stops
the watched operation.
Supported versions
| Active Record | Ruby 3.2 | Ruby 3.3 | Ruby 3.4 | Ruby 4.0 |
|---|---|---|---|---|
| 7.1 | CI | CI | — | — |
| 7.2 | CI | CI | CI | — |
| 8.0 | CI | CI | CI | CI |
Rails main runs in CI as an allowed-failure early-warning job because
rolledback!, committed!, and transaction-record registration are internal
APIs. See the internal API probe for the fixed
behavioral assumptions.
Performance
Records that have never been marked use Active Record's original serialization method directly. Rollgeist installs serialization and GlobalID watchpoints only on a record after its write is rolled back, then removes them when the record is normalized.
A local sample on Ruby 3.4.9, Active Record 8.0.5, and a 14-column model
measured 140,978 baseline as_json calls/s and 140,412 guarded calls/s:
0.40% overhead, within benchmark error and the +2% target. The benchmark uses
two unmarked instances of the same class and asserts that both resolve
serializable_hash to Active Record's original method owner.
Reproduce the benchmark with:
BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile \
bundle exec ruby -Ilib benchmark/serialization.rbDevelopment
bundle install
bundle exec appraisal rails_7_1 rspec
bundle exec appraisal rails_7_2 rspec
bundle exec appraisal rails_8_0 rspec
gem build rollgeist.gemspecLicense
Rollgeist is available under the MIT License.