Квоты, лимиты и таймауты

Почему скрипт падает на 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 лимит не обходят: нужен новый запуск.
  • Финальная порция обязана удалить курсор и триггер, а весь код — переживать повторный запуск без порчи данных.
Проверьте себя
1. Скрипт обрабатывает 5000 строк и падает с «Exceeded maximum execution time». Что попробовать в первую очередь?
AДобавить Utilities.sleep между итерациями, чтобы снизить нагрузку
BЗаменить getValue/setValue в цикле на один getValues и один setValues
CВызвать функцию рекурсивно из самой себя после 5 минут работы
DПеревести аккаунт на Google Workspace
2. Зачем в схеме порционной обработки курсор хранят в PropertiesService, а не в глобальной переменной?
AГлобальные переменные в Apps Script запрещены
BPropertiesService работает быстрее памяти
CКаждый запуск скрипта начинается с чистого листа — переменная не переживёт завершения
DТак требует ScriptApp.newTrigger