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×AIのバッチが6分で切れて要約380本を二重送信した — checkpoint再開とLockServiceで直した失敗3つ

0
Posted at

毎朝7時、スプレッドシートに溜まった640件の問い合わせをGASのスクリプトでLLMに要約させていました。ある朝、6分実行時間制限で処理が途中で止まり、慌てて手動で再実行した結果、送信済みの分も含め380本の要約がSlackに二重投稿され、API quotaも実効1.6倍で溶かしました。失敗の根っこは「1回の実行で全件を完走させる」という設計にありました。この記事では、checkpoint(送信済み行番号)と自発的な折り返しで、6分制限の内側を複数回に分けて二重送信ゼロで完走させる完全なコードを書きます。

何が起きたか: 6分制限の強制切断と、手動再実行の重複

実測のタイムラインです。1件あたりLLM呼び出し約0.6秒+間隔調整0.35秒で、6分間に処理できるのは約380件でした。

時刻 起きたこと
07:00:00 バッチ開始。タスク640件
07:06:00 実行時間上限の6分でスクリプトが強制切断。約380件めまで処理済み
07:10 通知を見て手動で再実行。コードには再開位置の概念がないので1行目から処理開始
07:16 再実行も6分で切断
07:20 3回目の実行で640件完走。ただし最初の380本が2回送信され、Slackに要約が二重投稿。API quotaの消費は640+380で実効1.6倍

ここでの事故には3層あります。①6分制限で切断される(外部要因)。②再開位置を持たず、切れたら1行目に戻る(設計欠陥)。③「もう送った行」を知らないため二重送信する(データ破損)。②と③は同じ根っこです: 「送信済み」をどこにも保存していなかったこと。

失敗した設計: 「1回の実行で完走」を前提にしていた

最初に書いていたのは、シート全行をfor文で回して終わり、という50行のスクリプトでした。再開・中断・同時実行という例外を一切書いていないので、6分という上限が現実のワークロードを超えた朝にすべてが崩れました。失敗を整理すると次の表になります。

失敗の種類 症状 正しい対策
実行時間制限 6分で強制切断。以降の行が未処理のまま放置 上限の内側(5分弱)で自発的に折り返し、続きをワンショットトリガーで再開
二重起動 手動再実行とトリガーが重なって同時に2実行 LockServiceでスクリプト全体を1実行に直列化
再開時の二重送信 送信済みの行をもう一度送る checkpoint(送信に成功した最後の行番号)だけを真実として持つ

「切れたら手動で回し直す」運用は、640件が上限近くになった途端に破綻します。制限値は変わらないので、設計側を制限に合わせるのが先です。

再開の設計: 送信済みは「行番号」ひとつだけで持つ

再開の単位ごとに、事故が起きる時間窓が変わります。比較した結果が次の表です。

再開の単位 実装 起こりうる事故 二重送信が起きる時間窓
全件一括(失敗設計) 6分全力で回し、切れたら1行目からやり直し 大量二重送信+quota浪費 最後の実行からやり直し全体(6分以上)
status列で再開 各行を「処理中→done」で管理し、処理中の行だけやり直し done書き込み前に落ちた行が二重化 1件あたりsend完了→done書き込みまで(数百ms)
checkpoint行番号(採用) 送信に成功した瞬間だけ行番号を1つ更新する 落ちても次の実行で行の先頭から継続 更新1回あたり数百msで、未更新ならその行だけ再送

採用した方式の不変条件はシンプルです: 「処理は必ず行番号の昇順で進む」「送信済み ⟺ 行番号 ≤ checkpoint」。この2つを守る限り、落ちた瞬間にいくら戻っても、未送信の最初の行(checkpoint+1)から再開するだけで二重送信は1行の窓(数百ms)まで縮みます。status列は人間が見るための表示に降格させ、「真実」はScript Propertiesの行番号1個に一本化します。

6分制限の正しい越え方: 自発折り返し+ワンショット継続トリガー

GASの実行時間上限は6分です。ギリギリまで使うと、最終行の書き込み途中で切れて整合性が崩れます。そこで自分の開始時刻を記録しておき、280秒(余裕20秒)を超えたら処理を中断して、2分後に自分自身を再実行するワンショットトリガーを生成します。残りが尽きるまで、開始→折り返し→継続が連鎖します。

スプレッドシート Tasks シート(A〜E列):
A1: タスクID   B1: 元テキスト   C1: ステータス   D1: 要約結果   E1: 処理完了
2行目以降: タスクID(空でなければ送信対象)、元テキストはAIに渡す本文

なおワンショットトリガーは実行ごとに消耗品なので、溜まり続けません。残務が0の状態で終わればトリガーは生成されず、翌朝のdailyBatchだけが走ります。日中のトリガー合計時間の上限(90分/日)に達しないよう、継続の発火間隔は2分にしています(実測で1日の実行合計は約14分でした)。

