ラベル conductor の投稿を表示しています。 すべての投稿を表示
ラベル conductor の投稿を表示しています。 すべての投稿を表示

2018年6月18日月曜日

conductor のタスクの timeoutSeconds と responseTimeoutSeconds の挙動を確認してみた

概要

タスクにはタイムアウト機能が備わっているようで一定の時間タスクが完了にならない場合には自動的にタイムアウトのステータスにしてくれるようです
今回はその機能を試してみました
で、結果的にはデフォルトでは動作せる自分でコードを書き換えてあげることでタイムアウト機能が使えるようになりました
その辺りの対処方法を紹介します

環境

  • macOS 10.13.5
  • docker 18.03.1-ce
  • conductor 1.0.0
  • Python 3.6.5

事前準備

conductor サーバの準備および Python SDK が使えるようにしておいてください

timeoutSeconds を 60 に設定

公式 を見ると timeoutSeconds は指定の時間の間ステータスが IN_PROGRESS であった場合に TIMED_OUT のステータスに変更してくれる機能です

まずは timeoutSeconds を 60 に設定したタスクおよびそれを使うワークフローを登録します
また、作成したワークフローを開始するスクリプトと開始したワークフローをポーリングするスクリプトも作成します

  • タスク (sample1_task.json)
{
  "name": "Sample_1",
  "retryCount": 0,
  "timeoutSeconds": 60,
  "timeoutPolicy": "TIME_OUT_WF",
  "retryLogic": "FIXED",
  "retryDelaySeconds": 0,
  "responseTimeoutSeconds": 0
}
  • ワークフロー (sample1_wf.json)
{
  "name": "sample1",
  "description": "sample1 wf",
  "version": 1,
  "tasks": [
    {
      "name": "Sample_1",
      "taskReferenceName": "s1",
      "type": "SIMPLE",
      "start_delay": 0
    }
  ],
  "schemaVersion": 2
}

それぞれ conductor にインポートするには registerTaskDefs と createWorkflowDef メソッドを使ってください
使い方はこちらで紹介しています

ワークフローは以下のような感じで登録できれば OK です
conductor_timeout3.png

ワークフローまで登録できたらワークフローをスタートするスクリプトとポーリングするスクリプトを準備します

  • vim start_wf1.py
from conductor import conductor

def main():
    wfc = conductor.WorkflowClient('http://localhost:8080/api')
    wfName = 'sample1'
    workflowId = wfc.startWorkflow(wfName, {}, 1, None)
    print(workflowId)

if __name__ == '__main__':
    main()

スタートするワークフロー名を指定します

  • vim polling1.py
from conductor import conductor

def main():
    tc = conductor.TaskClient('http://localhost:8080/api')
    taskType = 'Sample_1'
    workerId = 'python_1'
    ret = tc.pollForTask(taskType, workerId)
    print(ret)

if __name__ == '__main__':
    main()

ポーリングするタスク名を指定します

responseTimeoutSeconds を 60 に設定

responseTimeoutSeconds は指定の時間ステータスに変化がない場合に再度ステータスを SCHEDULED に変更してくれる機能です

それぞれの定義ですが先ほどの timeoutResponse とほぼ同様です
違うのはタスク用の JSON だけです
あとは Sample_1 となっているところを Sample_2 などに変更して同様にインポートしましょう

以下のようにワークフローが作成されれば OK です
conductor_timeout5.png

動作確認

それぞれワークフローを開始して、タスクをポーリングしてみましょう
タスクの詳細が以下のような感じになります

  • timeoutSeconds 60

conductor_timeout1.png

  • responseTimeoutSeconds 60

conductor_timeout2.png

これの Last Polled/Updated の 60 秒後にタスクのステータスに何かしらの変化が起こるはずです

が、、、なんと何も起きません
待てど待てどステータスはずっと IN_PROGRESS のままで本来であれば TIMED_OUT などになるはずです

なぜか

どうやらデフォルトではタイムアウト機能は disable になっているようです (理由は全くわかりません)
タイムタウト機能を enable にするには直接コードを編集しないとダメそうです
https://github.com/Netflix/conductor/issues/362

で、やってみました

まずは ServiceModule.java を開きます

  • vim conductor/server/src/main/java/com/netflix/conductor/server/ServiceModule.java

まずヘッダ部分に import 文を 1 行追加します

import com.netflix.conductor.core.execution.WorkflowSweeper;

次に 103 行目あたり、configure() メソッドの最後の行に以下を追記します

bind(WorkflowSweeper.class).asEagerSingleton();

これで OK です
あとは再度ソースをビルドして、jar と war を作成しそこから docker のイメージを作成し docker-compose up すれば OK です
ソースのビルド方法はこちらで紹介しています

再度動作確認してみた

ソースを変更後に再度 timeoutSeconds を試してみたところ正常に動作しました
conductor_timeout4.png

メッセージを見るとぴったり 60 秒ではないようです
また単位はミリ秒で表示されるようです

ちなみに responseTimeoutSeconds はステータスは IN_PROGRESS ままだったのですが指定時間後に再度ポーリングすると取得できたのでうまく動作しているんだと思います

最後に

conductor の timeoutSeconds と responseTimeoutSeconds の挙動を試してみました
試してみたところデフォルトでは ON になっていないようで現状はソースコードを修正する必要があるようです
なぜデフォルトで OFF になっているかは不明です
現状 Pull Request もまだないようなので、出しちゃってもいいかなと思っています

2018年5月10日木曜日

conductor を使ってそれっぽいワークフロー処理を実装してみた

概要

conductor を使ってそれっぽいワークフローを作成してみました
ワークフローの代表的なユースケースである承認システムを想定しています
conductor のインストール方法や基本的な使い方はこれまでの記事を参考にしてください

環境

  • macOS 10.13.4
  • conductor 1.9.0
  • docker 18.04.0-ce
  • Python 3.6.4

必要なタスクを登録する

今回は 3 つのタスクを登録します
2 つは承認用のタスクで 1 つはエラー処理用のタスクとします

  • vim create_task.py
from conductor import conductor

def main():
    tc = conductor.MetadataClient('http://localhost:8080/api')
    listOfTaskDefObj = [
      {
        "createTime": 1525672041,
        "name": "approval_1",
        "description": "Low level approval",
        "retryCount": 1,
        "timeoutSeconds": 0,
        "timeoutPolicy": "TIME_OUT_WF",
        "retryLogic": "FIXED",
        "retryDelaySeconds": 60,
        "responseTimeoutSeconds": 3600
      },
      {
        "createTime": 1525672041,
        "name": "approval_2",
        "description": "High level approval",
        "retryCount": 1,
        "timeoutSeconds": 0,
        "timeoutPolicy": "TIME_OUT_WF",
        "retryLogic": "FIXED",
        "retryDelaySeconds": 60,
        "responseTimeoutSeconds": 3600
      },
      {
        "createTime": 1525672041,
        "name": "error_1",
        "description": "Your approval has been denied",
        "retryCount": 0,
        "timeoutSeconds": 0,
        "timeoutPolicy": "TIME_OUT_WF",
        "retryLogic": "FIXED",
        "retryDelaySeconds": 0,
        "responseTimeoutSeconds": 0
      }
    ]
    ret = tc.registerTaskDefs(listOfTaskDefObj)
    print(ret)

if __name__ == '__main__':
    main()

これをベースにワークフローを作成していきます
作成済みのタスクの一覧を取得するには tc.getAllTaskDefs() を使います
my_workflow1.png

my_workflow2.png

ワークフローを作成する

先ほど作成したタスクを用いてワークフローを作成します

  • vim create_workflow.py
from conductor import conductor

def main():
    tc = conductor.MetadataClient('http://localhost:8080/api')
    wfdObj = {
      "createTime": 1525673798,
      "name": "approval_workflow",
      "description": "Get the money throught the approvals",
      "version": 1,
      "tasks": [
        {
          "name": "approval_1",
          "taskReferenceName": "approval_1",
          "inputParameters": {
            "money": "${workflow.input.money}",
          },
          "type": "SIMPLE",
          "startDelay": 0
        },
        {
          "name": "check_1",
          "taskReferenceName": "check_1",
          "inputParameters": {
            "status": "${approval_1.output.status}"
          },
          "type": "DECISION",
          "caseValueParam": "status",
          "decisionCases": {
            "ng": [
              {
                "name": "error_1",
                "taskReferenceName": "error_1",
                "type": "SIMPLE",
                "startDelay": 0
              },
            ],
            "ok": [
              {
                "name": "approval_2",
                "taskReferenceName": "approval_2",
                "inputParameters": {
                  "money": "${workflow.input.money}",
                },
                "type": "SIMPLE",
                "startDelay": 0
              },
              {
                "name": "check_2",
                "taskReferenceName": "check_2",
                "inputParameters": {
                  "status": "${approval_2.output.status}"
                },
                "type": "DECISION",
                "caseValueParam": "status",
                "decisionCases": {
                  "ng": [
                    {
                      "name": "error_1",
                      "taskReferenceName": "error_1",
                      "type": "SIMPLE",
                      "startDelay": 0
                    },
                  ],
                  "ok": [
                    {
                      "name": "event_1",
                      "taskReferenceName": "event_1",
                      "type": "EVENT",
                      "startDelay": 0,
                      "sink": "conductor"
                    },
                  ]
                },
                "startDelay": 0
              }
            ]
          },
          "startDelay": 0
        }
      ],
      "outputParameters": {
        "your_got_the_money": "${approval_2.output.money}"
      },
      "schemaVersion": 2
    }
    ret = tc.createWorkflowDef(wfdObj)
    print(ret)

if __name__ == '__main__':
    main()

これを実行すると以下のワークフローが作成されます
my_workflow3.png

少し流れを解説します
全体の流れとしては 2 つの承認が両方共 OK ならば依頼した金額を得ることができるというワークフローになります
後ほど紹介しますが金額はワークフロー開始時に入力情報として指定します
もし NG になった場合は error_1 のタスクに入ります
ここでは強制的にステータスを FAILED にします
なので、error_1 の流れに来た場合は最後までたどり着かずに必ずフローが失敗するようになっています
conductor の場合タスクを途中でぶった切って終了させるというタスクがないのでこのようにしています

最終的にワークフローが COMPLETED になった場合はいくら金額が取得できたのかを表示します
outputParameters の your_got_the_money がそれになります
あとは 2 つ目の承認が完了した際にイベントタスクを挟んでいます
今回は特にこのイベントに対する処理は書いていませんが、キューに情報が入るのでその情報を元に通知するなどの用途を考えています

だいたいの流れは上記となります
あとはそれぞれのタスクのワーカーを作成していきます

作成したワークフローを削除することは現状できないので、updateWorkflowDefs を使って更新するようにしてください

ワークフローをスタートする

まずはワークフローをスタートします
先ほど説明したように申請する金額の情報を設定します

  • vim start_workflow.py
from conductor import conductor

