[2.x] fix: Fixes filesystem traversal order affecting cache stability (#9646)

**Problem**
CompileInputs2.sources and packageBin / mappings are hashed positionally but arrive in
filesystem traversal order, which differs between APFS, ext4 and overlayfs. Two machines
hash an identical tree into different cache keys and cannot use each other's entries.

**Solution**
Sort sourcesVF by virtualized id, and mappings where packageConfigurationTask reads
it. Both are where the keys are built, so nothing appended later escapes the sort.

Generated-by: Claude Opus 5
This commit is contained in:
Christian Harrington
2026-08-20 15:52:50 -04:00
committed by GitHub
parent 38b73189ef
commit 82705f1cd5
15 changed files with 127 additions and 2 deletions
+3 -2
View File
@@ -625,8 +625,9 @@ object Defaults extends BuildCommon with DefExtra {
sources := Classpaths.concatDistinct(unmanagedSources, managedSources).value,
sourcesVF := Def.uncached {
val conv = fileConverter.value
sources.value.toVector.map: x =>
val vs = sources.value.toVector.map: x =>
(conv.toVirtualFile(x.toPath()): HashedVirtualFileRef)
vs.sortBy(_.id)
},
)
lazy val resourceConfigPaths = Seq(
@@ -2046,7 +2047,7 @@ object Defaults extends BuildCommon with DefExtra {
lazy val packageConfigurationTask: Initialize[Task[Pkg.Configuration]] =
Def.task {
Pkg.Configuration(
mappings.value,
mappings.value.sortBy(_._2),
artifactPath.value,
packageOptions.value,
)
+72
View File
@@ -0,0 +1,72 @@
import sbt.internal.util.CacheEventSummary
val delClasses = taskKey[Unit]("deletes the classes directory")
val delJar = taskKey[Unit]("deletes the packaged jar")
val saveJar = taskKey[Unit]("copies the packaged jar aside for later comparison")
val checkNoMiss = taskKey[Unit]("asserts the previous command was served entirely from the cache")
val checkClasses = taskKey[Unit]("asserts class files exist")
val checkJarMatches = taskKey[Unit]("asserts the jar is byte-identical to the saved one")
Global / localCacheDirectory := baseDirectory.value / "diskcache"
// A distinct project id keeps this fixture's output paths from colliding with same-named
// sibling fixtures in scripted's shared batch directory.
lazy val inputOrder = project
.in(file("."))
.settings(
scalaVersion := "3.8.4",
// Reversing stands in for a second filesystem's readdir order, which is the only part of
// this that cannot be reproduced on a single machine.
Compile / sources := {
val ss = (Compile / sources).value
if ((baseDirectory.value / "reverse.marker").exists) ss.reverse else ss
},
Compile / packageBin / mappings := {
val ms = (Compile / packageBin / mappings).value
if ((baseDirectory.value / "reverse.marker").exists) ms.reverse else ms
},
)
delClasses := Def.uncached {
val dir = (inputOrder / Compile / classDirectory).value
IO.delete(dir)
streams.value.log.info(s"deleted $dir")
}
delJar := Def.uncached {
val conv = fileConverter.value
val jar = conv.toPath((inputOrder / Compile / packageBin / artifactPath).value).toFile()
IO.delete(jar)
streams.value.log.info(s"deleted $jar")
}
saveJar := Def.uncached {
val conv = fileConverter.value
val jar = conv.toPath((inputOrder / Compile / packageBin).value).toFile()
IO.copyFile(jar, baseDirectory.value / "expected.jar")
}
checkJarMatches := Def.uncached {
val conv = fileConverter.value
val jar = conv.toPath((inputOrder / Compile / packageBin).value).toFile()
val expected = baseDirectory.value / "expected.jar"
assert(
java.util.Arrays.equals(IO.readBytes(jar), IO.readBytes(expected)),
s"jar bytes changed after reordering packageBin / mappings"
)
}
checkNoMiss := Def.uncached {
val config = Def.cacheConfiguration.value
val prev = config.cacheEventLog.previous match
case data: CacheEventSummary.Data => data
case _ => sys.error("empty event log")
streams.value.log.info(s"prev hitCount=${prev.hitCount} missCount=${prev.missCount}")
assert(prev.missCount == 0, s"expected no cache misses but missCount=${prev.missCount}")
}
checkClasses := Def.uncached {
val dir = (inputOrder / Compile / classDirectory).value
val classes = (dir ** "*.class").get()
assert(classes.nonEmpty, s"no class files under $dir")
}
@@ -0,0 +1 @@
a=1
@@ -0,0 +1 @@
z=1
@@ -0,0 +1 @@
r=1
@@ -0,0 +1,3 @@
package com.example.alpha
class Calpha1
@@ -0,0 +1,3 @@
package com.example.alpha
class Calpha2
@@ -0,0 +1,3 @@
package com.example.alpha
class Calpha3
@@ -0,0 +1,3 @@
package com.example.ci
class Cci1
@@ -0,0 +1,3 @@
package com.example.ci
class Cci2
@@ -0,0 +1,3 @@
package com.example.ci
class Cci3
@@ -0,0 +1,3 @@
package com.example.lib
class Clib1
@@ -0,0 +1,3 @@
package com.example.lib
class Clib2
@@ -0,0 +1,3 @@
package com.example.lib
class Clib3
+22
View File
@@ -0,0 +1,22 @@
# Regression for #9645: neither the traversal order of Compile / sources nor of
# packageBin / mappings may take part in a cache key. When it does, a macOS developer and a
# Linux CI runner hash an identical tree into different keys and neither can use the other's
# entries.
# Populate the cache with both input lists in filesystem traversal order.
> compile
> packageBin
> saveJar
# Reverse both lists. No key may move.
$ touch reverse.marker
> delClasses
> compile
> checkNoMiss
> checkClasses
> delJar
> packageBin
> checkNoMiss
> checkJarMatches