お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」は、2026年10月に英語版のページを公開しました。日本語の媒体から集めた記事も英語で読めるように、過去の記事へ英語の要約とタイトルを後から付けていく処理(バックフィル)を動かしています。今回は、そのうち英語タイトルの翻訳だけが何時間も1件も進まなかった原因と、直したあとに起きたことを書きます。
何が起きていたか
DevPickのバックエンドでは、30分ごとに1つの定期ジョブが動きます。そのジョブがまだ分析していない記事をLLMで分析し、続けて英語要約のバックフィル、英語タイトルのバックフィルを順番に実行します。LLMには、Groqの無料枠で使えるモデルを使っています。
2026年10月18日の朝、本番のログには30分ごとに同じ形の行が並んでいました。
07:01:49 English summary backfill: selected=30 retried=0 saved=20 rejected={} aborted=rate_limited
07:01:49 English title backfill: aborted=rate_limit_backoff(2026-10-17T22:07:52)
07:29:58 English summary backfill: selected=30 retried=0 saved=5 rejected={} aborted=rate_limited
07:29:59 English title backfill: aborted=rate_limit_backoff(2026-10-17T22:44:59)
07:59:46 English summary backfill: selected=30 retried=0 saved=5 rejected={} aborted=rate_limited
07:59:46 English title backfill: aborted=rate_limit_backoff(2026-10-17T23:14:47)
英語要約のバックフィルは、毎回30件を選び、5件か20件を保存したところでレート制限(HTTP 429)に当たって止まっています。止まりはしても、毎回いくらかは進んでいます。一方、その直後に動く英語タイトルのバックフィルは、1件も翻訳しないまま「バックオフ中」として終わっていました。
処理ごとの実行結果を記録している表を見ると、差ははっきりしていました。
| 処理 | 直近の状態 | 連続失敗 | 失敗の理由 |
|---|---|---|---|
| 英語要約のバックフィル | 成功 | 0回 | (なし) |
| 英語タイトルのバックフィル | 失敗 | 17回 | 未処理が残っているのに進んでいない |
英語タイトルが未処理のまま残っている記事は、この時点で1,412件ありました。英語要約はもう付いているのに、タイトルだけが日本語のまま、という記事です。英語版ページでは見出しが日本語のまま並ぶことになります。
IT女子 アラ美なぜ英語タイトルだけ止まったのか
原因は、LLMを呼ぶ3つの処理が「バックオフ中かどうか」の状態を1つだけ共有していたことと、その3つを決まった順番で続けて動かしていたことの組み合わせでした。
DevPickでは、どの処理がLLMから429を受け取っても、「この時刻までLLMを呼ばない」という解除予定時刻をDBの1か所に書き込みます。記事の分析も、英語要約の翻訳も、英語タイトルの翻訳も、LLMを呼ぶ前にこの時刻を確認し、まだ過ぎていなければ何もせずに終わります。上限に張り付いている間に叩き続けても429が増えるだけなので、全員で一緒に待つための仕組みです。単純化すると、次のような形です。
async def scheduled_job():
await analyze_backlog() # 記事の分析
await backfill_summaries() # 英語要約の翻訳
await backfill_titles() # 英語タイトルの翻訳
async def backfill_titles():
if in_rate_limit_backoff(): # 共有の解除予定時刻をまだ過ぎていない
log("aborted=rate_limit_backoff")
return 0
... # ここで初めてLLMを呼ぶ
# どの処理も、429を受けたら共有の解除予定時刻を書き込む
except RateLimitError as e:
set_rate_limited_until(now() + e.retry_after)
ログの時刻を見直すと、何が起きていたかが分かります。たとえば7時01分の回では、英語要約が429を受けて解除予定時刻を7時07分過ぎ(ログはUTCで22時07分)に設定しています。同じ秒に動いた英語タイトルは、その時刻を見て何もせずに終わりました。
解除予定時刻自体は6分ほど先なので、次の回(30分後)にはもう過ぎています。ところが次の回も、先に動くのは英語要約です。英語要約が空いた枠を使い切って再び429を受け、解除予定時刻を書き込み、その直後に英語タイトルがそれを確認して止まります。この流れが毎回繰り返されていました。
- 英語要約は、毎回「枠が空いた直後」に動けるので、少しずつでも進む
- 英語タイトルは、毎回「英語要約が枠を使い切った直後」にしか順番が回ってこない
このように、ほかの処理に資源を取られ続けて、ある処理だけがいつまでも実行されない状態を、一般に「餓死(starvation)」と呼びます。どちらの処理にもバグはありません。それぞれ単体で見れば正しく動いていて、並べ方だけで片方が止まっていました。



「停滞」通知を誤検知だと思っていた
実は、この止まり方に気づいたきっかけは、別の「通知がおかしい」という調査でした。
英語タイトルの機能を入れた10月17日には、同じ日に別の変更として、バックフィルが止まっていないかを見張る監視も入れていました。判定の基準は「まだ処理していない記事のうち、いちばん古い記事が取り込まれてから48時間を過ぎたら停滞」です。この監視をデプロイした約4分後の11時08分に、さっそく「英語タイトルのバックフィルが停滞している」という通知が届きました。同じ日の21時34分には、英語要約についても同じ通知が来ています。
この通知は、構造的に誤検知になるものでした。英語タイトルの機能はその日に入れたばかりなので、過去の記事はすべて未処理から始まります。しかもバックフィルは新しい記事から順に処理するため、いちばん古い記事は最後まで残ります。順調に進んでいても、過去の分を全部終えるまでは「停滞」と言われ続ける判定でした。
そこで、判定を「進んでいるか」を軸に直すことにしました。新しい判定は次のとおりです。
- 未処理の記事が残っている
- かつ、最後に1件でも進んだ時刻から2時間を過ぎている(一度も進んでいなければ、監視を始めた時刻から数える)
この修正の調査で、処理ごとの実行結果の記録を確かめたところ、前の章の表の差が見つかりました。英語要約の通知は、記録上は毎回成功していたので誤検知でした。一方、英語タイトルの通知は、17回続けて「進んでいない」と記録されていた本物の停止です。誤検知だと思って直そうとしていた通知の半分が、本当に止まっている処理を指していました。
修正は翌18日の朝に、まず英語タイトルが止まる原因(次の章で書く順番の入れ替え)を入れ、その約30分後に監視の判定を「進んでいるか」の基準へ切り替えています。
以前、プロセスが生きているかだけを見ていた/healthが、保存系APIの全滅を見逃していた件でも、「何をもって健康と呼ぶか」を先に決める必要があると書きました。今回も同じで、バックフィルにとっての健康は「古い記事が残っていないこと」ではなく「前に進んでいること」でした。



順番を入れ替えた理由と代償
直し方の候補は2つありました。英語タイトルを英語要約より先に動かすか、回ごとに先に動く側を交互に入れ替えるかです。今回は、英語タイトルを先に動かす方を選びました。
理由は、2つの処理が使うトークンの量が大きく違うからです。コストの試算では、英語要約は日本語の要約と要点をまとめて英訳するので1件あたり約550トークン、英語タイトルは見出し1行を訳すだけなので1件あたり約80トークンと見積もっています。軽い処理を先に済ませても、重い処理に残る枠への影響は小さく済みます。逆に、重い処理を先に動かすと、枠を使い切ったところで軽い処理の順番が来て、1件も動けなくなります。
変更そのものは、定期ジョブの中の2行を入れ替えただけです。
async def scheduled_job():
await analyze_backlog()
# タイトル翻訳は要約翻訳より低負荷・少トークンなので先に動かし、
# 要約翻訳のバックオフに巻き込まれて止まり続けないようにする
await backfill_titles()
await backfill_summaries()
ただ、この2行を元の順番に戻すだけで、同じ止まり方が再発します。そのため、定期ジョブが英語タイトル、英語要約の順に呼ぶことを確かめるテストを同じ変更で足し、順番そのものを仕様として固定しています。
def test_job_runs_title_before_summary(monkeypatch):
call_order = []
monkeypatch.setattr(sched, "backfill_titles", lambda: call_order.append("title"))
monkeypatch.setattr(sched, "backfill_summaries", lambda: call_order.append("summary"))
run(sched.scheduled_job())
assert call_order == ["title", "summary"]
代償もあります。英語タイトルの翻訳は、英語要約がすでに付いている記事だけを対象にしています。以前の順番なら、その回に英語要約が付いた記事を、直後の英語タイトルの翻訳がすぐに拾えました。入れ替えたあとは、その記事のタイトルは次の回、つまり最大30分後に回ります。止まり続けるよりは30分の遅れのほうがずっとましなので、この遅れは受け入れました。
もう1つ、この変更で解決しないこともあります。定期ジョブの先頭にいる記事の分析も、同じバックオフの状態を共有しています。分析が429を受ければ、そのあとの翻訳は2つとも止まります。入れ替えで直したのは、あくまで英語要約と英語タイトルの間の取り合いだけです。



