Очередь III · этап 1 · отчёт о выкатке · tazzz.ru

Карточка как проекция

Начата архитектурная очередь: карточка машины теперь собирается один раз при записи, а не на каждый показ. Фундамент (Э1 Ф1) и диета конфигураций (Э3) выкачены в прод, пережив два раунда ревью — 15 подтверждённых дефектов закрыто до выкатки. Стресс после выкатки: 1600 гостей при работающей записи — вдвое выше прежнего проверенного потолка.

Работы и выкатка 27 августа 2026
В проде с 13:45 UTC · sha 29ff27cd
Хост 89.169.46.226 · 4 vCPU / 7,75 ГБ · HDD
Статус подтверждено смоуком и стрессом на бою
Итог

Что изменилось за день

Пользователь не заметил ничего — и это по плану: этап «пишем, не читаем». Вся выгода включается флагами в следующих этапах, фундамент уже под ногами.

Гостей одновременно

проверено было 800

1600

p95 130 мс, ноль ошибок — и это поверх непрерывной записи проекций.

Отклик при 800 гостях

очередь II: 42 мс без записи

45 мс

Запись проекций на полной скорости не стоила чтению ничего.

Сборка карточек дренажем

~80 в сек

500 боевых карточек за 5,5–8,7 с на прогон, 5000 подряд без единого сбоя.

Найдено ревью до выкатки

15 дефектов

Два раунда (xhigh и max), все закрыты, на каждый — регресс-тест.

Диета конфигураций

13,5×

Новые парсы DongCheDi пишут белый список вместо сырца API в 286 КБ.

Ошибок под стрессом

0

На всех ступенях до 2400 включительно; на 2400 — замедление, не отказ.

Очереди I–II были настройкой и точечными правками; очередь III меняет форму данных. Первый её этап — материализованная карточка: раньше каждая выдача заново собирала словарь из ~150 колонок широкой таблицы, теперь готовый JSON лежит в отдельной узкой таблице и пересобирается только когда машина реально меняется. Второй — диета конфигураций: DongCheDi больше не тащит в базу энциклопедию своего приложения.

Ни один читающий путь пока не переключён: флаг CARD_PROJECTION=off. Это сознательный порядок выкатки — сначала фундамент живёт под боевой записью и наблюдением, потом теневое сравнение, потом чтение по одной поверхности за раз.

01 · Э1 Ф1

Считаем при записи, читаем готовое

Продолжение линии ADR 0015 (деньги считаются при сохранении) до единицы данных «карточка». Канон сборки остался один — материализация вызывает боевые сериализаторы, ничего не дублируя.

Путь карточки после Ф1

Каждый писатель широкой таблицы обязан инвалидировать проекцию: синхронные — той же транзакцией, асинхронные — durable-очередью с минутным дренажем.

Синхронные писатели парсер · модерация лотов persist живой сессии сборка той же транзакцией Асинхронные писатели переводы · фото · л.с. · бронь класс кузова · админ-правки 12 точек, все с врезками; карта стерегётся тестом parsed_cars ~150 колонок источник истины права всегда у неё; проекция пересобираема card_rebuild_outbox та же TX, что мутация attempts · dead-letter card_projections card_feed ~1,5 КБ · лента card_full ~10 КБ · модалка rev · src_updated_at условный UPSERT: чёрствое не затирает свежее redact · no-foreign при сборке сборка той же TX · SAVEPOINT beat-дренаж ≤ 60 c мутация → строка в outbox Ф2 shadow → Ф3 чтение лент по одной поверхности · сейчас CARD_PROJECTION=off

Хранение — отдельная таблица, а не колонка в parsed_cars: пересборка проекции не раздувает горячую широкую строку. JSONB — чтобы лента в Ф3 могла конкатенироваться на стороне SQL без раскодирования в Python.

