0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GASのUrlFetchAppが6分で死ぬ — AI APIをスプレッドシート3000行に回す3つの現実解

0
Posted at

私はスプレッドシートの3,000行の商品データ全行に対して、GASの UrlFetchApp でAI API(要約タグ付け)を回し、実行開始6分で「Exceeded maximum execution time」が発生してバッチが真っ二つに折れました。1,147行目まで処理して死ぬ。再実行すると今度はAPI側の重複請求。エラー処理もロールバックもないので、どの行が処理済みか分からない。この事故で学んだのは「GASの実行時間制限は『速く書け』ではなく**『止め方から設計せよ**』という話です。

6分制限は1回の実行あたりです(Workspace有料でも30分、無料枠なら6分)。つまり「1回の実行で全部やる」設計がそもそも間違いでした。この記事では、私がやった失敗設計と、3つの現実解を完全形のコードで出します。

失敗した設計: 1回のforループで全行を回す

最初に書いたのがこれです。これは失敗パターンなのでコピペしないでください(なぜ落ちるかはすぐ下で説明します)。

// ❌ 失敗パターン: 3000行を1実行で全部回す(6分で死ぬ)
function tagAllRowsBad() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("商品");
  const rows = sheet.getDataRange().getValues();      // 3000行を一括取得
  for (let i = 1; i < rows.length; i++) {             // ← ここが6分で落ちる
    const summary = callAi(rows[i][1]);               // 1行あたり2〜4秒
    sheet.getRange(i + 1, 3).setValue(summary);        // 1行ずつ書き込み(遅い)
  }
}

この設計の3つの死に方を説明します。

死に方①: 実行時間の6分制限。 AI APIは1行あたり2〜4秒かかります。3000行なら最速6,000秒。6分(360秒)で処理できるのは約90〜150行。つまり必ず落ちます。「速いAPIなら行ける」は錯覚で、ボトルネックはAPIレイテンシなのでGAS側を速く書しても意味がありません。

死に方②: setValue の呼びすぎ。 getRange().setValue() は1回あたり数十〜数百msのオーバーヘッドがあり、これで実行時間の何割かを溶かします。3000回呼ぶと、それだけで数分クラスの浪費です。

死に方③: 死んだ時に処理済み行が分からない。 上のコードは途中で死ぬと「1,147行目まで書いた」ことにはなりますが、途中の行でAPIエラーが返って空文字を書いた行と「未処理の行」が区別できません。再実行すると重複請求と重複タグ付けが起きます。失敗した設計の本質は「進捗を1セルも書いていない(書けていない)」ことでした。

解決の軸: 「チェックポイント+再開」に設計を変える

3つの解は、どれも「1実行で全部やらない」一点に尽きます。状況別に選びます。

# 解 仕組み 向くケース 1実行の上限感覚
1 時間ベース中断 経過秒を見て5分で停止し、次回トリガーで続きから まず安定させたい全ケース 5分 × n回
2 行数バッチ 「1回○行だけ」を処理して即終了 行数が予測可能な日次バッチ 例: 200行 × 15回
3 LockService併用 トリガー多重起動を1実行に直列化 15分トリガーで長期運用 解1/2と併用

まず共通の土台になるチェックポイント付きエンジン(解1)を完全形で出します。これなら6分制限と闘わず、制限と付き合えます。

解1: 時間ベース中断+チェックポイント再開(完全形)

処理済みフラグを「処理ステータス列」に書き戻し、再開はそこから始めます。main() を実行するだけで、何回でも安全に止めて再開できます。

// ✅ 完全形: 6分で死なないAIバッチ(チェックポイント+再開)
// シート構成: A=ID, B=商品名, C=要約(結果), D=処理済みフラグ
// 1行目はヘッダー。D列に "done" が書いてある行は再開時にスキップされる。

const SHEET_NAME = "商品";
const API_URL = "https://api.example.com/v1/summarize";  // 使うAI APIのURLに置換
const API_KEY = PropertiesService.getScriptProperties().getProperty("API_KEY");
const TIME_LIMIT_SEC = 5 * 60;  // 5分で停止(6分制限に1分の猶予)

function main() {
  const start = Date.now();
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
  const lastRow = sheet.getLastRow();
  const data = sheet.getRange(2, 1, lastRow - 1, 4).getValues();  // 一括読み込み

  let processed = 0;
  const writes = [];  // 書き込みは最後にまとめて1回(setValue呼びすぎ回避)

  for (let i = 0; i < data.length; i++) {
    if (data[i][3] === "done") continue;  // ← チェックポイント: 処理済みは飛ばす
    if ((Date.now() - start) / 1000 > TIME_LIMIT_SEC) {
      flushWrites_(sheet, writes);        // ここまでの結果だけ書いて終了
      ensureTrigger_();                    // 続きを回すためのトリガーを設定
      Logger.log(`5分経過: ${processed}行処理して中断。続きは次回トリガーで。`);
      return;
    }

    const summary = callAi_(String(data[i][1]));
    if (summary === null) continue;  // API失敗: doneを書かず次回リトライ対象に残す
    writes.push({ row: i + 2, summary: summary });
    processed++;
  }

  flushWrites_(sheet, writes);
  deleteTrigger_();  // 全部終わったのでトリガーは不要
  Logger.log(`完了: ${processed}行`);
}

function callAi_(text) {
  const res = UrlFetchApp.fetch(API_URL, {
    method: "post",
    contentType: "application/json",
    headers: { Authorization: `Bearer ${API_KEY}` },
    payload: JSON.stringify({ text: text, max_tokens: 200 }),
    muteHttpExceptions: true
  });
  if (res.getResponseCode() !== 200) return null;  // 失敗はnull(doneを書かない)
  return JSON.parse(res.getContentText()).summary || "";
}

function flushWrites_(sheet, writes) {
  if (writes.length === 0) return;
  // C列(要約)とD列(done)を書く。対象行は連続とは限らないので1行ずつ。
  writes.forEach(w => {
    sheet.getRange(w.row, 3).setValue(w.summary);
    sheet.getRange(w.row, 4).setValue("done");
  });
}

function ensureTrigger_() {
  deleteTrigger_();
  ScriptApp.newTrigger("main").timeBased().after(60 * 1000).create();  // 1分後に続き
}

function deleteTrigger_() {
  ScriptApp.getProjectTriggers()
    .filter(t => t.getHandlerFunction() === "main")
    .forEach(t => ScriptApp.deleteTrigger(t));
}

ポイントは3つです。

  1. getValues() で一括読み込み — 3000行の読み取りは1回のAPIコールで済みます。書き込み(flushWrites_)は行単位になりますが、これは5分で中断する前提なので1実行あたりの書き込み回数は数十行程度で収まり、書き込みで時間切れする前に必ず自分から止まります(「6分で殺される前に逃げる」の実装)。
  2. D列の done がチェックポイント — 死んでも再実行すれば続きから始まるので、「どこまで処理したか」が自明になります。
  3. 5分で「自分から」止まる — 制限で殺される6分より前に、結果を書き込んでトリガーをセットして終了します。解決策の要はこれです。

なお callAi_ は失敗時に null を返しますが、その行は done にならないため、次回実行で自動リトライされます。API障害時に無限リトライが怖いなら、後述の解2で「リトライ回数列」を足すのが安全です。

解2: 行数バッチ(1実行=200行)で確実に終わらせる

「時間を見て止まる」は動的ですが、行数を固定した方が予測しやすいケースもあります。1回の実行で「未処理の先頭200行」だけ処理して即終了します。実行時間は200行×4秒=最大800秒…ではなく、200行を選ぶ時点で6分に収まる行数に設計するのがこの解の本質です(APIレイテンシに応じて50〜150行で調整)。

// ✅ 完全形: 行数バッチ。1実行=先頭200行だけ。
const SHEET_NAME = "商品";
const BATCH_ROWS = 200;  // 6分に収まる行数に調整(APIが4秒/行なら50〜80行が安全)

function processBatch() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) return;  // 多重起動していたら何もしないで終了
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, 4).getValues();

    const todo = [];
    for (let i = 0; i < data.length && todo.length < BATCH_ROWS; i++) {
      if (data[i][3] !== "done") todo.push({ index: i, name: String(data[i][1]) });
    }
    if (todo.length === 0) {
      deleteBatchTrigger_();
      Logger.log("全行処理済み。トリガー削除。");
      return;
    }

    const summaries = todo.map(t => callAi_(t.name));
    const colC = sheet.getRange(2, 3, data.length, 1).getValues();
    const colD = sheet.getRange(2, 4, data.length, 1).getValues();
    todo.forEach((t, idx) => {
      colC[t.index][0] = summaries[idx];
      colD[t.index][0] = "done";
    });
    sheet.getRange(2, 3, data.length, 1).setValues(colC);
    sheet.getRange(2, 4, data.length, 1).setValues(colD);

    Logger.log(`${todo.length}行処理。残り: ${data.length - countDone_(colD)}行`);
    ensureBatchTrigger_();  // まだ残っているので15分後に続行
  } finally {
    lock.releaseLock();    // 例外でも必ずロック解放
  }
}

function ensureBatchTrigger_() {
  deleteBatchTrigger_();
  ScriptApp.newTrigger("processBatch").timeBased().everyMinutes(15).create();
}

function deleteBatchTrigger_() {
  ScriptApp.getProjectTriggers()
    .filter(t => t.getHandlerFunction() === "processBatch")
    .forEach(t => ScriptApp.deleteTrigger(t));
}

function countDone_(colD) {
  return colD.filter(r => r[0] === "done").length;
}

// callAi_ は解1と同じものを使う(同じスクリプト内に定義済みなら重複して定義しない)

解2は「1実行の処理量を固定」できるので、APIの遅さが変わっても6分制限は超えません(超えたら BATCH_ROWS を減らすだけ)。finally でロックを解放するのは、例外時にロックが握られたままになると以降15分の全実行が tryLock 失敗で無言終了するからです(私はこれで1日バッチが止まっているのに気づけませんでした)。

解3: LockServiceで多重起動を防ぐ意味

15分おきのトリガーは「前回がまだ動いている時に発火する」ことがあります。GASのトリガーは同一関数の多重実行を止めてくれません。解2に入れた3行がそれです。

const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) return;  // 動いていたら今回の発火は無視
// ... 処理 ...
lock.releaseLock();

これを入れないと、解1の「5分中断+1分後トリガー」と組み合わせた時に前回の書き込みと今回の書き込みが同一行に衝突します。AI呼び出しはお金なので、多重起動は二重請求に直結します。解1と解3、解2と解3は必ず併用してください。

3つの解の比較表

観点 解1: 時間ベース中断 解2: 行数バッチ 解1〜2なしの失敗設計
6分制限への耐性 ○ 自分から止まる ◎ 最初から収まる設計 × 必ず落ちる
進捗の可視化 ○ doneフラグで再開 ○ doneフラグで再開 × 不明
予測しやすさ △ API速度に依存 ◎ 行数で固定 —
二重起動の危険 LockService併用で対処 LockService内蔵 ○(むしろ多重請求)
実装の複雑さ 低 低 最低(速いだけで)

まとめ

  • 6分制限は速さの問題ではなく止め方の問題。「1実行で全部」設計を捨てる
  • チェックポイントはシートの1列で足りる。DBも PropertiesService も不要
  • 読み込みは getValues でまとめる。書き込みは5分中断とセットなら行単位でも安全
  • 時間中断は5分で自分から止める(6分で殺される前に逃げる)
  • 15分トリガーには必ず LockService。多重起動は二重請求になる

3,000行は解2+15分トリガーで2時間ちょいで回るようになりました。GASは「1実行で完結させる道具」ではなく「小さく切って何度も回す道具」だと割り切った瞬間、6分制限は敵ではなく仕様になります。

参考書籍: Google Apps Script × AI 実践入門 — スプレッドシートで動かすAIワークフロー(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)

🎁 読者特典を受け取る(無料・メール登録)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?