Skip to content

Commit 6c15f18

Browse files
authored
Remove Cache dependency from Metadata (heroku#1649)
The Cache class code is not unit testable locally (due to a dependence on GNU cp behavior) therefore anything that uses the Cache class cannot be unit tested. If we remove the use of a Cache instance, the metadata code becomes simpler, and we can write tests for it. The change here is that previously we wrote to the local `<app dir>/vendor/heroku` and then later copied it into the cache. Now we're directly writing into the cache. Deployed with current `heroku/ruby`: ``` $ git push heroku Enumerating objects: 666, done. Counting objects: 100% (666/666), done. Delta compression using up to 12 threads Compressing objects: 100% (321/321), done. Writing objects: 100% (666/666), 177.94 KiB | 88.97 MiB/s, done. Total 666 (delta 313), reused 660 (delta 310), pack-reused 0 (from 0) remote: Resolving deltas: 100% (313/313), done. remote: Updated 105 paths from 0366119 remote: Compressing source files... done. remote: Building source: remote: remote: -----> Building on the Heroku-24 stack remote: -----> Determining which buildpack to use for this app remote: ! Warning: Multiple default buildpacks reported the ability to handle this app. The first buildpack in the list below will be used. remote: Detected buildpacks: Ruby,Node.js remote: See https://devcenter.heroku.com/articles/buildpacks#buildpack-detect-order remote: -----> Ruby app detected remote: -----> Installing bundler 2.5.23 remote: -----> Removing BUNDLED WITH version in the Gemfile.lock remote: -----> Compiling Ruby/Rails remote: -----> Using Ruby version: ruby-3.2.4 remote: -----> Installing dependencies using bundler 2.5.23 remote: Running: BUNDLE_WITHOUT='development:test' BUNDLE_PATH=vendor/bundle BUNDLE_BIN=vendor/bundle/bin BUNDLE_DEPLOYMENT=1 bundle install -j4 remote: Fetching gem metadata from https://rubygems.org/......... remote: Fetching rake 13.3.0 remote: Installing rake 13.3.0 remote: Fetching concurrent-ruby 1.3.5 remote: Fetching bigdecimal 3.2.2 remote: Fetching base64 0.3.0 remote: Fetching benchmark 0.4.1 ``` Modified Ruby version and re-deploy: ``` $ git push heroku Enumerating objects: 5, done. Counting objects: 100% (5/5), done. Delta compression using up to 12 threads Compressing objects: 100% (3/3), done. Writing objects: 100% (3/3), 293 bytes | 293.00 KiB/s, done. Total 3 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0) remote: Updated 105 paths from 0f9d4c6 remote: Compressing source files... done. remote: Building source: remote: remote: -----> Building on the Heroku-24 stack remote: -----> Using buildpack: https://github.com/heroku/heroku-buildpack-ruby#schneems/metadata-too remote: -----> Ruby app detected remote: -----> Installing bundler 2.5.23 remote: -----> Removing BUNDLED WITH version in the Gemfile.lock remote: -----> Compiling Ruby/Rails remote: -----> Using Ruby version: ruby-3.3.9 remote: Loading bundler cache remote: Ruby version change detected. Clearing bundler cache. remote: Old: ruby 3.2.4 (2024-04-23 revision af471c0e01) [x86_64-linux] remote: New: ruby 3.3.9 (2025-07-24 revision f5c772fc7c) [x86_64-linux] remote: Purging bundler cache remote: -----> Installing dependencies using bundler 2.5.23 remote: Running: BUNDLE_WITHOUT='development:test' BUNDLE_PATH=vendor/bundle BUNDLE_BIN=vendor/bundle/bin BUNDLE_DEPLOYMENT=1 bundle install -j4 ``` (Note that "Ruby version change detected"). Re-deploy with new change (to ensure the metadata cache correctly persisted): ``` $ git push heroku Enumerating objects: 1, done. Counting objects: 100% (1/1), done. Writing objects: 100% (1/1), 188 bytes | 188.00 KiB/s, done. Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0) remote: Updated 105 paths from 0f9d4c6 remote: Compressing source files... done. remote: Building source: remote: remote: -----> Building on the Heroku-24 stack remote: -----> Using buildpack: https://github.com/heroku/heroku-buildpack-ruby#schneems/metadata-too remote: -----> Ruby app detected remote: -----> Installing bundler 2.5.23 remote: -----> Removing BUNDLED WITH version in the Gemfile.lock remote: -----> Compiling Ruby/Rails remote: -----> Using Ruby version: ruby-3.3.9 remote: Loading bundler cache remote: -----> Installing dependencies using bundler 2.5.23 remote: Running: BUNDLE_WITHOUT='development:test' BUNDLE_PATH=vendor/bundle BUNDLE_BIN=vendor/bundle/bin BUNDLE_DEPLOYMENT=1 bundle install -j4 remote: Bundle complete! 13 Gemfile dependencies, 87 gems now installed. remote: Gems in the groups 'development' and 'test' were not installed. remote: Bundled gems are installed into `./vendor/bundle` remote: Bundle completed (1.01s) remote: Cleaning up the bundler cache. remote: Running: bundle list remote: Gems included by the bundle: ```
1 parent dddbfee commit 6c15f18

File tree

6 files changed

+79
-54
lines changed

6 files changed

+79
-54
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [Unreleased]
44

5+
- Internal refactor: Remove Cache class dependency from Metadata class (https://github.com/heroku/heroku-buildpack-ruby/pull/1649)
56
- Improve message on `bin/detect` failure to include the list of files in the root directory (https://github.com/heroku/heroku-buildpack-ruby/pull/1647)
67

78
## [v322] - 2025-09-29

lib/language_pack/base.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ def initialize(app_path: , cache_path: , gemfile_lock: )
2626
@app_path = app_path
2727
@stack = ENV.fetch("STACK")
2828
@cache = LanguagePack::Cache.new(cache_path)
29-
@metadata = LanguagePack::Metadata.new(@cache)
29+
@metadata = LanguagePack::Metadata.new(cache_path: cache_path)
30+
@new_app = @metadata.empty?
3031
@bundler_cache = LanguagePack::BundlerCache.new(@cache, @stack)
3132
@fetchers = {:buildpack => LanguagePack::Fetcher.new(VENDOR_URL) }
3233
@arch = get_arch
@@ -47,6 +48,10 @@ def get_arch
4748
arch
4849
end
4950

51+
def new_app?
52+
@new_app
53+
end
54+
5055
def self.===(app_path)
5156
raise "must subclass"
5257
end

lib/language_pack/metadata.rb

Lines changed: 24 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,43 @@
11
require "language_pack"
22
require "language_pack/base"
33

4+
# Store data about the build in the cache
5+
#
6+
# Uses `<cache_path>/vendor/heroku` as the metadata directory. Which
7+
# is special cased in cache clearing code to be durable. This allows
8+
# for persistant generated data such as SECRET_KEY_BASE that would otherwise
9+
# cause session invalidation if it changed unexpectedly between deploys.
410
class LanguagePack::Metadata
5-
FOLDER = "vendor/heroku"
6-
7-
def initialize(cache)
8-
if cache
9-
@cache = cache
10-
@cache.load FOLDER
11-
end
12-
end
13-
14-
def [](key)
15-
read(key)
11+
def initialize(cache_path: )
12+
@dir = Pathname(cache_path)
13+
.join("vendor")
14+
.join("heroku")
15+
.tap(&:mkpath)
1616
end
1717

18-
def []=(key, value)
19-
write(key, value)
18+
def empty?
19+
@dir.children.empty?
2020
end
2121

2222
def read(key)
23-
full_key = "#{FOLDER}/#{key}"
24-
File.read(full_key).strip if exists?(key)
23+
@dir.join(key).read&.strip
2524
end
2625

2726
def exists?(key)
28-
full_key = "#{FOLDER}/#{key}"
29-
File.exist?(full_key) && !Dir.exist?(full_key)
27+
@dir.join(key).file?
3028
end
31-
alias_method :include?, :exists?
3229

33-
def write(key, value, isave = true)
34-
FileUtils.mkdir_p(FOLDER)
35-
36-
full_key = "#{FOLDER}/#{key}"
37-
File.open(full_key, 'w') {|f| f.puts value }
38-
save if isave
39-
40-
return true
41-
end
42-
43-
def touch(key)
44-
write(key, "true")
30+
def write(key, value)
31+
@dir.join(key).write(value)
4532
end
4633

4734
def fetch(key)
48-
return read(key) if exists?(key)
49-
50-
value = yield
51-
52-
write(key, value.to_s)
53-
return value
54-
end
55-
56-
def save(file = FOLDER)
57-
@cache ? @cache.add(file) : false
35+
if exists?(key)
36+
read(key)
37+
else
38+
value = yield
39+
write(key, value.to_s)
40+
value
41+
end
5842
end
5943
end

lib/language_pack/ruby.rb

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def warn_bad_binstubs
192192

193193
def default_malloc_arena_max?
194194
return true if @metadata.exists?("default_malloc_arena_max")
195-
return @metadata.touch("default_malloc_arena_max") if new_app?
195+
return @metadata.write("default_malloc_arena_max", "true") if new_app?
196196

197197
return false
198198
end
@@ -613,10 +613,6 @@ def install_ruby(install_path: )
613613
error message
614614
end
615615

616-
def new_app?
617-
@new_app ||= !app_path.join("vendor").join("heroku").exist?
618-
end
619-
620616
# find the ruby install path for its binstubs during build
621617
# @return [String] resulting path or empty string if ruby is not vendored
622618
def ruby_install_binstub_path(ruby_layer_path = ".")
@@ -1086,7 +1082,6 @@ def load_bundler_cache
10861082

10871083
full_ruby_version = run_stdout(%q(ruby -v)).strip
10881084
rubygems_version = run_stdout(%q(gem -v)).strip
1089-
heroku_metadata = "vendor/heroku"
10901085
old_rubygems_version = nil
10911086
ruby_version_cache = "ruby_version"
10921087
buildpack_version_cache = "buildpack_version"
@@ -1119,13 +1114,11 @@ def load_bundler_cache
11191114
purge_bundler_cache
11201115
end
11211116

1122-
FileUtils.mkdir_p(heroku_metadata)
1123-
@metadata.write(ruby_version_cache, full_ruby_version, false)
1124-
@metadata.write(buildpack_version_cache, BUILDPACK_VERSION, false)
1125-
@metadata.write(bundler_version_cache, bundler.version, false)
1126-
@metadata.write(rubygems_version_cache, rubygems_version, false)
1127-
@metadata.write(stack_cache, @stack, false)
1128-
@metadata.save
1117+
@metadata.write(ruby_version_cache, full_ruby_version)
1118+
@metadata.write(buildpack_version_cache, BUILDPACK_VERSION)
1119+
@metadata.write(bundler_version_cache, bundler.version)
1120+
@metadata.write(rubygems_version_cache, rubygems_version)
1121+
@metadata.write(stack_cache, @stack)
11291122
end
11301123

11311124
def purge_bundler_cache(stack = nil)

spec/hatchet/getting_started_spec.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,26 @@
44
it "works on Heroku-24" do
55
Hatchet::Runner.new("ruby-getting-started", stack: "heroku-24").deploy do |app|
66
expect(app.output).to_not include("Purging Cache")
7+
expect(app.output).to include("Fetching puma")
8+
9+
secret_key_base = app.run("echo $SECRET_KEY_BASE")
710

811
# Re-deploy with cache
912
run!("git commit --allow-empty -m empty")
1013
app.push!
1114

15+
# Assert used cached gems
16+
expect(app.output).to_not include("Fetching puma")
17+
1218
# Assert no warnings from `cp`
1319
# https://github.com/heroku/heroku-buildpack-ruby/pull/1586/files#r2064284286
1420
expect(app.output).to_not include("cp --help")
1521
expect(app.run("which ruby").strip).to eq("/app/bin/ruby")
1622

1723
environment_variables = app.run("env")
1824
expect(environment_variables).to match("PUMA_PERSISTENT_TIMEOUT")
25+
# Assert cached value persisted
26+
expect(environment_variables).to match("SECRET_KEY_BASE=#{secret_key_base}")
1927

2028
profile_d = app.run("cat .profile.d/ruby.sh")
2129
.strip

spec/helpers/metadata_spec.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
require 'spec_helper'
2+
3+
describe "Metadata" do
4+
it "can read and write to the metadata" do
5+
Dir.mktmpdir do |dir|
6+
metadata = LanguagePack::Metadata.new(cache_path: dir)
7+
expect(metadata.empty?).to be_truthy
8+
9+
expect(metadata.exists?("test")).to be_falsey
10+
metadata.write("test", "test")
11+
12+
expect(metadata.exists?("test")).to be_truthy
13+
expect(metadata.read("test")).to eq("test")
14+
expect(metadata.empty?).to be_falsey
15+
end
16+
end
17+
18+
it "can write a value conditionally" do
19+
Dir.mktmpdir do |dir|
20+
metadata = LanguagePack::Metadata.new(cache_path: dir)
21+
called = false
22+
23+
expect(metadata.empty?).to be_truthy
24+
expect(metadata.exists?("test")).to be_falsey
25+
metadata.fetch("test") do
26+
called = true
27+
"test"
28+
end
29+
expect(metadata.read("test")).to eq("test")
30+
expect(called).to be_truthy
31+
expect(metadata.empty?).to be_falsey
32+
end
33+
end
34+
end

0 commit comments

Comments
 (0)