二重起動はLockServiceで1本化

手動実行とトリガーが同時に走ると、checkpointが交互に読み書きされて破綻します。先頭で tryLock して取りられなかった実行は即終了させます。10秒だけ待って空くなら流します(継続トリガーの発火間隔でほぼ起きません)。

コピペで動く完全なコード(.gs 1本)

// ============================================================
// AI要約バッチ: checkpoint再開 + LockService二重起動ガード
// スクリプトプロパティ OPENAI_API_KEY を setup() で設定して使う
// ============================================================
const SHEET_NAME = 'Tasks';
const CHECKPOINT_KEY = 'ai_batch_checkpoint_row'; // 送信に成功した最後の行番号
const MODEL = 'gpt-4o-mini';
const API_URL = 'https://api.openai.com/v1/chat/completions';
const SAFE_LIMIT_MS = 280 * 1000;   // 6分制限の内側で自発的に折り返す(余裕20秒)
const HTTP_MAX_RETRIES = 3;         // 429/5xxの再試行上限
const COL_ID = 1, COL_TEXT = 2, COL_STATUS = 3, COL_RESULT = 4, COL_DONEAT = 5;
const HEADER_ROWS = 1;              // データは2行目から
const CONTINUE_DELAY_MS = 2 * 60 * 1000; // 継続トリガーの発火までの待ち

// ---- セットアップ: 最初に1回だけ手動実行する ----
function setup() {
  PropertiesService.getScriptProperties()
    .setProperty('OPENAI_API_KEY', 'sk-ここにAPIキーを貼る');
  const old = ScriptApp.getProjectTriggers();
  for (let i = 0; i < old.length; i++) {
    ScriptApp.deleteTrigger(old[i]);
  }
  ScriptApp.newTrigger('dailyBatch').timeBased().atHour(7).everyDays(1).create();
  Logger.log('セットアップ完了: 毎日07:00にdailyBatchが走ります');
}

// ---- 毎日07:00の入口 ----
function dailyBatch() {
  runQueue_();
}

// ---- 継続実行: 折り返しのときだけ生成されるワンショット ----
function queueContinue() {
  const trigs = ScriptApp.getProjectTriggers();
  for (let i = 0; i < trigs.length; i++) {
    if (trigs[i].getHandlerFunction() === 'queueContinue') {
      ScriptApp.deleteTrigger(trigs[i]);
    }
  }
  runQueue_();
}

function runQueue_() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(10 * 1000)) {
    Logger.log('別の実行が走行中のため終了(二重起動ガード)');
    return;
  }
  const start = Date.now();
  const props = PropertiesService.getScriptProperties();
  const apiKey = props.getProperty('OPENAI_API_KEY');
  if (!apiKey) {
    throw new Error('OPENAI_API_KEY が未設定です。setup() を1回実行してください');
  }
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
  const checkpoint = Number(props.getProperty(CHECKPOINT_KEY) || String(HEADER_ROWS));
  const lastRow = sheet.getLastRow();

  for (let row = checkpoint + 1; row <= lastRow; row++) {
    if (Date.now() - start > SAFE_LIMIT_MS) {
      ScriptApp.newTrigger('queueContinue').timeBased().after(CONTINUE_DELAY_MS).create();
      Logger.log('安全限界に達したので継続トリガーを2分後に生成して終了');
      lock.releaseLock();
      return;
    }
    const task = sheet.getRange(row, COL_ID, 1, 2).getValues()[0];
    const taskId = task[0];
    const text = task[1];
    if (taskId === '' || text === '') {
      continue; // 空行は送信対象外(checkpointは進めない)
    }
    const status = sheet.getRange(row, COL_STATUS).getValue();
    if (status === 'done') {
      continue; // 表示用ステータスの整合確認(真実はcheckpoint)
    }

    sheet.getRange(row, COL_STATUS).setValue('処理中');
    let summary;
    try {
      summary = callAi_(apiKey, text);
    } catch (e) {
      sheet.getRange(row, COL_STATUS, 1, 2).setValues([['error', String(e).slice(0, 250)]]);
      Logger.log('行' + row + 'で失敗。checkpointは進めず、翌朝この行から再開: ' + e);
      lock.releaseLock();
      return; // ここで止める=後続を流さない=大量欠落を防ぐ
    }
    sheet.getRange(row, COL_STATUS, 1, 3).setValues([['done', summary, new Date()]]);
    props.setProperty(CHECKPOINT_KEY, String(row)); // 送信成功の瞬間だけ進める
    Utilities.sleep(350); // LLM APIへの間隔調整
  }
  Logger.log('未処理行なし、バッチ完走');
  lock.releaseLock();
}

// ---- LLM呼び出し: 429/5xxは指数バックオフで最大3回 ----
function callAi_(apiKey, text) {
  const payload = {
    model: MODEL,
    temperature: 0.2,
    messages: [
      { role: 'system', content: 'あなたは問い合わせの要約係です。入力を3行以内の箇条書きで要約してください。' },
      { role: 'user', content: text }
    ]
  };
  let waitMs = 2000;
  for (let attempt = 1; attempt <= HTTP_MAX_RETRIES; attempt++) {
    const res = UrlFetchApp.fetch(API_URL, {
      method: 'post',
      contentType: 'application/json',
      headers: { 'Authorization': 'Bearer ' + apiKey },
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    });
    const code = res.getResponseCode();
    if (code === 200) {
      return JSON.parse(res.getContentText()).choices[0].message.content.trim();
    }
    if (code === 429 || (code >= 500 && code < 600)) {
      const headers = res.getHeaders();
      let retryAfter = 0;
      const keys = Object.keys(headers);
      for (let k = 0; k < keys.length; k++) {
        if (keys[k].toLowerCase() === 'retry-after') {
          retryAfter = Number(headers[keys[k]]) || 0;
        }
      }
      Utilities.sleep(Math.max(retryAfter * 1000, waitMs));
      waitMs = waitMs * 2;
      continue;
    }
    throw new Error('HTTP ' + code + ' リトライ不能: ' + res.getContentText().slice(0, 200));
  }
  throw new Error('429/5xxが' + HTTP_MAX_RETRIES + '回続き、再試行を断念');
}

// ---- 動作確認用: 最初に手動実行してAPI接続だけ確かめる ----
function testAi() {
  const key = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
  Logger.log(callAi_(key, 'この文章を3行で要約してください: GASの6分制限は安全限界で自発的に折り返し、継続トリガーで残りを回し切る。'));
}

タイムゾーンを固定したmanifestです(.gsと同じディレクトリのappsscript.jsonを上書きします。atHour指定のトリガーはタイムゾーン依存なので日本時間を明示します)。

{
  "timeZone": "Asia/Tokyo",
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8"
}

セットアップ手順(5分)

  1. スプレッドシートを作り、シート名を Tasks にして、上の「Tasks シート」のレイアウトのとおりA〜E1行目にヘッダーを入れる
  2. スプレッドシートのメニュー「拡張機能 → Apps Script」を開き、main部分のコードをそのまま貼り付ける
  3. setup() を一度だけ手動実行する(APIキーの登録と毎日07:00トリガーの作成をまとめて行う。キーはコードに書かず、実行後に「プロジェクトの設定 → スクリプト プロパティ」で OPENAI_API_KEY の値を実キーに差し替えるのが安全)
  4. testAi() を実行し、ログに要約文が返ることを確認する
  5. シートの2行目以降にタスクIDと元テキストを入れておけば、07:00のトリガーまたは手動実行の dailyBatch で回り始める

失敗パターン: status列を「処理中→done」で再開キーにしていた

最初の修正版では、checkpointではなくスプレッドシートのstatus列だけを再開キーにしていました。処理前に「処理中」、送信後に「done」を書く方式です。この方式は、LLMの応答を受け取った直後からstatus書き込み完了までの数百ミリ秒の窓を1行ずつ大量に作ります。私も実際に、夜間トリガーと手動テストが重なった日に、done書き込み直前で切断された行が翌日に二重送信されました。修正は「真実」をstatus列から1個の行番号に置き換えることでした。行番号は setProperty 1回で更新され、書き込みは原子的で、status列はただの画面表示に戻ります。

上限値の早見表

項目 上限 この設計での消費
1回の実行時間 6分 280秒で自発折り返し(余裕20秒)
全トリガーの合計実行時間 90分/日 約14分/日(3実行×折り返し含む)
UrlFetchApp 20,000回/日 640件+リトライ数回で余裕
スプレッドシートへの書き込み 30,000件/日 640行×約3書き込み+checkpoint

まとめ

  • 6分制限は「切れる前の280秒で自発的に折り返し、ワンショットトリガーでの続き走」として設計段階で組み込む。上限ギリギリ運用は書き込み途中で切れて整合性を壊す
  • 送信済みの真実は1つに絞る: Script Propertiesの行番号(checkpoint)。status列は表示でしかない
  • 同時実行は LockService.tryLock で直列化。10秒で取れないなら即終了でよい
  • 失敗した行はcheckpointを進めずにその場で止まる。後続を流し続けるより、翌朝同じ行から自動再開する方が安全で、大量の欠落も二重送信も発生しない
  • 次のアクション: 今日動いているGASバッチを1本開き、「切れたときの再開位置」と「送信済みの保存先」がコードに書かれているか確認する。どちらも無ければ、この記事のcheckpoint方式へ一段で置き換えるのが最短です。

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

関連記事(同テーマ)

参考書籍: 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?