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

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

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

Урок 5 из 25 ~18 мин +50 XP

Отложенные задачи: ProcessIn и ProcessAt

Отложенные задачи — та же постановка asynq плюс одна опция: ProcessIn назначает запуск через час, ProcessAt — на полночь, а Redis хранит расписание в сортированном множестве и отдаёт задачи, когда время пришло. Соберём напоминание о записи и ночной отчёт, разберём таймзоны и границу, за которой лучше cron.

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

Сервис записей к специалистам: пациент выбрал slot на 14:00, администратор поставил напоминание «за час до приёма». Логика на HTTP-хендлере невозможна: запрос закончится через миллисекунды, а напоминание должно сработать через час. Таймер-горутина не спасает — рестарт сервиса его уничтожит. Нужен способ сказать очереди: «вот задача, возьмись за неё не сейчас, а тогда-то». В asynq для этого существуют две опции постановки — ProcessIn для задержки от текущего момента и ProcessAt для точного времени. Устройство под ними общее: Redis хранит расписание, воркеры его опрашивают, задачи переживают рестарты всех участников. В этом уроке обе опции в деле: напоминание через час и отчёт в полночь, плюс детали, которые отличают «поставил на потом» от «сделал вовремя».

Asynq ProcessIn: задача с задержкой#

Asynq ProcessIn — опция постановки: client.Enqueue(task, asynq.ProcessIn(1time.Hour)) кладёт задачу не в очередь ожидания, а в расписание. Всё остальное вы уже знаете из уроков про первую задачу: NewTask с типом и payload, Enqueue возвращает TaskInfo, хендлер регистрируется в mux как обычно — меняется только момент исполнения. Поле info.NextProcessAt показывает расчётное время запуска: удобно логировать при постановке, чтобы потом сверить с фактом. Задача при этом переходит в состояние scheduled и живёт в расписании Redis; в дашборде её видно среди отложенных. Задержка принимается любым duration — от секунды до недель, но границы разумного стоит держать в голове: напоминание через минуту лучше ставить как immediate, а расписание на месяцы — повод посмотреть в сторону периодических задач. Механизм одинаков для любых типов: это не отдельный API, а обещание при постановке. Одна деталь, о которой стоит договориться сразу: ProcessIn задаёт нижнюю границу, а не точный момент. Когда срок истёк, задача переходит из scheduled в pending — но исполняется она тогда, когда её заберёт живой воркер с свободным слотом. На здоровом сервисе задержка сверх назначенного времени измеряется секундами; на сервисе с остановленными воркерами — до их возвращения, и это фича, а не баг: задача не потеряна. Отсюда правильные ожидания: напоминания, дедлайны и отложенные письма прощают секунды опоздания, а вот торговые заявки с точностью до миллисекунды — не для очереди с опросом расписания. Отсчёт начинается в момент постановки, а не в момент создания payload: лаг между ними сдвинет задачу целиком. Практика, которая делает код читаемым: держите duration-константы рядом с типами задач — asynq.ProcessIn(24time.Hour) говорит сам за себя, без магических чисел. И постановка с ProcessIn до старта воркеров абсолютно легальна: задача дождётся в расписании, потому что Redis живёт своей жизнью.

Напоминание через час: ProcessIn

GO
payload, err := json.Marshal(tasks.ReminderPayload{
    UserID:  42,
    Message: "приём у врача через час",
})
if err != nil {
    log.Fatal(err)
}

task := asynq.NewTask("reminder:send", payload)

info, err := client.Enqueue(task, asynq.ProcessIn(1*time.Hour))
if err != nil {
    log.Fatal(err)
}

log.Printf("напоминание отправится: %v", info.NextProcessAt)

От простой постановки отличается ровно одна строка: опция ProcessIn у Enqueue. Задача с типом reminder:send уйдёт в расписание Redis и станет pending через час, когда воркер заберёт её из расписания. NextProcessAt из TaskInfo — расчётный момент запуска: логируйте его, чтобы сверять с фактом при разборе жалоб.

ProcessAt: запуск в точное время#

ProcessAt принимает time.Time вместо длительности: ProcessAt(when) — «запусти не раньше этого момента». Математически ProcessIn(d) эквивалентен ProcessAt(time.Now().Add(d)), и выбирать стоит по смыслу задачи: длительность — для «через час после события», момент времени — для «в полночь», «в 09:00 по будням», «когда заканчивается триал». Ночной отчёт строится на time.Date: собираем год, месяц и день плюс один, часы-минуты-секунды нулевые — и получаем первую полночь после текущего момента. Таймзона здесь главный источник ошибок: time.Now() живёт в таймзоне сервера, которая в контейнерах почти всегда UTC, и «полночь» без указания места окажется полуднем по Москве. Лечение — time.LoadLocation и явный .In(loc) для всех расчётов времени. Приятный бонус: time.Date нормализует переполнения — день 32 декабря честно превратится в 1 января, и код конца года не потребует особых случаев. Ещё две привычки: держите LoadLocation в инициализации — чтение зоны с диска не бесплатное, и в горячем пути оно вам не нужно; и покройте расчёт тестом с фиксированным now, иначе таймзонные ошибки проявятся только на проде в неправильный час. Напоминание про контейнеры: минимальные образы без tzdata вернут ошибку из LoadLocation — лечится пакетом tzdata в образе или импортом time/tzdata.

Отчёт в полночь: ProcessAt

GO
loc, err := time.LoadLocation("Europe/Moscow")
if err != nil {
    log.Fatal(err)
}

now := time.Now().In(loc)
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, loc)

info, err := client.Enqueue(reportTask, asynq.ProcessAt(midnight))
if err != nil {
    log.Fatal(err)
}

log.Printf("отчёт уйдёт: %v", info.NextProcessAt)

LoadLocation с явной таймзоной защищает от UTC-сервера и его «полуночи». time.Date с днём плюс один нормализует переполнение: 31 декабря даст 1 января следующего года. Постановка через ProcessAt переводит задачу в scheduled с баллом-временем в расписании Redis, а NextProcessAt покажет расчётную полночь.

Отложенные задачи redis: sorted set внутри#

Отложенные задачи redis хранит не в списках, как immediate, а в сортированном множестве: член — сериализованная задача, балл — время запуска. Отсюда дешёвые операции расписания: постановка записывает член с баллом, а воркер опрашивает расписание и переводит созревшие задачи в pending-очередь, откуда их забирают хендлеры. Опрос идёт постоянно с коротким интервалом — порядка нескольких секунд, поэтому точность срабатывания в спокойной системе измеряется секундами, а не миллисекундами. Устройство переживает всё, что переживает Redis: рестарты воркеров, рестарты клиентов, перезапуск контейнера — расписание остаётся в хранилище, а не в памяти процессов. Имена ключей менялись между версиями asynq, но принцип один: несколько zset-ов с баллами-временами для scheduled, retry и архива. Если вы прошли курс по go-redis, вы уже встречали сортированные множества с баллами-временами — asynq использует ровно этот приём. Тот же приём обслуживает и повторные: retry-задачи лежат в таком же множестве с баллом-временем следующей попытки, так что ретрай после сбоя и отложенная постановка — близнецы внутри библиотеки. Практический вывод: включённая персистентность Redis — обязательное условие для расписаний, на которые кто-то надеется.

Кейс: напоминания о записи#

Соберём сценарий из первого блока урока целиком. При создании записи сервис ставит задачу reminder:send с ProcessIn(1*time.Hour) и payload из ID пользователя и текста. В час X воркер выполняет хендлер: достаём пользователя, шлём сообщение, return nil. Всё работает до первого отмененного приёма: напоминание-то уже в расписании, и в час X придёт сообщение о приёме, которого нет. Asynq не умеет «удалить задачу по бизнес-ключу» из клиента — постановка однонаправленна. Два рабочих решения: проверка актуальности в хендлере — смотрим в базу, если приёма нет, return nil молча; или управление задачей через Inspector, который умеет отменять по ID — ему посвящён пятнадцатый урок. Первый вариант дешевле и надёжнее: хендлер-проверка срабатывает и после сбоев, и после ручных правок в базе, тогда как отмена Inspector-ом требует хранить ID каждой поставленной задачи. Проверку в хендлере пишите как обычный guard: достали приём по идентификатору, не нашли или приём отменён — логируете пропуск и возвращаете nil. Ошибка тут была бы вредной: несуществующий приём — нормальный исход, а не сбой, и повтор задачи его не исправит.

Четыре способа отложить работу#

ИнструментКогда ставитПример
ProcessInчерез N от момента событиянапоминание через час
ProcessAtв конкретный момент времениотчёт в полночь
cron и периодикаповторяющееся расписаниепересчёт в 03:00 ежедневно
Ретраипосле сбоя с растущей паузойвторая попытка письма

Сценарии, которые просятся в расписание#

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

ProcessIn против cron: где граница#

Отложенные задачи и cron решают разные вопросы: ProcessIn и ProcessAt отвечают на «когда наступит момент, связанный с событием», cron — на «когда наступит очередной час по календарю». Напоминание о записи привязано к событию, случившемуся сейчас, и живёт в asynq: постановка из кода, payload с данными, ретраи при сбое. Ночной пересчёт рейтингов привязан к календарю и повторяется всегда — это территория cron или периодических задач asynq, которые построены на cron-выражениях и разобраны в девятом уроке. Взрослые схемы используют обе половины: cron ставит периодические задачи, а те, в свою очередь, ставят отложенные — «пересчитать в полночь, а отчёт отправить в девять утра». Если расписание у вас уже живёт в классическом cron, как описано в курсе по cron, asynq спокойно соседствует с ним: процесс-планировщик ставит задачи через client.Enqueue, и отдельного моста не нужно. Гибридный сценарий, где обе половины встречаются в одном предложении: «напомнить за сутки до дедлайна заказа». Дедлайн — дата из базы, значит, это ProcessAt с рассчитанным моментом, а не cron; зато ежедневный пересчёт дедлайнов — уже календарь. Правильный вопрос к себе всегда один: привязано время к событию или к календарю.

Проверка: момент активации

Задача поставлена с ProcessIn(30*time.Minute). Когда она попадёт в состояние active?

Проверка: расписание переживает простой

Воркеры остановлены на два часа, а scheduled-задача «созрела» в это время. Что с ней станет?

Что вернёт второй Enqueue?

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

client.Enqueue(task1, asynq.TaskID("reminder-42"), asynq.ProcessIn(1*time.Hour))

client.Enqueue(task2, asynq.TaskID("reminder-42"), asynq.ProcessIn(10*time.Minute))

Какая дата попадёт в расписание?

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

now := time.Date(2025, 12, 31, 15, 0, 0, 0, loc)
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, loc)

Соберите отложенную постановку

Допишите опции: задержку в минутах и собственный идентификатор напоминания.

info, err := client.Enqueue(task, asynq.(30 * time.), asynq.("reminder-42"), )

Соберите постановку на полночь

Расставьте строки в порядке работы: таймзона, расчёт времени, payload, постановка, лог.

Строки

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

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

Карточки: отложенные задачи

Термин

ProcessIn

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

Опция постановки: запуск не раньше заданной длительности от момента постановки. Задача переходит в scheduled.

1 / 6

