GASのトリガー上限20本の壁——6本のディスパッチャーに集約し、失敗を必ず記録する

トリガー上限20本に当たらないよう、定期処理を6本のディスパッチャーへ集約した設計の記録。

2026-09-14 公開 / 滋賀県彦根市の一人塾「総合学習教室ブリッジ」で実際に動いている仕組みの記録 / Google Apps Script(GAS)

何を自動化したか

Google Apps Script(GAS)には1プロジェクトあたりトリガー20本という上限がある。個人塾の運営を自動化しようとすると、体験生フォロー・未納督促・SNS投稿・月謝同期・週次レポート・月次レポート——と処理はいくらでも増えていくが、その一つ一つに専用の時間トリガーを立てていけばあっという間に上限に当たる。今の仕組みは、時間トリガーそのものは6本の「ディスパッチャー」関数に固定し、新しい定期処理を追加するときは新しいトリガーを作らず、既存の6本のどれかに関数呼び出しを1行足すだけにしている。現在この6本の中に約40個の個別処理がぶら下がっており、それでもトリガー本数は増えていない。実測できる数字は「新しい定期処理を追加するたびに消費していたはずのトリガー本数」がゼロになったこと。20本の壁に当たって既存の仕組みを止めるかどうかを迫られる事態は、この構成にしてから一度も起きていない。

仕組み(データの流れ)

6本のディスパッチャーは、頻度ごとに役割を分けてある。

ディスパッチャー頻度主な中身
runAutomationQueue10分ごと依頼キュー処理・外部ハートビート記録・月謝同期の設定ファイル巡回・トリガー自己点検(1日1回)
autoEvery4Hours4時間ごとOCR受信箱処理・整理受信箱処理・問い合わせメール収集・OneDrive月謝同期・広告メール仕分け
autoDailyMorning毎朝6時台トレンド収集・競合分析・海外ニュース要約・書類整理・顧客情報同期・体験生フォロー・朝のブリーフィング送信
autoDailyEvening毎晩21時台SNSエンゲージメント処理・フォロワー推移記録・投稿の日次学習・家計簿メール取込・データ整合性チェック・AI失敗のまとめ通知
autoWeeklyMonday毎週月曜7時台トークン自動リフレッシュ・週報変換・バズ構造抽出・退会予兆スキャン・未納督促・週次ダイジェストメール
autoMonthlyFirst毎月1日8時台月次レポート生成・休眠掘り起こし・アップセル提案・学年進級(4月のみ内部判定)

各ディスパッチャーの中身は、1つの処理を1行の autoRunSafely(ラベル, 関数) 呼び出しとして並べているだけの単純な構造にしてある。ここが失敗の記録につながる要になっている。

コード(要点)

ディスパッチャー本体は、処理を順番に呼ぶだけの薄いラッパーになっている。

/** 毎晩21時台: SNSのエンゲージメント処理・フォロワー記録・投稿の学習 */
function autoDailyEvening() {
  autoRunSafely('コメント返信案・メンション・ネガ検知', snsDailyEngagement);
  autoRunSafely('フォロワー推移の記録',                 snsRecordFollowers);
  autoRunSafely('伸びる投稿の日次学習',                 snsDailyLearning);
  autoRunSafely('家計簿メール自動取り込み',             budgetAutoImportMails);
  autoRunSafely('データ整合性の夜間チェック',           dataIntegrityNightly);
  autoRunSafely('AI失敗検知の通知',                     autoAiFailureAlert);
}

肝心なのは autoRunSafely 側で、1つの処理が例外を投げても他の処理を巻き込んで止めないこと、そして失敗を握りつぶさず必ず記録に残すことの2点を担っている。

/** サブ処理を安全に実行(1つ失敗しても他を止めない) */
function autoRunSafely(label, fn) {
  try {
    fn();
    Logger.log('OK: ' + label);
  } catch (e) {
    Logger.log('NG: ' + label + ' -> ' + e.message);
    // 失敗を実行ログだけに握りつぶさず記録に残し、毎晩のまとめ通知に載せる
    autoAiRecordFailure('定期処理「' + label + '」', e.message);
  }
}

失敗の記録側は、直近5件を保持する設計にしている。最新1件だけを上書きすると「同じ原因が繰り返しているのか、毎回別の原因なのか」が判別できなくなるため(要点のみ。全文はnoteへ)。

function autoAiRecordFailure(where, message) {
  try {
    var props = PropertiesService.getScriptProperties();
    var n = Number(props.getProperty('AUTO_AI_FAIL_COUNT') || 0) + 1;
    props.setProperty('AUTO_AI_FAIL_COUNT', String(n));
    var entry = new Date().toISOString() + ' ' + where + ': ' + String(message).slice(0, 300);
    props.setProperty('AUTO_AI_FAIL_LAST', entry);
    var recent = [];
    try { recent = JSON.parse(props.getProperty('AUTO_AI_FAIL_RECENT') || '[]'); } catch (e2) { recent = []; }
    recent.push(entry);
    if (recent.length > 5) recent = recent.slice(-5);
    props.setProperty('AUTO_AI_FAIL_RECENT', JSON.stringify(recent));
  } catch (e) { /* 記録自体の失敗で本処理を巻き込まない */ }
}