def main():
    wfc = conductor.WorkflowClient('http://localhost:8080/api')
    wfName = 'approval_workflow'
    input = { "money": 1000 }
    workflowId = wfc.startWorkflow(wfName, input, 1, None)
    print(workflowId)

if __name__ == '__main__':
    main()

これでワークフローの定義で金額を ${workflow.input.money} で参照できるようになります
ワークフロースタートすると RUNNING の状態になります
my_workflow4.png

approval_1 のワーカーを作成する

ワークフローをスタートすると 1 つ目のタスク approval_1 が SCHEDULED のステータスになります
1 つ目の承認用ののワーカーを作成しましょう

  • vim a1_worker.py
import sys
from conductor import conductor

def main(argv):
    tc = conductor.TaskClient('http://localhost:8080/api')
    taskType = 'approval_1'
    workerId = 'approval_worker_1'
    ret = tc.pollForTask(taskType, workerId)
    print(ret)
    val = "ok" if len(argv) <= 1 else argv[1]
    taskObj = {
      "taskId": ret['taskId'],
      "workflowInstanceId": ret['workflowInstanceId'],
      "status": "COMPLETED",
      "outputData": {
        "approver": "user001",
        "status": val,
      }
    }
    ret = tc.updateTask(taskObj)
    print(ret)

if __name__ == '__main__':
    main(sys.argv)

やっていることの概要は「ポーリング」->「ステータス設定」->「タスクの状態を更新」になります
ステータス情報はワーカーを起動するときの引数で ok、ng を切り替えられるようになっています
今回はそれっぽくするために承認者である approver も設定しています

ポーリングとタスクのステータス更新を同じワーカー内で行っていますが本来であればポーリングしてから何かしらのアクションがあってステータスを更新するのが自然の流れかなと思います
例えば承認者に対して通知をしてから承認ボタンを押すことでステータスが更新されるといった流れが自然かなと思います

分岐について

今回は a1_worker.py の outputData.status に応じてどちらに分岐するか決まります
conductor の DECISION タスクはその前のタスクの outputData を使って自動的にどちらに分岐するか決定してくれます
分岐が決まるとその先にあるタスクを自動的に SCHEDULED にしてくれるためワーカーなどを用意する必要はありません

タスクが SCHEDULED になったそのタスクに対するワーカーをまた動かすだけです

approval_2 のワーカーを作成する

2 つ目の承認のワーカーを作成しましょう
大まかな処理は approval_1 と変わりません

  • vim a2_worker.py
import sys
from conductor import conductor

def main(argv):
    tc = conductor.TaskClient('http://localhost:8080/api')
    taskType = 'approval_2'
    workerId = 'approval_worker_2'
    ret = tc.pollForTask(taskType, workerId)
    print(ret)
    val = "ok" if len(argv) <= 1 else argv[1]
    money = 0
    if val == "ok":
      money = tc.getTask(ret['taskId'])['inputData']['money']
    taskObj = {
      "taskId": ret['taskId'],
      "workflowInstanceId": ret['workflowInstanceId'],
      "status": "COMPLETED",
      "outputData": {
        "approver": "admin001",
        "status": val,
        "money": money
      }
    }
    ret = tc.updateTask(taskObj)
    print(ret)

if __name__ == '__main__':
    main(sys.argv)

違うのは金額情報 (money) をタスクの詳細から取得している点です
もし ok の場合はワークフロー実行時に設定した money を取得して設定します
そしてそれを outputData.money に設定することで最終的にこの値をワークフローの output として表示しています

error_1 のワーカーを作成する

エラーに遷移した場合にエラー処理をするワーカーが必要なので作成します
基本的にはタスクを FAILED にして次に遷移しないようにしかつエラーメッセージを表示します

  • vim error_worker.py
from conductor import conductor

def main():
    tc = conductor.TaskClient('http://localhost:8080/api')
    taskType = 'error_1'
    workerId = 'error_worker_1'
    ret = tc.pollForTask(taskType, workerId)
    print(ret)
    taskObj = {
      "taskId": ret['taskId'],
      "workflowInstanceId": ret['workflowInstanceId'],
      "status": "FAILED",
      "outputData": {
        "message": "It's too high."
      }
    }
    ret = tc.updateTask(taskObj)
    print(ret)

if __name__ == '__main__':
    main()

outputData.message は正直何でも OK です
とりあえず承認を拒否した理由なんかがあると良いかなと思いメッセージを指定できるようにしています
大事なのは "status": "FAILED" になります
こうすることでワークフロー全体が失敗の状態になり次に進まずここで終了になります

先ほども軽く説明しましたが conductor には後ろに繋げず強制終了するタスクの種類がないのでこのようなことをしています

また方法としては別の方法もありエラーに入った場合にエラー内容をセットし最終的に合流したタスクでそのエラー内容を判断して金額を表示しないという処理でも問題ないです
その場合にはワークフロー全体が FAILED にならず未承認の場合でもワークフローは必ず COMPLETED になると思います

動作確認

動作確認するにはタスクおよびワークフローを作成したら以下のように実行すれば OK です

  • python3 start_workflow.py
  • python3 a1_worker.py
  • python3 a2_worker.py

これで以下のようなフローになります
my_workflow5.png

失敗のフローに入れたい場合には

  • python3 start_workflow.py
  • python3 a1_worker.py ng
  • python3 error_worker.py

という感じで実行すれば以下のようになります
もちろん approval_2 で ng にしても OK です
my_workflow6.png

最後までいった場合はワークフロー全体の出力が { "your_got_the_money": 1000 } という感じになりエラーになった場合は { "your_got_the_money": null } になります

