Turborepo のダミータスク topo ってなんだー!?

JavaScript や TypeScript のモノレポを便利に使えるようにしてくれる Turborepo というツールのドキュメントを読んでて、↓この部分で???ってなった。ので、将来の自分用にブログにまとめておくことにした。

https://turbo.build/repo/docs/core-concepts/monorepos/task-dependencies#dependencies-outside-of-a-task

topo ってなんだー!?

add、subtract、multiply という workspace があって

  • subtract は add に依存している
  • multiply も add に依存している

という場合に、こういう書き方をすると↓

{
   "$schema": "https://turbo.build/schema.json",
   "pipeline": {
     "topo": {
       "dependsOn": ["^topo"]
     },
     "test": {
       "dependsOn": ["topo"]
     }
   }
 }

↓こういうことが実現できる、と書いてある。

  • add、subtract、multiply の test は、すべて同時に実行される
  • add に変更が入った場合は、subtract と multiply の test 実行時にキャッシュが使われずに再度実行される

なるほど?←なにも分かってない。

まずは dependsOn について再確認しておく。

workspace と task

その前に、workspace と task という言葉について。

  • workspace:モノレポの場合は、複数の workspace がひとつのリポジトリに入ってる。上の例の add、subtract、multiply がそれぞれ workspace。
  • task:npm scripts に定義される task のこと。buildlinttest など。

それぞれの workspace 内の task の依存関係

それぞれの workspace の中の task に依存関係がある場合がある。deploy の前に build を実行する必要がある、みたいなの。Turborepo を使う場合は、ルートディレクトリに置いた turbo.json で、一括でそういう依存関係を定義できる。

↓Turborepo のドキュメントより

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {},
    "deploy": {
      // A workspace's `deploy` task depends on the `build` task of the same workspace.
      "dependsOn": ["build"]
    }
  }
}

これは「それぞれの workspace 内の deploy タスクを実行するときには、その前に build タスクを実行する必要がある」と Turborepo に伝えることになる。

これはわかる。

モノレポ内の workspace 間の依存関係

モノレポなのでだいたい workspace 間に依存関係がある。さっきの add や subtract のように。だから、workspace 同士の依存関係に気をつけて build などのタスクを実行する必要がある。Turborepo はこのあたりの依存関係を扱えるようになってる。便利。ただ、ややこしいことに、これも dependsOn で定義する。さっきのと違って ^ を使う。

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      // A workspace's `build` command depends on its dependencies'
      // and devDependencies' `build` commands being completed first
      "dependsOn": ["^build"],
    }
  }
}

ちょっと説明のために書き換える。

    "foo": {
      "dependsOn": ["^bar"],
    }

この場合 foo を実行するためには、その workspace の依存先 workspace の bar タスクを先に実行する必要がある。という意味になる。

元の設定に戻ると↓は「build を実行するためには、その workspace の依存先 workspace の build を先に実行する必要がある」ということになる。こう書くことで、それが再帰的に連鎖して、モノレポ内のすべての依存関係がいい感じに build される。

    "build": {
      "dependsOn": ["^build"],
    }

すこしややこしいけど、落ち着いて考えたら、これもまぁ分かる。

さて本題

で、最初の定義の部分に戻るんだけど、やっぱりいまいち意味が分からなくて、ぼーっと探してたら、この Issue を見つけて、なるほどーってなった。

github.com

実現したいこと

実現したいのはシンプルにこういうこと↓

add、subtract、multiply という workspace があって

  • subtract は add に依存している
  • multiply も add に依存している

というときに

  • add、subtract、multiply の test を、すべて同時に実行したい

dependsOn を書かなければ add、subtract、multiply の test はふつうに同時実行される。問題は Turborepo のキャッシュ。

Turborepo のキャッシュ

Turborepo は task 実行の高速化のために、コードに変更がなければ前回実行時のキャッシュを使う。実際にはコード以外にも環境変数など色々なものを input として考慮してるけど、ここでは説明を簡単にするためにコードだけに注目する。

コードに変更がなければ、test は実行せずに、前回実行時の test の結果をキャッシュから返す。

workspace に依存関係がある場合の問題

