---
title: "Динамическое расписание: добавление на лету"
description: "Cron динамический: изменение расписания go-сервиса на лету — AddJob после Start, замена через Remove и ParseStandard, реестр EntryID под мьютексом без рестарта."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/15-izmenenie-raspisaniya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

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

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

Служба напоминаний — учебный полигон для живого расписания: пользователь ставит уведомление на своё время, передвигает его, отменяет, и всё это без релизов и деплоев. Требования к сервису собираются в три операции: добавить задачу по запросу клиента, заменить расписание существующей и снять её по отмене. Рестарт как способ применить изменения отпадает сразу: напоминания приходят круглосуточно, а перезапуск ради каждого нового пользователя — это зависимость доступности от потока задач. Внешний системный 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](/courses/cron/lessons/13-inspekciya-entry/). Отдельная забота — скорость жизни: добавление происходит редко и стоит копейки, поэтому пула или очереди на регистрации не строят; узкое место — не 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 с опечатанным выражением на живом планировщике. Что произойдёт?

- Планировщик упадёт вместе с процессом
- ✅ AddJob вернёт нулевой EntryID и ошибку: работающие задачи не пострадают
- Задача зарегистрируется с пустым расписанием и сработает на следующий день
- Планировщик заблокируется до перезапуска

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

### Изменение расписания: паттерн замены 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 сохранился: библиотека переиспользует освободившийся адрес
- ✅ EntryID новый: запись расписания новая, а счётчик монотонно растёт
- EntryID обнулился: замёне соответствует нулевой идентификатор
- EntryID стал отрицательным: библиотека помечает заменённые записи

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

### Реестр под мьютексом: менеджер живых задач

Менеджер задач — клей между базой, планировщиком и хендлерами: структура с планировщиком, мьютексом и картой бизнес-ключей на EntryID. Через него идут все операции живого расписания: Add — регистрация с сохранением адреса, Replace — замена из прошлого раздела, Cancel — идемпотентное снятие по ключу. Мьютекс обязателен: операции приходят из HTTP-горутин и админских команд одновременно, а карта — общий ресурс; гонка на реестре из урока про конкурентность здесь возникает быстрее всего. При рестарте менеджер пересобирает реестр из базы: регистрирует все включённые задачи и заполняет карту заново — идентификаторы прошлого процесса не доживают, и это нормально, если источник истины в хранилище, как в [уроке про конфиг и базу](/courses/cron/lessons/14-raspisanie-iz-konfiga/). Кейс напоминаний из [урока про тикер в боте](/courses/telegram-bot/lessons/19-napominaniya/) строится ровно так: там цикл тикера получал напоминания параметрами, здесь их роль играет живой реестр с планировщиком.

> **Двойная регистрация при ретрае хендлера.** Клиент отправил запрос на создание напоминания, истёк таймаут, клиент ретранит — и хендлер честно зарегистрировал задачу дважды: две записи в планировщике, две строки в базе, двойные письма ровно в назначенный час. Лечение на границе API: идемпотентный ключ в запросе и уникальный индекс на бизнес-ключ задачи в базе — повторный запрос возвращает уже созданное напоминание, а не плодит второе. Дополнительный щит — перед AddJob проверить под тем же мьютексом, что ключ ещё отсутствует в реестре. Проверка в реестре не заменяет индекс: реестр живёт в памяти и пересобирается при рестарте, а уникальность обязана держаться в источнике истины.

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

```go
id1, _ := c.AddFunc("* * * * *", jobA)
c.Remove(id1)
id2, err := c.AddFunc("30 8 * * *", jobB)
fmt.Println(id1 != id2, err == nil)
```

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

- ✅ true true: идентификатор новый, ошибка nil
- true false: после Remove парсер сломался
- false true: AddJob переиспользовал освободившийся адрес
- false false: записи удалённой задачи остались в расписании

Идентификатор — монотонный счётчик: новая запись получает новое число, поэтому id1 отличается от id2. Remove не влияет на счётчик, а валидное выражение возвращает nil. Следствие для реестра: после замены карте нужно новое значение — старый EntryID больше не адресует ничего.

### Операции живого расписания

| Операция | База | Планировщик | Реестр |
| --- | --- | --- | --- |
| Добавить | вставка строки | AddJob после Start | ключ -> новый EntryID |
| Заменить | обновление spec | валидация, Remove, AddJob | ключ -> новый EntryID |
| Снять | delete строки | Remove по EntryID | удаление ключа |
| Выключить | enabled = false | Remove по EntryID | ключ удаляется: задача не адресуется |

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