最後に

conductor を使って承認システムっぽいものを実装してみました
もし、たくさんの承認をしなければいけない場合はワーカーを増やせば簡単にスケールできます

今回はかなりワーカーを簡単に書きました
本来は DB や他のサービスとの連携などもう少し考えることはあるかなと思います
またロールバック処理も考えていないので本来であればそのフローも考える必要があるかなと思います

2018年5月9日水曜日

conductor の Dynamic Fork を試してみる

概要

前回 kitchensink を完走してみました
が、FORK_JOIN_DYNAMIC のタスクの実行に失敗しました
今回は動かすことに成功したのでその方法を紹介します

Dynamic Fork はその名の通り動的にタスクを動かすことができるタスクです
例えばある処理をした際にその結果に応じて動かすタスクを動的に変えたい場合があると思います
Dynamic Fork はそんなときに使えるタスクになります

環境

  • macOS 10.13.4
  • conductor 1.9.0
  • docker 18.04.0-ce
  • Python 3.6.4

Dynamic Fork させてみる

まず Dynamic Fork させる前のワークフローの状態は以下のようになります
dynamic_fork1.png

Dynamic Fork のタスクは fanout1 になります
ここでタスクを動的に作るために task_4 の outputData の中に Dynamic Fork させるためのデータを格納する必要があります

fanout1 が Dynamic Fork するために必要なパラメータは以下のようになっています

{
  "name": "dynamic_fanout",
  "taskReferenceName": "fanout1",
  "inputParameters": {
    "dynamicTasks": "${task_4.output.dynamicTasks}",
    "input": "${task_4.output.inputs}"
  },
  "type": "FORK_JOIN_DYNAMIC",
  "dynamicForkTasksParam": "dynamicTasks",
  "dynamicForkTasksInputParamName": "input",
  "startDelay": 0
}

Dynamic Fork をするには type を FORK_JOIN_DYNAMIC にし、かつ dynamicForkTasksParam と dynamicForkTasksInputParamName というパラメータの指定が必要です
今回この 2 つそれぞれ dynamicTasks と input と指定しています
このパラメータがどこを指しているかというと inputParameters のキーの値になります
inputParameters のこの値を使うというのを指定する感じになります

そしてそのキーに設定される値は何かというとそれぞれ ${task_4.output.dynamicTasks} と ${task_4.output.inputs} になります
これが何かというと task_4 の outputData に設定されている値になります

ということを踏まえて taks_4 のステータスを COMPLETED に変更してみます

  • vim update_task4.py
from conductor import conductor

def main():
    tc = conductor.TaskClient('http://localhost:8080/api')
    taskObj = {
      "taskId": "457a1546-f9fd-4012-88cf-25555913742e",
      "workflowInstanceId": "13d73f0d-202a-4b54-a11c-b44dbe0893c3",
      "status": "COMPLETED",
      "outputData": 
        {
          "inputs": {
            "task_5": {
              "params": {
                "recipe": "jpg"
              }
            }
        },
        "dynamicTasks": [
          {
            "name": "task_5",
              "taskReferenceName": "task_5",
              "type": "SIMPLE"
          }
        ]
      }
    }
    ret = tc.updateTask(taskObj)
    print(ret)

if __name__ == '__main__':
    main()

taskId と workflowInstanceId は適宜変更してください
先ほど fanout1 内で指定した inputs と dynamicTasks を outputData に指定します
そして dynamicTasks には動的に動かしたいタスクを指定します
当然ですがここで指定するタスクは既に conductor に登録しておく必要があります
もし存在しないタスクを指定するとワークフローがエラーとなります
そして inputs 側には動的に動かすタスクに対して与えるパラメータを指定することができます
今回の場合は task_5 に対して "recipe": "jpg" という値を渡すことになります

また変数名が複数形であることからもわかるように動かすタスクは複数動かすことが可能です

これで task_4 を更新するとワークフローが以下のようになると思います
dynamic_fork2.png

task_5 が突如出現しました
あとはこれまで通りポーリング -> タスクの更新とすれば join1 につながり、その後 search_elasticsearch のタスクに流れます

もし複数のタスクを動かしたのであれば、それらすべてのタスクが COMPLETED になるまで join1 には進みません
これが Dynamic Fork の仕組みになります

最後に

conductor の Dynamic Fork の仕組みを試してみました
冒頭にも説明しましたが、動かすタスクの数が決まっていない場合や動かすタスクの種類が決まっていない場合などに使えます

今回も kitchensink のサンプルを使っていますが右側の分岐は普通の Fork タスクの流れになります
こちらは始めからタスクが 2 つだけ Fork していることがわかると思います

公式のドキュメントにもありましたが Dynamic Fork を使う場合には必ず Join タスクも含めるようにしてください

参考サイト

2018年5月8日火曜日

conductor のサンプルワークフロー kitchensink を完走してみた

概要

前回 conductor に入門した際にサンプルのワークフローである kitchensink を使ってみました
ただ、すべてのタスクを COMPLETED にしていません
なので、今回はすべてのタスクを COMPLETED にしてみました
クライアントは Python SDK を使っています

環境

  • macOS 10.13.4
  • conductor 1.9.0
  • docker 18.04.0-ce
  • Python 3.6.4

ワークフローの定義を追加する

docker で動作させている場合既存の kitchensink は必ず失敗します
なぜなら ElasticSearch に問い合わせる際に localhost を見ているため conductor_server のコンテナから ElasticSearch にアクセスできません

  • vim docker/docker-compose.yaml
