プロセススーパービジョンのレシピ

監視とリンクを使ってプロセスの終了を観測し、障害を伝播し、キャンセルを処理し、ワーカーを再起動します。

分類: 部分的なレシピです。ライフサイクルの各スニペットは独立しています。ワーカープールのセクションは 主要なエントリを提供しますが、再起動を発生させて検証するための独立した制御プロセスは含みません。

コンテキストと依存関係

各スニペットはWippyランタイムv0.3.32aを対象とし、実行可能なLuaエントリ、 app:processesという稼働中のprocess.host、app.workers:task_workerなどプロジェクトで定義した ワーカーエントリを前提とします。processとchannel APIは実行コンテキストに組み込まれています。 time.*を呼び出すスニペットでは、エントリにtimeモジュールを追加し、ソースで local time = require("time")を指定してください。

プロセスの生成、ホスト選択、監視、リンク、送信、キャンセル、終了は保護された操作です。 これらを使う実行可能エントリにはアクターと、範囲を限定したallowポリシーを付与してください。 以下のワーカープール設定には必要なポリシーが含まれますが、独立したスニペットには含まれません。

モニタリングとリンク

モニタリングは一方向の監視を提供します:

  • 親が子を監視します。
  • 子が終了すると、親がEXITイベントを受信します。
  • 親は実行を継続します。

リンクは双方向に運命を共有します:

  • 親と子がリンクされます。
  • どちらかのプロセスが異常終了すると、もう一方も終了します。
  • trap_links=trueを設定すると、障害を処理可能なイベントへ変換できます。
flowchart TB
    subgraph Monitoring["MONITORING (one-way)"]
        direction TB
        P1[Parent monitors] -->|EXIT event
parent continues| C1[Child exits] end subgraph Linking["LINKING (bidirectional)"] direction TB P2[Parent linked] <-->|abnormal exit
fate sharing| C2[Child fails] end

プロセスモニタリング

モニタリング付きスポーン

process.spawn_monitored()を使用して生成とモニタリングを1回の呼び出しで実行:

local function main()
    local events_ch = process.events()

    -- Spawn worker and start monitoring
    local worker_pid, err = process.spawn_monitored(
        "app.workers:task_worker",
        "app:processes"
    )
    if err then
        return nil, "spawn failed: " .. tostring(err)
    end

    -- Wait for worker to complete
    local event = events_ch:receive()

    if event.kind == process.event.EXIT then
        print("Worker exited:", event.from)
        if event.result then
            print("Result:", event.result.value)
        end
        if event.result and event.result.error then
            print("Error:", event.result.error)
        end
    end
end

既存プロセスのモニタリング

すでに実行中のプロセスのモニタリングを開始するにはprocess.monitor()を呼び出す:

local function main()
    local time = require("time")
    local events_ch = process.events()

    -- Spawn without monitoring
    local worker_pid, err = process.spawn(
        "app.workers:long_worker",
        "app:processes"
    )
    if err then
        return nil, "spawn failed: " .. tostring(err)
    end

    -- Start monitoring later
    local ok, monitor_err = process.monitor(worker_pid)
    if monitor_err then
        return nil, "monitor failed: " .. tostring(monitor_err)
    end

    -- Cancel the worker
    time.sleep("5ms")
    local _, cancel_err = process.cancel(worker_pid)
    if cancel_err then
        return nil, "cancel failed: " .. tostring(cancel_err)
    end

    -- Receive EXIT event
    local event = events_ch:receive()
    if event.kind == process.event.EXIT then
        print("Worker terminated:", event.from)
    end
end

モニタリングの停止

EXITイベントの受信を停止するにはprocess.unmonitor()を使用:

local function main()
    local time = require("time")
    local events_ch = process.events()

    -- Spawn and monitor
    local worker_pid, err = process.spawn_monitored(
        "app.workers:long_worker",
        "app:processes"
    )
    if err then
        return nil, "spawn failed: " .. tostring(err)
    end

    time.sleep("5ms")

    -- Stop monitoring
    local ok, unmon_err = process.unmonitor(worker_pid)
    if unmon_err then
        return nil, "unmonitor failed: " .. tostring(unmon_err)
    end

    -- Cancel worker
    local _, cancel_err = process.cancel(worker_pid)
    if cancel_err then
        return nil, "cancel failed: " .. tostring(cancel_err)
    end

    -- No EXIT event will be received (we unmonitored)
    local timeout = time.after("200ms")
    local result = channel.select {
        events_ch:case_receive(),
        timeout:case_receive(),
    }

    if result.channel == events_ch then
        return nil, "should not receive event after unmonitor"
    end
end

プロセスリンク

明示的なリンク

双方向リンクを作成するにはprocess.link()を使用:

-- Worker that links to a target process
local function worker_main()
    local time = require("time")
    local events_ch = process.events()
    local inbox_ch = process.inbox()

    -- Enable trap_links to receive LINK_DOWN events
    local _, options_err = process.set_options({ trap_links = true })
    if options_err then
        return nil, "set_options failed: " .. tostring(options_err)
    end

    -- Receive target PID from sender
    local msg = inbox_ch:receive()
    local target_pid = msg:payload():data()
    local sender = msg:from()

    -- Create bidirectional link
    local ok, err = process.link(target_pid)
    if err then
        return nil, "link failed: " .. tostring(err)
    end

    -- Notify sender we're linked
    local _, send_err = process.send(sender, "linked", process.pid())
    if send_err then
        return nil, "confirmation failed: " .. tostring(send_err)
    end

    -- Wait for LINK_DOWN when target exits with an error
    local timeout = time.after("3s")
    local result = channel.select {
        events_ch:case_receive(),
        timeout:case_receive(),
    }

    if result.channel == events_ch then
        local event = result.value
        if event.kind == process.event.LINK_DOWN then
            return "LINK_DOWN_RECEIVED"
        end
    end

    return nil, "no LINK_DOWN received"
end

リンク付きスポーン

process.spawn_linked()を使用して生成とリンクを1回の呼び出しで実行:

local function parent_main()
    -- Enable trap_links to handle child death
    local _, options_err = process.set_options({ trap_links = true })
    if options_err then
        return nil, "set_options failed: " .. tostring(options_err)
    end

    local events_ch = process.events()

    -- Spawn and link to child
    local child_pid, err = process.spawn_linked(
        "app.workers:child_worker",
        "app:processes"
    )
    if err then
        return nil, "spawn_linked failed: " .. tostring(err)
    end

    -- If the child exits with an error, we receive LINK_DOWN
    local event = events_ch:receive()
    if event.kind == process.event.LINK_DOWN then
        print("Child died:", event.from)
    end
end

トラップリンク

デフォルトでは、リンクされたプロセスが失敗すると、現在のプロセスも失敗します。代わりにLINK_DOWNイベントを受信するにはtrap_links=trueを設定します。

デフォルト動作(trap_links=false)

trap_linksなしでは、リンクされたプロセスの失敗は現在のプロセスを終了させます:

local function worker_main()
    local events_ch = process.events()

    -- trap_links is false by default
    local opts = process.get_options()
    print("trap_links:", opts.trap_links)  -- false

    -- Spawn linked worker that will fail
    local child_pid, err = process.spawn_linked(
        "app.workers:error_worker",
        "app:processes"
    )
    if err then
        return nil, "spawn_linked failed: " .. tostring(err)
    end

    -- When child errors, THIS process terminates
    -- We never reach this point
    local event = events_ch:receive()
end

trap_links=trueの場合

LINK_DOWNイベントを受信して生存するにはtrap_linksを有効化:

local function worker_main()
    -- Enable trap_links
    local _, options_err = process.set_options({ trap_links = true })
    if options_err then
        return nil, "set_options failed: " .. tostring(options_err)
    end

    local events_ch = process.events()

    -- Spawn linked worker that will fail
    local child_pid, err = process.spawn_linked(
        "app.workers:error_worker",
        "app:processes"
    )
    if err then
        return nil, "spawn_linked failed: " .. tostring(err)
    end

    -- Wait for LINK_DOWN event
    local event = events_ch:receive()

    if event.kind == process.event.LINK_DOWN then
        print("Child failed, handling gracefully")
        return "LINK_DOWN_RECEIVED"
    end
end

これらの例でLINK_DOWNを受信するには、対象または子プロセスが異常終了する必要があります。 明示的なリンクの例では、その障害が3秒の待機時間内に発生する必要もあります。正常終了ではイベントは発生しません。

キャンセル

キャンセルシグナルの送信

プロセスに正常なキャンセルを要求するにはprocess.cancel()を使用します:

local function main()
    local time = require("time")
    local events_ch = process.events()

    -- Spawn and monitor worker
    local worker_pid, err = process.spawn_monitored(
        "app.workers:long_worker",
        "app:processes"
    )
    if err then
        return nil, "spawn failed: " .. tostring(err)
    end

    time.sleep("5ms")

    -- Cancel the worker
    local ok, cancel_err = process.cancel(worker_pid)
    if cancel_err then
        return nil, "cancel failed: " .. tostring(cancel_err)
    end

    -- Wait for EXIT event
    local event = events_ch:receive()
    if event.kind == process.event.EXIT then
        print("Worker cancelled:", event.from)
    end
end

キャンセルの処理

ワーカーはprocess.events()経由でCANCELイベントを受信します。

以下のcleanup()とhandle_message()は、このレシピでは定義していないアプリケーション側のコールバックです。

local function worker_main()
    local events_ch = process.events()
    local inbox_ch = process.inbox()

    while true do
        local result = channel.select {
            inbox_ch:case_receive(),
            events_ch:case_receive(),
        }

        if result.channel == events_ch then
            local event = result.value
            if event.kind == process.event.CANCEL then
                -- Cleanup resources
                cleanup()
                return "cancelled gracefully"
            end
        else
            -- Process inbox message
            handle_message(result.value)
        end
    end
end

スーパービジョントポロジー

スタートポロジー

親は、自身へリンクする複数の子を連携させられます:

-- Parent worker spawns children that link TO parent
local function star_parent_main()
    local time = require("time")
    local events_ch = process.events()
    local child_count = 10

    -- Enable trap_links to see children die
    local _, options_err = process.set_options({ trap_links = true })
    if options_err then
        error("set_options failed: " .. tostring(options_err))
    end

    local children = {}

    -- Spawn children
    for i = 1, child_count do
        local child_pid, err = process.spawn(
            "app.workers:linker_child",
            "app:processes"
        )
        if err then
            error("spawn child failed: " .. tostring(err))
        end

        -- Send parent PID to child
        local _, send_err = process.send(child_pid, "inbox", process.pid())
        if send_err then
            error("send parent PID failed: " .. tostring(send_err))
        end
        children[child_pid] = true
    end

    -- Wait for all children to confirm link
    for i = 1, child_count do
        local msg = process.inbox():receive()
        if msg:topic() ~= "linked" then
            error("expected linked confirmation")
        end
    end

    -- Trigger failure - all children should receive LINK_DOWN
    error("PARENT_STAR_FAILURE")
end

親にリンクする子ワーカー:

local function linker_child_main()
    -- LINK_DOWNイベントを受信するためにtrap_linksを有効化
    process.set_options({ trap_links = true })

    local events_ch = process.events()
    local inbox_ch = process.inbox()

    -- Receive parent PID
    local msg = inbox_ch:receive()
    local parent_pid = msg:payload():data()

    -- Link to parent
    local _, link_err = process.link(parent_pid)
    if link_err then
        return nil, "link failed: " .. tostring(link_err)
    end

    -- Confirm link
    local _, send_err = process.send(parent_pid, "linked", process.pid())
    if send_err then
        return nil, "confirmation failed: " .. tostring(send_err)
    end

    -- Wait for LINK_DOWN when parent dies
    local event = events_ch:receive()
    if event.kind == process.event.LINK_DOWN then
        return "parent_died"
    end
end

チェーントポロジー

線形チェーンでは各ノードが親へリンクします:

-- Chain root: A -> B -> C -> D -> E
local function chain_root_main()
    local time = require("time")

    -- Spawn first child
    local child_pid, err = process.spawn_linked(
        "app.workers:chain_node",
        "app:processes",
        4  -- depth remaining
    )
    if err then
        error("spawn failed: " .. tostring(err))
    end

    -- Wait for chain to build
    time.sleep("100ms")

    -- Trigger cascade - all linked processes die
    error("CHAIN_ROOT_FAILURE")
