記事を足したら、Workers の subrequest の上限を超えた #

AI 小説の記事の図を描き直した PR #89 をマージしたあと、make deploy で MoonBit 版のブログを Cloudflare Workers に配備した。すると https://blog.podhmo.workers.dev/ が、ページの代わりに次の一行を返すようになった。

Too many subrequests by single Worker invocation. To configure this limit, refer to https://developers.cloudflare.com/workers/wrangler/configuration/#limits

ユーザーの見立ては「以前の調査で、限界が来たら考えるとしていた Static Assets の制限を超えた」というものだった。この記事は、その原因と直し方の記録だ。

予告されていた失敗 #

MoonBit 版の Worker は、記事と文書を Wasm に埋め込まず、Static Assets に置いている。新しい isolate の最初のリクエストで、ファイルの一覧 files.txt と、そこに並ぶファイルを ASSETS バインディングから1つずつ取得し、Wasm に渡していた。

この形にしたときの調査レポートは、最後に未解決の問いを残していた。ASSETS への取得が subrequest の上限(無料プランで 50)に数えられるなら、記事と文書が増えると最初のリクエストが失敗しうる、というものだ。今回、それがそのまま起きた。

進め方 #

ユーザーの指示で、issue の登録から調査・修正・手元での確認・コミットまでを、新しく起動したサブエージェントに任せた。このセッションの Claude Code は、それまでに分かっていたことを渡し、戻ってきた報告を確かめる側に回った。

渡したのは次のことだ。

  • 壊れているのは MoonBit 版の Worker であること(make deploy があるのは moonbit/Makefile だけ)
  • 最初のリクエストで、files.txt と全ファイルを1つずつ取得していること
  • 前回の調査レポートに、上の未解決の問いがあること
  • 直し方の候補(1つのファイルにまとめる、上限を上げる、必要な分だけ読む、Wasm に埋め込み直す)。どれにするかは決めずに渡し、比べて選ぶよう頼んだ
  • push と配備はせず、issue も閉じないこと

一つ失敗もあった。サブエージェントを起動する前に、こちらで途中まで書き換えていた content.sh を元に戻すつもりが、パスを間違えて戻せていなかった。気づいてすぐに戻し、サブエージェントには「その書き換えを前提にせず、HEAD から読み直して」と伝えた。

原因 #

サブエージェントは issue #92 を立て、マージの時点ごとに内容のファイル数を数えた。

43 46 54 1 #87 #88 #89 修正後 マージした時点(最初のリクエストでの取得回数) 50 0 無料プランの subrequest の上限

最初のリクエストでの取得回数は files.txt の1回を含む。#88 までは 50 を下回っていたが、#89 で 53 ファイル、54 回になった。失敗が始まった時期と合い、Worker にはほかに subrequest を出すコードがない。

Cloudflare のドキュメントでは、subrequest は「Fetch API や R2、KV、D1 などへのリクエスト」で、上限は1回の呼び出しあたり無料プランで 50、有料プランで 10,000 だった。wrangler の limits.subrequests で上限を変えられるが、無料プランの最大は 50 のままだ。

ASSETS への取得が数えられるかどうかは、Static Assets のドキュメントには書かれていなかった。Service bindings のドキュメントには、バインディング経由のリクエストは上限に数えるとある。ASSETS も同じ種類のバインディングなので同じ扱いと考えられ、本番の失敗とも合う。上限は配備した Cloudflare の上でだけ課され、手元の wrangler dev では課されないことも書かれていた。

直し方 #

ビルド時に、記事と文書を1つのファイル content.bin にまとめるようにした。形式は、ファイルごとに <バイト数> <パス> のヘッダー1行と、中身のバイト列を並べただけのものだ。Worker はこれを1回だけ取得し、unpack で分けて、これまでどおり Wasm に渡す。取得は記事の数によらず1回になる。

Wasm のコードは変えていないので、Wasm の大きさも make dev と make deploy の手順も変わらない。プレビューページ用の /preview.wasm も、これまでどおり同じディレクトリからそのまま返している。

選ばなかった案は次のとおり。

  • 上限を上げる:無料プランでは 50 より上げられない。有料プランにしても、記事が増えるたびに上限へ近づく形は変わらない。
  • ページごとに必要なファイルだけ読む:トップページや一覧にはすべての記事のタイトルと日付が要るので、最初のリクエストで結局すべてを読む。
  • Wasm に埋め込み直す:記事を足すたびに Wasm が太るのを避けるために外へ出したので、戻す理由がない。
  • JSON にまとめる:今の記事や文書はテキストなので運べはする。ただ、Wasm にはもともとファイルごとのバイト列を渡しているので、バイト列のまま並べて切り出すほうが、文字列への変換を挟まずに済む。

修正は 4ac38e7 としてコミットされた。コメントと README、前回の調査レポートへの追記も同じコミットに入っている。

確かめたこと #

サブエージェントの報告によると、次のことを確かめている。

  • content.bin を unpack で分けると、53 ファイルの名前が前の files.txt と同じ順で並び、中身も元のファイルとバイト単位で一致した。途中で切れたファイルは、どのファイルが切れているかを示すエラーになった。
  • make dev で、トップページ、一覧、記事の束のページ、束のメンバーの生ファイル、文書、プレビューのページと /preview.wasm が、期待した Content-Type で返った。
  • env.ASSETS.fetch を数えるコードを一時的に足して動かすと、最初のリクエストで1回、その後のページで0回だった(確認のあとで外した)。最初の数え方は誤っていて、どのリクエストも0回と出ていたので、直して数え直したという。
  • 自分で起動した Go 版のブログと make compare で比べ、トップページと一覧からリンクされた 44 のパスと記事の生ファイルが一致した。ユーザーが動かしていた 8080 番のサーバーには触れていない。

報告をそのまま受け取らず、このセッションでもコミットの中身と unpack のコードを読んだ。content.sh を作業用ディレクトリに向けて実行し、worker.mjs の unpack で分けて、53 ファイルがすべて元のファイルとバイト単位で一致することも確かめた。

報告を読み直して直したところもある。調査レポートへの追記で、JSON を選ばなかった理由が「記事の束には画像など文字列でないファイルも入りうる」になっていた。けれども束は PNG や JPEG のようなバイナリーのファイルを扱わない。理由を「文字列への変換を挟まずに切り出せる」に書き直した。あわせて、files.txt のことを書いたままだった古いWasm のレポートに、その後の変更を注記した。

本番で確かめたこと #

手元の wrangler dev は上限を課さないので、失敗そのものは手元では再現できなかった。そこで、修正をユーザーが make -C moonbit deploy で配備したあと、本番の https://blog.podhmo.workers.dev/ を確かめた。トップページ、/posts/、/slops/、/posts/why-ai-novel-is-bored、/posts/why-ai-novel-is-bored/article1.md(text/markdown)、/docs/、/preview.wasm(application/wasm)が 200 を返し、存在しないパスは 404 を返した。この記事自身のページも表示された。

修正前はトップページから上限のエラーを返していたので、直ったと言ってよい。ただし、新しい isolate の最初のリクエストを狙って何度も打ち分けたわけではない。

その後:PR にまとめる #

修正と記録は、いったん main に直接コミットしていた。ユーザーの指示で、それらを fix-workers-subrequest-limit ブランチに移して PR #93 にした。手元の main は origin/main に戻した。PR の本文に "Fixes #92" と書いたので、マージで issue #92 も閉じた。

その後:make -s check は moon の版の問題だった #

サブエージェントの報告では、make -s check が moon info で止まっていた。コミット済みの .mbti ファイル6つで、Int32 が Int に書き換わるためだ。修正を退避した状態でも同じだったので、今回の修正とは関係がない。

ユーザーが「moon のバージョンの問題かもな」と moon upgrade すると、moon 0.1.20261012 / moonc v0.10.15 が moon 0.1.20261023 / moonc v0.10.17 になり、.mbti の書き換えは出なくなった。手元の版が古かったのが原因だった。

代わりに、新しいコンパイラで生成物が変わり、make -s check は次の2か所で止まった。

  • make ts が生成する ts/pkg/tinymarkdown/tinymarkdown.mjs
  • make preview が作るプレビュー用の preview.wasm(58,972 バイトから 59,077 バイトに)

もう一つ、新しい moon では moon check も pkg.generated.mbti を書き出すようになっていた。moonbit/experiments/ の下に、これまでなかった .mbti が17個できた。1つ消して moon check をかけると作り直されたので、moon check が書いていることを確かめられた。experiments の .mbti はこれまで管理していなかったので、.gitignore で無視することにした。

生成し直した2つのファイルと .gitignore の変更は、subrequest の修正とは混ぜず、main から切った別の PR #94 にした。これで make -s check は最後まで通り、make test-go も通った。

make test-ts は 73 件中 2 件が失敗した。古い tinymarkdown.mjs に戻しても同じ2件が落ちたので、生成し直したことは原因ではない。失敗の中身は、ts のテストが wsl-opencode の記事の古い本文を期待していることだった。記事を直したコミットのあとで、期待値が古いまま残ったらしい。リポジトリの決まりに従って TODO.md に記録し、ここでは直していない。

#93 と #94 は、どちらもマージされた。そのあとユーザーがもう一度 make -C moonbit deploy をして、本番の /preview.wasm が #94 で作り直したもの(59,077 バイト)とバイト単位で一致することを確かめた。

残ること #

  • content.bin は今 600 KB 余りで、Static Assets のファイル1つの上限は 25 MiB だ。それに近づく前に分け方を考える。
  • ts のテストの期待値を、wsl-opencode の記事の今の本文に合わせる(TODO.md に記録済み)。