Расписание освоено: ProcessIn для «через час после события», ProcessAt для «в полночь», sorted set внутри Redis как хранилище расписания, проверки актуальности в хендлере как страховка от отменённых событий. Пятый урок закрывает первую часть курса: у вас есть окружение, первая задача, payload с ID и отложенные постановки. Дальше курс разбирает поведение задач во времени: шестой урок — ретраи и экспоненциальная задержка, седьмой — очереди с приоритетами, восьмой — маршрутизация ServeMux. В шестом вы встретите DefaultRetryDelay и экспоненциальный рост паузы — и поймёте, зачем существует потолок в 25 попыток, который мы упоминали дважды. Попробуйте и мини-эксперимент: поставьте задачу на минуту вперёд и остановите воркеры — созревшая задача дождётся в расписании, и в этом весь смысл хранить время в Redis, а не в памяти процесса. Запускайте примеры локально и попробуйте кейс целиком: поставьте напоминание на две минуты, отмените приём в базе и посмотрите, как хендлер молча пропустит задачу. Формат прежний: код живёт на вашей машине с Go и Redis в Docker, песочница сайта показывает только тематическую программу на чистом Go. Это тот случай, когда пять минут практики стоят всей теории о проверках актуальности.

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

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

Вывод

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

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

  • ProcessIn(d) и ProcessAt(t) — опции постановки: задача уходит в состояние scheduled и ждёт срока.
  • Истечение срока переводит задачу в pending; исполняет её живой воркер — сверх срока уходят секунды.
  • Расписание живёт в сортированном множестве Redis с баллом-временем и переживает рестарты всех участников.
  • Для «в полночь» используйте time.Date с явной таймзоной; time.Now() сервера почти всегда UTC.
  • time.Date нормализует переполнения: день 32 декабря превращается в 1 января без особых случаев.
  • Клиент не удаляет задачи из расписания: актуальность проверяет хендлер, отменяет — Inspector.
  • Retry-задачи лежат в тех же сортированных множествах с баллом-временем: ретрай и отложка — близнецы внутри asynq.
  • ProcessIn — для событий, cron — для календаря; периодические задачи asynq разбирает девятый урок.

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

Реализуйте кейс урока целиком: задача reminder:send ставится с ProcessIn(2*time.Minute), хендлер проверяет флаг актив в payload или в базе и молча выходит, если запись отменена. Поставьте задачу, отмените запись и убедитесь, что в логе появился пропуск без отправки. Повторите с ProcessAt на следующую полночь и распечатайте NextProcessAt обеих постановок. А потом закомментируйте проверку актуальности и убедитесь, что напоминание ушло по отменённому приёму: эту боль лучше пережить на тестовом Redis, а не в проде.

Личные заметкиПусто
Хранятся только в вашем браузере (Asynq · Отложенные задачи: ProcessIn и ProcessAt)

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

Насколько точно срабатывает ProcessIn?

Задача исполняется не раньше срока; опоздание в спокойной системе — секунды, которые уходят на опрос расписания и свободный воркер. Если вам нужны миллисекунды, очередь задач — неправильный инструмент: смотрите в сторону реального времени, а asynq оставьте задачам, где секунда не видна.

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

Прямой опции «перенести» у клиента нет: постановка однонаправленна. Варианты — поставить новую задачу и пренебречь старой, проверяя актуальность в хендлере, либо работать с задачей через Inspector, который умеет запускать и отменять по ID; оба подхода сравнивает кейс этого урока.

Что произойдёт с расписанием при падении Redis?

Расписание живёт в Redis, значит, переживает ровно столько, сколько живёт хранилище. При сбое Redis задачи возвращаются после его восстановления, если настроена персистентность, и теряются последние секунды данных в противном случае; поэтому прод-установки включают снимки или журнал и следят за доступностью инстанса. Снимки с коротким интервалом обычно достаточны: потерянные секунды расписания лечатся повторной постановкой, а не бесконечной гарантией.

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

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

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

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

Kafka в Go: продюсеры и консьюмеры на kafka-go

Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.

  • Понимать топики, партиции, оффсеты и группы консьюмеров
  • Писать продюсеры с ключами, батчами и ретраями
  • Читать сообщения группой с коммитами оффсетов
  • Выбирать гарантии: at-most-once, at-least-once