Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion lib/fixture_kit/file_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "json"
require "fileutils"
require "securerandom"
require "active_support/inflector"

module FixtureKit
Expand Down Expand Up @@ -45,11 +46,32 @@ def write(data)
}

FileUtils.mkdir_p(File.dirname(path))
File.write(path, JSON.pretty_generate(content))
atomically_write(JSON.pretty_generate(content))
end

private

# A cache directory can be read by one process while another writes it: a
# warm-up process filling the cache before the suite runs, a second suite
# started against a preserved cache, or any runner whose workers share
# `cache_path`. `File.write` truncates the destination and fills it back in,
# so a reader that opens the file inside that window gets a prefix of the
# JSON and fails to parse it -- a corrupt-cache failure with no corrupt cache
# behind it. Writing a sibling file and renaming it over the destination
# publishes the new content in one step: a concurrent reader sees either the
# whole previous file or the whole new one, never a mixture.
#
# The temporary file is a sibling so the rename stays within one filesystem,
# and carries the pid so two processes writing the same fixture cannot
# collide on it.
def atomically_write(payload)
temporary_path = "#{path}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp"
File.write(temporary_path, payload)
File.rename(temporary_path, path)
ensure
FileUtils.rm_f(temporary_path) if temporary_path
end

def coder_for(class_name)
@coder_for ||= FixtureKit.runner.coders.index_by { |c| c.class.name }
@coder_for.fetch(class_name)
Expand Down
49 changes: 49 additions & 0 deletions spec/unit/file_cache_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
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) }
let(:stop_path) { "#{cache_path}.stop" }

before do
FileUtils.rm_rf(cache_path)
FileUtils.rm_f(stop_path)
end

after do
FileUtils.rm_rf(cache_path)
FileUtils.rm_f(stop_path)
end

describe "#path" do
Expand Down Expand Up @@ -83,5 +86,51 @@

expect(File.exist?(nested_path)).to be(true)
end

it "leaves nothing but the cache file behind" do
file_cache.write(FixtureKit::MemoryCache.new(data: {}, exposed: {}))

expect(Dir.children(cache_path)).to contain_exactly("test_fixture.json")
end

# Processes share a cache directory whenever the cache outlives the process
# that wrote it: a warm-up run, a preserved cache picked up by a second
# suite, workers pointed at one `cache_path`. A reader that catches a
# truncate-then-fill write reads a prefix of the JSON and blames the cache,
# which looks like corruption rather than a race.
it "does not expose a partially written file to a concurrent reader" do
skip "fork is not supported on this platform" unless Process.respond_to?(:fork)

# Large enough that a truncate-then-fill write leaves a window a reader
# can land in; small enough that a hundred rewrites stay quick.
data = FixtureKit::MemoryCache.new(
data: { FixtureKit::ActiveRecordCoder => { User => "INSERT INTO users (id, name) VALUES (1, '#{"a" * 500_000}')" } },
exposed: { alice: { User => 1 } }
)
file_cache.write(data)

reader = fork do
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60
torn = false
until File.exist?(stop_path) || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
begin
JSON.parse(File.read(file_path))
rescue JSON::ParserError
torn = true
break
end
end
exit!(torn ? 1 : 0)
end

begin
100.times { file_cache.write(data) }
ensure
FileUtils.touch(stop_path)
end
_, status = Process.waitpid2(reader)

expect(status.exitstatus).to eq(0), "a concurrent reader parsed a partially written cache file"
end
end
end