Store exposed records as class/id pairs instead of the records - #77
Merged
Conversation
`Definition#expose` held the ActiveRecord objects it was given, and the
registry holds every fixture for the life of the process, so each fixture
kept its whole object graph alive until the suite ended -- attributes and
any warmed association caches included. In a large suite that grows
monotonically as more spec files execute.
Only `Cache#save` ever read `exposed`, and only to convert it into the
class/id pairs the cache stores, so `expose` now does that conversion up
front and never retains a record. `FileCache#serialize_exposed` moves to
`Definition` as a private method; it never touched a file, and it was not
the inverse of anything on `FileCache` -- `FileCache#read` converts
`{"User" => id}` back to `{User => id}` because `JSON.generate` does the
class-to-string half.
Measured at 300 fixtures: retained heap 43.9 MB -> 26.5 MB, live objects
753,181 -> 622,166. Per-fixture retained growth drops from ~101 KB to
~43 KB.
Because a record's id is now read when it is exposed rather than at the
end of the definition, exposing an unsaved record would store a nil id
and silently resolve to nil in tests. Exposing a record that is not
persisted now raises FixtureKit::UnpersistedRecordError, which also
covers records inside an exposed collection and destroyed records. The
one case that cannot be detected is a collection appended to after being
exposed; that is documented in docs/reference.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQjkiuuGX2t3TSZKpzqpPv
ngan
force-pushed
the
np-expose-record-references
branch
from
August 3, 2026 22:53
4c9c6ee to
546d629
Compare
This was referenced Aug 3, 2026
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.
Problem
Definition#exposestored the ActiveRecord objects it was handed:Registry#@fixturesis never pruned and theRunneris memoized on theFixtureKitmodule, so everyDefinitionlives for the whole process — and each one pinned its records, their@attributes, and any association caches the definition warmed while wiring things up. In a large suite that grows monotonically as more spec files execute.The records were dead weight from the moment they were serialized:
Repositoryre-fetches rows per test withfind_by(id:), so only the class/id pairs matter.Confirmed with a root-removal test — weak refs to the exposed records, clear the registry and
@exposed, force GC: 2/2 collected. That chain was their only root.Fix
Only
Cache#saveever readexposed, and only to convert it into class/id pairs, soexposenow does that conversion up front and never retains a record.FileCache#serialize_exposedmoves toDefinitionas a private method. It never touched a file, and it wasn't the inverse of anything onFileCache—FileCache#readconverts{"User" => id}back to{User => id}becauseJSON.generatedoes the class-to-string half.Impact
Measured with a synthetic suite of anonymous fixtures (
ObjectSpace.memsize_of_allandGC.stat(:heap_live_slots), sampled before the diagnostic walk so the instrumentation doesn't inflate what it measures):Per-fixture retained growth: ~101 KB → ~43 KB. Records retained: 0.
Scale note: the benchmark fixture is tiny (1 user, 3 projects, 6 tasks). A fixture exposing a company plus its employees and payrolls retains proportionally more, so the real-world win is larger.
Behavior change
A record's id is now read when
exposeis called rather than at the end of the definition. An unsaved record would therefore store a nil id and silently resolve tonilin tests, so this now raises:The check uses
persisted?, so it also covers destroyed records, and it applies to each record inside an exposed collection.One case can't be detected — a collection appended to after being exposed is captured as-is (empty). There's no way to tell that from a legitimately empty collection, so it's documented in
docs/reference.mdrather than guessed at.Every fixture in this repo, and the documented idiom, call
exposeas the last statement of the definition, which avoids both cases.Notes for review
definition.exposednow returns class/id pairs rather than records. Not documented as public API, but worth a grep in consuming apps for anything reading it after generation.definition_specexamples exposed plain Strings; they use real records now, sinceexposereads.id. Side effect: exposing a non-record fails inside the definition block instead of deep inFileCacheat save time.serialize_exposedspecs moved fromfile_cache_spectodefinition_spec, plus new coverage for collections, STI, the unpersisted/destroyed guards, and thatexposedholds no record references.Testing
bundle exec rspec— 192 examples, 0 failuresFIXTURE_KIT_INTEGRATION_FRAMEWORK=minitest bundle exec rspec— 192 examples, 0 failuresA follow-up PR will address a second, independent leak: the adapters build a throwaway
ExampleGroup/TestCasesubclass per fixture generation, which Rails':active_record_fixturesload-hook registry retains forever.🤖 Generated with Claude Code
https://claude.ai/code/session_01PQjkiuuGX2t3TSZKpzqpPv