version: '2'
services:
  conductor-server:
    environment:
      - CONFIG_PROP=config.properties
    image: conductor:server
    build:
      context: ../
      dockerfile: docker/server/Dockerfile
    ports:
      - 8080:8080
    links:
      - elasticsearch:es
      - dynomite:dyno1

  conductor-ui:
    environment:
      - WF_SERVER=http://conductor-server:8080/api/
    image: conductor:ui
    build:
      context: ../
      dockerfile: docker/ui/Dockerfile
    ports:
      - 5000:5000
    links:
      - conductor-server

  dynomite:
    image: v1r3n/dynomite

  elasticsearch:
    image: elasticsearch:2.4
    ports:
      - 9200:9200

最後の ports の定義を追加しているだけです
これでホストマシン経由で ElasticSearch にアクセスできるようにします
docker-compose.yaml で links を使っているので、そこで指定していコンテナ名を指定しても OK だと思います

これで docker-compose up したあとにタスク定義を新規で追加します
タスク定義を追加するスクリプトは以下の通りです
また、Python SDK が使える環境の構築はこちらを参考にしてください

  • cd client/python
  • vim register_workflow.py
from conductor import conductor

def main():
    tc = conductor.MetadataClient('http://localhost:8080/api')
    wfdObj = {
      "createTime": 1525651296485,
      "name": "my_kitchensink",
      "description": "kitchensink workflow",
      "version": 1,
      "tasks": [
        {
          "name": "task_1",
          "taskReferenceName": "task_1",
          "inputParameters": {
            "mod": "${workflow.input.mod}",
            "oddEven": "${workflow.input.oddEven}",
            "env": {
              "taskId": "${CPEWF_TASK_ID}",
              "workflowId": "${HOSTNAME}"
            }
          },
          "type": "SIMPLE",
          "startDelay": 0
        },
        {
          "name": "event_task",
          "taskReferenceName": "event_0",
          "inputParameters": {
            "mod": "${workflow.input.mod}",
            "oddEven": "${workflow.input.oddEven}"
          },
          "type": "EVENT",
          "startDelay": 0,
          "sink": "conductor"
        },
        {
          "name": "dyntask",
          "taskReferenceName": "task_2",
          "inputParameters": {
            "taskToExecute": "${workflow.input.task2Name}"
          },
          "type": "DYNAMIC",
          "dynamicTaskNameParam": "taskToExecute",
          "startDelay": 0
        },
        {
          "name": "oddEvenDecision",
          "taskReferenceName": "oddEvenDecision",
          "inputParameters": {
            "oddEven": "${task_2.output.oddEven}"
          },
          "type": "DECISION",
          "caseValueParam": "oddEven",
          "decisionCases": {
            "0": [
              {
                "name": "task_4",
                "taskReferenceName": "task_4",
                "inputParameters": {
                  "mod": "${task_2.output.mod}",
                  "oddEven": "${task_2.output.oddEven}"
                },
                "type": "SIMPLE",
                "startDelay": 0
              },
              {
                "name": "dynamic_fanout",
                "taskReferenceName": "fanout1",
                "inputParameters": {
                  "dynamicTasks": "${task_4.output.dynamicTasks}",
                  "input": "${task_4.output.inputs}"
                },
                "type": "FORK_JOIN_DYNAMIC",
                "dynamicForkTasksParam": "dynamicTasks",
                "dynamicForkTasksInputParamName": "input",
                "startDelay": 0
              },
              {
                "name": "dynamic_join",
                "taskReferenceName": "join1",
                "type": "JOIN",
                "startDelay": 0
              }
            ],
            "1": [
              {
                "name": "fork_join",
                "taskReferenceName": "forkx",
                "type": "FORK_JOIN",
                "forkTasks": [
                  [
                    {
                      "name": "task_10",
                      "taskReferenceName": "task_10",
                      "type": "SIMPLE",
                      "startDelay": 0
                    },
                    {
                      "name": "sub_workflow_x",
                      "taskReferenceName": "wf3",
                      "inputParameters": {
                        "mod": "${task_1.output.mod}",
                        "oddEven": "${task_1.output.oddEven}"
                      },
                      "type": "SUB_WORKFLOW",
                      "startDelay": 0,
                      "subWorkflowParam": {
                        "name": "sub_flow_1",
                        "version": 1
                      }
                    }
                  ],
                  [
                    {
                      "name": "task_11",
                      "taskReferenceName": "task_11",
                      "type": "SIMPLE",
                      "startDelay": 0
                    },
                    {
                      "name": "sub_workflow_x",
                      "taskReferenceName": "wf4",
                      "inputParameters": {
                        "mod": "${task_1.output.mod}",
                        "oddEven": "${task_1.output.oddEven}"
                      },
                      "type": "SUB_WORKFLOW",
                      "startDelay": 0,
                      "subWorkflowParam": {
                        "name": "sub_flow_1",
                        "version": 1
                      }
                    }
                  ]
                ],
                "startDelay": 0
              },
              {
                "name": "join",
                "taskReferenceName": "join2",
                "type": "JOIN",
                "startDelay": 0,
                "joinOn": [
                  "wf3",
                  "wf4"
                ]
              }
            ]
          },
          "startDelay": 0
        },
        {
          "name": "search_elasticsearch",
          "taskReferenceName": "get_es_1",
          "inputParameters": {
            "http_request": {
              "uri": "http://172.21.0.1:9200/conductor/_search?size=10",
              "method": "GET"
            }
          },
          "type": "HTTP",
          "startDelay": 0
        },
        {
          "name": "task_30",
          "taskReferenceName": "task_30",
          "inputParameters": {
            "statuses": "${get_es_1.output..status}",
            "workflowIds": "${get_es_1.output..workflowId}"
          },
          "type": "SIMPLE",
          "startDelay": 0
        }
      ],
      "outputParameters": {
        "statues": "${get_es_1.output..status}",
        "workflowIds": "${get_es_1.output..workflowId}"
      },
      "schemaVersion": 2
    }
    ret = tc.createWorkflowDef(wfdObj)
    print(ret)