取り合う枠そのものを絞った
順番を入れ替えても、英語要約が毎回429に当たっていること自体は変わりません。そこで同じ18日の午前中に、順番の修正のあとから2回に分けて、LLMを呼ぶ量そのものを減らしました。
DevPickが使っているGroqの無料枠には、主に次の2つの上限があります。
- 1分あたりのトークン数(TPM):8,000
- 1日あたりのトークン数(TPD):200,000
調べると、英語要約のバックフィルは1回に最大30件を5件ずつ、10秒おきに送っていました。これだと1分間に12,000〜18,000トークンを要求することになり、1分あたりの上限を単独で超えます。毎回429に当たっていたのは当然でした。
1回目の変更では、英語要約のバックフィルを1回3件まで絞りました。DevPickはまだ収益が出ていないので、有料プランに切り替えて上限を上げる道は取らず、ランニングコスト0円のまま無料枠に収める方針にしています。同じ理由で、英語以外の言語を増やすのは収益化まで凍結しました。
2回目の変更は、1回目の試算の見直しです。1日あたりの試算に、英語タイトルのバックフィル(当時は1回10件、1日48回で約4.8万トークン)が入っていませんでした。足すと合計約23万トークンで、1日の上限を超え得ます。そこで英語要約を1回2件、英語タイトルを1回5件まで下げ、1日の試算を次のように組み直しました。
| LLMの呼び出し | 1日の量 | トークン/日 |
|---|---|---|
| 記事の分析 | 新着 約100件 × 約1,050 | 約105,000 |
| 英語要約のバックフィル | 48回 × 2件 × 約550 | 約53,000 |
| 英語タイトルのバックフィル | 48回 × (指示文 約150 + 5件 × 約80) | 約26,000 |
| 合計 | 約184,000(上限の約92%) |
同じ2回目の変更で、記事の分析も見直しています。分析は1回最大15件を5件ずつ、10秒おきに送っていたので、約20秒の間に7,500〜15,000トークンを要求し、こちらも1分あたりの上限を超え得ました。送る間隔を60秒に延ばし、1分に1回までにしています。1回の実行は2〜3分に延びますが、30分で15件という処理量は変わりません。
残っている英語要約の未処理は約6,000件あり、このペースだと1日約96件ずつの消化になります。遅いのは承知のうえで、無料枠の中で確実に進むほうを選びました。



