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.
The problem
Concurrent Ruby and Rails code often reads state, decides in memory, and writes back (read–modify–write), schedules work outside database transactions, or relies on uniqueness validations without matching unique indexes. Under load, two requests or jobs can interleave and produce lost updates, double work, or duplicate rows. race_guard gives you a single configuration surface, thread-local context, and pluggable reporters so those risks surface in development and CI—not only after production incidents.
Quick start (under five minutes)
- Install —
gem install race_guardor addgem "race_guard"to yourGemfileandbundle install. - Try reporting — from the repo root after
bundle install:
bundle exec irb -Ilib -r race_guard
Then:
require "stringio"
log = StringIO.new
RaceGuard.configure { |c| c.add_reporter(RaceGuard::Reporters::LogReporter.new(Logger.new(log))) }
RaceGuard.report(detector: "demo", message: "hello from race_guard", severity: :info)
puts log.string
You should see a single log line with severity, detector, and message (defaults keep this active in development / test only).
- Optional: Rails demo — a minimal app that triggers the read–modify–write detector lives under
examples/rmw_rails_app; seeexamples/README.md.
Architecture (v0.1)
At a high level, the gem keeps instrumentation and static analysis behind a small runtime core: configuration, context, RaceGuard.report, and reporters. Optional integrations (ActiveRecord transaction mirroring, commit-safety interceptors, Rails Railtie) load only when you require them or when Rails boots.
flowchart TB
subgraph host [Host application]
AppCode[Application code]
AR[ActiveRecord optional]
Rails[Rails Railtie optional]
end
subgraph rg [race_guard core]
Cfg[Configuration active environments reporters]
Ctx[Thread-local Context]
Report["RaceGuard.report Event"]
Eng[Rule engine define_rule]
end
subgraph outputs [Signals]
LogRep[LogReporter]
JsonRep[JsonReporter]
FileRep[FileReporter]
WebRep[WebhookReporter]
end
subgraph detectors [Detectors and scanners]
RMW[DB read-modify-write auditor]
SS[Shared state watcher]
Idx[Index integrity static]
CS[Commit safety hooks]
end
AppCode --> Ctx
AR --> Ctx
Rails --> Cfg
Cfg --> Report
Eng --> Report
detectors --> Report
Report --> outputs
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 install. Requiringrace_guardregistersRaceGuard::Railtiewheneverrailtiesis present (no dependency on Gemfile order). The Railtie loads rake tasks and, once Active Record is loaded, installs transaction tracking and read–modify–write hooks (idempotent with any eager load path).
Optional initializer (Epic 8): run bin/rails generate race_guard:install to create config/initializers/race_guard.rb, then edit RaceGuard.configure (reporters, feature flags, environments, etc.). Defaults keep production out of the active environment list unless you add it explicitly.
- 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.
Shared mutable state (optional)
What it is for: catching unsafe process-wide mutation patterns—class variables (@@x), globals ($x), and instance-variable memoization written as @cache ||= expensive_call in code you choose to scan—when more than one thread can run your app (Puma workers, Sidekiq, concurrent-ruby executors, etc.). It complements row-level tools like DB read–modify–write (which tracks per-record attribute reads vs saves on configured models).
Enable:
RaceGuard.configure do |c|
c.enable(:shared_state_watcher)
c.add_reporter(RaceGuard::Reporters::LogReporter.new(Logger.new($stderr)))
# optional: tune severities
c.severity(:'shared_state:conflict', :warn)
c.severity(:'shared_state:memoization', :info)
end
Runtime signals (when Ruby exposes them): the gem installs a TracePoint for class- and global-variable assignments and normalizes each event into an internal AccessEvent. On CRuby 3.x today, public TracePoint does not offer those assignment events, so the listener is not installed and you see a one-time Kernel.warn explaining that—there is no slow global fallback. When a Ruby build accepts them, the same configuration starts receiving events without API changes.
Conflict reporting: when assignment events are available (or when you drive the same pipeline from tests), the tracker looks for unprotected concurrent writes to the same logical key from different threads, and read/write overlap when both reads and writes are observed. Accesses that run inside Mutex#synchronize (detected via the call stack) are treated as synchronized: they do not emit conflict reports for that pattern.
Memoization scan: opt in with one or more globs of Ruby files to scan for @ivar ||= rhs:
RaceGuard.configure do |c|
c.enable(:shared_state_watcher)
c.shared_state_memo_globs("app/services/**/*.rb", "lib/my_gem/**/*.rb")
end
The scan is static (AST). Reports use detector shared_state:memoization and only fire after the process has started at least one non-main thread (a lightweight thread_begin hook), so single-threaded boot or one-off scripts stay quiet.
Detectors and severities:
| Detector | Meaning |
|---|---|
shared_state:conflict |
Concurrent writes or read/write overlap on a tracked shared key without an enclosing mutex (when events exist). |
shared_state:memoization |
A scanned `@ivar |
Override with c.severity(:'shared_state:conflict', :warn) and c.severity(:'shared_state:memoization', :info) as needed.
Typical use cases:
- Coordinating work through a class variable or global updated from a web thread and a background thread without a clear lock.
- A singleton-style service memoizing
@client ||= BigClient.newin a file under your glob while jobs and HTTP handlers share the same process.
Limitations (v0.1): on current MRI, assignment TracePoint events are unavailable, so automatic cvar/gvar conflict detection does not run until the runtime supports it; memo scanning + thread_begin still work when globs are set. Mutex detection is stack-heuristic and may differ across Ruby versions. Deeper behavior and rationale: docs/specs.md (shared state section).
Implementation: lib/race_guard/shared_state/trace_point.rb, lib/race_guard/shared_state/watcher.rb, lib/race_guard/shared_state/conflict_tracker.rb, lib/race_guard/shared_state/mutex_stack.rb, lib/race_guard/shared_state/memo_scanner.rb, lib/race_guard/shared_state/memo_registry.rb.
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).
Log lines: LogReporter includes optional location on the main line; if you set context: { "suggested_fix" => "…" }, a second line logs that remediation (for operators and CI logs).
JSON shape: the NDJSON object contract is documented in docs/schemas/race_guard_report_event.json (required keys: detector, message, severity, timestamp, context).
Severity :raise: when the configuration is active, RaceGuard.report(..., severity: :raise) runs reporters then raises RaceGuard::ReportRaisedError with the event attached—useful in RSpec or strict pipelines to fail fast after logs/JSON are written.
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
OSS-oriented Rails demo (read–modify–write): examples/README.md.
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.