Содержание урока · 10 разделов
Ситуация из тикета: очередь стоит, в asynqmon одна задача report:build живёт в active уже двадцать минут. Внутри — HTTP-вызов к партнёрскому API без таймаута: тот молчит, хендлер ждёт, воркер занят. Asynq по умолчанию не разрешает задачам вечность: если не задать пределы, постановка сама назначит дедлайн контекста в тридцать минут, но для большинства задач это не лимит, а алиби. Нужен способ сказать: «эта обработка должна уложиться в две минуты» или «после девяти утра результат никому не нужен». Для этого существуют две опции постановки: Timeout с длительностью и Deadline с точным моментом. Работают обе одинаково честно: они не убивают хендлер, а ставят дедлайн на его контекст — дальше всё зависит от того, слушает ли ваш код отмену. В уроке: механика истечения, связь с ретраями и выбор значений, которые не бьются с бизнесом. Терминологию зафиксируем сразу: таймаут — про длительность попытки, дедлайн — про крайний момент; их часто путают, и путаница стоит ночных инцидентов, когда отчёт поставили на полночь и удивляются, почему он «сам собой» ретраится к обеду.
Asynq Timeout: сколько живёт задача#
Asynq Timeout — опция постановки: client.Enqueue(task, asynq.Timeout(10time.Minute)) обещает, что обработка уложится в десять минут. Спустя этот срок истекает дедлайн контекста хендлера: если ProcessTask ещё не вернулся, asynq засчитывает неудачу, и задача ретраится по обычной политике. Значение — любой duration: секунды для вебхуков, минуты для генерации PDF, часы для ночных выгрузок. Правило чтения простое: Timeout отвечает на вопрос «сколько живёт задача после того, как воркер её взял». Если задан только Deadline, таймаут-компонент не участвует, и обработку ограничивает сам дедлайн; если не задано ни то ни другое, постановка ставит дефолт — тридцать минут, о котором документация Enqueue говорит прямо: by default, max retry is set to 25 and timeout is set to 30 minutes. Тридцать минут — не забота, а молчаливое согласие: зависший вызов съест треть часа слота воркера прежде, чем кто-то заметит. Поле info.Timeout из TaskInfo возвращает то, что реально записалось в задачу: удобно логировать при постановке и сверять с тем, что вы думали о лимите. Ещё одна граница, которую Timeout не двигает: время ожидания в очереди. Задача с ProcessAt на полночь и Timeout(10time.Minute) будет ждать в scheduled хоть сутки — таймаут про исполнение, а не про ожидание; отсчёт начинается, когда воркер возьмёт задачу в руки.
Постановка с таймаутом
GOinfo, err := client.Enqueue(task,
asynq.Queue("reports"),
asynq.Timeout(10*time.Minute),
)
if err != nil {
log.Fatal(err)
}
log.Printf("task=%s timeout=%v", info.ID, info.Timeout)Постановка отличается одной опцией: Timeout(10*time.Minute) записывает предел в сообщение задачи. Если хендлер не уложится, asynq отменяет ожидание и засчитывает неудачу — задача уйдёт в retry по обычной политике. info.Timeout возвращает записанную длительность: логируйте её рядом с ID, чтобы при разборе инцидента видеть фактический лимит.
Дедлайн задач go: опция Deadline#
Дедлайн задач go — это опция Deadline: client.Enqueue(task, asynq.Deadline(time.Now().Add(24*time.Hour))) назначает крайний момент не от начала обработки, а абсолютный. Разница в точке отсчёта определяет сценарии. Timeout описывает длительность работы: «генерация отчёта занимает не больше десяти минут». Deadline описывает смысл результата: «отчёт нужен к девяти утра; сделанный в полдень, он бесполезен, сколько бы воркер ни старался». Если заданы обе опции, победит та, что наступит раньше: попытка может успеть за таймаут, но после дедлайна всё равно провалится. Точку дедлайна считают от бизнес-события: конец рабочего дня, закрытие регистрации, момент, когда купон сгорает. Ночному отчёту с лимитом «до начала рабочего дня» ставят Deadline на девять утра по таймзоне бизнеса — с теми же правилами LoadLocation, что и у ProcessAt из пятого урока. Поле info.Deadline возвращает записанный момент; в дашборде он виден в карточке задачи, и при разборе жалоб сразу ясно, работала ли задача на живом дедлайне или его проигнорировали. Полезно развести оси и с ProcessAt из пятого урока: ProcessAt отвечает за «когда начнём», Deadline — за «когда уже поздно начинать». Для «напомнить за сутки до дедлайна заказа» обе опции встречаются в одной постановке: старт считается от даты из базы, а Deadline — сама дата заказа, после которой напоминание стало бы вредным.
Таймаут и дедлайн вместе
GOloc, err := time.LoadLocation("Europe/Moscow")
if err != nil {
log.Fatal(err)
}
now := time.Now().In(loc)
usefulUntil := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, loc)
info, err := client.Enqueue(reportTask,
asynq.Timeout(30*time.Minute),
asynq.Deadline(usefulUntil),
)Обе опции вместе: таймаут ограничивает длительность одной попытки, дедлайн — момент, после которого результат никому не нужен. Если попытка начнётся перед девятью и не уложится, провалится она по дедлайну; а ретрай, назначенный уже после девяти, сгорит мгновенно — контекст к его старту истёк. Победа всегда за более ранним пределом.
Проверка: конфликт пределов
Задача поставлена с asynq.Timeout(30*time.Minute) и asynq.Deadline через пять минут. Какой предел остановит обработку?
Истечение времени: контекст, а не пуля#
Главное, что стоит понимать про механику: asynq не убивает хендлер. Обе опции превращаются в дедлайн контекста, с которым вызывается ProcessTask. Когда срок истёк, воркер перестаёт ждать результата: задача засчитывается неудачей с ошибкой context deadline exceeded и уходит в retry. Горутина хендлера при этом продолжает жить, пока не вернётся сама: HTTP-вызов без таймаута внутри всё равно доделает свою тихую работу, и результат её уже никому не нужен. Отсюда дисциплина: длинные операции обязаны слушать контекст. Внешние вызовы принимают ctx первым аргументом и обрывают соединение при отмене; циклы проверяют ctx.Err() или select по ctx.Done() на каждой итерации; вместо time.Sleep — таймер и select по тому же каналу. Общий шаблон: каждое долгое действие внутри ProcessTask либо само отменяется по контексту, либо явно проверяет его между шагами. Тогда «истёк таймаут» означает, что работа действительно прекратилась, а не что асинхронный призрак догоняет ретрай. Контекст asynq создаёт, а уважать его — работа хендлера. Текст ошибки при этом предсказуем: ctx.Err() вернёт context.DeadlineExceeded, и именно он ляжет в LastErr задачи — по нему истечение узнаётся в логах мгновенно, без раскопок.
История: задаче с Timeout(2*time.Minute) поручили вызов платёжного шлюза без контекста. Шлюз завис на сорок минут, asynq честно засчитал неудачу и запустил ретрай, а первая горутина всё ещё висела. Через час — шесть параллельных попыток одной задачи и шесть списаний у одного пользователя. Вывод: таймаут на постановке не отменяет вашу работу — он перестаёт её ждать. Каждый внешний вызов в хендлере обязан получать ctx, а списания — идемпотентность.
После истечения: ретрай по обычной политике#
Истечение — обычная ошибка в глазах очереди. Воркер получает context deadline exceeded, счётчик Retried растёт, паузу перед следующей попыткой считает формула из шестого урока — или ваша RetryDelayFunc, если вы её настроили. После исчерпания MaxRetry задача попадает в архив, как любая другая неудачная. Эта честность удобна: отдельный код «для задач, которые не успели» не нужен — работает вся привычная механика повторов и архива. Обратная сторона: задачам, которым повтор не поможет, истечение жжёт попытки зря. Зависший партнёрский API вернёт то же молчание и на второй, и на двадцать пятой попытке — уместнее SkipRetry или меньший MaxRetry на постановке, о чём говорит четырнадцатый урок. Показания смотреть просто: в списке retry-задач поле LastErr хранит текст последней ошибки, и если «context deadline exceeded» собирается у одного типа задач пачками, пересмотра требует лимит или зависимый сервис, а не ретраи. Планируйте и худший случай по слотам: MaxRetry(3) с Timeout(10*time.Minute) означает до четырёх попыток по десять минут — сорок минут воркерного времени на одну упорную задачу. Если такое число для типа задач неприемлемо, уменьшайте либо таймаут, либо число попыток, либо разбирайтесь, почему задача вообще не успевает.
Пределы задачи и их следствия#
| Предел | Что ограничивает | Что будет при превышении |
|---|---|---|
| asynq.Timeout(d) | длительность одной обработки | неудача с context deadline exceeded и ретрай |
| asynq.Deadline(t) | крайний момент обработки | неудача в момент дедлайна; попытка после него сгорает сразу |
| MaxRetry | число попыток | архив после исчерпания |
| 30 минут | умолчание, когда не задано ни то ни другое | неудача и ретрай, как у явного таймаута |
Где хендлер обязан слушать контекст#
- HTTP-вызовы: передавайте ctx в клиент — отмена закроет соединение.
- Запросы к базе: контекст в Query и Exec, чтобы ретрай не конкурировал с прошлой попыткой.
- Циклы и батчи: проверяйте ctx.Err() на каждой итерации.
- Ожидания: вместо time.Sleep — таймер и select по ctx.Done().
- Длинные вычисления: режьте на шаги с проверками между ними.
- Логи: фиксируйте, на каком шаге контекст истёк, — это половина диагностики.
Проверка: судьба горутины
Дедлайн контекста истёк, а хендлер ещё не вернулся. Что делает asynq?
Что выйдет из хендлера без проверки?
Прочитайте код и выберите, что он выведет:
func handleSlow(ctx context.Context, t *asynq.Task) error {
time.Sleep(5 * time.Second) // select по ctx.Done() забыли
return nil
}
// постановка: asynq.Timeout(2 * time.Second)Соберите постановку с пределами
Допишите опции: длительность обработки и абсолютный крайний момент.
Соберите постановку с бизнес-дедлайном
Расставьте строки: расчёт момента, задача, срез опций, постановка, лог.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Сколько ставить: выбор значений#
Значения выбирают от обратного: не «сколько даём», а «сколько нужно, чтобы успеть, и чем рискуем, дав больше». Для обёртки над внешним вызовом таймаут равен p99 здорового ответа плюс запас: если платёжный шлюз отвечает за двести миллисекунд, двум минутам нечего делать в опции — ставьте пять секунд и ретрай. Для задач-конвейеров меряйте реальную длительность по логам хендлера, а не на глаз. Ночным задачам вместо большого таймаута часто честнее Deadline «до начала рабочего дня»: попытка успеет, а после девяти работать уже незачем. Дедлайны от событий — купон горит в полночь, регистрация закрывается в пятницу — только через Deadline: таймаут тут бессилен. Отложенная постановка не меняет пределы: ProcessIn сдвигает старт, но отсчёт таймаута всё равно начинается, когда воркер взял задачу. И помните про очередь в целом: короткие таймауты не спасут её от тысяч зависших задач, если зависимый сервис умер целиком, — тут нужны пауза очереди и алерт, о которых говорит пятнадцатый урок. Практический приём: считайте таймаут от измерений, а не от ощущений — возьмите из логов или метрик девяносто девятый перцентиль длительности, умножьте на два-три запаса и округлите до человеческого числа. Для батчей внутри одной задачи складывайте шаги: таймаут «двести писем по сто миллисекунд» — это двадцать секунд, а не сто миллисекунд от одиночной отправки.
Если ретрай назначен уже после абсолютного Deadline, попытка провалится до запуска хендлера: asynq проверяет контекст перед стартом и видит истёкший дедлайн. Это фича: задача не тратит воркеры после того, как результат потерял смысл. Но следите за retry-списком — несколько мгновенных провалов подряд нормальны, после них задача честно уйдёт в архив. В том же ключе работает парная история: Deadline уже в прошлом при самой постановке — задача поставится, но первая попытка сгорит мгновенно; проверяйте время до дедлайна в постановщике, если постановки генерируются из данных.
Карточки: таймауты и дедлайны
Таймаут и дедлайн закрыты: Timeout ограничивает длительность попытки, Deadline — момент, после которого результат не нужен; оба работают через контекст хендлера и ретраятся по обычной политике. Мини-эксперимент: поставьте задачу с Timeout(2*time.Second) и хендлером, который спит пять секунд без проверки контекста, — в asynqmon она уйдёт в retry с context deadline exceeded, а лог покажет, что горутина доспала свои пять секунд; затем тот же хендлер с таймером и select по ctx.Done() — сравните логи, где горутина завершилась сразу. Привычку логировать info.Timeout и info.Deadline при постановке приклейте к постановщику раз и навсегда: при разборе инцидента эти две строки экономят час. Формат прежний: код запускается локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go без сети. Дальше по курсу: двенадцатый урок научит не ставить одну задачу дважды, шестой останется опорой при выборе числа попыток для задач с жёстким дедлайном, а ручную отмену зависшей задачи разберёт пятнадцатый.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Asynq Timeout задаёт длительность обработки, Deadline — абсолютный момент; при конфликте работает более ранний предел.
- Оба предела превращаются в дедлайн контекста хендлера: asynq перестаёт ждать, но не убивает горутину.
- Истечение — обычная ошибка: Retried растёт, паузу считает формула ретраев, после исчерпания попыток — архив.
- Без явных опций постановка ставит дедлайн контекста в тридцать минут.
- Хендлер обязан слушать контекст: вызовы с ctx, проверки в циклах, таймеры вместо Sleep.
- Попытка, начавшаяся после абсолютного Deadline, сгорает мгновенно до запуска хендлера.
- info.Timeout и info.Deadline из TaskInfo показывают записанные пределы — логируйте их при постановке.
- Таймаут не ограничивает ожидание в очереди: отсчёт начинается, когда воркер взял задачу.
- Худший случай по слотам — попытки умножить на таймаут: планируйте бюджет воркерного времени.
- Значения выбирают от p99 внешних вызовов и бизнес-смысла результата, а не «с запасом».
Домашнее задание
Возьмите хендлер из прошлых уроков и поставьте задачу с Timeout(3*time.Second) и Deadline на пять минут вперёд. Разбейте работу на шаги с проверкой ctx.Err() и ожиданием через таймер с select по ctx.Done(). Убедитесь по логам: при коротком таймауте задача уходит в retry с context deadline exceeded, а горутина завершается по контексту. Затем поставьте задачу с Deadline в прошлом и посмотрите, как попытка сгорает мгновенно.
Личные заметкиПусто
Вопросы и ответы по уроку
Что будет, если задать только Deadline без Timeout?
Ограничением станет сам дедлайн: контекст истечёт в назначенный момент, сколько бы задача ни работала. Таймаут-компонент при этом не добавляется: если дедлайн через сутки, обработка теоретически может длиться сутки — для долгих задач это способ сказать «живи до полуночи и ни минутой дольше». Если нужны и длительность, и крайний момент — задавайте обе опции: более ранний предел сработает сам.
Отменяет ли asynq операцию в базе при истечении таймаута?
Нет: asynq отменяет только контекст. Дальше всё зависит от вызова: запросы к базе с контекстом обычно снимаются, запущенные транзакции — нет. Поэтому каждый вызов внутри хендлера получает ctx явно, а тяжёлые транзакции режутся на шаги с проверками контекста между ними. Проверяйте, что ваш драйвер поддерживает отмену, прежде чем полагаться на неё.
Можно ли поменять таймаут у уже поставленной задачи?
У клиента такой опции нет: пределы записываются при постановке. Практичный путь — Inspector: задачу можно удалить и поставить заново с новыми пределами, а активную — отменить сигналом, после которого сработает обычный ретрай. Оба приёма разбирает пятнадцатый урок курса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once