---
title: "Итоговый проект: планировщик отчётов"
description: "Cron проект go как сборка сервиса задач: конфиг расписаний, цепочка обёрток, ночные отчёты с ретраем, батч-очистка, напоминания и graceful shutdown."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Итоговый проект: планировщик отчётов

*Курс «Фоновые задачи в Go: планировщик на robfig/cron», урок 25 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cron/lessons/25-itogovyj-proekt/*

Итоговый cron проект go собирает курс в один сервис задач: ночные отчёты по календарному расписанию, очистка устаревших данных, напоминания с минутным интервалом — всё это живёт в одном планировщике, читает расписание из конфига и останавливается дожидаясь тиков. Проект обзорный: не строка за строкой, а карта решения — какие файлы, какие интерфейсы, какая цепочка обёрток и в каком порядке закрывается процесс. Планировщик задач go в таком виде — это не библиотечный вызов, а каркас с честными зависимостями, дедлайнами и идемпотентностью, который переживает деплой и падение соседа. Разберём устройство по слоям и соберём всё, что изучали двадцать четыре урока.

Задание проекта. Сервис ведёт журнал событий и ежедневные сводки. Требования: сводка отправляется в шесть утра по московскому времени; устаревшие события чистятся раз в сутки батчами; напоминания по незавершённым задачам уходят каждую минуту. Сервис запускается в двух экземплярах, расписание меняется без релиза, каждая задача ограничена дедлайном, а остановка сервиса не рвёт работу посреди письма. Три задачи — три разных профиля: календарная редкая с внешним эффектом, тяжёлая внутренняя и частая лёгкая. Именно разность профилей делает проект полезным: каждый приём курса находит здесь своё место, и ни один не остаётся теорией.

### Cron-проект: карта сервиса задач

Карта cron-проекта начинается с разделения: планировщик — инфраструктура, задачи — код с зависимостями, конфиг — источник расписаний. Структура папок минимальна: main.go собирает зависимости и запускает, config.go читает и валидирует файл задач, tasks.go хранит типы задач с методами Run, wrappers.go — цепочка обёрток, store.go — интерфейс хранилища. Интерфейсы ставятся на границах: хранилище и отправитель объявляются полями задач, как в [уроке про задачи-структуры](/courses/cron/lessons/06-addjob-structura/), — это делает каждую задачу проверяемой фейками из [урока про тесты](/courses/cron/lessons/22-testy-zadach/). Конфиг задач продолжает линию [урока про расписание из конфига](/courses/cron/lessons/14-raspisanie-iz-konfiga/): имя, строка cron, дедлайн. Один важный шаг добавляется на старте — валидация строк парсером, чтобы битое расписание падало при загрузке, а не в первый тик.

### Конфиг задач с валидацией на старте

```go
type 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 повторяет проверку из табличного теста [урока про секунды](/courses/cron/lessons/08-sekundy-raspisaniya/): битая строка останавливает загрузку конфига с именем задачи в ошибке. Смена расписания сводится к правке файла и перезагрузке из [урока про изменение расписания](/courses/cron/lessons/15-izmenenie-raspisaniya/).

### Проверка: зачем валидировать строки на старте

**Вопрос:** Строки расписаний валидируются cron.ParseStandard при загрузке конфига. Что это даёт по сравнению с проверкой в момент AddJob?

- Ускоряет регистрацию задач в планировщике
- ✅ Битое расписание падает с понятной ошибкой до старта сервиса, а не молча не срабатывает в первый тик
- Позволяет менять пояс задач без перезагрузки
- Освобождает от тестов расписаний

AddJob при битой строке вернул бы ошибку, которую легко проигнорировать: сервис стартует, задача не регистрируется, тик не приходит, и узнают об этом по устаревшим отчётам через сутки. Валидация на загрузке переносит сбой на самую раннюю точку с контекстом — именем задачи. Тесты расписаний это не отменяет: они фиксируют ожидаемые строки.

Цепочка обёрток — общий каркас всех задач и главная точка повторного использования в проекте. Порядок собран из [урока про wrappers](/courses/cron/lessons/16-wrappers/): снаружи Recover, потому что паника должна превратиться в запись со стеком до того, как её увидят остальные обёртки; следом обёртка логов из [урока про логирование](/courses/cron/lessons/17-logirovanie-zadach/) с полями job и длительностью; затем метрики из [урока про метрики задач](/courses/cron/lessons/18-metriki-zadach/) — счётчик тиков по статусам и гистограмма; дальше — проектные обёртки: ретраи из [урока про ошибки](/courses/cron/lessons/23-retrai-zadach/) и замок на несколько инстансов из [урока про блокировки](/courses/cron/lessons/21-raspredelennye-blokirovki/). Одна цепочка описывается один раз и применяется ко всем задачам через WithChain — индивидуальность задачи живёт в конфиге, а не в коде.

### Проверка: порядок цепочки обёрток

**Вопрос:** В WithChain переданы cron.Recover, обёртка логов, обёртка метрик и обёртка ретраев. Какая из них выполняется первой при тике?

- Обёртка ретраев: попытки должны начинаться немедленно
- Обёртка метрик: сначала замер длительности
- ✅ cron.Recover: он снаружи и первым принимает вызов Run
- Порядок в WithChain не влияет на исполнение

Обёртки образуют матрёшку: первая в списке — снаружи. Recover принимает вызов первым, чтобы паника из любой внутренней обёртки, включая ретраи, превратилась в запись со стеком. Метрики при этом видят аварийный тик благодаря замеру в defer — механика разбиралась в уроке про метрики.

### Ночной отчёт: дедлайн, ретрай и идемпотентность

Задача ночных отчётов собирает три контракта в одном методе. Дедлайн: контекст с таймаутом на весь тик — если внешняя система молчит, отчёт отменяется, а не висит до утра, как в [уроке про контекст](/courses/cron/lessons/10-kontekst-zadachi/). Ретрай: попытки и классификация ошибок не вписываются в Run — их держит обёртка из урока про ретраи, поэтому метод остаётся читаемым, а повторы видны метрике. Идемпотентность: ключ на дату проверяется до отправки, как в [уроке про блокировки](/courses/cron/lessons/21-raspredelennye-blokirovki/), — повторный тик после сбоя на отметке не отправит второе письмо. Обратите внимание на порядок шагов: проверка ключа, выборка, отправка, отметка. Необратимый шаг стоит предпоследним, и именно поэтому сбой на отметке лечится повтором безнаказанно.

### Задача отчёта с тремя контрактами

```go
type 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)
}
```

Контекст с дедлайном покрывает выборку и всё, что после неё. Идемпотентная проверка стоит до отправки: дубль после обрыва на отметке пропускает работу. Отметка выполняется после отправки — сбой между ними сделает повтор, но повтор увидит отметку или отправит отчёт заново только в худшем случае, который ловится ключом уникальности в хранилище.

### Сколько партий удалит цикл?

```go
total := 950
const batch = 200
parties := 0
for total > 0 {
    n := batch
    if total < n {
        n = total
    }
    total -= n
    parties++
}
fmt.Println("партий:", parties)
```

**Что выведет программа?**

- ✅ партий: 5
- партий: 4
- партий: 950
- партий: 200

Четыре полные партии унесут восемьсот записей, пятая — оставшиеся сто пятьдесят. Ограничение размера последней партии — тот же принцип, что и ограничение длительности: цикл обязан завершаться сам, без внешнего наблюдателя. В реальной чистке каждая партия — короткий DELETE с LIMIT, а между партиями — пауза, чтобы не выжать базу.

### Очистка и напоминания: дешёвые задачи

Две оставшиеся задачи проекта показывают противоположные профили. Очистка — тяжёлая внутренняя работа: удалить события старше месяца одним DELETE означает длинную транзакцию, раздутый журнал репликации и блокировки строк, о которые споткнётся основной сервис. Партиями по двести строк транзакции короткие, а метрика длительности из урока про метрики покажет, укладывается ли чистка в свой дедлайн. Напоминания — частая лёгкая задача: раз в минуту выбрать незавершённые события с наступившим сроком и отправить уведомления. Минутный интервал не оставляет запаса: тик, живущий дольше минуты, наложится на следующий, поэтому в цепочке для этой задачи стоит SkipIfStillRunning из урока про wrappers — перекрытие превращается в пропуск с записью в лог. Внешний эффект здесь тоже есть: отправка уведомления необратима, и ключ идемпотентности на событие защищает от повторных сообщений так же, как у отчёта — ключом на дату.

### Реестр задач проекта

| Задача | Расписание | Обёртки сверх базовых | Дедлайн |
| --- | --- | --- | --- |
| ночной отчёт | CRON_TZ=Europe/Moscow 0 6 * * * | ретрай, замок инстансов | 10 минут |
| очистка | 30 4 * * * | замок инстансов | 5 минут |
| напоминания | * * * * * | SkipIfStillRunning | 30 секунд |
| синхронизация справочника | @every 6h | ретрай | 2 минуты |

### Метрики, логи и остановка сервиса

Наблюдаемость проекта собрана заранее: обёртки метрик и логов уже в цепочке, лейбл job сквозит через логи, графики и алерты из [урока про здоровье сервиса](/courses/prometheus/lessons/19-zdorovie-servisa/). Осталась остановка — финальный контракт из [урока про graceful shutdown](/courses/cron/lessons/19-graceful-cron/): по сигналу сервис прекращает приём новой работы, вызывает Stop и дожидается тиков с дедлайном через select по контексту Stop. Дедлайн выбирается по худшему задачному таймауту с запасом: при десяти минутах у отчёта дожим не может быть длиннее, чем окно деплоя, поэтому дедлайн ставят в конфиг рядом с расписанием. Задачи, не уложившиеся в дожим, отменяются контекстом — и это штатное завершение, а не авария: отметки и идемпотентные ключи гарантируют, что недоделанное повторится при следующем старте.

> **Без Recover паника одной задачи убивает сервис.** Кейс до введения цепочки: в шаблоне отчёта разыменовывался nil, когда выборка за новые сутки возвращала пустой срез. Первый тик после полуночи паниковал — recover в библиотеке никто не ставил, и процесс умирал целиком: напоминания и чистка молчали вместе с отчётом, дежурный узнал утром по жалобам. Инцидент разобрал две вещи: паника в любой из двенадцати задач проекта падала на весь сервис, а исчезнувшие тики не поднимали алерт, потому что метрик не было. Лечение одной строкой — WithChain с cron.Recover первым — превратило панику в запись со стеком и рабочий сервис, а метрики добавили алерт на тишину по каждой задаче.

### Спорное: один планировщик на все задачи

Спорное решение проекта — один экземпляр Cron на все три задачи. Против него есть честный аргумент: общий планировщик — общий отказ — паника без Recover, забитый воркер или деградация хранилища падают на все задачи сразу, и критичный отчёт делит судьбу с безобидной чисткой. Изоляция экземплярами даёт независимые цепочки, дедлайны и графики загрузки. Но изоляция стоит наблюдаемости: три экземпляра — три конфигурации, три набора метрик, три точки остановки, и для трёх задач сервиса это проигрыш в простоте без выигрыша в живучести. Разумная граница: один планировщик на инстанс и на владельца — задачи одного сервиса с общим хранилищем живут вместе; при выделении команды, чужом хранилище или сильно разной критичности планировщик выделяется со своим конфигом. Наблюдаемость частично изолируется и без разделения: лейбл job в метриках и логах разворачивает общий процесс по задачам.

### Чеклист готового сервиса задач

- Расписания в конфиге с валидацией ParseStandard на загрузке: смена без релиза.
- Цепочка обёрток одна: Recover, логи, метрики, ретраи, замок — порядок зафиксирован.
- Каждая задача ограничена дедлайном через контекст, дедлайны тоже в конфиге.
- Необратимые шаги защищены ключами идемпотентности до отправки.
- Тяжёлая работа режется батчами; частые задачи стоят в SkipIfStillRunning.
- Остановка: Stop, дожим с дедлайном, недоделанное повторяется при старте.

### Соберите запуск сервиса

Впишите обёртку паники, её аргумент, регистрацию задачи и старт планировщика.

```go
c := cron.New(cron.WithChain(
    cron.___(___), // паника в стек лога
    wrappers.Metered(metrics),
    wrappers.Retry(3, time.Second),
))
c.___(cfg.Spec, job)
c.___()
```

Подсказка: Ловец паники — cron.Recover, ему нужен логгер; регистрация — AddJob; старт — Start.

Правильные ответы в пропусках: `Recover`, `logger`, `AddJob`, `Start`

### Расставьте поток main

Расставьте строки в порядке файла: конфиг, планировщик с цепочкой, регистрация задач, старт, ожидание сигнала и остановка.

Правильный порядок строк:

1. `cfg := LoadConfig("tasks.json")`
2. `c := cron.New(cron.WithChain(cron.Recover(logger)))`
3. `registerTasks(c, cfg, store)`
4. `c.Start()`
5. `<-stop`
6. `c.Stop()`

### Карточки: проект сервиса задач

- **Планировщик задач go** — Один Cron на инстанс, конфиг расписаний, общая цепочка обёрток, дедлайны в конфиге и дожим при остановке. Задачи — структуры с Run и зависимостями в полях.
- **Валидация конфига на старте** — ParseStandard по каждой строке при загрузке: битое расписание падает с именем задачи до старта сервиса, а не молча не срабатывает в первый тик.
- **Идемпотентный ключ** — Дата или идентификатор, проверяемый до необратимого шага: повторный тик после сбоя пропускает сделанное. Защищает и от ретраев, и от дублей инстансов.
- **Батч-очистка** — DELETE партиями с LIMIT: короткие транзакции вместо одной длинной, блокировки не держат основной сервис, длительность видна в метрике.
- **Дожим остановки** — Stop возвращает контекст: select по Done с дедлайном ждёт тики, не уложившиеся отменяются контекстом. Дедлайн зависит от худшего таймаута задач.
- **Изоляция планировщиков** — Один планировщик на инстанс и владельца: задачи одного сервиса живут вместе; чужое хранилище или другая критичность — повод для выделения.

> **Где запускать примеры.** Честно о границах: многокомпонентный сервис песочница не запустит — там нет файла конфигурации, хранилища и отправителя писем, и это не недостаток, а формат: песочница исполняет самодостаточный код с пакетами вроде fmt, strings, strconv, time. В ней проверяются фрагменты проекта: предикт с партиями очистки исполняется как есть, арифметика дедлайнов и форматы дат — через пакет time. Полная сборка проекта — локально: модуль второго урока курса уже содержит robfig/cron, остальное добавляется по ходу сборки.

Сервис задач из проекта — сумма курса: расписание в конфиге с валидацией на старте, одна цепочка обёрток на все задачи, дедлайны в контекстах, идемпотентные ключи перед необратимыми шагами, батчи для тяжёлой работы, наблюдаемость сквозь логи и метрики, остановка с дожимом. Каждая деталь на своём месте из своего урока — повторить проект можно по реестру задач и чеклисту, подставляя свои хранилище и отправителя. Если возвращались с пропуска: [обёртки](/courses/cron/lessons/16-wrappers/) — точка сборки цепочки, [конфиг расписаний](/courses/cron/lessons/14-raspisanie-iz-konfiga/) — источник задач, [graceful shutdown](/courses/cron/lessons/19-graceful-cron/) — финал процесса, [блокировки](/courses/cron/lessons/21-raspredelennye-blokirovki/) и [ретраи](/courses/cron/lessons/23-retrai-zadach/) — защита от дублей и сбоев. Полный маршрут с нуля — [на странице курса](/courses/cron/). В домашнем задании — сборка сервиса целиком в своём окружении.

## Коротко: что запомнить

- Планировщик задач go — каркас: конфиг расписаний, общая цепочка обёрток, задачи как структуры с Run.
- Строки расписаний валидируются ParseStandard на загрузке: битый конфиг падает до старта сервиса.
- Порядок цепочки: Recover снаружи, затем логи, метрики, ретраи и замки — он же порядок принятия вызова.
- Контракты отчёта: дедлайн в контексте, ретрай в обёртке, идемпотентный ключ до отправки.
- Тяжёлая работа режется батчами, частые задачи защищены SkipIfStillRunning от перекрытия.
- Остановка: Stop и дожим с дедлайном; недоделанное повторяется при старте благодаря идемпотентности.

## Домашнее задание

Соберите проект целиком в своём окружении: конфиг из трёх задач с валидацией, цепочка Recover, логов, метрик и ретраев, хранилище-заглушка в памяти. Запустите два экземпляра против одного хранилища и убедитесь, что отчёт выходит одним, а второй инстанс пишет отказ замка. Затем проверьте сценарии отказов: заглушка хранилища падает на отметке — повтор не отправляет второе письмо; заглушка паникует — Recover пишет стек, сервис живёт. Финал — отправьте сервису сигнал остановки в момент тика и убедитесь, что дожим дожидается тика в дедлайн, а не уложившийся тик отменяется контекстом и повторяется при следующем старте.

## Вопросы и ответы по уроку

### Почему дедлайны задач выносятся в конфиг, а не зашиваются в код?

Дедлайн — часть эксплуатационного контракта задачи, как расписание: он согласуется с окном деплоя, длительностью дожима и алертами. Вынос в конфиг позволяет менять его без релиза, когда инфраструктура меняется быстрее кода. Валидация читает дедлайн тем же парсером длительностей, что и расписание, поэтому битое значение тоже падает на старте.

### Как выбрать дедлайн дожима при остановке сервиса?

Ориентир — худший таймаут задачи с запасом на завершение записи: при десяти минутах у отчёта дожим около десяти-двенадцати минут, если окно деплоя позволяет. Окно деплоя — жёсткая граница: дожим длиннее его приводит к принудительному убийству процесса, которое обнуляет смысл дожима. Поэтому дедлайны задач и дожим настраиваются вместе.

### Стоит ли выделять каждую задачу в отдельный планировщик?

Для трёх задач одного сервиса — нет: общий планировщик даёт один конфиг, одну цепочку и одну точку остановки, а изоляцию наблюдаемости дают лейблы задач в метриках и логах. Выделение оправдано при другом владельце, чужом хранилище или сильно разной критичности: тогда у отдельного планировщика появляется свой график релизов и свой набор алертов.
