[2.x] feat: Add cacheVersion setting for global cache invalidation (#8993)

* [2.x] feat: Add cacheVersion setting for global cache invalidation

**Problem**
There was no escape hatch to invalidate all task caches when needed.

**Solution**
Add `Global / cacheVersion` setting that incorporates into the cache key
hash. Changing it invalidates all caches. Defaults to reading system
property `sbt.cacheversion`, or else 0L. When 0L, the hash is identical
to the previous behavior (backward compatible).

Fixes #8992

* [2.x] refactor: Simplify BuildWideCacheConfiguration and add cacheVersion test

- Replace auxiliary constructors with default parameter values
- Add unit test verifying cacheVersion invalidates the cache

* [2.x] fix: Restore auxiliary constructors for binary compatibility

* [2.x] test: Improve cacheVersion scripted test and add release note

- Scripted test now verifies cache invalidation via a counter
  that increments only when the task body actually executes
- Add release note documenting the cacheVersion setting
This commit is contained in:
Dream
2026-04-01 11:23:10 +09:00
committed by GitHub
parent 6d44aca9b1
commit 544f56695a
9 changed files with 152 additions and 12 deletions
@@ -41,9 +41,10 @@ object ActionCache:
* all we need from the input is to generate some hash value.
* - codeContentHash: This hash represents the Scala code of the task.
* Even if the input tasks are the same, the code part needs to be tracked.
* - extraHash: Reserved for later, which we might use to invalidate the cache.
* - extraHash: Extra hash for cache invalidation (combined with config.cacheVersion).
* - tags: Tags to track cache level.
* - config: The configuration that's used to store where the cache backends are.
* config.cacheVersion is incorporated into the cache key to allow global invalidation.
* - action: The actual action to be cached.
*/
def cache[I: HashWriter, O: JsonFormat](
@@ -62,7 +63,7 @@ object ActionCache:
// This fixes https://github.com/sbt/sbt/issues/7662
// Use the same input digest as success, distinguished by exitCode
cacheEventLog.append(ActionCacheEvent.OnsiteTask)
val (input, valuePath) = mkInput(key, codeContentHash, extraHash)
val (input, valuePath) = mkInput(key, codeContentHash, extraHash, config.cacheVersion)
val cachedFailure = CachedCompileFailure.fromException(e)
val json = Converter.toJsonUnsafe(cachedFailure)
val failureFile = StringVirtualFile1(valuePath, CompactPrinter(json))
@@ -101,7 +102,7 @@ object ActionCache:
result
else
cacheEventLog.append(ActionCacheEvent.OnsiteTask)
val (input, valuePath) = mkInput(key, codeContentHash, extraHash)
val (input, valuePath) = mkInput(key, codeContentHash, extraHash, config.cacheVersion)
val valueFile = StringVirtualFile1(valuePath, CompactPrinter(json))
val newOutputs = Vector(valueFile) ++ outputs.toVector
try
@@ -160,7 +161,7 @@ object ActionCache:
catch case _: Exception => None
// Optimization: Check if we can read directly from symlinked value file
val (input, valuePath) = mkInput(key, codeContentHash, extraHash)
val (input, valuePath) = mkInput(key, codeContentHash, extraHash, config.cacheVersion)
val resolvedValuePath = config.fileConverter.toPath(VirtualFileRef.of(valuePath))
def readFromSymlink(): Option[Either[Option[CachedCompileFailure], O]] =
@@ -240,7 +241,7 @@ object ActionCache:
): Either[Throwable, ActionResult] =
// val logger = config.logger
CacheImplicits.setCacheSize(config.localDigestCacheByteSize)
val (input, valuePath) = mkInput(key, codeContentHash, extraHash)
val (input, valuePath) = mkInput(key, codeContentHash, extraHash, config.cacheVersion)
val getRequest =
GetActionResultRequest(input, inlineStdout = false, inlineStderr = false, Vector(valuePath))
config.store.get(getRequest)
@@ -248,10 +249,18 @@ object ActionCache:
private inline def mkInput[I: HashWriter](
key: I,
codeContentHash: Digest,
extraHash: Digest
extraHash: Digest,
cacheVersion: Long,
): (Digest, String) =
val effectiveExtraHash =
if cacheVersion != 0L then Digest.sha256Hash(extraHash, Digest.dummy(cacheVersion))
else extraHash
val input =
Digest.sha256Hash(codeContentHash, extraHash, Digest.dummy(Hasher.hashUnsafe[I](key)))
Digest.sha256Hash(
codeContentHash,
effectiveExtraHash,
Digest.dummy(Hasher.hashUnsafe[I](key))
)
(input, s"$${OUT}/value/$input.json")
def manifestFromFile(manifest: Path): Manifest =
@@ -332,13 +341,14 @@ class BuildWideCacheConfiguration(
val logger: Logger,
val cacheEventLog: CacheEventLog,
val localDigestCacheByteSize: Long,
val cacheVersion: Long,
):
def this(
store: ActionCacheStore,
outputDirectory: Path,
fileConverter: FileConverter,
logger: Logger,
cacheEventLog: CacheEventLog
cacheEventLog: CacheEventLog,
) =
this(
store,
@@ -346,9 +356,20 @@ class BuildWideCacheConfiguration(
fileConverter,
logger,
cacheEventLog,
CacheImplicits.defaultLocalDigestCacheByteSize
CacheImplicits.defaultLocalDigestCacheByteSize,
0L
)
def this(
store: ActionCacheStore,
outputDirectory: Path,
fileConverter: FileConverter,
logger: Logger,
cacheEventLog: CacheEventLog,
localDigestCacheByteSize: Long,
) =
this(store, outputDirectory, fileConverter, logger, cacheEventLog, localDigestCacheByteSize, 0L)
override def toString(): String =
s"BuildWideCacheConfiguration(store = $store, outputDirectory = $outputDirectory)"
end BuildWideCacheConfiguration
@@ -259,6 +259,36 @@ object ActionCacheTest extends BasicTestSuite:
assert(v2 == 42)
assert(called == 2)
test("Changing cacheVersion invalidates the cache"):
withDiskCache(testCacheVersionInvalidation)
def testCacheVersionInvalidation(cache: ActionCacheStore): Unit =
import sjsonnew.BasicJsonProtocol.*
var called = 0
val action: ((Int, Int)) => InternalActionResult[Int] = { (a, b) =>
called += 1
InternalActionResult(a + b, Nil)
}
IO.withTemporaryDirectory: tempDir =>
val config0 = getCacheConfig(cache, tempDir)
// First call: computes the result
val v1 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config0)(action)
assert(v1 == 2)
assert(called == 1)
// Second call with same config: hits cache
val v2 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config0)(action)
assert(v2 == 2)
assert(called == 1)
// Third call with different cacheVersion: cache miss, recomputes
val config1 = getCacheConfig(cache, tempDir, cacheVersion = 1L)
val v3 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config1)(action)
assert(v3 == 2)
assert(called == 2)
// Fourth call with same cacheVersion=1: hits cache again
val v4 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config1)(action)
assert(v4 == 2)
assert(called == 2)
def withInMemoryCache(f: InMemoryActionCacheStore => Unit): Unit =
val cache = InMemoryActionCacheStore()
f(cache)
@@ -273,12 +303,24 @@ object ActionCacheTest extends BasicTestSuite:
keepDirectory = false
)
def getCacheConfig(cache: ActionCacheStore, outputDir: File): BuildWideCacheConfiguration =
def getCacheConfig(
cache: ActionCacheStore,
outputDir: File,
cacheVersion: Long = 0L,
): BuildWideCacheConfiguration =
val logger = new Logger:
override def trace(t: => Throwable): Unit = ()
override def success(message: => String): Unit = ()
override def log(level: Level.Value, message: => String): Unit = ()
BuildWideCacheConfiguration(cache, outputDir.toPath(), fileConverter, logger, CacheEventLog())
BuildWideCacheConfiguration(
cache,
outputDir.toPath(),
fileConverter,
logger,
CacheEventLog(),
CacheImplicits.defaultLocalDigestCacheByteSize,
cacheVersion,
)
def fileConverter = new FileConverter:
override def toPath(ref: VirtualFileRef): Path = Paths.get(ref.id)