if __name__ == '__main__':
    main()

長ったらしいですが既存の kitchensink の JSON をほぼ使い回しています
変更しているのは search_elasticsearch タスクの inputParameters -> http_request -> url の部分です
先ほど ElasticSearch をホストマシン経由でアクセスできるようにしたので localhost だった部分をホストマシンの IP に変更しています
これでワークフローを追加してあげましょう

  • python3 register_workflow.py

すると以下のようにワークフローが追加されると思います
kitchensink_goal1.png

作成したワークフローを開始する

  • vim start_workflow.py
from conductor import conductor

def main():
    wfc = conductor.WorkflowClient('http://localhost:8080/api')
    wfName = 'my_kitchensink'
    input = { "task2Name": "task_5" }
    workflowId = wfc.startWorkflow(wfName, input, 1, None)
    print(workflowId)

if __name__ == '__main__':
    main()
  • python3 start_workflow.py

これで新規で作成した my_kitchensink ワークフローが RUNNING になります
kitchensink_goal2.png

workflowInstanceId は後で使うのでメモしておきます

各タスクを更新していく

あとはここから各タスクを更新していくだけです
ステータスを更新対象のタスクの taskId は本来であればポーリングして取得しますが今回は UI から取得して設定しています

task_1 を COMPLETED にする

  • vim update_task1.py
from conductor import conductor

def main():
    tc = conductor.TaskClient('http://localhost:8080/api')
    taskObj = {
      "taskId": "a9a38752-7382-4b0b-a1ca-2200636af57c",
      "workflowInstanceId": "13d73f0d-202a-4b54-a11c-b44dbe0893c3",
      "status": "COMPLETED",
      "outputData": {
      }
    }
    ret = tc.updateTask(taskObj)
    print(ret)

if __name__ == '__main__':
    main()

こんな感じになります
kitchensink_goal3.png

task_2 を更新する

続けて task_2 を更新します
コードは先ほどの taskId の部分を変更するだけです
実行すると以下のようになります
kitchensink_goal4.png

search_elasticsearch の部分は自動で COMPLETED になるはずです
もしここで失敗する場合は以下のエラーになっていると思います

Failed to invoke http task due to: com.sun.jersey.api.client.ClientHandlerException: java.net.ConnectException: Connection refused (Connection refused)

これは冒頭で説明したとおり conductor_server から ElasticServer にアクセスできないのが原因なので docker-compose の設定やワークフローの定義の IP などが間違っていないか確認してみてください

また search_elasticsearch の inputParameters の定義は以下のようになっています
"type": "HTTP" にし http_request というパラメータを使うとタスクが SCHEDULED になったときに指定の uri に自動でリクエストを送信してくれるようです
また、そのレスポンスを outputData に自動で格納してくれるようです

"inputParameters": {
  "http_request": {
    "uri": "http://172.21.0.1:9200/conductor/_search?size=10",
    "method": "GET"
  }
}

task_30 を更新する

あとは最後のタスクを更新すれば OK です
これも先ほどのコードで taskId の部分だけ書き換えれば OK です
完了すると以下のようになります
kitchensink_goal5.png

ワークフローのステータスも COMPLETED に変わっています
これで最後まで完走できました

分岐させるには

無事完走はできましたが実は分岐の部分を完全にスルーしています
ちゃんと分岐させるには task_2 を更新する際に以下の用に outputData を設定します

from conductor import conductor

def main():
    tc = conductor.TaskClient('http://localhost:8080/api')
    taskObj = {
      "taskId": "f707da00-5fec-462b-ac07-1f2e2205866d",
      "workflowInstanceId": "13d73f0d-202a-4b54-a11c-b44dbe0893c3",
      "status": "COMPLETED",
      "outputData": {
        "oddEven": 0
      }
    }
    ret = tc.updateTask(taskObj)
    print(ret)

if __name__ == '__main__':
    main()

分岐を制御している oddEvenDecision のタスクを見るとわかりますが条件分岐の判定を以下のように定義しています

"inputParameters": {
  "oddEven": "${task_2.output.oddEven}"
}

task_2 の output (outputData) の oddEvent というキーの値を見ています
なのでこれを設定してあげることで分岐に入ることができます
kitchensink_goal6.png

あとは同様に task_4 を更新してあげれば次のタスクに進むことができるようになります
ただなぜか Input to the dynamically forked tasks is not a map -> expecting a map of K,V but found null のエラーになり先に進めませんでした
おそらく FORK_JOIN_DYNAMIC タスクの扱い方がおかしいんだと思います

最後に

kitchensink のタスクを完走してみました
とりあえず完走はできましたが一部動作しなかったタスクもありました
FORK_JOIN_DYNAMIC など、タスクの種類は他にもありタスクをうまく使いこなせないと conductor は使いこなせないと思います

参考サイト

2018年5月7日月曜日

conductor の Python SDK を使ってみた

概要

前回 conductor のインストールを行いました
今回は Python SDK を使ってワーカーを作成しました