end

チェーンノードは次のノードを生成してリンク:

local function chain_node_main(depth)
    if depth > 0 then
        -- Spawn next in chain
        local child_pid, err = process.spawn_linked(
            "app.workers:chain_node",
            "app:processes",
            depth - 1
        )
        if err then
            error("spawn failed: " .. tostring(err))
        end
    end

    -- Block until parent death kills us via LINK_DOWN (default trap_links=false)
    process.inbox():receive()
end

スーパービジョン付きワーカープール

設定

# src/_index.yaml
version: "1.0"
namespace: app

entries:
  - name: supervision-policy
    kind: security.policy
    policy:
      actions:
        - process.host
        - process.send
        - process.spawn
        - process.spawn.linked
      resources: "*"
      effect: allow

  - name: processes
    kind: process.host
    host:
      workers: 16
    lifecycle:
      auto_start: true
# src/supervisor/_index.yaml
version: "1.0"
namespace: app.supervisor

entries:
  - name: pool
    kind: process.lua
    source: file://pool.lua
    method: main
    modules:
      - time

  - name: pool-service
    kind: process.service
    process: app.supervisor:pool
    host: app:processes
    lifecycle:
      auto_start: true
      security:
        actor:
          id: "service:supervisor"
        groups:
          - app.security:supervisors

ストリクトモードはデフォルトで有効なため、セキュリティコンテキストを宣言しないサービスはprocess.spawnを含むすべてのチェックで拒否されます。プールとそのワーカーが使用するアクションを許可します:

# src/security/_index.yaml
version: "1.0"
namespace: app.security

entries:
  - name: supervisor_policy
    kind: security.policy
    policy:
      actions:
        - process.spawn
        - process.spawn.linked
        - process.host
        - process.registry.register
        - process.terminate
      resources: "*"
      effect: allow
    groups:
      - supervisors

ワーカーはスポーン元のプールのアクターとスコープを継承するため、独自のブロックは不要です。

スーパーバイザー実装

-- src/supervisor/pool.lua
local function main(worker_count)
    local time = require("time")
    worker_count = worker_count or 4

    -- Enable trap_links to handle worker deaths
    local _, options_err = process.set_options({ trap_links = true })
    if options_err then
        error("set_options failed: " .. tostring(options_err))
    end

    local events_ch = process.events()
    local workers = {}

    local function start_worker(id)
        local pid, err = process.spawn_linked(
            "app.workers:task_worker",
            "app:processes",
            id
        )
        if err then
            print("Failed to start worker " .. id .. ": " .. tostring(err))
            return nil
        end

        workers[pid] = {id = id, started_at = os.time()}
        print("Worker " .. id .. " started: " .. pid)
        return pid
    end

    -- Start initial pool
    for i = 1, worker_count do
        start_worker(i)
    end

    print("Supervisor started with " .. worker_count .. " workers")

    -- Supervision loop
    while true do
        local timeout = time.after("60s")
        local result = channel.select {
            events_ch:case_receive(),
            timeout:case_receive(),
        }

        if result.channel == timeout then
            -- Periodic health check
            local count = 0
            for _ in pairs(workers) do count = count + 1 end
            print("Health check: " .. count .. " active workers")

        elseif result.channel == events_ch then
            local event = result.value

            if event.kind == process.event.CANCEL then
                return "supervisor stopped"
            end

            if event.kind == process.event.LINK_DOWN then
                local dead_worker = workers[event.from]
                if dead_worker then
                    workers[event.from] = nil
                    local uptime = os.time() - dead_worker.started_at
                    print("Worker " .. dead_worker.id .. " died after " .. uptime .. "s, restarting")

                    -- Brief delay before restart
                    time.sleep("100ms")
                    start_worker(dead_worker.id)
                end
            end
        end
    end
end

return { main = main }

プロセス設定

ワーカー定義

# src/workers/_index.yaml
version: "1.0"
namespace: app.workers

entries:
  - name: task_worker
    kind: process.lua
    source: file://task_worker.lua
    method: main
    modules:
      - time
    security:
      actor:
        id: app.workers:task_worker
      policies:
        - app:supervision-policy

