← отчёты · спецификация · очередь III
| Статус | ЧЕРНОВИК — на утверждении владельца |
| Версия | 1.0 · 2026-08-27 |
| Автор | Claude (агент очередей I–II) |
| Предшественники | docs/plans/queue-2-perf-handoff.md (закрыт 27.08), отчёт https://salam0nn.ru/artifacts/otchet-2.html |
| База кода | ветка full-update, коммит f781c537 (обе очереди в проде) |
| Стандарт требований | RFC 2119: ОБЯЗАН = MUST, НЕ ДОЛЖЕН = MUST NOT, СЛЕДУЕТ = SHOULD, МОЖЕТ = MAY. Слова строчными — обычный язык без нормативной силы. |
Структура документа следует практике design docs Google (контекст → цели/не-цели → дизайн с компромиссами → рассмотренные альтернативы → сквозные вопросы) и IEEE-подходу к измеримым критериям приёмки. Каждый эпик самодостаточен: его можно вести отдельным заходом и отдельно принимать.
Три эпохи одного нагрузочного теста (p95 полного визита гостя, 3 запроса + пауза 20 с):
| Гостей | до правок | очередь I (26.08) | очередь II (27.08) |
|---|---|---|---|
| 100 | 304 мс | 190 мс | 28 мс |
| 400 | 24 619 мс (отказ) | 539 мс | 32 мс |
| 800 | — | 19 234 мс (перегруз) | 42 мс |
На 800 гостях backend занят на 81 % из 200 доступных, Postgres — на 11 %. Гостевой путь и первый экран авторизованного больше не ограничивают систему.
Очереди I–II — настройка и точечные правки. Оставшиеся проблемы не чинятся правкой строки: они в форме данных и в форме конкурентности.
| # | Проблема | Мера сегодня | Природа |
|---|---|---|---|
| Э1 | Каждая выдача собирает карточку из ~150 колонок parsed_cars заново | компакт-путь: 2 SQL и ~230 мкс сериализации на карточку; полный (модалка/агент): + распаковка TOAST | форма данных |
| Э2 | Живой поиск: ≤ 5 сессий на весь сайт, слот занят всю сессию | MAX_CONCURRENT_SEARCHES=5 в .env; медианы сессий: DongCheDi 28 с, Che168 92 с (p95 526 с) → 2–10 поисков/мин на сайт | конкурентность |
| Э3 | car_configurations = 4460 МБ при 23 588 строках (~190 КБ/строка) — 53 % базы (8354 МБ) | pglz TOAST, PG 16.11 | форма данных |
| Э4 | Диск не SSD: 102 МБ/с, 329 IOPS, 3 мс случайное чтение | случайное чтение оплачивает страничный кэш ОС | инфраструктура |
| Э5 | Посторонний проект horeca делит с продом 4 vCPU / 7,75 ГБ | сейчас ~450 МБ RAM, CPU в простое <1 % — но неучтён в планировании ёмкости | инфраструктура |
| ID | Цель | Метрика приёмки |
|---|---|---|
| G1 | Выдача любой ленты — чтение готового, а не сборка | p95 сервисного времени страницы «Из БД» (30 карточек) ≤ 5 мс; полная карточка (модалка) ≤ 15 мс; 0 обращений к отложенным TOAST-колонкам на списочном пути (сторож test_list_query_budget.py остаётся зелёным) |
| G2 | Живой поиск не упирается в 5 сессий | ≥ 12 одновременных сессий на текущем железе без деградации деталей; шестой пользователь не ждёт в очереди при свободной памяти |
| G3 | База помещается в кэш | car_configurations ≤ 900 МБ (−80 %); суммарный размер БД ≤ 4,5 ГБ |
| G4 | Диск перестаёт быть штрафом | случайное чтение ≤ 0,3 мс (SSD/NVMe); random_page_cost можно честно поднять после перезамера 8 горячих планов |
| G5 | Ёмкость сервера предсказуема | на машине только контейнеры auto-parser + observability; горизонт роста посчитан от 100 % железа |
PlatformAdapter/ScraperRegistry.Общая идея очереди III — закончить сдвиг «считать при записи, читать готовое», начатый деньгами (ADR 0015) и снимками (очередь II), доведя его до единицы данных — карточки; и убрать два внешних ограничителя (диск, соседний проект), чтобы ёмкость считалась от честного железа.
ЗАПИСЬ (парсер, переводы, фото, модерация)
│
▼
parsed_cars (150 колонок, источник истины)
│ той же транзакцией / outbox
▼
┌─ card_projections ─────────────────────┐
│ card_feed JSONB (~1,4 КБ, лента) │ Э1: считаем один раз при записи
│ card_full JSONB (~7–38 КБ, модалка) │
│ rev, built_at │
└────────────┬───────────────────────────┘
│ SELECT по PK / батч IN (...)
▼
ЧТЕНИЕ (ленты, модалка, снимки витрины/популярного, агент)
Порядок эпиков выбран по зависимостям и риску (см. §10): Э5 → Э4 → Э3 идут первыми (инфраструктура, низкий риск кода, освобождают диск и память под Э1), затем Э2 (включение готового флага), затем Э1 (самый большой объём кода).
serialize_car() (services/car_card.py) на каждый показ строит словарь из ~150 колонок ORM: компактный — ~230 мкс/карточку чистого CPU, полный — плюс json.loads четырёх TOAST-полей (inspection_report_ru, options_*, raw_json у Avito). Очередь II спрятала эту цену за снимки для двух лент, но каждая НЕ снимочная поверхность платит её живьём: страница «Из БД», избранное, история, алерты, similar/analogs, ленты агента, Developer API. Класс проблем Н-04/Н-05/Н-12 возвращается с каждой новой поверхностью — сторожа-тесты ловят регресс, но не убирают причину.
parsed_cars.serialize_car/to_feed_card; материализация вызывает его, а не дублирует. (Урок очереди II: два пути построения разъезжаются — см. историю гейта витрины.)parsed_cars; полная пересборка всего фонда ОБЯЗАНА быть идемпотентной операцией, выполнимой на живом проде (батчами, с лимитом скорости).is_favorite, carrier_package_rub, бейджи «Новое», overlay владельца tazzz) НЕ ДОЛЖНЫ попадать в проекцию — они остаются overlay-ями на чтении, как сейчас.CARD_PROJECTION (off → старый путь) без миграции данных назад.Хранение. Отдельная таблица (не колонки в parsed_cars — иначе каждый UPDATE проекции раздувает и без того широкую горячую строку и её TOAST):
CREATE TABLE card_projections (
car_id bigint PRIMARY KEY REFERENCES parsed_cars(id) ON DELETE CASCADE,
rev integer NOT NULL DEFAULT 1,
card_feed jsonb NOT NULL, -- контракт FEED_CARD_FIELDS (~1,4 КБ)
card_full jsonb NOT NULL, -- контракт serialize_car(compact=False)
built_at timestamptz NOT NULL DEFAULT now()
);
JSONB, а не TEXT: ленты отдают массив карточек — jsonb конкатенируется на стороне SQL (jsonb_agg) без раскодирования в Python, и остаётся путь к отдаче готовых байтов, как у bootstrap-снимка. ON DELETE CASCADE безопасен: parsed_cars не удаляется строками (инвариант 1), каскад — страховка от ручных операций.
Сборка. Новый модуль services/card_projection.py: build_for_car(session, car) -> None — вызывает боевые serialize_car(compact=False) и to_feed_card, применяет no-foreign гард (T1.6), UPSERT по car_id с rev = rev + 1. Единственная точка записи проекции (инвариант в духе «проекцию user_listings пишет только user_listing_service»).
Инвалидация. Полный список писателей parsed_cars (проверен grep-ом по коду, Приложение A) делится на два класса:
| Класс | Писатели | Механизм |
|---|---|---|
| Синхронный (той же транзакцией) | parser_repo.add_parsed_car (создание/репарс), user_listing_service._project (модерация/публикация), session_persist | прямой вызов build_for_car перед commit — как уже делается с parsed_car_body_class («same-TX», инвариант 4) |
| Асинхронный (outbox → beat ≤ 60 с) | translation batch (tasks/parser_tasks.translate_batch), horsepower_estimator, image_tasks (images_local_json), no_foreign_writeback, alias_store (комплектации), encar_region write-back города (db_results:1550), body_class_tasks | писатель кладёт car_id в card_rebuild_outbox (таблица, той же TX — по образцу alert_match_outbox, инвариант 6); beat-задача drain_card_rebuild_outbox раз в минуту пересобирает батчем |
Outbox, а не Redis-очередь и не триггеры БД: переживает рестарты (в отличие от Redis-списка), не прячет логику в SQL (в отличие от триггеров), уже отработан в проекте радаром.
Чтение. select_db_cars_page и списочные пути заменяют serialize_car(compact=True) на SELECT card_feed FROM card_projections WHERE car_id = ANY(...); промах по проекции (строка ещё не собрана) — фолбэк на живую сборку с постановкой в outbox (self-healing, как с фото). GET /cars/<id> отдаёт card_full + живые overlay-и (T1.5). Снимки витрины/популярного продолжают работать поверх — их сборка дешевеет с ~1–4 с до сотен миллисекунд.
Осознанные изменения ответа. Ровно два, оба уже случались в очереди II и приняты: (а) поля, которые сегодня вычисляются на лету и могут «дозреть» между сборками (свежий перевод, доехавшее фото), будут отставать до 60 с; (б) cache_age_days/data_freshness считаются при сборке — допустимая погрешность до минуты. Всё остальное ОБЯЗАНО совпадать с сегодняшним ответом байт-в-байт; контракт стерегут test_feed_card.py и новый оракул сравнения (см. §4.6).
| Альтернатива | Почему нет |
|---|---|
Колонка card_json прямо в parsed_cars | каждый пересчёт раздувает горячую широкую строку; HOT-update невозможен (меняется TOAST) → распухание таблицы, по которой ходит парсер |
| Redis как хранилище карточек | карточка обязана переживать рестарт и вытеснение; полный фонд ~480 К × ср. 9 КБ ≈ 4 ГБ — не влезает в RAM рядом с процессами; Redis уже занят сессиями/снимками |
| Триггеры Postgres на UPDATE | сборка карточки требует Python (переводы, канон галереи, no-foreign) — из триггера не позвать; NOTIFY-мост хрупче outbox |
| Генерируемые (GENERATED) колонки | то же ограничение: выражение не может звать сериализатор |
| Оставить как есть + расширять снимки | каждая новая поверхность = новый снимок и новый beat; класс проблемы не закрывается (уже 3 снимка) |
card_projection.py + outbox + beat-дренаж; фоновый бэкфилл всего фонда батчами по 500 с паузами (на живом проде, ~480 К строк — часы). Флаг CARD_PROJECTION=off: пишем, не читаем. Метрика: покрытие фонда (COUNT(card_projections)/COUNT(parsed_cars eligible)), лаг outbox.CARD_PROJECTION=shadow — читаем оба пути, сравниваем (нормализованный diff, логируем расхождения), отдаём старый. Выход из фазы: ≥ 99,9 % совпадений за 48 ч, расхождения объяснены списком §4.3.CARD_PROJECTION=on для страницы «Из БД» → избранное/алерты/similar → снимки → GET /cars/<id>. По одной поверхности за выкатку.on.Откат на любой фазе — флаг в off; таблица и outbox остаются (безвредны).
test_card_projection_parity.py — на sqlite-фонде строит проекцию и сравнивает с живой сборкой по всем платформам (болванки всех 8 площадок уже есть в test_feed_card/test_classic_db_page — переиспользовать).car_id в outbox (async) — параметризованный тест по списку писателей; новый писатель без записи в список ОБЯЗАН ронять тест.test_list_query_budget.py дополняется: списочный путь при on делает ровно 1 SQL к card_projections (+1 EXISTS пагинации).prod-measure-without-mutating до выкатки, повторный după.| Риск | Вероятность | Митигация |
|---|---|---|
| Пропущенный писатель → вечно чёрствая карточка | средняя | Приложение A собрано grep-ом, оракул инвалидации, self-healing фолбэк на промахе + недельный reconcile-скан (built_at < parsed_cars.updated_at) |
| Бэкфилл давит прод | средняя | батчи ≤ 500, statement_timeout, пауза по load average — как у refresh_body_class |
| Разъезд контрактов feed/full с фронтом | низкая | канон один (T1.2), сторожа остаются |
| Рост диска (+~4,5 ГБ проекций) | высокая | выполнять ПОСЛЕ Э3+Э4: −3,5 ГБ конфигураций и SSD с запасом покрывают |
MAX_CONCURRENT_SEARCHES=5 в .env прода перекрывает всю динамику capacity_service.max_concurrent_count(). Причина потолка — НЕ память и не CPU (во время поиска оба почти свободны), а то, что диспетчер деталей parse_details_progressive держит 1 слот очереди detail_fetch на всю жизнь сессии: слотов — по числу ядер, минус резерв → 5. При медиане сессии 28–92 с и p95 до 526 с это 2–10 поисков/мин на весь сайт; шестой пользователь стоит в очереди.
В коде очереди деталей уже существует невключённый режим fan-out (DETAIL_DISPATCH_MODE=fanout + GLOBAL_DETAIL_POOL=on, дефолт legacy): сессия не пиннит слот; детали идут короткими задачами-на-машину (services/session/detail_work.py: _pump_session → process_single_car_detail), admission v2 гейтит по сумме весов площадок (DEFAULT_PLATFORM_COST_MB: avito 80, encar/che168 120, dongchedi 250 МБ) через Lua + живой guard MemAvailable; скрейперы encar/mobile_de уже несут ветки под глобальный пул. При fan-out max_concurrent_count() сам становится RAM-производным (total_capacity_mb() // min(cost)). Дизайн-работа сделана; спецификация этого эпика — контролируемое включение.
MAX_CONCURRENT_SEARCHES СЛЕДУЕТ снять (=0) только после того, как fan-out прожил неделю на всех площадках; до того поднимать вручную (5 → 8 → 12) с замером деталей.test_db_page_prefetch-семейство и ручной сценарий классики.search_sessions истории — запрос уже есть в отчёте Н-09).MAX_CONCURRENT_SEARCHES — нет: при legacy-диспетчере слоты деталей физически кончаются, шестая сессия голодает по деталям.G2: 12 параллельных сессий на стенде из смеси che168+encar+avito, ни одна не в очереди, p95 «до первой карточки» не хуже legacy-базлайна, память в пределах guard. Отдельно подтверждено: kill воркера деталей посреди сессии → сессия доживает (насос переотправляет), инвариант «сессии отменяемы» (Redis job_id) сохранён.
car_configurations (database/models/parser.py:370): 4460 МБ / 23 588 строк = ~190 КБ на строку, 53 % всей БД. Четыре TEXT-поля: config_json, properties_json + их _ru-копии. Строки Che168 в той же таблице ~10 КБ — разница ×20 говорит, что для DongCheDi сохраняется сырой ответ API целиком. 23 116 из 23 588 строк живые (parsed_cars.config_car_id) — удалить нельзя. Таблица конкурирует за страничный кэш с горячей parsed_cars (3389 МБ) — прямой вклад в штраф медленного диска.
detail_fetcher_service, database_service, vehicle_database, che168 config_parser, dongchedi scraper, distributed_parser_tasks) ОБЯЗАНЫ быть проинвентаризованы по фактически читаемым ключам JSON ДО любого сжатия схемы: сохранить нужно ровно читаемое множество + ключи, нужные фронтовой модалке ТТХ.ALTER TABLE ... SET COMPRESSION lz4 на 4 колонках + default_toast_compression=lz4 в конфиг PG; сейчас pglz, PG 16.11 — lz4 доступен). Пережатие существующих строк — VACUUM FULL car_configurations в окно (таблица не в горячем пути выдачи; займёт минуты, блокирует только себя).pg_statio_user_tables).lz4 на JSON-тексте даёт типично 2–4× (замерить на копии 100 строк до решения); белый список — остальное до цели G3 (≤ 900 МБ). Главный риск — выбросить ключ, который читает редкий путь (модалка ТТХ китайцев, комплектации-канонизатор): закрывается T3.1 + оракул: снапшот ответов GET /cars/<id> для 20 эталонных dongchedi-машин до/после — побайтово равны.
Диск прода (80 ГБ, занято 44) — 102 МБ/с, 329 IOPS, 3 мс случайное чтение. Проверено в очереди I: тюнить random_page_cost под него НЕЛЬЗЯ (план счётчика «бренд+бюджет» переворачивается 9,5 → 47 мс) — спасает только страничный кэш. SSD убирает штраф, а не маскирует его. Это единственный эпик, требующий действий владельца в панели хостинга (Timeweb Cloud, судя по registry-зеркалу) — сам перенос данных автоматизируем.
rsync данных при работающем проде, замерить длительность догоняющего rsync), затем боевое окно.rsync --delete docker-томов и /opt/auto-parser → переключение точки монтирования → старт. Ожидаемо ≤ 15 мин (догоняющий rsync копирует только дельту). Веб-даунтайм — только на рестарт контейнеров (минуты).pg_dump -Fc) на НЕ участвующий в переносе носитель.random_page_cost = 1.1 → 1.0–1.1 (по фактическим планам, не по теории).Новый сервер целиком (SSD + больше RAM) с миграцией DNS — рассмотреть, если хостинг не умеет менять диск на месте: цена сопоставима, бонусом решается Э5 (horeca просто не переезжает). Решение за владельцем — см. §11, вопрос B.
/root/horeca, 6 контейнеров (web, backend, worker, postgres, redis, minio) на машине прода tazzz. Сейчас скромен (~450 МБ RAM, CPU < 1 % в простое), но: не учтён в admission-бюджете capacity_service (ёмкость считается от MemAvailable — всплеск horeca «съедает» слоты поиска незаметно), диск/IOPS общие, и любой его инцидент — инцидент прода tazzz.
docker compose down → архив томов → новый хост → docker compose up; DNS горячо. ОБЯЗАН быть согласован с пользователями horeca (не наша аудитория — окно назначает владелец).total_capacity_mb() и снятие ручного потолка резерва, если он занижен под соседа.card_full в проекции НЕ ДОЛЖЕН содержать site_url/dealer_url (redact-контракт _SOURCE_URL_KEYS применяется при сборке — двойная защита поверх выходного redact). Новые таблицы без пользовательского ввода.CARD_PROJECTION, DETAIL_DISPATCH_MODE, GLOBAL_DETAIL_POOL) читаются на старте процесса → включение = пересоздание контейнеров, штатная выкатка. Ни один этап не требует одновременной смены фронта и бэка.queue-2-perf-handoff.md §3 (он проверен дважды; nginx — только force-recreate). Каждая фаза Э1 — отдельный коммит с зелёными сторожами.docs/agent-knowledge/ (04-database, 05-celery, 13-invariants) и добавляет ADR: Э1 — «карточка как проекция», Э2 — «включение fan-out», Э3 — «диета конфигураций».Э5 (вынос horeca / решение «новый сервер»)
└─► Э4 (SSD) ──► Э3 (lz4 + диета) ──► Э1 (проекция карточки, Ф1–Ф4)
Э2 (fan-out) — независим от цепочки данных; стартует параллельно после Э5
| Эпик | Оценка чистой работы | Календарно (с наблюдением) | Ключевой риск |
|---|---|---|---|
| Э5 horeca | 0,5–1 день | по окну владельца | забытые тома/кроны |
| Э4 SSD | 1 день + репетиция | 2–3 дня | окно записи |
| Э3 конфигурации | 1–2 дня | 3–4 дня | потерянный ключ JSON |
| Э2 fan-out | 1 день правок + прогоны | 1,5–2 недели (канарейки) | антибот при росте параллелизма |
| Э1 проекция | 4–6 дней | 3–4 недели (теневая фаза 48 ч, пофазный rollout) | пропущенный писатель |
Оценки — прогноз; правило очередей I–II сохраняется: каждое утверждение о выигрыше подтверждается замером до/после, «очевидные» улучшения без замера не выкатываются (в очереди I три таких оказались ухудшениями).
| # | Вопрос | Блокирует |
|---|---|---|
| A | Окно на VACUUM FULL car_configurations и стоп-запись Э4 (ночь?) | Э3, Э4 |
| B | Менять диск на месте или новый сервер (тогда Э5 отпадает)? Тариф? | Э4, Э5 |
| C | Куда переносить horeca, кто его пользователи? | Э5 (если не B-новый-сервер) |
| D | Пул тестовых учёток с JWT для приёмки G1/G2 сквозь HTTP | приёмка Э1, Э2 |
| E | Биллинг GitHub / self-hosted runner — чинить до Э1 (много выкаток)? | комфорт всех эпиков |
Собрано grep-ом по full-update@f781c537; новый писатель обязан попасть сюда и в оракул инвалидации.
| Писатель | Что меняет | Класс (§4.3) |
|---|---|---|
database/repositories/parser_repo.py (add_parsed_car) | создание, репарс: цены, parsed_at, статусы | синхронный |
services/user_listing_service.py (_project, _unpublish) | проекция объявлений tazzz, статусы | синхронный |
services/session/session_persist.py | live-детали → БД | синхронный |
tasks/parser_tasks.translate_batch → translation_service | *_ru-поля | outbox |
services/horsepower_estimator.py | engine_power_hp | outbox |
tasks/image_tasks.py | images_local_json | outbox |
services/text/no_foreign_writeback.py | переводы write-back | outbox |
services/complectation_canon/alias_store.py | complectation_canon* | outbox |
services/agent/db_results.py:1556 (ленивая миграция города Encar) | city/city_cn | outbox |
tasks/body_class_tasks.py / services/body_class_sql.py | производный класс кузова (side-таблица; карточку меняет через body_class) | outbox |
services/parser_manager.py, tasks/distributed_parser_tasks.py | статусы, детали | синхронный (идут через repo) |
services/database_service.py, database/repositories/vehicle_repo.py | админ/служебные правки | outbox |
docs/plans/queue-2-perf-handoff.md (формат «грабли + замеры», доказавший себя в очередях I–II), docs/adr/*, docs/agent-knowledge/13-invariants.md