tradein/newbuilding: счётчик price_dynamics_rows меряет прирост таблицы, а читается как «сколько записали» #2807

Closed
opened 2026-08-10 08:19:02 +00:00 by bot-backend · 2 comments
Collaborator

newbuilding_enrich_backfill.py:605 считает не то, что обещает именем:

pd_after, rc_after, rv_after = _house_enrichment_counts(db, house_id)
result.price_dynamics_rows += max(0, pd_after - pd_before)

Это прирост числа строк в таблице, а не число записанных точек. Вставка идёт ON CONFLICT ON CONSTRAINT houses_price_dynamics_dim_key DO UPDATE, поэтому обновление существующей точки даёт ноль. Счётчик отвечает на вопрос «выросла ли таблица», а читают его как «пишется ли динамика цен».

Живой пример (прод, 10.08)

Прогон 3578 (newbuilding_enrich, 00:28–00:36 UTC) отчитался:

"price_dynamics_rows": 0,  "reliability_rows": 0,  "review_rows": 0,
"succeeded": 25, "enriched": 25, "attempted": 25

А в houses_price_dynamics за окно прогона — 64 строки с обновлённой recorded_at по 10 домам. Предыдущий прогон 3563 (09.08 18:49) отчитался price_dynamics_rows: 64 — это были те же 64 точки тех же 10 домов, только вставленные. То есть ноль означал «обновили ровно то, что вчера вставили», а читался как «динамика цен снова не пишется». Я едва не завёл по этому нулю ложную задачу.

Это не только price_dynamics

Тем же приёмом считаются оба соседних счётчика в том же месте, и оба врут по своей причине:

  • reliability_rowshouse_reliability_checks пишется обычным INSERT без UNIQUE, но следом caller зовёт _dedup_reliability и схлопывает до одной строки. Net-прирост повторного прогона = 0 при реально записанной строке.
  • review_rows_save_cian_reviews уже возвращает число записанных отзывов, и код это число выбрасывает, предпочитая ему разницу COUNT'ов. Отзывы тоже UPSERT'ятся по (source, ext_review_id) → повторный прогон даёт 0.

Больше в проде приёма count после − count до для счётчиков прогона нет: git grep даёт единственное второе совпадение — lots_dropped_secondary в pipeline.py:2597, и там это длина списка до/после фильтрации, то есть измерение по построению честное. Класс не подтверждён, это случай — но случай тройной, в одном месте.

Чем чинить

Различать вставлено и обновлено в самом писателе, а не угадывать по таблице: RETURNING (xmax = 0) AS inserted — идиома, уже применяемая в репозитории (backend/app/services/scrapers/gisogd66.py:301).

Осторожно: сторож нулевого результата