ワーカー実装

-- src/workers/task_worker.lua
local function main(worker_id)
    local time = require("time")
    local events_ch = process.events()
    local inbox_ch = process.inbox()

    -- 他のプロセスからこのワーカーに到達できるよう名前で登録する
    process.registry.register("worker-" .. worker_id)

    print("Task worker " .. worker_id .. " started")

    while true do
        local timeout = time.after("5s")
        local result = channel.select {
            inbox_ch:case_receive(),
            events_ch:case_receive(),
            timeout:case_receive(),
        }

        if result.channel == events_ch then
            local event = result.value
            if event.kind == process.event.CANCEL then
                print("Worker " .. worker_id .. " cancelled")
                return "cancelled"
            elseif event.kind == process.event.LINK_DOWN then
                print("Worker " .. worker_id .. " linked process died")
                return nil, "linked_process_died"
            end

        elseif result.channel == inbox_ch then
            local msg = result.value
            local topic = msg:topic()
            local payload = msg:payload():data()

            if topic == "work" then
                print("Worker " .. worker_id .. " processing: " .. payload)
                time.sleep("100ms")
                local _, send_err = process.send(msg:from(), "result", "completed: " .. payload)
                if send_err then
                    return nil, "send result failed: " .. tostring(send_err)
                end
            end

        elseif result.channel == timeout then
            -- Idle timeout
            print("Worker " .. worker_id .. " idle")
        end
    end
end

return { main = main }

プロセスホストの設定

「設定」セクションで定義したapp:processesエントリは、次のホスト設定を使用します:

# Within the app:processes entry in src/_index.yaml
host:
  workers: 16  # Worker goroutines (default: NumCPU)

workers設定:

  • CPUバウンドな処理の並列性を制御します。
  • 通常はCPUコア数に設定します。
  • ホスト上のすべてのプロセスが共有するスケジューラープールに適用されます。

イベントタイプ

イベント トリガー 必要な設定
EXIT モニタリング対象のプロセスが終了 spawn_monitored()またはmonitor()
LINK_DOWN リンクされたプロセスが失敗 spawn_linked()またはlink()とtrap_links=true
CANCEL process.cancel()が呼ばれた 対象がprocess.events()を受信する

スーパーバイザープールのレシピを使う

このプールはワーカーを起動して監視しますが、完全な実行可能チュートリアルではありません。 制御プロセス、そのプロセスの終了ポリシー、再起動を決定的に検証するアサーションは意図的に省略しています。 レシピをアプリケーションへ組み込んだら、通常どおり初期化して実行します:

wippy init
wippy run

スーパーバイザーが自動起動し、4つのワーカーをスポーンし、それぞれについてWorker N startedをログに記録します。LINK_DOWNはリンクされたプロセスがエラーで終了した場合にのみ配信されるため、ワーカーを強制終了して再起動をトリガーします。終了させるコードは同じランタイム内で実行される必要があるため、アドホックなサービスとして追加します:

# src/chaos/_index.yaml
version: "1.0"
namespace: app.chaos

entries:
  - name: killer
    kind: process.lua
    source: file://killer.lua
    method: main
    modules:
      - time

  - name: killer-service
    kind: process.service
    process: app.chaos:killer
    host: app:processes
    lifecycle:
      auto_start: true
      security:
        actor:
          id: "service:chaos"
        groups:
          - app.security:supervisors
-- src/chaos/killer.lua
local function main()
    local time = require("time")

    time.sleep("2s")
    process.terminate("worker-1")

    return "terminated"
end

return { main = main }

新しいエントリを取り込むためにwippy initを再実行し、wippy runを実行します。2秒後、プールがLINK_DOWNを受信し、100ms待機後にワーカーを同じidで再スポーンします:

INFO  Worker 1 died after 2s, restarting
INFO  Worker 1 started: {...@app:processes|0x00009}
INFO  Task worker 1 started

グレースフルなprocess.cancel()はワーカーを正常終了させるためLINK_DOWNは発生せず、したがって再起動もトリガーされません。だからこそ監視ループはシャットダウンをワーカーの障害として扱わず、CANCELで戻ります。

次のステップ