Skip to content

Lazy-load parent cache content when saving a child fixture#61

Open
navidemad wants to merge 4 commits into
Gusto:mainfrom
navidemad:lazy-load-parent-content
Open

Lazy-load parent cache content when saving a child fixture#61
navidemad wants to merge 4 commits into
Gusto:mainfrom
navidemad:lazy-load-parent-content

Conversation

@navidemad
Copy link
Copy Markdown

@navidemad navidemad commented May 27, 2026

Summary

Cache#evaluate reads the parent's coder data via fixture.parent.cache.content.data_for(...), but @content is only populated by Cache#load (full mount) or Cache#save. When a child fixture is generated in a fresh process where the parent has a cache file on disk but has never been mounted in this process, the child jumps straight to save and parent.cache.content is nil, raising:

NoMethodError: undefined method 'data_for' for nil

This happens in any setup where the test DB is reset (e.g. db:test:prepare) while preserving the fixture cache. Common when FIXTURE_KIT_PRESERVE_CACHE=1 is used to support parallel workers.

Repro

The spec loads parent content from disk when child saves without parent in memory reproduces it: save a parent fixture, call clear_memory to simulate a fresh process, then trigger child_cache.save. Without the patch, the spec raises NoMethodError.

Fix

Extract the lazy @content ||= file_cache.read into a new public Cache#ensure_content method. evaluate now uses fixture.parent ? fixture.parent.cache.ensure_content.data_for(...) : nil, so it transparently loads the parent's content from disk when needed. load is refactored to use the same helper.

The method is named ensure_content rather than read_content because it has a memoization side effect; read_ would have suggested a pure read. It raises when the cache file is absent, so callers must guarantee existence.

Review iteration

A reviewer flagged a safe-nav chain (parent&.cache&.ensure_content&.data_for(...)) on an earlier revision, arguing it could silently swallow a missing or corrupt parent cache into parent_data: nil. The premise didn't hold: FileCache#read always raises (Errno::ENOENT, JSON::ParserError) rather than returning nil. The chain was still over-defensive, so I tightened it back to the ternary form to match the pre-fix semantics: nil only when there is no parent, anything else surfaces loudly.

I also strengthened the cross-process reproducer. Previously, the mount stub recreated the parent's User inside the child save block, so ActiveRecordCoder captured User from the event stream regardless of parent_data. A faux fix returning parent_data: nil would still have satisfied the assertion. The stub now returns an empty repository (the user already lives in the DB from parent_cache.save), so User can only appear in the child cache via parent_data.keys.

I added three focused unit specs for #ensure_content: returns memoized @content without touching disk, re-reads disk after clear_memory, raises Errno::ENOENT when the file is absent.

Notes

  • No public API removed; Cache#content and Cache#load semantics are unchanged.
  • The existing inherited-fixtures spec is updated to stub ensure_content on the parent cache double.
  • Downstream workaround that motivated this PR: yespark/yespark-rails#21339.

Test plan

  • bundle exec rspec spec/unit/fixture_cache_spec.rb — 28/28 pass
  • bundle exec rspec (full suite) — 178/178 pass

When a child fixture extends a parent, `Cache#evaluate` reads the
parent's coder data with:

    fixture.parent.cache.content.data_for(coder.class)

But `@content` is only populated by `Cache#load` (full mount) or
`Cache#save`. If the parent was generated in a previous process (cache
file on disk) but never mounted in the current one — which happens when
the child cache is missing while the parent cache exists — the child
goes straight to `save` and `parent.cache.content` is `nil`, raising:

    NoMethodError: undefined method 'data_for' for nil

This is reproducible by clearing only the anonymous child cache (or
modifying the child definition so its identifier changes) while
preserving the parent cache. It also hits any setup where the test DB
has been reset (e.g. `db:test:prepare`) and the parent cache survives,
which is common when `FIXTURE_KIT_PRESERVE_CACHE=1` is used to support
parallel workers.

Extract the lazy read into a new `Cache#read_content` method so
`evaluate` (and `load`) can populate `@content` on demand without
triggering a full mount.

Add a spec that reproduces the original crash: save a parent, call
`clear_memory` to simulate a fresh process, then save a child that
depends on the parent. Without the fix the spec raises `NoMethodError`.

Refs yespark/yespark-rails#21339 for the
downstream workaround that motivated this upstream fix.
@gusto-fresh-eyes
Copy link
Copy Markdown

gusto-fresh-eyes Bot commented May 27, 2026

Fresh Eyes Review

Found 1 issue in this PR.

PR Description Issues

  • Blocker | [fresh_eyes]: claims-vs-reality: Description repeatedly refers to the method as read_content (3 occurrences) but the actual implementation names it ensure_content. This makes the description inaccurate — reviewers searching for read_content will find nothing. Likely the method was renamed during development but the description was not updated.

Download findings.json — drag the file into Claude or use /add to propose fixes


🙏🏽 Please 👍🏽 👎🏽 if you found this useful. Generated by Fresh Eyes Reviewer. Get help in #ai-code-reviews
💡 Tip: Apply disable-automated-review label to any PR to disable Fresh Eyes reviews.

Comment thread lib/fixture_kit/cache.rb Outdated
navidemad added 3 commits May 27, 2026 14:17
The safe-nav chain `parent&.cache&.read_content&.data_for(...)` was
over-defensive: `Cache` is never nil when `parent` exists, and
`read_content` either returns a `MemoryCache` or raises (it cannot
return nil). Reverting to the original explicit `parent ? ... : nil`
preserves the pre-fix semantics — nil only when there's no parent;
any other failure surfaces loudly.
- Rename `read_content` to `ensure_content`. The `read_` prefix
  suggested a pure read, hiding the memoization side effect. The new
  name reflects the actual contract: ensure `@content` is populated,
  reading from disk only if necessary. Docstring also calls out that
  the method raises when the cache file is absent — callers must
  guarantee existence.

- Strengthen the cross-process reproducer spec. The previous stub of
  `parent.mount` recreated `User` inside the child save block, so
  `ActiveRecordCoder` captured `User` from the event stream regardless
  of `parent_data`. A faux fix that returned `parent_data: nil` would
  still satisfy the assertion. The stub now returns an empty
  repository (User already lives in DB from `parent_cache.save`), so
  `User` only ends up in the child cache via `parent_data.keys` —
  making the assertion load-bearing. Also adds an explicit
  `expect(parent_cache.content).to be_nil` precondition right after
  `clear_memory`.

- Add three focused specs for `#ensure_content`: returns memoized
  `@content` without touching disk, re-reads disk after `clear_memory`,
  and raises `Errno::ENOENT` when the cache file is absent. Formalizes
  the contract instead of inheriting it implicitly from `File.read`.
The maintainer reads English, so French inline comments in the cache
spec are noise. Translates the cross-process reproducer comments and
the new #ensure_content memoization comment. No behavior change.
Comment thread lib/fixture_kit/cache.rb
# needs the parent's coder data, while the parent itself was already cached
# to disk in a previous process and has not yet been mounted). Raises if the
# cache file is absent or unreadable — callers must guarantee existence.
def ensure_content
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.

Can this be just be named content?

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