[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:
calm 2026-01-12 13:17:51 -08:00 committed by GitHub
parent fe6125d8d1
commit b4f73c9a7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 113 additions and 17 deletions

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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(\", \")"
},

View File

@ -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
)
}

View File

@ -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),

View File

@ -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("::: -----------")

View File

@ -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,

View File

@ -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,

View File

@ -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(""),
)

View File

@ -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(""),
)

View File

@ -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