Skip to content

Commit af5ff3f

Browse files
committed
Add jars-9 to ImmutableWorkspaceParents, skip workspace stubs
- Add jars-9 (classpath transformer cache) to ImmutableWorkspaceParents. The ClassNotFoundException for groovy build script classes was caused by partial jars-9 workspaces in the delta (receipt file without class files). - Skip workspace stubs that contain only metadata files (metadata.bin, results.bin, *.receipt) with no actual build output. These stubs provide no caching value and create broken workspace dirs when applied to a base that lacks the workspace hash. This fixes 558+ partial transforms workspaces observed in real CI deltas.
1 parent a944bf2 commit af5ff3f

10 files changed

Lines changed: 104 additions & 71 deletions

File tree

.golangci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ formatters:
2828
settings:
2929
goimports:
3030
local-prefixes:
31-
- github.com/joshfriend/bundle-cache
31+
- github.com/block/bundle-cache

action/src/helpers.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ async function install() {
3636

3737
let url;
3838
if (version === "latest") {
39-
url = `https://github.com/joshfriend/bundle-cache/releases/latest/download/gradle-cache-${suffix}`;
39+
url = `https://github.com/block/bundle-cache/releases/latest/download/gradle-cache-${suffix}`;
4040
} else {
4141
const tag = version.startsWith("v") ? version : `v${version}`;
42-
url = `https://github.com/joshfriend/bundle-cache/releases/download/${tag}/gradle-cache-${suffix}`;
42+
url = `https://github.com/block/bundle-cache/releases/download/${tag}/gradle-cache-${suffix}`;
4343
}
4444

4545
core.info(`Downloading gradle-cache from ${url}`);

cmd/gradle-cache/integration_test.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"strings"
1313
"testing"
1414
"time"
15+
16+
"github.com/block/bundle-cache/gradlecache"
1517
)
1618

