Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement a JSON compilation cache #370

Merged
merged 1 commit into from Sep 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 5 additions & 2 deletions lib/bootsnap.rb
Expand Up @@ -43,7 +43,8 @@ def self.setup(
autoload_paths_cache: nil,
disable_trace: nil,
compile_cache_iseq: true,
compile_cache_yaml: true
compile_cache_yaml: true,
compile_cache_json: true
)
unless autoload_paths_cache.nil?
warn "[DEPRECATED] Bootsnap's `autoload_paths_cache:` option is deprecated and will be removed. " \
Expand All @@ -69,7 +70,8 @@ def self.setup(
Bootsnap::CompileCache.setup(
cache_dir: cache_dir + '/bootsnap/compile-cache',
iseq: compile_cache_iseq,
yaml: compile_cache_yaml
yaml: compile_cache_yaml,
json: compile_cache_json,
)
end

Expand Down Expand Up @@ -113,6 +115,7 @@ def self.default_setup
load_path_cache: !ENV['DISABLE_BOOTSNAP_LOAD_PATH_CACHE'],
compile_cache_iseq: !ENV['DISABLE_BOOTSNAP_COMPILE_CACHE'] && iseq_cache_supported?,
compile_cache_yaml: !ENV['DISABLE_BOOTSNAP_COMPILE_CACHE'],
compile_cache_json: !ENV['DISABLE_BOOTSNAP_COMPILE_CACHE'],
)

if ENV['BOOTSNAP_LOG']
Expand Down
39 changes: 37 additions & 2 deletions lib/bootsnap/cli.rb
Expand Up @@ -21,7 +21,7 @@ def match?(string)

attr_reader :cache_dir, :argv

attr_accessor :compile_gemfile, :exclude, :verbose, :iseq, :yaml, :jobs
attr_accessor :compile_gemfile, :exclude, :verbose, :iseq, :yaml, :json, :jobs

def initialize(argv)
@argv = argv
Expand All @@ -32,37 +32,44 @@ def initialize(argv)
self.jobs = Etc.nprocessors
self.iseq = true
self.yaml = true
self.json = true
end

def precompile_command(*sources)
require 'bootsnap/compile_cache/iseq'
require 'bootsnap/compile_cache/yaml'
require 'bootsnap/compile_cache/json'

fix_default_encoding do
Bootsnap::CompileCache::ISeq.cache_dir = self.cache_dir
Bootsnap::CompileCache::YAML.init!
Bootsnap::CompileCache::YAML.cache_dir = self.cache_dir
Bootsnap::CompileCache::JSON.init!
Bootsnap::CompileCache::JSON.cache_dir = self.cache_dir

@work_pool = WorkerPool.create(size: jobs, jobs: {
ruby: method(:precompile_ruby),
yaml: method(:precompile_yaml),
json: method(:precompile_json),
})
@work_pool.spawn

main_sources = sources.map { |d| File.expand_path(d) }
precompile_ruby_files(main_sources)
precompile_yaml_files(main_sources)
precompile_json_files(main_sources)

if compile_gemfile
# Some gems embed their tests, they're very unlikely to be loaded, so not worth precompiling.
gem_exclude = Regexp.union([exclude, '/spec/', '/test/'].compact)
precompile_ruby_files($LOAD_PATH.map { |d| File.expand_path(d) }, exclude: gem_exclude)

