diff --git a/lm-core/src/main/contraband-scala/sbt/librarymanagement/UpdateStats.scala b/lm-core/src/main/contraband-scala/sbt/librarymanagement/UpdateStats.scala index caf69b88d..3c4917285 100644 --- a/lm-core/src/main/contraband-scala/sbt/librarymanagement/UpdateStats.scala +++ b/lm-core/src/main/contraband-scala/sbt/librarymanagement/UpdateStats.scala @@ -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) } diff --git a/lm-core/src/main/contraband-scala/sbt/librarymanagement/UpdateStatsFormats.scala b/lm-core/src/main/contraband-scala/sbt/librarymanagement/UpdateStatsFormats.scala index 0f508e2e7..f9a729421 100644 --- a/lm-core/src/main/contraband-scala/sbt/librarymanagement/UpdateStatsFormats.scala +++ b/lm-core/src/main/contraband-scala/sbt/librarymanagement/UpdateStatsFormats.scala @@ -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() } } diff --git a/lm-core/src/main/contraband/librarymanagement.json b/lm-core/src/main/contraband/librarymanagement.json index 5f9adaa02..87eca394c 100644 --- a/lm-core/src/main/contraband/librarymanagement.json +++ b/lm-core/src/main/contraband/librarymanagement.json @@ -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(\", \")" }, diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala b/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala index 649e24da0..e07416964 100644 --- a/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala +++ b/lm-coursier/src/main/scala/lmcoursier/internal/SbtUpdateReport.scala @@ -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 ) } diff --git a/lm-ivy/src/main/scala/sbt/internal/librarymanagement/IvyRetrieve.scala b/lm-ivy/src/main/scala/sbt/internal/librarymanagement/IvyRetrieve.scala index cf1dc0552..d939ddbad 100644 --- a/lm-ivy/src/main/scala/sbt/internal/librarymanagement/IvyRetrieve.scala +++ b/lm-ivy/src/main/scala/sbt/internal/librarymanagement/IvyRetrieve.scala @@ -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), diff --git a/lm-ivy/src/main/scala/sbt/internal/librarymanagement/ivyint/CachedResolutionResolveEngine.scala b/lm-ivy/src/main/scala/sbt/internal/librarymanagement/ivyint/CachedResolutionResolveEngine.scala index 6c537a26e..e30a547a3 100644 --- a/lm-ivy/src/main/scala/sbt/internal/librarymanagement/ivyint/CachedResolutionResolveEngine.scala +++ b/lm-ivy/src/main/scala/sbt/internal/librarymanagement/ivyint/CachedResolutionResolveEngine.scala @@ -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("::: -----------") diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 5f90ebbe2..b7b51cc0a 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -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, diff --git a/main/src/main/scala/sbt/internal/LibraryManagement.scala b/main/src/main/scala/sbt/internal/LibraryManagement.scala index f841c40bd..bd250f771 100644 --- a/main/src/main/scala/sbt/internal/LibraryManagement.scala +++ b/main/src/main/scala/sbt/internal/LibraryManagement.scala @@ -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, diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/a/src/main/scala/A.scala b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/a/src/main/scala/A.scala new file mode 100644 index 000000000..6c207ebdf --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/a/src/main/scala/A.scala @@ -0,0 +1,2 @@ +package a +object A diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/build.sbt b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/build.sbt new file mode 100644 index 000000000..31a7c86d9 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/build.sbt @@ -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(""), + ) diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/changes/build.sbt b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/changes/build.sbt new file mode 100644 index 000000000..a156b5313 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/changes/build.sbt @@ -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(""), + ) diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/itTests/src/test/scala/Test.scala b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/itTests/src/test/scala/Test.scala new file mode 100644 index 000000000..ff241cbaa --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/itTests/src/test/scala/Test.scala @@ -0,0 +1,2 @@ +package itTests +class Test diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/test b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/test new file mode 100644 index 000000000..aec2ea853 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/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