[2.x] test: Compare sbt 1.x vs 2.x vs remote cache

This commit is contained in:
Eugene Yokota
2026-09-28 03:09:52 -04:00
parent 4becc23595
commit 31cdedeae7
5 changed files with 195 additions and 60 deletions
+1 -1
View File
@@ -1449,7 +1449,7 @@ lazy val `community-build` = (project in file("community-build"))
scalaVersion := scala3,
libraryDependencies ++= Seq(junit % Test, junitInterface % Test),
Test / parallelExecution := false,
prepareCommunityBuild := {
prepareCommunityBuild := Def.uncached {
val _ = (sbtRoot / publishLocalBinAll).value
IO.write(baseDirectory.value / "target" / "sbt.version", version.value)
},
@@ -7,7 +7,7 @@ import java.nio.file.{ Files, Paths, StandardOpenOption }
final case class ParsedCacheSummary(
success: Boolean,
elapsedSeconds: Long,
hitRate: Double,
hitRate: Option[Double],
remoteCacheHits: Int,
diskCacheHits: Int,
onsiteTasks: Int,
@@ -44,25 +44,41 @@ object ParsedTestSummary:
case _ => None
end ParsedTestSummary
/** Collects cache summaries, attaching the preceding test summary to each. */
/**
* Collects cache summaries, attaching the preceding test summary to each.
* Summaries without a cache hit rate (sbt 1.x) are used only when there are none with it,
* since sbt 2.x also prints a plain total after each cache summary.
*/
final class CacheSummaryCollector:
private var pendingTests: Option[ParsedTestSummary] = None
private val summaries = List.newBuilder[ParsedCacheSummary]
private val withCache = List.newBuilder[ParsedCacheSummary]
private val withoutCache = List.newBuilder[ParsedCacheSummary]
def add(line: String): Unit =
ParsedTestSummary.parse(line) match
case Some(tests) => pendingTests = Some(tests)
case None =>
case None =>
ParsedCacheSummary
.parse(line)
.foreach(summary =>
summaries += summary.copy(tests = pendingTests)
val target = if summary.hitRate.isDefined then withCache else withoutCache
target += summary.copy(tests = pendingTests)
pendingTests = None
)
def result(): List[ParsedCacheSummary] = summaries.result()
def result(): List[ParsedCacheSummary] =
val cached = withCache.result()
if cached.nonEmpty then cached else withoutCache.result()
end CacheSummaryCollector
/** Describes one sbt invocation of a community project. */
final case class RunInfo(
project: String,
run: String,
sbtVersion: String,
wallClockSeconds: Double,
)
object ParsedCacheSummary:
private val Elapsed = """elapsed time: (\d+) s""".r.unanchored
private val HitRate = """\bcache (\d+)%""".r.unanchored
@@ -72,17 +88,16 @@ object ParsedCacheSummary:
private val Errors = """(\d+) errors?\b""".r.unanchored
def parse(line: String): Option[ParsedCacheSummary] =
for
elapsed <- first(Elapsed, line)
hitRate <- first(HitRate, line)
yield ParsedCacheSummary(
success = !line.contains("[error]"),
elapsedSeconds = elapsed.toLong,
hitRate = hitRate.toInt / 100.0,
remoteCacheHits = count(RemoteHits, line),
diskCacheHits = count(DiskHits, line),
onsiteTasks = count(Onsite, line),
errors = count(Errors, line),
first(Elapsed, line).map(elapsed =>
ParsedCacheSummary(
success = !line.contains("[error]"),
elapsedSeconds = elapsed.toLong,
hitRate = first(HitRate, line).map(_.toInt / 100.0),
remoteCacheHits = count(RemoteHits, line),
diskCacheHits = count(DiskHits, line),
onsiteTasks = count(Onsite, line),
errors = count(Errors, line),
)
)
private def first(regex: scala.util.matching.Regex, line: String): Option[String] =
@@ -92,27 +107,28 @@ object ParsedCacheSummary:
first(regex, line).fold(0)(_.toInt)
private[communitybuild] val tableHeader =
"""|| Project | Status | Elapsed | Cache | Remote hits | Disk hits | Onsite tasks | Errors | Tests | Cached tests |
||---|---|---|---|---|---|---|---|---|---|
"""|| Project | Run | sbt | Status | Wall clock | Elapsed | Cache | Remote hits | Disk hits | Onsite tasks | Errors | Tests | Cached tests |
||---|---|---|---|---|---|---|---|---|---|---|---|---|
|""".stripMargin
def markdownRow(project: String, summary: ParsedCacheSummary): String =
def markdownRow(info: RunInfo, summary: ParsedCacheSummary): String =
import summary.*
val status = if success then "success" else "error"
val percent = f"${hitRate * 100}%.0f%%"
val wallClock = f"${info.wallClockSeconds}%.1fs"
val percent = hitRate.fold("")(rate => f"${rate * 100}%.0f%%")
val testCount = tests.fold("")(_.total.toString)
val cachedTests = tests.fold("")(_.cached.toString)
s"| `$project` | $status | ${elapsedSeconds}s | $percent | $remoteCacheHits | $diskCacheHits | $onsiteTasks | $errors | $testCount | $cachedTests |\n"
s"| `${info.project}` | ${info.run} | ${info.sbtVersion} | $status | $wallClock | ${elapsedSeconds}s | $percent | $remoteCacheHits | $diskCacheHits | $onsiteTasks | $errors | $testCount | $cachedTests |\n"
def report(project: String, summaries: Seq[ParsedCacheSummary]): Unit =
summaries.foreach(s => log(s"[$project] $s"))
def report(info: RunInfo, summaries: Seq[ParsedCacheSummary]): Unit =
summaries.foreach(s => log(s"[${info.project}] ${info.run}: $s"))
sys.env
.get("GITHUB_STEP_SUMMARY")
.filter(_ => summaries.nonEmpty)
.foreach(p =>
val path = Paths.get(p)
val hasHeader = Files.exists(path) && Files.readString(path).contains(tableHeader)
val rows = summaries.map(markdownRow(project, _)).mkString
val rows = summaries.map(markdownRow(info, _)).mkString
Files.writeString(
path,
(if hasHeader then "" else tableHeader) + rows,
@@ -32,6 +32,30 @@ lazy val remoteCachePluginDir: Path =
)
dir
lazy val sbt1PluginDir: Path =
val dir = communitybuildDir.resolve("target").resolve("sbt1-plugins")
Files.createDirectories(dir)
Files.writeString(
dir.resolve("plugins.sbt"),
"""addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.11.0")
|addSbtPlugin("com.github.sbt" % "sbt2-compat" % "0.2.0")
|""".stripMargin,
)
Files.writeString(
dir.resolve("Sbt2Shims.scala"),
"""import sbt._
|
|object Sbt2Shims extends AutoPlugin {
| override def trigger = allRequirements
| object autoImport {
| val allowMismatchScala = settingKey[Boolean]("sbt 2.x shim")
| }
|}
|""".stripMargin,
)
dir
end sbt1PluginDir
lazy val sbtPluginFilePath: String =
// Workaround for https://github.com/sbt/sbt/issues/4395
new File(sys.props("user.home") + "/config/sbt/2/plugins").mkdirs()
@@ -65,6 +89,7 @@ enum Scenario:
case Test
case Build
case TestTest(minHitRate: Double, minTestHitRate: Double = 1.0)
case TestSbt1
sealed trait CommunityProject:
def project: String
@@ -74,6 +99,10 @@ sealed trait CommunityProject:
def docCommand: String
def binaryName: String
def runCommandsArgs: List[String] = Nil
def sbt1CommandsArgs: List[String] = Nil
def sbt1TestCommand: String = testCommand
def warmupCommand: Option[String] = None
def sbt1WarmupCommand: Option[String] = None
def environment: Map[String, String] = Map.empty
def diskCacheDir: Option[File] = None
def scenarioType: Scenario
@@ -87,7 +116,7 @@ sealed trait CommunityProject:
throw RuntimeException(
s"Publish command is not specified for $project. Project details:\n$this"
)
val (exitCode, _) = execAndShutdown(runCommandsArgs :+ publishCommand)
val (exitCode, _) = execAndShutdown(runCommandsArgs :+ publishCommand, "publish")
if exitCode != 0 then
throw RuntimeException(
s"Publish command exited with code $exitCode for project $project. Project details:\n$this"
@@ -97,7 +126,7 @@ sealed trait CommunityProject:
log(s"Documenting $project")
if docCommand eq null then
throw RuntimeException(s"Doc command is not specified for $project. Project details:\n$this")
val (exitCode, _) = execAndShutdown(runCommandsArgs :+ docCommand)
val (exitCode, _) = execAndShutdown(runCommandsArgs :+ docCommand, "doc")
if exitCode != 0 then
throw RuntimeException(
s"Doc command exited with code $exitCode for project $project. Project details:\n$this"
@@ -107,22 +136,33 @@ sealed trait CommunityProject:
case Scenario.Test => test()
case Scenario.Build => build()
case Scenario.TestTest(minHitRate, minTestHitRate) => testTest(minHitRate, minTestHitRate)
case Scenario.TestSbt1 => testSbt1()
final def build(): Int = execAndShutdown(buildCommands)._1
final def build(): Int = execAndShutdown(buildCommands, "build")._1
final def buildCommands = runCommandsArgs :+ testCompileCommand
final def test(): Int = execAndShutdown(runCommandsArgs :+ testCommand)._1
final def test(): Int = execAndShutdown(runCommandsArgs :+ testCommand, "test")._1
/** Runs the tests twice using sbt 1.x. */
final def testSbt1(): Int =
warmup(sbt1CommandsArgs, sbt1WarmupCommand)
val (firstExitCode, _) =
execAndShutdown(sbt1CommandsArgs :+ sbt1TestCommand, "test (1st)", sbt1CommandsArgs)
if firstExitCode != 0 then firstExitCode
else execAndShutdown(sbt1CommandsArgs :+ sbt1TestCommand, "test (2nd)", sbt1CommandsArgs)._1
/** Runs the tests twice, and asserts the second run is served from the cache. */
final def testTest(minHitRate: Double, minTestHitRate: Double): Int =
val (firstExitCode, _) = execAndShutdown(runCommandsArgs :+ testCommand)
warmup(runCommandsArgs, warmupCommand)
val (firstExitCode, _) = execAndShutdown(runCommandsArgs :+ testCommand, "test (1st)")
if firstExitCode != 0 then firstExitCode
else
diskCacheDir.foreach(wipeDirectory)
val (exitCode, summaries) = execAndShutdown(runCommandsArgs :+ testCommand)
val (exitCode, summaries) =
execAndShutdown(runCommandsArgs :+ testCommand, "test (2nd, disk cache wiped)")
assert(summaries.nonEmpty, s"no cache summary found in the second test run of $project")
val belowThreshold = summaries.filter(_.hitRate < minHitRate)
val belowThreshold = summaries.filter(_.hitRate.forall(_ < minHitRate))
assert(
belowThreshold.isEmpty,
s"cache hit rate of the second test run of $project is below $minHitRate: $belowThreshold"
@@ -135,13 +175,22 @@ sealed trait CommunityProject:
s"test hit rate of the second test run of $project is below $minTestHitRate: $testsBelowThreshold"
)
exitCode
end testTest
private def warmup(baseArgs: List[String], command: Option[String]): Unit =
command.foreach(cmd => execAndShutdown(baseArgs :+ cmd, "warm-up", baseArgs))
private def wipeDirectory(dir: File): Unit =
log(s"Wiping disk cache $dir")
IO.delete(IO.listFiles(dir))
private def execAndShutdown(arguments: List[String]): (Int, List[ParsedCacheSummary]) =
private def execAndShutdown(
arguments: List[String],
run: String,
baseArgs: List[String] = runCommandsArgs,
): (Int, List[ParsedCacheSummary]) =
val summaries = CacheSummaryCollector()
val start = System.nanoTime()
val exitCode = exec(
projectDir,
binaryName,
@@ -151,20 +200,31 @@ sealed trait CommunityProject:
println(line)
summaries.add(line)
)
exec(projectDir, binaryName, runCommandsArgs :+ "shutdown", environment)
val commandEnd = System.nanoTime()
exec(projectDir, binaryName, baseArgs :+ "shutdown", environment)
val shutdownEnd = System.nanoTime()
val wallClockSeconds = (commandEnd - start) / 1e9
log(
f"[$project] $run wall clock: $wallClockSeconds%.1f s command, ${(shutdownEnd - commandEnd) / 1e9}%.1f s shutdown"
)
val sbtVersionUsed = baseArgs.collectFirst { case s"-Dsbt.version=$v" => v }.getOrElse("")
val result = summaries.result()
ParsedCacheSummary.report(project, result)
ParsedCacheSummary.report(RunInfo(project, run, sbtVersionUsed, wallClockSeconds), result)
(exitCode, result)
end execAndShutdown
end CommunityProject
val sbt1Version = "1.12.1"
val sbt1Version = "1.13.0"
val sbt2Version = "2.0.3"
final case class SbtCommunityProject(
project: String,
testCmd: String = "test",
testCompileCmd: String = "Test/compile",
sbt1TestCmd: Option[String] = None,
warmupCmd: Option[String] = None,
sbt1WarmupCmd: Option[String] = None,
extraSbtArgs: List[String] = Nil,
publishCmd: String = "publishLocal",
docCmd: String = "doc",
@@ -178,13 +238,19 @@ final case class SbtCommunityProject(
scalacOptions.map("\"" + _ + "\"").mkString("List(", ",", ")")
private val baseCommand =
"set Global/logLevel := Level.Error; "
++ (if scalacOptions.isEmpty then ""
else s"""set Global/scalacOptions ++= $scalacOptionsString;""")
(if scalacOptions.isEmpty then ""
else s"""set Global/scalacOptions ++= $scalacOptionsString;""")
override val testCommand =
"""set Global/testOptions += Tests.Argument(TestFramework("munit.Framework"), "+l"); """
++ s"$baseCommand$testCmd"
private def mkTestCommand(cmd: String): String =
s"$baseCommand$cmd"
override val testCommand = mkTestCommand(testCmd)
override def sbt1TestCommand: String = sbt1TestCmd.fold(testCommand)(mkTestCommand)
override def warmupCommand: Option[String] = warmupCmd
override def sbt1WarmupCommand: Option[String] = sbt1WarmupCmd
override val testCompileCommand =
s"$baseCommand$testCompileCmd"
@@ -202,11 +268,21 @@ final case class SbtCommunityProject(
override def diskCacheDir: Option[File] = Some(localCacheDir)
private val sbtProps: List[String] = Option(System.getProperty("sbt.ivy.home")) match
case Some(ivyHome) => List(s"-Dsbt.ivy.home=$ivyHome")
case _ => Nil
override val sbt1CommandsArgs: List[String] =
extraSbtArgs ++ sbtProps ++ List(
s"-Dsbt.global.plugins=$sbt1PluginDir",
s"-Dsbt.version=$sbt1Version",
s"-Dsbt.boot=$bootDir",
"-Dsbt.supershell=false",
"--error",
)
override val runCommandsArgs: List[String] =
// Run the sbt command with the compiler version and sbt plugin set in the build
val sbtProps = Option(System.getProperty("sbt.ivy.home")) match
case Some(ivyHome) => List(s"-Dsbt.ivy.home=$ivyHome")
case _ => Nil
val remoteCacheProps = SbtCommunityProject.remoteCache.toList.flatMap(uri =>
List(
s"-Dsbt.global.plugins=$remoteCachePluginDir",
@@ -218,6 +294,7 @@ final case class SbtCommunityProject(
s"-Dsbt.boot=$bootDir",
s"-Dsbt.global.localcache=$localCacheDir",
"-Dsbt.supershell=false",
"--error",
)
end SbtCommunityProject
@@ -265,10 +342,17 @@ object projects:
project = "chimney",
testCmd = all(chimneyJvmProjects.map(p => s"$p/test")*),
testCompileCmd = all(chimneyJvmProjects.map(p => s"$p/Test/compile")*),
warmupCmd = Some(all(chimneyJvmProjects.map(p => s"$p/update")*)),
environment = Map("_JAVA_OPTIONS" -> "-Xmx2g"),
scenarioType = Scenario.TestTest(minHitRate = 0.9),
)
lazy val `chimney-sbt1` = chimney.copy(
sbt1TestCmd = Some(all(chimneyJvmProjects.map(p => s"${p}3/test")*)),
sbt1WarmupCmd = Some(all(chimneyJvmProjects.map(p => s"${p}3/update")*)),
scenarioType = Scenario.TestSbt1,
)
lazy val `sbt-compile-benchmark` = SbtCommunityProject(
project = "sbt-compile-benchmark",
scenarioType = Scenario.Build,
@@ -16,6 +16,7 @@ given testRunner: CommunityBuildRunner with
@Category(Array(classOf[TestCategory]))
class CommunityBuildTestA:
@Test def `chimney-sbt1` = projects.`chimney-sbt1`.run()
@Test def chimney = projects.chimney.run()
end CommunityBuildTestA
@@ -11,27 +11,41 @@ class ParsedCacheSummaryTest:
val summary = ParsedCacheSummary.parse(
"[success] elapsed time: 4 s, cache 100%, 191 remote cache hits, 8 disk cache hits"
)
assert(summary == Some(ParsedCacheSummary(true, 4, 1.0, 191, 8, 0, 0)))
assert(summary == Some(ParsedCacheSummary(true, 4, Some(1.0), 191, 8, 0, 0)))
@Test def parseSingularCountsAndDuration(): Unit =
val summary = ParsedCacheSummary.parse(
"[success] elapsed time: 74 s (0:01:14.0), cache 58%, 1 disk cache hit, 13 onsite tasks"
)
assert(summary == Some(ParsedCacheSummary(true, 74, 0.58, 0, 1, 13, 0)))
assert(summary == Some(ParsedCacheSummary(true, 74, Some(0.58), 0, 1, 13, 0)))
@Test def parseErrors(): Unit =
val summary = ParsedCacheSummary.parse(
"[error] elapsed time: 9 s, cache 91%, 1624 disk cache hits, 157 onsite tasks, 35 errors"
)
assert(summary == Some(ParsedCacheSummary(false, 9, 0.91, 0, 1624, 157, 35)))
assert(summary == Some(ParsedCacheSummary(false, 9, Some(0.91), 0, 1624, 157, 35)))
@Test def ignoreLinesWithoutCache(): Unit =
assert(ParsedCacheSummary.parse("[success] elapsed time: 18 s").isEmpty)
@Test def parseElapsedWithoutCache(): Unit =
val summary = ParsedCacheSummary.parse("[success] elapsed time: 5 s")
assert(summary == Some(ParsedCacheSummary(true, 5, None, 0, 0, 0, 0)))
@Test def ignoreLinesWithoutElapsed(): Unit =
assert(ParsedCacheSummary.parse("[info] compiling 3 Scala sources").isEmpty)
@Test def markdownRow(): Unit =
val row = ParsedCacheSummary.markdownRow("scalaz", ParsedCacheSummary(true, 4, 1.0, 191, 8, 0, 0))
assert(row == "| `scalaz` | success | 4s | 100% | 191 | 8 | 0 | 0 | | |\n")
val info = RunInfo("scalaz", "build", "2.1.0-SNAPSHOT", 12.34)
val row =
ParsedCacheSummary.markdownRow(info, ParsedCacheSummary(true, 4, Some(1.0), 191, 8, 0, 0))
assert(
row == "| `scalaz` | build | 2.1.0-SNAPSHOT | success | 12.3s | 4s | 100% | 191 | 8 | 0 | 0 | | |\n"
)
@Test def markdownRowWithoutCache(): Unit =
val info = RunInfo("chimney", "test (2nd)", "1.13.0", 9.5)
val row = ParsedCacheSummary.markdownRow(info, ParsedCacheSummary(true, 5, None, 0, 0, 0, 0))
assert(
row == "| `chimney` | test (2nd) | 1.13.0 | success | 9.5s | 5s | | 0 | 0 | 0 | 0 | | |\n"
)
@Test def tableHeaderLines(): Unit =
val lines = ParsedCacheSummary.tableHeader.linesIterator.toList
@@ -39,11 +53,13 @@ class ParsedCacheSummaryTest:
assert(lines.forall(line => line.startsWith("|") && line.endsWith("|")))
@Test def parseTestSummary(): Unit =
val tests = ParsedTestSummary.parse("[info] passed: total 94, failed 0, errors 0, passed 94, cached 94")
val tests =
ParsedTestSummary.parse("[info] passed: total 94, failed 0, errors 0, passed 94, cached 94")
assert(tests == Some(ParsedTestSummary(94, 0, 0, 94, 94)))
@Test def parseFailedTestSummary(): Unit =
val tests = ParsedTestSummary.parse("[error] failed: total 1, failed 1, errors 0, passed 0, cached 0")
val tests =
ParsedTestSummary.parse("[error] failed: total 1, failed 1, errors 0, passed 0, cached 0")
assert(tests == Some(ParsedTestSummary(1, 1, 0, 0, 0)))
@Test def collectorAttachesPrecedingTestSummary(): Unit =
@@ -51,20 +67,38 @@ class ParsedCacheSummaryTest:
List(
"[info] passed: total 94, failed 0, errors 0, passed 94, cached 94",
"[success] elapsed time: 5 s, cache 93%, 126 remote cache hits, 21 disk cache hits, 10 onsite tasks",
"[success] elapsed time: 5 s",
"[success] elapsed time: 1 s, cache 100%, 3 disk cache hits",
).foreach(collector.add)
val tests = Some(ParsedTestSummary(94, 0, 0, 94, 94))
assert(
collector.result() == List(
ParsedCacheSummary(true, 5, 0.93, 126, 21, 10, 0, tests),
ParsedCacheSummary(true, 1, 1.0, 0, 3, 0, 0, None),
ParsedCacheSummary(true, 5, Some(0.93), 126, 21, 10, 0, tests),
ParsedCacheSummary(true, 1, Some(1.0), 0, 3, 0, 0, None),
)
)
@Test def collectorFallsBackToElapsedOnly(): Unit =
val collector = CacheSummaryCollector()
List("[info] compiling 3 Scala sources", "[success] elapsed time: 5 s").foreach(collector.add)
assert(collector.result() == List(ParsedCacheSummary(true, 5, None, 0, 0, 0, 0)))
@Test def markdownRowWithTests(): Unit =
val summary = ParsedCacheSummary(true, 5, 0.93, 126, 21, 10, 0, Some(ParsedTestSummary(94, 0, 0, 94, 94)))
val row = ParsedCacheSummary.markdownRow("chimney", summary)
assert(row == "| `chimney` | success | 5s | 93% | 126 | 21 | 10 | 0 | 94 | 94 |\n")
val info = RunInfo("chimney", "test (1st)", "2.1.0-SNAPSHOT", 9.7)
val summary = ParsedCacheSummary(
true,
5,
Some(0.93),
126,
21,
10,
0,
Some(ParsedTestSummary(94, 0, 0, 94, 94)),
)
val row = ParsedCacheSummary.markdownRow(info, summary)
assert(
row == "| `chimney` | test (1st) | 2.1.0-SNAPSHOT | success | 9.7s | 5s | 93% | 126 | 21 | 10 | 0 | 94 | 94 |\n"
)
@Test def testHitRate(): Unit =
assert(ParsedTestSummary(94, 0, 0, 94, 94).testHitRate == 1.0)