Устойчивость закладывалась как требование, а не как надежда. Сбой сборки не имеет права трогать писателя: каждая сборка живёт под SAVEPOINT, при DB-ошибке откатывается только она, машина уходит в outbox, парсинг продолжается. «Ядовитая» строка не имеет права заклинить очередь: счётчик попыток, после десятой — dead-letter с error-логом, недельный reconcile возвращает машину, если она всё ещё черства. Медленная пересборка не имеет права затереть свежую: у проекции есть версия источника, устаревший payload отбрасывается условным UPSERT-ом. И сетевой перевод не имеет права жить в транзакции записи: синхронный путь только детектирует иностранный текст офлайн-регексами, добивка — за дренажем.

Наблюдаемость въехала вместе с кодом: покрытие фонда, глубина и лаг outbox, зависшие claimed-строки — всё в admin_metric_points; алерт-порог лага — 5 минут.
02 · Э3

Диета конфигураций DongCheDi

Таблица комплектаций занимала 4,46 ГБ — 53% всей базы — при 23 588 строках. Причина: DongCheDi сохранялся сырым ответом API, ~190 КБ на строку.

Инвентаризация потребителей (обязательное условие спецификации до любого сжатия) показала: из ~590 КБ полей properties на строку код и фронт читают только названия характеристик и списки значений — text, key, sub_list и их переводы. Всё остальное — обвязка приложения DongCheDi: энциклопедические статьи wiki_info с подписанными URL видео, служебные флаги, пустые поля. Мёртвый груз, умноженный на 20 893 строки.

ВариантСтрока (properties)Таблица целикомВердикт
Сейчас: сырец + pglz~190 КБ4460 МБ53% базы
Только lz4 (замер на 25 боевых строках)3,3× от сырца~4,0 ГБ−10%, цель не взята
Белый список (13,5×) + lz4~30 КБ~0,7–0,9 ГБцель G3 ✓

Важная находка замера: lz4 сам по себе почти не выигрывает у уже работающего pglz — спасает именно диета. Оракул стережёт побайтовую неизменность рендера модалки до и после.

Что уже в проде: каждый новый парс DongCheDi пишет отфильтрованный JSON. Что ждёт окна владельца: lz4 на четырёх колонках, батч-пережатие 20 тысяч исторических строк (около двух часов, идемпотентно, за флагом) и VACUUM FULL, возвращающий место операционной системе, — всё одним окном, команды записаны в журнале.

03 · Ревью

Пятнадцать дефектов до выкатки

Два раунда многоагентного ревью с независимыми верификаторами: глубокий (xhigh) и финальный «жёсткий» (max). Каждый кандидат проверялся построчно; часть отвергнута. Всё подтверждённое — закрыто до деплоя, на каждый дефект написан регресс-тест.

Р1 · класс из 5 мест

Оборванная транзакция PostgreSQL

блокер

На PostgreSQL любая DB-ошибка обрывает транзакцию целиком — а голые try/except защищали только от Python-ошибок. Одна машина с байтом, который отвергает jsonb, ломала бы сохранение при парсинге, навсегда заклинивала дренаж и останавливала бэкфилл фонда на первом плохом батче.

Фикс

SAVEPOINT вокруг каждой сборки и каждого «глотающего» enqueue; упавшие строки outbox остаются claimed (естественный backoff), бэкфилл идёт keyset-курсором мимо плохих машин. Побочный выигрыш: окно деплоя «код раньше миграции» перестало быть опасным для сейвов.

Р2

Гонка: медленная пересборка затирает свежую

блокер

Дренаж читает машину, строит payload; параллельный репарс пересобирает проекцию с новой ценой; дренаж накатывает свой устаревший payload поверх — и еженедельный скан чёрствости слеп, потому что время сборки у чёрствого свежее.

Фикс

Колонка src_updated_at — снимок версии источника на момент сборки — и условный UPSERT: строка с более старой версией источника просто отбрасывается. Смоук на бою подтвердил совпадение версий до микросекунды.