環境

  • macOS 10.13.4
  • conductor 1.9.0
  • docker 18.04.0-ce

ライブラリインストール

また pypi で公開されていないようなので自分でビルドしてインストールする必要があります
client/python に setup.py があるのでそれを利用します
https://github.com/Netflix/conductor/issues/273

ワークフロースタート

  • vim start_workflow.py
from conductor import conductor

def main():
    wfc = conductor.WorkflowClient('http://localhost:8080/api')
    wfName = 'kitchensink'
    input = { "task2Name": "task_5" }
    workflowId = wfc.startWorkflow(wfName, input, 1, None)
    print(workflowId)

if __name__ == '__main__':
    main()
  • python3 start_workflow.py

ポーリングする

  • vim polling_task.py
from conductor import conductor

def main():
    tc = conductor.TaskClient('http://localhost:8080/api')
    taskType = 'task_1'
    workerId = 'python_1'
    ret = tc.pollForTask(taskType, workerId)
    print(ret)

if __name__ == '__main__':
    main()
  • python3 polling_task.py

ここでステータスを見て IN_PROGRESS なら他のワーカーが握っているので処理をしないなど分岐条件を記載します
また、タスクの workerId を python_1 が設定されておりどのワーカーが処理しているかわかるようになっています

ステータスを更新する

  • vim update_task.py
from conductor import conductor

def main():
    tc = conductor.TaskClient('http://localhost:8080/api')
    taskObj = {
      "taskId": "aa33aaca-1ffd-4852-ab37-00ec140cdd1d",
      "workflowInstanceId": "f16446f1-eb5f-4b99-b631-68bce95e3146",
      "status": "COMPLETED",
      "output": {
        "mod": 5,
        "taskToExecute": "task_1",
        "oddEven": 0,
        "dynamicTasks": [
          {
            "name": "task_1",
            "taskReferenceName": "task_1_1",
            "type": "SIMPLE"
          },
          {
            "name": "sub_workflow_4",
            "taskReferenceName": "wf_dyn",
            "type": "SUB_WORKFLOW",
            "subWorkflowParam": {
              "name": "sub_flow_1"
            }
          }
        ],
        "inputs": {
          "task_1_1": {},
          "wf_dyn": {}
        }
      }
    }
    ret = tc.updateTask(taskObj)
    print(ret)

if __name__ == '__main__':
    main()
  • python3 update_task.py

taskId と workflowInstanceId が必須です
taskId はポーリングしたときに取得できます
"status": "COMPLETED" にすることでタスクを完了のステータスにすることができます
再度 IN_PROGRESS にしようとしてもできません
レスポンスは None が返ってきます

見て分かる通り実はただ POST /tasks の JSON をそのまま渡しているだけになります
なので、この JSON の各パラメータの意味をしっかり理解する必要があります

おまけ1 ワークフローを削除する

  • vim remove_workflow.py
from conductor import conductor

def main():
    wfc = conductor.WorkflowClient('http://localhost:8080/api')
    wfId = '7257b875-2646-458b-8ab2-8708cf9e8f0f'
    ret = wfc.removeWorkflow(wfId, False)
    print(ret)

if __name__ == '__main__':
    main()

これは執筆時点での master では動作しませんがいずれできるようになると思います

おまけ2 swagger-ui を表示する

http://localhost:8080/?url=localhost:8080 でいけます
各 API の JSON のパラメータの説明などはこれを見るのが手っ取り早いと思います

最後に

conductor の Python SDK を使ってみました
まだまだ開発中という感じがあってドキュメントも少なく始めるにはまだ敷居が高そうです
直接 client/python/conductor/conductor.py などのコードを眺める or swagger-ui を見るしかありません
JSON パラメータの説明もほぼ無いので try & error で用途を探るしかなさそうです
SDK であれば Java のほうが開発は進んでいそうです

今回紹介したメソッドも全 API の一部でしかありません
他にもたくさんの API があるので興味があれば試してみてください

参考サイト

2018年5月3日木曜日

Netflix/conductor を使ってみた

概要

conductor はワークフローツールで Netflix が開発している OSS です
今回はインストールと挙動の確認までやってみました
ドキュメントがまだ少ないので不確かな情報が多いです

環境

  • macOS 10.13.4
  • conductor 1.9.0
  • docker 18.04.0-ce

インストール

でコードが取得できたらまずはビルドします
docker を使ってビルドできるので docker を使います

  • docker build -f docker/server/Dockerfile.build -t conductor:server-build .
  • docker run -v $(pwd):/conductor conductor:server-build
  • docker build -f docker/server/Dockerfile -t conductor:server .
  • docker build -f docker/ui/Dockerfile -t conductor:ui .

これでサーバと UI のイメージが作成されます
あとはコンテナを起動すれば OK です
この 2 つでローカル上で動作サーバと UI を動作するための jar ファイルなどが生成されます
これを元にサーバと UI の docker イメージを作成します

  • cd conductor/docker
  • docker-compose build

でビルドが完了したら

  • cd docker
  • docker-compose up -d

で UI とサーバを起動します
これで localhost:5000 にアクセスすると conductor の UI が表示されます
また localhost:8080 が API サーバのアクセスポイントになります

docker-compose を使わないのであれば

  • cd conductor/docker
  • docker build -t conductor:server ./server
  • docker build -t conductor:ui ./ui

でビルドしてから up でも OK です
個別に run すると依存している elasticsearch と dynamite も手動で上げる必要があるので起動するときは docker-compose を素直に使うことをおすすめします

kitchensink というサンプルのワークフローを使って挙動を確認してみる