さらに、ディスパッチャーのトリガー自体が何らかの理由で消えていないかを自己点検する仕組みも同じ枠組みの中に相乗りさせてある。専用の点検トリガーを立てると「点検役自身が消えたら共倒れになる」ため、最も頻繁に実行される runAutomationQueue(10分ごと)の中で1日1回だけ点検する構成にした。

function autoSelfHealTriggers() {
  var expectedDispatchers = ['runAutomationQueue', 'autoEvery4Hours', 'autoDailyMorning',
                             'autoDailyEvening', 'autoWeeklyMonday', 'autoMonthlyFirst'];
  var counts = {};
  ScriptApp.getProjectTriggers().forEach(function(t) {
    counts[t.getHandlerFunction()] = (counts[t.getHandlerFunction()] || 0) + 1;
  });
  var missing = expectedDispatchers.filter(function(f) { return !counts[f]; });
  if (missing.length > 0) {
    setupAutomationTriggers();  // 冪等: 再実行しても重複しない
    autoNotify('【自己修復】ディスパッチャートリガーを再設定しました', '欠けていたトリガー: ' + missing.join(', '));
  }
}

ハマった点と回避策

最初にハマったのは「点検役をどこに置くか」だった。トリガーの自己点検を専用の週次トリガーに乗せると、そのトリガー自体が何らかの理由で消えたときに誰も気づけなくなる。点検役は「絶対に消えてほしくないもの」であるほど、その消失自体を監視する仕組みが必要になり、無限後退に陥りやすい。実際に採用したのは、最も実行頻度が高い依頼キュー処理(10分ごと)に相乗りさせ、そのさらに外側は外部からのハートビート確認(毎朝、GAS側が最終実行時刻をプロパティに書き、それを外部のCIジョブが読みに来る)に任せる二段構えにする方法だった。GAS内部だけで完結する監視は、GAS自体が丸ごと死ぬと道連れになる。

もう一つは、自動修復の範囲を線引きすることだった。ディスパッチャー本体のトリガーが消えていた場合は無条件に再作成して構わない(冪等な設定関数を呼ぶだけで、外部トークンには一切触れない)。しかしSNS投稿用のトリガー(Threads/X)は、投稿という対外出力に直結し、かつトークンの有効性確認が絡む。ここまで自動で再設定してしまうと、鍵が失効しているだけなのに投稿を試み続けて別の障害を重ねる恐れがあった。そのため、SNS系のトリガー不足は自動修復せず、通知だけして人間の判断に委ねる形に線を引いた。全部自動で直そうとしないことも、自己修復の設計のうちだと学んだ。

導入手順

  1. 今バラバラに立っている時間トリガーを棚卸しし、頻度ごと(10分・4時間・毎日朝・毎日晩・毎週・毎月)にグルーピングする
  2. 頻度ごとに1つずつ、空のディスパッチャー関数(例: autoDailyEvening)を用意する
  3. autoRunSafely(ラベル, 関数) のようなラッパー関数を作り、各処理をtry/catchで包んで例外が起きても後続処理を止めないようにする
  4. 失敗を記録する仕組み(スクリプトプロパティに直近N件を保存するだけでよい)を用意し、autoRunSafely のcatch節から呼ぶ
  5. 既存の個別トリガーを全て削除し、6本のディスパッチャー関数にだけ時間トリガーを設定し直す(ScriptApp.newTrigger。再実行しても重複しないよう、設定前に既存の同名トリガーを削除してから作り直す)
  6. 記録された失敗を1日1回まとめて通知するバッチ処理を用意し、いずれかのディスパッチャーの末尾に足す
  7. 新しい定期処理を追加したくなったら、新しいトリガーではなく該当する頻度のディスパッチャーに autoRunSafely の呼び出しを1行足すだけにする、という運用ルールをコード冒頭のコメントに明記しておく

よくある質問

Q. 1つのディスパッチャーに処理を詰め込みすぎると実行時間の上限に当たりませんか

当たる。GASの実行時間には上限があるため、重い処理(多数の行を回すデータ整合性チェックなど)は時間のかかるAPI呼び出し部分にリトライの待ち時間を抑える工夫を入れたり、1回の実行で処理する件数に上限を設けたりして分散させている。詰め込みすぎたと感じたら、頻度を分けるより先に個々の処理の実行時間を削る方を先に検討している。

Q. 6本という数字に根拠はありますか

厳密な最適値ではなく「トリガー20本の上限に対して十分な余白を残せる頻度の粒度」として選んだ数。実際にはこの6本以外にSNS投稿用など少数の専用トリガーも別枠で存在するが、それらも数本にとどめている。

Q. 処理の順序は重要ですか

重要な依存関係がある処理だけは明示的に順序を守っている。例えば「フォロワー推移の記録」を「投稿の日次学習」より先に実行しているのは、その日の増減を学習データに含めるため。逆に依存のない処理同士は、どの順で失敗しても他に影響しないよう独立させてある。

この仕組みの「全体像」と「コード全文」