はじめに
前回は、HtmlServiceのダイアログ+チャンク分割方式で、6分の実行時間上限を回避できることを確認しました。ただしこの方式は、モーダルダイアログを開いたまま画面を見ている必要があり、実装もやや作り込みが必要です。
今回検証するのは、Office Scripts第6回と同じセルに進捗を書き込むだけのシンプルな方式です。gas-01の時点で「1回のループが最後まで回り続けるこの方式は、単体では6分の壁を越えられない」ことが分かっていました。そこで今回は、時間主導型トリガーを連鎖させることで、処理を一定時間ごとに区切り、続きを次のトリガー実行に引き継ぐという方法を試します。ダイアログを開きっぱなしにする必要がなく、進捗はセルを見るだけで確認できる、より手軽な方式を目指します。
注意: 本記事のコードは、実際にGoogleスプレッドシート上で動作検証を行っています。1000件・2000件のデータでの完走確認、トリガーの後始末の確認、キャンセルボタンの動作確認など、検証で判明した内容をそのまま反映しています。なお検証の過程で、1回あたり6分の実行時間上限とは別に「トリガーの合計実行時間:1日90分(個人アカウント)」という別枠のクォータが存在することも判明しました(詳しくはStep3後半)。トリガー連鎖はあくまで1回あたりの上限を回避する手段であり、1日の合計処理量そのものを増やせるわけではない点にご注意ください。
前回までのおさらい
| 実現したいこと | これまでの検証結果 |
|---|---|
| 6分の実行時間上限の回避 | gas-02のダイアログ+チャンク分割方式で実現済み(1000件・6分30秒超で完走) |
| セルに書き込むだけのシンプルな進捗表示 | gas-01のセル単位ループでは1000件でタイムアウト。単体では6分の壁を越えられない |
時間主導型トリガーからのUi.alert() | gas-01で例外が発生することを確認済み(getUi()はUIコンテキストが必要) |
今回は、この「セルベースの進捗表示」を諦めずに、トリガーを使った実行時間の分割という別の切り口で6分の壁に挑みます。
実現方針の全体像
【今回検証するトリガー連鎖方式】
ボタン押下 → chainedTransfer() を直接実行(1回目)
└─ メインループの中で経過時間を計測
安全マージン(例:5分)を超えたら、その場でループを中断
├─ 制御シートに「再開行」を保存
└─ 時間主導型トリガーで chainedTransfer() を再度呼び出す予約をする
└─ トリガーで呼ばれた chainedTransfer() は、保存された「再開行」から処理を再開
再び安全マージンに達したら同じことを繰り返す(=連鎖)
└─ 最後まで処理が終わったら、後始末(トリガー削除・進捗表示の更新)をして終了
gas-02のチャンク分割方式が「クライアント側JSが少量ずつ呼び出す」仕組みだったのに対し、今回はサーバー側(GAS)だけで完結します。1回の実行が安全マージンに達したら、次の実行を自分で予約して終了する、という「トリガーのリレー」がポイントです。
※「チャンク」とは構造ごとのブロックに分けて処理すること。
準備:制御シートに項目を追加する
gas-01・gas-02で使ってきた「制御」シートに、今回用のセルを追加します。
| セル | 用途 | 初期値 |
|---|---|---|
| A1 | 進捗表示(文字列) | 空欄 |
| B1 | キャンセルフラグ(真偽値) | FALSE |
| D1 | 再開行(トリガー連鎖用。数値) | 空欄(未処理なら空欄のまま) |
セル位置について: A1(進捗表示)・B1(キャンセルフラグ)は、Office Scripts第6回と同じセルに揃えています。gas-01の「制御」シートは図形ボタンを置いているだけでA1・B1は未使用、gas-02が使っているのはC1(チャンクサイズ手動指定)のみだったため、あえてずらす理由がありませんでした。今回新たに必要な「再開行」だけをD1に追加し、C1はgas-02のコードと共存できるよう空けています。
Step 1:経過時間チェックとトリガー引き継ぎの基本実装
メインループの中でDate.now()を使って経過時間を測り、安全マージンを超えたらループを抜けて次のトリガーを予約します。HIT件数はキャッシュではなくPropertiesService(有効期限なし)で保持し、長時間・複数回の実行をまたいでも値が消えないようにしました。
PropertiesServiceとは: GASでキー・バリュー形式の値を保存できる仕組みの一つです。gas-02で使ったCacheServiceと似ていますが、CacheServiceが最長6時間で自動的に消えてしまうのに対し、PropertiesServiceに保存した値は明示的に削除するまで消えません。今回のようにトリガーをまたいで長時間値を持ち越したい場合に向いています。VBAでいえばSaveSetting/GetSetting(またはレジストリへの保存)に近いイメージです。スコープの異なる3種類(プロジェクト全体で共有するgetScriptProperties()・実行ユーザー単位のgetUserProperties()・紐づくドキュメント単位のgetDocumentProperties())があり、今回はスクリプト全体で共有したいのでgetScriptProperties()を使っています。使い分けの詳細は別記事で掘り下げる予定です。
const SAFE_LIMIT_MS = 5 * 60 * 1000; // 安全マージン:5分(6分の上限に対して1分の余裕を持たせる)
const TRIGGER_FUNC = 'chainedTransfer';
const BAR_LENGTH = 20; // 文字バーの全体の長さ
// ボタンから呼ぶ:初期化して1回目の処理を直接実行する
function startChainedTransfer() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const ctrlSh = ss.getSheetByName('制御');
const props = PropertiesService.getScriptProperties();
// ★ 前回の処理がまだ完了していないか確認する(多重起動防止・その1)
// トリガー発火とボタン押下が重なると、chainedTransfer()が二重に走って
// HIT件数などの状態が壊れることが実機検証で判明したため追加した
const resumeVal = ctrlSh.getRange('D1').getValue();
if (typeof resumeVal === 'number' && resumeVal > 0) {
const ui = SpreadsheetApp.getUi();
const response = ui.alert(
'確認',
'前回の処理が完了していないようです(続きの行が残っています)。最初からやり直しますか?\n「いいえ」を選ぶと、トリガーによる自動継続を待ちます。',
ui.ButtonSet.YES_NO
);
if (response !== ui.Button.YES) return;
}
// 前回分の古いトリガーが残っていないか掃除してから開始
deleteMyTriggers();
ctrlSh.getRange('D1').setValue(''); // 再開行をクリア(最初から処理する)
ctrlSh.getRange('B1').setValue(false); // キャンセルフラグをクリア
ctrlSh.getRange('A1').setValue('開始します…');
props.setProperty('transfer_hcount', '0');
props.setProperty('transfer_starttime', String(Date.now())); // ★ チェーン全体の開始時刻
// ★ 1回目はボタン押下=UIコンテキストがあるので直接実行する
chainedTransfer();
}
// トリガーからも、1回目は上のstartChainedTransfer()からも呼ばれる本体
function chainedTransfer() {
// ★ 多重起動防止・その2:他のインスタンスが実行中なら、今回はそのまま終了する
// トリガー実行とボタン起動が同時に走ってしまうケースの最終防波堤
const lock = LockService.getScriptLock();
if (!lock.tryLock(0)) {
console.log('別のchainedTransferが実行中のため、今回の呼び出しはスキップしました');
return;
}
try {
const legStartTime = Date.now(); // このレグ(1回の実行)の開始時刻。安全マージン判定に使う
const ss = SpreadsheetApp.getActiveSpreadsheet();
const ctrlSh = ss.getSheetByName('制御');
const prefSh = ss.getSheetByName('貼付元');
const sName = prefSh.getRange('K1').getValue();
const workSh = ss.getSheetByName(sName);
const wtCol = findValueInRow(workSh, 2, '▼');
const ptCol = findValueInRow(prefSh, 2, '▼');
const pMCol = getMaxInRow(prefSh, 2);
const wMCol = getMaxInRow(workSh, 2);
const gRow = workSh.getRange('G1').getValue(); // 本来の開始行
const pgRow = prefSh.getRange('G1').getValue();
const workShEndR = getLastRow(workSh, wtCol);
const prefShEndR = getLastRow(prefSh, ptCol);
const totalRows = workShEndR - gRow + 1;
const pCol = [];
for (let i = 1; i <= pMCol; i++) pCol.push(findValueInRow(prefSh, 2, i));
const wCol = [];
for (let i = 1; i <= wMCol; i++) wCol.push(findValueInRow(workSh, 2, i));
const tgetRng = prefSh.getRange(pgRow, ptCol, prefShEndR - pgRow + 1, 1).getValues();
// ★ 再開行:D1に値があればそこから、なければ通常の開始行(G1)から
const resumeVal = ctrlSh.getRange('D1').getValue();
const startRow = (typeof resumeVal === 'number' && resumeVal > 0) ? resumeVal : gRow;
const props = PropertiesService.getScriptProperties();
let lngHcount = Number(props.getProperty('transfer_hcount') || 0);
const chainStartTime = Number(props.getProperty('transfer_starttime') || legStartTime);
const checkInterval = Math.min(1000, Math.max(1, Math.ceil(totalRows / 50)));
// 進捗表示をまとめて組み立てる(文字バー+%+件数+チェーン全体の経過時間)
const buildStatus = (processedCount, extra) => {
const percent = Math.round((processedCount / totalRows) * 100);
const filled = Math.floor((percent / 100) * BAR_LENGTH);
const bar = '■'.repeat(filled) + '□'.repeat(BAR_LENGTH - filled);
const elapsed = formatElapsed(Date.now() - chainStartTime);
return `${bar} ${percent}%(${processedCount}/${totalRows}件・HIT ${lngHcount}件・${elapsed}経過)${extra || ''}`;
};
for (let tgetTmpR = startRow; tgetTmpR <= workShEndR; tgetTmpR++) {
// ★ キャンセル確認(トリガー実行には停止ボタンがないため必須。詳細はStep4)
if (ctrlSh.getRange('B1').getValue() === true) {
ctrlSh.getRange('A1').setValue(buildStatus(tgetTmpR - gRow, '(中断しました)'));
deleteMyTriggers();
return;
}
const tmpStr = workSh.getRange(tgetTmpR, wtCol).getValue();
let matchRng = -1;
for (let r = 0; r < tgetRng.length; r++) {
if (tgetRng[r][0] === tmpStr) { matchRng = r + pgRow; break; }
}
if (matchRng !== -1) {
const myArray = [];
for (let i = 0; i < pMCol; i++) myArray.push(prefSh.getRange(matchRng, pCol[i]).getValue());
for (let i = 0; i < wMCol; i++) workSh.getRange(tgetTmpR, wCol[i]).setValue(myArray[i]);
lngHcount++;
}
const processedCount = tgetTmpR - gRow + 1;
if (processedCount % checkInterval === 0) {
ctrlSh.getRange('A1').setValue(buildStatus(processedCount));
props.setProperty('transfer_hcount', String(lngHcount));
}
// ★ 経過時間チェック:このレグの実行時間が安全マージンを超えたら中断し、トリガーに引き継ぐ
if (Date.now() - legStartTime > SAFE_LIMIT_MS) {
props.setProperty('transfer_hcount', String(lngHcount));
ctrlSh.getRange('D1').setValue(tgetTmpR + 1); // 次回はここから再開
ctrlSh.getRange('A1').setValue(buildStatus(processedCount, '(トリガーに引き継ぎます…)'));
scheduleNextRun();
return;
}
}
// 完走
props.setProperty('transfer_hcount', String(lngHcount));
const elapsed = formatElapsed(Date.now() - chainStartTime);
// ★ 処理件数(=データ件数)と更新件数(=HIT件数)は別物なので、区別して表示する
ctrlSh.getRange('A1').setValue(`完了!【処理件数: ${totalRows} 件・更新件数: ${lngHcount} 件・${elapsed}経過】`);
ctrlSh.getRange('D1').setValue('');
deleteMyTriggers();
ss.toast(`処理件数: ${totalRows} 件・更新件数: ${lngHcount} 件`, '完了!', 10);
} finally {
lock.releaseLock(); // ★ 途中のreturnも含め、必ずここでロックを解放する
}
}
// ミリ秒を「◯:◯◯」形式の文字列に変換する
function formatElapsed(ms) {
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${String(sec).padStart(2, '0')}`;
}貼付元・貼付先シートの行・列マッピング検出処理(findValueInRow・getMaxInRow・getLastRow)はgas-01のヘルパー関数をそのまま流用します。
なお、chainedTransfer()内の字下げ(インデント)はtryブロックによる階層を反映せず簡略化しています。動作には影響ありません。
実際にA1セルに表示される進捗表示がこちらです。

★多重起動防止について(実機検証で発覚した不具合の修正): 検証中、トリガーが発火した直後に「データ転記実行」ボタンを押してしまい、chainedTransfer()が2つ同時に動いてしまうという不具合が実際に発生しました。両方のインスタンスが同じPropertiesServiceのHIT件数を読み書きするため、後から書き込んだ方が値を上書きしてしまい、HIT件数が処理件数と矛盾する(実際より多い・少ない)という壊れ方をします。これを防ぐため、2段構えの対策を入れています。
startChainedTransfer()側: D1(再開行)に値が残っている=前回の処理がまだ終わっていない場合、確認ダイアログを表示してから開始するchainedTransfer()側:LockService.getScriptLock()で排他制御し、すでに他のインスタンスが実行中ならtryLock(0)がfalseを返すので、何もせずそのまま終了する
1だけでは「ダイアログの『はい』を押した直後に、トリガー側のインスタンスもまだ処理を続けている」というタイミングまでは防げないため、2のLockServiceが最終的な防波堤になります。
ポイント①: HIT件数はCacheServiceではなくPropertiesServiceで保持しています。CacheServiceには最長6時間という有効期限があり、トリガーの起動間隔次第では途中で値が消えるリスクがあるため、有効期限のないPropertiesServiceを選びました。チェーン全体の開始時刻(transfer_starttime)も同じ理由でPropertiesServiceに保持しています。
ポイント②:経過時間は2種類ある: コード中には経過時間の計測が2つ登場します。legStartTimeはこのレグ(1回の実行)が始まってからの経過時間で、安全マージン判定にのみ使います。一方、進捗表示に出す「◯:◯◯経過」はchainStartTime(startChainedTransfer実行時に記録したチェーン全体の開始時刻)からの経過時間です。トリガーの起動待ち時間も含めた体感の所要時間を知りたい場合は後者を見てください。
ポイント③:「処理件数」と「更新件数」は別物: totalRows(=処理件数。貼付先シートの全行数で、ループは必ずこの件数だけ回る)とlngHcount(=更新件数。ID照合がヒットして実際に転記が行われた件数)は意味が異なります。ID照合が見つからない行があればlngHcountはtotalRowsより少なくなります。完了メッセージでは「処理件数:◯件・更新件数:◯件」と分けて表示し、この違いが一目で分かるようにしています。
文字バーで視覚的に見せる(データバーはGASでは非対応)
Office Scripts第6回では、文字の繰り返しによる疑似バーに加えて、Excelの「データバー」条件付き書式を使う方法も紹介していました。GAS(Google Sheets)の条件付き書式には、Excelのデータバーに相当する機能がありません。 Apps ScriptのSpreadsheetApp.newConditionalFormatRule()で組めるのは単色条件(whenNumberGreaterThan等)とグラデーション(setGradientMaxpoint等)の2種類のみで、セル内に数値の大きさに応じたバーを描画するAPIは用意されていません。そのため今回は、上記コードのbuildStatus関数内にある文字の繰り返し(■・□)による疑似バーのみで対応しています。
Step 2:トリガーの後始末(自己クリーンアップ)
時間主導型トリガーは、.create()で作成した1回限りのトリガーであっても自動では削除されません。実行のたびに新しいトリガーを作りっぱなしにすると、GASのトリガー上限(個人アカウントで1スクリプトあたり20個)にすぐ達してしまいます。そこで、次のトリガーを作る前・処理完了時・キャンセル時のいずれでも、まず既存のトリガーを掃除する関数を挟みます。
// 次回実行分のトリガーを1つ予約する
function scheduleNextRun() {
deleteMyTriggers(); // 念のため、予約前に既存のトリガーを掃除する
ScriptApp.newTrigger(TRIGGER_FUNC)
.timeBased()
.after(1000) // 約1秒後を指定(あくまで目安。実機検証では1秒未満〜2分程度までばらつきがあった。詳細はStep3)
.create();
}
// chainedTransfer に紐づくトリガーをすべて削除する
function deleteMyTriggers() {
const triggers = ScriptApp.getProjectTriggers();
for (const t of triggers) {
if (t.getHandlerFunction() === TRIGGER_FUNC) {
ScriptApp.deleteTrigger(t);
}
}
}ScriptApp.getProjectTriggers()でスクリプト内の全トリガーを取得し、getHandlerFunction()がchainedTransferと一致するものだけを削除する、という絞り込み方です。他の目的(例:gas-02のダイアログ方式)で別のトリガーを併用していても巻き込みません。
実機検証の補足: 1000件の処理が完走した後もApps Scriptエディタの「トリガー」一覧にchainedTransferのトリガーが1件残っていました。詳しくはStep3で説明しますが、これは.after()で作った1回限りのトリガーが発火後に「無効」化される仕様によるもので、再発火の心配はありません。3回連続で実行してもトリガー一覧は1行のままだったことから、deleteMyTriggers()は狙いどおり機能しており、蓄積することもないと確認できています。
Step 3:★6分の壁を回避できるか検証
ここが今回の本題です。実際にGoogleスプレッドシート上で1000件のデータを使い、ボタンからstartChainedTransferを実行して検証しました。
SAFE_LIMIT_MS=5分の設定で1000件を処理したところ、合計9分37秒(チェーン全体の経過時間表示より)で完走し、タイムアウトエラーは発生しなかった。
実機検証結果:
- 処理途中、72%(720/1000件・HIT 720件)時点でA1は「6:24経過」を表示しており、同時に再開行(D1)には
719が保存されていた。安全マージンに達した時点で正しく中断し、再開行を保存できていることを確認 - 完了時点でA1は「完了!【データ更新件数: 1000 件・9:37経過】」、B1(キャンセルフラグ)は
FALSEのまま(この完了メッセージの表記は、後述の理由により本文のコードでは「処理件数」「更新件数」を分けて表示する形に変更しています) - 安全マージンが5分のため、9:37という総所要時間は少なくとも2回のレグ(1レグ目で安全マージンいっぱいの約5分、2レグ目で残りの約4:37)に分割されて処理されたと推測される
- トリガーの引き継ぎ速度: 1000件のときは「トリガーに引き継ぎます…」の表示が1秒足らずで次の進捗表示に上書きされ、キャプチャできないほど速かった。ところが2000件で再検証したところ、レグとレグの間に1分30秒〜2分ほどの遅延があることが判明した(詳細はこの後の「2000件での追加検証」を参照)。
.after(1000)で指定した「約1秒後」はあくまで目安の予約であり、実際の起動タイミングには数秒〜数分単位のばらつきがあるというのが実態に近い - 1回目のレグ(ボタン起動)では、gas-01で確認したのと同じ「スクリプトを実行しています…[キャンセル][表示しない]」という実行状況バーが表示された。 2回目以降のトリガー実行ではこのバー自体が表示されない(詳細はStep4)
トリガーの残留について:
完走後にApps Scriptエディタの「トリガー」一覧を確認したところ、chainedTransferに紐づく時間ベースのトリガーが1件、「前回の実行:無効」という状態で残っていました。

無効理由の詳細を確認したところ、以下のように表示されていました。
無効理由:このトリガーは期限が切れています。トリガーが有効になるのは1回だけであり、完了すると無効になります。
これはGoogle公式の説明で、.after()で作成する1回限りの時間主導型トリガーは、1度発火すると「無効」状態になる仕様であることを示しています。再度このトリガーが発火して処理が二重に走る心配はないということです。
さらに、チェーンを3回連続で実行した後もトリガー一覧は1行のままであることを確認しました。つまり、表示されている「無効」トリガーは直近の実行で発火した1件がその都度上書きされているだけで、deleteMyTriggers()によって前回分は正しく削除されているということです。トリガー上限(個人アカウントで20個)に向かって蓄積していく心配はないと判断できます。
2000件での追加検証:レグ数が増えると何が起きるか
1000件のテストは2レグで完走したため、3レグ以上に連鎖した場合の挙動が未確認でした。そこでデータ量を2000件に増やして再検証しました。
結果は、5レグに分かれて合計28分52秒で完走(HIT件数2000/2000で一致、データの欠損・重複なし)しました。完走後にトリガー一覧を確認したところ、1000件のときと同じく1件のままでした。レグ数が5つに増えてもdeleteMyTriggers()はすり抜けることなく機能しており、トリガーの蓄積が起きないことも確認できました。
各レグの区切りタイミングを進捗表示の「◯:◯◯経過」から逆算すると、以下のようになります。
| レグ | 区切り時点のチェーン経過時間 | そのレグの処理件数(目安) |
|---|---|---|
| レグ1 | 5:00(安全マージンきっかり) | 577件 |
| レグ2 | 12:00 | 約444件 |
| レグ3 | 18:32 | 約449件 |
| レグ4 | 24:49 | 約298件 |
| レグ5(完走) | 28:52 | 約232件 |
ここでレグ1の実処理時間はきっかり5:00(安全マージンどおり)だが、レグ2以降の区切り間隔は5:00より長くなっている点に注目してください。差分を計算すると、レグとレグの間には1分30秒〜2分ほどの「見えない待ち時間」が挟まっていることが分かります。これは、各レグ自体の処理(安全マージンで正しく5分に収まっている)とは別に、トリガーが実際に起動するまでの遅延が発生していることを示しています。
.after(1000)という予約は「約1秒後」という目安にすぎず、実際の起動タイミングにはテストのたびにかなりの幅(1秒未満〜2分程度)が出ることがある、というのが現時点での結論です。原因(Google側のトリガー実行キューの混雑状況など)までは特定できていませんが、大量データを扱う場合はこの「レグ間の待ち時間」も含めて、実際の完了までの体感時間が処理時間の単純合計より長くなる、と見込んでおくのがよさそうです。
★もう一つの壁:トリガーの「1日あたりの合計実行時間」クォータ
一連の検証を繰り返していたところ、あるタイミングでchainedTransferがタイムアウト扱いになる現象が発生しました。Apps Scriptエディタの「実行数」ログで確認すると、以下のように記録されていました。
| 関数 | 種類 | 期間 | ステータス |
|---|---|---|---|
| startChainedTransfer | メニュー | 302.231秒 | 完了 |
| chainedTransfer | 時間主導型 | 302.562秒 | 完了 |
| chainedTransfer | 時間主導型 | 479.713秒 | タイムアウト |
3回目の実行はログ詳細に「起動時間の最大値を超えました」というエラーが記録されていました。

SAFE_LIMIT_MS(5分=300秒)による自前の中断より先に、Google側の別の上限に達して強制終了されたことがわかります。
GASには、これまで扱ってきた「1回の実行あたり6分(個人アカウント)」という上限とは別に、「トリガーの合計実行時間:1日90分(個人アカウント)」という、1日単位の別枠クォータがあります。この日は本記事のための検証を何度も繰り返していたため、時間主導型トリガーの合計実行時間がこの日だけで2時間を超えており、90分のクォータを使い切ったタイミングでこのタイムアウトが発生したと考えられます。
これは今回の「トリガー連鎖で6分の壁を回避する」というアプローチの、実用上とても重要な限界です。 SAFE_LIMIT_MSによる分割は「1回の実行が6分を超えないようにする」ことはできますが、1日にどれだけの回数・時間を処理に使えるかという合計クォータそのものは回避できません。 個人アカウントの場合、単純計算では1日に処理できる総実行時間は90分が上限になります(開発中の検証で使い切ってしまうと、当日はそれ以上テストできなくなる点にも注意が必要です)。
Step 4:協調的キャンセル(トリガー実行には停止ボタンがない)
Office Scripts第6回では、スクリプト実行ボタンから起動した場合に限り、実行状態バーの「停止」ボタンでネイティブに中断できました。しかし今回のGASトリガー連鎖方式は、2回目以降の実行が時間主導型トリガー経由になるため、そもそも実行状態バー自体が表示されません。UIと紐づかない実行方法である以上、Office Scripts第6回の「協調的キャンセル」と同じ発想で、セルのフラグを定期的に読み取る方式一本で対応する必要があります。
Step 1のコードにはすでに組み込み済みですが、あらためて該当部分を確認します。
// メインループの先頭で毎回チェックする
if (ctrlSh.getRange('B1').getValue() === true) {
ctrlSh.getRange('A1').setValue(buildStatus(tgetTmpR - gRow, '(中断しました)'));
deleteMyTriggers();
return;
}ユーザーが「制御」シートのB1セルにTRUEを入力すると、現在実行中の1回分の処理が次の行に進むタイミングで中断されます。次のトリガーもdeleteMyTriggers()で予約前に削除されるため、キャンセル後に処理が勝手に再開してしまうことはありません。
Office Scripts第6回の停止ボタンのような即時性はありませんが、gas-02のチャンク分割方式(次のチャンク開始前に判定)と近い反応速度になる見込みです。
セルの手入力をやめて、キャンセル用ボタンを用意する
B1セルへの直接入力でもキャンセルはできますが、毎回セルを探してTRUEと打つのは手間です。gas-01の「図形(描画)にスクリプトを割り当てる」方法を使い、専用のキャンセルボタンから同じ処理を呼び出せるようにします。
// 「キャンセル」ボタンから呼ぶ
function cancelChainedTransfer() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const ctrlSh = ss.getSheetByName('制御');
ctrlSh.getRange('B1').setValue(true);
ss.toast('キャンセルを受け付けました。次のチェックタイミングで停止します。', '', 5);
}設置手順はgas-01の「GASでボタンから実行する方法」と同じです。「挿入」→「図形描画」で「キャンセル」などの図形を作成し、「︙」メニューから「スクリプトを割り当て」でcancelChainedTransfer(括弧なし)を指定するだけです。「データ転記実行」ボタンの隣に並べておくと、VBAのキャンセルボタンに近い見た目・操作感になります。
なお、B1セル自体は残しています。 ボタンからでも手入力からでも同じ結果になりますし、シートを見るだけで現在のキャンセル状態が一目でわかる(デバッグ時に直接書き換えて動作確認もできる)というメリットがあるため、あえて隠していません。
キャンセルボタン設置後、処理中に実際にクリックして動作を確認しました。ボタン押下すると実行状況ダイアログが表示され、B1セルにTRUEが書き込まれました。処理中のchainedTransferが次のチェックタイミングで正しく中断されるてキャンセルされたことを確認しました。

できること・できないことの整理
| 実現したいこと | 今回の方法 |
|---|---|
| セルに書き込むだけのシンプルな進捗表示 | ✅ 制御シートのA1セルで表示(gas-02のダイアログより実装は軽量) |
| 視覚的なバー表示 | △ 文字の繰り返しによる疑似バーのみ対応(Excelのデータバーに相当するAPIがGASにはない) |
| チェーン全体の経過時間表示 | ✅ PropertiesServiceにチェーン開始時刻を保持し、進捗表示に「◯:◯◯経過」を付与 |
| 6分の実行時間上限の回避 | ✅ 実機検証で確認済み(1000件・9分37秒/2000件・5レグ・28分52秒で完走、いずれもタイムアウトなし) |
| トリガー起動タイミングの予測可能性 | △ .after(1000)はあくまで目安。実測では1秒未満〜2分程度までばらつきがあり、保証はできない |
| 1日の処理量そのものの制限回避 | ❌ 「トリガーの合計実行時間:1日90分(個人アカウント)」という別枠クォータは回避できない。検証中に実際に到達し、タイムアウトが発生した |
| トリガー発火とボタン押下が重なった場合の安全性 | ✅ 実機検証で二重起動によるHIT件数の破損を確認したため、LockServiceと確認ダイアログの2段構えで対策済み |
| 処理中のキャンセル | ✅ セルのフラグによる協調的キャンセル(2回目以降のトリガー実行には停止ボタンがない)。専用のキャンセルボタンを設置し、動作を実機確認済み |
| トリガーの後始末(蓄積させない) | ✅ 1000件・3回連続実行、2000件・5レグの連鎖のいずれもトリガー一覧は1行のまま。deleteMyTriggers()により前回分は正しく削除され、蓄積しないことを確認 |
| ダイアログ(gas-02)と比べた実装の手軽さ | ✅ HtmlServiceのHTMLファイル追加が不要で、既存の「制御」シートの拡張だけで完結 |
よくある質問・エラー対処
Q. 「制御」シートのD1が見当たらない
→ 本記事の準備セクションで追加したセルです。gas-01・gas-02からスプレッドシートを流用している場合は、D1(再開行)が未追加の可能性があります。A1(進捗表示)・B1(キャンセルフラグ)はOffice Scripts第6回と同じセルなので、既に使っている場合は値の上書きに注意してください。本文「準備:制御シートに項目を追加する」を参照してください。
Q. 完走後もトリガーが1件残っている
→ .after()で作成した1回限りのトリガーが発火後に無効化される仕様どおりの表示です(詳細はStep3)。再発火の心配はなく、繰り返し実行してもトリガー一覧が1行から増えないことも確認済みです。
Q. トリガーがなかなか起動せず、処理が止まっているように見える
→ .after(1000)はあくまで目安で、実際の起動タイミングは1秒未満〜2分程度までばらつくことを実機で確認しています(詳細はStep3)。数分程度は正常範囲として待ち、それでも長時間反応がなければトリガー一覧を確認してください。
Q. chainedTransferが「起動時間の最大値を超えました」というエラーでタイムアウトする
→ Googleの「トリガーの合計実行時間:1日90分(個人アカウント)」というクォータに達している可能性が高いです(詳細はStep3)。Apps Scriptの「実行数」ログで積算時間を確認してください。達した場合は翌日まで待つか、Workspaceアカウント(1日6時間)を使う必要があります。
Q. Ui.alert()を使いたい
→ 2回目以降の実行は時間主導型トリガー経由になるため、gas-01で確認したとおりgetUi()は呼び出せません。完了通知には引き続きtoast()を使ってください(本文Step1のコード参照)。
まとめ
- セルに書き込むだけのシンプルな進捗表示を、時間主導型トリガーの連鎖で6分の実行時間上限の回避を狙う実装を組んだ(制御シートはA1=進捗・B1=キャンセルとOffice Scripts第6回に揃え、D1=再開行を追加)
- 1000件(9:37・2レグ)/2000件(28:52・5レグ)とも完走し、6分の壁の回避を実機で確認
- トリガーの起動タイミングは
.after(1000)という指定があっても保証されず、実測では1秒未満〜2分程度までばらついた - ★重要な限界:1回あたり6分の上限とは別に「トリガーの合計実行時間:1日90分(個人アカウント)」という日次クォータがあり、検証中に実際に到達してタイムアウトした。 トリガー連鎖はこの合計上限までは回避できない
- ★実機で発覚したバグ:トリガー発火とボタン押下が重なると二重起動し、HIT件数が壊れることを確認。
LockServiceと確認ダイアログの2段構えで対策済み - トリガーの後始末(
deleteMyTriggers())・キャンセルボタンの動作は、いずれも繰り返しの実機テストで問題ないことを確認済み
次回予告
今回の実機検証結果を踏まえ、gas-02(ダイアログ+チャンク分割)と今回(セルベース+トリガー連鎖)の2方式を比較し、どちらをどんな場面で使うべきかを整理する記事を予定しています。あわせて、今回サラッと触れたPropertiesServiceについても、CacheService・DocumentPropertiesとの使い分けを含めて別記事で掘り下げる予定です。
サンプルファイルについて
この記事で使用したテスト用スプレッドシートは、Googleドライブ上のファイルへの共有リンクからご利用いただけます。GASのスクリプトはスプレッドシートに紐づいた状態(コンテナバインド型)で保存されているため、ご自身のGoogleドライブにコピーを作成すれば、スクリプトごとそのまま複製されます。
↑ リンク先ファイルのURL末尾を/copyにした「コピー用リンク」にしています。クリックするだけでご自分のGoogleドライブにスプレッドシートのコピーが複製されるようになっています。末尾/copyを外してアクセスした場合は、「閲覧のみ」となりますのでコピーを作成してご利用ください。
関連記事
- 【検証】生成AIはVBAをGoogle Apps Scriptに変換できるか?Office Scriptsとの違いも解説
- 【検証】GASでHtmlServiceダイアログの進捗表示を実装する|6分の実行時間上限も回避できた
- 【Office Scripts】UserForm代替パターン:進捗表示はセルで、キャンセルは実行ボタンで実装する
当サイトの記事で使用したVBAなどのサンプルをDLできます
ダウンロードページへは下のカードをクリックすればジャンプできます。
よろしければご利用ください!

