Расписание не является 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.
План внедрения
- Описать business window и timezone.
- Назначить run identity и unique constraint.
- Выбрать misfire и overlap policy.
- Создавать durable run вместо прямого вызова агента.
- Добавить deadline, budget и cancellation.
- Протестировать дубликат, простой и DST.
- Включить expected-run monitor.
- Запустить один schedule в shadow.
- Проверить ручной backfill и kill switch.
- Расширять по tenants с jitter.