Skip to content

Make Retriable config thread-safe (copy-on-write) - #151

Open
kamui wants to merge 11 commits into
mainfrom
thread-safe-config
Open

Make Retriable config thread-safe (copy-on-write)#151
kamui wants to merge 11 commits into
mainfrom
thread-safe-config

Conversation

@kamui

@kamui kamui commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Summary

  • make global configuration copy-on-write so readers observe complete published snapshots
  • serialize writers while keeping readers non-blocking during configure
  • preserve in-progress configuration visibility for the configuring thread, including retriable, with_context, and fibers
  • synchronize config publication and reads on non-MRI engines while retaining MRI's lock-free read path
  • deep-copy nested mutable configuration values and run with_context against one snapshot
  • reject nested configure calls, including calls from another fiber in the configuring thread, with an actionable ThreadError

Thread-safety contract

  • concurrent configure calls are serialized and publish only after a successful block
  • readers outside the configuring thread see the last fully published snapshot
  • the configuring thread sees its candidate configuration
  • direct mutation through Retriable.config remains outside the thread-safety guarantee; callers should use Retriable.configure

Verification

  • bundle exec rspec (190 examples, 0 failures; 99.6% line coverage)
  • bundle exec rubocop (15 files, no offenses)
  • bundle exec rbs -I sig validate

@kamui kamui mentioned this pull request Jun 13, 2026
@kamui
kamui force-pushed the thread-safe-config branch from f5da97a to 355429c Compare June 13, 2026 20:00
kamui added 11 commits July 13, 2026 00:22
Config#dup now deep-copies its mutable members (on, intervals,
contexts) via initialize_copy so a duplicated config is fully
isolated from the original. Scalars, procs and exception classes
stay shared by reference. This is the foundation for a
copy-on-write Retriable.configure.
Closes two thread-safety bugs:

- Init race: @config ||= Config.new was a check-then-act race that
  could create two Config objects under concurrent boot. @config is
  now eagerly initialized at load time (require is serialized in MRI).

- Torn reads: retriable's fast path read attributes off the live
  shared config one at a time, so a concurrent configure could yield
  an inconsistent mix. configure is now copy-on-write under
  CONFIG_MUTEX (dup -> yield -> atomic publish), so readers always
  observe a consistent, never-mutated snapshot. configure still does
  not validate; validation stays lazy at retriable-call time.
with_context previously read the published config multiple times (the
existence check via available_contexts and option resolution via
context_options_for, and config_contexts itself read config twice). A
concurrent configure publishing a new config between those reads could
let the existence check pass on the old snapshot while options resolved
against the new one, silently dropping the context's retry options.

Capture one config snapshot at the top of with_context and thread it
through available_contexts, context_options_for, and config_contexts so
the whole context resolution sees a single, consistent config.
with_context captured a config snapshot for context lookup and option
resolution, but then called retriable, which re-read config.to_h. A
concurrent configure between the snapshot capture and that re-read could
combine the old context's options with new global config values (e.g. a
changed on/retry_if list).

Extract retriable's body into a private retriable_with_config(base_config,
opts) helper that reads everything from base_config. Public retriable
delegates with the live config (one read, unchanged behavior); with_context
delegates with its captured snapshot, so the whole operation sees one
consistent config.
Config#dup only copied the top-level on/intervals/contexts containers and
each context's options hash one level deep, so mutable values nested deeper
(an intervals array inside a context, or the collection values of a Hash
on) stayed shared with the original. An in-place mutation inside a
configure block could then corrupt the previously published config.

Replace the two shallow helpers with a recursive deep_dup that copies
Hash/Array/Set containers at every level and leaves scalars, procs,
exception classes, and regexps shared by reference.
Address two PR review findings on the copy-on-write config:

- Guard #configure against reentrancy: nesting now raises an explicit
  ThreadError ("cannot be nested") via CONFIG_MUTEX.owned? instead of a
  cryptic recursive-locking deadlock. Preserves the loud-failure semantics
  (a nested update can't silently clobber the outer publish).

- Make the @config read correct on non-MRI engines. The lock-free read
  relies on MRI's GVL for a happens-before edge that JRuby/TruffleRuby do
  not provide. Gate it on RUBY_ENGINE == "ruby" (LOCK_FREE_CONFIG_READ);
  other engines read through CONFIG_MUTEX so they synchronize with
  #configure's publishing write. The JRuby CI job exercises this path.
@kamui
kamui force-pushed the thread-safe-config branch from 8610bc5 to f3eab66 Compare July 13, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant