Служба напоминаний — учебный полигон для живого расписания: пользователь ставит уведомление на своё время, передвигает его, отменяет, и всё это без релизов и деплоев. Требования к сервису собираются в три операции: добавить задачу по запросу клиента, заменить расписание существующей и снять её по отмене. Рестарт как способ применить изменения отпадает сразу: напоминания приходят круглосуточно, а перезапуск ради каждого нового пользователя — это зависимость доступности от потока задач. Внешний системный crontab не спасает: он умеет дёргать процессы, но не знает ни о вашей базе, ни о graceful shutdown, и связывать его с состоянием сервиса — отдельная боль. Хорошая новость: robfig/cron v3 проектируется под такой сценарий — регистрация и снятие работают на живом планировщике, а вчерашний урок про реестр идентификаторов становится фундаментом менеджера задач. Соберём все три операции в код, разберём паттерн замены с валидацией до снятия и оградим реестр от гонок и двойных регистраций.
AddJob после Start: cron динамический на живом планировщике#
Регистрация в robfig/cron v3 не привязана к жизненному циклу: AddFunc и AddJob работают до Start и после него. Разница в механике: до старта запись просто дописывается во внутренний срез, после — уходит в цикл планировщика по каналу, и на ближайшем обороте цикла для новой записи вычисляется первый тик. Cron динамический собирается именно на этом: хендлер HTTP или админская команда зовут тот же AddJob, которым на старте пользовался main, и планировщик подхватывает задачу без рестарта. Момент первого тика честно определяет выражение: добавление в 12:47 задачи на каждые десять минут даст первый тик в 12:50, а задача на девять утра будет ждать утро — мгновенного запуска регистрация не делает. Потокобезопасность обеспечивает сама библиотека: регистрация из нескольких горутин не ломает внутренние структуры. Ваша ответственность начинается на уровне учёта: если задачи складываются в общий реестр идентификаторов, защита нужна уже ваша — мьютекс из урока про Entries и Remove. Отдельная забота — скорость жизни: добавление происходит редко и стоит копейки, поэтому пула или очереди на регистрации не строят; узкое место — не AddJob, а работа самих задач.
Хендлер, который регистрирует задачу по запросу
GOfunc (s *Server) createReminder(w http.ResponseWriter, r *http.Request) {
var req CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpError(w, 400, "битое тело запроса")
return
}
if _, err := cron.ParseStandard(req.Spec); err != nil {
httpError(w, 400, "битое выражение: "+err.Error())
return
}
rem, err := s.store.Save(r.Context(), req)
if err != nil {
httpError(w, 500, "не сохранили напоминание")
return
}
if _, err := s.sched.Add(rem); err != nil {
httpError(w, 500, "не зарегистрировали задачу")
return
}
writeJSON(w, 201, map[string]int64{"reminder": rem.ID})
}Хендлер валидирует выражение до записи в базу, сохраняет задачу и регистрирует её в живом планировщике через менеджер реестра. Порядок «сначала источник истины, затем расписание» выбран сознательно: при сбое регистрации в базе останется напоминание без задачи в расписании, и его заметит перечитка на рестарте. Обратный порядок тоже рабочий — регистрация с компенсирующим удалением записи при ошибке, — но он требует чистки в двух местах.
Проверка: битое выражение мимо валидации
Хендлер без валидации вызвал AddJob с опечатанным выражением на живом планировщике. Что произойдёт?
Изменение расписания: паттерн замены Replace#
Замена расписания задачи — самая частая операция живого планировщика: пользователь передвинул напоминание, админ поправил выражение выгрузки. Наивный путь — зарегистрировать новую запись и забыть про старую: получите две задачи, обе будут срабатывать, и письмо уйдёт дважды. Корректная последовательность — Replace: валидация нового выражения, снятие старой записи по EntryID, регистрация новой, обновление реестра. Валидация идёт первой не для красоты: если новое выражение битое, операция обязана провалиться до снятия — иначе пользователь останется без напоминания вообще. Снятие до регистрации даёт окно, в котором задачи нет: секунда на стыке, и для большинства сервисов это приемлемо; если окно недопустимо — регистрируйте новую запись первой, затем снимайте старую, а лишний идентификатор чистите при ошибке. Изменение расписания go-кода через Replace сохраняет инвариант: одно бизнес-напоминание — одна запись в планировщике, и реестр остаётся согласован с базой. Проверку этого инварианта стоит включить в тесты: после каждой операции Replace длина Entries и содержимое карты реестра совпадают.
Replace: валидация, снятие, регистрация
GOfunc (m *Manager) Replace(ctx context.Context, remID int64, spec string) error {
if _, err := cron.ParseStandard(spec); err != nil {
return fmt.Errorf("выражение: %w", err)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.ids[remID] != 0 {
m.c.Remove(m.ids[remID])
}
id, err := m.c.AddJob(spec, &Delivery{rem: remID, store: m.store})
if err != nil {
return fmt.Errorf("регистрация: %w", err)
}
m.ids[remID] = id
return m.store.UpdateSpec(ctx, remID, spec)
}Валидация до блокировки: битое выражение не занимает мьютекс и не трогает расписание. Дальше под одним мьютексом: снятие старой записи, регистрация новой, обновление реестра и запись нового выражения в источник истины. Проверка на ноль перед Remove превращает замену в добавление, когда задачи ещё нет. Порядок «реестр, затем база» нарочитый: при падении записи планировщик уже живёт с новым выражением, а при рестарте перечитает старое — расхождение видно, а не потеряно.
Проверка: идентификатор после замены
Замену выполнили Remove по старому EntryID и AddJob нового выражения. Что стало с идентификатором задачи?
Реестр под мьютексом: менеджер живых задач#
Менеджер задач — клей между базой, планировщиком и хендлерами: структура с планировщиком, мьютексом и картой бизнес-ключей на EntryID. Через него идут все операции живого расписания: Add — регистрация с сохранением адреса, Replace — замена из прошлого раздела, Cancel — идемпотентное снятие по ключу. Мьютекс обязателен: операции приходят из HTTP-горутин и админских команд одновременно, а карта — общий ресурс; гонка на реестре из урока про конкурентность здесь возникает быстрее всего. При рестарте менеджер пересобирает реестр из базы: регистрирует все включённые задачи и заполняет карту заново — идентификаторы прошлого процесса не доживают, и это нормально, если источник истины в хранилище, как в уроке про конфиг и базу. Кейс напоминаний из урока про тикер в боте строится ровно так: там цикл тикера получал напоминания параметрами, здесь их роль играет живой реестр с планировщиком.
Клиент отправил запрос на создание напоминания, истёк таймаут, клиент ретранит — и хендлер честно зарегистрировал задачу дважды: две записи в планировщике, две строки в базе, двойные письма ровно в назначенный час. Лечение на границе API: идемпотентный ключ в запросе и уникальный индекс на бизнес-ключ задачи в базе — повторный запрос возвращает уже созданное напоминание, а не плодит второе. Дополнительный щит — перед AddJob проверить под тем же мьютексом, что ключ ещё отсутствует в реестре. Проверка в реестре не заменяет индекс: реестр живёт в памяти и пересобирается при рестарте, а уникальность обязана держаться в источнике истины.
Что напечатает замена?
Прочитайте код и выберите, что он выведет:
id1, _ := c.AddFunc("* * * * *", jobA)
c.Remove(id1)
id2, err := c.AddFunc("30 8 * * *", jobB)
fmt.Println(id1 != id2, err == nil)Операции живого расписания#
| Операция | База | Планировщик | Реестр |
|---|---|---|---|
| Добавить | вставка строки | AddJob после Start | ключ -> новый EntryID |
| Заменить | обновление spec | валидация, Remove, AddJob | ключ -> новый EntryID |
| Снять | delete строки | Remove по EntryID | удаление ключа |
| Выключить | enabled = false | Remove по EntryID | ключ удаляется: задача не адресуется |
Границы: что не меняется на лету#
Динамическим можно делать расписание, но не конфигурацию планировщика: зона, парсер и цепочка обёрток фиксируются при создании cron.Cron, и поменять их на живом процессе нельзя — правило из урока про Start и Stop. Обёртки Recover, DelayIfStillRunning и SkipIfStillRunning из урока про wrappers ставятся при сборке и оборачивают все задачи сразу, поэтому новая задача, добавленная на лету, получает те же защиты автоматически — это сильная сторона WithChain. Вторая граница — несколько инстансов: динамическое расписание живёт в памяти каждого пода и размножает задачи по числу копий; без распределённых блокировок напоминание сработает столько раз, сколько инстансов. Третья — тесты: менеджер — обычный код, его стоит покрывать тестами с фейковым планировщиком из урока про тесты задач: замена и снятие проверяются за миллисекунды без ожидания тика. Приём из урока про контекст остаётся в силе: снимаемая задача может работать — её дожимает контекст, а не Remove. И правило гигиены напоследок: добавление на лету не отменяет валидации всего расписания на старте — живые правки и загрузка из базы дополняют друг друга, а не заменяют.
Соберите Replace
Впишите валидацию выражения, снятие старой записи и регистрацию новой.
Расставьте шаги замены
Расставьте строки Replace в порядке выполнения: валидация с выходом, закрытие блока, снятие, регистрация и обновление реестра.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: живое расписание
Пакета cron в песочнице нет, а менеджер из урока — обычные структуры, карты и методы: Add, Replace и Cancel с картой ключей переписываются в песочницу и работают так же, как в локальном go run, — замыкания, срезы, strconv для ключей и time в ней есть. Реальный планировщик и HTTP-хендлер — локально из модуля второго урока: там же проверяется поведение при рестарте и пересборка реестра из базы. Полезное упражнение для песочницы: карта ключей с добавлением, заменой и снятием записи под простым счётчиком обращений — механика Replace без планировщика.
Живое расписание — три операции поверх вчерашнего реестра: добавление через AddJob на работающем планировщике, замена через Replace с валидацией до снятия и идемпотентное снятие по ключу. Инвариант один: одно бизнес-напоминание — одна запись в планировщике, источник истины в базе, реестр под мьютексом синхронен с обеими сторонами. Из ограничений главное — конфигурация Cron меняется только рестартом, а мультиинстансность требует блокировок вне процесса. Дальше по маршруту: обёртки wrappers — то, что уже защищает все ваши динамические задачи от паник и перекрытий, и graceful shutdown — как останавливать сервис с живым расписанием, не обрывая дожим задач. Если возвращались с пропуска — Entries и Remove дают базу реестра, а конфиг и база — источник истины. Полный маршрут — на странице курса. В домашнем задании — менеджер с тремя операциями, тесты на Replace и защита от ретраев.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- AddFunc и AddJob работают после Start: задачи добавляются на живом планировщике без рестарта.
- Replace — валидация, Remove, AddJob: битое выражение проваливает операцию до снятия старой записи.
- После замены EntryID новый: карта реестра обновляется под тем же мьютексом.
- Реестр ключей на EntryID — общий ресурс HTTP-горутин: мьютекс и пересборка из базы при рестарте.
- Создание задач идемпотентно: ключ запроса и уникальный индекс защищают от ретраев.
- На лету меняется расписание, а не конфигурация: зона, парсер и обёртки фиксируются при создании.
Домашнее задание
Доведите менеджер задач до трёх операций: Add, Replace и Cancel с картой ключей под мьютексом и пересборкой реестра из базы при старте. Накройте Replace тестом с фейковым планировщиком: битое выражение сохраняет старую задачу, валидное — снимает и регистрирует заново. Добавьте идемпотентный ключ в хендлер создания и проверьте ретраем, что вторая регистрация не случается. Финал — искусственный рестарт процесса: убедитесь, что реестр собрался из базы и напоминания сработали по расписанию без изменений.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужно ли перезапускать сервис после массовой правки расписания?
Нет: каждая операция — Add, Replace, Remove — доступна на живом планировщике. Рестарт нужен только при смене конфигурации самого Cron: зоны, парсера или цепочки обёрток, которые фиксируются при создании.
Чем Replace отличается от пары Remove и AddJob?
Порядком и гарантиями: Replace сначала валидирует новое выражение и только потом снимает старую запись, поэтому битый spec не оставляет пользователя без задачи. Плюс Replace обновляет реестр под одним мьютексом — без промежуточных состояний, которые видят параллельные хендлеры.
Как избежать двойных задач при повторных запросах?
Идемпотентный ключ на стороне клиента и уникальный индекс на бизнес-ключ в базе: повторный запрос возвращает существующее напоминание. Дополнительно перед AddJob проверяют отсутствие ключа в реестре под тем же мьютексом, но индекс в источнике истины — главная защита.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь