Квоты, лимиты и таймауты
Почему скрипт падает на 4000-й строке и как дописать его так, чтобы он доработал до конца — порциями и с продолжением по триггеру.
Квота — ограничение, которое Google накладывает на бесплатное выполнение вашего кода на своих серверах: сколько минут длится запуск, сколько писем в сутки, сколько внешних запросов. Превысили — скрипт останавливают на полуслове.
Первое столкновение
Скрипт обходит 5000 клиентов, для каждого дёргает внешний сервис и пишет результат в таблицу. На тесте из 20 строк всё летало. На боевых данных через шесть минут в журнале появляется строчка: Exceeded maximum execution time. Половина строк заполнена, половина пуста, и непонятно, где остановились.
Это не баг и не «слабый сервер». Apps Script — общий ресурс: если бы каждый скрипт крутился часами, платформа легла бы. Google даёт короткое окно и уходит. Работать в такой модели можно и нужно, просто задачу приходится проектировать иначе.
Что именно ограничено
Точные цифры зависят от типа аккаунта (обычный @gmail.com или корпоративный Google Workspace) и время от времени меняются — актуальная таблица живёт в документации, раздел «Quotas for Google Services». Порядок величин такой:
| Ограничение | Бесплатный аккаунт | Что происходит при превышении |
| Время одного выполнения | около 6 минут | Скрипт обрывается посреди работы. Сделанное не откатывается. |
| Пользовательская функция в ячейке | около 30 секунд | В ячейке появляется ошибка превышения времени. |
| Суммарное время всех триггеров за сутки | около 90 минут | Триггеры перестают срабатывать до конца суток. |
Писем в сутки (MailApp) | около 100 получателей | Исключение при отправке. Рассылку не доставят. |
Внешних запросов UrlFetchApp | десятки тысяч в сутки | Исключение Service invoked too many times. |
| Триггеров на скрипт у одного пользователя | 20 | Ошибка при создании нового триггера. |
Остаток почтовой квоты можно спросить прямо из кода — полезно перед массовой рассылкой:
function checkMailQuota() {
const left = MailApp.getRemainingDailyQuota();
console.log('Осталось писем сегодня: ' + left);
if (left < 50) {
throw new Error('Квота почти исчерпана, рассылку откладываем');
}
}
Сначала — не оптимизация, а батчи
Прежде чем городить продолжение по триггеру, проверьте, куда ушли шесть минут. В девяти случаях из десяти — не в вычисления, а в общение с таблицей. Каждый getValue() и setValue() — сетевой поход к серверу Sheets. Тысяча ячеек — тысяча походов.
// Плохо: 10 000 обращений к таблице
function slowFill() {
const sheet = SpreadsheetApp.getActiveSheet();
for (let i = 2; i <= 5000; i++) {
const price = sheet.getRange(i, 2).getValue();
sheet.getRange(i, 3).setValue(price * 1.2);
}
}
// Хорошо: два обращения к таблице
function fastFill() {
const sheet = SpreadsheetApp.getActiveSheet();
const rows = 4999;
const prices = sheet.getRange(2, 2, rows, 1).getValues();
const result = prices.map(row => [row[0] * 1.2]);
sheet.getRange(2, 3, rows, 1).setValues(result);
}
Первый вариант легко упирается в таймаут, второй отрабатывает за секунды: данные вычитываются одним куском в обычный JavaScript-массив, считаются в памяти и записываются одним куском обратно. Это правило номер один в Apps Script: читать и писать пачками. Часто одного этого хватает, и никакие триггеры-продолжения не нужны.
Когда без порций не обойтись
Батчи не спасают, если долгая часть — не таблица, а внешний сервис: 5000 клиентов × 0,4 секунды на запрос к API = 33 минуты. Тут остаётся один приём: обработать столько, сколько успеем, запомнить, где остановились, и запланировать продолжение через минуту. Курсор храним в PropertiesService — он переживает завершение скрипта.
const PROPS = PropertiesService.getScriptProperties();
const CHUNK = 100; // строк за один заход к таблице
const DEADLINE_MS = 4.5 * 60 * 1000; // останавливаемся за полторы минуты до лимита
function startJob() {
PROPS.setProperty('cursor', '2'); // первая строка данных, под шапкой
processChunk();
}
function processChunk() {
const started = Date.now();
const sheet = SpreadsheetApp.getActive().getSheetByName('Клиенты');
const last = sheet.getLastRow();
let cursor = Number(PROPS.getProperty('cursor') || 2);
while (cursor <= last) {
if (Date.now() - started > DEADLINE_MS) {
PROPS.setProperty('cursor', String(cursor));
scheduleContinuation_();
console.log('Пауза на строке ' + cursor + ', продолжим через минуту');
return;
}
const rows = Math.min(CHUNK, last - cursor + 1);
const range = sheet.getRange(cursor, 1, rows, 3);
const values = range.getValues();
const updated = values.map(row => {
row[2] = enrich_(row[1]); // медленный вызов внешнего сервиса
return row;
});
range.setValues(updated);
cursor += rows;
}
PROPS.deleteProperty('cursor');
cleanupTriggers_();
MailApp.sendEmail(Session.getEffectiveUser().getEmail(),
'Обработка завершена', 'Обработано строк: ' + (last - 1));
}
function scheduleContinuation_() {
cleanupTriggers_(); // чтобы не наплодить дублей
ScriptApp.newTrigger('processChunk')
.timeBased()
.after(60 * 1000)
.create();
}
function cleanupTriggers_() {
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === 'processChunk')
.forEach(t => ScriptApp.deleteTrigger(t));
}
Что здесь важно:
- Дедлайн с запасом. Мы не ждём шести минут, а останавливаемся на 4,5 — иначе прервёмся посреди
setValuesи не успеем сохранить курсор. - Курсор сохраняется до планирования триггера. Порядок строк не случаен: сначала запомнить позицию, потом договариваться о продолжении.
- Старые триггеры удаляются. Иначе за неделю накопится двадцать штук, они начнут наступать друг другу на пятки, а двадцать первый просто не создастся.
- Финал убирает за собой — стирает курсор, гасит триггер, пишет письмо. Без этого «продолжение» будет запускаться вечно.
Одна тонкость: .after(60 * 1000) — не обещание, а пожелание. Google запустит функцию примерно через минуту, иногда позже. Для фоновой обработки это нормально; для задач вида «ответить пользователю за 2 секунды» такой подход не годится.
Почему не рекурсия и не Utilities.sleep
Первая мысль новичка — просто вызвать processChunk() из самого себя или поставить Utilities.sleep(60000) и продолжить. Не сработает: и рекурсия, и сон происходят внутри того же самого выполнения, а его часы тикают. Сон только сожжёт минуту из шести. Разорвать лимит может лишь новый запуск, а новый запуск даёт только триггер.
PropertiesService: память между запусками
Скрипт каждый раз стартует с чистого листа, глобальные переменные не сохраняются. PropertiesService — простое хранилище «ключ → строка», привязанное к проекту.
| Хранилище | Кто видит | Для чего |
getScriptProperties() | все пользователи скрипта | курсор обработки, токены API, общие настройки |
getUserProperties() | только текущий пользователь | личные настройки, «когда я последний раз получал отчёт» |
getDocumentProperties() | привязано к конкретному файлу | настройки одной таблицы, если скрипт стал надстройкой |
Ограничения: значение — только строка (объект прогоняйте через JSON.stringify), одно значение ограничено несколькими килобайтами, всё хранилище — сотнями. Складывать туда список из 5000 обработанных id не нужно: для крупного состояния заведите служебный лист или пометьте строки в самой таблице колонкой «обработано». Для короткоживущих данных (например, «письмо об ошибке уже отправлено час назад») лучше подходит CacheService с явным сроком жизни.
function saveState(state) {
PROPS.setProperty('state', JSON.stringify(state));
}
function loadState() {
const raw = PROPS.getProperty('state');
return raw ? JSON.parse(raw) : { cursor: 2, errors: 0 };
}
Как это работает
Квоты считаются скользящим окном: суточный лимит писем не обнуляется в полночь, а «отпускает» через 24 часа после каждой отправки. Поэтому скрипт, упавший вчера на 100-м письме, сегодня утром может снова упасть на первом же — часть квоты ещё занята.
Обрыв по таймауту — это не аккуратное завершение: транзакции нет, откат не делается. Всё, что скрипт успел записать в таблицу и отправить, остаётся. Отсюда главное требование к долгим задачам — идемпотентность: повторный запуск не должен ломать данные. Проверяйте, не заполнена ли уже колонка результата, не отправлено ли письмо этому клиенту, ставьте отметку «готово» в строке. Тогда «упало на середине» превращается из аварии в паузу.
Частые ошибки
- Оптимизировать не то. Переписывают алгоритм, хотя тормозят
getValue()в цикле. Сначала батчи, потом всё остальное. - Дедлайн вплотную к лимиту. Проверка «прошло ли 5 минут 55 секунд» гарантирует обрыв на записи курсора.
- Триггеры не удаляются. Через месяц в проекте двадцать «продолжений», новый создать нельзя, а старые запускают обработку параллельно.
- Состояние в глобальной переменной. Между запусками она обнуляется — курсор нужно писать в
PropertiesService. - Рассылка без учёта квоты. 300 писем на бесплатном аккаунте не уйдут; половина адресатов не получит ничего, и вы об этом не узнаете, если не смотрите журнал.
- Неидемпотентный код. После падения запускают заново — и половина клиентов получает второе письмо.
Итоги
- Одно выполнение живёт около шести минут; квоты на письма, внешние запросы и время триггеров считаются за сутки скользящим окном.
- Главный ускоритель — чтение и запись пачками:
getValues()/setValues()вместо поячеечного доступа. - Долгую задачу режут на порции: обработали сколько успели → сохранили курсор в
PropertiesService→ создали разовый триггер.after()на продолжение. - Ни рекурсия, ни
Utilities.sleepлимит не обходят: нужен новый запуск. - Финальная порция обязана удалить курсор и триггер, а весь код — переживать повторный запуск без порчи данных.