Содержание урока · 10 разделов
Задание проекта. Сервис ведёт журнал событий и ежедневные сводки. Требования: сводка отправляется в шесть утра по московскому времени; устаревшие события чистятся раз в сутки батчами; напоминания по незавершённым задачам уходят каждую минуту. Сервис запускается в двух экземплярах, расписание меняется без релиза, каждая задача ограничена дедлайном, а остановка сервиса не рвёт работу посреди письма. Три задачи — три разных профиля: календарная редкая с внешним эффектом, тяжёлая внутренняя и частая лёгкая. Именно разность профилей делает проект полезным: каждый приём курса находит здесь своё место, и ни один не остаётся теорией.
Cron-проект: карта сервиса задач#
Карта cron-проекта начинается с разделения: планировщик — инфраструктура, задачи — код с зависимостями, конфиг — источник расписаний. Структура папок минимальна: main.go собирает зависимости и запускает, config.go читает и валидирует файл задач, tasks.go хранит типы задач с методами Run, wrappers.go — цепочка обёрток, store.go — интерфейс хранилища. Интерфейсы ставятся на границах: хранилище и отправитель объявляются полями задач, как в уроке про задачи-структуры, — это делает каждую задачу проверяемой фейками из урока про тесты. Конфиг задач продолжает линию урока про расписание из конфига: имя, строка cron, дедлайн. Один важный шаг добавляется на старте — валидация строк парсером, чтобы битое расписание падало при загрузке, а не в первый тик.
Конфиг задач с валидацией на старте
GOtype TaskConfig struct {
Name string
Spec string
}
type ServiceConfig struct {
Tasks []TaskConfig
}
func Load(path string) (*ServiceConfig, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg ServiceConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, err
}
for _, t := range cfg.Tasks {
if _, err := cron.ParseStandard(t.Spec); err != nil {
return nil, fmt.Errorf("задача %s: %w", t.Name, err)
}
}
return &cfg, nil
}Кодировка JSON сопоставляет ключи с полями без тегов, поэтому структура остаётся чистой; в реальном проекте теги добавляются по вкусу. Валидация через ParseStandard повторяет проверку из табличного теста урока про секунды: битая строка останавливает загрузку конфига с именем задачи в ошибке. Смена расписания сводится к правке файла и перезагрузке из урока про изменение расписания.
Проверка: зачем валидировать строки на старте
Строки расписаний валидируются cron.ParseStandard при загрузке конфига. Что это даёт по сравнению с проверкой в момент AddJob?
Цепочка обёрток — общий каркас всех задач и главная точка повторного использования в проекте. Порядок собран из урока про wrappers: снаружи Recover, потому что паника должна превратиться в запись со стеком до того, как её увидят остальные обёртки; следом обёртка логов из урока про логирование с полями job и длительностью; затем метрики из урока про метрики задач — счётчик тиков по статусам и гистограмма; дальше — проектные обёртки: ретраи из урока про ошибки и замок на несколько инстансов из урока про блокировки. Одна цепочка описывается один раз и применяется ко всем задачам через WithChain — индивидуальность задачи живёт в конфиге, а не в коде.
Проверка: порядок цепочки обёрток
В WithChain переданы cron.Recover, обёртка логов, обёртка метрик и обёртка ретраев. Какая из них выполняется первой при тике?
Ночной отчёт: дедлайн, ретрай и идемпотентность#
Задача ночных отчётов собирает три контракта в одном методе. Дедлайн: контекст с таймаутом на весь тик — если внешняя система молчит, отчёт отменяется, а не висит до утра, как в уроке про контекст. Ретрай: попытки и классификация ошибок не вписываются в Run — их держит обёртка из урока про ретраи, поэтому метод остаётся читаемым, а повторы видны метрике. Идемпотентность: ключ на дату проверяется до отправки, как в уроке про блокировки, — повторный тик после сбоя на отметке не отправит второе письмо. Обратите внимание на порядок шагов: проверка ключа, выборка, отправка, отметка. Необратимый шаг стоит предпоследним, и именно поэтому сбой на отметке лечится повтором безнаказанно.
Задача отчёта с тремя контрактами
GOtype ReportJob struct {
store Store
mail Mailer
timeout time.Duration
}
func (r *ReportJob) Run() {
ctx, cancel := context.WithTimeout(
context.Background(), r.timeout)
defer cancel()
date := time.Now().Format("2006-01-02")
if done, _ := r.store.ReportDone(date); done {
return // отчёт за эту дату уже ушёл
}
rows, err := r.store.Daily(ctx, date)
if err != nil {
return // попытки и статусы ведёт обёртка
}
r.mail.Send(buildReport(date, rows))
r.store.MarkReport(date)
}Контекст с дедлайном покрывает выборку и всё, что после неё. Идемпотентная проверка стоит до отправки: дубль после обрыва на отметке пропускает работу. Отметка выполняется после отправки — сбой между ними сделает повтор, но повтор увидит отметку или отправит отчёт заново только в худшем случае, который ловится ключом уникальности в хранилище.
Сколько партий удалит цикл?
Прочитайте код и выберите, что он выведет:
total := 950
const batch = 200
parties := 0
for total > 0 {
n := batch
if total < n {
n = total
}
total -= n
parties++
}
fmt.Println("партий:", parties)Очистка и напоминания: дешёвые задачи#
Две оставшиеся задачи проекта показывают противоположные профили. Очистка — тяжёлая внутренняя работа: удалить события старше месяца одним DELETE означает длинную транзакцию, раздутый журнал репликации и блокировки строк, о которые споткнётся основной сервис. Партиями по двести строк транзакции короткие, а метрика длительности из урока про метрики покажет, укладывается ли чистка в свой дедлайн. Напоминания — частая лёгкая задача: раз в минуту выбрать незавершённые события с наступившим сроком и отправить уведомления. Минутный интервал не оставляет запаса: тик, живущий дольше минуты, наложится на следующий, поэтому в цепочке для этой задачи стоит SkipIfStillRunning из урока про wrappers — перекрытие превращается в пропуск с записью в лог. Внешний эффект здесь тоже есть: отправка уведомления необратима, и ключ идемпотентности на событие защищает от повторных сообщений так же, как у отчёта — ключом на дату.
Реестр задач проекта#
| Задача | Расписание | Обёртки сверх базовых | Дедлайн |
|---|---|---|---|
| ночной отчёт | CRON_TZ=Europe/Moscow 0 6 * | ретрай, замок инстансов | 10 минут |
| очистка | 30 4 * | замок инстансов | 5 минут |
| напоминания | * | SkipIfStillRunning | 30 секунд |
| синхронизация справочника | @every 6h | ретрай | 2 минуты |
Метрики, логи и остановка сервиса#
Наблюдаемость проекта собрана заранее: обёртки метрик и логов уже в цепочке, лейбл job сквозит через логи, графики и алерты из урока про здоровье сервиса. Осталась остановка — финальный контракт из урока про graceful shutdown: по сигналу сервис прекращает приём новой работы, вызывает Stop и дожидается тиков с дедлайном через select по контексту Stop. Дедлайн выбирается по худшему задачному таймауту с запасом: при десяти минутах у отчёта дожим не может быть длиннее, чем окно деплоя, поэтому дедлайн ставят в конфиг рядом с расписанием. Задачи, не уложившиеся в дожим, отменяются контекстом — и это штатное завершение, а не авария: отметки и идемпотентные ключи гарантируют, что недоделанное повторится при следующем старте.
Кейс до введения цепочки: в шаблоне отчёта разыменовывался nil, когда выборка за новые сутки возвращала пустой срез. Первый тик после полуночи паниковал — recover в библиотеке никто не ставил, и процесс умирал целиком: напоминания и чистка молчали вместе с отчётом, дежурный узнал утром по жалобам. Инцидент разобрал две вещи: паника в любой из двенадцати задач проекта падала на весь сервис, а исчезнувшие тики не поднимали алерт, потому что метрик не было. Лечение одной строкой — WithChain с cron.Recover первым — превратило панику в запись со стеком и рабочий сервис, а метрики добавили алерт на тишину по каждой задаче.
Спорное: один планировщик на все задачи#
Спорное решение проекта — один экземпляр Cron на все три задачи. Против него есть честный аргумент: общий планировщик — общий отказ — паника без Recover, забитый воркер или деградация хранилища падают на все задачи сразу, и критичный отчёт делит судьбу с безобидной чисткой. Изоляция экземплярами даёт независимые цепочки, дедлайны и графики загрузки. Но изоляция стоит наблюдаемости: три экземпляра — три конфигурации, три набора метрик, три точки остановки, и для трёх задач сервиса это проигрыш в простоте без выигрыша в живучести. Разумная граница: один планировщик на инстанс и на владельца — задачи одного сервиса с общим хранилищем живут вместе; при выделении команды, чужом хранилище или сильно разной критичности планировщик выделяется со своим конфигом. Наблюдаемость частично изолируется и без разделения: лейбл job в метриках и логах разворачивает общий процесс по задачам.
Чеклист готового сервиса задач#
- Расписания в конфиге с валидацией ParseStandard на загрузке: смена без релиза.
- Цепочка обёрток одна: Recover, логи, метрики, ретраи, замок — порядок зафиксирован.
- Каждая задача ограничена дедлайном через контекст, дедлайны тоже в конфиге.
- Необратимые шаги защищены ключами идемпотентности до отправки.
- Тяжёлая работа режется батчами; частые задачи стоят в SkipIfStillRunning.
- Остановка: Stop, дожим с дедлайном, недоделанное повторяется при старте.
Соберите запуск сервиса
Впишите обёртку паники, её аргумент, регистрацию задачи и старт планировщика.
Расставьте поток main
Расставьте строки в порядке файла: конфиг, планировщик с цепочкой, регистрация задач, старт, ожидание сигнала и остановка.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: проект сервиса задач
Честно о границах: многокомпонентный сервис песочница не запустит — там нет файла конфигурации, хранилища и отправителя писем, и это не недостаток, а формат: песочница исполняет самодостаточный код с пакетами вроде fmt, strings, strconv, time. В ней проверяются фрагменты проекта: предикт с партиями очистки исполняется как есть, арифметика дедлайнов и форматы дат — через пакет time. Полная сборка проекта — локально: модуль второго урока курса уже содержит robfig/cron, остальное добавляется по ходу сборки.
Сервис задач из проекта — сумма курса: расписание в конфиге с валидацией на старте, одна цепочка обёрток на все задачи, дедлайны в контекстах, идемпотентные ключи перед необратимыми шагами, батчи для тяжёлой работы, наблюдаемость сквозь логи и метрики, остановка с дожимом. Каждая деталь на своём месте из своего урока — повторить проект можно по реестру задач и чеклисту, подставляя свои хранилище и отправителя. Если возвращались с пропуска: обёртки — точка сборки цепочки, конфиг расписаний — источник задач, graceful shutdown — финал процесса, блокировки и ретраи — защита от дублей и сбоев. Полный маршрут с нуля — на странице курса. В домашнем задании — сборка сервиса целиком в своём окружении.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Планировщик задач go — каркас: конфиг расписаний, общая цепочка обёрток, задачи как структуры с Run.
- Строки расписаний валидируются ParseStandard на загрузке: битый конфиг падает до старта сервиса.
- Порядок цепочки: Recover снаружи, затем логи, метрики, ретраи и замки — он же порядок принятия вызова.
- Контракты отчёта: дедлайн в контексте, ретрай в обёртке, идемпотентный ключ до отправки.
- Тяжёлая работа режется батчами, частые задачи защищены SkipIfStillRunning от перекрытия.
- Остановка: Stop и дожим с дедлайном; недоделанное повторяется при старте благодаря идемпотентности.
Домашнее задание
Соберите проект целиком в своём окружении: конфиг из трёх задач с валидацией, цепочка Recover, логов, метрик и ретраев, хранилище-заглушка в памяти. Запустите два экземпляра против одного хранилища и убедитесь, что отчёт выходит одним, а второй инстанс пишет отказ замка. Затем проверьте сценарии отказов: заглушка хранилища падает на отметке — повтор не отправляет второе письмо; заглушка паникует — Recover пишет стек, сервис живёт. Финал — отправьте сервису сигнал остановки в момент тика и убедитесь, что дожим дожидается тика в дедлайн, а не уложившийся тик отменяется контекстом и повторяется при следующем старте.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему дедлайны задач выносятся в конфиг, а не зашиваются в код?
Дедлайн — часть эксплуатационного контракта задачи, как расписание: он согласуется с окном деплоя, длительностью дожима и алертами. Вынос в конфиг позволяет менять его без релиза, когда инфраструктура меняется быстрее кода. Валидация читает дедлайн тем же парсером длительностей, что и расписание, поэтому битое значение тоже падает на старте.
Как выбрать дедлайн дожима при остановке сервиса?
Ориентир — худший таймаут задачи с запасом на завершение записи: при десяти минутах у отчёта дожим около десяти-двенадцати минут, если окно деплоя позволяет. Окно деплоя — жёсткая граница: дожим длиннее его приводит к принудительному убийству процесса, которое обнуляет смысл дожима. Поэтому дедлайны задач и дожим настраиваются вместе.
Стоит ли выделять каждую задачу в отдельный планировщик?
Для трёх задач одного сервиса — нет: общий планировщик даёт один конфиг, одну цепочку и одну точку остановки, а изоляцию наблюдаемости дают лейблы задач в метриках и логах. Выделение оправдано при другом владельце, чужом хранилище или сильно разной критичности: тогда у отдельного планировщика появляется свой график релизов и свой набор алертов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь