Reuse one adapter harness class instead of one per generation - #78
Merged
Conversation
ngan
force-pushed
the
np-reuse-adapter-harness
branch
from
August 3, 2026 23:40
d495bc5 to
2f452f4
Compare
Both adapters built a throwaway test class for every fixture generation,
and neither could be collected. Two roots held them:
- RSpec assigns a permanent constant to every example group it builds
(ExampleGroups.assign_const), and only clears them in World#reset,
which runs before a suite rather than during one.
- rspec-rails includes FixtureSupport into every example group with no
metadata filter, and MinitestAdapter included ActiveRecord::TestFixtures
directly. Either way that runs
ActiveSupport.run_load_hooks(:active_record_fixtures, self), which
appends to ActiveSupport's @loaded array. That array is never pruned.
Removing the constants alone does not free the classes -- verified by
removing them and recounting -- so the fix is to stop building them.
Each adapter now builds one harness lazily and reuses it.
The block that closes over the Cache is kept off the shared class. RSpec
clears the group's examples after each run, since the example holds the
block. Minitest defines its test method on the instance instead of the
class: a test is run by sending its name to the instance, so a singleton
method is enough, and it goes away with the instance. That also leaves
the harness class untouched by generation -- nothing to define, remove,
or clear.
Measured at 300 fixtures: retained heap 26.5 MB -> 21.1 MB, live objects
622,161 -> 452,940, allocations 4,336,506 -> 3,987,015, and harness
classes retained for the whole run 301 -> 1.
Reusing the harness means fixture code that explicitly reaches
`self.class` now mutates a shared class rather than a per-generation one.
Fixture blocks run at instance level, and code they call has its own
`self`, so only a literal `self.class` in a definition is affected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQjkiuuGX2t3TSZKpzqpPv
ngan
force-pushed
the
np-reuse-adapter-harness
branch
from
August 3, 2026 23:47
2f452f4 to
04b6417
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #77. Independent of it — a second thing that accumulated for the whole run.
Problem
To generate a fixture, the adapter has to run your fixture code inside something that behaves like a test, so transactions and Rails test setup work. Both adapters built a throwaway test class for every generation — and neither could ever be collected.
Two separate roots held onto them:
1. An RSpec constant.
ExampleGroup.subclass→set_it_up→ExampleGroups.assign_const(self)(example_group.rb:449)const_sets the group underRSpec::ExampleGroups, so you accumulateFixtureKit,FixtureKit_2,FixtureKit_3… RSpec only clears those inWorld#reset("Reset world to 'scratch' before running suite") andRSpec.reset, neither of which fires mid-run.2. A Rails global.
rspec-rails/configuration.rb:88includesFixtureSupportinto every group with no metadata filter, andMinitestAdapterincludedActiveRecord::TestFixturesdirectly. Either path runs:Removing the constants alone does not free the classes — I removed all of them, forced GC, and recounted: 40 alive before, 40 after. A heap dump traced the survivors to
ActiveSupport.@loaded[:active_record_fixtures].These classes aren't cheap either: the RSpec harness ends up with 65 own instance methods (37 of them assertion delegators rspec-rails
define_methods onto every group) across 30 ancestors, plus aLetDefinitionsmodule, metadata hash, and singleton class.Fix
Since we don't own either registry — and one of them is private Rails internals — the fix is to stop creating classes to leak. Each adapter builds one harness lazily and reuses it.
The block that closes over the
Cacheis kept off the shared class:examplesafter each run, since theExampleholds the block.Minitest::Test#rundoesself.send self.name(test.rb:91), so a singleton method is enough, and it goes away with the instance.That leaves the Minitest harness class completely untouched by generation — nothing to define, remove, or clear. Worth noting what isn't available here:
ActiveSupport::Testing::Declarative#testis justdefine_methodplus a raise-on-redefinition guard, and Minitest'srunnable_methodsis pure reflection over/^test_/names. There's no registry to add to or remove from, so there was never anything to unregister.Impact
At 300 fixtures:
The last row is the real signal: one per generation before, exactly one total after, regardless of suite size. Per-fixture retained growth drops ~43 KB → ~24 KB, at which point the remainder is RSpec's own per-example-group cost.
Why reusing the harness is safe
Per-run state goes through the instance, not the class —
configure_exampleusesexample.example_group_instance.singleton_class(configuration.rb:1582), andwith_around_and_singleton_context_hooksusesexample_group_instance.singleton_class(example.rb:509). A fresh instance per generation means those die each run.I snapshotted the RSpec group across three generations: ivars, own methods, ancestors, metadata keys, serialized metadata size, and every hook collection count were identical. Hooks are written to the group, but
processsubtracts hooks already present inparent_groupsfirst, so it appends once and short-circuits after — idempotent by construction.Global hook firing is identical reused vs. fresh:
before(:context)0,after(:context)0,before(:example)1 per generation, both ways.I also checked
store_before_context_ivars, which copies instance ivars onto a class-level hash — the one path that could carry fixture data onto the group. With four fixtures each setting a 500KB ivar:before_context_ivarsis{}and 0 blobs survive. It runs against the instance's singleton, andrun_after_context_hooksends withbefore_context_ivars.clear.After 5 real generations the harness has 30 ancestors — same as a pristine group — so
Definition#evaluate'scontext.singleton_class.prependlands on the instance, not the class.Known behavior change
Reusing the harness means fixture code that explicitly reaches
self.classnow mutates a shared class instead of a per-generation one. Demonstrated:In practice this is unreachable from normal fixtures: a definition block runs at instance level via
instance_exec, and any factory or library code it calls has its ownself. Only a literalself.class.…written in a definition is affected. Nothing in this repo does that. Flagging it because it is a real reduction in isolation, not because I expect it to bite.Testing
bundle exec rspec— 197 examples, 0 failuresFIXTURE_KIT_INTEGRATION_FRAMEWORK=minitest bundle exec rspec— 197 examples, 0 failuresextendschains): minitest 21 runs / 67 assertions, RSpec 23 examplesNew specs: harness reuse across executes, only one
RSpec::ExampleGroupsconstant no matter how many runs, no retained example after running, the Minitest test method defined on the instance and not the class, and no per-generation state accumulating on the reused harness class across a success and a raise.Note for review
ExampleGroup#examplesis marked# @privatein rspec-core though it is a public method. There is no less-coupled way to drop the example while reusing the group; it is consistent with the existing deliberate use ofconnection.__send__(:execute_batch, …), and there is an inline comment explaining why it is there.🤖 Generated with Claude Code
https://claude.ai/code/session_01PQjkiuuGX2t3TSZKpzqpPv