---
title: "Отложенные задачи: ProcessIn и ProcessAt"
description: "Как отложить задачу в asynq: ProcessIn для напоминания через час, ProcessAt для отчёта в полночь. Таймзоны, сортированное множество Redis и граница, за которой лучше cron."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/05-otlozhennye-zadachi/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 18
---

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

*Курс «Asynq в Go: очередь фоновых задач на Redis», урок 5 из 25. ~18 минут. Обновлено 23.09.2026. Источник: https://golangmaster.ru/courses/asynq/lessons/05-otlozhennye-zadachi/*

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

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

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

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

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

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

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

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

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

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

> **Напоминание ушло — а встреча отменилась.** Классика поставленных на потом задач: забыть про отмену. Что сломалось бы: пользователи получают напоминания о приёмах, которых нет, доверие к рассылкам падает, а саппорт называет вас спамерами. Лечение из кейса выше: хендлер обязан проверять актуальность перед отправкой, return nil для неактуальных — и никакого «удалить из расписания» через клиент: этой операции нет.

> **Таймзона — аргумент, а не надежда.** Полночь сервера и полночь клиента живут в разных местах. time.Now() в контейнере — почти всегда UTC; time.Date без таймзоны возьмёт локальную и подарит отчёт в неожиданный час. Явный LoadLocation в конфиге и .In(loc) на каждом расчёте времени снимают вопрос целиком; для Alpine-контейнеров не забудьте про пакет tzdata.

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

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

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

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

- Ровно через 30 минут, независимо от воркеров
- ✅ Когда истечёт срок и живой воркер заберёт её из очереди
- Когда Redis решит, что пора
- Через 30 минут после старта воркера

Истечение срока переводит задачу из scheduled в pending; active она станет, когда её возьмёт свободный воркер. На здоровом сервисе задержка сверх срока — секунды, при остановленных воркерах — до их возвращения.

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

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

- Задача потеряется навсегда
- ✅ Выполнится, как только воркеры вернутся
- Redis выполнит её сам
- Перейдёт в архив как просроченная

Расписание живёт в Redis, а не в памяти воркеров: вернувшиеся воркеры найдут созревшую задачу и выполнят её. Просрочка не карается — это отличие очереди от таймеров в памяти процесса, которые умирают вместе с процессом.

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

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

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

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

- Ту же TaskInfo: вторая постановка игнорируется
- ✅ Ошибку ErrTaskIDConflict: первая задача ещё жива в расписании
- Обе задачи поставятся
- Вторая перезапишет первую

Scheduled-задача — живая задача, и её собственный ID защищён так же, как у pending. Повторная постановка вернёт ErrTaskIDConflict; это рабочая защита от двойных напоминаний, о которой договорились в четвёртом уроке.

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

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

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

- Ошибка: 32 декабря не существует
- ✅ 1 января 2026, 00:00 — time.Date нормализует переполнение
- 31 декабря 2025, 00:00
- Паника при создании времени

time.Date складывает компоненты и нормализует переполнения: день 32 в декабре становится 1 января следующего года. Именно поэтому код «конца года» не требует особых случаев — арифметика времени в Go безопасна по построению.

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

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

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

Подсказка: Задержку задаёт ProcessIn с duration от time, идентификатор — TaskID.

Правильные ответы в пропусках: `ProcessIn`, `Minute`, `TaskID`

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

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

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

1. `loc, err := time.LoadLocation("Europe/Moscow")`
2. `now := time.Now().In(loc)`
3. `when := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, loc)`
4. `payload, err := json.Marshal(tasks.ReportPayload{Day: when.Format("2006-01-02")})`
5. `task := asynq.NewTask(tasks.TypeDailyReport, payload)`
6. `info, err := client.Enqueue(task, asynq.ProcessAt(when))`
7. `log.Printf("отчёт запланирован: %v", info.NextProcessAt)`

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

- **ProcessIn** — Опция постановки: запуск не раньше заданной длительности от момента постановки. Задача переходит в scheduled.
- **ProcessAt** — Опция постановки: запуск не раньше заданного time.Time. Эквивалент ProcessIn с отсчётом от текущего момента.
- **Scheduled** — Состояние отложенной задачи: живёт в сортированном множестве Redis с баллом-временем запуска и переживает рестарты.
- **NextProcessAt** — Поле TaskInfo: расчётное время запуска. Полезно логировать при постановке и сверять с фактом при разборе.
- **time.Date** — Конструктор времени, нормализующий переполнения: день 32 декабря станет 1 января. Основа «концов года» без особых случаев.
- **Отмена напоминания** — Клиент не умеет удалять задачи из расписания: актуальность проверяет хендлер или отменяет Inspector по ID.

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

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

- 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, а не в проде.

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

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

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

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

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

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

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