У нас есть сервис, который собирает продавцам XML-фид для автозагрузки Авито. В воскресенье 27 сентября я выкатил миграцию данных. По моему расчёту она ставила новый признак ровно одному объявлению. После выкатки признак стоял у 321. Вреда не случилось, потому что я пересчитал помеченные строки раньше, чем фид успел пересобраться. Ниже код, запросы и мой главный промах: условие я считал в голове, а на проде не прогнал ни разу.
Откуда вообще взялся бэкфилл
Сборщик фида решает, откуда брать нишевые теги объявления: из файла, который продавец когда-то загрузил, или из настроек кабинета. От этого зависят телефон в объявлении, цена и ещё десяток полей. Решение принималось одной строкой в generate_feed.py:
ff = params.get("feed_fields") ff = ff if isinstance(ff, dict) else {} has_import = bool(ff)
Логика «если в params.feed_fields что-то лежит, значит, был импорт» работала, пока feed_fields писал только импортёр. Потом в карточке объявления появилась форма параметров, и она стала писать туда же. Объявление, у которого владелец руками выбрал «Гарантия: Есть», для сборщика превращалось в импортированное из файла. Мы нашли это на живом объявлении: его никто никогда не импортировал, а фид собирался для него по импортной ветке.
Починка напрашивалась сама. Импортёр ставит отдельный явный маркер params.feed_import_at, сборщик смотрит только на него. Со старыми импортами сложнее: они были сделаны до появления маркера, и без бэкфилла сборщик перестал бы считать их импортом. У одного такого объявления в файле был свой номер телефона. Без маркера в фид ушёл бы телефон кабинета по умолчанию, то есть покупатели звонили бы не тому человеку.
Значит, код и бэкфилл надо выкатывать одним PR.
Как я выбирал условие
Для бэкфилла нужен признак «это объявление когда-то пришло из файла». feed_fields не годится, с ним как раз и была проблема. Я прошёлся grep’ом по всем местам в apps/api и apps/worker, где пишутся ключи в params, и искал ключи с единственным писателем, импортёром.
Первый список оказался неточным. feed_legacy_id я считал импортным, а пишут его ещё четыре места: починка ссылки на фид, восстановление объявления оператором и две фоновые задачи. Его я выкинул. Остались шесть ключей, у каждого единственный писатель, feed/service.py::_ingest_items:
_IMPORTER_ONLY_KEYS = ( "feed_category_path", "feed_fields_raw", "feed_fields_xml", "avito_status_from_file", "avito_date_end_from_file", "contact_phone", ) KEY_EXISTS_PREDICATE = " OR ".join( f"params ? '{k}'" for k in _IMPORTER_ONLY_KEYS )
И сама миграция:
def upgrade() -> None: conn = op.get_bind() now_iso = datetime.now(timezone.utc).isoformat() conn.execute( text( "UPDATE ads SET params = params || jsonb_build_object(" " 'feed_import_at', CAST(:at AS text)," " 'feed_import_source', CAST(:src AS text)" ") " "WHERE deleted_at IS NULL " " AND NOT (params ? 'feed_import_at') " f" AND ({KEY_EXISTS_PREDICATE})" ), {"at": now_iso, "src": _SOURCE}, )
Здесь две вещи сделаны правильно, и обе потом пригодились. Бэкфилл пишет свой источник feed_import_source = 'backfill_feedimp01', поэтому его пометки отличаются от пометок настоящего импорта (у того upload или url). И downgrade() снимает ключи только там, где стоит этот источник.
В докстринге миграции я написал: «на проде ровно одно объявление получит маркер». Во всей базе непустой feed_fields был у двух объявлений. Одно из них пришло из файла, второе было заполнено формой. Из этого я вывел, что импортированных объявлений одно.
Где ошибка, видно уже здесь. Я посчитал, у кого непустой feed_fields. В миграции проверяется совсем другое: есть ли хоть один из шести ключей. Эти два условия я мысленно склеил и не заметил, что это разные множества.
Тест, который прошёл
Для миграции был интеграционный тест на настоящем Postgres (операторы jsonb ?, ||, - на моках не проверишь). Он загружает файл миграции по пути, запускает настоящий upgrade() на трёх строках и проверяет результат:
rows = ( ("imported", { "contact_phone": "+70000000000", "feed_fields": {"WorkExperience": "8"}, }), ("form_only", {"feed_fields": {"Guarantee": "Есть"}}), ("empty", {}), )
Ожидалось, что imported получит маркер, а form_only и empty нет. Так и вышло, CI зелёный, PR смержен в 13:31.
Задним числом видно, что три строки теста — это ровно та модель данных, которая была у меня в голове. Импортированное объявление в ней всегда несёт и импортный ключ, и нишевые поля. Строки «импортный ключ есть, feed_fields пустой» в тесте нет, потому что я не подозревал, что такие бывают. Тест честно проверил, что SQL делает то, что я задумал. Верно ли я задумал, он проверить не мог.
Что было на проде
После выкатки я посчитал помеченные строки:
SELECT count(*) FROM ads WHERE params->>'feed_import_source' = 'backfill_feedimp01';
Вышло 321. По кабинетам: 222, 98 и 1.
Разрезал все живые объявления по двум признакам, ради которых всё затевалось (запрос повторил позже в тот же день):
SELECT params ?| array['feed_category_path','feed_fields_raw','feed_fields_xml', 'avito_status_from_file','avito_date_end_from_file', 'contact_phone'] AS importer_key, (params->'feed_fields' IS NOT NULL AND params->'feed_fields' <> '{}'::jsonb) AS has_ff, params ? 'feed_import_at' AS marked, count(*) FROM ads WHERE deleted_at IS NULL GROUP BY 1, 2, 3 ORDER BY 1, 2, 3;
importer_key | has_ff | marked | count --------------+--------+--------+------- f | f | f | 4960 f | t | f | 1 t | f | f | 320 t | t | t | 1
Это состояние уже после отката, поэтому у 320 строк marked = f. До отката там стояло t.
Насколько я смог проследить, эти 320 объявлений достались нам при переезде со старой системы на n8n. Тот перенос складывал в params телефон и путь категории под теми же именами, что и наш импортёр. grep по коду этого показать не мог: он видит, кто пишет ключи сейчас, а кто писал их в базу до переезда, в коде уже не видно. Нишевых полей из файла у этих объявлений нет, и старый has_import правильно отправлял их по обычной ветке сборщика.
С пометкой они ушли бы по импортной. Телефон брался бы из старых перенесённых данных, а не из текущих настроек кабинета, и цена из файла имела бы приоритет над колонкой, которую владелец правит в карточке. Если продавец за это время сменил номер, после ближайшей пересборки фида покупатели звонили бы на старый. В одном кабинете таких объявлений было 222.
Как откатывал
downgrade() здесь не годился: он снял бы пометку и с того единственного объявления, которому она нужна. Откатывать миграцию целиком и выкатывать заново тоже не хотелось, потому что в том же релизе ехал код сборщика, который уже читает новый маркер.
Сделал руками. Сначала выгрузил все 321 помеченную строку в CSV на сервере, чтобы было к чему вернуться. Потом снял оба ключа, feed_import_at и feed_import_source, у строк с источником backfill_feedimp01, кроме одного нужного объявления. Повторный счёт показал 1.
Проверял потом не по счётчику, а по результату. Пересобрал штатной задачей фид кабинета, где лежит то самое объявление, и сравнил с предыдущей сборкой: 37 объявлений из 38 совпали байт в байт. 38-е побайтно не совпало, но набор «тег → значение» у него тот же.
Что поменял в миграции
На проде миграция уже применена, и Alembic сверяет ревизии, а не текст, так что повторно она там не запустится. Но на любой свежей базе (CI, dev, новый стенд) она бы снова пометила лишнее. Поэтому в файл ушло условие, которое я держал в голове с самого начала:
"WHERE deleted_at IS NULL " " AND NOT (params ? 'feed_import_at') " - f" AND ({KEY_EXISTS_PREDICATE})" + f" AND ({KEY_EXISTS_PREDICATE}) " + " AND params->'feed_fields' IS NOT NULL " + " AND params->'feed_fields' <> '{}'::jsonb"
revision и down_revision я не трогал. В тест добавил две строки, те самые из таблицы: импортный ключ есть, а feed_fields либо отсутствует, либо равен {}. Пометки у них быть не должно. Старое условие пометило бы обе.
Что не сработало и почему
Ревью смотрело на код, а в коде всё было корректно: условие в SQL в точности соответствовало тому, что написано в докстринге про ключи. Расхождение было между докстрингом и числом «ровно одно», и ни один ревьюер не мог его увидеть, не заглянув в прод.
grep по писателям я считал доказательством. Он и был доказательством, только для утверждения «кто пишет эти ключи в текущем коде». Для бэкфилла нужен был ответ на другой вопрос: у кого эти ключи лежат в базе. Код на него не отвечает.
И сам тест. Три строки фикстуры я придумал сам, поэтому они проверяли мою модель данных, а не реальную базу. Если бы фикстуру строил по таблице четырёх комбинаций, строка t | f появилась бы в ней сама.
Что теперь делаю перед мержем миграции данных
Беру WHERE из файла миграции буквально, копированием, и запускаю на проде как SELECT count(*) ... GROUP BY по кабинетам. Число кладу в описание PR рядом с ожидаемым. Если они не совпадают, мерж ждёт, пока я не пойму, какое из двух условий неверно.
Для этой миграции такой запрос занял бы секунду и показал 321 вместо 1 ещё до мержа.
После выкатки делаю ту же сверку второй раз, уже по маркеру источника. Именно она в этот раз и спасла. Но спасла потому, что фид собирается по расписанию, а не сразу после релиза, и у меня было окно. В следующий раз его может не быть.
Итог в числах: ждал 1, миграция пометила 321, после ручного отката осталась 1. Фид кабинета с тем самым объявлением совпал с предыдущей сборкой на 37 объявлениях из 38 байт в байт, у последнего совпал набор тегов.

