Содержание урока · 10 разделов
- Cron wrappers: цепочка WithChain и тип JobWrapper
- Recover: паника в задаче вместо падения сервиса
- DelayIfStillRunning и SkipIfStillRunning: перекрытия под контролем
- Обёртки из коробки и их семантика
- Свои JobWrapper: метрики, ретраи, лимиты
- Чеклист обёрток планировщика
- Recover не оправдание: спорное про честный код
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сценарий, который знают все, кто выкатил планировщик без обёрток: через месяц в проде ночной отчёт падает на новой конфигурации внешнего API, паника вылетает из горутины тика — и процесс умирает целиком вместе с HTTP-сервером и остальными задачами. Или второй вариант: задача синхронизации при деградации внешней системы начинает работать дольше интервала, запускается в двух, трёх, десяти копиях, и каждая пишет в базу. Обе проблемы лечатся одной механикой — обёртками; Recover и guard уже упоминались как спасение в уроках про AddFunc и конкурентность. Здесь разберём механизм целиком: как устроен JobWrapper, в каком порядке применяется цепочка, что делают три обёртки из коробки и как написать собственную.
Cron wrappers: цепочка WithChain и тип JobWrapper#
Обёртка в robfig/cron v3 — это тип type JobWrapper func(job Job) Job: функция, которая принимает задачу и возвращает задачу. Опция cron.WithChain(w1, w2, w3) при создании планировщика объявляет список обёрток, и каждая зарегистрированная задача оборачивается всей цепочкой: AddFunc и AddJob из урока про AddJob принимают обычный Job, а внутрь среза записей уходит уже обёрнутый. Порядок применения обратный: последняя обёртка в списке оказывается ближе всего к вашей задаче, первая — снаружи. Поэтому Recover ставят первой: внешняя обёртка ловит панику, вылетевшую из любой внутренней. Cron wrappers — декоратор поверх интерфейса Job: поведение приписывается задаче снаружи, а сам код задачи остаётся чистым. Граница тоже важна: цепочка фиксируется при создании cron.Cron — поменять её на живом планировщике нельзя, только собрать новый, как в уроке про динамическое расписание. Зато любая задача, добавленная на лету, получает те же обёртки автоматически.
Планировщик с защитой на старте
GOlogger := cron.PrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))
c := cron.New(cron.WithChain(
cron.Recover(logger),
cron.SkipIfStillRunning(logger),
))
c.AddFunc("*/15 * * * *", pullOrders)
c.AddFunc("0 2 * * *", nightlyDigest)
c.Start()Две обёртки закрывают два самых дорогих вида инцидентов: панику одной задачи, которая раньше роняла процесс, и перекрытие запусков, которое раньше плодило параллельные копии. Логгер передаётся в обе обёртки: Recover пишет в него панику со стеком, SkipIfStillRunning — событие пропущенного тика. Выбор PrintfLogger здесь условен: подойдёт любой тип с методами Info и Error, а разговор про логгеры — тема следующего урока.
Проверка: порядок обёрток в цепочке
WithChain(cron.Recover(logger), cron.SkipIfStillRunning(logger)): какая обёртка снаружи и что это даёт?
Recover: паника в задаче вместо падения сервиса#
Обёртка cron.Recover(logger) оборачивает каждую задачу отложенным recover и при панике делает две вещи: пишет в логгер сообщение с ошибкой и стеком, после чего возвращает управление — планировщик продолжает тикать, остальные задачи не тронуты. Без неё паника из горутины тика по умолчанию убивает всю программу, и это уже было разжевано в уроке про AddFunc: robfig/cron не ставит защиту сам. Что Recover не делает: он не исправляет баг и не снимает с вас ответственность. Паника — это ошибка программирования: nil-указатель, выход за границы, обращение к закрытому ресурсу. Обёртка меняет лишь масштаб аварии: вместо падения сервиса в три ночи — запись в логе и работа задачи на следующем тике. Поэтому связка такая: Recover на старте как страховка, а по каждой записи паники — разбор и фикс в коде. Механику panic и recover в чистом виде разбирает урок про панику; в cron поверх неё добавляется только вопрос, куда записать событие, — вопрос логгера из следующего урока.
Своя обёртка: замер длительности
GOfunc Timed(name string, next cron.Job) cron.Job {
return cron.FuncJob(func() {
start := time.Now()
next.Run()
d := time.Since(start)
if d > 30*time.Second {
log.Printf("%s: %v", name, d)
}
})
}
c := cron.New(cron.WithChain(
cron.Recover(logger),
Timed("sync", cron.SkipIfStillRunning(logger)),
))JobWrapper — обычная функция высшего порядка, поэтому свои обёртки пишутся в три строки: замер до вызова next.Run(), замер после, решение по результату. Здесь задача синхронизации получает Recover снаружи, затем замер длительности, и только потом guard от перекрытий. На замерах такого рода строится наблюдаемость: в продакшене вместо log.Printf ставят метрику-гистограмму, о чём отдельный урок про метрики задач.
DelayIfStillRunning и SkipIfStillRunning: перекрытия под контролем#
Обе обёртки решают вопрос перекрытий из урока про перекрытие запусков, но с противоположной семантикой. DelayIfStillRunning сериализует запуски: если предыдущий запуск ещё жив, новый тик ждёт его завершения и выполняется после — каждый тик гарантированно отработает, но при длительной деградации очередь догоняющих запусков растёт, и после восстановления они посыпятся подряд. SkipIfStillRunning пропускает тик, пока предыдущий запуск не закончился: перекрытий нет, но потерянные тики не восполняются. Если обёртке передан логгер, событие пропуска или задержки попадает в лог, поэтому решение о том, что делать с шумом, остаётся за вами. Правило выбора — продуктовое: для выгрузок и отчётов честнее Skip, потому что догоняющая серия из двенадцати запусков бесполезна — нужен один свежий; для задач, где важен каждый тик, например инкрементальных синхронизаций без чекпоинта, подходит Delay. Обе обёртки работают на уровне одной задачи: защита не распространяется на соседние задачи.
Проверка: выбор обёртки для отчёта
Ночной отчёт раз в сутки иногда не укладывается в интервал: сбор данных затягивается. Какая обёртка уместнее?
Кейс из практики: обёртки написали в порядке WithChain(SkipIfStillRunning(logger), cron.Recover(logger)) — Recover внутри. Паника случилась не в задаче, а в собственной обёртке команды, которая стояла между ними, — защита не сработала, процесс упал. Порядок цепочки — не вкусовщина: Recover обязан быть первым элементом списка, чтобы ловить всё, что происходит ниже. Вторая ловушка того же рода — обёртка с собственным defer, который паникует при неправильных аргументах: паника в самой внешней обёртке Recover уже не поймает. Проверяйте цепочку на старте тестом, который паникует в задаче, — это два десятка строк, которые стоят одного ночного инцидента.
Что напечатает цепочка обёрток?
Прочитайте код и выберите, что он выведет:
wrap := func(name string, next func()) func() {
return func() {
fmt.Println(name, "вход")
next()
fmt.Println(name, "выход")
}
}
job := func() { fmt.Println("тик") }
job = wrap("первая", job)
job = wrap("вторая", job)
job()Обёртки из коробки и их семантика#
| Обёртка | Что делает | Когда уместна | Цена |
|---|---|---|---|
| cron.Recover(logger) | гасит панику, пишет ошибку со стеком | на старте для всех задач, без вариантов | скрывает баг, если нет разбора записей |
| cron.DelayIfStillRunning(logger) | ждёт завершения предыдущего запуска | каждый тик важен, деградации короткие | очередь догоняющих запусков растёт |
| cron.SkipIfStillRunning(logger) | пропускает тик, пока задача жива | отчёты и выгрузки со свежими данными | потерянные тики не восполняются |
| своя JobWrapper | любое поведение: замер, повтор, лимит | не закрыто коробочными обёртками | пишется и тестируется руками |
Свои JobWrapper: метрики, ретраи, лимиты#
Коробочных обёрток три, а тип JobWrapper — публичный, поэтому всё, что не покрылось из коробки, пишется как обычная функция высшего порядка. Типичные свои обёртки: замер длительности с отправкой в метрики, повтор запуска при ошибке, лимит параллельных запусков через семафор, аудит-запись о начале и конце. Строить их лучше поверх материала соседних уроков: повтор запусков при ошибке — ретраи из урока про ретраи, замер длительности — материал урока про метрики задач, а лимит параллелизма — очередь воркеров из урока про конкурентность. Два правила к своим обёрткам. Первое: обёртка не должна знать о бизнесе — она получает Job и возвращает Job, всё бизнес-поведение живёт внутри самой задачи, иначе декоратор превращается в нечитаемую свалку. Второе: обёртка обязана быть честной к панике — отложенные действия должны выполняться и при панике ниже по цепочке, поэтому recover-безопасные обёртки тестируют задачей, которая паникует.
Чеклист обёрток планировщика#
- Recover первым элементом WithChain на каждом планировщике сервиса.
- Выбор между Delay и Skip по продуктовому вопросу: терять тики или копить очередь.
- Логгер передан в обе обёртки: события пропусков и паник видны в одном месте.
- Своя обёртка не знает о бизнесе: получает Job, возвращает Job.
- Тест на панику в задаче: процесс жив, тик записан в лог, планировщик тикает дальше.
- Цепочка не меняется на лету: смена обёрток — новый Cron и старт из урока про Start и Stop.
Пакета cron в песочнице нет, но сама идея обёрток — чистые функции высшего порядка над func(): пример с wrap и предикт переписываются в песочницу один в один и работают через fmt.Println, а time для замеров в ней есть. Механику Recover не воспроизвести — паника там остаётся паникой, так что обёртки библиотеки и порядок цепочки проверяйте локально из модуля второго урока: go run с log.New и двумя задачами покажет и порядок сообщений, и поведение при перекрытии.
Recover не оправдание: спорное про честный код#
Спорная привычка, которую стоит оспорить: считать Recover готовой архитектурой вместо части работы с ошибками. Обёртка гасит панику, но не возвращает задачу к незавершённому делу: если отчёт упал посреди транзакции, Recover не откатит её, не почистит временные файлы и не перезапустит выгрузку до следующего тика. Зрелая настройка выглядит иначе: Recover как подстраховка плюс честная обработка ошибок внутри задач — ошибки через return и лог, а не через panic; дедлайны через контекст из урока про контекст; идемпотентность, чтобы повторный тик после пропуска ничего не ломал. Отдельный вопрос — мониторинг: если Recover молча глотает паники каждую ночь, обёртка превратилась в маскировку деградации. События паник должны быть видимыми: лог с уровнем ошибки и стека, а лучше — счётчик в метриках, по которому ставится алерт. Тогда Recover выполняет свою роль: авария не распространяется, а её причина не живёт в проде месяцами.
Соберите цепочку обёрток
Впишите опцию цепочки и обе обёртки: защита от паники и пропуск перекрытий.
Расставьте сборку защищённого планировщика
Расставьте строки в порядке файла: логгер, создание с цепочкой, две обёртки, закрытие опции и регистрация задачи.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: обёртки задач
Обёртки — это декоратор над интерфейсом Job, приписанный всем задачам разом: WithChain собирает JobWrapper в цепочку, первая обёртка в списке снаружи, поэтому Recover ставится первой и ловит паники из всего, что ниже. DelayIfStillRunning и SkipIfStillRunning закрывают перекрытия с противоположными семантиками — копить очередь или терять тики, — и выбор между ними продуктовый. Свои обёртки пишутся как замыкания вокруг next.Run() и наследуют те же правила: никакого бизнеса, честное поведение при панике, тест на живучесть планировщика. Дальше по маршруту: логирование задач — куда попадают события обёрток, метрики задач — как превратить замеры длительности в гистограммы. Если возвращались с пропуска — перекрытие запусков объясняет, откуда берётся проблема, которую закрывают Skip и Delay, а graceful shutdown покажет, как обёртки ведут себя при остановке. Полный маршрут — на странице курса. В домашнем задании — своя обёртка замера и тест на панику.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- JobWrapper — func(job Job) Job: обёртка добавляет задаче поведение снаружи, не трогая её код.
- WithChain применяет обёртки в обратном порядке: первая в списке — самая внешняя.
- Recover ставится первым: гасит панику любой задачи, пишет ошибку со стеком и не даёт упасть процессу.
- DelayIfStillRunning копит очередь догоняющих запусков, SkipIfStillRunning теряет тики: выбор продуктовый.
- Свои обёртки — замыкания вокруг Run без бизнес-логики: замеры, ретраи, лимиты параллелизма.
- Цепочка фиксируется при создании Cron: новые задачи получают обёртки автоматически, смена — только через новый планировщик.
Домашнее задание
Соберите планировщик с цепочкой Recover и SkipIfStillRunning и напишите собственную обёртку Timed, которая замеряет длительность задачи и печатает предупреждение при превышении порога. Добавьте тест, в котором задача паникует: процесс жив, планировщик тикает дальше, событие попало в лог. Затем заведите задачу, которая спит дольше своего интервала, и проверьте в логах разницу между Skip и Delay: сколько тиков потеряно, сколько отложено. Финал — измените порядок обёрток и убедитесь тестом, что паника из внутренней обёртки больше не ловится.
Личные заметкиПусто
Вопросы и ответы по уроку
Обязательно ли ставить Recover на каждый планировщик?
Без Recover паника одной задачи падает на весь процесс, включая HTTP-сервер и остальные задачи. Обёртка не заменяет разбор ошибок, но меняет масштаб аварии: вместо падения сервиса — запись в логе. Ставить её на старте дешевле, чем после первого ночного инцидента.
Можно ли менять цепочку обёрток на живом планировщике?
Нет: цепочка фиксируется при создании cron.Cron. Чтобы поменять обёртки, собирают новый планировщик, регистрируют задачи и переключаются на него в рамках graceful shutdown. Задачи, добавленные на лету, цепочку текущего планировщика наследуют.
Чем DelayIfStillRunning отличается от SkipIfStillRunning?
Delay ставит перекрывшийся тик в очередь и выполняет после завершения предыдущего запуска — тики не теряются, но накапливаются. Skip пропускает тик, пока задача жива, — потерянные тики восполняются следующим периодом. Для отчётов и выгрузок обычно честнее Skip, для синхронизаций без чекпоинта — Delay.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь