diff --git a/CHANGELOG.md b/CHANGELOG.md index 0270caa..344d4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## master +- Add `RequireHooks::Bootsnap.add_version_hash` for external cache inputs. + ## 0.5.0 (2026-08-26) - Use Bootsnap compiler namespaces for cache invalidation without changing Bootsnap's cache directory. diff --git a/README.md b/README.md index 7fe0fec..4491d4e 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,14 @@ The _around load_ hooks are executed for all files independently of whether they Require Hooks separates cached instructions by hook configuration. New source transformers and hijackers invalidate affected cache entries automatically. +If a transform depends on an external library or configuration, add that input to the cache key: + +```ruby +RequireHooks::Bootsnap.add_version_hash { "parser-#{Parser::VERSION}-#{options_digest}" } +``` + +The value can be a String or a callable that returns a String. Require Hooks hashes each contribution and combines all contributions with its hook key. The callable runs when Require Hooks computes the key, so it does not freeze the key before later hooks register. + ## Limitations - Coverage tracking is only supported in Ruby 4.0.4+ (or 3.4.10+ for 3.4.x series); for older versions in 3.4 and 4.0 series, you can enable `eval` coverage tracking to make it work with Require Hooks (`Coverage.start(eval: true, ...)` or `SimpleCov.enable_coverage :eval`). For Ruby 3.2.x and 3.3.x, only around hooks are supported. diff --git a/lib/require-hooks/mode/bootsnap.rb b/lib/require-hooks/mode/bootsnap.rb index d53d038..dc77648 100644 --- a/lib/require-hooks/mode/bootsnap.rb +++ b/lib/require-hooks/mode/bootsnap.rb @@ -41,11 +41,12 @@ def fetch(path, cache_dir: self.cache_dir) # Bootsnap 1.24+ compiler wrapper. Its namespace isolates transformed ISeqs # without changing Bootsnap's process-wide cache directory. class VersionedCompiler - attr_reader :namespace + attr_reader :namespace, :version_hash - def initialize(compiler) + def initialize(compiler, version_hash) @compiler = compiler - @namespace = "#{compiler.namespace}-require-hooks-#{RequireHooks::Bootsnap.version_hash}" + @version_hash = version_hash + @namespace = "#{compiler.namespace}-require-hooks-#{version_hash}" end def input_to_storage(source, path, *args) @@ -120,11 +121,37 @@ def compiler_for(path) compiler = @original_compiler_selector&.call(path) || iseq.default_compiler return compiler if RequireHooks.context_for(path).empty? - @compilers[compiler] ||= VersionedCompiler.new(compiler) + version_hash = RequireHooks::Bootsnap.version_hash + cached = @compilers[compiler] + return cached if cached&.version_hash == version_hash + + @compilers[compiler] = VersionedCompiler.new(compiler, version_hash) + end + + def add_version_hash(value = nil, &block) + contributor = block || value + unless contributor.is_a?(String) || contributor.respond_to?(:call) + raise ArgumentError, "version hash contribution must be a String or callable" + end + + @version_hash_contributors ||= [] + @version_hash_contributors << (contributor.is_a?(String) ? contributor.dup.freeze : contributor) + @compilers&.clear + contributor end def version_hash - @version_hash ||= RequireHooks.contexts.values.map(&:to_cache_key).join("-") + return @version_hash unless @version_hash.nil? + + context_keys = RequireHooks.contexts.values.map(&:to_cache_key) + contribution_keys = Array(@version_hash_contributors).map do |contributor| + value = contributor.respond_to?(:call) ? contributor.call : contributor + raise TypeError, "version hash contribution must return a String" unless value.is_a?(String) + + Zlib.crc32(value).to_s + end.sort + + (context_keys + contribution_keys).join("-") end def version_hash=(version_hash) diff --git a/spec/require-hooks/bootsnap_spec.rb b/spec/require-hooks/bootsnap_spec.rb index 27b8c41..9899bb6 100644 --- a/spec/require-hooks/bootsnap_spec.rb +++ b/spec/require-hooks/bootsnap_spec.rb @@ -32,7 +32,7 @@ misses = output.scan(/miss: (.*)$/).flatten # Since we use different folders for different hook configuration, # we expect to see miss, not stale - misses.size.should == 2 + misses.size.should == 4 end end end @@ -46,6 +46,45 @@ end end + it "invalidates transformed code when an external cache key changes" do + run_ruby( + File.join(__dir__, "fixtures", "bootsnap-cache.rb").to_s, + env: {"TRANSFORM_VALUE" => "First", "CACHE_KEY" => "first", "CACHE_KEY_CALLABLE" => "true", "REQUIRE_HOOKS_MODE" => "bootsnap"} + ) do |_status, output, _err| + output.should include("First (false)\n") + end + + run_ruby( + File.join(__dir__, "fixtures", "bootsnap-cache.rb").to_s, + env: {"TRANSFORM_VALUE" => "Second", "CACHE_KEY" => "second", "CACHE_KEY_CALLABLE" => "true", "REQUIRE_HOOKS_MODE" => "bootsnap"} + ) do |_status, output, _err| + output.should include("Second (false)\n") + end + end + + it "includes hooks registered after a version hash read" do + run_ruby( + File.join(__dir__, "fixtures", "bootsnap-cache-key.rb").to_s, + env: {"EARLY_READ" => "true"} + ) do |_status, output, _err| + output.should include("early_read_changed=true\n") + end + end + + it "does not depend on version hash contribution order" do + keys = [] + ["first,second", "second,first"].each do |cache_keys| + run_ruby( + File.join(__dir__, "fixtures", "bootsnap-cache-key.rb").to_s, + env: {"CACHE_KEYS" => cache_keys} + ) do |_status, output, _err| + keys << output[/version_hash=(.*)$/, 1] + end + end + + keys[0].should == keys[1] + end + it "re-raises syntax errors" do run_ruby( File.join(__dir__, "fixtures", "bootsnap-syntax-error.rb").to_s, diff --git a/spec/require-hooks/fixtures/bootsnap-cache-key.rb b/spec/require-hooks/fixtures/bootsnap-cache-key.rb new file mode 100644 index 0000000..3d9d7e5 --- /dev/null +++ b/spec/require-hooks/fixtures/bootsnap-cache-key.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "bootsnap" +Bootsnap.setup( + cache_dir: File.join(__dir__, "tmp/cache"), + development_mode: true, + load_path_cache: false, + compile_cache_iseq: true, + compile_cache_yaml: false +) + +ENV["REQUIRE_HOOKS_MODE"] = "bootsnap" +require "require-hooks/setup" + +if (keys = ENV["CACHE_KEYS"]) + keys.split(",").reject(&:empty?).each do |key| + RequireHooks::Bootsnap.add_version_hash(key) + end +end +if ENV["EARLY_READ"] == "true" + RequireHooks.source_transform(patterns: ["*/fixtures/cache-key-a/**/*.rb"]) { |_path, source| source } + first = RequireHooks::Bootsnap.version_hash + + RequireHooks.source_transform(patterns: ["*/fixtures/cache-key-b/**/*.rb"]) { |_path, source| source } + second = RequireHooks::Bootsnap.version_hash + + puts "early_read_changed=#{first != second}" +end + +puts "version_hash=#{RequireHooks::Bootsnap.version_hash}" diff --git a/spec/require-hooks/fixtures/bootsnap-cache.rb b/spec/require-hooks/fixtures/bootsnap-cache.rb index fc0a99a..4ade058 100644 --- a/spec/require-hooks/fixtures/bootsnap-cache.rb +++ b/spec/require-hooks/fixtures/bootsnap-cache.rb @@ -9,6 +9,8 @@ compile_cache_yaml: true ) +transform_value = ENV.fetch("TRANSFORM_VALUE", "Good-bye") + if ENV["FROZEN"] == "true" Bootsnap::CompileCache::ISeq.compiler_selector = ->(_) { Bootsnap::CompileCache::ISeq::FROZEN_STRING_LITERAL } end @@ -22,9 +24,18 @@ unless ENV["HOOKS"] == "false" || ARGV.include?("--no-hooks") require "require-hooks/setup" + if ENV["CACHE_KEY"] + contribution = if ENV["CACHE_KEY_CALLABLE"] == "true" + -> { ENV["CACHE_KEY"] } + else + ENV["CACHE_KEY"] + end + RequireHooks::Bootsnap.add_version_hash(contribution) + end + RequireHooks.source_transform(patterns: ["*/fixtures/hello.rb"]) do |path, source| source ||= File.read(path) - source.gsub!("Hello", "Good-bye") + source.gsub!("Hello", transform_value) source end @@ -40,7 +51,7 @@ if ENV["HOOKS"] == "double-transform" RequireHooks.source_transform(patterns: ["*/fixtures/hello.rb"]) do |path, source| source ||= File.read(path) - source.gsub!("Good-bye", "Ciao") + source.gsub!(transform_value.to_s, "Ciao") source end end