コンテンツにスキップ
コンソール →
公式サイト →
URL を貼り付けて AI に質問 https://keelson.dev/ja/llms.txt

バックグラウンドタスク

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-app
runtime: python-slim
command: "python app.py"
db:
mode: libsql
tasks:
- name: generate-pdf
command: "python generate_pdf.py"
timeout: 180 # 1 回の試行を最大 180 秒にする(全プランで有効)
max_attempts: 3 # 初回を含めて最大 3 回まで試行する
項目意味
nameタスクの名前。小文字英数字とハイフンで指定します。投入時にこの名前を指定します
command1 回分の処理を実行し、終了するコマンド
timeout1 回の試行の制限時間(秒)。省略時は 300 秒(プランの上限がそれより短ければ上限)。上限を超える値を書くとデプロイが拒否されます
max_attempts初回を含めた試行の最大回数。省略時は 3 回

name と command は必須です。項目の範囲や検証のルールは keelson.yaml リファレンスを参照してください。

アプリのコードから、宣言した名前と入力データを指定して投入します。投入はすぐに返り、タスクの 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":
... # 今月の枠を使い切った

idempotency_key(冪等キー)を渡すと、同じタスク名・同じキーで投入し直しても新しいタスクは作られず、最初のタスクの ID が返ります。キーは 1〜128 文字の英数字・記号です。

get が返す状態(status)は次の 5 つです。

状態意味
queued未処理。開始待ち・再試行待ちを含みます
running実行中
succeeded成功した(終了コード 0)
failed失敗した。理由は last_failure_code で分かります(失敗したとき)
cancelled開始前に取り消された

claimed_attempts は開始した試行の数、last_failure_code は最後に失敗した試行の理由です。

完了を画面に出したいときは、タスクのコマンドが結果をデータベースに書き、画面はデータベースの状態列を見る作りにします。get は確認の補助として使います。

タスクのコマンドは、普通のプログラムです。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 json
import sys
doc = json.loads(sys.stdin.readline())
order_id = doc["payload"]["order_id"]
# ... PDF を作って保存する ...
# 例外で終わると終了コードが 0 以外になり、失敗として扱われる

タスクは Web アプリとはローカルディスクを共有しません。結果はデータベースか Files / Media SDK に保存してください。コマンドが読める環境変数は定期実行ジョブと同じです。

2 回走っても壊れないように書く

Section titled “2 回走っても壊れないように書く”

タスクは少なくとも 1 回実行されます。失敗した試行は自動で再試行され、成功した後でも終了の連絡が Keelson に届かなければ、もう一度走ることがあります。同じタスクのコマンドが 2 回以上走っても結果が壊れないように(冪等に)書くのは、アプリ側の責任です。

次の 3 つを守ります。

  1. 状態を比較して書き換える:「未処理なら処理中にする」を、1 文の UPDATE で行います。更新した行が 0 件なら、別の試行がすでに処理しているので終了します。

    UPDATE orders SET pdf_status = 'processing'
    WHERE id = ? AND pdf_status = 'pending';
  2. 外部 API には冪等キーを渡す:決済やメール送信など、同じ依頼を 2 回受けると困る外部 API には、冪等キーを渡します。再試行しても変わらない task_id を使えます。

  3. 出力のファイル名や行のキーを固定する:receipts/order-1.pdf のように、入力から決まる名前で保存します。日時や乱数を含む名前にすると、2 回走ったときに出力が 2 つできます。

注文を保存したら必ず処理する

Section titled “注文を保存したら必ず処理する”

「注文を保存したら、必ず領収書のタスクも投入する」ようにしたい場合、注文の保存とタスクの投入を 1 つのトランザクションにはできません。注文はアプリのデータベースに、タスクは Keelson 側に記録されるためです。保存の直後にアプリが止まると、注文だけが残ってタスクが投入されません。

これを防ぐには、注文と同じトランザクションで「まだ投入していないタスク」の行(送信待ち行)をアプリのデータベースに書いておき、commit の後に投入します。一般に outbox パターンと呼ばれる方法です。

  1. 1 つのトランザクションで、注文の行と送信待ち行(ID・タスク名・入力・投入済みの印)を書く
  2. commit の後、送信待ち行の ID を idempotency_key にして enqueue する
  3. 投入に成功したら、送信待ち行に投入済みの印を付ける
  4. 投入に失敗した行や、印を付ける前にアプリが止まった行は、次のリクエストか定期実行ジョブで同じキーのまま送り直す。キーが同じなので、二重に投入されることはありません
Web(リクエスト処理) アプリのデータベース Keelson(タスク)
│ ① 注文の行 + 送信待ち行を 1 つのトランザクションで書く
│ ─────────────────────────────▶ │
│ ② enqueue(名前, 入力, idempotency_key = 送信待ち行の ID)
│ ────────────────────────────────────────────────────────────▶ │
│ ◀──────────────────────────────────────────────── task_id ──── │
│ ③ 送信待ち行に投入済みの印を付ける
│ ─────────────────────────────▶ │
④ ②③の前に失敗・停止した行は、次のリクエストか cron が同じキーで送り直す(重複しない)
import json
import uuid
from 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()

自動の再試行:コマンドが走って失敗した場合(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 が自動で行います。コードにトークンを書く必要はありません。

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 で更新してください。

アプリを通さずにコマンドだけを試すこともできます。

Terminal window
keelson dev task run generate-pdf --payload '{"order_id": 1}'