1719
// fakeCachew is a minimal file-backed implementation of the cachew object API
@@ -442,7 +444,7 @@ func must(t *testing.T, err error) {
442444
// complete immutable workspaces even when only some files have new mtimes.
443445
//
444446
// In CI, the base bundle provides workspace output files (old mtime) while the
445-
// delta overwrites metadata files (new mtime). Without ImmutableWorkspaceParents,
447+
// delta overwrites metadata files (new mtime). Without AtomicCacheParents,
446448
// save-delta only captures the metadata → partial workspace. When that delta is
447449
// applied to a base that lacks the workspace, Gradle crashes with:
448450
//
@@ -554,7 +556,7 @@ dependencies { implementation("com.google.guava:guava:33.4.0-jre") }
554556
// ── Step 3: Simulate mtime skew, save delta ─────────────────
555557
// Snapshot workspace file counts before backdating (reference).
556558
refWorkspaceDirs := map[string]string{}
557-
for _, dirID := range []string{tt.dslCacheID, "transforms"} {
559+
for _, dirID := range []string{tt.dslCacheID, "transforms", "jars-9"} {
558560
dir := findDslCacheDir(t, ctx.gradleUserHome, dirID)
559561
if dir != "" {
560562
refWorkspaceDirs[dirID] = dir
@@ -591,13 +593,17 @@ dependencies { implementation("com.google.guava:guava:33.4.0-jre") }
591593

592594
// Check for partial workspaces by comparing delta vs reference.
593595
var corruptCount int
594-
for _, dirID := range []string{tt.dslCacheID, "transforms"} {
596+
for _, dirID := range []string{tt.dslCacheID, "transforms", "jars-9"} {
595597
refDir := refWorkspaceDirs[dirID]
596598
deltaDir := findDslCacheDir(t, freshHome, dirID)
597-
corruptCount += checkPartialWorkspaces(t, refDir, deltaDir)
599+
n := checkPartialWorkspaces(t, refDir, deltaDir)
600+
if n > 0 {
601+
t.Logf(" %s: %d partial workspace(s)", dirID, n)
602+
}
603+
corruptCount += n
598604
}
599605
if corruptCount > 0 {
600-
t.Errorf("found %d partial workspace(s) — ImmutableWorkspaceParents not working", corruptCount)
606+
t.Errorf("found %d partial workspace(s) — AtomicCacheParents not working", corruptCount)
601607
}
602608

603609
// Also restore base + delta normally and verify Gradle works.
@@ -614,8 +620,9 @@ dependencies { implementation("com.google.guava:guava:33.4.0-jre") }
614620

615621
output, err := gradleRunMayFail(ctx.projectDir, ctx.gradlew, ctx.gradleUserHome, "assemble")
616622
if err != nil {
617-
if strings.Contains(output, "metadata.bin") || strings.Contains(output, "workspace metadata") {
618-
t.Fatalf("workspace corruption after delta restore:\n%s", output)
623+
if strings.Contains(output, "metadata.bin") || strings.Contains(output, "workspace metadata") ||
624+
strings.Contains(output, "ClassNotFoundException") || strings.Contains(output, "immutable workspace") {
625+
t.Fatalf("cache corruption after delta restore:\n%s", output)
619626
}
620627
t.Fatalf("Gradle failed after delta restore: %v\n%s", err, output)
621628
}
@@ -624,15 +631,16 @@ dependencies { implementation("com.google.guava:guava:33.4.0-jre") }
624631
}
625632
}
626633

627-
// backdateWorkspaceOutputs walks immutable workspace directories (transforms/,
628-
// groovy-dsl/, kotlin-dsl/) and backdates all non-metadata files to simulate the
629-
// mtime skew from base+delta extraction. Returns the number of workspaces affected.
634+
// backdateWorkspaceOutputs walks atomic cache directories (transforms/,
635+
// groovy-dsl/, kotlin-dsl/, jars-9/) and backdates all non-metadata files to
636+
// simulate the mtime skew from base+delta extraction. Returns the number of
637+
// workspaces affected.
630638
func backdateWorkspaceOutputs(t *testing.T, gradleUserHome, dslCacheID string) int {
631639
t.Helper()
632640
oldTime := time.Now().Add(-1 * time.Hour)
633641
affected := 0
634642

635-
for _, dirID := range []string{dslCacheID, "transforms"} {
643+
for _, dirID := range []string{dslCacheID, "transforms", "jars-9"} {
636644
wsParent := findDslCacheDir(t, gradleUserHome, dirID)
637645
if wsParent == "" {
638646
continue
@@ -649,9 +657,10 @@ func backdateWorkspaceOutputs(t *testing.T, gradleUserHome, dslCacheID string) i
649657
return nil
650658
}
651659
name := d.Name()
652-
// Keep metadata.bin and results.bin with current (new) mtime.
660+
// Keep metadata/receipt files with current (new) mtime.
653661
// Backdate everything else to simulate base-provided output files.
654-
if name != "metadata.bin" && name != "results.bin" {
662+
if name != "metadata.bin" && name != "results.bin" &&
663+
filepath.Ext(name) != ".receipt" {
655664
_ = os.Chtimes(path, oldTime, oldTime)
656665
backdatedAny = true
657666
}
@@ -755,7 +764,7 @@ func workspaceFileCounts(dir string) map[string]int {
755764
if isHexHash(name) {
756765
n := 0
757766
_ = filepath.WalkDir(filepath.Join(dir, name), func(_ string, d os.DirEntry, _ error) error {
758-
if d != nil && !d.IsDir() {
767+
if d != nil && !d.IsDir() && !gradlecache.IsExcludedCache(d.Name()) {
759768
n++
760769
}
761770
return nil

cmd/gradle-cache/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"github.com/alecthomas/errors"
1313
"github.com/alecthomas/kong"
1414

15-
"github.com/joshfriend/bundle-cache/gradlecache"
15+
"github.com/block/bundle-cache/gradlecache"
1616
)
1717

1818
const gradleUserHomeEnv = "GRADLE_USER_HOME"

dist/main/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33560,10 +33560,10 @@ async function install() {
3356033560

3356133561
let url;
3356233562
if (version === "latest") {
33563-
url = `https://github.com/joshfriend/bundle-cache/releases/latest/download/gradle-cache-${suffix}`;
33563+
url = `https://github.com/block/bundle-cache/releases/latest/download/gradle-cache-${suffix}`;
3356433564
} else {
3356533565
const tag = version.startsWith("v") ? version : `v${version}`;
33566-
url = `https://github.com/joshfriend/bundle-cache/releases/download/${tag}/gradle-cache-${suffix}`;
33566+
url = `https://github.com/block/bundle-cache/releases/download/${tag}/gradle-cache-${suffix}`;
3356733567
}
3356833568

3356933569
core.info(`Downloading gradle-cache from ${url}`);

dist/post/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33560,10 +33560,10 @@ async function install() {
3356033560

3356133561
let url;
3356233562
if (version === "latest") {
33563-
url = `https://github.com/joshfriend/bundle-cache/releases/latest/download/gradle-cache-${suffix}`;
33563+
url = `https://github.com/block/bundle-cache/releases/latest/download/gradle-cache-${suffix}`;
3356433564
} else {
3356533565
const tag = version.startsWith("v") ? version : `v${version}`;
33566-
url = `https://github.com/joshfriend/bundle-cache/releases/download/${tag}/gradle-cache-${suffix}`;
33566+
url = `https://github.com/block/bundle-cache/releases/download/${tag}/gradle-cache-${suffix}`;
3356733567
}
3356833568

3356933569
core.info(`Downloading gradle-cache from ${url}`);

dist/pre/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33560,10 +33560,10 @@ async function install() {
3356033560

3356133561
let url;
3356233562
if (version === "latest") {
33563-
url = `https://github.com/joshfriend/bundle-cache/releases/latest/download/gradle-cache-${suffix}`;
33563+
url = `https://github.com/block/bundle-cache/releases/latest/download/gradle-cache-${suffix}`;
3356433564
} else {
3356533565
const tag = version.startsWith("v") ? version : `v${version}`;
33566-
url = `https://github.com/joshfriend/bundle-cache/releases/download/${tag}/gradle-cache-${suffix}`;
33566+
url = `https://github.com/block/bundle-cache/releases/download/${tag}/gradle-cache-${suffix}`;
3356733567
}
3356833568

3356933569
core.info(`Downloading gradle-cache from ${url}`);

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
module github.com/joshfriend/bundle-cache
1+
module github.com/block/bundle-cache
22

33
go 1.25.0
44

gradle-cache.hcl

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
description = "Gradle build cache restore/save tool backed by S3"
2-
homepage = "https://github.com/joshfriend/bundle-cache"
2+
homepage = "https://github.com/block/bundle-cache"
33

44
binaries = ["gradle-cache"]
55
test = "gradle-cache --help"
66

77
# macOS: one universal binary for both Intel and Apple Silicon.
88
darwin {
9-
source = "https://github.com/joshfriend/bundle-cache/releases/download/${version}/gradle-cache-darwin-universal"
9+
source = "https://github.com/block/bundle-cache/releases/download/${version}/gradle-cache-darwin-universal"
1010
rename = {"gradle-cache-darwin-universal": "gradle-cache"}
1111
}
1212

1313
# Linux amd64
1414
linux {
1515
arch = "amd64"
16-
source = "https://github.com/joshfriend/bundle-cache/releases/download/${version}/gradle-cache-linux-amd64"
16+
source = "https://github.com/block/bundle-cache/releases/download/${version}/gradle-cache-linux-amd64"
1717
rename = {"gradle-cache-linux-amd64": "gradle-cache"}
1818
}
1919

2020
# Linux arm64
2121
linux {
2222
arch = "arm64"
23-
source = "https://github.com/joshfriend/bundle-cache/releases/download/${version}/gradle-cache-linux-arm64"
23+
source = "https://github.com/block/bundle-cache/releases/download/${version}/gradle-cache-linux-arm64"
2424
rename = {"gradle-cache-linux-arm64": "gradle-cache"}
2525
}
2626

gradlecache/save.go

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,20 @@ var CacheExclusions = []string{
415415
"user-id.txt",
416416
}
417417

418+
// isHexHash returns true if s is a non-empty string of hex characters (0-9, a-f).
419+
func isHexHash(s string) bool {
420+
if len(s) == 0 {
421+
return false
422+
}
423+
for _, c := range s {
424+
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
425+
if !isHex {
426+
return false
427+
}
428+
}
429+
return true
430+
}
431+
418432
// DeltaExclusions are additional file/directory names excluded only from delta
419433
// bundles. These files are already present in the base bundle and get rewritten
420434
// every build (Gradle's embedded BTree DB flushes on close even for read-only
@@ -624,41 +638,37 @@ func WriteDeltaTar(w io.Writer, baseDir string, relPaths []string) error {
624638
return tw.Close()
625639
}
626640

627-
// ImmutableWorkspaceParents maps directory names to the depth at which their
628-
// immutable workspace hash directories live. Depth 1 means the hash dirs are
629-
// direct children (e.g. transforms/<hash>/). Depth 2 means the hash dirs are
630-
// one level deeper (e.g. kotlin-dsl/scripts/<hash>/).
641+
// AtomicCacheParents lists directory names whose descendants include
642+
// hash-keyed workspace directories that must be captured atomically.
643+
// The walker recurses through intermediate subdirectories until it finds
644+
// children whose names look like hex hashes, then treats those as atomic
645+
// units: if ANY file inside has mtime > marker, ALL files are included.
631646
//
632-
// Each workspace is an atomic unit: Gradle creates all files together via an
633-
// atomic directory rename, and expects all files to be present when reading.
634-
// However, mtime skew can occur across delta cycles: after restoring base +
635-
// delta, the base provides output files (old mtime, before the marker) while
636-
// the delta overwrites metadata files like metadata.bin and results.bin (new
637-
// mtime, after the marker). A naive per-file mtime check would capture only
638-
// the metadata files, producing a partial workspace in the next delta. When
639-
// that partial delta is applied to a different base that lacks the workspace
640-
// hash, Gradle crashes with "Could not read workspace metadata".
647+
// These directories contain hash-keyed subdirectories where Gradle expects
648+
// all files to be present together. Some are true immutable workspaces
649+
// (transforms, groovy-dsl, kotlin-dsl, dependencies-accessors) created via
650+
// atomic directory renames. Others are persistent caches with receipt-based
651+
// completion markers (jars-9). In both cases, a partial directory (e.g.
652+
// metadata.bin without output files, or a .receipt without the transformed
653+
// classes) causes Gradle to crash.
641654
//
642-
// The fix: when ANY file in a workspace is newer than the marker, include ALL
643-
// files from that workspace in the delta.
644-
//
645-
// Workspace directory structure (relative to caches/<version>/):
646-
// - transforms/<hash>/ depth 1 - artifact transforms
647-
// - groovy-dsl/<hash>/ depth 1 - compiled Groovy build scripts
648-
// - kotlin-dsl/scripts/<hash>/ depth 2 - compiled Kotlin build scripts
649-
// - kotlin-dsl/accessors/<hash>/ depth 2 - generated type-safe accessors
650-
// - dependencies-accessors/<hash>/ depth 1 - version catalog accessors
651-
var ImmutableWorkspaceParents = map[string]int{
652-
"transforms": 1,
653-
"groovy-dsl": 1,
654-
"kotlin-dsl": 2,
655-
"dependencies-accessors": 1,
655+
// Mtime skew across delta cycles can produce partial directories: after
656+
// restoring base + delta, the base provides output files (old mtime) while
657+
// the delta overwrites metadata/receipt files (new mtime). A per-file mtime
658+
// check captures only the newer files, producing a partial directory in the
659+
// next delta. When applied to a base that lacks the hash, Gradle crashes.
660+
var AtomicCacheParents = map[string]bool{
661+
"transforms": true,
662+
"groovy-dsl": true,
663+
"kotlin-dsl": true,
664+
"dependencies-accessors": true,
665+
"jars-9": true,
656666
}
657667

658668
// CollectNewFiles walks realCaches in parallel and returns paths of regular files
659-
// with mtime strictly after since. For directories listed in ImmutableWorkspaceParents,
660-
// if any file in a child workspace is newer than since, all files in that workspace
661-
// are included to prevent partial restores.
669+
// with mtime strictly after since. For directories listed in AtomicCacheParents,
670+
// if any file in a child hash directory is newer than since, all files in that
671+
// directory are included to prevent partial restores.
662672
func CollectNewFiles(realCaches string, since time.Time, gradleHome string) ([]string, error) {
663673
workers := min(8, runtime.GOMAXPROCS(0))
664674
sem := make(chan struct{}, workers)
@@ -691,12 +701,12 @@ func CollectNewFiles(realCaches string, since time.Time, gradleHome string) ([]s
691701
}
692702

693703
// walkWorkspaceParent handles directories like transforms/ whose children
694-
// are atomic workspace directories. depth indicates how many directory levels
695-
// below dir the actual hash workspace directories live. When depth > 1
696-
// (e.g. kotlin-dsl/scripts/<hash>/), it recurses into intermediate directories
697-
// before treating children as atomic workspaces.
698-
var walkWorkspaceParent func(dir, rel string, depth int)
699-
walkWorkspaceParent = func(dir, rel string, depth int) {
704+
// are atomic workspace directories. It auto-detects whether children are
705+
// hash-keyed workspaces (hex names) or intermediate directories (like
706+
// kotlin-dsl/scripts/). For intermediates, it recurses; for hash dirs,
707+
// it checks if any file is new and captures all files atomically.
708+
var walkWorkspaceParent func(dir, rel string)
709+
walkWorkspaceParent = func(dir, rel string) {
700710
defer wg.Done()
701711

702712
entries, err := os.ReadDir(dir)
@@ -718,11 +728,11 @@ func CollectNewFiles(realCaches string, since time.Time, gradleHome string) ([]s
718728
childDir := filepath.Join(dir, entry.Name())
719729
childRel := rel + "/" + entry.Name()
720730

721-
if depth > 1 {
722-
// Not yet at the hash workspace level — recurse one level deeper.
731+
if !isHexHash(entry.Name()) {
732+
// Intermediate directory (e.g. kotlin-dsl/scripts/) — recurse.
723733
sem <- struct{}{}
724734
wg.Add(1)
725-
go walkWorkspaceParent(childDir, childRel, depth-1)
735+
go walkWorkspaceParent(childDir, childRel)
726736
continue
727737
}
728738

@@ -740,7 +750,21 @@ func CollectNewFiles(realCaches string, since time.Time, gradleHome string) ([]s
740750

741751
if hasNew {
742752
files := collectAll(childDir, childRel)
743-
if len(files) > 0 {
753+
// Skip workspace stubs that contain only metadata files
754+
// (metadata.bin, results.bin, *.receipt) with no actual
755+
// build output. These stubs provide no caching value and
756+
// can create broken workspace dirs when applied to a base
757+
// that lacks the workspace hash.
758+
hasOutput := false
759+
for _, f := range files {
760+
base := filepath.Base(f)
761+
if base != "metadata.bin" && base != "results.bin" &&
762+
filepath.Ext(base) != ".receipt" {
763+
hasOutput = true
764+
break
765+
}
766+
}
767+
if hasOutput {
744768
mu.Lock()
745769
allFiles = append(allFiles, files...)
746770
mu.Unlock()
@@ -776,10 +800,10 @@ func CollectNewFiles(realCaches string, since time.Time, gradleHome string) ([]s
776800
if IsExcludedCache(name) || IsDeltaExcluded(name) {
777801
continue
778802
}
779-
if depth, ok := ImmutableWorkspaceParents[name]; ok {
803+
if AtomicCacheParents[name] {
780804
sem <- struct{}{}
781805
wg.Add(1)
782-
go walkWorkspaceParent(filepath.Join(dir, name), childRel, depth)
806+
go walkWorkspaceParent(filepath.Join(dir, name), childRel)
783807
} else {
784808
sem <- struct{}{}
785809
wg.Add(1)

0 commit comments

Comments
 (0)