Динамическим можно делать расписание, но не конфигурацию планировщика: зона, парсер и цепочка обёрток фиксируются при создании cron.Cron, и поменять их на живом процессе нельзя — правило из [урока про Start и Stop](/courses/cron/lessons/09-start-stop/). Обёртки Recover, DelayIfStillRunning и SkipIfStillRunning из [урока про wrappers](/courses/cron/lessons/16-wrappers/) ставятся при сборке и оборачивают все задачи сразу, поэтому новая задача, добавленная на лету, получает те же защиты автоматически — это сильная сторона WithChain. Вторая граница — несколько инстансов: динамическое расписание живёт в памяти каждого пода и размножает задачи по числу копий; без [распределённых блокировок](/courses/cron/lessons/21-raspredelennye-blokirovki/) напоминание сработает столько раз, сколько инстансов. Третья — тесты: менеджер — обычный код, его стоит покрывать тестами с фейковым планировщиком из [урока про тесты задач](/courses/cron/lessons/22-testy-zadach/): замена и снятие проверяются за миллисекунды без ожидания тика. Приём из [урока про контекст](/courses/cron/lessons/10-kontekst-zadachi/) остаётся в силе: снимаемая задача может работать — её дожимает контекст, а не Remove. И правило гигиены напоследок: добавление на лету не отменяет валидации всего расписания на старте — живые правки и загрузка из базы дополняют друг друга, а не заменяют.

### Соберите Replace

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

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

Подсказка: Валидация — ParseStandard; снятие старой записи — Remove; регистрация новой — AddJob.

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

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

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

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

1. `if _, err := cron.ParseStandard(spec); err != nil {`
2. `    return fmt.Errorf("выражение: %w", err)`
3. `}`
4. `c.Remove(m.ids[remID])`
5. `id, err := c.AddJob(spec, job)`
6. `m.ids[remID] = id; return err`

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

- **AddJob после Start** — Работает на живом планировщике: запись уходит в цикл по каналу, первый тик рассчитывается на ближайшем обороте. Потокобезопасно средствами библиотеки.
- **Replace** — Валидация, снятие старой записи, регистрация новой, обновление реестра. Валидация первая: битое выражение не оставляет пользователя без задачи.
- **EntryID после замены** — Новый: запись расписания другая, счётчик монотонный. Старый идентификатор больше не адресует ничего — карта реестра обновляется.
- **Реестр под мьютексом** — Карта бизнес-ключей на EntryID, общая для HTTP-горутин и админки. При рестарте пересобирается из базы.
- **Идемпотентность создания** — Идемпотентный ключ запроса плюс уникальный индекс на бизнес-ключ: ретрай возвращает существующее напоминание, а не плодит вторую задачу.
- **Что не меняется на лету** — Зона, парсер и цепочка обёрток фиксируются при создании Cron. Новая задача получает обёртки WithChain автоматически.

> **Где запускать примеры.** Пакета cron в песочнице нет, а менеджер из урока — обычные структуры, карты и методы: Add, Replace и Cancel с картой ключей переписываются в песочницу и работают так же, как в локальном go run, — замыкания, срезы, strconv для ключей и time в ней есть. Реальный планировщик и HTTP-хендлер — локально из модуля второго урока: там же проверяется поведение при рестарте и пересборка реестра из базы. Полезное упражнение для песочницы: карта ключей с добавлением, заменой и снятием записи под простым счётчиком обращений — механика Replace без планировщика.

Живое расписание — три операции поверх вчерашнего реестра: добавление через AddJob на работающем планировщике, замена через Replace с валидацией до снятия и идемпотентное снятие по ключу. Инвариант один: одно бизнес-напоминание — одна запись в планировщике, источник истины в базе, реестр под мьютексом синхронен с обеими сторонами. Из ограничений главное — конфигурация Cron меняется только рестартом, а мультиинстансность требует блокировок вне процесса. Дальше по маршруту: [обёртки wrappers](/courses/cron/lessons/16-wrappers/) — то, что уже защищает все ваши динамические задачи от паник и перекрытий, и [graceful shutdown](/courses/cron/lessons/19-graceful-cron/) — как останавливать сервис с живым расписанием, не обрывая дожим задач. Если возвращались с пропуска — [Entries и Remove](/courses/cron/lessons/13-inspekciya-entry/) дают базу реестра, а [конфиг и база](/courses/cron/lessons/14-raspisanie-iz-konfiga/) — источник истины. Полный маршрут — [на странице курса](/courses/cron/). В домашнем задании — менеджер с тремя операциями, тесты на Replace и защита от ретраев.

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

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

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

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

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

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

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

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

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

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

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