tl;dr
Azure Durable FunctionsのOrchestrator関数をJSで実装するときはGenerator関数の文法で書くけど、厳密には本物のGeneratorではなく、Durable Task Frameworkによってエミュレートされた独特な挙動をする。
- JSのGenerator
- yieldのたびに関数が一時停止
- 動作再開時は前回のyield直後から動く
- Azure Orchestrator関数のJS実装
- yieldのたびに関数が一時停止
- 動作再開時は関数の先頭から再度動く
- 実行済みのyield(Activity関数)は再実行されずに前回の出力値が使われる
背景
Azure FunctionsをJavaScript/TypeScriptで書いている方はとても多いと思います。その中の一部の人は、僕と同じように以下のようなルートを辿るのではないでしょうか。
- ちょっとヘビーなFunctionが出来上がった
- HTTPトリガでFunctionの終了を待つのが長過ぎるので、非同期呼び出ししたくなる
- 調べてみるとDurable Functionsで出来るらしい。AWSのStep Functionsみたいなものかな?
- Client関数、Orchestrator関数、Activity関数の3組で動かすのが基本らしい
- Orchestrator関数はGenerator関数で実装する必要があるのか。。Generatorの存在くらいは知ってたけど、実用するのは初めてだ。。。
- まずJSのGeneratorの基本をおさらいしよう。yieldのたびに関数が一時停止して、イテレータを回すたびに前回のyield直後から動作が再開するのか。完全に理解した!
- Durable Functionsを書いてみたので動かす
- Orchestrator関数がyieldのたびに何回も先頭から動くぞ!なんじゃこりゃー!!!
似たような経験をした方は、以下を読み進めるとそれなりに理由が分かると思います。
公式ドキュメントの記述
オーケストレーター関数は持続性と信頼性を備えています。 関数で "await" または "yield" が実行されると、実行の進行状況に自動的にチェックポイントが設定されます。 プロセスがリサイクルされるときや VM が再起動されるときに、ローカルの状態が失われることがありません。
タスクの完了を待機した後に関数が再開されるたびに、Durable Task Framework によってオーケストレーター関数が最初から再実行されます。 再実行のたびに、実行履歴が調べられて、現在の非同期タスクが完了しているかどうかが確認されます。 実行履歴にタスクが既に完了したことが示されている場合、フレームワークはそのタスクの出力を再生し、次のタスクに進みます。 実行履歴全体が再生されるまで、このプロセスが続けられます。 現在の実行履歴が再生されると、ローカル変数が以前の値に復元されます。
ちゃんとドキュメントには書いてありますね!
C#はじめ他の言語の事情は詳しくないのですが、おそらくJS以外の言語でも「一時停止後に関数の先頭から再実行される関数」というのは言語標準機能では存在しないんじゃないかなぁ。どの言語でもOrchestrator独自の動きなのだと思います。(違ったらごめん)
特にJSの場合はyieldってなかなか使わないので、yield初体験のタイミングで言語標準と違う動きをされると脳内が大混乱なわけですが、「Durable FunctionsのOrchestratorではこう動くのね」と割り切って使う必要があるのでしょう。
とはいえ「何故わざわざ言語の標準仕様と違う動きをさせているのか?」という点は気になる。そこの設計思想が腑に落ちて初めて、割り切って使えるってもんであります。
ChatGPTに解説させてみた
以下、ChatGPTに質問してみた回答をそのまんま貼り付けます。100%鵜呑みにしてはいけないと思いますが、それなりに納得できる回答でした。
yieldのたびに先頭から再実行される設計思想
Q. Azure Functionのオーケストレーション関数内でyieldを複数書くと、なぜ1つのyieldが終わるたびにオーケストレーション関数が最初から実行されるのですか?そのような仕様になっている理由・設計思想を解説してください。
Azure Functionsのオーケストレーション機能でyieldがあるたびにオーケストレーション関数が最初から実行されるように見える理由は、Durable Functionsの設計と実行モデルに基づいています。この仕組みは、Durable Task Frameworkの「再実行可能性」(replayability)という重要な設計原則に基づいています。
以下にその理由と設計思想を詳しく説明します:
1. Durable Functionsの実行モデル: 再実行と状態管理
Durable Functionsでは、オーケストレーション関数は「イベントソース」の状態に基づいて動作します。
• オーケストレーション関数は「決定論的」でなければならず、何度再実行されても同じ結果を出す必要があります。
• 各yield(もしくはTask.await)で外部の非同期操作(アクティビティ関数やタイマーなど)を呼び出します。その際、Durable Task Frameworkは「その時点での状態」を保存します。
ただし、オーケストレーション関数は継続的に実行されるわけではなく、 状態が保存されたタイミングから再実行(replay) されます。これにより、Durable Functionsは以下を実現しています:
1. 耐障害性:プロセスが停止しても、保存された状態から復元可能。
2. スケーラビリティ:オーケストレーション関数を中断し、他のリソースにスケジュール可能。
2. なぜ最初から再実行されるのか?
Durable Functionsの「状態保存」と「再実行」は、システムの再現性を保証するためです。
流れの例
• オーケストレーション関数内で1つ目のyieldまで到達します。
• この時点でDurable Task Frameworkが状態を保存。
• 外部の非同期操作(例: アクティビティ関数)が完了すると、次のyieldに進むためにオーケストレーション関数が再実行されます。
• 再実行時、関数は最初からスタートしますが、過去の進行状況(履歴)を再生して、各yieldの結果を再現します。
再実行設計の理由
• 決定論的な処理を保証する:
オーケストレーション関数が過去にどのように動作したかを履歴から再現し、状態を復元することで、すべての実行が一貫性を持つように設計されています。
• フェイルセーフな設計:
途中で障害が発生しても、再生可能な履歴を基に実行を再開できるため、耐障害性が向上します。
3. 再実行されるように設計された理由・思想
Durable Functionsがこのような動作を採用しているのは、クラウド環境の特性に適応し、高い信頼性とスケーラビリティを実現するためです。
a. 状態の永続化によるスケーラビリティ
Durable Functionsでは、オーケストレーション関数の実行状態を外部(Azure Storage)に保存します。この設計により、次のような利点があります:
• メモリに状態を保持しないため、スケールアウトが容易。
• 長時間実行されるプロセスにも対応可能。
b. 再現性と分散システムの一貫性
Durable Functionsは分散システム上で動作します。再現性を保証することで、次のような課題を解決します:
• 再実行可能なオーケストレーション関数によって、途中で失敗しても正しく復元可能。
• 決定論的な処理により、同じ入力に対して同じ出力を保証。
c. プログラミングモデルの簡素化
Durable Functionsは開発者に対して、イベント駆動型の分散アプリケーションを簡単に記述できるモデルを提供します。この仕組みを内部で処理することで、開発者は状態管理やエラーハンドリングを意識することなくオーケストレーションを記述できます。
4. 開発者としての考慮点
• 決定論的な設計:
オーケストレーション関数では、日時やランダム生成など非決定論的な操作を避ける必要があります。
• 副作用を避ける:
外部のリソース(例: データベース更新)への影響をオーケストレーション関数内で直接行わないようにし、アクティビティ関数に任せます。
まとめ
オーケストレーション関数がyieldごとに最初から実行されるのは、Durable Functionsの再現性、耐障害性、スケーラビリティを実現するための設計によるものです。この仕組みによって、複雑な分散システムにおいても信頼性の高いアプリケーションを構築できます。
Generatorの挙動をどんな仕組みで変えているのか?
Q. JSのgenerator関数を普通に作る場合は、generator#next()を呼び出すたびに、前回のyield終了直後から実行されます。その点でAzure Functionのオーケストレーション関数の挙動はgenerator関数の標準的な動きではなく、Node.jsランタイムに独自の拡張をしているように見えますが、解釈は合っていますか?
その解釈は ほぼ正しい です。ただし、厳密にはAzure FunctionsのDurable Functions(特にオーケストレーション関数)の仕組みは、Node.jsランタイム自体に拡張を加えているわけではなく、Durable Task Frameworkによる 高度な実行管理 によって実現されています。この実行管理は、JavaScriptの標準的なGenerator関数の動作を模倣しつつ、Durable Functionsの特性(状態永続化、再生可能性など)を実現するために独自の仕組みを取り入れています。
以下に詳しく説明します。
1. JavaScriptのGenerator関数の動作
標準的なGenerator関数の動作は次のようになっています:
• generator#next()を呼び出すと、前回のyield直後から再開される。
• 呼び出し元がGeneratorの制御を持つ間、その実行状態(ローカル変数や実行ポイントなど)はメモリ上に保持される。
これは完全に同期的な実行コンテキストの中で動作し、状態の永続化や分散環境での再実行といったクラウド特有の要件はありません。
2. Durable Functionsの動作とGenerator関数の違い
Durable Functionsのオーケストレーション関数は、見た目としてはJavaScriptのGenerator関数に似た構造(yieldやawaitを使用)を持っていますが、以下の点で異なります:
a. 状態の永続化
• Durable Functionsでは、yield(またはawait)ごとに実行状態(ローカル変数、現在の実行ポイントなど)がAzure Storageなどに永続化されます。
• 次に再実行されるとき、永続化された履歴(イベントソース)が再生され、過去の状態を再現します。
b. 決定論的な再実行
• オーケストレーション関数が中断されるとき、関数全体が停止し、後で再実行されます。このとき、関数は最初から再開されますが、過去のyieldやawaitの結果はDurable Task Frameworkが履歴を使って補完します。
• Generator関数ではnext()を呼び出すたびに前回の実行状態が保持されていますが、Durable Functionsではその保持をメモリではなく外部の永続ストレージで行います。
c. 非同期・分散環境での動作
• Durable Functionsは分散システムの特性に適応するよう設計されています。特に長時間の非同期タスクをサポートするため、オーケストレーション関数は中断→再生のプロセスを通じて進行します。
• Generator関数は主に同期的なシングルスレッド環境で動作し、Durable Functionsのような耐障害性や分散実行はサポートしていません。
3. Node.jsランタイムの拡張はない
Durable Functionsの挙動はNode.jsランタイム自体に直接の拡張を加えたものではありません。以下のように動作しています:
a. JavaScriptランタイムの上に独自の仕組みを構築
• Durable FunctionsのJavaScript実装(@azure/durable-functionsライブラリ)は、Node.jsのランタイムの標準的な機能(Promise、async/awaitなど)を使いつつ、Durable Task Frameworkのバックエンドで動作します。
• このライブラリが、オーケストレーション関数の「実行と再実行」の管理を行い、状態をAzure Storageに保存します。
b. エミュレーションによる実現
• Durable Functionsのオーケストレーション関数の中でyieldを使った制御構造は、Generator関数の挙動をエミュレートしていますが、実際にはライブラリレベルでのカスタム処理が組み込まれています。
• これにより、クラウド環境向けに必要な永続化や再実行の仕組みが追加されています。
4. Durable Functionsの設計におけるGenerator関数的なインスピレーション
Durable Functionsのオーケストレーション関数は、以下のようなインスピレーションを受けて設計されています:
• Generator関数やasync/awaitのフロー制御:
• 開発者が直感的に非同期タスクを記述できるようにするため、Generatorやasync/awaitの制御構造を模倣しています。
• イベントソース型アプローチ:
• クラウドネイティブな設計として、各実行ステップをログ(履歴)に保存し、それをもとに状態を復元するDurable Task Frameworkの概念を採用。
5. まとめ
Azure Functionsのオーケストレーション関数は、Node.jsランタイム自体を拡張しているわけではありません。その挙動は、Durable FunctionsのライブラリとバックエンドのDurable Task Frameworkが提供する仕組みによって実現されています。この仕組みは、JavaScriptのGenerator関数の概念を一部借用しつつも、クラウド環境特有の永続化、再実行、スケーラビリティを実現するために設計された独自のアプローチです。
結果として、見た目はGenerator関数に似ていますが、動作の背後にある仕組みは分散システム特化のものです。そのため、Node.jsの標準的なGenerator関数の動きとは異なる部分があるのです。
(ChatGPTによる解説はここまで)
おわりに
まだまだ知らないことが多く、勉強になって面白いですね!AWSとAzureを両方使いこなせる男になるべく日々精進いたします。
ChatGPTで概要を解説してもらってから、裏付けがてら公式ドキュメントをじっくり読む順番だと効率よく調べ物できると思いました。特にChatGPTさん、僕の仮説に対していきなり訂正するのではなく、まず太字で「ほぼ正しい」と書いてくれたのが人付き合いのセンスあるなぁと思いましたw
しかし自分で書いておいてなんですが、ChatGPTに聞けば分かることをQiitaに書くのはちょっとどうなのかなぁという気もするんですけどね、、、次にハマってググった人に役立つように技術記事を書くわけですが、そのうちみんなググらずにAIに聞くようになったらあんまり意味ないのかなーと思いつつ、一方でこの記事がさらにAIに読み込まれてAIでの検索精度が上がるのであれば、それはそれで価値はあるのかなーとも思ったり。このあたりは、向こう数年間手探りになりそうですな。
記事の内容に誤りあったら、ドシドシ指摘してくださいませ〜。
ではまた!