Skip to content

Design: invalidate fixture cache when source definition changes #63

Description

@navidemad

Context

PR #61 fixed a NoMethodError when child fixtures were saved against a parent whose cache existed on disk but was not mounted in the current process. During review, a reviewer surfaced a deeper, pre-existing concern:

The lazy-load path can silently use stale parent cache data when the parent cache file exists but does not match the current parent fixture definition. Fixture#generate skips regeneration when @cache.exists? && !force, and Cache#exists? treats a disk file as sufficient. The new child-save path reads parent data directly from that file, and ActiveRecordCoder#generate merges parent_data.keys into the captured model set. No definition digest, mtime check, schema/version key, or parent-definition validation was found in Cache, FileCache, Fixture, or Runner.

This was deliberately scoped out of #61 because it is not new behavior — but it is a real correctness gap and warrants a separate design discussion.

Today's behavior

A fixture cache on disk is treated as valid forever, regardless of:

  • Changes to the fixture's Definition block in source code.
  • Changes to the underlying schema (new columns, dropped tables, type changes).
  • Changes to Coder subclasses (new coders registered, custom encode/decode updated).
  • Changes to the Rails / Ruby version that wrote the cache.

The only safety nets today are:

  1. FK verification at mount time (ActiveRecord.verify_foreign_keys_for_fixtures), which only catches a subset of schema drift and only at load.
  2. Manual FIXTURE_KIT_PRESERVE_CACHE opt-out and db:test:prepare.

What needs deciding

1. What identifies a "valid" cache?

Candidates:

  • Source digest of the Definition block (and any procs it closes over — likely needs RubyVM::InstructionSequence or source-location-based hashing).
  • mtime of the fixture file that declared the fixture. Cheaper, more brittle (touching the file invalidates).
  • Schema fingerprint (digest of db/schema.rb, or ActiveRecord::Migration.current_version).
  • Coder fingerprint (registered coder classes + their source digest).
  • FixtureKit version string (cheap, catches gem upgrades).

A useful cache fingerprint is probably a tuple of several of these, not one. The cheapest worthwhile combo is (fixture_kit_version, schema_version).

2. Where does the metadata live?

  • New top-level key in the cache JSON ("meta": { "version": ..., "schema": ..., "definition_digest": ... }). Simple, requires migrating existing caches.
  • Sidecar .meta.json file. Avoids touching the existing format. More disk I/O.
  • Filename suffix (fixture.json.v2.abc123.json). Visible but ugly.

3. What happens on mismatch?

  • Raise with a clear message ("cache version mismatch — regenerate"). Conservative.
  • Silently regenerate. Convenient but hides bugs in the fingerprint logic.
  • Warn + regenerate. Probably the right middle ground; logging is cheap.

4. Backward compatibility

Existing users have a populated cache directory. Options:

  • Treat a cache without a meta block as version-0 and force regeneration. One-time cost per fixture, no manual migration needed.
  • Add a CLI / rake task to migrate caches in place.
  • Bump major version of the gem and document the cache reset as a breaking change.

Out of scope

  • Auto-regeneration logic itself. Fixture#generate(force:) already exists; this issue is about when to set force: true automatically.
  • Cross-process locking. Worth tracking but separate.

Why this is filed as a discussion, not a PR

Each of the four questions above has reasonable answers in multiple directions, and the choices interact (a heavier fingerprint is more correct but more expensive; mismatch behavior is shaped by how cheap regeneration is in practice). Picking a strategy unilaterally and shipping a PR risks landing the wrong tradeoff. Looking for maintainer steer on at least Q1 and Q3 before coding.

Related

Activity

  1. ngan commented on May 29, 2026

    @ngan
    Collaborator

    Cache busting is too difficult to get right. This is why we default to building caches for every test run (overwrite). If you know what you're doing, you can opt in view environment variable. This means you need to clear out the cache yourself if you know you're doing something that would affect it.

  2. navidemad commented on Aug 28, 2026

    @navidemad
    ContributorAuthor

    Agreed that the gem is the wrong place for this: it cannot know which files the cached rows depend on, and getting that list wrong is worse than not busting at all.

    The application can know, though, and cache_path is already the hook. #82 documents the pattern we run in CI: digest the fixture definitions, the factories they call and the schema file, fold the digest into cache_path, and a cache written under any other state of the code is never read. It also spells out what it costs, since obsolete digest directories are not reclaimed and one changed file regenerates everything.

    Leaving this issue to you to close or keep as a design note; nothing here asks for a change in the gem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions