4サイト分の記事 MDX をまとめて検査する処理を、コード実行ツールの1セルに丸投げしていたら、途中で応答が途切れました。エラーらしいエラーは出ず、ただ出力が止まる。手元のログを見直して、ようやく腑に落ちました。6月のツール更新で、コード実行はセル単位に90秒の上限が明示されたのです。私の検査ループは、ファイル数が増えるにつれて静かにその壁へ近づいていました。
この上限は、一見すると厄介な制約に見えます。けれど設計の起点として捉え直すと、むしろ素直な約束事です。鍵になるのは「90秒はセル単位だが、コンテナのファイルシステムはセルを跨いで生き続ける」という非対称です。これを使えば、長い仕事を90秒未満のセルに割って、進捗を持ち越しながら何度でも再開できます。
持ち越しの作法は、計測・冪等なチェックポイント・撤退判断の3点に絞れます。以下では、その実装を順に追います。題材は Dolice Labs の個人開発で回している私自身のバッチ処理ですが、考え方は検索結果の後処理でも、画像メタデータの一括変換でも変わりません。
90秒はセル単位、コンテナは生き続ける ― この非対称が設計の起点です
最初に押さえたいのは、上限が「会話全体」ではなく「1セルの実行」にかかる点です。つまり、ひとつのセルが90秒で打ち切られても、同じセッションのコンテナ自体はそのまま残ります。前のセルで /tmp に書いたファイルは、次のセルからそのまま読めます。
ここを取り違えると設計を誤ります。コンテナの寿命は会話(セッション)に紐づくのであって、セルごとに作り直されるわけではありません。だから戦略はシンプルです。仕事を90秒に収まる単位へ割り、各セルの最後に「どこまで終わったか」をコンテナのファイルへ書き残す。次のセルはその続きから始める。これだけです。
逆に言えば、セッションを跨ぐと前提が崩れます。会話が変わればコンテナは別物になり、/tmp の進捗は消えます。セッションを越えて持ち越したいなら、進捗は外部ストレージ(自分の MCP サーバーやオブジェクトストレージ)へ逃がす必要があります。本記事は「同一セッション内で長いバッチを完走させる」範囲に絞ります。コード実行ツールそのものの基本的な使い方は、コード実行ツールでCSVレポートを自動生成する実装メモで扱っているので、土台が曖昧な方はそちらを先に読むと地続きになります。
まず1セルの実時間を測る ― 計測なしに分割しない
分割の粒度を勘で決めると、たいてい外します。私も最初は「1サイトずつなら大丈夫だろう」と雑に割って、結局あるサイトだけ件数が多くて再び90秒に当たりました。先に1単位あたりの実時間を測るのが遠回りに見えて近道です。
import time
def measure_unit(items, process, sample=5):
"""先頭 sample 件だけ実処理して、1件あたりの実時間を概算する"""
start = time.monotonic()
n = min(sample, len(items))
for item in items[:n]:
process(item)
elapsed = time.monotonic() - start
per_item = elapsed / n if n else 0.0
# 90秒上限に対し、書き込みやオーバーヘッドの余白を引いて使える秒数を見積もる
usable = 75.0 # 後述の時間予算
safe_chunk = max(1, int(usable / per_item)) if per_item else len(items)
print(f"1件 {per_item*1000:.0f}ms / セルあたり安全に回せる目安 {safe_chunk} 件")
return safe_chunk
# 期待する出力例:
# 1件 320ms / セルあたり安全に回せる目安 234 件
time.time() ではなく time.monotonic() を使う点だけ注意します。前者はシステム時刻の補正で巻き戻ることがあり、経過時間の計測には向きません。後者は単調増加が保証されるので、こうした締め切り判定に安心して使えます。
測ってみると、想像とずれることが多いはずです。私の MDX 検査では、正規表現より MDX 本文を構文解析する箇所が支配的で、最も重い1件は軽い1件の3倍ほどかかっていました。ファイルサイズよりも、コードブロックの多さで1件あたりの時間が大きく振れていたのです。計測は、分割の根拠を「印象」から「数値」へ移してくれます。
進捗をコンテナのファイルへ残して、セルを跨いで再開する
ここが本丸です。チェックポイントは「次にどこから始めるか」を1ファイルに書くだけの素朴な仕組みで十分機能します。要点は、書き込みを必ずアトミックにすることです。セルが書き込みの途中で90秒に当たっても、本体ファイルが壊れないようにします。
import json, os
CKPT = "/tmp/batch_progress.json"
def load_ckpt():
if os.path.exists(CKPT):
with open(CKPT) as f:
return json.load(f)
return {"done": 0, "results": []}
def save_ckpt(state):
# 一時ファイルへ書いてから rename。rename は同一FS上で原子的なので、
# 途中で打ち切られても CKPT 本体は前回の正しい状態のまま残る
tmp = CKPT + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f, ensure_ascii=False)
os.replace(tmp, CKPT)
os.replace() を経由するのが肝です。json.dump() を本体ファイルへ直接書くと、その最中にセルが打ち切られた瞬間、半分だけ書かれた壊れた JSON が残ります。次のセルはそれを読めずに最初からやり直し ― という最悪の循環に入ります。一時ファイルへ書ききってから rename すれば、本体は常に「完結した過去の状態」か「完結した新しい状態」のどちらかにしかなりません。
この save_ckpt を使えば、再開ループ本体は驚くほど短く書けます。
state = load_ckpt()
todo = all_items[state["done"]:] # 終わった分はスキップ
for offset, item in enumerate(todo):
state["results"].append(process(item))
state["done"] += 1
save_ckpt(state) # 1件進むごとに記録
print(f"このセルで {state['done']}/{len(all_items)} 件まで前進しました")
次のセルで同じコードを走らせると、load_ckpt() が done を読み、all_items[state["done"]:] が残りだけを返します。完了済みの仕事は二度と触りません。セッションが続く限り、何度このセルを叩いても少しずつ前に進みます。
セル内に時間予算ガードを置く ― 75秒で自分から畳む
上のループには穴があります。残り件数が多いと、結局そのセルが90秒に当たって打ち切られます。チェックポイントのおかげで進捗は守られますが、打ち切りは「外側からの強制終了」であり、最後の数百ミリ秒で何が起きるかを制御できません。できれば、その手前で自分から畳みたいところです。
そこで、セルの先頭で締め切りを決め、1件ごとに残り時間を確認します。
import time, json, os
CKPT = "/tmp/batch_progress.json"
BUDGET_SEC = 75.0 # 90秒上限に対し15秒のマージン
def run_one_cell(all_items, process):
start = time.monotonic()
state = load_ckpt()
todo = all_items[state["done"]:]
for item in todo:
if time.monotonic() - start > BUDGET_SEC:
save_ckpt(state)
print(f"⏸ 時間予算に到達。{state['done']}/{len(all_items)} 件で安全に中断しました")
return False # 未完。次のセルで続行
state["results"].append(process(item))
state["done"] += 1
save_ckpt(state)
print(f"✅ 全 {len(all_items)} 件完了")
return True # 完了
15秒のマージンは、私の環境で1件が最大2〜3秒かかるケースに合わせた数字です。1件あたりの処理が重いほど、最後の1件を始めてしまうと締め切りを超える危険が増えるので、マージンは「最も重い1件の実時間より大きく」取るのが安全です。先ほどの measure_unit の戻り値と突き合わせて、BUDGET_SEC を環境ごとに調整してください。
return False / return True の真偽値が、外側の判断材料になります。コード実行を組み込んだ自動化では、セルの標準出力に出した完了センチネル(✅ 全 … 件完了)を読み取り、未完なら「続けてください」ともう一度モデルにセルを走らせる ― という単純なドライバループで全体を回せます。応答が途中で切れても部分応答は保持されるようになったので、このセンチネル方式は途切れに強い設計と相性が良いです。背景ジョブとユーザー応答の枠を分けて優先度を保つ話は、service_tier で背景ワークロードを隔離する設計と合わせて考えると、長時間処理の置き場所が整理できます。
チェックポイントは冪等に ― 途中再実行で二度手間にしない
セルを跨ぐ設計の信頼性は、突き詰めると「同じセルをもう一度走らせても、結果が壊れないか」に集約されます。これは冪等性の問題です。
上の実装は done のインデックスを単調に増やし、再開時は必ず all_items[state["done"]:] から再開するので、完了済みの要素を二度処理しません。ここで気をつけたいのは、process() 自体に副作用(ファイル出力・外部への書き込み)がある場合です。インデックスを進める前に副作用を起こし、進める直前で打ち切られると、次の再開で同じ副作用がもう一度走ります。
副作用を含むなら、順序を「副作用 → チェックポイント更新」ではなく、「出力先を要素ごとに決定的にする」方針が安全です。たとえば結果ファイル名を要素のスラッグから決め打ちにすれば、二度書いても同じ内容で上書きされるだけで、重複は生まれません。
def process(item):
# 出力先を item から決定的に導く(再実行で同じ場所を上書き=冪等)
out = f"/tmp/out/{item['slug']}.json"
os.makedirs(os.path.dirname(out), exist_ok=True)
result = compute(item)
tmp = out + ".tmp"
with open(tmp, "w") as f:
json.dump(result, f, ensure_ascii=False)
os.replace(tmp, out)
return out
「再実行しても同じ結果になる」ことを各要素レベルで保証しておくと、チェックポイントの粒度が多少粗くても破綻しません。スケジュール実行で冪等性を軸に住み分けを決めた話は、Managed Agents のスケジュールデプロイと自前 cron の住み分けでも同じ原則を扱っています。
コード実行に残す仕事・自前インフラへ逃がす仕事の線引き
チェックポイント分割は強力ですが、万能ではありません。「1単位がそもそも90秒を超える不可分の処理」は、いくら割っても割れません。どこまでコード実行ツールに任せ、どこから自前インフラへ逃がすか。私は次の表で判断しています。
| 仕事の性質 | 置き場所 | 理由 |
| 単発で90秒に収まる純粋計算 | コード実行ツール | そのまま1セルで完結する。分割の仕組みすら要らない |
| N件の反復で、1件は軽いが総量が大きい | コード実行ツール+チェックポイント分割 | セルを跨いで前進できる。本記事の対象 |
| 1単位が90秒を超える不可分処理(重い変換・大規模推論) | 自前インフラ(MCP経由のツール) | 割れないので、時間制約のない実行環境へ渡す |
| 外部ネットワークに常時つなぎ続ける/常駐が必要 | Managed Agents・自前ワーカー | コード実行は短命なセル前提。常駐には向かない |
| 状態をセッションを跨いで保持したい | 外部ストレージ+自前ロジック | コンテナFSは会話寿命で消える |
この線引きの良いところは、撤退の判断が早くなる点です。「チェックポイントで頑張れば何とかなるのでは」と粘りすぎると、本来は自前インフラへ渡すべき重い処理を、無理にセルへ押し込む設計になりがちです。1単位の実時間を測った時点で、それが90秒に対してどの行に当たるかは見えています。数値が出ているなら、潔く逃がす判断が正解です。
つまずきやすい3点
最後に、私が実際に踏んだ落とし穴を3つだけ残します。
1. チェックポイントの非アトミック書き込み
os.replace を省いて本体へ直接 json.dump すると、打ち切られた瞬間に壊れた JSON が残り、次のセルが読めずに全件やり直しになります。これを回避するため、アトミック書き込みは省略しないでください。
2. コンテナ寿命の取り違え
/tmp の進捗が会話を跨いで残ると思い込むと、別セッションで「進捗が消えた」と慌てます。同一セッション内でのみ持ち越せるという前提を最初に確認しておくと、本番運用での取りこぼしを防げます。
3. 保存粒度の細かすぎる I/O
1件ごとに save_ckpt を呼ぶ実装は素直ですが、件数が数万に達すると書き込み自体が律速になります。その場合は「N件ごと、または残り時間が予算の半分を切ったら保存」のように粒度を粗くするのが私のお勧めです。チェックポイントは安全装置であって、毎回書くこと自体が目的ではありません。
長い処理が90秒で切られたら、まずは1件あたりの実時間を測ってみてください。数値さえ出れば、何件で割るか・コード実行に残すか逃がすかは、迷わず決められるようになります。私自身まだ運用しながら調整している最中ですが、計測を起点にする習慣だけは、どの自動化でも裏切らないと感じています。