Merge pull request #8593 from calm329/fix/8357-transitive-update-invalidation-1.x

[1.x] fix: Invalidate update cache across commands when dependencies change
This commit is contained in:
eugene yokota 2026-01-20 18:00:48 -05:00 committed by GitHub
commit 08ab372fa6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 97 additions and 8 deletions

View File

@ -3827,7 +3827,7 @@ object Classpaths {
substituteScalaFiles(scalaOrganization.value, _)(providedScalaJars),
skip = (update / skip).value,
force = shouldForce,
depsUpdated = transitiveUpdate.value.exists(!_.stats.cached),
transitiveUpdates = transitiveUpdate.value,
uwConfig = (update / unresolvedWarningConfiguration).value,
evictionLevel = evictionErrorLevel.value,
versionSchemeOverrides = libraryDependencySchemes.value,

View File

@ -26,7 +26,8 @@ import scala.compat.Platform.EOL
private[sbt] object LibraryManagement {
implicit val 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[Long])
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],
@ -102,8 +103,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, out.stamps, log)) &&
fileUptodate(out.cachedDescriptor, out.stamps, log)
@ -166,7 +168,18 @@ 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.
// Hash the resolved module IDs (org, name, revision) from each transitive update.
// This changes when any transitive dependency's resolved versions change,
// enabling correct cache invalidation across commands.
val transitiveStamps = transitiveUpdates.map { ur =>
ur.configurations
.flatMap(_.modules.map(mr => (mr.module.organization, mr.module.name, mr.module.revision)))
.toSet
.hashCode
.toLong
}.toVector
handler((extraInputHash, settings, withoutClock, transitiveStamps))
}
private[this] def fileUptodate(file: File, stamps: Map[File, Long], log: Logger): Boolean = {
@ -299,7 +312,7 @@ private[sbt] object LibraryManagement {
identity,
skip = (update / skip).value,
force = shouldForce,
depsUpdated = transitiveUpdate.value.exists(!_.stats.cached),
transitiveUpdates = transitiveUpdate.value,
uwConfig = (update / unresolvedWarningConfiguration).value,
evictionLevel = Level.Debug,
versionSchemeOverrides = Nil,

View File

@ -46,16 +46,27 @@ lazy val root = (project in file("."))
.withMetadataDirectory(dependencyCacheDirectory.value)
import sbt.librarymanagement.{ ModuleSettings, UpdateConfiguration, LibraryManagementCodec }
type In = (Long, ModuleSettings, UpdateConfiguration)
// The fourth element is transitive dependency stamps for cross-command cache invalidation
type In = (Long, ModuleSettings, UpdateConfiguration, Vector[Long])
import LibraryManagementCodec._
// Compute transitive stamps the same way as LibraryManagement.cachedUpdate
val transitiveStamps0 = transitiveUpdate.value.map { ur =>
ur.configurations
.flatMap(_.modules.map(mr => (mr.module.organization, mr.module.name, mr.module.revision)))
.toSet
.hashCode
.toLong
}.toVector
val f: In => Unit =
Tracked.inputChanged(cacheStoreFactory make "inputs") { (inChanged: Boolean, in: In) =>
val extraInputHash1 = in._1
val moduleSettings1 = in._2
val inline1 = moduleSettings1 match { case x: InlineConfiguration => x }
val updateConfig1 = in._3
val transitiveStamps1 = in._4
if (inChanged) {
sys.error(s"""
@ -82,11 +93,19 @@ $updateConfig1
updateConfig0
$updateConfig0
-----
transitiveStamps1 == transitiveStamps0: ${transitiveStamps1 == transitiveStamps0}
transitiveStamps1:
$transitiveStamps1
transitiveStamps0
$transitiveStamps0
""")
}
}
f((extraInputHash0, (inline0: ModuleSettings), updateConfig0))
f((extraInputHash0, (inline0: ModuleSettings), updateConfig0, transitiveStamps0))
},
// https://github.com/sbt/sbt/issues/3226

View File

@ -0,0 +1,17 @@
// 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")
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")

View File

@ -0,0 +1,16 @@
// 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 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")

View File

@ -0,0 +1,20 @@
# 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
> itTests/update
# Compile to verify all dependencies are available
> itTests/compile