結論としては以下のissueに大体全部書いてあります。
依存ライブラリのjarのキャッシュではなく、あくまでインクリメンタルコンパイラのキャッシュの話です。
簡単に現象をまとめると
- sbtのインクリメンタルコンパイラのキャッシュというのは、(当然?)ファイルに保存されている
- sbtはそのファイルの変更日時を見ている
- 新しめのsbtでは、そのファイルの変更日時はミリ秒の精度で付与される(秒精度だと過去に問題があった?ので、sbtがOSごとに違うコードを頑張って書いてわざわざそれやってる)
- しかし、CircleCIというか、それに限らずDocker環境などで、キャッシュを一時保存してもう一度ロードするためにzip => unzipなどすると、ミリ秒の精度が失われて秒精度になり "ファイルの変更日時" が変わったことになり、sbtがそのキャッシュは使えないというような判断になって再コンパイルされてしまう?
という感じです。その現象を防ぐには
- sbtに "-Dsbt.io.jdktimestamps=true" という引数渡して起動すると、独自に頑張らずにJDKのもの使ってくれるらしい(JDKの仕様なのか実装依存なのかバグなのか、少なくともJDKに頼ると秒精度になる場合があった)
- しかし結局最近のJDKでは、それやってもミリ秒精度になってしまい意味がない(JDK側のバグ改善された?)
- よって、build.sbtに以下のようなミリ秒を秒精度にtruncateするworkaroundを書いておくとうまく動く(微妙に無駄な処理があったので、sbtのissueに書いてあるコード少しだけ改変した)
Seq(Compile, Test).map { c =>
(c / previousCompile) := {
val realPreviousResult = (c / previousCompile).value
import java.util.concurrent.TimeUnit.{ MILLISECONDS, SECONDS }
import sbt.internal.inc.{ Analysis, LastModified, Stamps }
import xsbti.compile.CompileAnalysis
import xsbti.compile.analysis.Stamp
def truncateMtime(s: Stamp): Stamp = s match {
case mtime: LastModified =>
val truncated = SECONDS.toMillis(MILLISECONDS.toSeconds(mtime.value))
new LastModified(truncated)
case other =>
other
}
def truncate(stamps: Stamps): Stamps = Stamps(
products = stamps.products.mapValues(truncateMtime).toMap,
sources = stamps.sources.mapValues(truncateMtime).toMap,
binaries = stamps.binaries.mapValues(truncateMtime).toMap
)
if (sys.env.get("CI").isEmpty) {
realPreviousResult
} else {
realPreviousResult.withAnalysis(
realPreviousResult
.analysis()
.map[CompileAnalysis] {
case a: Analysis =>
a.copy(stamps = truncate(a.stamps))
}
)
}
}
}