previewはapplyのコールバックを近似しない、実行しない
このノート群では、Pulumiの Output.apply について「previewは原理的に近似になる」と繰り返し書いてきた。この言い方は結論としては正しいが、機序としては誤解を招く。近似という語は「同じプログラムを精度を落として評価した」と読ませるが、実際に起きているのはそれではない。
入力が未知のとき、previewはコールバックの中に一歩も入らない。評価されるのは精度の落ちたプログラムではなく、長さの違うプログラムである。
何が起きているか
apply(func) は func を即座に呼ばない。入力Outputの解決を待ってから呼ぶ。preview時、依存リソースがまだ作られていなければ入力は未知であり、SDKは func を呼ばずに「未知」を表すOutputをそのまま返す。
だから func の中に何を書いても — リソース生成でも、HTTP呼び出しでも、console.log でも — previewには一切現れない。表示から漏れるのではなく、そのコードに到達していない。
pulumi.all([...]).apply(...) の場合、既知性の集約は every である。入力が1つでも未知なら全体が未知になり、コールバックは飛ぶ。複数の値をまとめて待つほど、コールバックが走らない確率は上がる。
「近似」と言うと何を取り違えるか
近似という語には、元の対象と近似の間に誤差という連続量があり、努力すれば縮まるという含みがある。実際にはそうではない。
| 近似だとしたら | 実際 | |
|---|---|---|
| previewが見ているもの | 同じプログラムの粗い評価 | コールバックを含まない別の(短い)プログラム |
| 誤差の性質 | 量的 — 詰めれば縮む | 質的 — 実行していないので誤差以前 |
| 改善の方向 | 精度を上げる | 原理的に上げられない。走らせれば副作用が出る |
| ユーザーができること | 精度設定を上げる | コールバックに書かない、しかない |
previewが func を実行しないのは実装の手抜きではなく、そうするしかないからである。previewは世界を変えないことを約束している。func は任意のコードなので、実行すればその約束を破りうる。「実行しない」は約束を守るための唯一の選択肢であり、だから精度の問題として改善できない。
未知でなければ走る
ここは正確に書いておく。「previewではコールバックが走らない」は無条件ではない。条件は「入力が未知であること」であって「previewであること」ではない。
前回の up で既に作られたリソースの属性など、preview時点で値が判明していれば入力は既知であり、func はpreview中に実行される。公式ドキュメントが「applyの中で作られたリソースは、outputの値が既にわかっている場合を除きpulumi preview に現れない」と条件付きで書いているのはこのためである。
これは実務上やっかいな性質を生む。同じコードが、初回のpreviewでは走らず、2回目以降のpreviewでは走る。previewの副作用の有無がstateの履歴に依存する。
planをレビューするワークフローへの帰結
IaCではIRダンプが一級の製品機能であるに「成果物にプログラム片が残る設計では、IRダンプを見せてもそれは近似の提示にとどまる」と書いた。ここを言い換えると、提示されているのは近似ではなく、まだ実行されていないコードを除いた残りである。
レビュー可能性の観点ではこの差が効く。近似なら「誤差はあるが全体は見えている」だが、実際は「見えていない領域があり、その広さはコードを読まないと分からない」である。レビュアーが apply のコールバックを見つけたとき、planの表示からその中身について言えることは何もない。モノイドとモナドを分けるのは継続を持てるかどうかである
根拠の確度
sdk/nodejs/output.ts の applyHelperAsync(master cd514ac1d 時点でL490-523)で確認済み。判定は const doApply = isKnown || runWithUnknowns の一行で、偽なら func を呼ばずに { value: undefined, isKnown: false, isSecret } を返す。クラスのコメントが意図をそのまま書いている — 「previewでは値が未知でありうるので、.apply を実行したくない。コールバックがundefinedを想定していないかもしれないからだ」。
条件は isKnown であってpreviewフラグではない。 isKnown はエンジンから来た値をそのまま使うのではなく、コンストラクタ(L246-248)で containsUnknowns(val) をANDして再計算している。
runWithUnknowns は公開シグネチャに現れない内部の抜け穴で(apply<U>(func, runWithUnknowns?))、SDK内の唯一の呼び手はプロパティ持ち上げのProxy自身である。この経路ではコールバックに未知のセンチネル値が渡る。通常のユーザーコードからは効かないので、本文の「未知なら入らない」はユーザーから見た挙動としては正しい。
「applyの中でリソースを作るのは避けよ」の原文は applyページ の "Creating resources inside an apply should be avoided whenever possible" で、"unless the output's value is already known" という留保も原文にある。なお隣接して禁止と書かれているものが1つある — stack outputsをapplyの中で作ることはできない。「避けよ」と「できない」が併記されているので、混ぜないこと。
then が無いことの経緯も辿れた。Proxyの get トラップで if (prop === "then") return undefined としており(L301-306)、コメントは「他のシステムがPromiseかどうかを判定するのに使うので、そうだと示したくない」。これはプロパティ持ち上げのProxyを入れたPR #2510(2019-03)で同時に入っている。任意のプロパティ名を apply に持ち上げる仕組みなので、素通しすると then まで持ち上がってawaitが壊れる。持ち上げの副作用を塞ぐための処置であって、最初からモナド的規律のために置かれたものではない。
この確認の副産物として、未知値の規律と段階分離は三すくみであるの「toStringは例外を投げる」が誤りだと分かった。既定では投げず、エラー文言を文字列として返す。投げるのは utils.errorOutputString が設定されているときだけで、既定では `${output}` がエラーメッセージをそのまま埋め込む。同日、あちらの本文と精度ノートを直した。
行番号は以前記録したものから概ね+1ずれている。liftInnerOutput の520は呼び出し側で、定義はL461。