Azure Durable Functions (TypeScript) で遊んでみた

# はじめに

Azure Functions はちょくちょく使っているのですが、先日開催された [Microsoft のウェビナー](https://twitter.com/msdevjp/status/1615627563770023936?s=20)の中で、Durable Functions について触れられていて、今更ながら気になったので触ってみました。
ということで、習うより慣れろ！の精神でやっていきます。

# Durable Functions って？

詳しい説明は Microsoft の公式ドキュメントにお任せするとして、ざっくり言うと通常のFunctionだとタイムアウトしてしまうような長時間のタスクや、関数の直列・並列実行等のフロー制御、ステート管理、リトライ制御などが簡潔に実装できる拡張機能だそうです。

https://learn.microsoft.com/ja-jp/azure/azure-functions/durable/durable-functions-overview?tabs=csharp-inproc

## Durable Functions を構成する関数

Drable Functions は以下の3つの役割を持つ関数で構成されます。

### 1. オーケストレーター関数

アクティビティ関数の実行を制御する関数です。
オーケストレーター関数の中から更に別のオーケストレーター関数を呼び出すこともできます。

### 2. アクティビティ関数

実際のタスクを実行する独立した関数です。
オーケストレーター関数で呼び出しを制御されます。

### 3. クライアント関数

処理の起点となる関数です。
オーケストレーター関数を呼び出します。

# Durable Functions を触ってみよう！

## 準備が必要なもの

- VSCode に[Azure Functions の拡張機能](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions)をインストールしておく
- [Azure Storage Explorer](https://azure.microsoft.com/ja-jp/products/storage/storage-explorer/) をインストールしておく
- [Azure Cosmos DB アカウント](https://learn.microsoft.com/ja-jp/azure/cosmos-db/how-to-manage-database-account)を作っておく
- Node.js をインストールしておく(執筆時のバージョンは `v18.14.0`)

## 何をつくるか

pokéAPI を使ってポケモンの名前と画像を取ってきて、Azure Storageに格納する
という1つの関数でも作れそうな至ってシンプルな処理を Durable Functions を使って無駄に大げさに作ってみようと思います。

https://pokeapi.co/

(いつの間にか GraphQL 版が出ていた！)

## 全体の構成

どんな感じに大げさかというと、こんな感じです。
![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/ec027c10-f527-d44f-c107-c6ffbc7f4302.png)

1. Cosmos DB のレコードが更新されると、Change Feed をトリガーしているクライアント関数(`cosmosdbStarter`)が起動する
1. `cosmosdbStarter` がオーケストレーター関数(`orchestrator`)を呼び出す
1. `orchestrator` が2つの関数を呼び出す(並列実行)
    1. pokéAPI(GraphQL)からポケモンの名前を取得するアクティビティ関数(`getPokeName`)
    1. ポケモンの画像データを取得するためのオーケストレーター関数(`subOrchestrator`)
        - `subOrchestrator` が2つの関数を呼び出して結果を返す(直列実行)
            1. pokéAPI(REST)からポケモンの画像URLを取得するアクティビティ関数(`getPokeImageUrl`)
            1. `getPokeImageUrl` で取得した URL から画像データを取得するアクティビティ関数(`getImageData`)
1. `orchestrator` が 2つの関数(`getPokeName`、`subOrchestrator`) の完了を待って、Azure Blob Storage に画像ファイルを保存するアクティビティ関数(`outputResult`)を呼び出す

## 準備

### データベース作成

Cosmos DB にデータベースとコンテナを作成します。

- データベース名
    - `durable-func-demo`
- コンテナ名
    - `input` (パーティションキー：`/id`)


## 実装！

関数の雛形は VSCode の Azure Functions 拡張機能でサクッと作れます。
以下のチュートリアルを参考に、関数プロジェクト、関数の作成を行います。
※この記事では TypeScript で実装しています

https://learn.microsoft.com/ja-jp/azure/azure-functions/durable/quickstart-js-vscode

### 作成する関数

以下の関数たちを作成していきます。

|No.| 関数名          | テンプレート                   | 説明                                                                     | 
|:---:| --------------- | ------------------------------ | ------------------------------------------------------------------------ | 
|1| orchestrator    | Durable Functions orchestrator | クライアント関数から呼び出されるオーケストレーター関数                   | 
|2| subOrchestrator | Durable Functions orchestrator | `orchestrator` から呼び出されるオーケストレーター関数                    | 
|3| getPokeName     | Durable Functions activity     | pokéAPI(GraphQL)からポケモンの名前を取得するアクティビティ関数           | 
|4| getPokeImageUrl    | Durable Functions activity     | pokéAPI(REST)からポケモンの画像URLを取得するアクティビティ関数           |
|5| getImageData    | Durable Functions activity     | `getPokeImageUrl` で取得した URL から画像データを取得するアクティビティ関数 |
|6| outputResult    | Durable Functions activity     | Azure Blob Storage に取得した画像を保存するアクティビティ関数                             | 
|7| cosmosdbStarter | Azure Cosmos DB trigger        | Change Feed をトリガーして `orchestrator` を呼び出すクライアント関数     | 

※最初の orchestrator 関数を作成する際に使用するストレージを聞かれるので、今回は「Azure Storage」を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/5aed8fde-4f2e-e090-9cd4-99f4383ca980.png)
Durable Functions では実行状態を保存するためにストレージが使用されます。

https://learn.microsoft.com/ja-jp/azure/azure-functions/durable/durable-functions-storage-providers

### 必要なパッケージのインストール

VSCode の拡張機能で関数プロジェクトを作成すると、最低限必要な依存関係を含んだ `package.json` が自動的に作成されているはずなので、追加で必要となるパッケージをインストールします。

```bash:bash
npm i --save pokenode-ts graphql @apollo/client
npm i -D azurite  # Azure Storage のエミュレーター
```

### 設定ファイルの編集

`package.json` と同じく `local.settings.json` という設定ファイルが作成されているはずなので、Cosmos DB と Azure Storage の接続情報を設定します。

```diff_json:local.settings.json
{
  "IsEncrypted": false,
  "Values": {
-    "AzureWebJobsStorage": "",
+    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "node",
+    "CosmosDBConnection": "<replace with your cosmos db connection string>"
  }
}
```

Azure Storage のエミュレーターを使用するので、 `"AzureWebJobsStorage": "UseDevelopmentStorage=true"` を指定します。
Cosmos DB の接続先は自身の環境に合わせて書き換えてください。
※Cosmos DB も[エミュレーター](https://learn.microsoft.com/ja-jp/azure/cosmos-db/local-emulator?tabs=ssl-netstd21)を使用したかったのですが、自己署名証明書関連のエラーで入力バインドが使えなかったため、断念

### 7. cosmosdbStarter (クライアント関数)

※実際の処理の流れに沿った方がわかりやすいと思い、上記の表とは並び順を変えています

```typescript:cosmosdbStarter/index.ts
import * as df from 'durable-functions';
import { AzureFunction, Context } from '@azure/functions';
import { InputItem } from '../lib/types';

/**
 * Durable Functions のクライアント関数
 * Cosmos DB のChange Feedをトリガーとして起動する
 */
const cosmosDBTrigger: AzureFunction = async function (
  context: Context,
  items?: InputItem[]
): Promise<void> {
  const item = items?.[0];
  if (!item) return;

  const client = df.getClient(context);
  // orchestrator を呼び出し
  await client.startNew(
    'orchestrator',
    undefined, // instanceId
    item
  );
};

export default cosmosDBTrigger;
```

<details>
<summary>function.json</summary>
<div>

```json:cosmosdbStarter/function.json
{
  "bindings": [
    {
      "type": "cosmosDBTrigger",
      "name": "items",
      "direction": "in",
      "leaseCollectionName": "leases",
      "connectionStringSetting": "CosmosDBConnection",
      "databaseName": "durable-func-demo",
      "collectionName": "input",
      "createLeaseCollectionIfNotExists": true
    },
    {
      "name": "starter",
      "type": "orchestrationClient",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/cosmosdbStarter/index.js"
}

```

</div>
</details>

### 1. orchestrator (オーケストレーター関数)

※リトライオプションとかありますが、後で触れるのでここでは一旦気にしないでください

```typescript:orchestrator/index.ts
import * as df from 'durable-functions';
import { InputItem, Result } from '../lib/types';

const orchestrator = df.orchestrator(function* (context) {
  const item = context.df.getInput<InputItem>();

  // 1.ポケモンの名前を取得
  context.df.callSubOrchestratorWithRetry;
  const getPokeNameTask = context.df.callActivityWithRetry(
    'getPokeName',
    retryOption,
    item
  );

  // 2.Sub Orchestrator 呼び出し
  //   2-1.ポケモンのイメージURLを取得
  //   2-2.URLから画像データ取得(base64)
  const getPokeImageTask = context.df.callSubOrchestrator(
    'subOrchestrator',
    item,
    `${context.df.instanceId}:sub`
  );

  // 1 と 2 を並列呼び出し
  yield context.df.Task.all([getPokeNameTask, getPokeImageTask]);

  // 1 と 2 の結果をまとめて...
  const result: Result = {
    no: item.no,
    name: getPokeNameTask.result as string,
    imageData: getPokeImageTask.result as string,
  };

  // 3.Blob Storage にファイルを保存
  yield context.df.callActivity('outputResult', result);

  context.log('Finished!!');
});

// リトライオプション
const retryOption = new df.RetryOptions(
  // １回目の再試行間隔
  5000,
  // 最大試行回数
  3
);

export default orchestrator;
```

```json:orchestrator/function.json
{
  "bindings": [
    {
      "name": "context",
      "type": "orchestrationTrigger",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/orchestrator/index.js"
}
```

### 3. getPokeName (アクティビティ関数)

```typescript:getPokeName/index.ts
import { AzureFunction, Context } from '@azure/functions';
import { getPokemonSpeciesName } from '../lib/poke-api/graphql';
import { InputItem } from '../lib/types';

/** ポケモンの名前取得 */
const getPokeName: AzureFunction = async function (
  context: Context,
  item?: InputItem
): Promise<string | null> {
  if (!item) throw Error('Invalid input.');

  const pokemonSpeciesName = await getPokemonSpeciesName(item.no);

  return pokemonSpeciesName?.name ?? null;
};

export default getPokeName;
```

```json:getPokeName/function.json
{
  "bindings": [
    {
      "name": "item",
      "type": "activityTrigger",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/getPokeName/index.js"
}
```

### 2. subOrchestrator (オーケストレーター関数)

```typescript:subOrchestrator/index.ts
import * as df from 'durable-functions';
import { InputItem } from '../lib/types';

/** 2つの関数を直列で呼び出し */
const orchestrator = df.orchestrator(function* (context) {
  const item = context.df.getInput<InputItem>();

  // 1.ポケモンのイメージURLを取得
  const url = yield context.df.callActivity('getPokeImageUrl', item);

  // 2.URLから画像データ取得(base64)
  const imageData = yield context.df.callActivity('getImageData', url);

  return imageData;
});

export default orchestrator;
```

<details>
<summary>function.json</summary>
<div>

```json:subOrchestrator/function.json
{
  "bindings": [
    {
      "name": "context",
      "type": "orchestrationTrigger",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/subOrchestrator/index.js"
}
```

</div>
</details>

### 4. getPokeImageUrl (アクティビティ関数)

<details>
<summary>index.ts / function.json</summary>
<div>

```typescript:getPokeImageUrl/index.ts
import { AzureFunction, Context } from '@azure/functions';
import { getPokemonSprites } from '../lib/poke-api/rest';
import { InputItem } from '../lib/types';

/** ポケモンのイメージURL取得 */
const getPokeImageUrl: AzureFunction = async function (
  context: Context,
  item?: InputItem
): Promise<string | null> {
  if (!item) throw Error('Invalid input.');

  const sprites = await getPokemonSprites(item.no);

  return (
    sprites.other?.['official-artwork'].front_default ?? sprites.front_default
  );
};

export default getPokeImageUrl;
```

```json:getPokeImageUrl/function.json
{
  "bindings": [
    {
      "name": "item",
      "type": "activityTrigger",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/getPokeImageUrl/index.js"
}
```

</div>
</details>

### 5. getImageData (アクティビティ関数)

バイナリデータ(`ArrayBuffer`) のままだと呼び出し元で復元できなかった(保存されない？)ので base64 文字列に変換して返しています。

<details>
<summary>index.ts / function.json</summary>
<div>

```typescript:getImageData/index.ts
import { AzureFunction, Context } from '@azure/functions';
import { fetchImageAsBase64String } from '../lib/utils/image';

const activityFunction: AzureFunction = async function (
  context: Context,
  url?: string
): Promise<string> {
  if (!url) throw Error('Invalid input.');

  // base64 文字列に変換して返す
  const imageData = await fetchImageAsBase64String(url);

  return imageData;
};

export default activityFunction;
```

```json:getImageData/function.json
{
  "bindings": [
    {
      "name": "url",
      "type": "activityTrigger",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/getImageData/index.js"
}
```

</div>
</details>

### 6. outputResult (アクティビティ関数)

出力するファイルのパス(`path`)は、入力バインドのオブジェクトを参照して動的になるようにしています。
(`{プロパティ名}` で入力バインドで受け取ったオブジェクトのプロパティを参照できる)

<details>
<summary>index.ts / function.json</summary>
<div>

```typescript:outputResult/index.ts
import { AzureFunction, Context } from '@azure/functions';
import { Result } from '../lib/types';

const activityFunction: AzureFunction = async function (
  context: Context,
  result?: Result
): Promise<void> {
  if (!result) throw Error('Invalid input.');

  // base64 文字列をバイナリに変換
  context.bindings.outputBlob = Buffer.from(result.imageData, 'base64');
};

export default activityFunction;
```

```json:outputResult/function.json
{
  "bindings": [
    {
      "name": "result",
      "type": "activityTrigger",
      "direction": "in"
    },
    {
      "name": "outputBlob",
      "type": "blob",
      "dataType": "binary",
      "path": "output/{no}_{name}.png",
      "connection": "AzureWebJobsStorage",
      "direction": "out"
    }
  ],
  "scriptFile": "../dist/outputResult/index.js"
}
```

</div>
</details>

### その他細かいロジック

<details>
<summary>その他</summary>
<div>

#### type定義
```typescript:lib/types.ts
/** Cosmos DB の入力用スキーマ */
type InputItem = {
  id: string;
  no: number;
};

/** 出力結果 */
type Result = {
  no: number;
  name: string;
  imageData: string;
};

export { InputItem, Result };
```

#### PokéAPIのGraphQLクライアント
```typescript:lib/poke-api/graphql.ts
import 'isomorphic-fetch';
import {
  ApolloClient,
  gql,
  InMemoryCache,
  QueryOptions,
} from '@apollo/client/core';

type GraphQLQueryResultType = {
  pokemonSpeciesName: {
    pokemon_v2_pokemonspeciesname: PokemonSpeciesName[];
  };
};

type PokemonSpeciesName = {
  name: string;
};

const apolloClient = new ApolloClient({
  uri: 'https://beta.pokeapi.co/graphql/v1beta',
  headers: {
    'X-Method-Used': 'graphql',
  },
  cache: new InMemoryCache(),
});

const query = async <T>(options: QueryOptions): Promise<T> => {
  const { data, errors, error } = await apolloClient.query<T>(options);

  if (errors || errors) throw Error(JSON.stringify({ error, errors }));

  return data;
};

/** ポケモンの名前を取得 */
const getPokemonSpeciesName = async (
  pokemonId: number
): Promise<PokemonSpeciesName | null> => {
  const data = await query<GraphQLQueryResultType['pokemonSpeciesName']>({
    query: gql`
      query GetPokemonSpeciesName(
        $where: pokemon_v2_pokemonspeciesname_bool_exp
      ) {
        pokemon_v2_pokemonspeciesname(where: $where) {
          name
        }
      }
    `,
    variables: {
      where: {
        // 図鑑No.
        pokemon_species_id: {
          _eq: pokemonId,
        },
        // 言語
        language_id: {
          _eq: 11, // 日本語
        },
      },
    },
  });

  return data.pokemon_v2_pokemonspeciesname[0] ?? null;
};

export { getPokemonSpeciesName };
```

#### PokéAPIのRESTクライアント
```typescript:lib/poke-api/rest.ts
import { PokemonClient, PokemonSprites } from 'pokenode-ts';

const api = new PokemonClient();

/** ポケモンの画像取得 */
const getPokemonSprites = async (
  pokemonId: number
): Promise<PokemonSprites> => {
  const pokemon = await api.getPokemonById(pokemonId);
  return pokemon.sprites;
};

export { getPokemonSprites };
```

#### URL の画像データをbase64文字列で取得
```typescript:lib/utils/image.ts
/** URLから画像データを取得し、base64文字列に変換 */
const fetchImageAsBase64String = async (url: string) => {
  const response = await fetch(url);
  const blob = await response.blob();
  const buffer = await blob.arrayBuffer();
  const base64String = Buffer.from(buffer).toString('base64');
  return base64String;
};

export { fetchImageAsBase64String };
```

</div>
</details>

### 最終的なディレクトリ構成

<details>
<summary>ディレクトリ構成</summary>
<div>

```text:ディレクトリ構成
.
├── cosmosdbStarter
│   ├── function.json
│   └── index.ts
├── getImageData
│   ├── function.json
│   └── index.ts
├── getPokeImageUrl
│   ├── function.json
│   └── index.ts
├── getPokeName
│   ├── function.json
│   └── index.ts
├── host.json
├── lib
│   ├── poke-api
│   │   ├── graphql.ts
│   │   └── rest.ts
│   ├── types.ts
│   └── utils
│       └── image.ts
├── local.settings.json
├── orchestrator
│   ├── function.json
│   └── index.ts
├── outputResult
│   ├── function.json
│   └── index.ts
├── package-lock.json
├── package.json
├── subOrchestrator
│   ├── function.json
│   └── index.ts
└── tsconfig.json
```

</div>
</details>

## 動かしてみる

### azurite の起動

azurite (Azure Storage のエミュレーター) を起動しておきます。

```bash:bash
# -l はワークスペースのパス(デフォルトはカレントディレクトリ)
npx azurite -l azurite
```

ターミナルにこんな感じで表示されていればOKです。

```bash:bash
Azurite Blob service is starting at http://127.0.0.1:10000
Azurite Blob service is successfully listening at http://127.0.0.1:10000
Azurite Queue service is starting at http://127.0.0.1:10001
Azurite Queue service is successfully listening at http://127.0.0.1:10001
Azurite Table service is starting at http://127.0.0.1:10002
Azurite Table service is successfully listening at http://127.0.0.1:10002
```

実行すると `azurite` というディレクトリが出来上がります。
(ディレクトリ名は何でもOK)


### Functions の起動

新しいターミナルを開いて、コマンド実行。

```bash:bash
npm start
```

ターミナルに作成した関数が表示されれば準備OKです。

```bash:bash
Functions:

        cosmosdbStarter: cosmosDBTrigger

        getImageData: activityTrigger

        getPokeImageUrl: activityTrigger

        getPokeName: activityTrigger

        orchestrator: orchestrationTrigger

        outputResult: activityTrigger

        subOrchestrator: orchestrationTrigger
```

### Cosmos DB にデータを登録

Cosmos DB データエクスプローラーから `input` コンテナにデータを登録します（手動で）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/0c534e0e-ab8a-10fa-1370-bd4b9f1664d2.png)
"no" に該当する図鑑No.のポケモンデータを取得します。

データを保存すると関数が動いて、コンソールにログが表示されました。

```bash:bash
[2023-03-03T14:44:38.087Z] Executing 'Functions.cosmosdbStarter' (Reason='New changes on collection input at 2023-03-03T14:44:38.0623085Z', Id=d1634844-c256-4cb0-bca4-63559ccfcfd8)
[2023-03-03T14:44:38.270Z] Executed 'Functions.cosmosdbStarter' (Succeeded, Id=d1634844-c256-4cb0-bca4-63559ccfcfd8, Duration=200ms)
[2023-03-03T14:44:38.319Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=7b689763-93a8-4d31-ae72-af1a13aca821)
[2023-03-03T14:44:38.369Z] Executed 'Functions.orchestrator' (Succeeded, Id=7b689763-93a8-4d31-ae72-af1a13aca821, Duration=54ms)
[2023-03-03T14:44:38.399Z] Executing 'Functions.subOrchestrator' (Reason='(null)', Id=160eea64-12f3-4c29-9c2a-f90b641b27f9)
[2023-03-03T14:44:38.408Z] Executing 'Functions.getPokeName' (Reason='(null)', Id=fa2b17ee-1fd9-4292-85ea-cc0715002a7b)
[2023-03-03T14:44:38.409Z] Executed 'Functions.subOrchestrator' (Succeeded, Id=160eea64-12f3-4c29-9c2a-f90b641b27f9, Duration=10ms)
[2023-03-03T14:44:38.420Z] Executing 'Functions.getPokeImageUrl' (Reason='(null)', Id=43495be2-68f3-4713-980b-85dd80e1005a)
[2023-03-03T14:44:38.598Z] Executed 'Functions.getPokeImageUrl' (Succeeded, Id=43495be2-68f3-4713-980b-85dd80e1005a, Duration=178ms)
[2023-03-03T14:44:38.629Z] Executing 'Functions.subOrchestrator' (Reason='(null)', Id=a29d284b-f32e-4687-bd8f-7a634e9ae44f)
[2023-03-03T14:44:38.639Z] Executed 'Functions.subOrchestrator' (Succeeded, Id=a29d284b-f32e-4687-bd8f-7a634e9ae44f, Duration=11ms)
[2023-03-03T14:44:38.651Z] Executing 'Functions.getImageData' (Reason='(null)', Id=712fd023-cfa0-4064-9524-3823af7cc055)
[2023-03-03T14:44:38.996Z] Executed 'Functions.getImageData' (Succeeded, Id=712fd023-cfa0-4064-9524-3823af7cc055, Duration=344ms)
[2023-03-03T14:44:39.064Z] Executing 'Functions.subOrchestrator' (Reason='(null)', Id=70af13e3-6d33-474c-ad5a-b5e6f398501c)
[2023-03-03T14:44:39.093Z] Executed 'Functions.subOrchestrator' (Succeeded, Id=70af13e3-6d33-474c-ad5a-b5e6f398501c, Duration=29ms)
[2023-03-03T14:44:39.145Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=3e8fd6d3-e7cb-4731-9630-dc02c4bc1921)
[2023-03-03T14:44:39.173Z] Executed 'Functions.orchestrator' (Succeeded, Id=3e8fd6d3-e7cb-4731-9630-dc02c4bc1921, Duration=32ms)
[2023-03-03T14:44:40.781Z] Executed 'Functions.getPokeName' (Succeeded, Id=fa2b17ee-1fd9-4292-85ea-cc0715002a7b, Duration=2375ms)
[2023-03-03T14:44:40.806Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=5bb2d58e-961c-4b30-8642-1c268d52accc)
[2023-03-03T14:44:40.824Z] Executed 'Functions.orchestrator' (Succeeded, Id=5bb2d58e-961c-4b30-8642-1c268d52accc, Duration=19ms)
[2023-03-03T14:44:40.869Z] Executing 'Functions.outputResult' (Reason='(null)', Id=8a1d3bee-a202-4964-bea2-83d0182336e0)
[2023-03-03T14:44:41.045Z] Executed 'Functions.outputResult' (Succeeded, Id=8a1d3bee-a202-4964-bea2-83d0182336e0, Duration=178ms)
[2023-03-03T14:44:41.062Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=07bf811a-d9ef-487e-b56f-7d9d1b369c65)
[2023-03-03T14:44:41.077Z] Finished!!
[2023-03-03T14:44:41.083Z] Executed 'Functions.orchestrator' (Succeeded, Id=07bf811a-d9ef-487e-b56f-7d9d1b369c65, Duration=21ms)
```

ログを見るとわかりますが、オーケストレーター関数(`orchestrator`、`subOrchestrator`)は起動してすぐに終了を何度か繰り返しています。
アクティビティ関数を呼び出すと一旦終了して、しかるべきタイミングで再度起動して続きの処理を実行...という動きになっています。(この辺りまだ理解が足りてない・・・)
この動きを「リプレイ」というのですが、従量課金だとリプレイ毎に課金される等注意すべきポイントがあるようです。

https://learn.microsoft.com/ja-jp/azure/azure-functions/durable/durable-functions-billing

では、Azure Storage Explorer で Blob Storage に画像が保存されているか見てみましょう。
(`ストレージアカウント` > `エミュレーター` > `Blob Container` を選択します)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/1d2ddc7a-7bd1-6f74-8213-69aaeaae96c6.png)
やったー！ちゃんと保存されていました。
ファイル名も `<図鑑No.>_<ポケモンの名前>.png` になっていますね。

## リトライ制御

`orchestrator` 関数のコードでちらっと出てきていますが、アクティビティ関数を呼び出す際、`callActivityWithRetry` という関数を使用すると、エラー時にリトライしてくれるようになります。
(この例では最大3回試行するようになっています)

また、今回は使いませんが`callSubOrchestratorWithRetry` という関数を使用するとオーケストレーターレベルでのリトライもできるようです。

```typescript:orchestrator/index.ts (一部抜粋)
const orchestrator = df.orchestrator(function* (context) {
  // 1.Function1_ポケモンの名前を取得
  const getPokeNameTask = context.df.callActivityWithRetry( // ←これ
    'getPokeName',
    retryOption,
    item
  );
});

// リトライオプション
const retryOption = new df.RetryOptions(
  // １回目の再試行間隔
  5000,
  // 最大試行回数
  3
);
```

このままでは特にエラーが発生しないので、無理やりエラーを起こして動きを確認してみます。

```diff_typescript:getPokeName/index.ts
/** ポケモンの名前取得 */
const getPokeName: AzureFunction = async function (
  context: Context,
  item?: InputItem
): Promise<string | null> {
  if (!item) throw Error('Invalid input.');

+  thirdTimeLucky();
  const pokemonSpeciesName = await getPokemonSpeciesName(item.no);

  return pokemonSpeciesName?.name ?? null;
};

+// 2回エラーを起こす
+let errorCount = 0;
+const thirdTimeLucky = () => {
+  if (errorCount++ < 2) throw Error(`エラー ${errorCount} 回目`);
+  errorCount = 0;
+};
```

Cosmos DB にデータを追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/eff10a9e-7e89-0dcf-cd5a-50fdad95424b.png)

今度は最初に2回エラーでリトライして、3度目の正直で成功して処理が完走しました。

```diff_shell:bash
[2023-03-03T14:56:13.841Z] Executing 'Functions.cosmosdbStarter' (Reason='New changes on collection input at 2023-03-03T14:56:13.8161202Z', Id=49a629d4-3a80-4c45-bbba-af0432300f53)
[2023-03-03T14:56:14.018Z] Executed 'Functions.cosmosdbStarter' (Succeeded, Id=49a629d4-3a80-4c45-bbba-af0432300f53, Duration=191ms)
[2023-03-03T14:56:14.060Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=6b1415bd-ad09-407d-b65e-7305465fb32f)
[2023-03-03T14:56:14.101Z] Executed 'Functions.orchestrator' (Succeeded, Id=6b1415bd-ad09-407d-b65e-7305465fb32f, Duration=44ms)
[2023-03-03T14:56:14.130Z] Executing 'Functions.subOrchestrator' (Reason='(null)', Id=b0c19ceb-2057-4671-9c26-5a89b6180355)
[2023-03-03T14:56:14.142Z] Executed 'Functions.subOrchestrator' (Succeeded, Id=b0c19ceb-2057-4671-9c26-5a89b6180355, Duration=12ms)
+[2023-03-03T14:56:14.146Z] Executing 'Functions.getPokeName' (Reason='(null)', Id=42d25139-7446-46c7-9dda-8f6671beb6db)
[2023-03-03T14:56:14.159Z] Executing 'Functions.getPokeImageUrl' (Reason='(null)', Id=810f3844-8e20-4030-bceb-2cf40c22b99e)
+[2023-03-03T14:56:14.172Z] Executed 'Functions.getPokeName' (Failed, Id=42d25139-7446-46c7-9dda-8f6671beb6db, Duration=27ms)
+[2023-03-03T14:56:14.172Z] System.Private.CoreLib: Exception while executing function: Functions.getPokeName. System.Private.CoreLib: Result: Failure
+[2023-03-03T14:56:14.172Z] Exception: エラー 1 回目
(・・・中略・・・)
[2023-03-03T14:56:14.230Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=d02ac3e7-513f-419a-b5e7-f6525dc3bee2)
[2023-03-03T14:56:14.254Z] Executed 'Functions.orchestrator' (Succeeded, Id=d02ac3e7-513f-419a-b5e7-f6525dc3bee2, Duration=26ms)
[2023-03-03T14:56:14.319Z] Executed 'Functions.getPokeImageUrl' (Succeeded, Id=810f3844-8e20-4030-bceb-2cf40c22b99e, Duration=160ms)
[2023-03-03T14:56:14.337Z] Executing 'Functions.subOrchestrator' (Reason='(null)', Id=81c76bd5-55f5-4531-a6a8-d2dcafbb4448)
[2023-03-03T14:56:14.342Z] Executed 'Functions.subOrchestrator' (Succeeded, Id=81c76bd5-55f5-4531-a6a8-d2dcafbb4448, Duration=4ms)
[2023-03-03T14:56:14.360Z] Executing 'Functions.getImageData' (Reason='(null)', Id=c35b70c7-7117-4f3a-9f30-6d57aa970d84)
[2023-03-03T14:56:14.683Z] Executed 'Functions.getImageData' (Succeeded, Id=c35b70c7-7117-4f3a-9f30-6d57aa970d84, Duration=323ms)
[2023-03-03T14:56:14.751Z] Executing 'Functions.subOrchestrator' (Reason='(null)', Id=01d2b9c3-7c81-465e-9a1e-40b4d9cfcf62)
[2023-03-03T14:56:14.785Z] Executed 'Functions.subOrchestrator' (Succeeded, Id=01d2b9c3-7c81-465e-9a1e-40b4d9cfcf62, Duration=35ms)
[2023-03-03T14:56:14.839Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=cbb1f0e9-5a18-4b4f-b167-dfd51b293037)
[2023-03-03T14:56:14.851Z] Executed 'Functions.orchestrator' (Succeeded, Id=cbb1f0e9-5a18-4b4f-b167-dfd51b293037, Duration=15ms)
[2023-03-03T14:56:18.499Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=10f54ef5-6d34-4c87-bcce-88ccb3cc3a40)
[2023-03-03T14:56:18.510Z] Executed 'Functions.orchestrator' (Succeeded, Id=10f54ef5-6d34-4c87-bcce-88ccb3cc3a40, Duration=11ms)
+[2023-03-03T14:56:18.521Z] Executing 'Functions.getPokeName' (Reason='(null)', Id=a18ca7fe-b8dc-4cd7-b5c5-73caf7fa2661)
+[2023-03-03T14:56:18.524Z] Executed 'Functions.getPokeName' (Failed, Id=a18ca7fe-b8dc-4cd7-b5c5-73caf7fa2661, Duration=3ms)
+[2023-03-03T14:56:18.524Z] System.Private.CoreLib: Exception while executing function: Functions.getPokeName. System.Private.CoreLib: Result: Failure
+[2023-03-03T14:56:18.524Z] Exception: エラー 2 回目
(・・・中略・・・)
[2023-03-03T14:56:18.559Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=602334a9-c468-4cce-b089-fbf7277d29e0)
[2023-03-03T14:56:18.584Z] Executed 'Functions.orchestrator' (Succeeded, Id=602334a9-c468-4cce-b089-fbf7277d29e0, Duration=25ms)
[2023-03-03T14:56:25.242Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=8b36e313-f152-417d-9d6a-7392e6342efa)
[2023-03-03T14:56:25.253Z] Executed 'Functions.orchestrator' (Succeeded, Id=8b36e313-f152-417d-9d6a-7392e6342efa, Duration=10ms)
+[2023-03-03T14:56:25.268Z] Executing 'Functions.getPokeName' (Reason='(null)', Id=54600c9a-2679-4e3c-b582-ff0fb6aeea0e)
+[2023-03-03T14:56:26.858Z] Executed 'Functions.getPokeName' (Succeeded, Id=54600c9a-2679-4e3c-b582-ff0fb6aeea0e, Duration=1590ms)
[2023-03-03T14:56:26.883Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=a9553d70-c07b-446e-a6fc-eda68e99ff1f)
[2023-03-03T14:56:26.903Z] Executed 'Functions.orchestrator' (Succeeded, Id=a9553d70-c07b-446e-a6fc-eda68e99ff1f, Duration=21ms)
[2023-03-03T14:56:26.934Z] Executing 'Functions.outputResult' (Reason='(null)', Id=ae8ea978-23ab-4f87-bb83-2a62a039ae0f)
[2023-03-03T14:56:27.121Z] Executed 'Functions.outputResult' (Succeeded, Id=ae8ea978-23ab-4f87-bb83-2a62a039ae0f, Duration=188ms)
[2023-03-03T14:56:27.141Z] Executing 'Functions.orchestrator' (Reason='(null)', Id=2c1ee745-8aab-4761-bc09-c80afd0bc1d9)
[2023-03-03T14:56:27.154Z] Finished!!
[2023-03-03T14:56:27.158Z] Executed 'Functions.orchestrator' (Succeeded, Id=2c1ee745-8aab-4761-bc09-c80afd0bc1d9, Duration=17ms)
```

ファイルも保存されています！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/d7ba150e-8121-49f6-e38d-137197c166d7.png)

# まとめ

Durable Functions を使用することで、普通の TypeScript の関数を呼び出す感覚で別の Functions を呼び出すことができるし、実行フローの制御も簡単にできてしまうので、非常に便利だと思いました。
まだ実務では Durable Functions が必要となるような複雑なアーキテクチャは扱ったことがないですが、今のうちから色々試しながら理解を深めていきたいと思います！