dependsOn を書いていない場合は、Turborepo はそれらの workspace に依存関係があることを知らない。そのため「add が更新されているから subtract の test を再実行してほしいんだけど、subtract が更新されていないからキャッシュから返される」という動きになる。これはダメだ。

じゃあ、依存関係を見るようにするかー!と思ってこういう風に定義をすると

    "test": {
      "dependsOn": ["^test"],
    }

subtract の test を実行する前に依存先の add の test を先に実行してね!となる。これで、Turborepo は workspace の依存関係に気づけるので add に更新があった場合には、subtract の test 実行時にはキャッシュを使わずに再度実行してくれるようになる。

でも、この設定を入れたことにより、同時実行されなくなってしまった。

workspace の依存関係は直接コードを見ている

状況を整理すると、add に変更があった場合には、subtract と multiply は、キャッシュを使わないで再度 test を実行したい。モノレポの workspace 参照なので、npm パッケージへの依存と違って add のビルドなどを先にする必要はない。単純にキャッシュを使わずに再実行したいだけ。

だから、実現したいことはこういうことになる。やっと最初の要望の意味がわかった。

  • add、subtract、multiply の test は、すべて同時に実行したい
  • add に変更が入った場合は、subtract と multiply の test 実行時にキャッシュを使わずに再実行したい

ダミータスク topo

つまり、キャッシュの判断のために依存関係は見てほしいけど、task の実行自体には依存関係はないから同時に実行したい。という状況。これを解決するのがダミータスク topo になる。モノレポ内に存在しない task であればいいので名前はなんでもいいけど、topology を意味する topo を使うみたい。

{
   "$schema": "https://turbo.build/schema.json",
   "pipeline": {
     "topo": {
       "dependsOn": ["^topo"]
     },
     "test": {
       "dependsOn": ["topo"]
     }
   }
 }
  • topo 自体は何もしない。
  • test は「その workspace 内の」 topo を先に実行する必要がある
  • topo は「依存先 workspaceの」 topo を先に実行する必要がある

これにより、Turborepo は test 実行時に、workspace 間の依存関係を認識して、キャッシュの利用判断をしてくれる。

さらに、topo は何もしないので、test はすべて同時に実行される。

ということでやりたいことが実現できた!なるほどー。(ヽ´ω`)

質問

testdependsOn^topo を書くだけでいいのでは?

それだと、1つ先の依存までしか見てくれないからダメ。

この場合 test は「依存先 workspaceの」 topo を先に実行する必要がある、という意味になる。これで、add と subtract の依存関係に気づけるので、add に更新があった場合に、キャッシュを使わない状態になる。ここまではいい。

でも、この定義だと、再帰的にはならない。どういうことかというと、もうひとつ別の workspace として number が存在して、add が number に依存している場合を考える。

subtract の testを実行するときには add の topo を見るだけで、add の topo はその先の依存関係を見に行かない。なので、number だけが更新された場合には、subtract はキャッシュを利用してしまう。

だから、topo に再帰的な依存を入れておく必要があるのだ。

     "topo": {
       "dependsOn": ["^topo"]
     },

ややこしい!

dependsOn に書いてなくても依存関係をキャッシュのキーとして考慮すればいいのでは?

そもそも、dependenciesdevDependencies に workspace の依存関係が書いてあるので、Turborepo がそれを最初からキャッシュのキーとして考慮すればいいのでは?ということなんだけど、そういうわけにはいかない。

今回の場合は「workspace の依存がキャッシュに影響を与える」というケースだったが、他のケースとしてあるのが「workspace は依存しているがキャッシュには影響を与えない」という依存関係もある。例えば ESLint の workspace。

ESLint の設定を workspace としてくくりだしている場合、subtract から ESLint workspace への依存は存在するが、ESLint が更新されても、それは test の実行には関係ないので、キャッシュから返しても問題がない。

だから、依存関係を自動的にキャッシュのキーとして扱うわけにはいかない。

ややこしい!

ということで

topo についてこんがらがってた頭の中を整理した。1ヶ月後に覚えてる自信が全くないので、書き残しておいた。

簡単なコードを使って動作を確認しておいた↓

https://github.com/bufferings/turbo-topo

おもしろかった!