Durable Functionsのリプレイについて

みなさま、こんにちは。サイオステクノロジー武井です。今宵は、Durable Functionsのリプレイについて説明したいと思います。

Durable Functionsのリプレイ

Durable Functionsの大きな特徴の一つに「リプレイ」という機能があります。これはアクティビティ関数がコールされるごとに、その実行結果や戻り値を外部のストレージ(Azure Table Storage)に保存して、オーケストレーター関数をまた最初から起動するという仕組みです。

この仕組のおかげで、仮にDurable Functionsを実行しているサーバーが故障して、その処理を他のサーバーが引き継いだとしても、きちんと故障して中断された処理から再開されます。これがDurable Functionsが「Durable」と言われる由縁かと思います。

このあたりの詳細は以下のブログにて記載してありますので、ご覧いただけたらと思います。

多分わかりやすいDurable Functions 〜サーバーレスは次のステージへ〜【理論編】

ジェネレーター関数について

リプレイの解説のところで「アクティビティ関数を実行したら、オーケストレーター関数は終了して、また初めからやり直す」みたいな解説ありますよね。これ、なんだかよくイメージ湧きにくいのですが、ジェネレーター関数っていう仕組みをつかっています。

ジェネレーター関数の特徴は、yieldと記載したところで処理を一時中断することができます。

百聞は一見にしかずなので、まずかんたんなコードをお見せしたいと思います。

function* generator() {
    console.log("hoge");
    yield 1; // (1)
    console.log("fuga");
    yield 2; // (2)
    console.log("piyo");
    yield 3; // (3)
}
  
fn = generator(); // (4)
  
fn.next(); // (5) {value: 1, done: false}
fn.next(); // (6) {value: 2, done: false}
fn.next(); // (7) {value: 3, done: false}
fn.next(); // (8) {value: undefined, done: true}

 

ジェネレーター関数はfunction*のようにアスタリスクをつけます。このコードがどんなふうに動くのかを順を追って説明します。

  1. (4)にて、変数fnにジェネレータ関数generatorを代入する。
  2. (5)にて、nextメソッドを実行する。このときgenerator関数が起動する。
  3. generator関数内で(1)のyield 1まで実行され、この関数を抜ける。
  4. (5)のfn.next()の戻り値として、{value: 1, done: false}が返ってくる。valueはyieldの引数で指定された値(この場合は1)、doneはこのジェネレーター関数が最後まで起動するとtrue、そうでない場合はfalseになる。
  5. (6)にて、nextメソッドを実行する。このとき、再びgenerator関数が起動する。
  6. 先程処理を中断したyield 1の次の行(console.log(“fuga”);)から処理を再開する。
  7. generator関数内で(2)のyield 2が実行され、この関数を抜ける。
  8. (6)のfn.next()の戻り値として、{value: 2, done: false}が返ってくる。
  9. (7)にて、nextメソッドを実行する。このとき、再びgenerator関数が起動する。
  10. 先程処理を中断したyield 2の次の行(console.log(“piyo”);)から処理を再開する。
  11. generator関数内で(3)のyield 3が実行され、この関数を抜ける。
  12. (7)のfn.next()の戻り値として、{value: 3, done: false}が返ってくる。
  13. (8)にて、next()メソッドを実行すると、generator関数は最後まで起動したので、doneがtrueになる。

いかがでしょうか?つまり、ジェネレーター関数はnextメソッドで呼び出すたびにyiledが実行されて、ジェネレーター関数を抜けて、またnextメソッドで呼び出すと、中断したところから再開するという動きをしています。

Durable Functionsに当てはめてみる

このジェネレーター関数がDurable Functionsにどのように適用されるかといいますと、Durable Functionsもリプレイという機構を使って、アクティビティ関数が呼び出されるたびにオーケストレーター関数が一旦終了し、再び最初からオーケストレーター関数が起動するという動きをしていますよね。まさにこのジェネレーター関数そのものです。

このアクティビティ関数とオーケストレーター関数の動きを簡単なコードで再現してみました。

// オーケストレーター関数
function* orchestrator() {
    yield () => { return {"status": "completed", "result": "1"} } // 1つ目のアクティビティ関数;
    yield () => { return {"status": "completed", "result": "2"} } // 2つ目のアクティビティ関数;
    yield () => { return {"status": "executed", "result": "3"} } // 3つ目のアクティビティ関数;
    yield "" // 4つ目のアクティビティ関数;
    yield "" // 5つ目のアクティビティ関数;
}

while (true) {
    let finished = false;

    // アクティビティ関数を取得する。
    const fn = orchestrator();

    // 1つ目と2つ目のアクティビティ関数を起動する。
    let curr = fn.next();
    let next = fn.next();

    while (true) {
        if (next.done) {
            // 全てのアクティビティ関数が起動完了すると、
            // 次のアクティビティ関数(next)がdoneになるので、
            // オーケストレーター関数を終了する。
            finished = true;
            break;
        } else if (next.value().status == "executed") {
            // 次のアクティビティ関数(next)がexecuted(実行完了)の場合には、
            // もう一度最初からオーケストレーター関数を起動する。
            break;
        } else if (next.value().status == "completed") {
            // 次のアクティビティ関数(next)がcompleted(実行済み)の場合には、
            // さらに次の新たなアクティビティ関数を起動する。
            [curr, next] = [next, fn.next()];
        }
    }

    if (finished) break;
}

 

詳細はコメント見て頂ければと思いますが、orchestratorというジェネレータ関数の中のyieldの部分が、アクティビティ関数に相当します。アクティビティ関数は実際は、Azure Table Storageにその実行状態を問い合わせに行きまして、そのアクティビティ関数が実行済みか未実行かの状態を取得します。

ただ、今回はその変の処理は割愛しまして、実行済みだったら、status「completed」、初めての実行だったらstatus「executed」を返すようにしています。上記のアクティビティ関数であれば最初の2つは既に実行済みで、3つ目は初めて実行された状態を表しています。実際はこのあたりは動的に変化するわけですが、今回は動きのイメージを確認するだけなので固定値を返しています。

wihle(true){〜}の部分で、各アクティビティ関数の実行状態を見て、アクティビティ関数を最初から全て実行し直すか(リプレイするか)、もしくは次のアクティビティ関数を実行するかどうかを決めています。

アクティビティ関数の結果がcomletedだったら、次のアクティビティ関数を実行します。

そんな感じで次々とアクティビティ関数を実行していき、executedが返された場合、つまり初めて実行された場合、ジェネレータ関数であるorchestrator関数をもう一度最初から実行し直して、最初のyiledからやり直します。つまりリプレイですね。

全てのアクティビティ関数がcompletedになったら、wihle(true){〜}のループを抜けて、オーケストレーター関数終了となります。

内部的にほんとにこんな書き方しているかどうかはわかりません。もっと賢いロジックだとは思いますが、概ねこんなイメージかとは思います。

まぁ、つまりこんな感じでオーケストレーター関数は何度も何度も実行されるわけですね。

オーケストレーター関数は決定論的でなければならない

Durable Functionsのドキュメントを見ると、オーケストレーター関数は決定論的でなければならないとあります。英語でいうとdeterministicっていいます。以下のドキュメントに書いてあります。

https://docs.microsoft.com/ja-jp/azure/azure-functions/durable/durable-functions-code-constraints

 

難しい言葉ですね。普段日常で決定論的なんて絶対使いませんよね。

でも、これは先程ご説明したリプレイの仕組みを考えると、すっと腑に落ちるかなと思います。

オーケストレーター関数はリプレイという機構により何度も何度も実行されるので、常に同じ結果を出さなければいけないです。常に同じ結果を出すような実装の仕方を「決定論的」といいます。

例えば、先程のコードをちょっと変えて以下のようにしてみたいと思います。

const uuidv4 = require('uuid4');

// オーケストレーター関数
function* orchestrator() {
    // UUIDを生成する。
    const uuid = uuidv4();

    if (context.isReplay) console.log("リプレイされたよ");

    yield () => { return {"status": "completed", "result": uuid} } // 1つ目のアクティビティ関数;
    yield () => { return {"status": "completed", "result": uuid} } // 2つ目のアクティビティ関数;
    yield () => { return {"status": "executed", "result": uuid} } // 3つ目のアクティビティ関数;
    yield "" // 4つ目のアクティビティ関数;
    yield "" // 5つ目のアクティビティ関数;
}

// オーケストレーター関数の様々な状態を保持するcontext
let context = {
    "isReplay": false
}

while (true) {
    let finished = false;

    // アクティビティ関数を取得する。
    const fn = orchestrator();
    console.log("オーケストレーター関数開始");

    // 1つ目のアクティビティ関数を起動する。
    let curr = fn.next();

    // 1つ目のアクティビティ関数の結果を出力する。
    console.log(curr.value().result);

    // 2つ目のアクティビティ関数を起動する。
    let next = fn.next();

    // 2つ目のアクティビティ関数の結果を出力する。
    console.log(next.value().result);
 
    while (true) {
        if (next.done) {
            // 全てのアクティビティ関数が起動完了すると、
            // 次のアクティビティ関数(next)がdoneになるので、
            // オーケストレーター関数を終了する。
            finished = true;
            break;
        } else if (next.value().status == "executed") {
            // 次のアクティビティ関数(next)がexecuted(実行完了)の場合には、
            // もう一度最初からオーケストレーター関数を起動する。
            console.log(next.value().result);
            context.isReplay = true;
            console.log("オーケストレーター関数終了");
            break;
        } else if (next.value().status == "completed") {
            // 次のアクティビティ関数(next)がcompleted(実行済み)の場合には、
            // さらに次の新たなアクティビティ関数を起動する。
            [curr, next] = [next, fn.next()];
        }
    }

    if (finished) break;
}


 

先ほどとの違いは、ジェネレーター関数であるorchestrator関数の中でUUIDを生成しているところです。例えば、後続のアクティビティ関数がこのUUIDを使って何らかの処理を実行するとしましょう。

本来であれば、1つ目のアクティビティ関数も2つ目のアクティビティ関数も3つ目のアクティビティ関数も同じ値を返すことを期待してしまいます。

しかし、オーケストレーター関数は、先程説明したように何度も実行されます。つまり、上記コード中のorchestratorは何度も実行されるわけです。1回目のオーケストレーター関数実行時のアクティビティ関数の結果と、2回目のオーケストレーター関数実行時の結果は異なってしまいます。実際にこのコードを動かすと以下のような結果になります。

オーケストレーター関数開始
c19bca43-82f3-43e1-bf06-6700da954db0
c19bca43-82f3-43e1-bf06-6700da954db0
c19bca43-82f3-43e1-bf06-6700da954db0
オーケストレーター関数終了
オーケストレーター関数開始
リプレイされたよ
0e2c8c5e-ea34-4597-9c7d-c1640b2026fc
0e2c8c5e-ea34-4597-9c7d-c1640b2026fc
0e2c8c5e-ea34-4597-9c7d-c1640b2026fc
オーケストレーター関数終了

つまりこのコードは決定論的ではないということになります。

これを回避するには、UUIDを生成するための専用のアクティビティ関数を作成し、その実行結果をUUIDとして利用することになります。

まとめ

Durable Functionsのリプレイについて、わかりみ深く説明してみました。本記事が、ちょっとわかりにくいリプレイを理解するための一助となれば幸いにございます。

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です