mirror of https://github.com/sbt/sbt.git
[2.x] Make `clearCaches` clear the sbt 2 action cache as well
`clearCaches` only reset the in-memory session caches (compiler cache, classloader cache, and file-cache stores) carried over from sbt 1, so none of the action caches introduced in sbt 2 were actually cleared despite the help text saying otherwise. The only way to remove the action cache was `cleanFull`, which also wipes `target/out` entirely, including build outputs and the command history. `clearCaches` now additionally deletes, while respecting `cleanKeepFiles` and `cleanKeepGlobs`: - the contents of every local `DiskActionCacheStore` (the `cas/` and `ac/` directories under `localCacheDirectory`) - the materialized task-value files under `target/out/value` - symbolic links under `target/out` that point into a cleared store, which would otherwise be left dangling Build outputs, the command history, and the boot directory are preserved. Ordering and robustness details: - The in-memory caches are reset before the on-disk stores are deleted, so files those caches may still hold open (e.g. jars referenced by cached classloaders) are released before their deletion is attempted. On Windows an open handle otherwise makes the underlying file undeletable. - `target/out` references (symlinks) are deleted before the cas blobs they point at. - Deletions retry on transient `IOException` (as `DiskActionCacheStore#syncFile` already does for writes) and, instead of being silently swallowed, any file that still cannot be deleted is reported: per-path at warn level, with the summary downgraded from "cleared" to "partially cleared". The `clearCaches` help text is updated to describe the new behavior, and a scripted test (`cache/clear-caches`) covers it. The test records a baseline of existing store entries and asserts only that the entries it creates are cleared, so unrelated blobs left behind by other tests sharing the batch sandbox are out of scope.
This commit is contained in:
parent
b3b1a3477e
commit
0085a98614
|
|
@ -254,7 +254,13 @@ $AliasCommand name=
|
|||
def continuousBriefHelp: (String, String) =
|
||||
(ContinuousExecutePrefix + " <command>", continuousDetail)
|
||||
def ClearCaches: String = "clearCaches"
|
||||
def ClearCachesDetailed: String = "Clears sbt's internal caches."
|
||||
def ClearCachesDetailed: String =
|
||||
"Clears sbt's internal in-memory caches (compiler cache, classloader cache, and file-cache stores), " +
|
||||
"deletes the contents of the local disk action cache (its cas/ and ac/ directories), " +
|
||||
"and removes action-cache references under the root output directory: " +
|
||||
"the materialized task-value files in target/out/value and any symbolic links pointing into the deleted cache. " +
|
||||
"Paths matching cleanKeepFiles or cleanKeepGlobs are preserved. " +
|
||||
"Unlike cleanFull, build outputs, the command history (target/out/.history), and the sbt boot directory are not deleted."
|
||||
|
||||
val CleanFull: String = "cleanFull"
|
||||
def cleanFullDetailed: String = "Clears sbt's local caches."
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import sbt.nio.file.*
|
|||
import sbt.nio.file.syntax.pathToPathOps
|
||||
import sbt.nio.file.Glob.GlobOps
|
||||
import sbt.util.{ DiskActionCacheStore, Level }
|
||||
import sbt.internal.io.Retry
|
||||
import sbt.internal.util.complete.SizeParser
|
||||
import sjsonnew.JsonFormat
|
||||
import xsbti.{ PathBasedFile, VirtualFileRef }
|
||||
|
|
@ -205,10 +206,101 @@ private[sbt] object Clean {
|
|||
s.get(Keys.cacheStoreFactoryFactory).foreach(_.close())
|
||||
s.put(Keys.cacheStoreFactoryFactory, InMemoryCacheStore.factory(size))
|
||||
|
||||
private def actionCacheKeepFilter(s: State): Path => Boolean =
|
||||
val extracted = Project.extract(s)
|
||||
val refs = extracted.structure.allProjectRefs
|
||||
val keepFiles =
|
||||
refs.flatMap(ref => extracted.getOpt(ref / cleanKeepFiles).getOrElse(Nil)).distinct
|
||||
val keepGlobs =
|
||||
refs.flatMap(ref => extracted.getOpt(ref / cleanKeepGlobs).getOrElse(Nil)).distinct
|
||||
val globs = keepFiles.map {
|
||||
case f if f.isDirectory => Glob(f, AnyPath)
|
||||
case f => f.toPath.toGlob
|
||||
} ++ keepGlobs
|
||||
(p: Path) => globs.exists(_.matches(p))
|
||||
|
||||
private def deleteStoreReferences(
|
||||
dir: Path,
|
||||
bases: Seq[Path],
|
||||
exclude: Path => Boolean,
|
||||
delete: Path => Unit
|
||||
): Unit = {
|
||||
def loop(d: Path): Unit =
|
||||
FileTreeView.default
|
||||
.list(Glob(d, AnyPath))
|
||||
.foreach {
|
||||
// FileTreeView reports all-false attributes for dangling symlinks, so symlink
|
||||
// detection must not rely on attrs.isSymbolicLink.
|
||||
case (link, _) if Files.isSymbolicLink(link) =>
|
||||
try {
|
||||
val raw = Files.readSymbolicLink(link)
|
||||
val resolved =
|
||||
(if (raw.isAbsolute) raw
|
||||
else Option(link.getParent).fold(raw)(_.resolve(raw))).normalize()
|
||||
if (bases.exists(resolved.startsWith(_)) && !exclude(link)) delete(link)
|
||||
} catch { case _: IOException => () }
|
||||
case (subdir, attrs) if attrs.isDirectory => loop(subdir)
|
||||
case _ => ()
|
||||
}
|
||||
loop(dir)
|
||||
}
|
||||
|
||||
private val clearActionCacheStores: State => State = (s: State) => {
|
||||
val stores = s.get(BasicKeys.cacheStores).getOrElse(Nil).collect {
|
||||
case d: DiskActionCacheStore => d
|
||||
}
|
||||
val bases = stores.map(_.base.toAbsolutePath.normalize).distinct
|
||||
val exclude = actionCacheKeepFilter(s)
|
||||
val failed = scala.collection.mutable.ListBuffer.empty[(Path, IOException)]
|
||||
/* Deletion failures are collected and reported rather than swallowed. On
|
||||
* Windows a delete can transiently fail with AccessDeniedException while a
|
||||
* handle is briefly held, so retry (as DiskActionCacheStore#syncFile does for
|
||||
* writes). Directories left non-empty by cleanKeepFiles / cleanKeepGlobs are
|
||||
* expected to survive.
|
||||
*/
|
||||
val delete: Path => Unit = path => {
|
||||
try {
|
||||
s.log.debug(s"clearCaches -- deleting $path")
|
||||
Retry.io(Files.deleteIfExists(path), classOf[DirectoryNotEmptyException])
|
||||
()
|
||||
} catch {
|
||||
case _: DirectoryNotEmptyException => ()
|
||||
case e: IOException => failed += (path -> e)
|
||||
}
|
||||
}
|
||||
/* Delete the target/out references (symlinks) before the cas blobs they point at. */
|
||||
s.get(BasicKeys.rootOutputDirectory).foreach { rootOut =>
|
||||
val valueDir = rootOut.resolve("value")
|
||||
if (Files.exists(valueDir)) {
|
||||
deleteContents(valueDir, exclude, FileTreeView.default, delete)
|
||||
delete(valueDir)
|
||||
}
|
||||
if (Files.exists(rootOut) && bases.nonEmpty)
|
||||
deleteStoreReferences(rootOut, bases, exclude, delete)
|
||||
}
|
||||
bases.filter(Files.exists(_)).foreach { base =>
|
||||
deleteContents(base, exclude, FileTreeView.default, delete)
|
||||
}
|
||||
failed.foreach { case (p, e) => s.log.warn(s"clearCaches failed to delete $p: $e") }
|
||||
if (bases.nonEmpty)
|
||||
if (failed.isEmpty)
|
||||
s.log.info(s"cleared action cache store(s): ${bases.mkString(", ")}")
|
||||
else
|
||||
s.log.warn(
|
||||
s"partially cleared action cache store(s): ${bases.mkString(", ")} " +
|
||||
s"(${failed.size} path(s) could not be deleted)"
|
||||
)
|
||||
s
|
||||
}
|
||||
|
||||
/* Reset the in-memory caches before deleting the on-disk stores so that
|
||||
* files the old caches may still reference (e.g. jars held open by cached
|
||||
* classloaders) are released before their deletion is attempted. */
|
||||
private val clearCachesFun: State => State =
|
||||
registerCompilerCache
|
||||
.andThen(_.initializeClassLoaderCache)
|
||||
.andThen(addCacheStoreFactoryFactory)
|
||||
.andThen(clearActionCacheStores)
|
||||
|
||||
def clearCaches: Command =
|
||||
val help = Help.more(ClearCaches, ClearCachesDetailed)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import sbt.internal.util.StringVirtualFile1
|
||||
import sjsonnew.BasicJsonProtocol.*
|
||||
import sbt.nio.file.{ Glob, RecursiveGlob }
|
||||
|
||||
val pure1 = taskKey[Unit]("")
|
||||
val recordCacheBaseline = taskKey[Unit]("")
|
||||
val checkCacheNonEmpty = taskKey[Unit]("")
|
||||
val checkCacheCleared = taskKey[Unit]("")
|
||||
val checkNoStoreLinks = taskKey[Unit]("")
|
||||
|
||||
Global / localCacheDirectory := baseDirectory.value / "diskcache"
|
||||
Global / cleanKeepGlobs += Glob(baseDirectory.value / "diskcache" / "keep", RecursiveGlob)
|
||||
|
||||
def storeEntries(base: File): Set[String] =
|
||||
for {
|
||||
dir <- Set("cas", "ac")
|
||||
file <- Option((base / dir).listFiles).fold(Set.empty[String])(_.map(_.getName).toSet)
|
||||
} yield s"$dir/$file"
|
||||
|
||||
def baselineFile(base: File): File = base / "cache-baseline.txt"
|
||||
|
||||
pure1 := {
|
||||
val output = StringVirtualFile1("${OUT}/a.txt", "foo")
|
||||
Def.declareOutput(output)
|
||||
()
|
||||
}
|
||||
|
||||
/* In batch scripted mode all tests in a group share one sandbox and one
|
||||
* server JVM, and store entries left behind by a previous test may be
|
||||
* pinned by pre-existing classloader/jar-handle leaks that make them
|
||||
* undeletable on Windows (tracked separately; see
|
||||
* .ai/2026-07/2026-07-20-0320-test-classloader-jar-handle-leaks-report.md).
|
||||
* This test therefore only verifies the action-cache entries it creates
|
||||
* itself: everything present at baseline time is out of scope. */
|
||||
recordCacheBaseline := Def.uncached {
|
||||
val entries = storeEntries(baseDirectory.value / "diskcache")
|
||||
IO.writeLines(baselineFile(baseDirectory.value), entries.toList.sorted)
|
||||
}
|
||||
|
||||
checkCacheNonEmpty := Def.uncached {
|
||||
val baseline = IO.readLines(baselineFile(baseDirectory.value)).toSet
|
||||
val added = storeEntries(baseDirectory.value / "diskcache") -- baseline
|
||||
assert(added.nonEmpty, "no new action cache entries were created since the baseline")
|
||||
}
|
||||
|
||||
checkCacheCleared := Def.uncached {
|
||||
def leftover: Set[String] = {
|
||||
val baseline = IO.readLines(baselineFile(baseDirectory.value)).toSet
|
||||
storeEntries(baseDirectory.value / "diskcache") -- baseline
|
||||
}
|
||||
/* A deleted entry can linger in a directory listing for a moment on
|
||||
* Windows (delete-pending state), so poll briefly before asserting. */
|
||||
def eventuallyCleared: Boolean =
|
||||
leftover.isEmpty || (1 to 10).exists { _ =>
|
||||
Thread.sleep(100)
|
||||
leftover.isEmpty
|
||||
}
|
||||
assert(eventuallyCleared, s"entries created by this test survived clearCaches: ${leftover.mkString(", ")}")
|
||||
}
|
||||
|
||||
checkNoStoreLinks := Def.uncached {
|
||||
import java.nio.file.Files
|
||||
val out = (baseDirectory.value / "target" / "out").toPath
|
||||
val diskcache = (baseDirectory.value / "diskcache").toPath.toAbsolutePath.normalize
|
||||
if (Files.exists(out)) {
|
||||
val stream = Files.walk(out)
|
||||
try {
|
||||
val it = stream.iterator()
|
||||
while (it.hasNext) {
|
||||
val p = it.next()
|
||||
if (Files.isSymbolicLink(p)) {
|
||||
val raw = Files.readSymbolicLink(p)
|
||||
val resolved =
|
||||
(if (raw.isAbsolute) raw else Option(p.getParent).fold(raw)(_.resolve(raw)))
|
||||
.normalize()
|
||||
assert(!resolved.startsWith(diskcache), s"$p still points into $diskcache")
|
||||
}
|
||||
}
|
||||
} finally stream.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Populate the action cache, then verify clearCaches deletes the store entries
|
||||
# created by this test and the references under target/out, while preserving
|
||||
# everything else. Entries already present at baseline time (leftovers from a
|
||||
# previous test in the same batch sandbox) are out of scope for this test.
|
||||
> startServer
|
||||
> recordCacheBaseline
|
||||
> pure1
|
||||
$ exists target/out/a.txt
|
||||
> checkCacheNonEmpty
|
||||
$ mkdir diskcache/keep
|
||||
$ touch diskcache/keep/marker.txt
|
||||
$ touch target/out/regular.txt
|
||||
> clearCaches
|
||||
> checkCacheCleared
|
||||
> checkNoStoreLinks
|
||||
$ absent target/out/value
|
||||
$ absent target/out/a.txt
|
||||
$ exists target/out/regular.txt
|
||||
$ exists diskcache/keep/marker.txt
|
||||
> pure1
|
||||
$ exists target/out/a.txt
|
||||
> checkCacheNonEmpty
|
||||
Loading…
Reference in New Issue