sbt 2.x リモートキャッシュ
これは Scala Advent Calendar 2023 の 23日目の記事です。21日目は、さっちゃんのpath 依存型って何? 調べてみました!でした。
はじめに
リモートキャッシュは、ビルドの結果を共有することで劇的な性能の改善を可能とする。Mokhov 2018 ではクラウド・ビルド・システム (cloud build system) とも呼ばれている。これは、僕が Blaze (現在は Bazel としてオープンソース化されている) のことを聞いて以来関心を持ち続けてきた機能だ。2020年に、僕は sbt 1.x のコンパイルキャッシュを実装した。reibitto さんの報告によると「以前は全てをコンパイルするのに 7分かかっていたが、15秒で終わるようになった」らしい。他にも 2x ~ 5x 速くなったという報告を他の人も行っている。これらは期待の持てる内容であることに間違いないが、現行の機能は少し不器用で compile
タスクにしか使えないという限界がある。2023年の3月に、RFC-1: sbt cache ideas として現状の課題と対策の設計のアウトラインを書き出してみた。以下に課題をまとめる:
- 問題1: sbt 1.x は
compile
のリモートキャッシュ、およびその他いくつかのタスクに対してディスクキャッシュを実装するが、カスタムタスクが参加できるソリューションが望ましい。 - 問題2: sbt 1.x はディスクキャッシュとリモートキャッシュで別の機構を持つが、ビルドユーザがローカルかリモートのキャッシュかを切り替えられる統一した機構が望ましい。
- 問題3: sbt 1.x は Ivy resolver をキャッシュの抽象化に用いたが、よりオープンなリモートキャッシュ・バックエンドが望ましい
12月中は適当に自分でプロジェクトを選んで 毎日少しでもいいから作業して、それをブログに数行ずつ記録したり #decemberadventure というハッシュタグをつけて投稿するという独りアベントが Mastodon 界隈の一部で流行ってて、僕の december adventure 2023 として、sbt 2.x のリモートキャッシュに挑戦してみようと思った。実装の提案は GitHub #7464 で、本稿では、提案した変更点の解説を行う。注意: sbt の内部構造に関する予備知識はあんまり必要としないが、プルリクコメントの拡張版のようなものなので上級レベルの読者を想定している。あと、プルリク段階なので書いている先から詳細はどんどん変わっていくかもしれない。
低レベルな基礎
抽象的には、キャッシュ化されたタスクは以下のように考える事ができる:
(In1, In2, In3, ...) => (A1 && Seq[Path])
インプット値のハッシュと結果値をどこか (例えばディスク内) に保存できれば、次回呼ばれたときには重いタスクの評価をする代わりに結果だけを返すことができる。キャッシュ化されたタスクの結果値は ActionResult
として表される:
import xsbti.HashedVirtualFileRef
class ActionResult[A1](a: A1, outs: Seq[HashedVirtualFileRef]):
def value: A1 = a
def outputs: Seq[HashedVirtualFileRef] = outs
....
end ActionResult
HashedVirtualFileRef
は後でもみるが、ファイル名とコンテンツハッシュを持つ。これらを使って以下のように cache
関数を実装できる:
import sjsonnew.{ HashWriter, JsonFormat }
import xsbti.VirtualFile
object ActionCache:
def cache[I: HashWriter, O: JsonFormat: ClassTag](
key: I,
codeContentHash: Digest,
extraHash: Digest,
tags: List[CacheLevelTag],
)(
action: I => (O, Seq[VirtualFile])
)(
config: BuildWideCacheConfiguration
): O =
val input =
Digest.sha256Hash(codeContentHash, extraHash, Digest.dummy(Hasher.hashUnsafe[I](key)))
....
end ActionCache
上の型パラメータ I
は典型的にはタプル型となる。action
関数のシグネチャが Seq[VirtualFile]
というのが出てきて不自然に見えるかもしれない。これはタスク内でのファイル出力エフェクトを捕獲するためのものだ。
キャッシュ化されたタスクの自動導出
sbt の DSL は、Applicative のための do 記法で、
someKey := {
name.value + version.value + "!"
}
をマクロを経由して Applicative の mapN
式に書き換える:
someKey <<= i.mapN((wrap(name), wrap(version)), (q1: String, q2: String) => {
q1 + q2 + "!"
})
Scala 3 マクロを使って、結果値をさらに装飾してキャッシュ化されたタスクを自動導出することができる:
someKey <<= i.mapN((wrap(name), wrap(version)), (q1: String, q2: String) => {
ActionCache.cache[(String, String), String](
key = (q1, q2),
otherInputs = 0): input =>
(q1 + q2 + "!", Nil))
})
これがうまくいくにはインプット・タプルは sjsonnew.HashWriter
を満たす必要があり、String
などの結果値の型は JsonFormat
を満たす必要がある。便宜的には、これは build.sbt
の抽象構文木と疑似 case class からマークル木を構築していると考えることができる。
キャッシュのバックエンド
以下の trait はキャッシュのバックエンドを抽象化する。
opaque type Digest = String
/**
* An abstration of a remote or local cache store.
*/
trait ActionCacheStore:
def put[A1: ClassTag: JsonFormat](
actionDigest: Digest,
value: A1,
blobs: Seq[VirtualFile],
): ActionResult[A1]
def get[A1: ClassTag: JsonFormat](input: Digest): Option[ActionResult[A1]]
def putBlobs(blobs: Seq[VirtualFile]): Seq[HashedVirtualFileRef]
def getBlobs(refs: Seq[HashedVirtualFileRef]): Seq[VirtualFile]
def syncBlobs(refs: Seq[HashedVirtualFileRef], outputDirectory: Path): Seq[Path]
end ActionCacheStore
メソッドはだいたい自明だと思うが、これはキャッシュのバックエンドを実装したい人向けのものなので、詳細を理解するのは重要ではない。興味深いのはたったの 5つのメソッドで済むということだ。初期段階のテストでは、ローカル環境でのディスクキャッシュに注力することにする。
キャッシュ・タスク化した packageBin
を実行後のキャッシュ・ディレクトリは以下のようになった:
$ tree $HOME/Library/Caches/sbt/v2/
~/Library/Caches/sbt/v2/
├── ac
│ ├── sha256-d3ea49940f3ec7f983ddfe91f811161d2fee53c19ec58db224c789b63c5d759d
│ └── sha256-e2d1010d6ce5808902e35222ec91d340ae7ecb013ec7cb3b568c3b2c33c3ffa0
└── cas
├── sha256-02775d17841ec170a97b2abec01f56fb3e3949fefc8d69121e811f80c041cfb1
├── sha256-601ba6379aeed7fefd522d3a916b3750c35fe8cd02afe95a7be4960de1fbcfa7
└── sha256-f824ffec2c48cbc5e4cdcaec71670983064312055d3e9cfcc1220d7f4f193027
ac/sha256-d3ea49940f3ec7f983ddfe91f811161d2fee53c19ec58db224c789b63c5d759d
のファイルの内容は:
{"$fields":["value","outputFiles"],"value":"${OUT}/jvm/scala-3.3.1/hello/hello_3-0.1.0-SNAPSHOT.jar>sha256-f824ffec2c48cbc5e4cdcaec71670983064312055d3e9cfcc1220d7f4f193027","outputFiles":["${OUT}/jvm/scala-3.3.1/hello/hello_3-0.1.0-SNAPSHOT.jar>sha256-f824ffec2c48cbc5e4cdcaec71670983064312055d3e9cfcc1220d7f4f193027"]}
cas/sha256-f824ffe...
は JAR ファイルだ:
$ unzip -l $HOME/Library/Caches/sbt/v2/cas/sha256-f824ffec2c48cbc5e4cdcaec71670983064312055d3e9cfcc1220d7f4f193027
Archive: ~/Library/Caches/sbt/v2/cas/sha256-f824ffec2c48cbc5e4cdcaec71670983064312055d3e9cfcc1220d7f4f193027
Length Date Time Name
--------- ---------- ----- ----
298 01-01-2010 00:00 META-INF/MANIFEST.MF
0 01-01-2010 00:00 example/
608 01-01-2010 00:00 example/Greeting.class
363 01-01-2010 00:00 example/Greeting.tasty
....
キャッシュ化における実際の問題
もしキャッシュ化が簡単ならば、オープンソースから利益を上げることと並んで計算機科学の 2大難問と言われることは無いだろう (あとは off-by-one エラーも)。
シリアライゼーション問題
第一に、キャッシュ化は serialization-hard、つまりシリアライゼーション問題と同等もしくはそれ以上に困難だ。現在の形で 10年以上続いているビルドツールである sbt にとっては、これが最大の難関となると思う。具体例で説明すると、Attributed[A1]
というデータ型があって、これは A1
のデータ及び任意のメタデータをキー・値として保持する。クラスパスなどの基礎的なものが Seq[Attributed[File]]
として表されており、これを用いてクラスパス内のエントリーを Zinc の Analysis
と関連付けたりしている。
compile
のようなタスクをメモリ内で実行しているうちは、ぶっちゃけ Map[String, Any]
と等価である Attributed[A1]
で特に問題は無かった。しかし、キャッシュ化を考慮するとインプットならば HashWriter
、結果値ならば JsonFormat
が必要となり、Any
はどれも不可能だ。この場合は、StringAttributeMap
という別のデータ型を作ることで回避した。
ファイル・シリアライゼーション問題
キャッシュ化は file-serialization-hard、ファイル・シリアライゼーション問題と同等もしくはそれ以上に困難だ。java.io.File
(もしくは Path
) は特別な存在なので、別個に考察する必要があるが、それが技術的に難しいからというよりは、それが何を意味するかという我々自身の期待による所が大きい。僕たちが「ファイル」と言うときそれは以下のことを意味する:
- 事前に取り決めた場所からの相対パス
- ファイルに関する一意な証明、コンテンツハッシュなど
- 具現化された実際のファイル
java.io.File
を使った場合、上の 3つのうちどれを意味したのかが少し曖昧になる。厳密には File
はただのファイル・パスなので、target/a/b.jar
といったファイル名だけをデシリアライズするだけでいい。しかし、下流のタスクが target/a/b.jar
がファイルシステムに存在していることを期待していた場合、タスクは失敗する。
これを明示化するために、xsbti.VirtualFileRef
は相対パスのためのみに用い、xsbti.VirtualFile
はコンテンツを持った具現化された仮想ファイルを指す。しかし、ファイルのリストなどをキャッシュする用途としてはどちらも不向きだ。ファイル名だけを保存してもファイルそのものが同一であるか保証できないし、ファイルの全コンテンツを引き回すのは JSON などには非効率的すぎる。同じ JAR がビルド内で何度も出てくることを考えると、ただの参照がほしいだけなのにファイルを埋め込んでしまうのは馬鹿げている。
ここで、謎の2つ目の選択肢ファイルに関する一意な証明が役に立つ。Bazel cache のイノベーションの鍵の一つに content-addressable storage (CAS) というアイディアがある。ディレクトリいっぱいにファイルが入っていて、それぞれがコンテンツハッシュをもとにファイル名が付けられているようなものだと考えていい。これがあれば、コンテンツハッシュを知っているだけでいつでもファイルを具現化できる。実際には、ファイル名も必要になってくるので、これを表すために HashedVirtualFileRef
というデータ型を sbt 2.x に追加した:
public interface HashedVirtualFileRef extends VirtualFileRef {
String contentHashStr();
}
エフェクト問題
ファイル・シリアライゼーション問題を全ての副作用に一般化するとキャッシュ化は IO-hard だと考えることができる。とにかくタスクが実行する副作用のうち、僕たちが必要だと思うものは管理する必要がある。これには例えば画面に文字を表示することも含む。合成についても考える必要があるかもしれない。
アウトプットの宣言
sbt 2.x で、僕は Def.declareOutput
という新しい関数を導入する:
Def.declareOutput(out)
これはファイル出力の宣言を行うためにタスク内で呼ばれる。典型的なビルドツールだとファイルの生成は副作用ととして行われ、1つのタスクから多くのファイルが生成されることもあり、それらの一部だけを下流のタスクが使うといったことがある。リモートキャッシュを用いたビルドツールは、期待されるファイルをダウンロードする必要があるため、アウトプットを宣言する必要がある。compile
タスクのようにファイルを色々生成するが、戻り値の型にファイルを持たないものもあることに注意してほしい。
someKey := Def.cachedTask {
val output = StringVirtualFile1("a.txt", "foo")
Def.declareOutput(output)
name.value + version.value + "!"
}
上のタスクは、以下のようになる:
someKey <<= i.mapN((wrap(name), wrap(version)), (q1: String, q2: String) => {
var o0 = _
ActionCache.cache[(String, String), String](
key = (q1, q2),
otherInputs = 0): input =>
var o1: VirtualFile = _
val output = StringVirtualFile1("a.txt", "foo")
o1 = output
(q1 + q2 + "!", List(o1))
})
このタスクを最初に走らせたときは、sbt は q1 + q2 + "!"
を評価して、また別に o1
を CAS に保存して HashedVirtualFileRef
のリストを持つ ActionResult
を計算する。2度目にこのタスクが呼び出されたときは、ActionCache.cache(...)
はこのファイルを物理ファイルとして具現化してそれを参照する VirtualFile
を返す。
シリアライゼーションからのオプトアウト
上の例では、全てのインプット側のセッティングとタスクはキャッシュキーである前提でマクロ展開が行われた:
ActionCache.cache[(String, String), String](
key = (q1, q2),
....
これは多分デフォルトのふるまいとしては適切だが、実際にはキャッシュキーから除外したいキーもあるはずだ。例えば、ログに使われる streams
キーなんかは、新しい値が毎回与えられ、シリアライゼーションできる意味のある値を特に持たない。そのため、無理にこれを JSON に変換する必要性が無い。
このような除外のために、cacheLevel(...)
というアノテーションを追加した:
@meta.getter
class cacheLevel(
include: Array[CacheLevelTag],
) extends StaticAnnotation
enum CacheLevelTag:
case Local
case Remote
end CacheLevelTag
これで、以下のようにして streams
をキャッシュからオプトアウトすることができる:
@cacheLevel(include = Array.empty)
val streams = taskKey[TaskStreams]("Provides streams for logging and persisting data.")
.withRank(DTask)
一般的に、マシンに特定なキーや非密閉な (non-hermetic) なキーは、可能な限りキャッシュから除外するべきだ。
レイテンシー・トレードオフ問題
キャッシュ化は latency-tradeoff-hard、レイテンシーのトレードオフ問題と同等もしくはそれ以上に困難だ。仮に compile
タスクが 100 の .class
ファイルを生成して、packageBin
が 1つの .jar
を生成するとした場合、compile
タスクはキャッシュが当たったとしてもディスクキャッシュからから 100個のファイルを読み込むか、リモートキャッシュから 100個のファイルをダウンロードをする必要がある。JAR ファイルが .class
ファイル群を近似することを考慮すると、ファイルのダウンロード往復を減らすためには compile
にも JAR ファイルを使うべきだろう。
密閉性問題
リモートキャッシュ化は密閉性問題 (hermeticity) と同等もしくはそれ以上に困難だ。リモートキャッシュの前提条件はキャッシュの結果が異なるマシンで共有可能であることだ。意図せずにマシン特定の情報を生成物の中に捕獲してしまった場合、キャッシュのサイズが大きくなってしまったり、キャッシュヒット率が低下したり、実行時エラーとなったりする。これを密閉性が壊れたと言ったりする。
2つのよくある問題は java.io.File
経由で絶対パスを捕獲してしまうのと、現在のタイムスタンプを捕獲してしまうことだ。もう少し目立たないが実際に僕が遭遇したことがあるのは JVM のバグでマシンのタイムゾーンを捕獲してしまう問題と GraalVM が glibc のバージョンを捕獲してしまう問題だ。
パッケージ集約問題
キャッシュの無効化はパッケージ集約問題 (package aggregation) と同等もしくはそれ以上に困難だ。詳細は Analysis of Zinc 参照。「パッケージ集約問題」という用語は今僕が勝手に思いついたものだが、問題を要約すると、1つのサブプロジェクトにより多くのソースファイルが集約すると、サブプロジェクト間の依存性がより密になってしまい、依存性グラフを逆向きにするという単純な無効化だと、コード変更による初期の無効化が山火事のようにモノリポ全体に広がってしまうという問題だ。
ビルドツールはそれぞれこの問題に対して色々な対策を行っている:
- サブプロジェクトの粒度を上げる。1:1:1 ルール (1ディレクトリ、1 パッケージ、1 ターゲット)
- 間接的依存性を無視する。これは strict deps とも呼ばれている。(Bazel は Java でこれを行う)
- メソッドの使用粒度で依存性を追跡する (Zinc が行っていること)
- 未使用の import やライブラリ依存性の削除
今回は、多分単純な無効化から実装すると思うが、後でこのあたりを改善できる道は確保しておきたい。
ケーススタディー: packageBin タスク
packageBin
タスクは class ファイルから構成される JAR ファイルを作る。一般的に、package*
系のタスクは packageTaskSettings
と packageTask
関数および Package
object によって定義される。packageBin
タスクをキャッシュ・タスク化してみよう。
第一に、PackageOption
をシリアライズ可能とする必要がある。Scala 3 enum を使って実装して、それぞれに対して JsonFormat
を実装して、直和型を定義した:
enum PackageOption:
case JarManifest(m: Manifest)
case MainClass(mainClassName: String)
case ManifestAttributes(attributes: (Attributes.Name, String)*)
case FixedTimestamp(value: Option[Long])
object PackageOption:
....
given JsonFormat[PackageOption] = flatUnionFormat4[
PackageOption,
PackageOption.JarManifest,
PackageOption.MainClass,
PackageOption.ManifestAttributes,
PackageOption.FixedTimestamp,
]("type")
end PackageOption
Package.Configuration
クラスは以下のように変更した:
// in sbt 1.x
final class Configuration(
val sources: Seq[(File, String)],
val jar: File,
val options: Seq[PackageOption]
)
// in sbt 2.x
final class Configuration(
val sources: Seq[(HashedVirtualFileRef, String)],
val jar: VirtualFileRef,
val options: Seq[PackageOption]
)
インプット側のソースは HashedVirtualFileRef
で表し、アウトプット用のファイル名は VirtualFileRef
で表していることに注目してほしい。実際に JAR ファイルを作る Pkg.apply(...)
は Unit
でなく VirtualFile
を返すようにした。
Keys.scala
での packageBin
キーの定義は以下のように変更した:
val packageBin = taskKey[HashedVirtualFileRef]("Produces a main artifact, such as a binary jar.").withRank(ATask)
新しい pacakgeTask
は以下のようになる:
def packageTask: Initialize[Task[HashedVirtualFileRef]] =
Def.cachedTask {
val config = packageConfiguration.value
val s = streams.value
val converter = fileConverter.value
val out = Pkg(
config,
converter,
s.log,
Pkg.timeFromConfiguration(config)
)
Def.declareOutput(out)
out
}
地味なポイントかもしれないがここでも注意してほしいのは、out
の型は VirtualFile
であるが、タスクの戻り値の型はわざと HashedVirtualFileRef
に広げてあることだ。タスクキーを Initialize[Task[VirtualFile]]
に変えるとコンパイルが通らないはずだ:
[error] -- [E172] Type Error: /user/xxx/sbt/main/src/main/scala/sbt/Defaults.scala:1979:5
[error] 1979 | }
[error] | ^
[error] |Cannot find JsonWriter or JsonFormat type class for xsbti.VirtualFile.
ディスクキャッシュ ac/sha256-d3ea49940f3ec7f983ddfe91f811161d2fee53c19ec58db224c789b63c5d759d
の中身が以下であることを思い出してほしい:
{"$fields":["value","outputFiles"],"value":"${OUT}/jvm/scala-3.3.1/hello/hello_3-0.1.0-SNAPSHOT.jar>sha256-f824ffec2c48cbc5e4cdcaec71670983064312055d3e9cfcc1220d7f4f193027","outputFiles":["${OUT}/jvm/scala-3.3.1/hello/hello_3-0.1.0-SNAPSHOT.jar>sha256-f824ffec2c48cbc5e4cdcaec71670983064312055d3e9cfcc1220d7f4f193027"]}
もしタスクの戻り値の型が VirtualFile
ならば、この JSON の中に全ファイルコンテンツを埋め込む必要がある。代わりに、相対パスと SHA-256 を使ったファイルに関する一意な証明のみを保存する: "${OUT}/jvm/3.3.1/hello/scala-3.3.1/hello_3-0.1.0-SNAPSHOT.jar>farm64-b9c876a13587c8e2"
。実際のコンテンツは Def.declareOutput(out)
にて CAS に渡す。
ディスクキャッシュが潤うと、clean
の後でも、packageBin
はインプットを zip せずともディスクキャッシュに対して高速にシンボリックリンクを張るだけでよくなる。
ケーススタディー: compile task
packageBin
の自動キャッシュ化ができたので、この考え方を compile
にも当てはめて考えることができる。課題の 1つとして、上でも言及したレイテンシー・トレードオフ問題がある。sbt 1.x では、型付けされた並列処理のまとまりだったので、好きなだけ細かいタスクを作ることができた。sbt 2.x ではおそらくネットワークのレイテンシーも考慮に入れる必要がある (検証実験が多分必要)。幸いなことに、JAR ファイルというコンパイラが扱い慣れているものがあるので、全ての *.class
をキャッシュ化する代わりに JAR ファイルを生成させる。
compileIncremental
の大まかな流れは以下のようになる:
compileIncremental := (Def.cachedTask {
val s = streams.value
val ci = (compile / compileInputs).value
val c = fileConverter.value
// do the normal incremental compilation here:
val analysisResult: CompileResult =
BspCompileTask
.compute(bspTargetIdentifier.value, thisProjectRef.value, configuration.value) {
bspTask => compileIncrementalTaskImpl(bspTask, s, ci, ping, reporter)
}
val analysisOut = c.toVirtualFile(setup.cachePath())
Def.declareOutput(analysisOut)
// inline packageBin to create a JAR file
val mappings = ....
val pkgConfig = Pkg.Configuration(...)
val out = Pkg(...)
s.log.info(s"wrote $out")
Def.declareOutput(out)
analysisResult.hasModified() -> (out: HashedVirtualFileRef)
})
.tag(Tags.Compile, Tags.CPU)
.value,
使ってみると、こんな感じだ:
$ sbt
[info] welcome to sbt 2.0.0-alpha8-SNAPSHOT (Azul Systems, Inc. Java 1.8.0_352)
[info] loading project definition from hello1/project
[info] compiling 1 Scala source to hello1/target/out/jvm/scala-3.3.1/hello1-build/classes ...
[info] wrote ${OUT}/jvm/scala-3.3.1/hello1-build/hello1-build-0.1.0-SNAPSHOT-noresources.jar
....
sbt:Hello> compile
[info] compiling 1 Scala source to hello1/target/out/jvm/scala-3.3.1/hello/classes ...
[info] wrote ${OUT}/jvm/scala-3.3.1/hello/hello_3-0.1.0-SNAPSHOT-noresources.jar
[success] Total time: 3 s
sbt:Hello> clean
[success] Total time: 0 s
sbt:Hello> compile
[success] Total time: 1 s
sbt:Hello> run
[info] running example.Hello
hello
[success] Total time: 1 s
sbt:Hello> exit
[info] shutting down sbt server
これは clean
で target ディレクトリごと消しても compile
がキャッシュされていることを示す。実際には、キャッシュ化されていない依存タスクもあるので完全な no-op では無いが、1秒で終わった。sbt セッションを抜けて、target/
を再度消して確認してみる:
$ rm -rf project/target
$ rm -rf target
$ sbt
[info] welcome to sbt 2.0.0-alpha8-SNAPSHOT (Azul Systems, Inc. Java 1.8.0_352)
....
sbt:Hello> run
[info] running example.Hello
hello
[success] Total time: 2 s
sbt:Hello> exit
[info] shutting down sbt server
$ ls -l target/out/jvm/scala-3.3.1/hello/
$ ls -l target/out/jvm/scala-3.3.1/hello/
total 0
drwxr-xr-x 4 xxx staff 128 Dec 27 03:44 classes/
lrwxr-xr-x 1 xxx staff 113 Dec 27 03:44 hello_3-0.1.0-SNAPSHOT-noresources.jar@ -> /Users/xxx/Library/Caches/sbt/v2/cas/sha256-02775d17841ec170a97b2abec01f56fb3e3949fefc8d69121e811f80c041cfb1
lrwxr-xr-x 1 eed3si9n staff 113 Dec 27 03:44 hello_3-0.1.0-SNAPSHOT.jar@ -> /Users/xxx/Library/Caches/sbt/v2/cas/sha256-f824ffec2c48cbc5e4cdcaec71670983064312055d3e9cfcc1220d7f4f193027
drwxr-xr-x 5 xxx staff 160 Dec 27 03:44 streams/
drwxr-xr-x 3 xxx staff 96 Dec 27 03:44 sync/
drwxr-xr-x 3 xxx staff 96 Dec 27 03:44 update/
drwxr-xr-x 3 xxx staff 96 Dec 27 03:44 zinc/
Scala コンパイラを呼ばずに run
を動かすことができた。ここで 2つの JAR があるのは、厳密には compile
タスクは src/main/resources/
のコンテンツを含まないからだ。sbt 1.x ではこの作業は copyResources
というタスクで行われ、products
タスクがそれを呼び出す。
タスクの粒度のトレードオフがまた出てきた。コンパイルとリソースを分ければ、ソースが変わるたびにリソースファイルをキャッシュにアップロードすることを回避することができる。一方、分けることで product
つまり packageBin
を呼んだときに二重でアップロードが必要となる。
新しい Classpath 型
前の方でも出てきたが、sbt 1.x ではクラスパスは Seq[Attributed[File]]
として表現される。java.io.File
は、絶対パスを捕獲してしまうのと、コンテンツ変更に無頓着なせいでキャッシュのインプットには向いていない。sbt 2.x では Classpath
は以下のように定義する:
type Classpath = Seq[Attributed[HashedVirtualFileRef]]
補足すると、HashedVirtualFileRef
は、fileConverter.value
で得られる FileConverter
のインスタンスがあればいつでも Path
に変換することができる。Scala 3 の extension メソッドを使ってクラスパスを Seq[Path]
に変換する files
も定義してある:
given FileConverter = fileConverter.value
val cp = (Compile / dependencyClasspath).value.files
まとめ
RFC-1: sbt cache ideas をもとに、#7464 は Def.cachedTask
という自動キャッシュタスクを実装する:
someKey := Def.cachedTask {
val output = StringVirtualFile1("a.txt", "foo")
Def.declareOutput(output)
name.value + version.value + "!"
}
これは Scala 3 マクロを用い依存タスクをキャッシュ・キーとして追跡して、アウトプットのシリアライズやデシリアライズを自動的に行う。インプットは sjsonnew.HashWriter
というマークル木のための型クラスを満たす必要があり、結果型は sjsonnew.JsonFormat
を満たす必要がある。
ファイルの追跡のために sbt 2.x は主に VirtualFile
と HashedVirtualFileRef
という 2つの型を用いる。VirtualFile
は、タスクが実際の読み書きを行うのに用いられ、HashedVirtualFileRef
はキャッシュに便利なファイル参照としてクラスパス関連などのタスクに用いられる。
タスクのアウトプットとして意味のあるファイルは、Def.declareOutput(...)
を用いて明示的に宣言される必要がある。例えば、compile
は *.class
ファイルも生成するかもしれないが、キャッシュには含まれない。代わりに JAR ファイルが Def.declareOutput(...)
を用いて登録される。
この機構を試すために、#7464 は packageBin
タスクと compile
タスクの自動キャッシュ化を実装する。