conductor server が起動すると kitchensink というワークフローが登録されているのが確認できます
まだワークフローだけで実際に動作するワーカーがないので手動 (curl) でワークフローを辿ってみたいと思います

まずワークフローを実行させましょう
実行は UI からはできず API から行います
UI はステータスの確認を行うのに使います

  • curl -X POST --header 'Content-Type: application/json' --header 'Accept: text/plain' 'http://localhost:8080/api/workflow/kitchensink' -d '{ "task2Name": "task_5" }'

これでワークフローが起動します
他のワークフローはいろいろ試した残骸なので無視してください
conductor1.png

更にワークフローの詳細を確認すると task_1 というタスクが SCHEDULED になっていることが確認できます
conductor2.png

要するにワークフローが開始され一番初めのタスクが待ち状態になりました
待ち状態というのは具体的にいうとワーカーさんに「処理してください」という状態になったということです (おそらく)

task_1 の情報を取得しましょう
以下の API は所謂ポーリング処理になり実際はワーカーから定期的に投げられる処理になります

  • curl http://localhost:8080/api/tasks/poll/task_1

すると以下のような JSON が返ってくると思います

{
  "taskType": "task_1",
  "status": "IN_PROGRESS",
  "inputData": {
    "mod": null,
    "oddEven": null,
    "env": {
      "taskId": "eb934c01-731f-48b2-a5e7-a9ee81162256",
      "workflowId": "14c8631506a5"
    }
  },
  "referenceTaskName": "task_1",
  "retryCount": 0,
  "seq": 1,
  "pollCount": 1,
  "taskDefName": "task_1",
  "scheduledTime": 1524795601602,
  "startTime": 1524795883025,
  "endTime": 0,
  "updateTime": 1524795883025,
  "startDelayInSeconds": 0,
  "retried": false,
  "callbackFromWorker": true,
  "responseTimeoutSeconds": 3600,
  "workflowInstanceId": "54313f13-199c-497c-9732-843e20a86b67",
  "taskId": "eb934c01-731f-48b2-a5e7-a9ee81162256",
  "callbackAfterSeconds": 0,
  "workflowTask": {
    "name": "task_1",
    "taskReferenceName": "task_1",
    "inputParameters": {
      "mod": "${workflow.input.mod}",
      "oddEven": "${workflow.input.oddEven}",
      "env": {
        "taskId": "${CPEWF_TASK_ID}",
        "workflowId": "${HOSTNAME}"
      }
    },
    "type": "SIMPLE",
    "startDelay": 0
  },
  "queueWaitTime": 281423,
  "taskStatus": "IN_PROGRESS"
}

長ったらしいので自分も全部は理解していませんがタスクの状態や実行時間、入力情報などがあります
ポイントは status でこれが IN_PROGRESS になっているのがわかります
要するにワーカーが一度ポーリングするとステータスが IN_PROGRESS になりどれかのワーカーに握られたタスクだとということを明示しています

本来はここでタスクに紐付いたロジックをワーカーが実行します
そして処理が完了したらタスクのステータスを完了にします
ここでは成功した状態にします

curl -H 'Content-Type:application/json' -H 'Accept:application/json' -X POST http://localhost:8080/api/tasks/ -d '
{
    "taskId": "eb934c01-731f-48b2-a5e7-a9ee81162256",
    "workflowInstanceId": "54313f13-199c-497c-9732-843e20a86b67",
    "status": "COMPLETED",
    "output": {
        "mod": 5,
        "taskToExecute": "task_1",
        "oddEven": 0,
        "dynamicTasks": [
            {
                "name": "task_1",
                "taskReferenceName": "task_1_1",
                "type": "SIMPLE"
            },
            {
                "name": "sub_workflow_4",
                "taskReferenceName": "wf_dyn",
                "type": "SUB_WORKFLOW",
                "subWorkflowParam": {
                    "name": "sub_flow_1"
                }
            }
        ],
        "inputs": {
            "task_1_1": {},
            "wf_dyn": {}
        }
    }
}'

API 的には /tasks に対して POST を投げているだけですがボディの JSON がやたら長ったらしいです
おそらくちゃんと運用するようになったら各パラメータが何を意味するかちゃんと理解する必要はあると思います

ここでは冒頭の workflowInstanceId と taskId を変更します
先ほどポーリングした際に task_1 から取得したものを設定してください

そしてリクエストすると task_1 のタスクが終了し次の処理に移行しているのがわかると思います
conductor3.png

途中でイベントが挟まっていますがイベントは本来別の外部キューシステムなどにエンキューしたりするのができるようです
https://netflix.github.io/conductor/metadata/systask/#event

次のタスクは task_5 となっているので再度ポーリングするとステータスが SCHEDULED -> IN_PROGRESS に変わるのが確認できると思います

だいたいこんな感じで、タスクポーリング -> ワーカー処理 -> タスクにフィードバックという流れでフローが進んでいくようです
ことあとで更に Decision タスクや Fork タスクなどが出てくるようですがそれはまた別の機会に試してみたいと思います

最後に

Netflix/conductor の起動方法と簡単な使い方の確認をしてみました
本格的に使うにはワーカーが必要です
どうやら SDK として Java or Python があるようなので次回は Python SDK を使ってワーカーと連携させてみたいと思います

まだ利便性が見えてきているわけではないですが、感じとしては Amazon SQS のようなキューイングシステムになるのでワーカー側で結構頑張らないとダメな印象はあります
イベントパイプラインのような pub/sub システムではないのでその辺りの区別も必要かなと思います

参考サイト