Р3

Лимит 65 535 параметров протокола PG

блокер

Массовый enqueue слал один INSERT на весь список: reconcile с 20 000 машин — это 100 000 bind-параметров, задача падала бы ровно в сценарии, ради которого существует. Фикс: чанкование по 5000 строк плюс сортировка id против дедлоков конкурентных вставок. Регресс: enqueue 14 000 id на настоящем Postgres.

Р4 · 5 мест

Пропущенные писатели широкой таблицы

чёрствость до 7 суток

Карта инвалидации из спецификации оказалась неполной: догон переводов, перевод кодов опций, второй путь записи лошадиных сил и оба админ-перепереводчика меняли карточку, не трогая проекцию. Все пять захуканы; карта писателей теперь стерегётся тестом — выпиленная врезка роняет его с именем файла.

Р5

Сетевой LLM-вызов внутри открытой транзакции

деньги + локи

Гард «пользователь не видит иероглифов» ходит в переводчик — а первая версия звала его из транзакции сохранения и из многострочной транзакции дренажа, держа блокировки на время сети. Включение бэкфилла превратилось бы в массовые платные вызовы по всему фонду — профиль реального инцидента со сожжёнными кредитами.

Фикс

Синхронный путь — только офлайн-детекция; дренаж собирает каждую машину в собственной короткой транзакции, сеть отрабатывает до захвата чьих-либо локов; бэкфилл вообще не ходит в сеть — грязные машины уезжают в outbox под штатный лимит скорости дренажа.

Р6–Р15

Остальное: гонки, циклы, дубли

закрыто

Потерянный re-fire между claim и ack (ack теперь пропускает пере-взведённые строки) · дедлок-пара многострочных писателей (общий порядок захвата по id) · вечный пустой цикл гарда для площадок, которые он всё равно не переводит · «ядовитые» строки без карантина и невидимые в метриках (attempts + dead-letter + метрика claimed) · оккупация воркера дренажем (мягкий бюджет 45 с) · дословный дубль механики outbox с радаром (общий движок outbox_sql, радар переведён на него без смены семантики) · двойная сериализация карточки · потерянная идентичность писателя в диагностике · импорт приватных хелперов, превращавший чужой рефакторинг в молчаливое отключение гарда.

Отвергнуто верификаторами (кандидаты были, дефекта нет): «модалка может обратно раздуть продиетированную строку», «незащищённые enqueue фото и класса кузова» — второе оказалось точным повторением осознанного домового контракта радара. Проверка: оракулы 48 + 57/59 + 21 на sqlite и на настоящем PostgreSQL 16, PG-тесты радара, полная регрессия из 14 сьютов.

04 · Выкатка

Руками, по проверенному порядку

