キャッシュ化

sbt 2.0 は、ローカル/リモートの両方で使えるハイブリッドなキャッシュ・システムを導入し、タスク結果をローカルのディスクもしくは、Bazel 互換のリモート・キャッシュにキャッシュ化することができる。 過去のリリースを通じて、sbt は update のキャッシュ、差分コンパイルなど、様々なキャッシュ化を実装してきたが、sbt 2.x のキャッシュはいくつかの理由により、大きな変化となる:

  1. 自動化。sbt 1.x ではプラグイン作者がタスク実装内でキャッシュ化関数を呼び出す必要があったが、sbt 2.x キャッシュはタスク・マクロに組み込まれているため、自動化されている。
  2. マシン・ワイド。sbt 2.x のディスク・キャッシュはマシン上の全ビルドから共有されている。
  3. リモート対応。sbt 2.x では、キャッシュのストレージは独立して設定可能なため、全てのキャッシュ可能なタスクは自動的にリモート・キャッシュに対応している。

キャッシュ化の全体目標は、コード量が増えるにつれて増加していくビルドとテスト時間の成長率を現状よりも平たく抑えることにある。そのため、高速化の比率はコード量などにもよるが、現行のテストに 10分以上かかるようなビルドの場合、5倍から 20倍を狙っていくことも可能となってくる。

キャッシュ化の基本

ビルドプロセスがあたかも (A1, A2, A3, ...) というインプットを受け取り、 (R1, List(O1, O2, O3, ...)) というアウトプットを返す純粋関数であるかのように扱うというのがキャッシュ化の基本的な考えだ。例えば、ソース・ファイルのリストと Scala バージョンを受け取って、*.jar ファイルを生成することができる。もし仮定が成立するなら、同一のインプットに対してはアウトプットの JAR を全員に対してメモ化できる。メモ化された JAR を使うほうが、Scala コンパイルのような実際のタスクを実行するよりも高速であることがこのような技法を使うメリットとなる。

密閉ビルド

「純粋関数としてのビルド」のメンタルモデルとして、ビルド界隈のエンジニアは密閉ビルド (hermetic build)、つまり砂漠の真ん中に置かれたコンテナの中で時計も Internet も無い状態で行われるビルドという用語を使うことがある。そのような状態で JAR ファイルを生成することができれば、その JAR ファイルはどのマシンと共有しても大丈夫なはずだ。何故時計の話が出てきたのだろうか? それは、JAR ファイルが仕様としてタイムスタンプを捕捉するため、毎回少しづつ違った JAR を生成するからだ。これを回避するために、「密閉な」ビルドツールはいつビルドが実行されてもタイムスタンプを 2010-01-01 に上書きする慣習がある。

逆に、不安定なインプットを捕捉してしまったビルドは「密閉性を壊した」、または「非密閉である」という。密閉性が壊れるもう 1つのよくあるパターンは、インプットもしくはアウトプットで絶対パスを捕捉してしまうことだ。時としてはパスはマクロ経由で JAR に入ってしまう事があるので、バイトコードの差を検査しないと発見できないかもしれない。

自動的キャッシュ化

以下に、自動的キャッシュ化を具体例を用いて解説する:

val someKey = taskKey[String]("something")

someKey := name.value + version.value + "!"

sbt 2.x では、このタスクの結果は、nameversion という 2つのセッティングの値に基づいて自動的にキャッシュ化される。最初にこのタスクが実行されたときには、オンサイトで実行され、2回目以降はディスク・キャッシュの値が用いられる:

sbt:demo> show someKey
[info] demo0.1.0-SNAPSHOT!
[success] elapsed time: 0 s, cache 0%, 1 onsite task
sbt:demo> show someKey
[info] demo0.1.0-SNAPSHOT!
[success] elapsed time: 0 s, cache 100%, 1 disk cache hit

キャッシュ化はシリアライゼーション問題と同等に困難だ

キャッシュの自動化に参加するためには、(nameversion などの) インプットキーは sjsonnew.HashWriter 型クラスの given、戻り値は sjsonnew.JsonFormat 型クラスの given を提供する必要がある。Contraband を使って sjson-new のコーデックを生成することができる。

ファイルのキャッシュ化

ファイル (java.io.File など) のキャッシュ化は特別な配慮を必要とするが、それは技術的に難しいからというよりは、ファイルが関わったときに発生する曖昧さと思い込みによる所が大きい。一言に「ファイル」といっても、実は様々なことを意味する:

  1. 取り決められた場所からの相対パス
  2. 現物化された実際のファイル
  3. コンテンツ・ハッシュ値などの一意的なファイルの証明

厳密には、File はファイルパスのみを指すため、target/a/b.jar というようなファイル名のみを復元すればいい。これは、下流のタスクが target/a/b.jar というファイルがファイル・システムに存在すると思い込んでいた場合失敗してしまう。これを明瞭化しつつ、絶対パスを回避するために、sbt 2.x は 3つのそれぞれの場合に対して別々の型を提供する。

  • xsbti.VirtualFileRef は、ただの相対パスのみを表すのに用いられ、これは文字列を渡すのと等価だ
  • xsbti.VirtualFile は、コンテンツを持つ現物化されたファイルを表し、仮想ファイルもしくはディスク上のファイルであってもいい

しかしながら、密閉ビルドという観点から見ると、ファイルのリストを表すのにどちらも優れていない。ファイル名のみを持っていてもファイルが同一性を保証できないし、ファイルの全コンテンツを持ち回すのは JSON などに使うには非効率的だ。

ここで謎の 3つ目方法、ファイルの一意的な証明が便利になる。HashedVirtualFileRef は、相対パスの他にも SHA-256 コンテンツ・ハッシュとファイル・サイズを追跡する。これは、JSON に簡単にシリアライズできるが、特定のファイルを参照することもできる。

ファイル作成の作用

ファイルを生成するが、VirtualFile を戻り値の型として使わないタスクがたくさんある。例えば、sbt 1.x では compileAnalysis を返し、*.class ファイルの生成は「副作用」として行われる。

キャッシュ化に参加するためには、これらの作用も後に残しておきたいものとして宣言する必要がある。

someKey := {
  val conv = fileConverter.value
  val out: java.nio.file.Path = createFile(...)
  val vf: xsbti.VirtualFile = conv.toVirtualFile(out)
  Def.declareOutput(vf)
  vf: xsbti.HashedVirtualFileRef
}

リモート・キャッシュ

オプションとして、ビルドを拡張してローカルでのディスク・キャッシュの他にリモート・キャッシュも使うことができる。リモート・キャッシュは、複数のマシンがビルドの成果物やアウトプットを共有することでビルドの性能を向上できる。

自分のプロジェクトもしくは会社に 10名ぐらいのメンバーがいると想像してほしい。毎朝、その 10名の書いた変更を git pull で取り込んで、書かれたコードをビルドする必要がある。プロジェクトが順調にいけば、コード量は時間とともに増加していき、一日のうち他人のコードをビルドする時間の割合も増えていく。これは、いずれチーム規模とコード量の制限要因となる。リモート・キャッシュは、CI システムにキャッシュを補給させ自分たちは成果物やタスクのアウトプットをダウンロードできるようにすることでこの傾向を逆転させる。

sbt 2.x は、Bazel 互換の gRPC インターフェイスを実装するため、オープンソース及び商用の複数のバックエンド・システムと統合する。詳細は、リモート・キャッシュのセットアップ参照。

レファレンス

キャッシュ・タスクのレファレンスガイドも参照。