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

0057. Единый per-account gate WB-тяжёлых джоб, alreadyRunning и прогресс в status poll

Копия ADR из репозитория: backend/docs/adr/0057-per-account-wb-job-gate.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-09-28
  • Связанные задачи: P0 из frontend/docs/BACKEND-NEEDS.md (mutex + alreadyRunning); уточняет ADR-0040, ADR-0041, ADR-0048, ADR-0051

Контекст и проблема​

После OP-214/217/218/220 импорт оцифровки устойчивее к 429, но оставались дыры:

  • Нет единого gate на кабинет. Приоритет user-импорта (OP-214) и cross-tier anti-overlap (OP-217) работали только внутри очереди digitization-sync. autobidder-sync-user, плановые rnp-ads-sync (fullstats) и rnp-finance-sync (отчёт реализации) шли параллельно с импортом того же кабинета. Лимиты WB считаются «на аккаунт продавца», наш token-bucket их лишь приближает (ADR-0048) — параллельные джобы получали реальные 429 и выжигали попытки.
  • Повторный POST мог плодить вторую джобу: дедуп импорта был по «кабинет + период», запрос другого периода ставил вторую джобу.
  • ImportJobDto / AutobidderSyncJobDto — только { jobId }, без alreadyRunning (в inventory sync он уже был).
  • Статус висел pending 15–26 минут до терминала (после OP-220 паузы стали длиннее) — UI не мог объяснить, чего ждёт задача.
  • autobidder-sync-user ретраился по 30 c — короче минутных окон WB.

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

  1. Одна общая очередь на все WB-джобы с группировкой по кабинету (BullMQ Pro groups). Плюс: сериализация «из коробки». Минус: платная функция, переезд семи очередей, ломает приоритеты и concurrency (ADR-0040).
  2. Проверки «есть ли живая джоба в соседних очередях» в фан-аутах (как UserImportActivityService). Минус: снимок устаревает к моменту старта, не защищает user-джобы друг от друга, требует знания всех очередей в каждом фан-ауте.
  3. Lease в Redis на кабинет, захватываемый в обёртке процессора. Плюс: одна точка (WbRateLimitRescheduleService.run, ADR-0051) уже оборачивает все длинные синки; ожидание уходит в delayed без траты слота и попыток. Минус: нужен TTL/heartbeat и правила приоритета.

Решение​

Вариант 3.

Gate (WbAccountJobGate)​

  • Ключ wb:gate:{accountId}, значение — владелец <очередь>:<jobId>, TTL 90 c с heartbeat каждые 30 c. Падение процесса не оставляет вечный замок. Повторный запуск той же джобы (перенос, ретрай) реентерабелен.
  • Захват — Lua-скрипт, зеркалящий чистую функцию evaluateGateAcquire (контракт «TS-эталон + Lua-зеркало», как у token-bucket в ADR-0041).
  • Занят → джоба переносится в delayed на 15 c (moveToDelayed, попытка не расходуется), фаза прогресса waiting-account.
  • Приоритет user. Заблокированная user-джоба ставит отметку wb:gate:{accountId}:wanted (TTL 60 c, продлевается при каждом опросе). Пока отметка есть, плановая джоба не займёт освободившийся gate, а плановый владелец отдаёт gate на ближайшей паузе лимита WB. Активную плановую работу не прерываем — это был бы тот же «ложный failed».
  • Перенос по лимиту WB (ADR-0051): владелец продлевает lease на паузу + 60 c, чтобы соседняя джоба не вклинилась в те же окна посреди прогона.
  • Плановые джобы не копят хвост: ждут gate не дольше maxWaitMs от постановки, затем завершаются { skipped: true, reason: 'account-busy' } — следующий тик подхватит кабинет. ads — 5 мин (каденция), инкремент фидов — его интервал, бэкфилл — 3 ч, финансы РНП — 6 ч.

Участники gate: import-user-feeds, import-user-report, autobidder-sync-user (user); sync-account оцифровки (оба яруса), rnp:ads-sync-account, rnp:finance-sync-account (scheduled). Оркестраторы фан-аутов в WB не ходят и в gate не участвуют. inventory-sync, rnp-sections-sync, catalog-sync — другие бакеты WB; при необходимости подключаются одной строкой gate: {…}.

Повторный POST → alreadyRunning​

  • Импорт оцифровки: если по кабинету жива джоба того же типа любого периода — возвращаем её jobId и alreadyRunning: true, вторая не ставится. Фиды и отчёт — разные типы, их сериализует gate.
  • Автобиддер: дедуп уже был по кабинету; добавлен флаг.
  • alreadyRunning — обязательное поле ответа 202 (false для новой задачи).

Прогресс в status poll​

Обёртка процессора пишет job.progress = { phase, resumeAt?, reason? }: running в начале, waiting-account при занятом gate, rate-limited при переносе по лимиту, retrying при падении не последней попытки (с моментом следующей по формуле backoff BullMQ и признаком 429). Status-эндпоинты отдают progress: { phase, message, resumeAt, attempt, maxAttempts }, где message — готовый русский текст («Wildberries ответил «слишком много запросов» (429) — повторим (попытка 3 из 5) через ~2 мин»). Поле state не менялось — обратная совместимость для FE.

Длинный backoff на 429 для автобиддера​

autobidder-sync-user ставится с attempts: 5, exp-backoff от 60 c — та же минутная лестница, что у user-импорта (ADR-0048).

Последствия​

  • Положительные:
    • по кабинету одновременно работает одна WB-тяжёлая джоба — ручной импорт и ads/fullstats больше не выжигают окна друг друга;
    • user-джобы не стоят за плановыми дольше одного «отрезка» плановой работы;
    • повторный POST идемпотентен и честно сообщает alreadyRunning;
    • UI показывает, чего ждёт задача, вместо вечного pending.
  • Отрицательные / компромиссы:
    • кабинет с долгим user-импортом пропускает несколько тиков ads-синка РНП (свежесть рекламной статистики РНП временно ниже);
    • ожидание gate — опрос раз в 15 c (два обращения к Redis на джобу);
    • findInFlight сканирует in-flight наборы очереди оцифровки на каждый POST (десятки джоб — как и фан-ауты UserImportActivityService).
  • Дальше: при появлении метрики конфликтов (optima_wb_rate_limit_reschedules_total{scope="account-gate"}) решить, подключать ли к gate inventory-sync и catalog-sync.