# Gems that include YAML files usually don't put them in `lib/`.
# Gems that include JSON or YAML files usually don't put them in `lib/`.
# So we look at the gem root.
gem_pattern = %r{^#{Regexp.escape(Bundler.bundle_path.to_s)}/?(?:bundler/)?gems\/[^/]+}
gem_paths = $LOAD_PATH.map { |p| p[gem_pattern] }.compact.uniq
precompile_yaml_files(gem_paths, exclude: gem_exclude)
precompile_json_files(gem_paths, exclude: gem_exclude)
end

if exitstatus = @work_pool.shutdown
Expand Down Expand Up @@ -137,6 +144,29 @@ def precompile_yaml(*yaml_files)
end
end

def precompile_json_files(load_paths, exclude: self.exclude)
return unless json

load_paths.each do |path|
if !exclude || !exclude.match?(path)
list_files(path, '**/*.json').each do |json_file|
# We ignore hidden files to not match the various .config.json files
if !File.basename(json_file).start_with?('.') && (!exclude || !exclude.match?(json_file))
@work_pool.push(:json, json_file)
end
end
end
end
end

def precompile_json(*json_files)
Array(json_files).each do |json_file|
if p(CompileCache::JSON.precompile(json_file, cache_dir: cache_dir))
STDERR.puts(json_file) if verbose
end
end
end

def precompile_ruby_files(load_paths, exclude: self.exclude)
return unless iseq

Expand Down Expand Up @@ -240,6 +270,11 @@ def parser
Disable YAML precompilation.
EOS
opts.on('--no-yaml', help) { self.yaml = false }

help = <<~EOS
Disable JSON precompilation.
EOS
opts.on('--no-json', help) { self.json = false }
end
end
end
Expand Down
11 changes: 10 additions & 1 deletion lib/bootsnap/compile_cache.rb
Expand Up @@ -4,7 +4,7 @@ module CompileCache
Error = Class.new(StandardError)
PermissionError = Class.new(Error)

def self.setup(cache_dir:, iseq:, yaml:)
def self.setup(cache_dir:, iseq:, yaml:, json:)
if iseq
if supported?
require_relative('compile_cache/iseq')
Expand All @@ -22,6 +22,15 @@ def self.setup(cache_dir:, iseq:, yaml:)
warn("[bootsnap/setup] YAML parsing caching is not supported on this implementation of Ruby")
end
end

if json
if supported?
require_relative('compile_cache/json')
Bootsnap::CompileCache::JSON.install!(cache_dir)
elsif $VERBOSE
warn("[bootsnap/setup] YAML parsing caching is not supported on this implementation of Ruby")
end
end
end

def self.permission_error(path)
Expand Down
79 changes: 79 additions & 0 deletions lib/bootsnap/compile_cache/json.rb
@@ -0,0 +1,79 @@
# frozen_string_literal: true
require('bootsnap/bootsnap')

module Bootsnap
module CompileCache
module JSON
class << self
attr_accessor(:msgpack_factory, :cache_dir, :supported_options)

def input_to_storage(payload, _)
obj = ::JSON.parse(payload)
msgpack_factory.dump(obj)
end

def storage_to_output(data, kwargs)
if kwargs && kwargs.key?(:symbolize_names)
kwargs[:symbolize_keys] = kwargs.delete(:symbolize_names)
end
msgpack_factory.load(data, kwargs)
end

def input_to_output(data, kwargs)
::JSON.parse(data, **(kwargs || {}))
end

def precompile(path, cache_dir: self.cache_dir)
Bootsnap::CompileCache::Native.precompile(
cache_dir,
path.to_s,
self,
)
end

def install!(cache_dir)
self.cache_dir = cache_dir
init!
if ::JSON.respond_to?(:load_file)
::JSON.singleton_class.prepend(Patch)
end
end

def init!
require('json')
require('msgpack')

self.msgpack_factory = MessagePack::Factory.new
self.supported_options = [:symbolize_names]
if ::JSON.parse('["foo"]', freeze: true).first.frozen?
self.supported_options = [:freeze]
end
self.supported_options.freeze
end
end

module Patch
def load_file(path, *args)
return super if args.size > 1
if kwargs = args.first
return super unless kwargs.is_a?(Hash)
return super unless (kwargs.keys - ::Bootsnap::CompileCache::JSON.supported_options).empty?
end

begin
::Bootsnap::CompileCache::Native.fetch(
Bootsnap::CompileCache::JSON.cache_dir,
File.realpath(path),
::Bootsnap::CompileCache::JSON,
kwargs,
)
rescue Errno::EACCES
::Bootsnap::CompileCache.permission_error(path)
end
end

ruby2_keywords :load_file if respond_to?(:ruby2_keywords, true)
end
end
end
end
81 changes: 81 additions & 0 deletions test/compile_cache/json_test.rb
@@ -0,0 +1,81 @@
# frozen_string_literal: true
require('test_helper')

class CompileCacheJSONTest < Minitest::Test
include(TmpdirHelper)

module FakeJson
Fallback = Class.new(StandardError)
class << self
def load_file(path, symbolize_names: false, freeze: false, fallback: nil)
raise Fallback
end
end
end

def setup
super
Bootsnap::CompileCache::JSON.init!
FakeJson.singleton_class.prepend(Bootsnap::CompileCache::JSON::Patch)
end

def test_json_input_to_output
document = ::Bootsnap::CompileCache::JSON.input_to_output(<<~JSON, {})
{
"foo": 42,
"bar": [1]
}
JSON
expected = {
'foo' => 42,
'bar' => [1],
}
assert_equal expected, document
end

def test_load_file
Help.set_file('a.json', '{"foo": "bar"}', 100)
assert_equal({'foo' => 'bar'}, FakeJson.load_file('a.json'))
end

def test_load_file_symbolize_names
Help.set_file('a.json', '{"foo": "bar"}', 100)
FakeJson.load_file('a.json')

if ::Bootsnap::CompileCache::JSON.supported_options.include?(:symbolize_names)
2.times do
assert_equal({foo: 'bar'}, FakeJson.load_file('a.json', symbolize_names: true))
end
else
assert_raises(FakeJson::Fallback) do # would call super
FakeJson.load_file('a.json', symbolize_names: true)
end
end
end

def test_load_file_freeze
Help.set_file('a.json', '["foo"]', 100)
FakeJson.load_file('a.json')

if ::Bootsnap::CompileCache::JSON.supported_options.include?(:freeze)
2.times do
string = FakeJson.load_file('a.json', freeze: true).first
assert_equal("foo", string)
assert_predicate(string, :frozen?)
end
else
assert_raises(FakeJson::Fallback) do # would call super
FakeJson.load_file('a.json', freeze: true)
end
end
end

def test_load_file_unknown_option
Help.set_file('a.json', '["foo"]', 100)
FakeJson.load_file('a.json')

assert_raises(FakeJson::Fallback) do # would call super
FakeJson.load_file('a.json', fallback: true)
end
end
end
5 changes: 5 additions & 0 deletions test/setup_test.rb
Expand Up @@ -20,6 +20,7 @@ def test_default_setup
load_path_cache: true,
compile_cache_iseq: Bootsnap.iseq_cache_supported?,
compile_cache_yaml: true,
compile_cache_json: true,
)

