mirror of https://github.com/sbt/sbt.git
[2.x] fix: Invalidate update cache across commands when dependencies change (#8501)
**Problem** When project A's dependencies changed and A was compiled in one command, project B (depending on A) would not invalidate its update cache in a subsequent command. This caused stale classpaths. The root cause was that `depsUpdated` only checked `!stats.cached`, which only detected fresh resolves within the same command. When a dependency was served from cache (even if resolved fresh in a previous command), `cached` was marked `true`, causing incorrect cache reuse. Debug scenario from issue: sbt:aaa> clean sbt:aaa> a/compile sbt:aaa> show itTests/depsUpdated [info] * false <-- BUG: should be true **Solution** Added `stamp: String` field to `UpdateStats` that records when the update was resolved. This stamp persists across commands and enables accurate cross-command comparison: - If any dependency's stamp > our cached stamp, we re-resolve - Falls back to `!cached` check for backwards compatibility with old caches - Using `String` type allows future transition from timestamps to content hashes
This commit is contained in:
parent
fe6125d8d1
commit
b4f73c9a7b
|
|
@ -4,26 +4,28 @@
|
|||
|
||||
// DO NOT EDIT MANUALLY
|
||||
package sbt.librarymanagement
|
||||
/** @param stamp Stamp for cache invalidation across commands. Currently a timestamp, but may transition to content hash. */
|
||||
final class UpdateStats private (
|
||||
val resolveTime: Long,
|
||||
val downloadTime: Long,
|
||||
val downloadSize: Long,
|
||||
val cached: Boolean) extends Serializable {
|
||||
|
||||
val cached: Boolean,
|
||||
val stamp: Option[String]) extends Serializable {
|
||||
|
||||
private def this(resolveTime: Long, downloadTime: Long, downloadSize: Long, cached: Boolean) = this(resolveTime, downloadTime, downloadSize, cached, None)
|
||||
|
||||
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
|
||||
case x: UpdateStats => (this.resolveTime == x.resolveTime) && (this.downloadTime == x.downloadTime) && (this.downloadSize == x.downloadSize) && (this.cached == x.cached)
|
||||
case x: UpdateStats => (this.resolveTime == x.resolveTime) && (this.downloadTime == x.downloadTime) && (this.downloadSize == x.downloadSize) && (this.cached == x.cached) && (this.stamp == x.stamp)
|
||||
case _ => false
|
||||
})
|
||||
override def hashCode: Int = {
|
||||
37 * (37 * (37 * (37 * (37 * (17 + "sbt.librarymanagement.UpdateStats".##) + resolveTime.##) + downloadTime.##) + downloadSize.##) + cached.##)
|
||||
37 * (37 * (37 * (37 * (37 * (37 * (17 + "sbt.librarymanagement.UpdateStats".##) + resolveTime.##) + downloadTime.##) + downloadSize.##) + cached.##) + stamp.##)
|
||||
}
|
||||
override def toString: String = {
|
||||
Seq("Resolve time: " + resolveTime + " ms", "Download time: " + downloadTime + " ms", "Download size: " + downloadSize + " bytes").mkString(", ")
|
||||
}
|
||||
private def copy(resolveTime: Long = resolveTime, downloadTime: Long = downloadTime, downloadSize: Long = downloadSize, cached: Boolean = cached): UpdateStats = {
|
||||
new UpdateStats(resolveTime, downloadTime, downloadSize, cached)
|
||||
private def copy(resolveTime: Long = resolveTime, downloadTime: Long = downloadTime, downloadSize: Long = downloadSize, cached: Boolean = cached, stamp: Option[String] = stamp): UpdateStats = {
|
||||
new UpdateStats(resolveTime, downloadTime, downloadSize, cached, stamp)
|
||||
}
|
||||
def withResolveTime(resolveTime: Long): UpdateStats = {
|
||||
copy(resolveTime = resolveTime)
|
||||
|
|
@ -37,8 +39,12 @@ final class UpdateStats private (
|
|||
def withCached(cached: Boolean): UpdateStats = {
|
||||
copy(cached = cached)
|
||||
}
|
||||
def withStamp(stamp: Option[String]): UpdateStats = {
|
||||
copy(stamp = stamp)
|
||||
}
|
||||
}
|
||||
object UpdateStats {
|
||||
|
||||
def apply(resolveTime: Long, downloadTime: Long, downloadSize: Long, cached: Boolean): UpdateStats = new UpdateStats(resolveTime, downloadTime, downloadSize, cached)
|
||||
def apply(resolveTime: Long, downloadTime: Long, downloadSize: Long, cached: Boolean, stamp: Option[String]): UpdateStats = new UpdateStats(resolveTime, downloadTime, downloadSize, cached, stamp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ given UpdateStatsFormat: JsonFormat[sbt.librarymanagement.UpdateStats] = new Jso
|
|||
val downloadTime = unbuilder.readField[Long]("downloadTime")
|
||||
val downloadSize = unbuilder.readField[Long]("downloadSize")
|
||||
val cached = unbuilder.readField[Boolean]("cached")
|
||||
val stamp = unbuilder.readField[Option[String]]("stamp")
|
||||
unbuilder.endObject()
|
||||
sbt.librarymanagement.UpdateStats(resolveTime, downloadTime, downloadSize, cached)
|
||||
sbt.librarymanagement.UpdateStats(resolveTime, downloadTime, downloadSize, cached, stamp)
|
||||
case None =>
|
||||
deserializationError("Expected JsObject but found None")
|
||||
}
|
||||
|
|
@ -27,6 +28,7 @@ given UpdateStatsFormat: JsonFormat[sbt.librarymanagement.UpdateStats] = new Jso
|
|||
builder.addField("downloadTime", obj.downloadTime)
|
||||
builder.addField("downloadSize", obj.downloadSize)
|
||||
builder.addField("cached", obj.cached)
|
||||
builder.addField("stamp", obj.stamp)
|
||||
builder.endObject()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -841,7 +841,8 @@
|
|||
{ "name": "resolveTime", "type": "long" },
|
||||
{ "name": "downloadTime", "type": "long" },
|
||||
{ "name": "downloadSize", "type": "long" },
|
||||
{ "name": "cached", "type": "boolean" }
|
||||
{ "name": "cached", "type": "boolean" },
|
||||
{ "name": "stamp", "type": "Option[String]", "default": "None", "since": "2.0.0", "doc": ["Stamp for cache invalidation across commands. Currently a timestamp, but may transition to content hash."] }
|
||||
],
|
||||
"toString": "Seq(\"Resolve time: \" + resolveTime + \" ms\", \"Download time: \" + downloadTime + \" ms\", \"Download size: \" + downloadSize + \" bytes\").mkString(\", \")"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ private[internal] object SbtUpdateReport {
|
|||
UpdateReport(
|
||||
new File("."), // dummy value
|
||||
configReports.toVector,
|
||||
UpdateStats(-1L, -1L, -1L, cached = false),
|
||||
UpdateStats(-1L, -1L, -1L, cached = false, stamp = Some(System.currentTimeMillis().toString)),
|
||||
Map.empty
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,7 +238,13 @@ object IvyRetrieve {
|
|||
Map.empty
|
||||
).recomputeStamps()
|
||||
def updateStats(report: ResolveReport): UpdateStats =
|
||||
UpdateStats(report.getResolveTime, report.getDownloadTime, report.getDownloadSize, false)
|
||||
UpdateStats(
|
||||
report.getResolveTime,
|
||||
report.getDownloadTime,
|
||||
report.getDownloadSize,
|
||||
false,
|
||||
Some(System.currentTimeMillis().toString)
|
||||
)
|
||||
def configurationReport(confReport: ConfigurationResolveReport): ConfigurationReport =
|
||||
ConfigurationReport(
|
||||
ConfigRef(confReport.getConfiguration),
|
||||
|
|
|
|||
|
|
@ -526,7 +526,8 @@ private[sbt] trait CachedResolutionResolveEngine extends ResolveEngine {
|
|||
resolveTime,
|
||||
(cachedReports map { _.stats.downloadTime }).sum,
|
||||
(cachedReports map { _.stats.downloadSize }).sum,
|
||||
false
|
||||
false,
|
||||
Some(System.currentTimeMillis().toString)
|
||||
)
|
||||
val configReports = rootModuleConfigs map { conf =>
|
||||
log.debug("::: -----------")
|
||||
|
|
|
|||
|
|
@ -3921,7 +3921,7 @@ object Classpaths {
|
|||
substituteScalaFiles(so, _)(providedScalaJars),
|
||||
skip = sk,
|
||||
force = shouldForce,
|
||||
depsUpdated = tu.exists(!_.stats.cached),
|
||||
transitiveUpdates = tu,
|
||||
uwConfig = uwConfig,
|
||||
evictionLevel = eel,
|
||||
versionSchemeOverrides = lds,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ import scala.concurrent.duration.FiniteDuration
|
|||
private[sbt] object LibraryManagement {
|
||||
given linter: sbt.dsl.LinterLevel.Ignore.type = sbt.dsl.LinterLevel.Ignore
|
||||
|
||||
private type UpdateInputs = (Long, ModuleSettings, UpdateConfiguration)
|
||||
// The fourth element is transitive dependency stamps for cross-command cache invalidation
|
||||
private type UpdateInputs = (Long, ModuleSettings, UpdateConfiguration, Vector[String])
|
||||
|
||||
def cachedUpdate(
|
||||
lm: DependencyResolution,
|
||||
|
|
@ -37,7 +38,7 @@ private[sbt] object LibraryManagement {
|
|||
transform: UpdateReport => UpdateReport,
|
||||
skip: Boolean,
|
||||
force: Boolean,
|
||||
depsUpdated: Boolean,
|
||||
transitiveUpdates: Seq[UpdateReport],
|
||||
uwConfig: UnresolvedWarningConfiguration,
|
||||
evictionLevel: Level.Value,
|
||||
versionSchemeOverrides: Seq[ModuleID],
|
||||
|
|
@ -123,8 +124,9 @@ private[sbt] object LibraryManagement {
|
|||
|
||||
/* Check if a update report is still up to date or we must resolve again. */
|
||||
def upToDate(inChanged: Boolean, out: UpdateReport): Boolean = {
|
||||
// Transitive dependency stamps are now part of UpdateInputs, so inChanged
|
||||
// will be true if any transitive stamp changed (cross-command invalidation).
|
||||
!force &&
|
||||
!depsUpdated &&
|
||||
!inChanged &&
|
||||
out.allFiles.forall(f => fileUptodate(f.toString, out.stamps, log)) &&
|
||||
fileUptodate(out.cachedDescriptor.toString, out.stamps, log)
|
||||
|
|
@ -186,7 +188,9 @@ private[sbt] object LibraryManagement {
|
|||
val handler = if (skip && !force) skipResolve(outStore)(_) else doResolve(outStore)
|
||||
// Remove clock for caching purpose
|
||||
val withoutClock = updateConfig.withLogicalClock(LogicalClock.unknown)
|
||||
handler((extraInputHash, settings, withoutClock))
|
||||
// Collect transitive stamps for cross-command cache invalidation
|
||||
val transitiveStamps = transitiveUpdates.flatMap(_.stats.stamp).toVector
|
||||
handler((extraInputHash, settings, withoutClock, transitiveStamps))
|
||||
}
|
||||
|
||||
private def fileUptodate(file0: String, stamps: Map[String, Long], log: Logger): Boolean = {
|
||||
|
|
@ -382,7 +386,7 @@ private[sbt] object LibraryManagement {
|
|||
identity,
|
||||
skip = sk,
|
||||
force = shouldForce,
|
||||
depsUpdated = tu.exists(!_.stats.cached),
|
||||
transitiveUpdates = tu,
|
||||
uwConfig = uwConfig,
|
||||
evictionLevel = Level.Debug,
|
||||
versionSchemeOverrides = Nil,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
package a
|
||||
object A
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// Test for https://github.com/sbt/sbt/issues/8357
|
||||
// Verifies that transitiveUpdate correctly invalidates across command invocations
|
||||
// when a dependency's dependencies change.
|
||||
|
||||
ThisBuild / scalaVersion := "2.12.21"
|
||||
|
||||
// Use a setting to control library version - this can be changed via reload
|
||||
lazy val catsVersion = settingKey[String]("Cats version")
|
||||
|
||||
// Track the stamp from our own update to verify invalidation
|
||||
lazy val ourStamp = taskKey[String]("Our update's stamp")
|
||||
// Track the max stamp from transitive dependencies
|
||||
lazy val maxDepStamp = taskKey[String]("Max stamp from transitive deps")
|
||||
|
||||
lazy val a = project.in(file("a"))
|
||||
.settings(
|
||||
catsVersion := "2.8.0",
|
||||
libraryDependencies += "org.typelevel" %% "cats-core" % catsVersion.value,
|
||||
)
|
||||
|
||||
lazy val itTests = project.in(file("itTests"))
|
||||
.dependsOn(a % "test->test")
|
||||
.settings(
|
||||
// Get our update's stamp
|
||||
ourStamp := update.value.stats.stamp.getOrElse(""),
|
||||
|
||||
// Get the max stamp from transitive dependencies
|
||||
maxDepStamp := transitiveUpdate.value.flatMap(_.stats.stamp).maxOption.getOrElse(""),
|
||||
)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Test for https://github.com/sbt/sbt/issues/8357
|
||||
// This is the changed build.sbt with different library version
|
||||
|
||||
ThisBuild / scalaVersion := "2.12.21"
|
||||
|
||||
lazy val catsVersion = settingKey[String]("Cats version")
|
||||
lazy val ourStamp = taskKey[String]("Our update's stamp")
|
||||
lazy val maxDepStamp = taskKey[String]("Max stamp from transitive deps")
|
||||
|
||||
lazy val a = project.in(file("a"))
|
||||
.settings(
|
||||
// Changed from 2.8.0 to 2.9.0
|
||||
catsVersion := "2.9.0",
|
||||
libraryDependencies += "org.typelevel" %% "cats-core" % catsVersion.value,
|
||||
)
|
||||
|
||||
lazy val itTests = project.in(file("itTests"))
|
||||
.dependsOn(a % "test->test")
|
||||
.settings(
|
||||
ourStamp := update.value.stats.stamp.getOrElse(""),
|
||||
maxDepStamp := transitiveUpdate.value.flatMap(_.stats.stamp).maxOption.getOrElse(""),
|
||||
)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
package itTests
|
||||
class Test
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# Test for https://github.com/sbt/sbt/issues/8357
|
||||
# Verifies that transitiveUpdate correctly invalidates across command invocations
|
||||
|
||||
# Step 1: Run itTests/update to establish baseline with cats 2.8.0
|
||||
> itTests/update
|
||||
|
||||
# Step 2: Change a's dependency version (cats 2.8.0 -> 2.9.0)
|
||||
$ copy-file changes/build.sbt build.sbt
|
||||
|
||||
# Step 3: Reload to pick up the new settings
|
||||
> reload
|
||||
|
||||
# Step 4: Run a/update in a SEPARATE command
|
||||
> a/update
|
||||
|
||||
# Step 5: Now run itTests/update - should re-resolve with new dependencies
|
||||
> show itTests/ourStamp
|
||||
> show itTests/maxDepStamp
|
||||
|
||||
# Compile to verify all dependencies are available
|
||||
> itTests/compile
|
||||
Loading…
Reference in New Issue