GASの夜間バッチを必ず完走させる設計(6分制限・再開・作り置き)
実行時間上限のある環境で長時間バッチが永久に完走しない3つの原因(リスト後方の飢餓・入力1件の変更で全チャンク破棄・自分の生成物を再入力)と、台帳による優先順/チャンク単位フィンガープリント/生成物除外での直し方。部分確定と失敗カウンタ期限切れ、検証手順つき。
約8.5万トークンの節約 (API料金換算で約130円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「GASの夜間バッチを必ず完走させる設計(6分制限・再開・作り置き)」は、Google WorkspaceカテゴリのAI指示書(MDファイル)です。実行時間上限のある環境で長時間バッチが永久に完走しない3つの原因(リスト後方の飢餓・入力1件の変更で全チャンク破棄・自分の生成物を再入力)と、台帳による優先順/チャンク単位フィンガープリント/生成物除外での直し方。部分確定と失敗カウンタ期限切れ、検証手順つき。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約8.5万トークン(API料金換算で約130円)・71%のトークンを節約できます。
- カテゴリ
- Google Workspace
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約12万トークン
- この巻物使用時
- 約3.5万トークン
- 節約量
- 約8.5万トークン (約130円)
- 更新日
- 2026-08-26
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/gas-6/raw を読み込んで、この指示書どおりに実装して"
中身
GAS の夜間バッチを「必ず完走」させる設計(6分制限・再開・作り置き)
Google Apps Script のトリガーで「全レコードを LLM に読ませて要約を作り置きする」型のバッチは、 素直に書くと 永久に完走しない。実運用で5晩回しても 1/5 の進捗のまま止まった実例をもとに、 原因3つと、それぞれの直し方をコードの形で示す。GAS 以外(Lambda/Cloud Functions など 実行時間に上限があり、状態を外に持つしかない環境)でもそのまま通じる。
前提とする処理の形
夜間トリガー(例: 01:00〜04:00 を30分刻みで7回)
→ 対象レコード一覧を取得
→ 各レコードについて「添付資料を全部 LLM に投げて要約」
→ 結果をシート/DBに作り置き
→ 昼間の生成処理は作り置きを読むだけ(= 表示が速く、課金も1回で済む)
1回の実行は 6分(GAS の上限)で強制終了される。1レコードの処理が数十秒〜2分かかるなら、 1回の実行で進むのは1〜2件。ここから先が本題。
原因1: リストの後方が永久に飢える
// ❌ 毎回リストの先頭から走査する
for (var i = 0; i < records.length; i++) {
if (Date.now() - started > START_CUTOFF_MS) break; // 新規着手の締切
var materials = collectMaterials(records[i]); // ここが数秒かかる
if (isUpToDate(records[i], materials)) continue; // スキップ判定のために毎回走査している
process(records[i]);
}
締切に達すると break するので、リスト後方のレコードには毎晩到達しない。 しかも「スキップ判定のため」に、更新の必要がないレコードまで毎回重いスキャンを払っている。
直し方: 順序決定を「安いデータ」だけで行う
進捗の台帳を Script Properties(= 外部ストア)に1件だけ持ち、 並べ替えの段階では外部 API を一切呼ばない。重いスキャンは「今回着手すると決めた分」にだけ払う。
var LEDGER_KEY = 'BATCH_LEDGER';
var RECHECK_MS = 20 * 60 * 60 * 1000; // 完了済みの再確認は1日1回で十分
// 台帳: { "<id>": { s: '部分'|'完了', d: 済チャンク, t: 全チャンク, c: 最終確認ms, p: 部分になったms } }
function orderRecords(records, ledger, nowMs) {
return (records || []).map(function (r, index) { return { r: r, i: index, e: ledger[String(r.id)] }; })
// 完了済みで、かつ最近確認したものは今回の候補から外す
.filter(function (x) { return !x.e || x.e.s !== '完了' || nowMs - (Number(x.e.c) || 0) >= RECHECK_MS; })
.sort(function (a, b) {
// 未着手(0) → 部分(1) → 完了(2)
var ap = !a.e ? 0 : a.e.s === '部分' ? 1 : 2;
var bp = !b.e ? 0 : b.e.s === '部分' ? 1 : 2;
if (ap !== bp) return ap - bp;
if (ap === 1) return (Number(a.e.p) || 0) - (Number(b.e.p) || 0) || a.i - b.i; // 古い未完から
if (ap === 2) return (Number(a.e.c) || 0) - (Number(b.e.c) || 0) || a.i - b.i; // 古い確認から
return a.i - b.i;
}).map(function (x) { return x.r; });
}
カーソル(最後に処理した位置)より台帳の方がよい。カーソルは「失敗したものを飛ばし続ける」か 「同じものを再試行し続ける」のどちらかに転びやすいが、台帳は未完を必ず先に持ってくる。 1レコード処理するたびに台帳を保存すること(実行が途中で殺されても進捗が残る)。
Script Properties は 1値あたりのサイズに上限があるので、キーは短縮名にし、 上限に近づいたら「完了済みで最終確認が古いもの」から間引く。
原因2: 入力が1件変わると全チャンクを捨てて最初からやり直す
大きなレコードは資料を複数チャンクに割って、1実行=1チャンクで部分保存し次回再開する。 このとき進捗の再利用条件を「レコード全体のフィンガープリント一致」にすると——
// ❌ 全体 fp が1文字でも変われば、済んでいた要約を全部捨てる
if (storedFingerprint === currentFingerprint && storedState === '部分') { reuse(); }
稼働中のレコードは資料が動き続けるので、完走する前に必ずやり直しになる。 5晩かけても 1/5 のまま、という症状はこれ。
直し方: フィンガープリントをチャンク単位で持つ
function chunkFingerprint(chunkItems) { // 全体 fp と同じハッシュ実装を共有する
return fingerprint(chunkItems); // 対象は「そのチャンクに入る id:更新日時」だけ
}
// 保存時: 「チャンクN要約」の見出し行の隣のセルに、そのチャンクの fp を書く
rows.push(['チャンク' + (i + 1) + '要約', chunkFingerprints[i], '']);
// 再開時: インデックス一致ではなく fp 一致で引き当てる
function parseProgress(rows, fingerprint, chunkTotal, chunkFingerprints) {
var byFingerprint = {}, summaries = [];
// …保存済みの (fp → 要約) 辞書を作る…
if (chunkFingerprints && Object.keys(byFingerprint).length) {
chunkFingerprints.forEach(function (fp, index) {
if (byFingerprint[fp]) summaries[index] = byFingerprint[fp]; // 変わっていないチャンクは流用
});
}
return { summaries: summaries, chunkTotal: chunkTotal };
}
これで「資料が1件増えて 5→6 チャンクになった」ときも、既存5本の要約はそのまま使い、 新しい1本だけ課金される。状態が『完了』の行の要約も再利用対象に含めるのを忘れないこと (完了後に資料が1件増えるケースが最も多い)。
fp 列が無い旧データは従来条件(全体fp一致 かつ 部分)でのみ再利用する、と分けておけば後方互換になる。
処理ループも「先頭から連続」ではなく穴埋めに変える(途中のチャンクだけ欠けた状態が普通になる):
for (var i = 0; i < total; i++) {
if (summaries[i]) continue; // 流用できたものは飛ばす
if (processed >= maxChunksPerRun || now() - started >= budget) break;
summaries[i] = processChunk(i);
processed++;
savePartial(summaries); // 1チャンクごとに部分保存
}
原因3: 自分の生成物を入力として読み直している(ハルシネーションの自己強化)
作り置きの出力をレコードのフォルダに書いていると、次回はそれが入力に混ざる。 害は2つ。①過去の生成物の誤りが「資料に書かれた事実」として固定される ②一次資料(規定・実測・図面)と二次生成物が同じ重みで混ざる。 さらに、生成するたびに入力集合が変わるので原因2のやり直しを毎回引き起こす。
// ✅ 命名規約で機械的に外す。語ではなく「接頭辞」で判定するのが安全
function isAppGenerated(label, recordId) {
var name = String(label || '').normalize('NFC');
var id = String(recordId || '').normalize('NFC');
if (!id || name.indexOf('議事録') >= 0) return false; // 一次情報は残す(例外を先に置く)
return name.indexOf(id + '_') === 0 || name.indexOf('<出力プレフィックス>') === 0;
}
- 除外理由は
reason: 'app_generated'のようにラベル付けし、デバッグコマンドから見えるようにする。 - 「初版」「スライド」「手順」のような一般語で除外してはいけない。顧客・取引先が同じ語を使う。
- 生成物の命名を最初から
<recordId>_…/ 固定プレフィックスに寄せておくと、この判定が1行で済む。
仕上げ: 止まったまま放置されない仕掛け
部分のまま滞留したら確定させる
入力が動き続けるレコードは、理屈上いつまでも「部分」になれる。逃げ道を用意する。
function shouldFinalizePartial(v) {
return Number(v.chunksDone) >= 1 && Number(v.chunkTotal) > Number(v.chunksDone)
&& Number(v.partialSinceMs) > 0 && Number(v.nowMs) - Number(v.partialSinceMs) >= 5 * 24 * 3600 * 1000;
}
確定させるときは、それが部分であることを本文に残す
(注記 行を書き、読み出し側が本文先頭に「一部のみ反映」と付ける)。黙って完成扱いにしない。
失敗カウンタは必ず期限切れにする
無人ループには失敗の打ち切りが必要(外部 API を叩き続けてアカウントが凍結された実例がある)。 ただし打ち切りが永久だと、一過性の障害で対象が静かに消える。
function shouldSkipByFailure(v) { // 5回失敗しても、3日経てばまた試す
return Number(v.n) >= 5 && Number(v.lastMs) > 0 && Number(v.nowMs) - Number(v.lastMs) < 3 * 24 * 3600 * 1000;
}
注意: カウンタを「着手前に +1、成功で削除」する実装だと、実行時間上限で殺されるたびに増える。 優先順を変えて重いレコードを先頭に持ってくると、この事故を踏みやすくなる(だから期限切れが要る)。
検証のしかた(ここを省くと直った気になるだけ)
- 純関数に切り出してローカルでテストする。並べ替え・進捗の引き当て・確定条件・失敗判定は
すべて外部 API を呼ばない純関数にできる。
module.exportsに足せば GAS 外のテストランナーで回る。 - 本番で1件を完走させる。読み取り専用のデバッグコマンドで状態を read-back し、
state: 完了・済/全チャンク・本文の文字数を数字で確認する(「動いたはず」で終わらせない)。 - バッチ全体を1回手で走らせる。「全件走査できたか」(残り0か)を戻り値に含めておくと、 飢餓が直ったことを1回の実行で確認できる。
- 仮説は実測で棄却する。上の実例では「失敗カウンタ5回で永久停止しているのでは」を最初に疑ったが、 カウンタ一覧を読むと空だった。推測のまま層を足すと、本当の漏れ口は開いたまま残る。
コストの目安
1チャンク(PDF・画像を数MB分まとめて長文脈モデルへ)で $0.02 前後、6チャンク+統合で $0.12 前後。 チャンク単位の再利用が効いていれば、2回目以降は変わったチャンクの分だけしか課金されない。 「入力が変わっていなければ 0 円」を成立させるのが、この設計の一番の効果。
よくある質問
+「GASの夜間バッチを必ず完走させる設計(6分制限・再開・作り置き)」とは何ですか?
実行時間上限のある環境で長時間バッチが永久に完走しない3つの原因(リスト後方の飢餓・入力1件の変更で全チャンク破棄・自分の生成物を再入力)と、台帳による優先順/チャンク単位フィンガープリント/生成物除外での直し方。部分確定と失敗カウンタ期限切れ、検証手順つき。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約12万トークンかかりますが、この巻物を使えば約3.5万トークンで済みます。差し引き約8.5万トークン(API料金換算で約130円)・71%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
GAS完全自動化テンプレ — Driveコマンドキュー方式
Google Apps Script の「毎回エディタで▶実行」を根絶。Drive 経由のコマンドキューで、初回1クリック以降は AI がすべての GAS 関数をリモート実行できるようになるテンプレート指示書。
人間の手入力台帳を壊さずに自動更新する — GAS Web App upsert 設計
各PC/各拠点の点検結果を、人間が手運用しているスプレッドシート台帳へ自動反映する。手入力列とコメントを絶対に壊さない突合設計、タブ/列の解決、並行POST対策、配布シークレットの落とし穴まで。
数式まみれの業務スプレッドシートを、Webアプリから壊さずに編集させる型
ArrayFormula と per-row 数式が混在する台帳を、セル単位 allowlist・dry-run 既定・適用前バックアップ・触っていないセルの数式不変検査で安全に書き換える設計手順。列ごとの数式復元と、テストが緑のまま壊れる典型例つき。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア