CircleCI上でsbtのインクリメンタルコンパイラのキャッシュが効かなくて再コンパイルされる問題

結論としては以下のissueに大体全部書いてあります。

Incremental compilation inside docker container broken since SBT 1.1.0 · Issue #4168 · sbt/sbt · GitHub

依存ライブラリの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))
          }
      )
    }
  }
}