Счётчики прогона читает mark_backfill_finished (#2695) — но он смотрит на attempted / enriched / gone / blocked, а не на *_rows. Сторож #2703 (_alert_if_consecutive_zero_results) читает total_seen/lots_fetched/unique_fetched, которых у этой задачи нет вовсе. Правка счётчиков *_rows не имеет права ни ослабить, ни усилить оба гейта — прогон, который ничего не записал, обязан остаться неуспешным. Это должно быть проверено тестом, а не рассуждением.

`newbuilding_enrich_backfill.py:605` считает не то, что обещает именем: ```python pd_after, rc_after, rv_after = _house_enrichment_counts(db, house_id) result.price_dynamics_rows += max(0, pd_after - pd_before) ``` Это **прирост числа строк в таблице**, а не число записанных точек. Вставка идёт `ON CONFLICT ON CONSTRAINT houses_price_dynamics_dim_key DO UPDATE`, поэтому обновление существующей точки даёт ноль. Счётчик отвечает на вопрос «выросла ли таблица», а читают его как «пишется ли динамика цен». ## Живой пример (прод, 10.08) Прогон 3578 (`newbuilding_enrich`, 00:28–00:36 UTC) отчитался: ``` "price_dynamics_rows": 0, "reliability_rows": 0, "review_rows": 0, "succeeded": 25, "enriched": 25, "attempted": 25 ``` А в `houses_price_dynamics` за окно прогона — **64 строки с обновлённой `recorded_at` по 10 домам**. Предыдущий прогон 3563 (09.08 18:49) отчитался `price_dynamics_rows: 64` — это были те же 64 точки тех же 10 домов, только вставленные. То есть ноль означал «обновили ровно то, что вчера вставили», а читался как «динамика цен снова не пишется». Я едва не завёл по этому нулю ложную задачу. ## Это не только price_dynamics Тем же приёмом считаются оба соседних счётчика в том же месте, и оба врут по своей причине: * `reliability_rows` — `house_reliability_checks` пишется обычным INSERT без UNIQUE, но следом caller зовёт `_dedup_reliability` и схлопывает до одной строки. Net-прирост повторного прогона = 0 при реально записанной строке. * `review_rows` — `_save_cian_reviews` **уже возвращает** число записанных отзывов, и код это число выбрасывает, предпочитая ему разницу COUNT'ов. Отзывы тоже UPSERT'ятся по `(source, ext_review_id)` → повторный прогон даёт 0. Больше в проде приёма `count после − count до` для счётчиков прогона нет: `git grep` даёт единственное второе совпадение — `lots_dropped_secondary` в `pipeline.py:2597`, и там это длина списка до/после фильтрации, то есть измерение по построению честное. **Класс не подтверждён, это случай** — но случай тройной, в одном месте. ## Чем чинить Различать **вставлено** и **обновлено** в самом писателе, а не угадывать по таблице: `RETURNING (xmax = 0) AS inserted` — идиома, уже применяемая в репозитории (`backend/app/services/scrapers/gisogd66.py:301`). ## Осторожно: сторож нулевого результата Счётчики прогона читает `mark_backfill_finished` (#2695) — но он смотрит на `attempted` / `enriched` / `gone` / `blocked`, а не на `*_rows`. Сторож #2703 (`_alert_if_consecutive_zero_results`) читает `total_seen`/`lots_fetched`/`unique_fetched`, которых у этой задачи нет вовсе. Правка счётчиков `*_rows` не имеет права ни ослабить, ни усилить оба гейта — прогон, который ничего не записал, обязан остаться неуспешным. Это должно быть проверено тестом, а не рассуждением.
Author
Collaborator

Прод-проверка кода 2026-08-10 08:59 UTC: правка в живом контейнере

Проверено вызовом ВНУТРИ tradein-scraper, а не по релизу:

RETURNING xmax: True
keys: ['price_dynamics_inserted', 'price_dynamics_updated', 'reliability_inserted', 'review_upserted']

Числовая верификация ждёт прогона — критерий из PR остаётся в силе и записан до факта:

Первый прогон newbuilding_enrich после деплоя (расписание ежесуточное, ~00:28 UTC → ожидается 2026-08-11) обязан показать в scrape_runs.counters непустое price_dynamics_updated при price_dynamics_inserted около нуля — потому что все 301 fetchable-дома уже обогащены, и работа такого прогона по определению состоит из обновлений. Оба поля в нуле при succeeded > 0 = правка не работает.

Для сравнения — что показывали последние прогоны на старом счётчике:

run succeeded price_dynamics_rows (старый) что было на самом деле
3578 (10.08 00:28) 25 0 64 строки переписаны по 10 домам
3563 (09.08 18:49) 25 64 те же 64 строки, вставлены
3561 (09.08 18:13) 14 0
## Прод-проверка кода 2026-08-10 08:59 UTC: правка в живом контейнере Проверено вызовом ВНУТРИ `tradein-scraper`, а не по релизу: ``` RETURNING xmax: True keys: ['price_dynamics_inserted', 'price_dynamics_updated', 'reliability_inserted', 'review_upserted'] ``` Числовая верификация ждёт прогона — критерий из PR остаётся в силе и записан до факта: **Первый прогон `newbuilding_enrich` после деплоя** (расписание ежесуточное, ~00:28 UTC → ожидается 2026-08-11) обязан показать в `scrape_runs.counters` непустое `price_dynamics_updated` при `price_dynamics_inserted` около нуля — потому что все 301 fetchable-дома уже обогащены, и работа такого прогона по определению состоит из обновлений. Оба поля в нуле при `succeeded > 0` = правка не работает. Для сравнения — что показывали последние прогоны на старом счётчике: | run | succeeded | price_dynamics_rows (старый) | что было на самом деле | |---|---|---|---| | 3578 (10.08 00:28) | 25 | **0** | 64 строки переписаны по 10 домам | | 3563 (09.08 18:49) | 25 | 64 | те же 64 строки, вставлены | | 3561 (09.08 18:13) | 14 | 0 | — |
Author
Collaborator

Числовая верификация, которой не хватало на момент закрытия

Задача закрыта 10.08 08:50, а критерий из PR был записан на «первый прогон newbuilding_enrich после деплоя, ожидается 2026-08-11». Прогон был — фиксирую доказательство, чтобы закрытие не осталось на одном чтении кода.

run started (UTC) succeeded price_dynamics_inserted price_dynamics_updated reliability_inserted review_upserted
3656 11.08 00:29 25 0 64 10 0
3736 12.08 00:58 25 9 64 10 0

Критерий («непустое price_dynamics_updated при price_dynamics_inserted около нуля, потому что все 301 fetchable-дома уже обогащены») выполнен точно: прогон 3656 — 0 вставок и 64 обновления. Это ровно тот случай, который старый счётчик показывал как price_dynamics_rows: 0 и по которому едва не завелась ложная задача. reliability_inserted: 10 тоже перестал схлопываться в ноль после _dedup_reliability.

Один ноль остался и требует причины: review_upserted = 0 в обоих прогонах. Это уже честное число писателя (_save_cian_reviews возвращает записанное), а не разница COUNT'ов, — но по двум прогонам оно не отличает «Циан не отдал отзывов» от «путь сохранения не вызывается». Задачу не переоткрываю: её предметом было «счётчик меряет не то, что обещает», и это исправлено. Если отзывы нужны как данные — это отдельный вопрос с отдельным замером.

Сторожа нулевого результата (#2695, #2703) правка, как и требовалось, не сдвинула: оба смотрят на attempted/enriched/total_seen, а не на *_rows.

## Числовая верификация, которой не хватало на момент закрытия Задача закрыта 10.08 08:50, а критерий из PR был записан на «первый прогон `newbuilding_enrich` после деплоя, ожидается 2026-08-11». Прогон был — фиксирую доказательство, чтобы закрытие не осталось на одном чтении кода. | run | started (UTC) | succeeded | price_dynamics_inserted | price_dynamics_updated | reliability_inserted | review_upserted | |---|---|---:|---:|---:|---:|---:| | 3656 | 11.08 00:29 | 25 | **0** | **64** | 10 | 0 | | 3736 | 12.08 00:58 | 25 | 9 | **64** | 10 | 0 | Критерий («непустое `price_dynamics_updated` при `price_dynamics_inserted` около нуля, потому что все 301 fetchable-дома уже обогащены») выполнен точно: прогон 3656 — 0 вставок и 64 обновления. Это ровно тот случай, который старый счётчик показывал как `price_dynamics_rows: 0` и по которому едва не завелась ложная задача. `reliability_inserted: 10` тоже перестал схлопываться в ноль после `_dedup_reliability`. **Один ноль остался и требует причины:** `review_upserted` = 0 в обоих прогонах. Это уже честное число писателя (`_save_cian_reviews` возвращает записанное), а не разница COUNT'ов, — но по двум прогонам оно не отличает «Циан не отдал отзывов» от «путь сохранения не вызывается». Задачу не переоткрываю: её предметом было «счётчик меряет не то, что обещает», и это исправлено. Если отзывы нужны как данные — это отдельный вопрос с отдельным замером. Сторожа нулевого результата (#2695, #2703) правка, как и требовалось, не сдвинула: оба смотрят на `attempted`/`enriched`/`total_seen`, а не на `*_rows`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2807
No description provided.