※本記事はプロモーションを含みます
目次
Google Apps Script(GAS)でスプレッドシートの膨大な行を読み込んで外部APIと同期したり、数百通のメールを一括送信したり、Drive上の重いファイルを走査したりしていると、必ず直面するのが「Exceeded maximum execution time(最長実行時間を超過しました)」というエラーです。GASには通常アカウント・Google Workspaceアカウント問わず1回あたりのスクリプト実行時間に上限が設けられており、標準的な無料アカウントでは6分(360秒)でスクリプトが強制終了されます。
途中で強制終了されると、トランザクションがロールバックされるわけではありません。なので、どこまで書き込みが終わったのかが分からなくなり、データの二重処理や中途半端な破損を招きます。この問題を解決するには、GAS 6分制限 回避 分割実行の仕組みをコード内に組み込み、自前で処理経過時間を計測しながら安全なタイミングで中断し、次の実行へ引き継ぐ設計を組む必要があります。ただし、本稿では、実務のバッチ処理で破綻しないための時間監視とトリガー連動の設計を整理します。
6分の壁を超えるための「自前タイマー監視」と処理中断の基本骨格
時間制限の対策として最も確実なのは、処理ループの1周ごとに経過時間をミリ秒単位で取得する手法です。なので、制限時間(360秒)よりも余裕を持った閾値(例えば5分=300秒)に達した瞬間にループを抜け出す自律的な制御を行います。外部から強制終了される前にスクリプト側が自主的に終了処理へ移行できれば、どこまで処理したかのインデックスを退避させられます。というのは、クリーンな状態で終了することができるからです。
実務でこの構成を使うのは、数千件規模のスプレッドシート行を更新する処理や、HTTPリクエストを1件ずつ投げる処理、Driveフォルダー内の深い階層を再帰探索するバッチなどです。なので、1回の処理に掛かる時間が読めないタスクほど、固定の件数で区切るのではなく「実時間」を計測して分割する方式が有効になります。
/**
* 実行時間を監視しながら安全に中断・継続するバッチ処理
*/
function runBatchWithTimeCheck() {
const startTime = new Date().getTime();
const TIME_LIMIT_MS = 300000; // 5分(300,000ミリ秒)を目安に設定
const props = PropertiesService.getScriptProperties();
// 前回の続きのインデックスを取得(初回は0)
let lastIndex = parseInt(props.getProperty('BATCH_CURRENT_INDEX') || '0', 10);
const totalItems = 1000;
Logger.log('バッチ処理開始: 開始インデックス = ' + lastIndex);
let isInterrupted = false;
for (let i = lastIndex; i < totalItems; i++) {
// 経過時間を計測
const elapsed = new Date().getTime() - startTime;
if (elapsed > TIME_LIMIT_MS) {
Logger.log('5分経過のため次回へ持ち越し: 現在インデックス = ' + i);
// 次回開始位置を保存
props.setProperty('BATCH_CURRENT_INDEX', i.toString());
isInterrupted = true;
break;
}
// ここに1件ごとの実処理を記述(例: 重いAPI呼び出しやセル計算)
processItem(i);
}
if (isInterrupted) {
// 次回トリガーをセットして終了
scheduleNextRun();
} else {
// 全件完了したため、状態をリセット
Logger.log('全件処理が正常に完了しました');
props.deleteProperty('BATCH_CURRENT_INDEX');
clearBatchTrigger();
}
}
function processItem(index) {
// 実際のデータ更新やAPI通信の代行ダミー処理
Utilities.sleep(100);
}
function scheduleNextRun() {
clearBatchTrigger(); // 既存トリガーの重複を防止
ScriptApp.newTrigger('runBatchWithTimeCheck')
.timeBased()
.after(60000) // 1分後に再実行
.create();
Logger.log('1分後に次回トリガーを設定しました');
}
function clearBatchTrigger() {
const triggers = ScriptApp.getProjectTriggers();
for (let i = 0; i < triggers.length; i++) {
if (triggers[i].getHandlerFunction() === 'runBatchWithTimeCheck') {
ScriptApp.deleteTrigger(triggers[i]);
}
}
}
上記コードの根幹は、ループの先頭で (new Date().getTime() - startTime) > 300000 を判定している箇所です。なので、6分の壁対策として、300秒(5分)を超えた段階で安全弁を働かせています。ただし、これにより最大でも残り60秒の猶予を残してループを抜け出せるため、その後の状態保存やトリガー作成処理がタイムアウトに巻き込まれる事故を防止できます。
ミリ秒計算と処理ブロックのズレで発生するタイムアウトの罠
時間監視を導入したにもかかわらず、本番環境で「やはり6分で落ちた」というトラブルが起きることがあります。というのは、この原因の多くが、時間監視を行っている「チェック地点」と「1イテレーションの所要時間」のズレにあるからです。
例えば、外部APIへのリクエストや、大量のセル範囲を読み込む処理が1周あたり20〜30秒かかる重い処理だったとします。なので、タイマー判定の時点で経過時間が4分50秒(290秒)だった場合、閾値の300秒未満であるためプログラムは次のループに突入します。ただし、その周の処理に80秒かかってしまうと、ループ終了時には370秒に達し、判定の直前あるいは処理の途中で6分の壁に激突してスクリプトが即死します。
開発者がこれを回避するためには、安全マージン(閾値)を処理ブロックの「最悪の所要時間」から逆算して設定しなければなりません。なので、1ループの最大所要時間が不明な場合は、閾値を4分30秒(270,000ミリ秒)や4分(240,000ミリ秒)まで前倒しにするのが実務的な安全策です。ただし、余裕を持たせすぎると1回あたりの進捗が遅くなりますが、途中でクラッシュして状態が失われるリスクに比べれば、少し手前で止めて次のトリガーに引き渡す方がはるかに堅牢です。
また、スプレッドシートの操作自体も大きな遅延要因です。なので、ループの中で sheet.getRange(i, 1).setValue(...) のようにセルへの個別アクセスを行っていると、API呼び出し回数の上限に引っかかるだけでなく、ネットワークレイテンシが蓄積してループ1周の時間がどんどん肥大化します。ただし、シートの読み書きを行う場合は、ループの外で一括配列として読み書きを行い、メモリ上の配列インデックスに対して時間監視を行う設計を徹底しましょう。
プロパティストアを用いた進捗管理と「二重起動」の競合制御
時間監視によってループを安全に中断できても、中断時のステータスをどこに保持するかが次の課題になります。スプレッドシートの特定のセルに進捗行番号を書き込む方法もあります。ただし、シートをユーザーが手動で編集したり行を削除したりするとバッチのステータスが狂ってしまいます。なので、状態管理には PropertiesService.getScriptProperties() を使用しましょう。
ただし、プロパティストアを使う際には「二重起動による競合」を考慮しなければなりません。動的に時間トリガー(after(60000) など)を発行して連続バッチを回す場合、前回の処理が何らかの理由で長引いているケースがあります。なので、Google側のインフラの都合でトリガーが予定より数秒早く発火したり、管理者が手動でテスト実行ボタンを押してしまったりすると、同一のスクリプトが複数同時に動く事態が発生します。
2つのプロセスが同時に動くと、同じ進捗インデックスを読み込み、同じ対象に対して二重にAPI通信やメール送信を行ってしまいます。なので、これを防ぐには、処理開始時にロックを取得する排他制御が必須です。ただし、GASでは LockService.getScriptLock() を利用して、先行するスクリプトが動いている間は後続のスクリプトを待機させるか、即座に終了させるガード処理を挟む必要があります。
具体的には、関数の冒頭で const lock = LockService.getScriptLock(); if (!lock.tryLock(1000)) { return; } のように排他ロックを試行し、すでに別プロセスが動いているなら何もしないで終了させる設計にします。なので、トリガーの重複や手動実行との衝突によるデータの多重更新を完全に遮断できます。ただし、中断時または全件終了時に確実に lock.releaseLock() を呼ぶか、スクリプト終了時の自動解放に委ねることで、安全なバッチ進行が担保されます。
動的トリガーによる自律ループの終了判定とゾンビトリガーの破棄
GAS 6分制限 回避 分割実行を実現する最大の武器は、中断処理の中で自前で次の時間トリガーを生成する「リレー方式」です。ただし、この設計を採用したプロジェクトで頻繁に見られる障害が、トリガーの削除漏れによって無限にトリガーが残り続け、プロジェクトの最大トリガー数(1プロジェクトあたり20個)に達してスクリプト全体が停止する現象です。
開発者がこれを避けるためには、トリガーの生成と破棄のライフサイクルを厳密に管理しなければなりません。なので、新しいトリガーを作成する前には、必ず ScriptApp.getProjectTriggers() を走査し、同名の関数に紐づいている既存トリガーをすべて deleteTrigger() で消去してから新しいトリガーを作る習慣をつけましょう。というのは、これを怠ると、エラーが発生してリトライが走った際などにトリガーがネストして倍々ゲームで増殖していくからです。
さらに重要なのが、終了判定のロジックです。なので、「全データ件数に達した」という判定だけでなく、「処理対象が0件だった場合」「エラーが規定回数以上連続した場合」にもプロパティをクリアし、残っているトリガーを完全に消し去る出口を用意しなければなりません。ただし、もし全件終了時のトリガー消去に不具合があると、データがない状態で1分ごとに空の関数が呼び出され続ける、いわゆるゾンビトリガー化が発生します。
また、Google Workspaceの全体クォータとして「1日あたりのトリガー合計実行時間(通常90分、Workspaceで6時間)」が存在することも忘れてはなりません。なので、6分制限を回避して事実上無限に処理を続けられるといっても、1日の総実行時間を使い果せばその日は一切のトリガーが発火しなくなります。ただし、分割実行を設計する際は、本当にすべてのデータを1件ずつ処理する必要があるのか、前段で更新対象の絞り込みを行えないか、といった処理対象自体の最適化を併せて検討しましょう。

