全国オンライン対応受付 平日 9:00-18:00
お問い合わせ

開発記録 / AIFlex制作記 / Vol.15 AIFlex制作記 Vol.1

記事 03

重複メールが送られた原因を2段階で特定した話

添削フォームを送信したユーザーに、同じ添削レポートが2通届いた。1通は想定内、2通は不具合だ。Queueシートを見ると、同じevent_idの行が2件存在していた。2行ともDONEになっていた。

2026-06-07 公開

2通届いた。

添削フォームを送信したユーザーに、同じ添削レポートが2通届いた。1通は想定内、2通は不具合だ。Queueシートを見ると、同じevent_idの行が2件存在していた。2行ともDONEになっていた。

原因を調べると、独立した2つの問題が重なっていた。


Queueに同じエントリが2件入る理由

同じevent_idでQueueシートに行が2件入るには2つのシナリオがある。

1つ目:doPostが2回呼ばれた(TypeFormのリトライ)。2つ目:processQueueが同じPENDING行を2回処理した。

どちらが起きているかをGASの実行ログで確認した。両方が起きていた。


原因1:TypeFormのWebhookリトライ

TypeFormはWebhookに対して30秒以内にHTTP 200が返らないと、同じWebhookをリトライする。

Flex版のdoPostはQueueシートに書き込んでから200を返す。通常は1秒以内に終わる。しかし「Queueシートへの書き込みが完了する前に一瞬の遅延が発生した」というケースで、TypeFormが30秒待ってリトライすることがあった。

event_idでの重複チェックを実装した。

function doPost(e) {
    const payload = JSON.parse(e.postData.contents);
    const eventId = payload.event_id;

    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Queue');
    const rows = sheet.getDataRange().getValues();

    const isDuplicate = rows.slice(1).some(row => row[3] === eventId);
    if (isDuplicate) {
        return ContentService.createTextOutput(
            JSON.stringify({ status: 'duplicate' })
        ).setMimeType(ContentService.MimeType.JSON);
    }

    sheet.appendRow([new Date(), 'PENDING', JSON.stringify(payload), eventId]);
    return ContentService.createTextOutput(
        JSON.stringify({ status: 'queued' })
    ).setMimeType(ContentService.MimeType.JSON);
}

この修正でdoPostの重複問題は解決した。しかし重複メールは依然として発生した。


原因2:processQueueの同時実行

キュー方式では1分ごとにprocessQueueが起動する。Gemini処理に2〜3分かかる場合、1回目の実行が終わっていない状態で2回目のトリガーが起動する。

1回目:行1をPENDINGPROCESSINGに変更。Gemini呼び出し中。2回目(1分後起動):行1を見る。PROCESSINGになっている。

「PROCESSINGはスキップする」実装があってもレースコンディションが残る。processQueueが行ステータスをPROCESSINGに更新する前に2回目が起動すると、両方がPENDINGを掴む。GASのスプレッドシート書き込みには数十ミリ秒かかる。

解決策はLockServiceだ。

function processQueue() {
    const lock = LockService.getScriptLock();
    if (!lock.tryLock(0)) {
        return; // 別のインスタンスが実行中
    }

    try {
        const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Queue');
        const rows = sheet.getDataRange().getValues();

        for (let i = 1; i < rows.length; i++) {
            if (rows[i][1] !== 'PENDING') continue;

            sheet.getRange(i + 1, 2).setValue('PROCESSING');
            SpreadsheetApp.flush();

            const payload = JSON.parse(rows[i][2]);
            try {
                generateAndSaveReport(payload);
                sheet.getRange(i + 1, 2).setValue('DONE');
            } catch (err) {
                sheet.getRange(i + 1, 2).setValue('ERROR: ' + err.message);
            }
            break;
        }
    } finally {
        lock.releaseLock();
    }
}

tryLock(0)でロックが取れなければ即座にreturnする。同時実行は起きない。


2つを同時に修正する必要があった

原因1と原因2はどちらか一方だけを修正しても、完全な解決にはならない。

原因1のみ修正:event_idの重複チェックでQueueへの二重登録は防げる。しかし、processQueueの同時実行問題は残る。1件のPENDINGを2つのprocessQueueインスタンスが同時に処理すれば、やはり2通送信される。

原因2のみ修正:LockServiceで同時実行を防げる。しかし、Queueに同じペイロードが2件入っていれば、別の実行でそれぞれ処理されて2通送信される。

2つの原因が異なるタイミングで独立して発生するため、両方を修正して初めて「どのケースでも重複しない」状態になる。


修正後に届いたエラーメール

修正後、GASから10件のエラーメールが届いた日があった。エラー内容はprocessQueue → Error code INTERNAL

Googleのサービス内部エラーだ。コードのバグではない。

Queueシートを確認すると、PENDINGの行が1件もなかった。最後に添削リクエストが来たのは修正から数日前だった。つまりユーザーへの影響はゼロだった。

GASのエラーメールを受け取ったとき「Queueシートを見てPENDING行がなければ実害なし」というのが判断基準になった。エラーの深刻さはユーザーへの影響で測る。


*シンプルシステム株式会社 代表 伊藤勝彦*