Перейти к основному содержимому

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) слоты всё равно выгорают на сон — несколько кабинетов, одновременно упёршихся в свои бакеты, съедают пул, и честной параллели кабинетов не получается.

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

Рассмотренные варианты​

  1. Оставить сон, поднять concurrency. Плюс: ничего не менять. Минус: лечит симптом — при росте числа кабинетов пул выгорает так же, только позже; сон остаётся оплаченным слотом.

  2. Бросать ошибку и полагаться на retry BullMQ. Плюс: минимум кода. Минус: расходует attemptsMade, поэтому длинное окно исчерпывает бюджет попыток и джоба уходит в failed без настоящего сбоя; backoff ретрая не совпадает с реальным остатком окна.

  3. Переносить джобу в delayed на время ожидания (Job.moveToDelayed + DelayedError). Плюс: слот освобождается немедленно, пауза равна фактическому остатку окна, попытка не расходуется (skipAttempt: true). Минус: нужен способ поднять «сколько ждать» из глубины стека наверх, к процессору, у которого есть джоба и её токен.

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

Решение​

Выбран вариант 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 (labels queue, 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), поэтому повторный запуск продолжает с места переноса.
  • Дальше: OP-219 закрывает воркеры синков. Кандидаты на расширение — catalog-sync и точечные пути автобиддера, если метрика покажет там заметное число ожиданий.