Bootsnap.default_setup
Expand All @@ -34,6 +35,7 @@ def test_default_setup_with_ENV_not_dev
load_path_cache: true,
compile_cache_iseq: Bootsnap.iseq_cache_supported?,
compile_cache_yaml: true,
compile_cache_json: true,
)

Bootsnap.default_setup
Expand All @@ -48,6 +50,7 @@ def test_default_setup_with_DISABLE_BOOTSNAP_LOAD_PATH_CACHE
load_path_cache: false,
compile_cache_iseq: Bootsnap.iseq_cache_supported?,
compile_cache_yaml: true,
compile_cache_json: true,
)

Bootsnap.default_setup
Expand All @@ -62,6 +65,7 @@ def test_default_setup_with_DISABLE_BOOTSNAP_COMPILE_CACHE
load_path_cache: true,
compile_cache_iseq: false,
compile_cache_yaml: false,
compile_cache_json: false,
)

Bootsnap.default_setup
Expand All @@ -83,6 +87,7 @@ def test_default_setup_with_BOOTSNAP_LOG
load_path_cache: true,
compile_cache_iseq: Bootsnap.iseq_cache_supported?,
compile_cache_yaml: true,
compile_cache_json: true,
)
Bootsnap.expects(:logger=).with($stderr.method(:puts))

Expand Down
5 changes: 4 additions & 1 deletion test/test_helper.rb
Expand Up @@ -10,6 +10,7 @@
require('bundler/setup')
require('bootsnap')
require('bootsnap/compile_cache/yaml')
require('bootsnap/compile_cache/json')

require('tmpdir')
require('fileutils')
Expand All @@ -18,7 +19,7 @@
require('mocha/minitest')

cache_dir = File.expand_path('../../tmp/bootsnap/compile-cache', __FILE__)
Bootsnap::CompileCache.setup(cache_dir: cache_dir, iseq: true, yaml: false)
Bootsnap::CompileCache.setup(cache_dir: cache_dir, iseq: true, yaml: false, json: false)

if GC.respond_to?(:verify_compaction_references)
# This method was added in Ruby 3.0.0. Calling it this way asks the GC to
Expand Down Expand Up @@ -99,6 +100,7 @@ def setup
@prev = Bootsnap::CompileCache::ISeq.cache_dir
Bootsnap::CompileCache::ISeq.cache_dir = @tmp_dir
Bootsnap::CompileCache::YAML.cache_dir = @tmp_dir
Bootsnap::CompileCache::JSON.cache_dir = @tmp_dir
end

def teardown
Expand All @@ -107,5 +109,6 @@ def teardown
FileUtils.remove_entry(@tmp_dir)
Bootsnap::CompileCache::ISeq.cache_dir = @prev
Bootsnap::CompileCache::YAML.cache_dir = @prev
Bootsnap::CompileCache::JSON.cache_dir = @prev
end
end