Skip to content

Commit

Permalink
share custom toolclasspath classloader across projects & configs
Browse files Browse the repository at this point in the history
Since d81dd72 (see commit description), when `scalafix` or `scalafixAll`
is run on a build root that aggregates many projects, many identical
classloaders may be created when an external rule is provided on the
CLI or when projects use local rules via ScalafixConfig.

This allows aggregated tasks across projects & configs to share a
single, warm classloader when possible (same Scalafix classpath).

Note that cache is bound on purpose to a task value, so two successive
invocations of `scalafix` with the exact same arguments & environment
will not share the same classloader. This is for simplicity, as local
rules can be updated from one run to the other, and to my knowledge,
there is no simple way to invalidate a URLClassLoader if the underlying
class files change.
  • Loading branch information
github-brice-jaglin committed Jul 7, 2020
1 parent e3f0660 commit 7f800d4
Show file tree
Hide file tree
Showing 2 changed files with 49 additions and 9 deletions.
21 changes: 21 additions & 0 deletions src/main/scala/scalafix/internal/sbt/BlockingCache.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package scalafix.internal.sbt

import scala.collection.mutable

/** A basic thread-safe cache without any eviction. */
class BlockingCache[K, V] {
private val underlying = new mutable.HashMap[K, V]

/**
* @param value By-name parameter evaluated when the key if missing. Value computation is guaranteed
* to be called only once per key across all invocations.
*/
def getOrElseUpdate(key: K, value: => V): V = {
// ConcurrentHashMap does not guarantee that there is only one evaluation of the value, so
// we use our own (global) locking, which is OK as the number of keys is expected to be
// very small (bound by the number of projects in the sbt build).
underlying.synchronized {
underlying.getOrElseUpdate(key, value)
}
}
}
37 changes: 28 additions & 9 deletions src/main/scala/scalafix/sbt/ScalafixPlugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ object ScalafixPlugin extends AutoPlugin {
Invisible
)

// Memoize ScalafixInterface instances initialized with a custom tool classpath across projects & configurations
// during task execution, to amortize the classloading cost when invoking scalafix concurrently on many targets
private val scalafixInterfaceCache
: TaskKey[BlockingCache[ToolClasspath, ScalafixInterface]] =
TaskKey(
"scalafixInterfaceCache",
"Implementation detail - do not use",
Invisible
)

override lazy val projectConfigurations: Seq[Configuration] =
Seq(ScalafixConfig)

Expand Down Expand Up @@ -173,7 +183,11 @@ object ScalafixPlugin extends AutoPlugin {
// depend on settings, while local rules classpath must be looked up via tasks
loadedRules = () => scalafixInterfaceProvider.value().availableRules(),
terminalWidth = Some(JLineAccess.terminalWidth)
).parser
).parser,
scalafixInterfaceCache := new BlockingCache[
ToolClasspath,
ScalafixInterface
]
)

override def buildSettings: Seq[Def.Setting[_]] =
Expand All @@ -186,6 +200,7 @@ object ScalafixPlugin extends AutoPlugin {
private def scalafixArgsFromShell(
shell: ShellArgs,
scalafixInterface: () => ScalafixInterface,
scalafixInterfaceCache: BlockingCache[ToolClasspath, ScalafixInterface],
projectDepsExternal: Seq[ModuleID],
baseDepsExternal: Seq[ModuleID],
baseResolvers: Seq[Repository],
Expand Down Expand Up @@ -214,15 +229,18 @@ object ScalafixPlugin extends AutoPlugin {
val customToolClasspath =
(projectDepsInternal0 ++ projectDepsExternal ++ rulesDepsExternal).nonEmpty
val interface =
if (customToolClasspath)
scalafixInterface().withArgs(
ToolClasspath(
projectDepsInternal0.map(_.toURI.toURL),
baseDepsExternal ++ projectDepsExternal ++ rulesDepsExternal,
baseResolvers
)
if (customToolClasspath) {
val toolClasspath = ToolClasspath(
projectDepsInternal0.map(_.toURI.toURL),
baseDepsExternal ++ projectDepsExternal ++ rulesDepsExternal,
baseResolvers
)
scalafixInterfaceCache.getOrElseUpdate(
toolClasspath,
// costly: triggers artifact resolution & classloader creation
scalafixInterface().withArgs(toolClasspath)
)
else
} else
// if there is nothing specific to the project or the invocation, reuse the default
// interface which already has the baseDepsExternal loaded
scalafixInterface()
Expand Down Expand Up @@ -290,6 +308,7 @@ object ScalafixPlugin extends AutoPlugin {
val (shell, mainInterface0) = scalafixArgsFromShell(
shellArgs,
scalafixInterfaceProvider.value,
scalafixInterfaceCache.value,
projectDepsExternal,
scalafixDependencies.in(ThisBuild).value,
scalafixResolvers.in(ThisBuild).value,
Expand Down

0 comments on commit 7f800d4

Please sign in to comment.