時間主導型トリガーの張り方と、二重起動を止める書き方
トリガーは画面からも張れますが、配布するツールではコードから張ることが多くなります。 そのときに出てくる「増える」「ずれる」「重なる」の3つを、順番に片づけていきます。
定期実行のツールを配ると、受け取った人はまずトリガーを張る必要があります。エディタの画面から張ってもらうこともできますが、手順が増えるほど途中でつまずく人も増えます。できれば設定ボタンひとつで済ませたいところです。
ただ、コードから張ると、画面から張るときには出てこない問題が3つあります。
コードから張ると、実行するたびに増える
ScriptApp.newTrigger() は、同じ関数のトリガーがすでにあっても、呼ぶたびに新しく作ります。重複のチェックはしてくれません。
// これを2回実行すると、5分おきの実行が2本になる
ScriptApp.newTrigger('runDigest').timeBased().everyMinutes(5).create();
設定ボタンを2回押す人がいると、集計メールが2通届くようになります。3回押せば3通です。
作る前に、同じ関数のトリガーを消しておくと安心です。
function ensureTrigger() {
for (const trigger of ScriptApp.getProjectTriggers()) {
if (trigger.getHandlerFunction() === 'runDigest') {
ScriptApp.deleteTrigger(trigger);
}
}
ScriptApp.newTrigger('runDigest').timeBased().everyMinutes(5).create();
}
'runDigest' の2か所を、定期実行したい関数の名前に置き換えて使います。
getProjectTriggers() は、このプロジェクトで自分が作ったトリガーをまとめて返します。関数名で絞らずに全部消すと、ほかの機能のトリガーまで消えてしまうので気をつけてください。
everyMinutes() に渡せる値は 1・5・10・15・30 のどれかです。7分や20分のような間隔は指定できません。
実行時刻はぴったりにはならない
everyHours(1) で張っても、毎時0分ちょうどに動くとは限りません。何分に動くかは Google 側で決まります。nearMinute() で分を指定しても、前後15分の幅があります。atHour(9) も同じで、9時台のどこかで動きます。
なので「毎朝9時00分の時点の値を記録する」のような処理は、思ったとおりになりません。時刻そのものを前提にせず、実行されたときに「前回から今までの分」を取る形にしておくと崩れません。
処理の中では new Date() を基準にして、そこから遡るのが扱いやすいです。
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
先頭の 7 が日数です。1日分にしたいなら 1 に変えます。
前の実行が終わる前に、次が始まる
5分おきのトリガーで、処理に6分かかったとします。前の実行がまだ動いているうちに次の実行が始まり、2つが同じシートへ同時に書き込むことになります。
行を追記する処理だと、同じデータが2行入ります。「最終行を調べて、その下に書く」書き方をしていると、2つが同じ最終行を見てしまい、片方の書き込みが上書きされます。
データが少ないうちは処理がすぐ終わるので、まず起きません。件数が増えて処理時間が延びてきた頃に、原因のわかりにくい重複として出てきます。
LockService で入口を1つにする
ロックを取れた実行だけが先に進み、取れなかった実行は何もせずに終わる、という形にします。
function runDigest() {
const lock = LockService.getScriptLock();
// 5秒待っても取れなければ、今回は何もしない。次のトリガーで拾える。
if (!lock.tryLock(5000)) return;
try {
sendDigest(); // 集計してメールを送る、本来の処理
} finally {
lock.releaseLock();
}
}
sendDigest() のところを、実際にやりたい処理に置き換えて使ってください。
ロックには3種類あります。
| 取り方 | 同時に1つだけになる範囲 |
|---|---|
getScriptLock() |
スクリプト全体。誰が実行しても同じロック |
getUserLock() |
実行しているユーザーごと。別のユーザーとは同時に動ける |
getDocumentLock() |
紐づいているドキュメントごと。ドキュメントに紐づかないスクリプトでは null |
トリガーは、作った人のアカウントで実行されます。作った人が1人なら、getUserLock() でもトリガー同士の重なりは止められます。ただ、ほかの人が画面のボタンから同じ処理を呼んだ場合は、ユーザーが違うので止まりません。どこから呼ばれても1本にしておきたいなら、getScriptLock() を選んでおくのが確実です。
tryLock と waitLock の使い分け
tryLock(ミリ秒) は、ロックが取れたかどうかを true / false で返します。waitLock(ミリ秒) は、時間内に取れなければ例外を投げます。
定期実行なら tryLock がおすすめです。ロックが取れなかったときは、前の実行がまだ動いているということなので、その回は何もせずに終わらせて大丈夫です。残った分は次のトリガーが拾ってくれます。
waitLock で例外にすると、そのたびに実行ログへエラーが残ります。5分おきのトリガーだと、処理が重なるたびに失敗の記録が増えていきます。異常ではないものがエラーとして並ぶと、本当のエラーを見落としやすくなります。
画面のボタンから呼ぶ処理は逆で、押した人が結果を待っています。こちらは waitLock で待ってもらい、それでも取れなければ「ほかの処理が動いています」と画面に出すのが親切です。
解放は finally に置く
releaseLock() は finally の中に書きます。途中で例外が出ても、必ず解放されます。
スクリプトが終われば、ロックは自動で解放されます。それでも公式のリファレンスでは、使い終わったらすぐ解放するよう勧めています。ロックが要るのが処理の前半だけなら、そこで解放しておくと、待っているほかの実行が早く進めます。
動きがおかしいときに見るところ
トリガーが思ったとおりに動かないときは、エディタの左側から次の順に見ていくと早いです。
- 「トリガー」の画面で、同じ関数のトリガーが何本あるか
- 同じ画面に出ているエラー率
- 「実行数」の画面で、その関数が1回にどれくらいかかっているか
2本以上あるなら、ensureTrigger() を通さずに作ったか、途中で作り方を変えたときの古いものが残っています。1本しかないのに重複が出るなら、処理時間がトリガーの間隔を超えています。
2026-09-15 追記:
getUserLock()がトリガーの二重起動に効かない、と書いていた部分は誤りでした。作った人が1人なら効きます。getScriptLock()を選ぶ理由を、別のユーザーからの実行も止められる点に直しています。