順番を変えても、また止まった
ここまでで直ったと言いたいところですが、翌19日の朝、再び「英語要約のバックフィルが停滞している」「英語タイトルのバックフィルが停滞している」の2つの通知が8時01分に届きました。今度は、進み具合を見る新しい判定による通知です。
19日の未明から朝にかけての定期ジョブを、本番ログから並べると次のようになります。
| 時刻 | 結果 |
|---|---|
| 3:29 | 英語タイトル5件、続いて英語要約2件を保存 |
| 3:59 | 英語タイトルの翻訳で429、英語要約はバックオフで止まる |
| 4:29 | 記事の分析で429、翻訳は2つともバックオフで止まる |
| 4:59 | 記事の分析で429、翻訳は2つともバックオフで止まる |
| 5:30 | 英語タイトル5件、続いて英語要約2件を保存 |
| 5:59 | 英語タイトルの翻訳で429、英語要約はバックオフで止まる |
| 6:29 | 記事の分析は成功、英語タイトルの翻訳で429、英語要約はバックオフで止まる |
| 6:59 | 英語タイトルの翻訳で429、英語要約はバックオフで止まる |
| 7:29 | 記事の分析で429、翻訳は2つともバックオフで止まる |
| 7:59 | 記事の分析で429、翻訳は2つともバックオフで止まる |
成功した回では、英語タイトル、英語要約の順に動いていて、入れ替えた順番どおりです。ただ、5時30分を最後に進まなくなり、2時間を過ぎたあとの8時01分に通知が出ました。
表には、もう1つ気になる形が出ています。英語タイトルの翻訳が429を受けた回では、そのあとの英語要約がバックオフで止まっています。18日の朝とは逆に、今度は英語要約の側が巻き込まれていました。順番を入れ替えても、共有のバックオフで前の処理が止まれば後ろも止まる構造は変わっていません。変わったのは、どちらが後ろに並ぶかだけです。
429の中身は、それまでとは違うものでした。
Rate limit hit, API says wait 900s: Error code: 429 - Request too large for model `qwen/qwen3.8-27b` ...
on output tokens per minute (OTPM): Limit 1000, Requested 1252. The request's expected output tokens
exceed the enforced limit; reduce max_tokens (or the request's expected output) and try again.
Groqには、TPMやTPDとは別に、1分あたりの出力トークン数(OTPM)の上限があり、DevPickの組織では1,000でした。3時59分から7時59分までの429は8回で、すべてこの上限に対する「Request too large」です。内訳は英語タイトルの翻訳が4回、記事の分析が4回でした。要求量は1,038〜2,048トークンで、英語タイトル5件の呼び出しでも2,048が要求された回があります。
厄介なのは、これが「使いすぎ」ではなく「1回のリクエストが大きすぎる」という拒否だったことです。DevPickのLLM呼び出しは出力の上限(max_tokens)を指定していなかったので、Groqが見積もった出力量が、1回だけで1分の上限を超えていました。この場合、15分(900秒)待っても同じリクエストはまた拒否されます。待てば回復するという前提で作ったバックオフが、ここでは何の役にも立ちませんでした。OTPMの上限は、前の章で作ったコストの試算にも入っていませんでした。
この記事を書いている時点(19日の朝)では、この問題はまだ直っていません。すべてのLLM呼び出しで出力の上限を指定すること、1分あたりの出力量が上限を超えないよう呼び出しの間隔を調整すること、記事の分析のバッチ件数を下げることを、次の対応として起票しています。
また、2つ特定できなかったことがあります。1つは、18日に入れた順番の入れ替えと流量の削減のどちらが、どれだけ効いたかです。本番のログが19日の3時08分以降しか残っておらず、18日の推移を追えませんでした。もう1つは、未処理の件数の変化の内訳です。英語タイトルが未処理の記事は、19日の朝にアプリと同じ条件で数えると524件でした。ただ、英語タイトルを処理した日時は記録していないので、1,412件からの差のうちバックフィルで進んだ分がどれだけかは分かりませんでした。



まとめ
英語タイトルの翻訳が1,412件残ったまま進まなかった原因は、LLMのレート制限によるバックオフの状態を複数の処理で1つだけ共有し、その処理を決まった順番で続けて動かしていたことでした。前の処理が枠を使い切るたびに、後ろの処理は確認しただけで終わり、1件も実行されない状態が続いていました。
今回やったことを並べると、次のとおりです。
- 軽い英語タイトルの翻訳を、重い英語要約の翻訳より先に動かす順番へ入れ替え、順番をテストで固定した
- 停滞の監視を、古い記事が残っているかではなく、最後に進んだ時刻から数える判定へ切り替えた
- 英語要約・英語タイトル・記事の分析の流量を、無料枠の1分あたり・1日あたりの上限に収まるよう絞った
そのうえで翌朝、試算に入っていなかった出力トークンの上限で再び止まりました。そして、順番を入れ替えた結果、今度は英語要約の側が巻き込まれる形も見えました。今の私の考えは、順番の調整は応急処置にすぎず、根本は「取り合う量をすべての上限の内側に収めること」だ、というものです。上限は1つではないので、どの上限に当たったのかを、ログからその都度確かめる必要があります。
もう1つは監視です。順番を変えても、流量を絞っても、それが効いているかを教えてくれたのは「進んでいるか」を見る監視でした。バックフィルのような長く続く処理は、残りの量ではなく進み具合で見張るのがよいと考えています。












