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 он уже был).- Статус висел
pending15–26 минут до терминала (после OP-220 паузы стали длиннее) — UI не мог объяснить, чего ждёт задача. autobidder-sync-userретраился по 30 c — короче минутных окон WB.
Рассмотренные варианты
- Одна общая очередь на все WB-джобы с группировкой по кабинету (BullMQ Pro groups). Плюс: сериализация «из коробки». Минус: платная функция, переезд семи очередей, ломает приоритеты и concurrency (ADR-0040).
- Проверки «есть ли живая джоба в соседних очередях» в фан-аутах (как
UserImportActivityService). Минус: снимок устаревает к моменту старта, не защищает user-джобы друг от друга, требует знания всех очередей в каждом фан-ауте. - 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"}) решить, подключать ли к gateinventory-syncиcatalog-sync.