Skip to content

Replace standalone bundle with a fork-based runner in the file-by-file CI part - #1293

Merged
myronmarston merged 2 commits into
mainfrom
joshuaw/fork-rspec-file-by-file
Jul 6, 2026
Merged

Replace standalone bundle with a fork-based runner in the file-by-file CI part#1293
myronmarston merged 2 commits into
mainfrom
joshuaw/fork-rspec-file-by-file

Conversation

@jwils

@jwils jwils commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Why

The run_specs_file_by_file CI part boots RSpec once per spec file (currently 288 files) to verify every spec file can run in isolation. To avoid paying bundler's boot cost 288 times, it used bundle install --standalone + binstubs, which loads gems from a static $LOAD_PATH file without activating them through RubyGems — so Gem.loaded_specs is empty at runtime.

That's a subtly nonstandard gem environment, and it recently bit us: elastic-transport 8.5.2+ consults Gem.loaded_specs['multi_json'] on every request and crashed only in this CI part, forcing a version pin in #1290 (upstream fix: elastic/elastic-transport-ruby#128).

What

A small runner script (script/rspec_file_by_file) boots bundler and rspec-core once, then runs each spec file in a fork of that parent:

  • The parent never loads any ElasticGraph or spec code, so each child starts from the same clean slate as a freshly booted rspec process — the isolation guarantee this build part exists to check is preserved (verified: a missing-require failure and a deliberately failing spec file both still fail the run, and subsequent files don't run, matching today's fail-fast behavior).
  • Children inherit a normally-activated bundle, so Gem.loaded_specs is populated and gems behave exactly as in production. No more standalone-only failure modes.
  • The runner restores its invocation environment (both ENV and Bundler::ORIGINAL_ENV) after booting bundler, so children — and anything they shell out to via Bundler.with_original_env — observe the same environment a fresh rspec process would. (The first draft missed this and this build part correctly caught it, via schema_definition's rake_tasks_spec; all 7 env-sensitive spec files in the repo now pass under the runner locally.)

Timing

CI (the number that matters): this PR's run_specs_file_by_file job ran in 20m49s, vs the last four runs of the current approach on main: 23m17s, 25m55s, 26m18s, 26m29s (median ~26m). That's a ~5 minute / ~20% improvement, faster than every recent baseline run.

Local measurements (M-series macOS, warm caches, ES 9.4.2, NO_VCR=1):

Workload standalone (current) bundle exec per file fork runner
elasticgraph-support — 19 files, all unit 4.7s 29.4s 3.7s
elasticgraph-admin — 7 files, mostly datastore integration (median of 3 alternating runs) 23s ~27s 33s

The local admin regression is a macOS-specific fork penalty on socket I/O (raw Net::HTTP to local ES is ~40% slower in a forked child on macOS; pure-CPU and allocation-heavy benchmarks show zero fork penalty, and the same benchmark on Linux shows ~no penalty) — consistent with the CI job getting faster while local datastore-heavy gems get slower. Something to be aware of when running the file-by-file loop locally on a Mac, but CI is where this build part runs.

jwils added 2 commits July 3, 2026 17:51
The run_specs_file_by_file CI part boots RSpec once per spec file (288
files) to verify each file can run in isolation. To avoid paying for
bundler on every boot, it used `bundle install --standalone` with
binstubs, which loads gems via a static $LOAD_PATH file without ever
activating them through RubyGems. That leaves `Gem.loaded_specs` empty,
which breaks gems that consult it -- most recently elastic-transport
8.5.2+, which crashed on every request and forced us to pin the gem
(see #1290 and elastic/elastic-transport-ruby#128).

Instead, boot bundler and rspec-core once in a small runner script and
run each spec file in a fork of that parent. The parent loads no
ElasticGraph or spec code, so each child still starts from the same
clean slate as a freshly booted rspec process, preserving the isolation
guarantee -- but children inherit a normally-activated bundle, so gems
that consult `Gem.loaded_specs` behave as they do in production, and we
can drop the standalone workaround.
Booting bundler in the runner parent mutates ENV (BUNDLE_GEMFILE,
RUBYOPT, etc.) and captures that mutated state in
`Bundler::ORIGINAL_ENV`, which forked children inherit. A freshly
booted rspec process has neither: it loads bundler lazily with the
unmodified environment. This difference broke a schema_definition spec
that shells out to `bundle` inside `Bundler.with_original_env` -- the
shelled command picked up the repo root's BUNDLE_GEMFILE instead of the
temp project's Gemfile.

Snapshot the environment before booting bundler and restore both ENV
and `Bundler::ORIGINAL_ENV` afterwards, so children observe the same
environment a fresh process would.

@myronmarston myronmarston left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome! Thanks @jwils.

@myronmarston
myronmarston merged commit f581168 into main Jul 6, 2026
24 checks passed
@myronmarston
myronmarston deleted the joshuaw/fork-rspec-file-by-file branch July 6, 2026 22:19
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.

2 participants