race_guard
race_guard helps detect race conditions in Ruby and Rails applications by combining static and runtime analysis behind an extensible API.
- Principles (v0.1): framework-agnostic core, safe-by-default, prefer low false positives, composable protection, and optional DSLs (see
docs/specs.md). - Source code and issue tracker: github.com/ViniciusPuerto/race_guard.
Requirements
- Ruby 3.1+
Install
Add to your Gemfile:
gem "race_guard"
Optionally pin a release:
gem "race_guard", "~> 0.1"
Then run bundle install.
To install globally with RubyGems:
gem install race_guard
To use the latest revision from GitHub instead of the published gem:
gem "race_guard", github: "ViniciusPuerto/race_guard", branch: "main"
Configuration
RaceGuard keeps a per-process configuration object. Use RaceGuard.configure or read RaceGuard.configuration / RaceGuard.config (aliases).
require "race_guard"
RaceGuard.configure do |c|
c.enable :db_lock_auditor
c.severity :warn
end
enable/disable: turn detectors on and off. Nothing is active until you callenable, so you can load the gem in production with no work unless you opt in and widen environments (below).severity: with one argument, sets the default level for all detectors. With two arguments, sets the level for a single detector (overrides the default for that name). Valid levels::info,:warn,:error,:raise.environments: pass a list ofRACK_ENV/RAILS_ENVnames (as symbols) where race_guard may run. Default is development and test only; in production the config is inactive until you add:production(or otherwise include your deploy environment).
ENV['RACK_ENV'] is read first, then ENV['RAILS_ENV'] if the former is unset. If both are missing, the current environment is treated as development for that check.
| Setting | Default |
|---|---|
| Enabled detectors | None (all off until enable) |
| Default severity | :info |
environments |
development, test |
| Active in default production deploy | No (unless you add production to environments) |
Use reset_configuration! in tests or console to drop the cached singleton and start from defaults.
Context
RaceGuard.context exposes thread-local state: each Ruby thread has its own stack and transaction depth. Nothing is stored in a global Thread hash, so finished threads do not leave behind context entries.
RaceGuard.context.current— immutable snapshot:thread_id(opaqueThread.current.object_id),in_transaction(true when nestedbegin_transactiondepth is positive),protected_blocks(symbols, outermost first — firstpush_protectedis index0, innermost is last),current_rule(reserved, alwaysniluntil the rule engine exists).push_protected/pop_protected— stack helpers;popon an empty stack is a no-op.begin_transaction/end_transaction— nesting counter; extraend_transactionwhen depth is zero is a no-op.RaceGuard.context.reset!— clears context for the current thread only (use in tests; does not resetRaceGuard.configuration).
ActiveRecord transactions (optional)
For Rails apps, you can mirror ActiveRecord::Base.transaction onto the same thread-local depth counter (in_transaction? becomes true for the duration of each nested transaction block, including requires_new: true inner blocks).
require "active_record" # or load via Rails
require "race_guard"
require "race_guard/active_record" # prepends once; or call RaceGuard::ActiveRecord.install_transaction_tracking!
ActiveRecord::Base.transaction do
RaceGuard.context.current.in_transaction? # => true
end
Core RaceGuard.context already exposes begin_transaction / end_transaction for tests or non-AR code paths; the optional file wires ActiveRecord only. Implementation: lib/race_guard/active_record.rb.
Commit safety interceptors (optional, Task 3.2)
After RaceGuard.configure (active environment + reporters as needed), load and install hooks that emit RaceGuard.report events with detector names commit_safety:active_job, commit_safety:action_mailer, commit_safety:net_http, and commit_safety:faraday. Reporting and emitter logic are wrapped so failures do not break the host app.
require "race_guard"
require "race_guard/interceptors"
RaceGuard::Interceptors.install_active_job! # ActiveJob::Base.perform_later
RaceGuard::Interceptors.install_action_mailer! # ActionMailer::MessageDelivery#deliver_later
RaceGuard::Interceptors.install_net_http! # Net::HTTP#request (requires "net/http")
RaceGuard::Interceptors.install_faraday! # Faraday::Connection#run_request
# or RaceGuard::Interceptors.install_all! for each constant that is already loaded
- ActionMailer: the event is emitted after enqueue so Rails is not tripped by reading the message before
MailDeliveryJobruns. - Faraday / ActiveJob: require those libraries before calling the matching
install_*method (or callinstall_all!once dependencies are loaded). Eachinstall_*is idempotent per process.
Implementation: lib/race_guard/interceptors.rb.
Custom commit-safety watches (watch_commit_safety)
Register your own side-effect boundaries so they emit the same style of commit_safety:* events as the built-in interceptors, without wrapping calls in RaceGuard.protect.
RaceGuard.configure do |c|
c.watch_commit_safety :custom do |w|
w.intercept(MyClient, :call)
end
end
intercept(klass, method_name, scope: :auto)— same resolution rules asRaceGuard.watch: only public methods defined onklass(not inherited-only);:autoprefers an instance method when both instance and singleton match.- Detector name — events use
commit_safety:<name>where<name>is the symbol or string you passed towatch_commit_safety. - Idempotent per watch — the same
interceptline does not double-prepend; you may register different watch names that wrap the same method (each emits once per call in prepend order). - Implementation:
lib/race_guard/commit_safety/watcher.rb.
After successful transaction (RaceGuard.after_commit)
Run work after the current ActiveRecord transaction block finishes without raising (same nesting level). If the current thread is not in a transaction, the block runs immediately. Errors inside the block are rescued so they do not take down the host app.
require "race_guard/active_record" # AR transaction patch + depth tracking
ActiveRecord::Base.transaction do
RaceGuard.after_commit { enqueue_follow_up }
end
- Prerequisite: load
race_guard/active_recordsoActiveRecord::Base.transactiondrivesRaceGuard.contextdepth and passes a success flag when the block completes. Without it, usebegin_transaction/end_transactionmanually; deferred callbacks flush onend_transaction(success: true). - Nesting: inner frames flush first; a raised inner block discards that frame’s deferred callbacks while outer frames follow normal success/failure rules.
RaceGuard.context.reset!clears deferred callbacks for the current thread (useful in tests).
Implementation: lib/race_guard/context.rb, lib/race_guard/active_record.rb, lib/race_guard.rb.
DB read–modify–write (Epic 4.1) and lock awareness (4.2)
Opt-in runtime signal when a configured ActiveRecord model reads an attribute in the current thread, then a successful save / save! persists a change to that same attribute. Reports use detector db_lock_auditor:read_modify_write.
- Requires ActiveRecord and that you load the integration that prepends the patches, e.g.
require "race_guard/active_record". - Configure the model classes to audit; untracked classes are not instrumented.
- Semantics: reads are tracked via
read_attributeand_read_attribute(the path used by generated column readers). The write check runs after a successfulsave/save!usingsaved_changes.update/update!are covered because they end insave. Atomic SQL updates viaupdate_all("col = col +/- n")are treated as safe for this detector and clear stale read-journal entries for affected rows. - Lock awareness (4.2): if the same row is written under a pessimistic lock via
with_lock(including nested) orlock!inside a tracked ActiveRecordtransaction(as mirrored ontoRaceGuard.contextby the integration), the RMW report for that change is suppressed for that model; another tracked model in the same process can still report if it is not under an observed lock. Journal state for a row is cleared onlock!to avoid spurious RMW for reads taken before locking. - Thread-local journal with a short TTL and max key count (see
RaceGuard::Context::MutableStore); reads in another thread do not correlate.RaceGuard.context.reset!clears the journal for the current thread and the read–modify–write “inside save” / read re-entrancy thread flags (so a stuck depth from a bad stack unwind in IRB does not skip read capture, which would makermw_read_age_ms_forreturn nil and suppress reports). - Severity: e.g.
c.severity(:'db_lock_auditor:read_modify_write', :warn).
require "active_record"
require "race_guard"
require "race_guard/active_record" # prepends RMW + transaction patches
class Account < ApplicationRecord
end
RaceGuard.configure do |c|
c.add_reporter(RaceGuard::Reporters::LogReporter.new(Logger.new($stdout)))
c.db_lock_read_modify_write_models(Account) # or pass several classes
c.severity(:'db_lock_auditor:read_modify_write', :warn)
end
Implementation: lib/race_guard/db_lock_auditor/read_modify_write.rb.
Smoke test (IRB-quality scenarios, no mtmpdir typo): from the repo root, with dev dependencies installed:
ruby script/smoke_db_lock_rmw.rb
# or: bundle exec ruby -Ilib script/smoke_db_lock_rmw.rb
The script prepends the repo lib/ directory to $LOAD_PATH, so you do not need -Ilib when you run it from the repository root. It uses require "tmpdir" and Dir.tmpdir for a file-backed SQLite DB, asserts one RMW JSON line without a lock, then checks that with_lock, lock! in a transaction, nested with_lock, and two threads using with_lock produce no RMW lines and leaves balance 8 after two decrements.
Index integrity (static, Epic 5)
What it does: finds ActiveRecord-style validates … uniqueness: declarations in Ruby source and checks that each one is backed by a unique database index whose columns match the validation’s fields plus optional scope: (set equality; column order on the index does not matter). This is static analysis only—it does not boot your app to run validations, and it does not detect runtime races by itself (for runtime RMW and locks, see DB read–modify–write under Epic 4 earlier in this README).
Why it matters: uniqueness in Ruby can pass twice under concurrency if the database does not enforce the same constraint (for example two Sidekiq jobs inserting the same logical key). A matching unique index closes that gap.
Rails app: full check (Epic 5.4)
- Add
gem "race_guard"to theGemfileand runbundle installsorequire "race_guard"runs after Rails (typicalBundler.requireorder is enough forRaceGuard::Railtieto register tasks). - Ensure
db/schema.rbexists (or rely on ActiveRecord: if the file is missing, the task usesActiveRecord::Base.connectionto list unique indexes). - Run:
bundle exec rake race_guard:index_integrity
- Exit 0: no missing unique indexes for any scanned uniqueness validation.
- Exit non-zero: at least one violation; STDOUT includes a suggested
add_index :table, [:col, …], unique: trueline per issue.
Scanned layout: all app/models/**/*.rb except paths under app/models/concerns/. Table names are inferred from the file path (for example app/models/user.rb → :users, app/models/admin/user.rb → :admin_users). Custom self.table_name is not read.
CI: run the same rake task in RAILS_ENV=test (or your CI env) after migrations or schema:load; no prompts.
Programmatic API (IRB, scripts, custom CI)
Use the same building blocks the rake task uses:
5.1 — Model scanner (Ruby source only; no Rails required):
require "race_guard/index_integrity/model_scanner"
src = 'validates :slug, uniqueness: { scope: :account_id }'
RaceGuard::IndexIntegrity::ModelScanner.scan_source(src, filename: "app/models/page.rb")
# => [#<struct RaceGuard::IndexIntegrity::UniquenessValidation …>]
RaceGuard::IndexIntegrity::ModelScanner.scan_file("app/models/user.rb")
5.2 — Schema analyzer (db/schema.rb or a connection):
require "race_guard/index_integrity/schema_analyzer"
RaceGuard::IndexIntegrity::SchemaAnalyzer.parse_file("db/schema.rb")
# => [#<struct RaceGuard::IndexIntegrity::IndexDefinition table=…, columns=[…], unique=true, name=…>, …]
# When AR is loaded and a connection exists (e.g. Rails console):
RaceGuard::IndexIntegrity::SchemaAnalyzer.from_connection(ActiveRecord::Base.connection)
5.3 — Comparison (validations + indexes):
require "race_guard/index_integrity/model_scanner"
require "race_guard/index_integrity/schema_analyzer"
require "race_guard/index_integrity/comparison_engine"
v = RaceGuard::IndexIntegrity::ModelScanner.scan_file("app/models/user.rb")
idx = RaceGuard::IndexIntegrity::SchemaAnalyzer.parse_file("db/schema.rb")
RaceGuard::IndexIntegrity::ComparisonEngine.missing_indexes(validations: v, indexes: idx)
# => [] on success, or [#<RaceGuard::IndexIntegrity::MissingIndexViolation …>] with #message and #suggested_migration
5.4 — One-shot runner (same glob + schema path rules as the rake task; pass your app root):
require "race_guard/index_integrity/runner"
RaceGuard::IndexIntegrity::Runner.exit_code_for(Rails.root, stdout: $stdout, stderr: $stderr)
# => 0 or 1
Use any Pathname to your application root (for example Rails.root in Rails).
Limitations (v0.1): partial unique indexes (where: in schema.rb) are skipped; app/models/concerns/ is skipped; path-based table inference may not match non-conventional table names. Details: docs/specs.md (Epic 5).
Implementation: lib/race_guard/index_integrity/model_scanner.rb, lib/race_guard/index_integrity/schema_analyzer.rb, lib/race_guard/index_integrity/comparison_engine.rb, lib/race_guard/index_integrity/runner.rb, lib/race_guard/railtie.rb, lib/tasks/race_guard/index_integrity.rake.
Protection (RaceGuard.protect)
Wrap code so the thread-local context stack records a named block (used by future detectors and by reporting):
RaceGuard.protect(:payment_flow) do
# monitored
end
Nested protect calls push/pop in order (outermost block is first in context.current.protected_blocks). The block body runs between push and pop; pop runs in ensure, so the stack is restored even if the block raises.
When you call RaceGuard.report inside an active protect, the event context hash is merged with protect (innermost block name as a string) and protect_stack (all nested names, outermost first).
Register optional hooks with RaceGuard.configure { |c| c.add_protect_detector(obj) } if obj responds to on_protect_enter(name) / on_protect_exit(name) (see RaceGuard::DetectorRuntime).
Method watch (RaceGuard.watch)
Install a prepend wrapper so every call to a public method defined directly on the class or module runs inside RaceGuard.protect (same stack and reporting hooks as a manual protect):
RaceGuard.watch(MyService, :call)
scope::auto(default) picks an own instance method if one exists onklass, otherwise an own singleton (class) method. If both exist, instance wins. Usescope: :instanceorscope: :singletonto force.- Idempotent: calling
watchagain for the sameklass, method name, and owner is a no-op (no double wrap). Registration is guarded by a mutex so concurrentwatchcalls are safe. - v0.1 limitation: only public methods declared on that class (
public_instance_methods(false)/singleton_class.public_instance_methods(false)) are eligible; inherited-only methods are not matched by:auto.
Implementation: RaceGuard::MethodWatch.
Rules (RaceGuard.define_rule)
Register named rules with a detect / message pair and optional hooks on protect boundaries. Callbacks receive RaceGuard.context.current (a frozen snapshot) and a metadata Hash with symbol keys (for example event:, protect:).
RaceGuard.define_rule(:no_side_effects_in_txn) do |rule|
rule.detect { |ctx, meta| ctx.in_transaction? }
rule.message { |_ctx, _meta| "Side effect while in transaction" }
rule.hook(:protect_enter) { |ctx, meta| # observe only }
rule.run_on :protect_exit # when set, dispatch runs detect on these events
rule.severity :warn # optional; else `severity_for(:rule_name)` from config
end
RaceGuard.configure do |c|
c.enable_rule :no_side_effects_in_txn
end
- Enablement: rules are off until
enable_ruleonRaceGuard::Configuration. In inactive environments (same rules as the rest of the gem),enabled_rule?is false even if the name was toggled on. run_on: if you omit it,detect/messageare not run automatically fromprotect; useRaceGuard::RuleEngine.evaluatefrom tests or future detectors. Withrun_on :protect_enter/:protect_exit,DetectorRuntimedispatches after eachprotectpush/pop.hook: only:protect_enterand:protect_exitare supported in v0.1. Hook failures are swallowed so your app keeps running.- Registry: duplicate rule names raise; tests can call
RaceGuard::RuleEngine.reset_registry!to clear definitions (prepended modules fromwatchare separate).
Implementation: RaceGuard::RuleEngine, RaceGuard::Rule.
Reporting
RaceGuard.report delivers events to any number of reporters. The payload is a RaceGuard::Event; you can also pass a Hash with string or symbol keys (detector, message, severity required). See RaceGuard::Event::SCHEMA for the field contract.
add_reporter/remove_reporter/clear_reporters: register objects responding toreport(event).- Built-in reporters:
RaceGuard::Reporters::LogReporter(stdlib Logger),JsonReporter(one JSON line per event to an IO),FileReporter(append JSONL to a path),WebhookReporter(POST JSON; failures are swallowed so your app is not taken down by a bad URL).
RaceGuard.report does nothing when the configuration is not active in the current environment (same rules as the rest of the gem: default is development/test only).
RaceGuard.configure do |c|
c.add_reporter RaceGuard::Reporters::LogReporter.new(Logger.new($stderr))
c.add_reporter RaceGuard::Reporters::JsonReporter.new($stdout)
end
RaceGuard.report(detector: "demo", message: "hello", severity: :warn, location: "app.rb:1")
Try it in irb
In an app that already lists race_guard in the Gemfile, use bundle exec irb -r race_guard.
When developing the gem from a git clone, from the repository root use bundle exec irb -Ilib -r race_guard.
- Reset and set dev —
RaceGuard.reset_configuration!thenENV["RACK_ENV"] = "development"(or leave unset; it defaults todevelopment). - Register reporters — e.g.
log_io = StringIO.new; RaceGuard.configure { |c| c.add_reporter(RaceGuard::Reporters::LogReporter.new(Logger.new(log_io))) }(in plain IRB use a realLoggerto$stdoutor a file if you do not haveStringIOloaded:require "stringio"first). - Report —
RaceGuard.report(detector: "a", message: "b", severity: :info); inspect your IO orlog_io.string. - File line —
RaceGuard.reset_configuration!;require "tmpdir"; p = File.join(Dir.tmpdir, "rg.jsonl")thenconfigure { |c| c.add_reporter(RaceGuard::Reporters::FileReporter.new(p)) }andRaceGuard.report(...);File.read(p). (UseDir.tmpdir, notDir.mtmpdir.) - Production no-op —
ENV["RACK_ENV"] = "production", re-add aJsonReporterto$stdout, runreport; you should see no new output, thenENV.delete("RACK_ENV")andRaceGuard.reset_configuration!.
Development
bundle install
bundle exec rspec
bundle exec rubocop
ruby script/smoke_db_lock_rmw.rb # DB lock RMW + lock awareness (optional)
rake # RSpec + RuboCop
To build and install the gem from your checkout into your RubyGems user directory:
bundle exec rake install
Contributing
Please read CONTRIBUTING.md. For conduct expectations see CODE_OF_CONDUCT.md. To report a security issue, use SECURITY.md (do not use public issues). Release history: CHANGELOG.md.
License
MIT — see LICENSE.txt.