Расписание не является worker

Scheduler отвечает на вопрос «когда создать намерение выполнить работу». Он не должен держать LLM-вызов, управлять браузером и сохранять результат. В назначенный момент scheduler создаёт immutable run с schedule_id, scheduled_for и версией конфигурации, после чего durable worker выполняет обычный workflow.

Так рестарт scheduler не уничтожает работу, а изменение расписания не переписывает историю уже созданных запусков.

Cron, interval и календарь

ТипСмыслПример
Cron/calendarлокальный момент календаряпо будням в 09:00
Fixed intervalкаждые N минутпроверка очереди каждые 15 минут
One-timeодин абсолютный моментопубликовать после embargo
Event + debounceпосле тишинысводка через 10 минут после изменений

«Каждый день» и «каждые 24 часа» — разные требования при переводе часов.

Timezone хранится явно

Не используйте timezone сервера как неявную настройку. Храните IANA zone, например Europe/Moscow, рядом с schedule. Для глобального продукта tenant или пользователь выбирает зону, а UI показывает следующий запуск и его UTC‑эквивалент.

Если бизнес требует единого абсолютного момента, храните UTC. Если требуется «в 9 утра по местному времени», календарь вычисляется в указанной зоне.

DST: несуществующее и двойное время

При переходе на летнее время локальный момент может не существовать; осенью один момент может повториться дважды. Политика должна быть продуктовым решением: пропустить, перенести на ближайшее допустимое время или запустить один раз с выбранным offset. Зафиксируйте ожидаемое поведение тестами для реальных дат зоны.

Run identity и идемпотентность

Ключ запуска строят из schedule_id + scheduled_for + definition_version, а не из фактического времени worker. Если controller создаст один тик дважды, уникальный индекс вернёт существующий run. Все внешние действия внутри run всё равно имеют собственные business keys.

schedule_run_key = sha256(
  schedule_id + scheduled_for_utc + definition_version
)

Misfire: что делать с пропущенным тиком

Scheduler мог быть выключен, quota исчерпана или сервис недоступен. После восстановления нужно решить, догонять ли прошлые тики.

  • Skip — старый прогноз или напоминание уже бесполезны.
  • Fire once now — собрать одну актуальную сводку вместо десяти.
  • Catch up all — обработать каждое расчётное окно.
  • Catch up bounded — выполнить не более N свежих окон.

Политика хранится в schedule и ограничивается максимальным возрастом.

Overlap: предыдущий run ещё работает

Длинный deep research может пересечься со следующим запуском. Выберите политику заранее: skip, buffer_one, queue_all, cancel_previous или allow. Для отчёта обычно полезен buffer-one; для расчёта каждого финансового периода — queue-all; для обновления одного latest snapshot — replace/cancel после безопасной остановки.

Одна блокировка недостаточна

Локальный lock исчезает при рестарте и не виден второму instance. Нужен lease в общем хранилище или concurrency policy orchestrator. Lease имеет owner, expiry и heartbeat; новый worker после истечения сначала сверяет side effects и checkpoint, а не начинает слепо заново.

Scheduled_for важнее started_at

Все выборки данных должны опираться на логическое окно запуска, а не на задержанный фактический старт. Еженедельный отчёт за прошлую неделю остаётся тем же, даже если queue задержала его на два часа. Сохраняйте window_start, window_end и timezone в run.

Deadline и freshness budget

У run есть момент, после которого результат теряет смысл. Scheduler не должен запускать просроченную задачу только ради красивого success rate. Перед каждым дорогим шагом проверяйте remaining time и cost. Если отчёт уже не успеет к совещанию, безопаснее остановиться с expired и сообщить причину.

Retries не создают новый календарный run

Временный 429 повторяет activity текущего run с тем же run ID. Он не создаёт новый scheduled tick и не меняет scheduled_for. Общий retry deadline ограничен freshness window; после него результат становится terminal failure или требует следующего нормального запуска.

Изменение и пауза расписания

Schedule definition версионируется. Изменение времени влияет только на будущие тики; созданные runs сохраняют старую версию. Пауза запрещает новые runs, но отдельно определите судьбу queued и executing. Resume не должен неожиданно создать месяцы catch-up без preview и лимита.

Backfill и ручной запуск

Backfill — управляемое создание runs за исторические окна. Пользователь видит диапазон, число запусков, примерную стоимость и overlap policy. Manual run получает отдельный trigger type и либо явное business window, либо режим «сейчас». Нельзя маскировать ручной replay под регулярный тик.

Безопасность автономного расписания

Schedule сохраняет ссылку на policy и workflow, но не постоянный secret. Worker получает short-lived credentials при старте. Для публикации, платежей и массовой рассылки задайте approval, лимит объёма и kill switch. Подписанный недоверенный контент остаётся данными, даже если задача запускается автоматически.

Наблюдаемость: ошибка и тишина

Обычный error alert не заметит, что scheduler вообще не создал run. Создайте expected-run monitor: последний scheduled_for, последний созданный run, start lag, duration, terminal outcome, overlap skips, misfires, backlog age и стоимость.

Алерт формулируется по смыслу: «для schedule X нет успешного run за 26 часов», а не только «процесс завершился с кодом 1».

Capacity и thundering herd

Запуск тысяч tenants ровно в 00:00 создаёт пик. Используйте контролируемое jitter/flexible window, shard по tenant и отдельные квоты interactive и scheduled workload. Jitter не должен менять business window: отчёт за сутки остаётся отчётом за те же сутки.

Тесты календаря и отказов

  • два controller одновременно создают один тик;
  • scheduler выключен три периода;
  • run длиннее интервала;
  • pause и resume с backlog;
  • DST spring-forward и fall-back;
  • timezone изменён пользователем;
  • worker падает после внешнего эффекта;
  • 429 продолжается до freshness deadline;
  • backfill на сто окон превышает бюджет;
  • schedule удалён во время executing run.

План внедрения

  1. Описать business window и timezone.
  2. Назначить run identity и unique constraint.
  3. Выбрать misfire и overlap policy.
  4. Создавать durable run вместо прямого вызова агента.
  5. Добавить deadline, budget и cancellation.
  6. Протестировать дубликат, простой и DST.
  7. Включить expected-run monitor.
  8. Запустить один schedule в shadow.
  9. Проверить ручной backfill и kill switch.
  10. Расширять по tenants с jitter.
Cron гарантирует ровно один запуск?
Нет. Контроллер может создать дубликат или пропустить тик при сбое. Нужны стабильный run key, unique constraint и идемпотентный workflow.
Что делать, если предыдущая задача ещё работает?
Выбрать явную overlap policy: пропустить, сохранить один ожидающий запуск, поставить все в очередь, безопасно отменить предыдущий или разрешить параллельность.
Чем каждые 24 часа отличается от ежедневно в 09:00?
Интервал измеряет прошедшее время, календарь привязан к локальному времени и timezone. При DST результаты могут различаться.
Нужно ли догонять пропущенные запуски?
Зависит от смысла данных. Для актуальной сводки обычно достаточно одного запуска сейчас; для расчёта каждого периода нужен ограниченный catch-up.
Как заметить, что scheduler молча перестал работать?
Мониторить ожидаемые тики: сравнивать плановый scheduled_for с последним созданным и успешным run, алертить по допустимому окну.
Можно ли запускать агента прямо из cron?
Для простого локального скрипта можно, но production-задаче полезнее создавать durable run через очередь или orchestrator с состоянием, retries и аудитом.
← Все статьи блога