wrangler.toml — キュー設定の本体
最初にwrangler.tomlを仕上げます。ここを雑に書くと後から手戻りが大変なので、一度通しで書いてしまうのがおすすめです。
name = "claude-pipeline"
main = "src/index.ts"
compatibility_date = "2026-04-01"
# Producer + Consumerを同じWorkerで提供するパターン
[[ queues . producers ]]
queue = "claude-jobs-high"
binding = "QUEUE_HIGH"
[[ queues . producers ]]
queue = "claude-jobs-bulk"
binding = "QUEUE_BULK"
[[ queues . consumers ]]
queue = "claude-jobs-high"
max_batch_size = 5 # 1度のバッチで取り出す最大数
max_batch_timeout = 2 # 2秒待ってもバッチが埋まらなければ起動
max_retries = 5 # 一時エラーの最大リトライ
max_concurrency = 10 # 同時起動するConsumer数の上限
dead_letter_queue = "claude-jobs-dlq"
[[ queues . consumers ]]
queue = "claude-jobs-bulk"
max_batch_size = 10
max_batch_timeout = 30
max_retries = 3
max_concurrency = 3 # バルクは控えめに
dead_letter_queue = "claude-jobs-dlq"
[[ queues . consumers ]]
queue = "claude-jobs-dlq"
max_batch_size = 1
max_concurrency = 1
[[ d1_databases ]]
binding = "DB"
database_name = "claude-jobs"
database_id = "YOUR_D1_DATABASE_ID"
[[ r2_buckets ]]
binding = "RESULTS"
bucket_name = "claude-results"
ポイントはmax_concurrencyをHigh側を10、Bulk側を3に振っているところです。Claude APIのRPM制限(モデル・組織で異なるが概ね4,000RPM前後)を考えると、両方合わせて13並列でも安全圏に収まります。ここを一律10にするとバルクジョブが大量投入された瞬間にHighキューが詰まります。
Producer — リクエストハンドラはキューに積むだけ
Producer側は驚くほど短く済みます。重要なのは「ユーザーがリトライ可能な形でジョブIDを返すこと」だけです。
// src/producer.ts
import type { Env, Job } from './types' ;
export async function enqueueJob (
request : Request ,
env : Env
) : Promise < Response > {
const body = await request. json <{ prompt : string ; priority ?: 'high' | 'bulk' }>();
// 入力検証 — Claude APIに到達する前に弾く方が安い
if ( ! body.prompt || body.prompt. length > 200_000 ) {
return Response. json (
{ error: 'prompt missing or exceeds 200k chars' },
{ status: 400 }
);
}
const jobId = crypto. randomUUID ();
const job : Job = {
jobId,
prompt: body.prompt,
createdAt: Date. now (),
attempts: 0 ,
userId: request.headers. get ( 'x-user-id' ) ?? 'anonymous' ,
};
// D1に「pending」として記録 — ステータスポーリングのため
await env. DB . prepare (
'INSERT INTO jobs (job_id, status, created_at, user_id) VALUES (?, ?, ?, ?)'
)
. bind (jobId, 'pending' , job.createdAt, job.userId)
. run ();
// キューに積む — Highは即時、Bulkは遅延もOK
const queue = body.priority === 'bulk' ? env. QUEUE_BULK : env. QUEUE_HIGH ;
await queue. send (job, {
contentType: 'json' ,
// bulkは少し遅延させてHighの邪魔をしない
delaySeconds: body.priority === 'bulk' ? 5 : 0 ,
});
// 期待出力: { jobId: "...", status: "pending", pollUrl: "/jobs/..." }
return Response. json (
{ jobId, status: 'pending' , pollUrl: `/jobs/${ jobId }` },
{ status: 202 }
);
}
D1へのINSERTを先に行うのは、Queueが死んだ場合に「依頼があったのに記録がない」という最悪ケースを避けるためです。逆順にするとどんな手段でも復旧できなくなります。
Consumer — Claude API呼び出しと正しいack/retry/DLQ分岐
これが本記事の中核です。私が最初に書いたConsumerはエラーがあれば全部message.retry()していたのですが、これは間違いです。Claude APIのエラーは「再試行で直るもの」と「直らないもの」が明確に分かれており、ここを正しく分岐しないと、永久に直らないジョブが3時間ごとに自動再試行されてClaude APIの請求だけが膨らみます。
// src/consumer.ts
import Anthropic from '@anthropic-ai/sdk' ;
import type { Env, Job } from './types' ;
// 一時エラー — リトライで回復する可能性が高い
const RETRYABLE_STATUS = new Set ([ 408 , 429 , 500 , 502 , 503 , 504 , 529 ]);
export async function consumeJobs (
batch : MessageBatch < Job >,
env : Env ,
ctx : ExecutionContext
) {
const client = new Anthropic ({ apiKey: env. ANTHROPIC_API_KEY });
// バッチ内の各メッセージを並列処理 — Cloudflare Queuesは元々バッチで届く
await Promise . all (
batch.messages. map (( msg ) => processOne (msg, client, env, ctx))
);
}
async function processOne (
msg : Message < Job >,
client : Anthropic ,
env : Env ,
ctx : ExecutionContext
) {
const job = msg.body;
const startedAt = Date. now ();
try {
// タイムアウトを明示的に切る — Workerの30sと衝突させない
const controller = new AbortController ();
const timeoutId = setTimeout (() => controller. abort (), 25_000 );
const response = await client.messages. create (
{
model: 'claude-sonnet-4-6' ,
max_tokens: 4096 ,
messages: [{ role: 'user' , content: job.prompt }],
},
{ signal: controller.signal }
);
clearTimeout (timeoutId);
const text = response.content
. filter (( b ) => b.type === 'text' )
. map (( b ) => (b as { text : string }).text)
. join ( ' \n ' );
// 結果はR2に保存 — D1のサイズ制限を踏まないため
await env. RESULTS . put ( `results/${ job . jobId }.txt` , text, {
httpMetadata: { contentType: 'text/plain; charset=utf-8' },
});
// D1のステータスを更新
await env. DB . prepare (
'UPDATE jobs SET status = ?, completed_at = ?, input_tokens = ?, output_tokens = ? WHERE job_id = ?'
)
. bind (
'completed' ,
Date. now (),
response.usage.input_tokens,
response.usage.output_tokens,
job.jobId
)
. run ();
msg. ack (); // 正常完了 — キューから削除される
} catch ( err : unknown ) {
const apiErr = err as { status ?: number ; message ?: string ; name ?: string };
// 直らないエラー — DLQへ即送り
if (apiErr.status === 400 || apiErr.status === 401 || apiErr.status === 403 ) {
await env. DB . prepare (
'UPDATE jobs SET status = ?, error = ? WHERE job_id = ?'
)
. bind ( 'failed' , `${ apiErr . status }: ${ apiErr . message }` , job.jobId)
. run ();
msg. ack (); // ackすることで「DLQには送らない」明示的失敗
return ;
}
// 一時エラー — 指数バックオフでリトライ
if (
apiErr.name === 'AbortError' ||
RETRYABLE_STATUS . has (apiErr.status ?? 0 )
) {
const attempt = msg.attempts ?? 0 ;
const delaySeconds = Math. min ( 60 * Math. pow ( 2 , attempt), 600 ); // 最大10分
msg. retry ({ delaySeconds });
return ;
}
// 想定外 — DLQに任せる
msg. retry ();
}
}
ここでmsg.ack()とmsg.retry()を使い分けているのが要点です。ackはキューから消える(成功扱い)、retryはmax_retries回まで自動再投入される、何もしなければデフォルトでretry扱い、です。max_retriesを超えるとDLQに自動送信されます。直らないエラーはackしつつD1にfailedを記録することで、DLQを汚染せずに済みます。
デッドレターキューでの最終処理
DLQには「すべてのリトライを使い切ったジョブ」が来ます。ここでやるべきことはClaude APIの再呼び出しではなく、人間(または別のフロー)に渡す準備です。
// src/dlq.ts
import type { Env, Job } from './types' ;
export async function consumeDeadLetters (
batch : MessageBatch < Job >,
env : Env
) {
for ( const msg of batch.messages) {
const job = msg.body;
// ステータスをdead-lettered更新
await env. DB . prepare (
'UPDATE jobs SET status = ?, error = ? WHERE job_id = ?'
)
. bind ( 'dead-lettered' , 'exceeded retry budget' , job.jobId)
. run ();
// 重要: ユーザー通知(メール・Webhook等)
if (env. NOTIFY_WEBHOOK_URL ) {
await fetch (env. NOTIFY_WEBHOOK_URL , {
method: 'POST' ,
headers: { 'content-type' : 'application/json' },
body: JSON . stringify ({
event: 'job.failed' ,
jobId: job.jobId,
userId: job.userId,
createdAt: job.createdAt,
}),
}). catch (() => {}); // 通知失敗は無視(DLQの再DLQは作らない)
}
msg. ack ();
}
}
fetchの失敗を無視しているのは意図的です。DLQ消費中にさらにエラーを起こすと、DLQ自身がリトライループに陥ります。観察可能性は別経路(Logpush等)で担保し、DLQコンシューマーは「最低限の記録だけ確実に行う」設計にするのが安全です。
トークン予算とバックプレッシャー
Claude APIは「秒単位のRPM制限」と「1分あたりトークン制限(TPM)」が独立しています。Queuesのmax_concurrencyはリクエスト数しか制御できないので、トークン消費が大きいプロンプトが連続するとTPMの方が先に枯れます。
// src/budget.ts — Durable Objectでトークン残量を管理
export class TokenBudget {
state : DurableObjectState ;
// 1分あたりの予算(RPM/TPMはAnthropicコンソールで確認)
private readonly perMinute = 400_000 ;
constructor ( state : DurableObjectState ) {
this .state = state;
}
async fetch ( request : Request ) : Promise < Response > {
const url = new URL (request.url);
if (url.pathname !== '/reserve' ) return new Response ( 'not found' , { status: 404 });
const requested = Number (url.searchParams. get ( 'tokens' ) ?? '0' );
const now = Math. floor (Date. now () / 60_000 ); // 分単位のバケット
const stored = await this .state.storage. get <{ minute : number ; used : number }>(
'bucket'
);
const bucket = ! stored || stored.minute !== now
? { minute: now, used: 0 }
: stored;
if (bucket.used + requested > this .perMinute) {
// 予算切れ — Consumerにretryさせる
return Response. json ({ allowed: false , retryAfterSeconds: 60 - (Date. now () % 60_000 ) / 1000 });
}
bucket.used += requested;
await this .state.storage. put ( 'bucket' , bucket);
return Response. json ({ allowed: true });
}
}
Consumer側はmessages.createの前にこのDurable Objectに「使う予定のトークン量」を予約問い合わせし、拒否されたらmsg.retry()で時間を空けます。プロンプト長から大まかに推定するだけでも、TPM由来の429を9割以上減らせます。詳細なレート制限の対処法は別記事 にまとめています。
よくある間違い・落とし穴
実装中に私自身が踏んだ罠と、コードレビューで見かけたミスを5つ挙げます。
1. ackを呼ばないまま例外を投げる : Cloudflare Queuesは「例外で抜けるとretry扱い」がデフォルトです。try/catchで握り潰したのにackを呼び忘れると、無事処理した後にもう一度Claude APIを叩くという最悪のパターンになります。成功時は明示的にmsg.ack()を呼ぶ習慣にしてください。
2. max_batch_sizeを大きくしすぎる : max_batch_size: 100にして並列で全部Claude APIに投げると、瞬間的に100並列のリクエストが上流に到達します。これでレート制限に直撃した話を何度も見ています。max_batch_sizeは5〜10にして、Consumer内ではPromise.allでなくp-limit等で同時並列を絞るのがおすすめです。
3. リトライ回数の上限を信用しない : max_retries: 5の指数バックオフでも、Claude APIが完全に落ちている時間帯では5回全て無駄に消費して終わります。「24時間以内に完了しなければそもそも価値がない」要件のジョブには、Producer側にexpiresAtを持たせ、Consumer冒頭で期限切れを判定してackする処理を入れてください。
4. プロンプトをキューにそのまま乗せる : Cloudflare Queuesのメッセージ上限は128 KiBです。長文プロンプトをそのまま送ると上限に引っかかります。プロンプト本体はR2かD1に保存し、キューにはIDだけ入れる「クレームチェック・パターン 」が定石です。
5. DLQの監視を後回しにする : DLQは「気づいたら数千件溜まっていた」という事故が起きやすい場所です。Consumer起動時にSELECT COUNT(*) FROM jobs WHERE status='dead-lettered' AND created_at > ?を実行し、閾値を超えたらSlackに通知する仕掛けを最初から入れることを強く推奨します。
本番想定の応用例:ユーザーアップロードPDFの並列分析
ここまでの設計を、私が実際に運用しているPDF分析サービスに当てはめてみます。ユーザーが10〜100件のPDFをまとめてアップロードし、それぞれを要約 + 構造化抽出する、というワークロードです。
// src/pdf-pipeline.ts
import type { Env } from './types' ;
export async function uploadAndDispatch (
request : Request ,
env : Env
) : Promise < Response > {
const form = await request. formData ();
const files = form. getAll ( 'files' ) as File [];
if (files. length === 0 || files. length > 100 ) {
return Response. json (
{ error: '1〜100ファイルを送ってください' },
{ status: 400 }
);
}
const batchId = crypto. randomUUID ();
const userId = request.headers. get ( 'x-user-id' ) ?? 'anonymous' ;
// 各ファイルをR2に保存し、jobIdごとにキューへ
const jobs = await Promise . all (
files. map ( async ( file , idx ) => {
const jobId = crypto. randomUUID ();
const key = `uploads/${ batchId }/${ jobId }.pdf` ;
await env. RESULTS . put (key, file. stream ());
return {
jobId,
batchId,
userId,
r2Key: key,
idx,
promptTemplate: 'pdf-summarize-v1' ,
createdAt: Date. now (),
};
})
);
// バルクキューに送る — 1ユーザーが大量投入してもHighキューに影響しない
await env. QUEUE_BULK . sendBatch (
jobs. map (( j ) => ({ body: j, contentType: 'json' }))
);
// batchIdでステータス追跡できる
return Response. json (
{
batchId,
total: files. length ,
pollUrl: `/batches/${ batchId }` ,
},
{ status: 202 }
);
}
Consumer側はR2からGETしてClaude APIのdocument blockに渡す処理が増えるだけで、骨格は変わりません。1ユーザーが100件投げても、Bulkキューのmax_concurrency: 3によりClaude APIは最大3並列に制限され、High側でリアルタイム対話している別ユーザーには影響が出ません。これがキューを噛ませる本当の価値です。
D1スキーマと観察可能性 — 後悔しないための初日設定
ステータス管理だけでなく、後から運用判断ができる粒度の情報を最初から記録することが本番運用では決定的に効きます。実際に私が落ち着いた最終形のスキーマは次の通りです。
-- migrations/0001_jobs.sql
CREATE TABLE jobs (
job_id TEXT PRIMARY KEY ,
batch_id TEXT ,
user_id TEXT NOT NULL ,
status TEXT NOT NULL CHECK ( status IN ( 'pending' , 'running' , 'completed' , 'failed' , 'dead-lettered' )),
priority TEXT NOT NULL DEFAULT 'high' ,
created_at INTEGER NOT NULL ,
started_at INTEGER ,
completed_at INTEGER ,
attempts INTEGER NOT NULL DEFAULT 0 ,
input_tokens INTEGER ,
output_tokens INTEGER ,
model TEXT ,
error TEXT
);
CREATE INDEX idx_jobs_status_created ON jobs ( status , created_at);
CREATE INDEX idx_jobs_user_status ON jobs (user_id, status );
CREATE INDEX idx_jobs_batch ON jobs (batch_id);
statusを文字列のままにせずCHECK制約を入れているのは、コードのバグで「pendigg」のような誤った値が入った瞬間に気づくためです。本番でしばらく運用すると、status='completed'なのにoutput_tokensがNULLという矛盾データが出てくることが必ずあります。CHECK制約でこの種の混入を最小化できます。
観察可能性のために最低限ダッシュボード化したい指標は4つです。
レイテンシのp50/p95/p99 : completed_at - started_atをbucketごとに集計。Sonnet 4.6で長文要約だとp95は40秒前後、p99は80秒を超える日もあるので、外形監視のSLOはp95で60秒程度に置くと現実的です
失敗率 : failedとdead-letteredを分けて追う。両者は意味が違います(恒久エラーかリトライ枯渇か)
キュー深度 : pendingで滞留しているジョブの最古タイムスタンプ。これが10分を超えたらConsumer不足、または上流のClaude APIが詰まっているサイン
トークン消費 : ユーザー別・モデル別の累計。コスト管理の出発点
これらはSELECT一発で出るのでスケジュールタスクに組み込むのが最も簡単です。Cloudflare Workersのscheduledハンドラーで毎分集計してD1のmetricsテーブルに書き、Grafana Cloudにエクスポートする構成が私の現在の運用です。
ローカル開発と再現可能なテスト
wrangler devはQueuesのローカル動作もサポートしているため、開発体験は悪くありません。ただし、Claude APIへの実呼び出しを毎回行うとコストが膨らむので、開発時はAnthropicクライアントをモックに差し替える設計が必須です。
// src/__tests__/consumer.test.ts
import { describe, it, expect, vi } from 'vitest' ;
import { processOne } from '../consumer' ;
describe ( 'processOne' , () => {
it ( 'completed状態でD1に書く正常系' , async () => {
const fakeClient = {
messages: {
create: vi. fn (). mockResolvedValue ({
content: [{ type: 'text' , text: 'OK' }],
usage: { input_tokens: 100 , output_tokens: 20 },
}),
},
};
const ackSpy = vi. fn ();
const retrySpy = vi. fn ();
const msg = {
body: { jobId: 'job-1' , prompt: 'hello' , userId: 'u1' , createdAt: 0 , attempts: 0 },
ack: ackSpy,
retry: retrySpy,
attempts: 0 ,
};
const env = {
RESULTS: { put: vi. fn (). mockResolvedValue ( undefined ) },
DB: {
prepare : () => ({ bind : () => ({ run: vi. fn (). mockResolvedValue ({}) }) }),
},
ANTHROPIC_API_KEY: 'sk-test' ,
};
await processOne (msg as any , fakeClient as any , env as any , {} as any );
expect (ackSpy). toHaveBeenCalledOnce ();
expect (retrySpy).not. toHaveBeenCalled ();
});
it ( '429はretry、400はack' , async () => {
const fakeClient = {
messages: {
create: vi. fn ()
. mockRejectedValueOnce (Object. assign ( new Error ( 'rate limited' ), { status: 429 }))
. mockRejectedValueOnce (Object. assign ( new Error ( 'bad request' ), { status: 400 })),
},
};
// 1回目: 429 → retry呼ばれる
const ack1 = vi. fn (), retry1 = vi. fn ();
await processOne (
{ body: { jobId: 'j' , prompt: 'p' , userId: 'u' , createdAt: 0 , attempts: 0 }, ack: ack1, retry: retry1, attempts: 0 } as any ,
fakeClient as any ,
{ /* env stub */ } as any ,
{} as any
);
expect (retry1). toHaveBeenCalled ();
expect (ack1).not. toHaveBeenCalled ();
// 2回目: 400 → ack呼ばれる(リトライ不可)
const ack2 = vi. fn (), retry2 = vi. fn ();
await processOne (
{ body: { jobId: 'j' , prompt: 'p' , userId: 'u' , createdAt: 0 , attempts: 0 }, ack: ack2, retry: retry2, attempts: 0 } as any ,
fakeClient as any ,
{ DB: { prepare : () => ({ bind : () => ({ run: vi. fn () }) }) } } as any ,
{} as any
);
expect (ack2). toHaveBeenCalled ();
});
});
このテストの肝は、Claude APIへの実呼び出しを完全に避けつつ、429と400で挙動が分岐することを保証している点です。本番デプロイのたびにこの2ケースが通ることを確認できれば、誤って全エラーをretryに分類する退行バグは防げます。
段階的な本番投入とフィーチャーフラグ
既存のClaude API呼び出しが動いているサービスでQueuesに切り替える場合、いきなり全トラフィックを移すのは危険です。私は次の4段階で段階的に切り替えています。
第一段階は影響の小さいエンドポイントだけを移します。たとえば「ユーザーのバッジ説明文を生成する」のような、即時性が要らず失敗しても致命的でない処理から始めるのが定石です。第二段階で対話系のエンドポイントを移しますが、ここでフィーチャーフラグを使い5%のユーザーだけを新パイプラインに流します。Cloudflare Workersのrequest.cf?.coloやcrypto.subtle.digest('SHA-1', userId)のハッシュで決定論的に振り分けます。
// src/feature-flag.ts
async function isInRollout ( userId : string , percent : number ) : Promise < boolean > {
const buf = new TextEncoder (). encode (userId);
const hash = await crypto.subtle. digest ( 'SHA-1' , buf);
const view = new DataView (hash);
const value = view. getUint32 ( 0 ) % 100 ;
return value < percent;
}
export async function callClaude ( prompt : string , userId : string , env : Env ) {
if ( await isInRollout (userId, env. QUEUE_ROLLOUT_PERCENT ?? 5 )) {
return enqueueAndPoll (prompt, userId, env); // 新経路
}
return callDirect (prompt, env); // 旧経路
}
第三段階で50%、第四段階で100%に切り替え、各段階で最低48時間は様子を見ます。観察可能性のダッシュボードでp95レイテンシ・失敗率・コストの3指標が新旧で同等以上であることを確認してから次のステージに進みます。
この段階的ロールアウトのもう一つの利点は、問題が起きてもQUEUE_ROLLOUT_PERCENTを即座に0に戻すだけでロールバックできることです。デプロイは要らず、wrangler secret put一発で全Workerに反映されます。
実コストの見積もり — 1万件処理した場合
設計の良し悪しは結局コストで効いてきます。実際にPDF分析パイプラインで1万件のClaude API呼び出しを処理したときの内訳をそのまま晒します。プロンプトは平均3,000トークン、出力は平均800トークン、モデルはSonnet 4.6で計算しています。
Claude API : 入力 3,000 × 10,000 = 3,000万トークン、出力 800 × 10,000 = 800万トークン。Sonnet 4.6の従量課金で約 $135(入力 $90 + 出力 $45)。プロンプトキャッシュを併用するともう一段下げられます
Cloudflare Workers : Producerが1万リクエスト、Consumerが1万実行 + α(リトライ含めて1.05倍と仮定)。有料プランの基本料$5に対し、Workers Paidの含まれる無料枠を超える分はわずかです。実測で月$0.5未満
Cloudflare Queues : 1メッセージあたり1〇operationとして、Producer書き込み + Consumer読み取り + ack で約3 ops × 1万 = 3万 ops。Queues Standardの料金は最初の100万 operations が含まれているため事実上無料
D1 : 1万行の書き込みと数倍のread。これも有料プランの含まれる枠内
R2 : 入力PDF + 出力テキストで合計5〜10GB保存。$0.015/GB/月で月$0.15
合計してClaude API以外のCloudflare費用は$5〜6/月で、可変分はほぼClaude API課金です。Queuesを噛ませることで「Workerが死んでもう一度叩く」ような無駄なコストが消える方が、インフラ追加コストよりはるかに大きいというのが実感です。プロンプトキャッシュとモデル選択の最適化についてはAnthropic APIコスト最適化ガイド に詳しくまとめています。
バッチエンドポイントとQueuesの使い分け
ここまで読むと「全部Queuesでよくない?」と思うかもしれません。実際にはClaude APIのMessages Batches API とQueuesは補完関係にあり、判断基準は明確です。
ユーザーが結果を1分以内に見たい場合はQueues一択です。バッチAPIは24時間以内に完了する保証なので、対話系UIには向きません。一方、夜間に蓄積したログを翌朝までに要約する、というワークロードならバッチAPIの方が単価が半額(50%オフ)なので有利です。
実運用では、ConsumerからバッチAPIへ流すパターンも組み合わせます。Highキューはリアルタイム呼び出し、Bulkキューは夜間バッチAPI送信、というハイブリッドです。Bulk Consumerは「キューから取り出してjobIdをBatchに加える」だけになり、Claudeへの実呼び出しはバッチAPIが行います。これでさらにコストが下がりつつ、ジョブ管理の一貫性は保たれます。
冪等性 — 「もう一度実行されても安全」を保証する
Cloudflare Queuesに限らず、リトライがあるシステムは構造上「同じメッセージが2回以上処理される」可能性を排除できません。Claude APIのレスポンスがR2に書き終わった瞬間にWorkerがクラッシュしてackが届かない、というシナリオを防ぐ手段はないので、Consumerは「2回呼ばれても結果が同じ」設計(冪等性)であることが前提になります。
最もシンプルかつ確実な方法は、jobIdをR2のキーと書き込み条件に使うことです。
// src/idempotent-write.ts
async function writeResultIfFirst (
env : Env ,
jobId : string ,
text : string
) : Promise < 'wrote' | 'already-existed' > {
const key = `results/${ jobId }.txt` ;
const head = await env. RESULTS . head (key);
if (head) {
return 'already-existed' ; // 別のConsumerが既に書いた
}
await env. RESULTS . put (key, text, {
httpMetadata: { contentType: 'text/plain; charset=utf-8' },
onlyIf: { etagDoesNotMatch: '*' }, // 既存があれば失敗
});
return 'wrote' ;
}
onlyIfの条件付きPUTを使うと、同じキーで2つのConsumerが競合しても1つしか成功しません。already-existedの場合は「自分は遅れて来た2回目の実行だった」と判断し、Claude APIを叩かずに即ackを返すフローにします。これでClaude APIへの二重課金を構造的に防げます。
D1側も同じ考え方で、UPDATE jobs SET ... WHERE job_id = ? AND status != 'completed'のように条件付きで書きます。これで「completedをcompletedで上書きする」という意味のない処理を避けつつ、最初に成功した実行のtoken使用量を保護できます。
冪等性をConsumerだけでなくProducerにも持たせると、ユーザー側のリトライにも強くなります。ProducerのenqueueJobにIdempotency-Keyヘッダを受け取り、同じキーで2回呼ばれた場合は同じjobIdを返すようにすれば、ネットワークの揺らぎでクライアントが二重投稿しても影響しません。これは決済APIの世界では当たり前のパターンですが、AI APIパイプラインでも同じ価値があります。
次に手をつける一歩
最初から完璧なパイプラインを目指すと挫折します。今日できる最小の一歩はこうです。
既存のClaude API呼び出しの中で、ユーザー応答にとって「待たせても許される処理」を1つだけ選び、Cloudflare QueuesのQUEUE_BULKに切り出してください。Producer側のコード追加は10行未満です。Consumerはこの記事のprocessOneを雛形にすればその日のうちに動きます。1本切り出せれば、残りの呼び出しを順次同じパターンに乗せていく作業は驚くほど機械的になります。
なお、複数モデルでフォールバックする戦略を組み合わせると、可用性はさらに上がります。あわせてClaude APIのマルチモデルフォールバック設計 もご覧ください。