Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions metals/src/main/scala/scala/meta/internal/metals/Configs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1036,4 +1036,25 @@ object Configs {
}
}
}

/**
* Configuration for turbine cache. When enabled, turbine compilation results
* are persisted to disk and restored on startup to avoid recompiling unchanged
* sources.
*
* @param enabled Whether caching is enabled
*/
final case class TurbineCacheConfig(enabled: Boolean)

object TurbineCacheConfig {
val default: TurbineCacheConfig = TurbineCacheConfig(enabled = false)
val enabled: TurbineCacheConfig = TurbineCacheConfig(enabled = true)

def fromConfig(value: Option[Boolean]): TurbineCacheConfig = {
value match {
case Some(enabled) => TurbineCacheConfig(enabled)
case None => default
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ object Directories {
RelativePath(".metals").resolve("rules")
def explainedDiagnostics: RelativePath =
RelativePath(".metals").resolve("explained-diagnostics")
def turbineCache: RelativePath =
RelativePath(".metals").resolve("turbine-cache.jar")

val stacktraceFilename = "stacktrace.scala"
val dependenciesName = "dependencies"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ abstract class MetalsLspService(
fallbackClasspaths = () => compilers.fallbackClasspaths,
sleeper = sleeper,
turbineRecompileDelay = () => userConfig.javaTurbineRecompileDelay,
turbineCacheConfig = () => userConfig.javaTurbineCache,
indexFilters = MbtIndexFilter.allFilters,
protobufLspConfig = () => userConfig.protobufLspConfig,
metalsOutDir = Some(embedded.targetDir),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import scala.meta.internal.metals.Configs.ProtobufLspConfig
import scala.meta.internal.metals.Configs.RangeFormattingProviders
import scala.meta.internal.metals.Configs.ReferenceProviderConfig
import scala.meta.internal.metals.Configs.ScalaImportsPlacementConfig
import scala.meta.internal.metals.Configs.TurbineCacheConfig
import scala.meta.internal.metals.Configs.TurbineRecompileDelayConfig
import scala.meta.internal.metals.Configs.WorkspaceSymbolProviderConfig
import scala.meta.internal.metals.JsonParser.XtensionSerializedAsOption
Expand Down Expand Up @@ -106,6 +107,7 @@ case class UserConfiguration(
javaSymbolLoader: JavaSymbolLoaderConfig = JavaSymbolLoaderConfig.default,
javaTurbineRecompileDelay: TurbineRecompileDelayConfig =
TurbineRecompileDelayConfig.default,
javaTurbineCache: TurbineCacheConfig = TurbineCacheConfig.default,
Comment thread
tgodzik marked this conversation as resolved.
javacServicesOverrides: JavacServicesOverrides =
JavacServicesOverrides.default,
compilerProgress: CompilerProgressConfig = CompilerProgressConfig.default,
Expand Down Expand Up @@ -1393,6 +1395,9 @@ object UserConfiguration {
val javaTurbineRecompileDelay = TurbineRecompileDelayConfig.fromConfig(
getStringKey("java-turbine-recompile-delay")
)
val javaTurbineCache = TurbineCacheConfig.fromConfig(
getBooleanKey("java-turbine-cache")
)
val javacServicesOverrides =
getKey(
"javac-services-overrides",
Expand Down Expand Up @@ -1525,6 +1530,7 @@ object UserConfiguration {
protoOutlineProvider,
javaSymbolLoader,
javaTurbineRecompileDelay,
javaTurbineCache,
javacServicesOverrides,
compilerProgress,
referenceProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import scala.meta.internal.metals.BaseWorkDoneProgress
import scala.meta.internal.metals.Buffers
import scala.meta.internal.metals.Configs.JavaSymbolLoaderConfig
import scala.meta.internal.metals.Configs.ProtobufLspConfig
import scala.meta.internal.metals.Configs.TurbineCacheConfig
import scala.meta.internal.metals.Configs.TurbineRecompileDelayConfig
import scala.meta.internal.metals.Configs.WorkspaceSymbolProviderConfig
import scala.meta.internal.metals.Directories
Expand Down Expand Up @@ -106,6 +107,8 @@ class MbtWorkspaceSymbolProvider(
sleeper: Sleeper = Sleeper.TestingSleeper,
turbineRecompileDelay: () => TurbineRecompileDelayConfig = () =>
TurbineRecompileDelayConfig.fromConfig(None),
turbineCacheConfig: () => TurbineCacheConfig = () =>
TurbineCacheConfig.default,
indexFilters: List[MbtIndexFilter] = MbtIndexFilter.allFilters,
protobufLspConfig: () => ProtobufLspConfig = () =>
ProtobufLspConfig.default,
Expand Down Expand Up @@ -140,6 +143,12 @@ class MbtWorkspaceSymbolProvider(
def protoJavaOutlines(file: AbsolutePath): Seq[VirtualTextDocument] =
documents.get(file).toSeq.flatMap(protobufWorkspace.allJavaOutlines)

private val turbineCache = new TurbineCache(
workspace.resolve(Directories.turbineCache).toNIO,
turbineCacheConfig,
turbineRecompileDelay,
time,
)
private val turbineCompiler: TurbineCompiler[AbsolutePath] =
new TurbineCompiler[AbsolutePath](
() => documentsKeys,
Expand Down Expand Up @@ -180,6 +189,7 @@ class MbtWorkspaceSymbolProvider(
onIndexingDone = onIndexingDone,
onNewProjectClasspath = classpath =>
protobufWorkspace.onNewProjectClasspath(classpath),
turbineCache = Some(turbineCache),
)

// NOTE: runs unconditionally even if the user config is not mbt-v2 for usage
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package scala.meta.internal.metals.mbt

import java.io.BufferedOutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.time.LocalDateTime
import java.util.jar.JarEntry
import java.util.jar.JarOutputStream
import java.util.zip.ZipEntry

import scala.util.Using
import scala.util.control.NonFatal

import scala.meta.internal.jdk.CollectionConverters._
import scala.meta.internal.metals.Configs.TurbineCacheConfig
import scala.meta.internal.metals.Configs.TurbineRecompileDelayConfig
import scala.meta.internal.metals.Time
import scala.meta.internal.metals.Timer

import com.google.common.collect.ImmutableMap
import com.google.common.collect.ImmutableSet
import com.google.common.hash.Hashing
import com.google.turbine.binder.ClassPathBinder
import com.google.turbine.binder.sym.ClassSymbol
import com.google.turbine.lower.Lower
import com.google.turbine.zip.Zip

/**
* Handles caching of Turbine compilation results to disk.
*
* The cache is stored as a JAR file containing the compiled class files.
* Each class file is stored under its binary name with a .class extension.
*
* @param cachePath Path to the cache JAR file
* @param cacheConfig Configuration for caching behavior
* @param recompileDelayConfig Configuration for recompile delay (to check if turbine is disabled)
*/
class TurbineCache(
cachePath: Path,
cacheConfig: () => TurbineCacheConfig,
recompileDelayConfig: () => TurbineRecompileDelayConfig,
time: Time,
) {

// we need to always compile on start
private def isCacheEnabled: Boolean = {
val config = cacheConfig()
val recompileConfig = recompileDelayConfig()
config.enabled && !recompileConfig.isEffectivelyDisabled
}

/**
* Writes the Turbine compilation result to the cache file.
*
* @param result The compilation result to cache
*/
def writeCache(result: TurbineCompileResult): Unit = {
if (!isCacheEnabled) return

val timer = new Timer(time)
try {
val bytes = result.lowered.bytes()
if (bytes.isEmpty()) {
scribe.debug("turbine-cache: skipping write, no classes to cache")
return
}

Files.createDirectories(cachePath.getParent())

Using.resource(
new JarOutputStream(
new BufferedOutputStream(
Files.newOutputStream(
cachePath,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
)
)
)
) { jos =>
bytes.forEach { (binaryName, classBytes) =>
addEntry(jos, binaryName + ".class", classBytes)
}
}

scribe.info(
s"turbine-cache: wrote ${result.lowered.symbols().size()} classes in ${timer.elapsedMillis}ms"
)
} catch {
case NonFatal(e) =>
scribe.warn(s"turbine-cache: failed to write cache: ${e.getMessage}")
}
}

/**
* Reads the cached Turbine compilation result from disk.
*
* @return The cached result, or None if cache doesn't exist or is invalid
*/
def readCache(classpath: Seq[Path]): Option[TurbineCompileResult] = {
if (!isCacheEnabled) {
scribe.debug("turbine-cache: caching is disabled")
None
} else if (!Files.exists(cachePath)) {
scribe.debug("turbine-cache: no cache file found")
None
} else {
val timer = new Timer(time)
try {
val bytesBuilder = ImmutableMap.builder[String, Array[Byte]]()
val symbolsBuilder = ImmutableSet.builder[ClassSymbol]()

Using.resource(new Zip.ZipIterable(cachePath)) { zipIterable =>
zipIterable.forEach { entry =>
val name = entry.name()
if (name.endsWith(".class")) {
val binaryName = name.stripSuffix(".class")
val sym = new ClassSymbol(binaryName)
symbolsBuilder.add(sym)
bytesBuilder.put(binaryName, entry.data())
}
}
}

val lowered = Lower.Lowered.create(
bytesBuilder.build(),
symbolsBuilder.build(),
)
// Bind the project classpath (libraries) so dependency symbols remain
// discoverable when serving classes from the cached lowered output.
val classPath = ClassPathBinder.bindClasspath(classpath.asJava)
val result = TurbineCompileResult(classPath, lowered)

scribe.info(
s"turbine-cache: loaded ${lowered.symbols().size()} classes in ${timer.elapsedMillis}ms"
)
Some(result)
Comment thread
tgodzik marked this conversation as resolved.
Outdated
} catch {
case NonFatal(e) =>
scribe.warn(s"turbine-cache: failed to read cache: ${e.getMessage}")
deleteCache()
None
}
}
}

/**
* Deletes the cache file if it exists.
*/
def deleteCache(): Unit = {
try {
Files.deleteIfExists(cachePath)
scribe.debug("turbine-cache: deleted cache file")
} catch {
case NonFatal(e) =>
scribe.warn(s"turbine-cache: failed to delete cache: ${e.getMessage}")
}
}

private val DEFAULT_TIMESTAMP: LocalDateTime =
LocalDateTime.of(2010, 1, 1, 0, 0, 0)

private def addEntry(
jos: JarOutputStream,
name: String,
bytes: Array[Byte],
): Unit = {
val entry = new JarEntry(name)
entry.setTimeLocal(DEFAULT_TIMESTAMP)
entry.setMethod(ZipEntry.STORED)
entry.setSize(bytes.length)
entry.setCrc(Hashing.crc32().hashBytes(bytes).padToLong())
jos.putNextEntry(entry)
jos.write(bytes)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@ object TurbineCompiler {
)
TurbineCompileResult(boundClasspath, lowered)
}
private def validClasspaths(classpath: Seq[Path]): Seq[Path] = {
private[mbt] def validClasspaths(classpath: Seq[Path]): Seq[Path] = {
classpath.filter(isJarFile)
}
private def isJarFile(path: Path): Boolean = {
private[mbt] def isJarFile(path: Path): Boolean = {
Files.isRegularFile(path) &&
path.getFileName().toString().endsWith(".jar")
}
Expand All @@ -134,6 +134,7 @@ class TurbineCompiler[T](
sleeper: Sleeper,
onIndexingDone: () => Unit,
onNewProjectClasspath: ClassPath => Unit,
turbineCache: Option[TurbineCache] = None,
)(implicit ec: ExecutionContext, rc: ReportContext) {
private val sourcepathByPackageName =
TrieMap.empty[String, ju.concurrent.ConcurrentLinkedDeque[
Expand All @@ -157,6 +158,7 @@ class TurbineCompiler[T](
private def isRecompilationDisabled: Boolean =
debounceDelay.toMillis >= 3600000

private val isFirstCompile = new AtomicBoolean(true)
private val doCompile =
BatchedFunction.fromFuture[Unit, TurbineCompileResult](
_ => {
Expand Down Expand Up @@ -184,16 +186,56 @@ class TurbineCompiler[T](
}

var result = TurbineCompiler.emptyResult

/**
* Attempts to load compilation results from cache.
* Should be called during initialization before any compilation.
*
* @return true if cache was loaded successfully, false otherwise
*/
def loadFromCache(classpath: Seq[Path]): Option[TurbineCompileResult] = {
turbineCache match {
case Some(cache) =>
cache.readCache(classpath) match {
case Some(cachedResult) =>
scribe.info(
s"Loaded turbine cache with ${cachedResult.lowered.symbols().size()} symbols"
)
Some(cachedResult)
case None =>
None
}
case None =>
None
}
}

def doCompileNow(): TurbineCompileResult = {
result = TurbineCompiler.compileClassfiles(
allCompilationUnits(),
parseUnit,
classpath(),
progressBars,
)
cleanup()
// Clear deleted binary names after recompile - they are no longer in the compiled output
deletedBinaryNames.clear()

def compile() = {
result = TurbineCompiler.compileClassfiles(
allCompilationUnits(),
parseUnit,
classpath(),
progressBars,
)
cleanup()
// Clear deleted binary names after recompile - they are no longer in the compiled output
deletedBinaryNames.clear()
// Write to cache after successful compilation
turbineCache.foreach(_.writeCache(result))
}

if (isFirstCompile.getAndSet(false)) {
loadFromCache(TurbineCompiler.validClasspaths(classpath())) match {
case Some(cachedResult) =>
result = cachedResult
case None =>
compile()
}
} else {
compile()
}
onIndexingDone()
result
}
Expand Down
Loading