0051. Перенос джобы в delayed вместо блокирующего ожидания лимита WB
Копия ADR из репозитория:
backend/docs/adr/0051-rate-limit-job-reschedule.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-09-08
- Связанные задачи: OP-219 (F5 · фоновые задачи); уточняет ADR-0006, ADR-0040, ADR-0041; опирается на OP-193/OP-215 (resume-курсоры)
Контекст и проблема
WbRateLimiter.acquire ждал открытия окна лимита обычным setTimeout прямо
в процессе воркера. На бакетах со скоростью 1 запрос/окно (фиды statistics,
OP-192) ожидание доходит до эффективного потолка ~125 с (два окна + запас,
ADR-0041 / OP-209).
Всё это время слот очереди занят ничем:
- при
concurrency = 1встаёт вся очередь: кабинет, попавший в закрытое окно, блокирует обработку всех остальных кабинетов; - при
concurrency = N(OP-216 далSYNC_QUEUE_CONCURRENCY, дефолт 5) слоты всё равно выгорают на сон — несколько кабинетов, одновременно упёршихся в свои бакеты, съедают пул, и честной параллели кабинетов не получается.
Ожидание при этом не является ошибкой: данные никуда не делись, окно откроется. Правильная реакция — вернуть управление очереди, а не держать исполнителя.
Рассмотренные варианты
-
Оставить сон, поднять concurrency. Плюс: ничего не менять. Минус: лечит симптом — при росте числа кабинетов пул выгорает так же, только позже; сон остаётся оплаченным слотом.
-
Бросать ошибку и полагаться на retry BullMQ. Плюс: минимум кода. Минус: расходует
attemptsMade, поэтому длинное окно исчерпывает бюджет попыток и джоба уходит вfailedбез настоящего сбоя; backoff ретрая не совпадает с реальным остатком окна. -
Переносить джобу в
delayedна время ожидания (Job.moveToDelayed+DelayedError). Плюс: слот освобождается немедленно, пауза равна фактическому остатку окна, попытка не расходуется (skipAttempt: true). Минус: нужен способ поднять «сколько ждать» из глубины стека наверх, к процессору, у которого есть джоба и её токен. -
Проверять квоту в процессоре до начала работы. Плюс: не трогает лимитер. Минус: ожидание возникает не на первой странице, а на любой середине многостраничного прогона — предварительная проверка его не ловит.
Решение
Выбран вариант 3.
Как ожидание доезжает наверх. Введён AsyncLocalStorage-контекст
(runWithRateLimitDeferral(thresholdMs, fn)). Внутри него acquire при
ожидании дольше порога бросает WbRateLimitDeferError(waitMs) вместо сна.
ALS выбран потому, что ожидание возникает в глубине стека
(процессор → сервис импорта → WbApiClient.request → acquire), и параметр
пришлось бы протаскивать через десятки сигнатур, включая сервисы, которым про
очереди знать незачем. Тот же приём уже используется для correlation-id (OP-44).
WbRateLimitDeferError намеренно не входит в семейство WbApiError: это
управляющий сигнал планировщика, а не сбой обращения к WB. Благодаря этому его
не подхватывает retry-политика клиента и не мапит GlobalExceptionFilter.
Порог — 5 секунд (DEFAULT_RATE_LIMIT_DEFER_THRESHOLD_MS). Перенос стоит
пары обращений к Redis и потери прогретого состояния прогона, поэтому короткие
паузы дешевле проспать на месте.
Неблокирующий примитив. WbRateLimiter.tryAcquire() делает ровно один
проход token-bucket и возвращает { acquired } | { acquired, waitMs } без сна
и исключений. acquire() построен поверх него — Lua-скрипт вызывается из
одного места, а acquire добавляет к нему только политику ожидания.
Точка применения. WbRateLimitRescheduleService.run({ queue, job, token })
оборачивает обработку в процессорах длинных синков: digitization-sync,
rnp-ads-sync, rnp-finance-sync, rnp-sections-sync, inventory-sync,
autobidder-sync. Короткие очереди (pricing-bulk, booking-slot-monitor)
не оборачиваются: их вызовы не многостраничные, а бронировщику важна
частота опроса, а не экономия слота.
Последствия
-
Положительные:
- воркер не блокируется ожиданиями дольше порога; пока один кабинет ждёт своё окно, очередь обрабатывает остальные;
attemptsMadeне растёт от переносов (moveToDelayedидёт соskipAttempt: true), поэтому бюджет ретраев остаётся на настоящие сбои;- пауза равна реальному остатку окна из Redis, а не угаданному backoff;
- появилась метрика
optima_wb_rate_limit_reschedules_total(labelsqueue,scope) — видно, какие бакеты перегружены.
-
Отрицательные / компромиссы:
- поведение лимитера теперь зависит от неявного (ALS) контекста. Смягчено тем, что контекст ставится ровно в одном месте — обёртке процессора, и покрыт тестами на протечку между параллельными обработками;
- на HTTP-путях ничего не изменилось (контекста нет) — там по-прежнему короткое ожидание либо локальный 429 по потолку. Это осознанно: у HTTP-запроса нет «потом», переносить его некуда;
- внутри контекста джобы перенос выигрывает у потолка: до потолка (и локального 429) дело просто не доходит. Для фоновой джобы это верно — прогон продолжится, тогда как 429 потерял бы его.
-
Что учтено:
- Anti-overlap не сломан. Джоба остаётся в очереди под тем же
jobId, поэтому дедупadd()продолжает отбрасывать дубли, а снимки «живых» джоб (UserImportActivityService, состоянияactive/waiting/delayed/prioritized) уже учитываютdelayed— фан-аут не поставит второй прогон по кабинету. - Прогресс не теряется. Перенос не меняет
job.data; курсоры прогона пишутся по ходу дела вjob.data.cursor(OP-193) либо вwb_sync_cursors(OP-215), поэтому повторный запуск продолжает с места переноса.
- Anti-overlap не сломан. Джоба остаётся в очереди под тем же
-
Дальше: OP-219 закрывает воркеры синков. Кандидаты на расширение —
catalog-syncи точечные пути автобиддера, если метрика покажет там заметное число ожиданий.