Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 15 из 25 ~18 мин +20 XP

Динамическое расписание: добавление на лету

Cron динамический — режим, при котором задачи добавляются, заменяются и снимаются без рестарта сервиса: AddJob работает и на живом планировщике, Remove снимает запись по EntryID, а замена собирается из валидации, снятия и регистрации заново. Кейс — служба напоминаний: пользователь поставил уведомление на 18:30, и никто не собирается деплоить ради одной минуты. Разберём механику регистрации после Start, паттерн замены, реестр идентификаторов под мьютексом и границы, где изменение расписания go-сервиса требует осторожности.

Содержание урока · 8 разделов

Служба напоминаний — учебный полигон для живого расписания: пользователь ставит уведомление на своё время, передвигает его, отменяет, и всё это без релизов и деплоев. Требования к сервису собираются в три операции: добавить задачу по запросу клиента, заменить расписание существующей и снять её по отмене. Рестарт как способ применить изменения отпадает сразу: напоминания приходят круглосуточно, а перезапуск ради каждого нового пользователя — это зависимость доступности от потока задач. Внешний системный 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, а работа самих задач.

Хендлер, который регистрирует задачу по запросу

GO
func (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: валидация, снятие, регистрация

GO
func (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-горутин и админских команд одновременно, а карта — общий ресурс; гонка на реестре из урока про конкурентность здесь возникает быстрее всего. При рестарте менеджер пересобирает реестр из базы: регистрирует все включённые задачи и заполняет карту заново — идентификаторы прошлого процесса не доживают, и это нормально, если источник истины в хранилище, как в уроке про конфиг и базу. Кейс напоминаний из урока про тикер в боте строится ровно так: там цикл тикера получал напоминания параметрами, здесь их роль играет живой реестр с планировщиком.

Что напечатает замена?

Прочитайте код и выберите, что он выведет:

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 = falseRemove по EntryIDключ удаляется: задача не адресуется

Границы: что не меняется на лету#

Динамическим можно делать расписание, но не конфигурацию планировщика: зона, парсер и цепочка обёрток фиксируются при создании cron.Cron, и поменять их на живом процессе нельзя — правило из урока про Start и Stop. Обёртки Recover, DelayIfStillRunning и SkipIfStillRunning из урока про wrappers ставятся при сборке и оборачивают все задачи сразу, поэтому новая задача, добавленная на лету, получает те же защиты автоматически — это сильная сторона WithChain. Вторая граница — несколько инстансов: динамическое расписание живёт в памяти каждого пода и размножает задачи по числу копий; без распределённых блокировок напоминание сработает столько раз, сколько инстансов. Третья — тесты: менеджер — обычный код, его стоит покрывать тестами с фейковым планировщиком из урока про тесты задач: замена и снятие проверяются за миллисекунды без ожидания тика. Приём из урока про контекст остаётся в силе: снимаемая задача может работать — её дожимает контекст, а не Remove. И правило гигиены напоследок: добавление на лету не отменяет валидации всего расписания на старте — живые правки и загрузка из базы дополняют друг друга, а не заменяют.

Соберите Replace

Впишите валидацию выражения, снятие старой записи и регистрацию новой.

if _, err := cron.(spec); err != nil { return err } m.mu.Lock() defer m.mu.Unlock() c.(m.ids[remID]) id, err := c.(spec, job)

Расставьте шаги замены

Расставьте строки Replace в порядке выполнения: валидация с выходом, закрытие блока, снятие, регистрация и обновление реестра.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: живое расписание

Термин

AddJob после Start

нажмите, чтобы перевернуть
Определение

Работает на живом планировщике: запись уходит в цикл по каналу, первый тик рассчитывается на ближайшем обороте. Потокобезопасно средствами библиотеки.

1 / 6

Живое расписание — три операции поверх вчерашнего реестра: добавление через AddJob на работающем планировщике, замена через Replace с валидацией до снятия и идемпотентное снятие по ключу. Инвариант один: одно бизнес-напоминание — одна запись в планировщике, источник истины в базе, реестр под мьютексом синхронен с обеими сторонами. Из ограничений главное — конфигурация Cron меняется только рестартом, а мультиинстансность требует блокировок вне процесса. Дальше по маршруту: обёртки wrappers — то, что уже защищает все ваши динамические задачи от паник и перекрытий, и graceful shutdown — как останавливать сервис с живым расписанием, не обрывая дожим задач. Если возвращались с пропуска — Entries и Remove дают базу реестра, а конфиг и база — источник истины. Полный маршрут — на странице курса. В домашнем задании — менеджер с тремя операциями, тесты на Replace и защита от ретраев.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • AddFunc и AddJob работают после Start: задачи добавляются на живом планировщике без рестарта.
  • Replace — валидация, Remove, AddJob: битое выражение проваливает операцию до снятия старой записи.
  • После замены EntryID новый: карта реестра обновляется под тем же мьютексом.
  • Реестр ключей на EntryID — общий ресурс HTTP-горутин: мьютекс и пересборка из базы при рестарте.
  • Создание задач идемпотентно: ключ запроса и уникальный индекс защищают от ретраев.
  • На лету меняется расписание, а не конфигурация: зона, парсер и обёртки фиксируются при создании.

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

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

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · Динамическое расписание: добавление на лету)

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

Нужно ли перезапускать сервис после массовой правки расписания?

Нет: каждая операция — Add, Replace, Remove — доступна на живом планировщике. Рестарт нужен только при смене конфигурации самого Cron: зоны, парсера или цепочки обёрток, которые фиксируются при создании.

Чем Replace отличается от пары Remove и AddJob?

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

Как избежать двойных задач при повторных запросах?

Идемпотентный ключ на стороне клиента и уникальный индекс на бизнес-ключ в базе: повторный запрос возвращает существующее напоминание. Дополнительно перед AddJob проверяют отсутствие ключа в реестре под тем же мьютексом, но индекс в источнике истины — главная защита.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Slog25 уроков~9 чпрактика25 уроков уже доступны

Логирование в Go: структурные логи на slog

Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.

  • Логировать структурированно: уровни, ключи-атрибуты, группы
  • Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
  • Мигрировать со старого пакета log без переписывания всей кодовой базы
  • Держать производительность: Enabled, предвычисление, горячий путь