From 4f6e6e242fadcf8f772e577e379dae23e7f20fe5 Mon Sep 17 00:00:00 2001 From: Ngan Pham Date: Thu, 26 Feb 2026 11:10:37 -0800 Subject: [PATCH] Clear MemoryCache for inline fixtures after context finishes After a context/suite finishes running, inline (anonymous) fixtures hold a MemoryCache reference that will never be used again. Named fixtures are shared and keep their memory. Releasing the MemoryCache for inline fixtures allows GC to reclaim the SQL statements and exposed mappings. - Extract FileCache and MemoryCache into their own classes - Add Cache#clear_memory to nil out @data - Add Fixture#finish which calls clear_memory for anonymous fixtures - Hook finish into RSpec append_after(:context) and Minitest run_suite Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/fixture_kit.rb | 2 + lib/fixture_kit/cache.rb | 58 ++++---------- lib/fixture_kit/file_cache.rb | 51 +++++++++++++ lib/fixture_kit/fixture.rb | 8 ++ lib/fixture_kit/memory_cache.rb | 17 +++++ lib/fixture_kit/minitest.rb | 2 + lib/fixture_kit/rspec.rb | 4 + spec/unit/file_cache_spec.rb | 106 ++++++++++++++++++++++++++ spec/unit/fixture_cache_spec.rb | 65 +++++++++++++++- spec/unit/fixture_spec.rb | 36 +++++++++ spec/unit/memory_cache_spec.rb | 34 +++++++++ spec/unit/minitest_entrypoint_spec.rb | 33 +++++++- spec/unit/rspec_entrypoint_spec.rb | 25 ++++++ 13 files changed, 394 insertions(+), 47 deletions(-) create mode 100644 lib/fixture_kit/file_cache.rb create mode 100644 lib/fixture_kit/memory_cache.rb create mode 100644 spec/unit/file_cache_spec.rb create mode 100644 spec/unit/memory_cache_spec.rb diff --git a/lib/fixture_kit.rb b/lib/fixture_kit.rb index dfbe1ce..b79f06e 100644 --- a/lib/fixture_kit.rb +++ b/lib/fixture_kit.rb @@ -21,6 +21,8 @@ class CircularFixtureInheritance < Error; end autoload :Repository, File.expand_path("fixture_kit/repository", __dir__) autoload :SqlSubscriber, File.expand_path("fixture_kit/sql_subscriber", __dir__) autoload :Cache, File.expand_path("fixture_kit/cache", __dir__) + autoload :FileCache, File.expand_path("fixture_kit/file_cache", __dir__) + autoload :MemoryCache, File.expand_path("fixture_kit/memory_cache", __dir__) autoload :Runner, File.expand_path("fixture_kit/runner", __dir__) autoload :Adapter, File.expand_path("fixture_kit/adapter", __dir__) autoload :MinitestAdapter, File.expand_path("fixture_kit/adapters/minitest_adapter", __dir__) diff --git a/lib/fixture_kit/cache.rb b/lib/fixture_kit/cache.rb index 436b3ed..3596aa2 100644 --- a/lib/fixture_kit/cache.rb +++ b/lib/fixture_kit/cache.rb @@ -1,14 +1,10 @@ # frozen_string_literal: true -require "json" -require "fileutils" require "active_support/core_ext/array/wrap" -require "active_support/inflector" module FixtureKit class Cache ANONYMOUS_DIRECTORY = "_anonymous" - MemoryData = Data.define(:records, :exposed) include ConfigurationHelper @@ -19,7 +15,7 @@ def initialize(fixture) end def path - File.join(configuration.cache_path, "#{identifier}.json") + file_cache.path end def identifier @@ -34,7 +30,11 @@ def identifier end def exists? - data || File.exist?(path) + data || file_cache.exists? + end + + def clear_memory + @data = nil end def load @@ -42,7 +42,7 @@ def load raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{fixture.identifier}'" end - @data ||= load_memory_data + @data ||= file_cache.read statements_by_connection(data.records).each do |connection, statements| connection.disable_referential_integrity do # execute_batch is private in current supported Rails versions. @@ -64,17 +64,23 @@ def save captured_models.concat(fixture.parent.cache.data.records.keys) end - @data = MemoryData.new( + @data = MemoryCache.new( records: generate_statements(captured_models), - exposed: build_exposed_mapping(fixture.definition.exposed) + exposed: file_cache.serialize_exposed(fixture.definition.exposed) ) end - save_file_data + file_cache.write(data) end private + def file_cache + @file_cache ||= FileCache.new( + File.join(configuration.cache_path, "#{identifier}.json") + ) + end + def generate_statements(models) models.uniq.each_with_object({}) do |model, statements| columns = model.column_names @@ -104,16 +110,6 @@ def build_insert_sql(table_name, columns, rows, connection) "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}" end - def build_exposed_mapping(exposed) - exposed.each_with_object({}) do |(name, record), hash| - if record.is_a?(Array) - hash[name] = record.map { |record| { record.class => record.id } } - else - hash[name] = { record.class => record.id } - end - end - end - def statements_by_connection(records) deleted_tables = Set.new @@ -129,27 +125,5 @@ def statements_by_connection(records) grouped[connection] << sql if sql end end - - def load_memory_data - file_data = JSON.parse(File.read(path)) - records = file_data.fetch("records").transform_keys do |model_name| - ActiveSupport::Inflector.constantize(model_name) - end - - exposed = file_data.fetch("exposed").each_with_object({}) do |(name, value), hash| - if value.is_a?(Array) - hash[name.to_sym] = value.map { |r| { ActiveSupport::Inflector.constantize(r.keys.first) => r.values.first } } - else - hash[name.to_sym] = { ActiveSupport::Inflector.constantize(value.keys.first) => value.values.first } - end - end - - MemoryData.new(records: records, exposed: exposed) - end - - def save_file_data - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, JSON.pretty_generate(data.to_h)) - end end end diff --git a/lib/fixture_kit/file_cache.rb b/lib/fixture_kit/file_cache.rb new file mode 100644 index 0000000..cfaaf5c --- /dev/null +++ b/lib/fixture_kit/file_cache.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" +require "active_support/inflector" + +module FixtureKit + class FileCache + attr_reader :path + + def initialize(path) + @path = path + end + + def exists? + File.exist?(path) + end + + def read + file_data = JSON.parse(File.read(path)) + records = file_data.fetch("records").transform_keys do |model_name| + ActiveSupport::Inflector.constantize(model_name) + end + + exposed = file_data.fetch("exposed").each_with_object({}) do |(name, value), hash| + if value.is_a?(Array) + hash[name.to_sym] = value.map { |r| { ActiveSupport::Inflector.constantize(r.keys.first) => r.values.first } } + else + hash[name.to_sym] = { ActiveSupport::Inflector.constantize(value.keys.first) => value.values.first } + end + end + + MemoryCache.new(records: records, exposed: exposed) + end + + def write(data) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate(data.to_h)) + end + + def serialize_exposed(exposed) + exposed.each_with_object({}) do |(name, record), hash| + if record.is_a?(Array) + hash[name] = record.map { |record| { record.class => record.id } } + else + hash[name] = { record.class => record.id } + end + end + end + end +end diff --git a/lib/fixture_kit/fixture.rb b/lib/fixture_kit/fixture.rb index 40bd7a1..f737458 100644 --- a/lib/fixture_kit/fixture.rb +++ b/lib/fixture_kit/fixture.rb @@ -36,8 +36,16 @@ def mount emit(:cache_mounted) { @cache.load } end + def finish + @cache.clear_memory if anonymous? + end + private + def anonymous? + !identifier.is_a?(String) + end + def emit(event) cache_identifier = @cache.identifier unless block_given? diff --git a/lib/fixture_kit/memory_cache.rb b/lib/fixture_kit/memory_cache.rb new file mode 100644 index 0000000..b883603 --- /dev/null +++ b/lib/fixture_kit/memory_cache.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module FixtureKit + class MemoryCache + attr_reader :records, :exposed + + def initialize(records:, exposed:) + @records = records + @exposed = exposed + freeze + end + + def to_h + { records: records, exposed: exposed } + end + end +end diff --git a/lib/fixture_kit/minitest.rb b/lib/fixture_kit/minitest.rb index 1ddd48d..7f575f4 100644 --- a/lib/fixture_kit/minitest.rb +++ b/lib/fixture_kit/minitest.rb @@ -23,6 +23,8 @@ def run_suite(reporter, options = {}) end super + + declaration&.finish end end diff --git a/lib/fixture_kit/rspec.rb b/lib/fixture_kit/rspec.rb index 8ed8d16..00281da 100644 --- a/lib/fixture_kit/rspec.rb +++ b/lib/fixture_kit/rspec.rb @@ -35,6 +35,10 @@ def fixture(name = nil, extends: nil, &block) prepend_before(:context) do self.class.metadata[DECLARATION_METADATA_KEY].generate end + + append_after(:context) do + self.class.metadata[DECLARATION_METADATA_KEY].finish + end end end diff --git a/spec/unit/file_cache_spec.rb b/spec/unit/file_cache_spec.rb new file mode 100644 index 0000000..3bc9ccf --- /dev/null +++ b/spec/unit/file_cache_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FixtureKit::FileCache do + let(:cache_path) { Rails.root.join("tmp/cache/fixture_kit_file_cache_test").to_s } + let(:file_path) { File.join(cache_path, "test_fixture.json") } + let(:file_cache) { described_class.new(file_path) } + + before do + FileUtils.rm_rf(cache_path) + end + + after do + FileUtils.rm_rf(cache_path) + end + + describe "#path" do + it "returns the path provided at initialization" do + expect(file_cache.path).to eq(file_path) + end + end + + describe "#exists?" do + it "returns false when the file does not exist" do + expect(file_cache.exists?).to be(false) + end + + it "returns true when the file exists" do + FileUtils.mkdir_p(cache_path) + File.write(file_path, "{}") + + expect(file_cache.exists?).to be(true) + end + end + + describe "#write and #read" do + it "round-trips MemoryCache through JSON on disk" do + data = FixtureKit::MemoryCache.new( + records: { User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" }, + exposed: { alice: { User => 1 } } + ) + + file_cache.write(data) + + expect(File.exist?(file_path)).to be(true) + result = file_cache.read + expect(result).to be_a(FixtureKit::MemoryCache) + expect(result.records).to eq({ User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" }) + expect(result.exposed).to eq({ alice: { User => 1 } }) + end + + it "round-trips MemoryCache with nil sql values" do + data = FixtureKit::MemoryCache.new( + records: { User => nil }, + exposed: {} + ) + + file_cache.write(data) + result = file_cache.read + + expect(result.records).to eq({ User => nil }) + end + + it "round-trips MemoryCache with array exposed values" do + data = FixtureKit::MemoryCache.new( + records: {}, + exposed: { users: [{ User => 1 }, { User => 2 }] } + ) + + file_cache.write(data) + result = file_cache.read + + expect(result.exposed).to eq({ users: [{ User => 1 }, { User => 2 }] }) + end + + it "creates intermediate directories" do + nested_path = File.join(cache_path, "nested", "deep", "fixture.json") + nested_file_cache = described_class.new(nested_path) + data = FixtureKit::MemoryCache.new(records: {}, exposed: {}) + + nested_file_cache.write(data) + + expect(File.exist?(nested_path)).to be(true) + end + end + + describe "#serialize_exposed" do + it "converts ActiveRecord instances to class/id pairs" do + user = User.create!(name: "Alice", email: "alice-file-cache@example.com") + + result = file_cache.serialize_exposed({ alice: user }) + + expect(result).to eq({ alice: { User => user.id } }) + end + + it "converts arrays of ActiveRecord instances" do + alice = User.create!(name: "Alice", email: "alice-array@example.com") + bob = User.create!(name: "Bob", email: "bob-array@example.com") + + result = file_cache.serialize_exposed({ users: [alice, bob] }) + + expect(result).to eq({ users: [{ User => alice.id }, { User => bob.id }] }) + end + end +end diff --git a/spec/unit/fixture_cache_spec.rb b/spec/unit/fixture_cache_spec.rb index 073eced..8affba0 100644 --- a/spec/unit/fixture_cache_spec.rb +++ b/spec/unit/fixture_cache_spec.rb @@ -141,8 +141,9 @@ def identifier_for(identifier) fixture_cache.save - expect(File.exist?(fixture_cache.path)).to be(true) - data = JSON.parse(File.read(fixture_cache.path)) + path = fixture_cache.path + expect(File.exist?(path)).to be(true) + data = JSON.parse(File.read(path)) expect(data["records"]).to have_key("User") expect(data["exposed"]).to have_key("alice") end @@ -226,7 +227,7 @@ def identifier_for(identifier) end it "includes parent fixture model records when saving inherited fixtures" do - parent_cache_data = FixtureKit::Cache::MemoryData.new( + parent_cache_data = FixtureKit::MemoryCache.new( records: { User => nil }, exposed: {} ) @@ -257,6 +258,61 @@ def identifier_for(identifier) end end + describe "#clear_memory" do + it "nils out @data" do + cache.instance_variable_set(:@data, FixtureKit::MemoryCache.new(records: {}, exposed: {})) + + cache.clear_memory + + expect(cache.data).to be_nil + end + + it "still reports exists? as true when file cache is present" do + fixture_definition = FixtureKit::Definition.new do + alice = User.create!(name: "Alice", email: "alice-clear@example.com") + expose(alice: alice) + end + fixture_double = instance_double( + FixtureKit::Fixture, + identifier: fixture_name, + definition: fixture_definition, + parent: nil + ) + fixture_cache = described_class.new(fixture_double) + fixture_cache.save + + fixture_cache.clear_memory + + expect(fixture_cache.data).to be_nil + expect(fixture_cache.exists?).to be(true) + end + + it "re-reads from file cache on next load" do + fixture_definition = FixtureKit::Definition.new do + alice = User.create!(name: "Alice", email: "alice-reread@example.com") + expose(alice: alice) + end + fixture_double = instance_double( + FixtureKit::Fixture, + identifier: fixture_name, + definition: fixture_definition, + parent: nil + ) + fixture_cache = described_class.new(fixture_double) + fixture_cache.save + + fixture_cache.clear_memory + expect(fixture_cache.data).to be_nil + + User.delete_all + repository = fixture_cache.load + + expect(fixture_cache.data).not_to be_nil + expect(repository.alice).to be_a(User) + expect(repository.alice.name).to eq("Alice") + end + end + describe "#load" do it "documents that connection execute_batch is currently private" do connection = User.connection @@ -274,7 +330,7 @@ def identifier_for(identifier) allow(FixtureKit::Repository).to receive(:new).with({}).and_return(:repository) cache.instance_variable_set( :@data, - FixtureKit::Cache::MemoryData.new( + FixtureKit::MemoryCache.new( records: { User => user_sql, Project => project_sql, @@ -334,4 +390,5 @@ def identifier_for(identifier) end end + end diff --git a/spec/unit/fixture_spec.rb b/spec/unit/fixture_spec.rb index ba61955..c364126 100644 --- a/spec/unit/fixture_spec.rb +++ b/spec/unit/fixture_spec.rb @@ -128,6 +128,42 @@ end end + describe "#finish" do + it "clears memory for anonymous fixtures" do + anonymous_definition = FixtureKit::Definition.new {} + anonymous_scope = Class.new + allow(cache).to receive(:clear_memory) + fixture = described_class.new(anonymous_scope, anonymous_definition) + + fixture.finish + + expect(cache).to have_received(:clear_memory) + end + + it "does not clear memory for named fixtures" do + allow(cache).to receive(:clear_memory) + fixture = described_class.new("project_management", definition) + + fixture.finish + + expect(cache).not_to have_received(:clear_memory) + end + + it "allows a finished anonymous fixture to still be mounted from file cache" do + allow(cache).to receive(:clear_memory) + allow(cache).to receive(:exists?).and_return(true) + anonymous_definition = FixtureKit::Definition.new {} + anonymous_scope = Class.new + fixture = described_class.new(anonymous_scope, anonymous_definition) + + fixture.finish + result = fixture.mount + + expect(cache).to have_received(:clear_memory) + expect(result).to eq(:repository) + end + end + describe "#parent" do it "loads and memoizes parent fixture from registry" do parent_fixture = instance_double(FixtureKit::Fixture) diff --git a/spec/unit/memory_cache_spec.rb b/spec/unit/memory_cache_spec.rb new file mode 100644 index 0000000..088f3af --- /dev/null +++ b/spec/unit/memory_cache_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FixtureKit::MemoryCache do + describe "#initialize" do + it "stores records and exposed data" do + cache = described_class.new(records: { "User" => "SQL" }, exposed: { alice: 1 }) + + expect(cache.records).to eq({ "User" => "SQL" }) + expect(cache.exposed).to eq({ alice: 1 }) + end + + it "is frozen after initialization" do + cache = described_class.new(records: {}, exposed: {}) + + expect(cache).to be_frozen + end + end + + describe "#to_h" do + it "returns a hash of records and exposed" do + cache = described_class.new( + records: { "User" => "INSERT INTO users VALUES (1)" }, + exposed: { alice: { "User" => 1 } } + ) + + expect(cache.to_h).to eq({ + records: { "User" => "INSERT INTO users VALUES (1)" }, + exposed: { alice: { "User" => 1 } } + }) + end + end +end diff --git a/spec/unit/minitest_entrypoint_spec.rb b/spec/unit/minitest_entrypoint_spec.rb index 1325ad8..7bf3174 100644 --- a/spec/unit/minitest_entrypoint_spec.rb +++ b/spec/unit/minitest_entrypoint_spec.rb @@ -4,7 +4,7 @@ require "fixture_kit/minitest" RSpec.describe FixtureKit::Minitest::ClassMethods do - let(:fixture_declaration) { instance_double(FixtureKit::Fixture, generate: nil, mount: :repository) } + let(:fixture_declaration) { instance_double(FixtureKit::Fixture, generate: nil, mount: :repository, finish: nil) } let(:runner) do instance_double( FixtureKit::Runner, @@ -97,6 +97,37 @@ Minitest::Runnable.runnables.delete(test_case) end + it "calls finish on the declaration after tests run" do + allow(runner).to receive(:started?).and_return(true) + test_case = Class.new(ActiveSupport::TestCase) do + test "noop" do + assert true + end + end + test_case.fixture_kit_declaration = fixture_declaration + reporter = build_reporter + allow(test_case).to receive(:filter_runnable_methods).with({}).and_return(["test_noop"]) + + expect(fixture_declaration).to receive(:finish) + + test_case.run_suite(reporter, {}) + ensure + Minitest::Runnable.runnables.delete(test_case) + end + + it "calls finish even when there are no runnable methods" do + test_case = Class.new(ActiveSupport::TestCase) + test_case.fixture_kit_declaration = fixture_declaration + reporter = build_reporter + allow(test_case).to receive(:filter_runnable_methods).with({}).and_return([]) + + expect(fixture_declaration).to receive(:finish) + + test_case.run_suite(reporter, {}) + ensure + Minitest::Runnable.runnables.delete(test_case) + end + it "does not start or cache when there are no runnable methods" do test_case = Class.new(ActiveSupport::TestCase) test_case.fixture_kit_declaration = fixture_declaration diff --git a/spec/unit/rspec_entrypoint_spec.rb b/spec/unit/rspec_entrypoint_spec.rb index 74b6eef..a796f53 100644 --- a/spec/unit/rspec_entrypoint_spec.rb +++ b/spec/unit/rspec_entrypoint_spec.rb @@ -80,6 +80,17 @@ end end + describe "after(:context) hook" do + it "attaches an after context hook that calls finish on the declaration" do + group = build_group + group.fixture("project_management") + + expect(fixture_declaration).to receive(:finish) + + run_after_context_hook(group) + end + end + def build_group(parent_metadata = {}) Class.new do extend FixtureKit::RSpec::ClassMethods @@ -97,10 +108,24 @@ def build_group(parent_metadata = {}) define_singleton_method(:fixture_kit_before_context_hook) do @fixture_kit_before_context_hook end + + define_singleton_method(:append_after) do |scope, &block| + raise "Unexpected scope: #{scope}" unless scope == :context + + @fixture_kit_after_context_hook = block + end + + define_singleton_method(:fixture_kit_after_context_hook) do + @fixture_kit_after_context_hook + end end end def run_before_context_hook(group) group.new.instance_exec(&group.fixture_kit_before_context_hook) end + + def run_after_context_hook(group) + group.new.instance_exec(&group.fixture_kit_after_context_hook) + end end