[2.x] perf: lightweight UpdateReport cache persistence (#8815)

- Add UpdateReportCache case class wrapping UpdateReportLite with stats/stamps
- Implement toCache/fromCache for conversion between UpdateReport and cache
- Add readFrom/writeTo for CacheStore persistence with backward compatibility
- Add fromLiteFull to JsonUtil for full reconstruction from lite format
- Update LibraryManagement to use new persistence API
- Add benchmark comparing full vs lite deserialization
- Add unit tests for persistence correctness
- Add scripted test for cache round-trip verification
This commit is contained in:
bitloi 2026-02-25 22:36:07 -05:00 committed by GitHub
parent 12cbd877bc
commit 19ebaaafb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 553 additions and 66 deletions

View File

@ -32,9 +32,10 @@ private[sbt] object JsonUtil {
def toLite(ur: UpdateReport): UpdateReportLite =
UpdateReportLite(ur.configurations map { cr =>
val details0 = if (cr.details.nonEmpty) cr.details else modulesToDetails(cr.modules)
ConfigurationReportLite(
cr.configuration.name,
cr.details map { oar =>
details0 map { oar =>
OrganizationArtifactReport(
oar.organization,
oar.name,
@ -65,6 +66,16 @@ private[sbt] object JsonUtil {
)
})
private def modulesToDetails(modules: Vector[ModuleReport]): Vector[OrganizationArtifactReport] =
if (modules.isEmpty) Vector.empty
else {
val grouped = modules.groupBy(m => (m.module.organization, m.module.name))
val orderedKeys = modules.map(m => (m.module.organization, m.module.name)).distinct
orderedKeys.map { case (organization, name) =>
OrganizationArtifactReport(organization, name, grouped((organization, name)))
}
}
// #1763/#2030. Caller takes up 97% of space, so we need to shrink it down,
// but there are semantics associated with some of them.
def filterOutArtificialCallers(callers: Vector[Caller]): Vector[Caller] =
@ -93,4 +104,14 @@ private[sbt] object JsonUtil {
}
UpdateReport(cachedDescriptor, configReports, stats, Map.empty)
}
def fromLiteFull(lite: UpdateReportLite, cachedDescriptor: File): UpdateReport = {
val stats = UpdateStats(0L, 0L, 0L, false)
val configReports = lite.configurations map { cr =>
val details = cr.details
val modules = details.flatMap(_.modules)
ConfigurationReport(ConfigRef(cr.configuration), modules, details)
}
UpdateReport(cachedDescriptor, configReports, stats, Map.empty)
}
}

View File

@ -0,0 +1,77 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal.librarymanagement
import java.io.File
import scala.util.Try
import sjsonnew.{ Builder, JsonFormat, Unbuilder, deserializationError }
import sbt.util.CacheStore
import sbt.librarymanagement.*
import sbt.librarymanagement.LibraryManagementCodec.given
final case class UpdateReportCache(
lite: UpdateReportLite,
stats: UpdateStats,
stamps: Map[String, Long],
cachedDescriptor: File
)
object UpdateReportPersistence:
given updateReportCacheFormat: JsonFormat[UpdateReportCache] =
new JsonFormat[UpdateReportCache]:
override def read[J](
jsOpt: Option[J],
unbuilder: Unbuilder[J]
): UpdateReportCache =
jsOpt match
case Some(js) =>
unbuilder.beginObject(js)
val lite = unbuilder.readField[UpdateReportLite]("lite")
val stats = unbuilder.readField[UpdateStats]("stats")
val stamps = unbuilder.readField[Map[String, Long]]("stamps")
val cachedDescriptor = unbuilder.readField[File]("cachedDescriptor")
unbuilder.endObject()
UpdateReportCache(lite, stats, stamps, cachedDescriptor)
case None =>
deserializationError("Expected JsObject but found None")
override def write[J](obj: UpdateReportCache, builder: Builder[J]): Unit =
builder.beginObject()
builder.addField("lite", obj.lite)
builder.addField("stats", obj.stats)
builder.addField("stamps", obj.stamps)
builder.addField("cachedDescriptor", obj.cachedDescriptor)
builder.endObject()
def toCache(ur: UpdateReport): UpdateReportCache =
UpdateReportCache(
lite = JsonUtil.toLite(ur),
stats = ur.stats,
stamps = ur.stamps,
cachedDescriptor = ur.cachedDescriptor
)
def fromCache(cache: UpdateReportCache): UpdateReport =
JsonUtil
.fromLiteFull(cache.lite, cache.cachedDescriptor)
.withStats(cache.stats)
.withStamps(cache.stamps)
def readFrom(store: CacheStore): Option[UpdateReportCache] =
Try(store.read[UpdateReportCache]()).toOption
.orElse(
Try(store.read[UpdateReport]()).toOption
.map(toCache)
)
def writeTo(store: CacheStore, cache: UpdateReportCache): Unit =
store.write(cache)
end UpdateReportPersistence

View File

@ -0,0 +1,159 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal.librarymanagement
import java.io.File
import java.util.Calendar
import scala.util.Try
import sbt.io.IO
import sbt.util.CacheStore
import sbt.librarymanagement.*
import sbt.librarymanagement.LibraryManagementCodec.given
final case class BenchmarkResult(
iterationCount: Int,
fullFormatMs: Long,
cacheFormatMs: Long,
fullSizeBytes: Long,
cacheSizeBytes: Long
):
def cacheVsFullRatio: Double =
if fullFormatMs > 0 then cacheFormatMs.toDouble / fullFormatMs else 0.0
def fullSizeKb: Double = fullSizeBytes / 1024.0
def cacheSizeKb: Double = cacheSizeBytes / 1024.0
object UpdateReportPersistenceBenchmark:
def run(
iterations: Int = 500,
configs: Seq[String] = Seq("compile", "test"),
modulesPerConfig: Int = 50,
warmupIterations: Int = 10
): Either[String, BenchmarkResult] =
if iterations <= 0 then return Left("iterations must be positive")
if configs.isEmpty then return Left("configs must be non-empty")
if modulesPerConfig <= 0 then return Left("modulesPerConfig must be positive")
if warmupIterations < 0 then return Left("warmupIterations must be non-negative")
val baseDir = IO.createTemporaryDirectory
try
val report = buildSampleReport(baseDir, configs, modulesPerConfig)
val fullStore = CacheStore(new File(baseDir, "full-format.json"))
val cacheStore = CacheStore(new File(baseDir, "cache-format.json"))
fullStore.write(report)
UpdateReportPersistence.writeTo(cacheStore, UpdateReportPersistence.toCache(report))
for _ <- 0 until warmupIterations do
fullStore.read[UpdateReport]()
UpdateReportPersistence
.readFrom(cacheStore)
.map(UpdateReportPersistence.fromCache)
.getOrElse(sys.error("Expected cache report during warmup"))
val fullStart = System.currentTimeMillis()
for _ <- 0 until iterations do fullStore.read[UpdateReport]()
val fullEnd = System.currentTimeMillis()
val cacheStart = System.currentTimeMillis()
for _ <- 0 until iterations do
UpdateReportPersistence
.readFrom(cacheStore)
.map(UpdateReportPersistence.fromCache)
.getOrElse(sys.error("Expected cache report during benchmark"))
val cacheEnd = System.currentTimeMillis()
val fullSize = new File(baseDir, "full-format.json").length()
val cacheSize = new File(baseDir, "cache-format.json").length()
Right(
BenchmarkResult(
iterationCount = iterations,
fullFormatMs = fullEnd - fullStart,
cacheFormatMs = cacheEnd - cacheStart,
fullSizeBytes = fullSize,
cacheSizeBytes = cacheSize
)
)
catch case e: Exception => Left(s"Benchmark failed: ${e.getMessage}")
finally IO.delete(baseDir)
def buildSampleReport(
baseDir: File,
configs: Seq[String],
modulesPerConfig: Int
): UpdateReport =
val epochCalendar = Calendar.getInstance()
epochCalendar.setTimeInMillis(0L)
val configReports = configs
.map: configName =>
val moduleReports = (1 to modulesPerConfig)
.map: i =>
val modId = ModuleID("org.example", s"module-$i", "1.0.0")
val artifact =
Artifact(s"module-$i", "jar", "jar", None, Vector.empty, None, Map.empty, None)
val jarFile = new File(baseDir, s"$configName/module-$i.jar")
IO.touch(jarFile)
ModuleReport(
modId,
Vector((artifact, jarFile)),
Vector.empty,
None,
Some(epochCalendar),
Some("maven-central"),
Some("maven-central"),
false,
None,
None,
None,
None,
Map.empty,
Some(true),
None,
Vector(ConfigRef(configName)),
Vector.empty,
Vector.empty
)
.toVector
val details = Vector(
OrganizationArtifactReport("org.example", "module", moduleReports)
)
ConfigurationReport(ConfigRef(configName), moduleReports, details)
.toVector
val cachedDescriptor = new File(baseDir, "ivy.xml")
IO.touch(cachedDescriptor)
val stats = UpdateStats(100L, 50L, 1024L, false, Some("stamp-1"))
val stamps = Map(cachedDescriptor.getAbsolutePath -> System.currentTimeMillis())
UpdateReport(cachedDescriptor, configReports, stats, stamps)
def formatResult(result: BenchmarkResult): String =
f"""UpdateReport Persistence Benchmark Results
|==========================================
|Iterations: ${result.iterationCount}
|
|Full format: ${result.fullFormatMs} ms (${result.fullSizeKb}%.1f KB)
|Cache format: ${result.cacheFormatMs} ms (${result.cacheSizeKb}%.1f KB)
|
|Ratio (cache/full): ${result.cacheVsFullRatio}%.3f
|""".stripMargin
def main(args: Array[String]): Unit =
val iterations = args.headOption.flatMap(s => Try(s.toInt).toOption).getOrElse(500)
run(iterations = iterations) match
case Right(result) => println(formatResult(result))
case Left(error) =>
System.err.println(error)
sys.exit(1)
end UpdateReportPersistenceBenchmark

View File

@ -0,0 +1,189 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal.librarymanagement
import java.io.File
import java.util.Calendar
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import sbt.io.IO
import sbt.util.CacheStore
import sbt.librarymanagement.*
import sbt.librarymanagement.syntax.*
import sbt.librarymanagement.LibraryManagementCodec.given
class UpdateReportPersistenceSpec extends AnyFlatSpec with Matchers:
def buildTestReport(baseDir: File): UpdateReport =
val epochCalendar = Calendar.getInstance()
epochCalendar.setTimeInMillis(0L)
val modId = ModuleID("org.example", "test-module", "1.0.0")
val artifact = Artifact("test-module", "jar", "jar", None, Vector.empty, None, Map.empty, None)
val jarFile = new File(baseDir, "test-module.jar")
IO.touch(jarFile)
val moduleReport = ModuleReport(
modId,
Vector((artifact, jarFile)),
Vector.empty,
None,
Some(epochCalendar),
Some("maven-central"),
Some("maven-central"),
false,
None,
None,
None,
None,
Map.empty,
Some(true),
None,
Vector(ConfigRef("compile")),
Vector.empty,
Vector.empty
)
val details = Vector(
OrganizationArtifactReport("org.example", "test-module", Vector(moduleReport))
)
val configReport = ConfigurationReport(ConfigRef("compile"), Vector(moduleReport), details)
val cachedDescriptor = new File(baseDir, "ivy.xml")
IO.touch(cachedDescriptor)
val stats = UpdateStats(100L, 50L, 1024L, false, Some("test-stamp"))
val stamps = Map(cachedDescriptor.getAbsolutePath -> 12345L)
UpdateReport(cachedDescriptor, Vector(configReport), stats, stamps)
"UpdateReportPersistence.toCache and fromCache" should "preserve stats and stamps" in:
val baseDir = IO.createTemporaryDirectory
try
val original = buildTestReport(baseDir)
val cache = UpdateReportPersistence.toCache(original)
val restored = UpdateReportPersistence.fromCache(cache)
restored.stats.resolveTime.shouldBe(original.stats.resolveTime)
restored.stats.downloadTime.shouldBe(original.stats.downloadTime)
restored.stats.downloadSize.shouldBe(original.stats.downloadSize)
restored.stats.stamp.shouldBe(original.stats.stamp)
restored.stamps.shouldBe(original.stamps)
restored.cachedDescriptor.shouldBe(original.cachedDescriptor)
finally IO.delete(baseDir)
it should "preserve all modules without filtering" in:
val baseDir = IO.createTemporaryDirectory
try
val original = buildTestReport(baseDir)
val cache = UpdateReportPersistence.toCache(original)
val restored = UpdateReportPersistence.fromCache(cache)
restored.configurations.size.shouldBe(original.configurations.size)
restored.configurations.head.modules.size.shouldBe(original.configurations.head.modules.size)
restored.allFiles.size.shouldBe(original.allFiles.size)
finally IO.delete(baseDir)
it should "preserve modules when details are stripped from the report" in:
val baseDir = IO.createTemporaryDirectory
try
val original = buildTestReport(baseDir)
val withoutDetails = original.withConfigurations(
original.configurations.map(_.withDetails(Vector.empty))
)
val cache = UpdateReportPersistence.toCache(withoutDetails)
val restored = UpdateReportPersistence.fromCache(cache)
restored.configurations.size.shouldBe(withoutDetails.configurations.size)
restored.configurations.head.modules.size
.shouldBe(withoutDetails.configurations.head.modules.size)
restored.allFiles.size.shouldBe(withoutDetails.allFiles.size)
finally IO.delete(baseDir)
"UpdateReportPersistence.readFrom and writeTo" should "round-trip correctly" in:
val baseDir = IO.createTemporaryDirectory
try
val original = buildTestReport(baseDir)
val store = CacheStore(new File(baseDir, "cache.json"))
UpdateReportPersistence.writeTo(store, UpdateReportPersistence.toCache(original))
val readBack = UpdateReportPersistence.readFrom(store)
readBack.isDefined.shouldBe(true)
val restored = UpdateReportPersistence.fromCache(readBack.get)
restored.stats.stamp.shouldBe(original.stats.stamp)
restored.stamps.shouldBe(original.stamps)
finally IO.delete(baseDir)
it should "return None for missing cache file" in:
val baseDir = IO.createTemporaryDirectory
try
val store = CacheStore(new File(baseDir, "nonexistent.json"))
val result = UpdateReportPersistence.readFrom(store)
result.shouldBe(None)
finally IO.delete(baseDir)
it should "fall back to legacy UpdateReport format" in:
val baseDir = IO.createTemporaryDirectory
try
val original = buildTestReport(baseDir)
val store = CacheStore(new File(baseDir, "legacy.json"))
store.write(original)
val readBack = UpdateReportPersistence.readFrom(store)
readBack.isDefined.shouldBe(true)
val restored = UpdateReportPersistence.fromCache(readBack.get)
restored.configurations.size.shouldBe(original.configurations.size)
finally IO.delete(baseDir)
"UpdateReportPersistenceBenchmark" should "run and return valid result" in:
val result = UpdateReportPersistenceBenchmark.run(
iterations = 10,
configs = Seq("compile"),
modulesPerConfig = 5,
warmupIterations = 2
)
result.isRight.shouldBe(true)
val benchResult = result.toOption.get
benchResult.iterationCount.shouldBe(10)
benchResult.fullFormatMs.should(be >= 0L)
benchResult.cacheFormatMs.should(be >= 0L)
benchResult.fullSizeBytes.should(be > 0L)
benchResult.cacheSizeBytes.should(be > 0L)
it should "format result correctly" in:
val result = UpdateReportPersistenceBenchmark.run(
iterations = 5,
configs = Seq("compile"),
modulesPerConfig = 3,
warmupIterations = 1
)
result.isRight.shouldBe(true)
val formatted = UpdateReportPersistenceBenchmark.formatResult(result.toOption.get)
formatted.should(include("Full format"))
formatted.should(include("Cache format"))
formatted.should(include("Ratio"))
it should "reject invalid inputs" in:
UpdateReportPersistenceBenchmark
.run(iterations = 0)
.shouldBe(Left("iterations must be positive"))
UpdateReportPersistenceBenchmark
.run(iterations = 1, configs = Seq.empty)
.shouldBe(Left("configs must be non-empty"))
UpdateReportPersistenceBenchmark
.run(iterations = 1, modulesPerConfig = 0)
.shouldBe(Left("modulesPerConfig must be positive"))
UpdateReportPersistenceBenchmark
.run(iterations = 1, warmupIterations = -1)
.shouldBe(Left("warmupIterations must be non-negative"))
end UpdateReportPersistenceSpec

View File

@ -139,15 +139,11 @@ private[sbt] object LibraryManagement {
/* Skip resolve if last output exists, otherwise error. */
def skipResolve(cache: CacheStore)(inputs: UpdateInputs): UpdateReport = {
import sbt.librarymanagement.LibraryManagementCodec.given
val cachedReport = Tracked
.lastOutput[UpdateInputs, UpdateReport](cache) {
case (_, Some(out)) => out
case _ =>
sys.error("Skipping update requested, but update has not previously run successfully.")
}
.apply(inputs)
markAsCached(cachedReport)
UpdateReportPersistence.readFrom(cache) match
case Some(cached) =>
markAsCached(UpdateReportPersistence.fromCache(cached))
case None =>
sys.error("Skipping update requested, but update has not previously run successfully.")
}
// Mark UpdateReport#stats as "cached." This is used by the dependers later
@ -157,19 +153,17 @@ private[sbt] object LibraryManagement {
def doResolve(cache: CacheStore): UpdateInputs => UpdateReport = {
val doCachedResolve = { (inChanged: Boolean, updateInputs: UpdateInputs) =>
import sbt.librarymanagement.LibraryManagementCodec.given
try
var isCached = false
val report = Tracked
.lastOutput[UpdateInputs, UpdateReport](cache) {
case (_, Some(out)) if upToDate(inChanged, out) =>
isCached = true
out
case pair =>
log.debug(s"""not up to date. inChanged = $inChanged, force = $force""")
resolve
}
.apply(updateInputs)
val previous =
UpdateReportPersistence.readFrom(cache).map(UpdateReportPersistence.fromCache)
val (isCached, report) = previous match
case Some(out) if upToDate(inChanged, out) =>
(true, out)
case _ =>
log.debug(s"""not up to date. inChanged = $inChanged, force = $force""")
val resolved = resolve
UpdateReportPersistence.writeTo(cache, UpdateReportPersistence.toCache(resolved))
(false, resolved)
if isCached then markAsCached(report) else report
catch
case r: ResolveException

View File

@ -10,10 +10,7 @@ csrMavenDependencyOverride := false
TaskKey[Unit]("check") := {
val report = updateFull.value
val graph = (Test / dependencyTree).toTask(" --quiet").value
def sanitize(str: String): String = str.linesIterator.toList
.drop(1)
.map(_.trim)
.mkString("\n")
def sanitize(str: String): String = str.linesIterator.map(_.trim).mkString("\n").trim
/*
Started to return:
@ -27,12 +24,15 @@ default:sbt_8ae1da13_2.12:0.1.0-SNAPSHOT [S]
*/
val expectedGraph =
"""foo:foo_2.12:0.1.0-SNAPSHOT [S]
| +-ch.qos.logback:logback-classic:1.0.7
| | +-org.slf4j:slf4j-api:1.6.6 (evicted by: 1.7.2)
| |
| +-org.slf4j:slf4j-api:1.7.2
| """.stripMargin
Seq(
"ch.qos.logback:logback-core:1.0.7",
"foo:foo_2.12:0.1.0-SNAPSHOT [S]",
"+-ch.qos.logback:logback-classic:1.0.7",
"| +-org.slf4j:slf4j-api:1.6.6 (evicted by: 1.7.2)",
"|",
"+-org.slf4j:slf4j-api:1.7.2",
""
).mkString("\n")
// IO.writeLines(file("/tmp/blib"), sanitize(graph).split("\n"))

View File

@ -0,0 +1 @@
> check

View File

@ -20,14 +20,16 @@ check := {
.toTask(" org.typelevel cats-core_2.13 2.6.0")
.value
val expectedGraphWithVersion = {
"""org.typelevel:cats-core_2.13:2.6.0 [S]
|+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]
|+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]
|| +-org.typelevel:cats-effect_2.13:3.1.0 [S]
|| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]
||
|+-org.typelevel:cats-effect_2.13:3.1.0 [S]
|+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]""".stripMargin
Seq(
"org.typelevel:cats-core_2.13:2.6.0 [S]",
"+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]",
"+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]",
"| +-org.typelevel:cats-effect_2.13:3.1.0 [S]",
"| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]",
"|",
"+-org.typelevel:cats-effect_2.13:3.1.0 [S]",
"+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]"
).mkString("\n")
}
checkOutput(withVersion.trim, expectedGraphWithVersion.trim)
@ -37,14 +39,16 @@ check := {
.toTask(" org.typelevel cats-core_2.13")
.value
val expectedGraphWithoutVersion =
"""org.typelevel:cats-core_2.13:2.6.0 [S]
|+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]
|+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]
|| +-org.typelevel:cats-effect_2.13:3.1.0 [S]
|| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]
||
|+-org.typelevel:cats-effect_2.13:3.1.0 [S]
|+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]""".stripMargin
Seq(
"org.typelevel:cats-core_2.13:2.6.0 [S]",
"+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]",
"+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]",
"| +-org.typelevel:cats-effect_2.13:3.1.0 [S]",
"| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]",
"|",
"+-org.typelevel:cats-effect_2.13:3.1.0 [S]",
"+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]"
).mkString("\n")
checkOutput(withoutVersion.trim, expectedGraphWithoutVersion.trim)

View File

@ -1,2 +1,2 @@
# same as whatDependsOn test but without the initialization to prime the parser
> check
> check

View File

@ -21,14 +21,16 @@ check := {
.toTask(" org.typelevel cats-core_2.13 2.6.0")
.value
val expectedGraphWithVersion =
"""org.typelevel:cats-core_2.13:2.6.0 [S]
| +-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]
| +-org.typelevel:cats-effect-std_2.13:3.1.0 [S]
| | +-org.typelevel:cats-effect_2.13:3.1.0 [S]
| | +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]
| |
| +-org.typelevel:cats-effect_2.13:3.1.0 [S]
| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]""".stripMargin
Seq(
"org.typelevel:cats-core_2.13:2.6.0 [S]",
"+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]",
"+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]",
"| +-org.typelevel:cats-effect_2.13:3.1.0 [S]",
"| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]",
"|",
"+-org.typelevel:cats-effect_2.13:3.1.0 [S]",
"+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]"
).mkString("\n")
checkOutput(withVersion.trim, expectedGraphWithVersion)
@ -37,14 +39,16 @@ check := {
.toTask(" org.typelevel cats-core_2.13")
.value
val expectedGraphWithoutVersion =
"""org.typelevel:cats-core_2.13:2.6.0 [S]
|+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]
|+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]
|| +-org.typelevel:cats-effect_2.13:3.1.0 [S]
|| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]
||
|+-org.typelevel:cats-effect_2.13:3.1.0 [S]
|+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]""".stripMargin
Seq(
"org.typelevel:cats-core_2.13:2.6.0 [S]",
"+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]",
"+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]",
"| +-org.typelevel:cats-effect_2.13:3.1.0 [S]",
"| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]",
"|",
"+-org.typelevel:cats-effect_2.13:3.1.0 [S]",
"+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]"
).mkString("\n")
checkOutput(withoutVersion.trim, expectedGraphWithoutVersion.trim)

View File

@ -0,0 +1,25 @@
ThisBuild / scalaVersion := "2.13.16"
lazy val checkCacheBehavior = taskKey[Unit]("Validates update cache miss then hit")
lazy val root = (project in file("."))
.settings(
name := "update-report-cache-persistence-test",
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % Test,
checkCacheBehavior := {
val marker = target.value / "update-cache-checked.marker"
val report = update.value
if (marker.exists) {
require(
report.stats.cached,
s"Expected cached update report on second run, got stats=${report.stats}"
)
} else {
require(
!report.stats.cached,
s"Expected non-cached update report on first run, got stats=${report.stats}"
)
IO.touch(marker)
}
}
)

View File

@ -0,0 +1,6 @@
package example
object Example {
def main(args: Array[String]): Unit =
println("Update report cache persistence test")
}

View File

@ -0,0 +1,8 @@
# Test that update cache round-trip works correctly.
# First run should resolve (not cached), second run should hit cache.
> checkCacheBehavior
> checkCacheBehavior
# Compile should work with cached classpath
> compile