バックグラウンドタスク
PDF の生成や外部 API の重い呼び出しなど、リクエストを受けた後に時間のかかる処理を、Web アプリとは別のインスタンスで実行できます。この処理を「バックグラウンドタスク」(以下「タスク」)と呼びます。
keelson.yaml の tasks にコマンドを宣言し、アプリのコードから Keelson SDK の enqueue で投入すると、Keelson がそのコマンドを 1 回起動します。失敗した場合は自動で再試行します。
通常は AI エージェントに、リクエストの後に続けたい処理を伝えると、tasks の宣言と投入のコードを書きます。
注文を受け付けたら、領収書の PDF を作ってメールで送る処理をバックグラウンドタスクにしてください。同じタスクが 2 回走っても、PDF とメールが重複しないようにしてください。
| 処理 | 使うもの |
|---|---|
| フォームの保存など、リクエスト中に終わる短い処理 | リクエスト処理の中で完了させる |
| 毎朝のレポートなど、時刻で動かす処理 | 定期実行ジョブ(crons) |
| リクエストを受けて、後で走らせる処理 | バックグラウンドタスク(tasks) |
タスクが向いているのは、次のような処理です。
- HTTP リクエストの上限(120 秒)に収まらない処理
- 外部 API の重い呼び出し(AI の生成・大量のデータ送信など)
- PDF・画像の生成、メールの一斉送信
レスポンスを返した後にアプリの中で続ける処理(FastAPI の BackgroundTasks など)は、最後まで実行されることが保証されません。こうした処理はタスクに移します。
keelson.yaml の tasks に、タスクの名前と実行するコマンドを書きます。
slug: my-appruntime: python-slimcommand: "python app.py"db: mode: libsql
tasks: - name: generate-pdf command: "python generate_pdf.py" timeout: 180 # 1 回の試行を最大 180 秒にする(全プランで有効) max_attempts: 3 # 初回を含めて最大 3 回まで試行する| 項目 | 意味 |
|---|---|
name | タスクの名前。小文字英数字とハイフンで指定します。投入時にこの名前を指定します |
command | 1 回分の処理を実行し、終了するコマンド |
timeout | 1 回の試行の制限時間(秒)。省略時は 300 秒(プランの上限がそれより短ければ上限)。上限を超える値を書くとデプロイが拒否されます |
max_attempts | 初回を含めた試行の最大回数。省略時は 3 回 |
name と command は必須です。項目の範囲や検証のルールは keelson.yaml リファレンスを参照してください。
投入して状態を見る
Section titled “投入して状態を見る”アプリのコードから、宣言した名前と入力データを指定して投入します。投入はすぐに返り、タスクの ID(task_id)が返ります。SDK の導入方法は Keelson SDK を参照してください。
from keelson import tasks
task_id = tasks.enqueue("generate-pdf", {"order_id": 1}, idempotency_key="order-1-pdf")status = tasks.get(task_id)print(status.status, status.claimed_attempts, status.last_failure_code)
try: tasks.enqueue("generate-pdf", {"order_id": 2})except tasks.TasksError as e: if e.code == "TASK_MONTHLY_QUOTA_EXCEEDED": ... # 今月の枠を使い切ったimport { enqueue, get, TasksError } from "@keelsonhq/tasks";
const taskId = await enqueue("generate-pdf", { order_id: 1 }, { idempotencyKey: "order-1-pdf",});const status = await get(taskId);console.log(status.status, status.claimed_attempts, status.last_failure_code);
try { await enqueue("generate-pdf", { order_id: 2 });} catch (e) { if (e instanceof TasksError && e.code === "TASK_MONTHLY_QUOTA_EXCEEDED") { // 今月の枠を使い切った }}import "github.com/keelsonhq/go-sdk/tasks"
client, err := tasks.New()if err != nil { return err}taskID, err := client.Enqueue(ctx, "generate-pdf", map[string]int{"order_id": 1}, tasks.WithIdempotencyKey("order-1-pdf"))if err != nil { var te *tasks.Error if errors.As(err, &te) && te.Code == "TASK_MONTHLY_QUOTA_EXCEEDED" { // 今月の枠を使い切った } return err}status, err := client.Get(ctx, taskID)idempotency_key(冪等キー)を渡すと、同じタスク名・同じキーで投入し直しても新しいタスクは作られず、最初のタスクの ID が返ります。キーは 1〜128 文字の英数字・記号です。
get が返す状態(status)は次の 5 つです。
| 状態 | 意味 |
|---|---|
queued | 未処理。開始待ち・再試行待ちを含みます |
running | 実行中 |
succeeded | 成功した(終了コード 0) |
failed | 失敗した。理由は last_failure_code で分かります(失敗したとき) |
cancelled | 開始前に取り消された |
claimed_attempts は開始した試行の数、last_failure_code は最後に失敗した試行の理由です。
完了を画面に出したいときは、タスクのコマンドが結果をデータベースに書き、画面はデータベースの状態列を見る作りにします。get は確認の補助として使います。
コマンドの書き方
Section titled “コマンドの書き方”タスクのコマンドは、普通のプログラムです。Keelson はコマンドを起動すると、標準入力に次の JSON を 1 行書いてから閉じます。
{"attempt_no":1,"payload":{"order_id":1},"schema_version":1,"task_id":"…","task_name":"generate-pdf"}| キー | 内容 |
|---|---|
attempt_no | 何回目の試行か(1 から) |
payload | 投入時に渡した入力データ(省略時は null) |
schema_version | この入力の形式の版。現在は 1 |
task_id | タスクの ID。再試行しても変わりません |
task_name | タスクの名前 |
コマンドが終了コード 0 で終われば成功、それ以外は失敗です。
import jsonimport sys
doc = json.loads(sys.stdin.readline())order_id = doc["payload"]["order_id"]# ... PDF を作って保存する ...# 例外で終わると終了コードが 0 以外になり、失敗として扱われるimport { readFileSync } from "node:fs";
const doc = JSON.parse(readFileSync(0, "utf8"));const orderId = doc.payload.order_id;// ... PDF を作って保存する ...// 例外で終わると終了コードが 0 以外になり、失敗として扱われるvar doc struct { AttemptNo int `json:"attempt_no"` Payload json.RawMessage `json:"payload"` SchemaVersion int `json:"schema_version"` TaskID string `json:"task_id"` TaskName string `json:"task_name"`}if err := json.NewDecoder(os.Stdin).Decode(&doc); err != nil { log.Fatal(err) // 終了コード 1 で終わり、失敗として扱われる}// ... PDF を作って保存する ...タスクは Web アプリとはローカルディスクを共有しません。結果はデータベースか Files / Media SDK に保存してください。コマンドが読める環境変数は定期実行ジョブと同じです。
2 回走っても壊れないように書く
Section titled “2 回走っても壊れないように書く”タスクは少なくとも 1 回実行されます。失敗した試行は自動で再試行され、成功した後でも終了の連絡が Keelson に届かなければ、もう一度走ることがあります。同じタスクのコマンドが 2 回以上走っても結果が壊れないように(冪等に)書くのは、アプリ側の責任です。
次の 3 つを守ります。
-
状態を比較して書き換える:「未処理なら処理中にする」を、1 文の
UPDATEで行います。更新した行が 0 件なら、別の試行がすでに処理しているので終了します。UPDATE orders SET pdf_status = 'processing'WHERE id = ? AND pdf_status = 'pending'; -
外部 API には冪等キーを渡す:決済やメール送信など、同じ依頼を 2 回受けると困る外部 API には、冪等キーを渡します。再試行しても変わらない
task_idを使えます。 -
出力のファイル名や行のキーを固定する:
receipts/order-1.pdfのように、入力から決まる名前で保存します。日時や乱数を含む名前にすると、2 回走ったときに出力が 2 つできます。
注文を保存したら必ず処理する
Section titled “注文を保存したら必ず処理する”「注文を保存したら、必ず領収書のタスクも投入する」ようにしたい場合、注文の保存とタスクの投入を 1 つのトランザクションにはできません。注文はアプリのデータベースに、タスクは Keelson 側に記録されるためです。保存の直後にアプリが止まると、注文だけが残ってタスクが投入されません。
これを防ぐには、注文と同じトランザクションで「まだ投入していないタスク」の行(送信待ち行)をアプリのデータベースに書いておき、commit の後に投入します。一般に outbox パターンと呼ばれる方法です。
- 1 つのトランザクションで、注文の行と送信待ち行(ID・タスク名・入力・投入済みの印)を書く
- commit の後、送信待ち行の ID を
idempotency_keyにしてenqueueする - 投入に成功したら、送信待ち行に投入済みの印を付ける
- 投入に失敗した行や、印を付ける前にアプリが止まった行は、次のリクエストか定期実行ジョブで同じキーのまま送り直す。キーが同じなので、二重に投入されることはありません
Web(リクエスト処理) アプリのデータベース Keelson(タスク) │ ① 注文の行 + 送信待ち行を 1 つのトランザクションで書く │ ─────────────────────────────▶ │ │ ② enqueue(名前, 入力, idempotency_key = 送信待ち行の ID) │ ────────────────────────────────────────────────────────────▶ │ │ ◀──────────────────────────────────────────────── task_id ──── │ │ ③ 送信待ち行に投入済みの印を付ける │ ─────────────────────────────▶ │ ④ ②③の前に失敗・停止した行は、次のリクエストか cron が同じキーで送り直す(重複しない)import jsonimport uuidfrom keelson import tasks
def place_order(conn, order_id, items): outbox_id = str(uuid.uuid4()) conn.execute("INSERT INTO orders (id, items) VALUES (?, ?)", (order_id, items)) conn.execute( "INSERT INTO task_outbox (id, task_name, payload, enqueued) VALUES (?, ?, ?, 0)", (outbox_id, "send-receipt", json.dumps({"order_id": order_id})), ) conn.commit() flush_outbox(conn)
def flush_outbox(conn): # 次のリクエストや定期実行ジョブからも呼ぶ rows = conn.execute( "SELECT id, task_name, payload FROM task_outbox WHERE enqueued = 0" ).fetchall() for outbox_id, name, payload in rows: tasks.enqueue(name, json.loads(payload), idempotency_key=outbox_id) conn.execute("UPDATE task_outbox SET enqueued = 1 WHERE id = ?", (outbox_id,)) conn.commit()失敗したとき
Section titled “失敗したとき”自動の再試行:コマンドが走って失敗した場合(exit_nonzero・timed_out・no_completion)、試行回数が max_attempts に達していなければ、10 秒・20 秒・40 秒・80 秒の間隔を空けて再試行します。間隔は指定できません。
コマンドが走る前に終わった場合(delivery_failed・manifest_unresolvable・quota_exhausted)は、再試行せずにそのまま failed になります。
last_failure_code | 意味 |
|---|---|
exit_nonzero | コマンドが 0 以外の終了コードで終わった |
timed_out | 制限時間(timeout)を超えて打ち切られた |
no_completion | 終了の連絡が無いまま期限を過ぎた |
delivery_failed | 実行の準備ができてから 1 時間以内に試行を開始できなかった |
manifest_unresolvable | 待っている間にデプロイが 2 回以上重なり、試行に使う宣言を解決できなかった |
quota_exhausted | 再試行の分、または月をまたいで開始する分の月間枠を確保できなかった |
原因を調べる:コンソールのアプリ画面の「バックグラウンドタスク」タブで、タスクの状態と、最後の試行の標準エラー(256 KiB まで。シークレットの値は伏せて表示)を確認できます。標準エラーは SDK の get では返りません。
やり直す:手動の再実行はありません。やり直すには、アプリから投入し直します。冪等キーを付ける場合は、元のタスクとは別のキーにします(同じキーでは元のタスクが返ります)。
取り消されるとき:次の操作をすると、対象の queued のタスクは cancelled になります。実行中の試行は最後まで走りますが、失敗しても再試行しません。
- アプリの手動のサスペンド
- 不正利用によるアプリの隔離
- アプリの削除(タスクの記録も消えます)
tasksからその名前を外すデプロイ(外した名前のタスクだけ)
このほか、コンソールの「バックグラウンドタスク」タブから、指定した queued のタスクを 1 件ずつ取り消せます。アクセスが無いことによるアプリのスリープでは、タスクは取り消されません。
| 項目 | 上限 |
|---|---|
| 宣言数 | アプリあたり 10 |
未処理(queued)のタスク | アプリあたり 1,000 件。超えると投入が TASK_BACKLOG_LIMIT_EXCEEDED で拒否されます |
| 1 回の試行の実行時間 | 既定 300 秒・最大 600 秒。プランごとの上限は Starter 180 秒 / Plus 300 秒 / Team 以上 600 秒 |
| 試行回数 | 既定 3 回・最大 5 回 |
| 入力の大きさ | 投入要求全体(payload と idempotency_key を JSON にしたもの)で 65,536 bytes。超えると TASK_PAYLOAD_TOO_LARGE |
| 入力の保存期間 | タスクが終わってから 7 日で削除 |
| 月間枠 | 定期実行ジョブと合わせた「バックグラウンド実行」の回数と合計実行時間 |
timeout を省略したときの既定(300 秒)がプランの上限を超える場合は、上限に切り下げます。上限を超える値を明示すると、デプロイは拒否されます。
月間枠では、開始した試行 1 回を 1 回と数え、再試行も 1 回ずつ数えます。上限に達すると、新規の投入は TASK_MONTHLY_QUOTA_EXCEEDED で拒否されます。同じ冪等キーでの投入し直しは、上限に達していても最初のタスクを返します。
プランにより同時に走るタスクの数に上限があり、超えた分は順番を待ちます。月間枠の数値はプランと制限を参照してください。
タスクは Web アプリのリクエスト処理だけでなく、定期実行ジョブやタスクのコマンドからも投入できます。たとえば、毎晩の定期実行ジョブで対象を探し、1 件ずつタスクに投入できます。
認証は Keelson が自動で行います。コードにトークンを書く必要はありません。
手元で動かす
Section titled “手元で動かす”Keelson の外(KEELSON_MODE=local を指定したとき、または Keelson の環境変数が無いとき)では、SDK の enqueue が CLI の keelson dev task run を子プロセスとして起動し、コマンドが終わるまで待ってから返ります(同期実行)。
本番とは次の点が違います。
enqueueが、処理が終わるまで返りません- 別のインスタンスではなく、同じ開発機の子プロセスで走ります。本番では見えないローカルのファイルが見えてしまいます
- 再試行しません。コマンドが失敗しても
enqueueはエラーにならず、getがfailedを返します - 同時実行の制限・未処理件数の上限・月間枠がありません
- 実行時間はプランで切り下げず、宣言した
timeoutをそのまま使います - 冪等キーで重複を防げるのは、同じプロセスの中で順に投入した場合だけです。同時の投入・別のプロセス・再起動の後は防ぎません
getで引けるのは、同じプロセスの中で投入したタスクだけです
手元で通っても、本番で同じ結果になるとは限りません。本番では再試行やプランの上限がかかり、試行が 2 回以上走ることがあります。
手元で動かすには、keelson dev task run を含む版の CLI が必要です。古い CLI では enqueue が TASKS_LOCAL_CLI_FAILED になるので、keelson upgrade で更新してください。
アプリを通さずにコマンドだけを試すこともできます。
keelson dev task run generate-pdf --payload '{"order_id": 1}'