CI по-прежнему лежит (биллинг GitHub Actions). Порядок — из handoff очереди II, с одним осознанным улучшением.

  1. Селф-тесты CI локально; rsync-дифф сверен поимённо: ровно 34 файла очереди III, удалений ноль.
  2. Сборка образов на сервере (base + все backend-сервисы).
  3. Миграция до пересоздания контейнеров — одноразовым контейнером на новом образе. Отступление от прежнего порядка: миграция чисто аддитивная, старый код её не видит, зато окно «новый код без таблиц» схлопнуто в ноль.
  4. Пересоздание десяти backend-сервисов; веб-пауза ~10 секунд. nginx не трогался — его конфиг не менялся.
  5. Маркеры .deployed/*.sha проставлены — следующий прогон CI не пересоберёт лишнего.

Смоук на бою — все контуры живьём

05 · Стресс

Гости поверх работающей записи

Худший реалистичный случай: гостевые ступени users_sim (визит = 3 запроса, пауза 20 с, мимо nginx) одновременно с флудом из 5000 пересборок проекций через outbox. SLA прежний: p95 визита < 1,5 с.

ГостейВизитовp50p95p99ОшибокВердикт
800239827 мс45 мс60 мс0OK — очередь II без записи: 42 мс
1600478643 мс130 мс217 мс0OK — новая территория
240066562,14 с3,05 с3,23 с0перегруз по SLA, отказов нет

Дренаж под этой нагрузкой: десять минутных прогонов подряд по 500 сборок за 5,5–8,7 с, failed=0, outbox 5000 → 0. Пики железа: load average 2,49 из 4, backend ~178% из 400%, Postgres ~14%, свободной памяти больше 3 ГБ — запас есть даже на перегрузной ступени.

Главный вывод: на 800 гостях p95 совпал с чистым замером очереди II — запись проекций на полной скорости не стоила чтению ничего. Потолок сдвинулся с «800 проверено» к «1600 держит уверенно»; на 2400 система деградирует без единой ошибки.

Две оговорки честности. Цифра 2400 загрязнена самим генератором нагрузки на той же машине (плюс gunicorn в конфигурации 1 воркер × 4 потока) — чистый потолок мерить со второй машины. Лаг outbox во время флуда дорастал до ~9 минут — это артефакт синтетики из 5000 задач разом; для органики алерт-порог 5 минут остаётся валидным.
06 · Разведано

Данные для решений владельца

Два инфраструктурных эпика упираются не в код, а в решения. Всё, что можно было разведать без них, — разведано.

ЭпикФактыЖдёт
Э4 · SSDДиск прода — честный HDD (ROTA=1): 102 МБ/с, 329 IOPS, 3 мс случайное чтение; занято 44 из 80 ГБ. Целевой объём с проекциями и запасом — от 120 ГБ.Решение: менять диск на месте или новый сервер (тогда Э5 отпадает сам).
Э5 · сосед horecaИнвентаризация снята: 6 контейнеров, данных меньше 200 МБ (postgres 175 МБ + minio 280 КБ), ~450 МБ RAM. Наружу не выставлен: портов нет, nginx его не маршрутизирует, хост-кронов нет. Перенос — минуты копирования.Подтвердить пользователей проекта и выбрать хост.
Э2 · fan-out поискаКод готов и покрыт тестами (зелёные); включение — переменные окружения и пересоздание контейнеров. План канарейки записан: стенд → che168 сутки → подъём потолка 5 → 8 → 12 с замерами и перепроверкой прокси-пула на каждом шаге.Окно на канарейку.
07 · Дальше

Порядок включения выгоды

Фундамент в проде; каждая следующая ступень — отдельная выкатка со своим наблюдением и своим откатом-флагом.

Сделано: Ф1 + Э3-код в проде

выкачено 27.08
  • Таблицы, канон сборки, полная карта писателей, beat-контуры, метрики, стресс.
  • Новые парсы DongCheDi пишут диету.

Окно A: lz4 + пережатие истории + VACUUM FULL

ночное окно · команды готовы
  • lz4 на четырёх колонках → включить пережатие (~2 часа фоном) → VACUUM FULL → перезамер. Ожидание: таблица конфигураций 4,46 ГБ → ~0,7–0,9 ГБ, база целиком ~8,4 → ~4,5 ГБ.

Решение B: SSD → бэкфилл → Ф2 shadow

решение владельца
  • После SSD — включить бэкфилл проекций (штатно, батчами, ~80 карточек/с по замеру стресса — фонд за часы).
  • Затем Ф2: теневое чтение с diff-логом, выход по критерию ≥ 99,9% совпадений за 48 часов; затем Ф3 — чтение лент по одной поверхности за выкатку.
Открытые вопросы прежние (A–E спецификации): окно на lz4/VACUUM · диск на месте или новый сервер · судьба horeca · пул тестовых учёток для приёмки сквозь HTTP · биллинг GitHub Actions (ручная выкатка воспроизводима, но комфорт не тот).