docs(#3390): убрать ложные «counters ЗАМЕНЯЕТ» из комментариев/докстрингов; гейт test_3168 — честная формулировка
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
This commit is contained in:
parent
36f2429fbe
commit
ae6ad9e440
5 changed files with 41 additions and 47 deletions
|
|
@ -32,8 +32,8 @@ from typing import Any
|
|||
# его алиас, реализация одна и counters везде МЕРЖАТСЯ (`counters || :counters`). До
|
||||
# #3390 копии было две, и app-копия counters ЗАМЕНЯЛА — тогда чекпоинт курсора
|
||||
# import_rosreestr_dkp (#3168) обязан был писаться именно kit-именем, иначе resume-вердикт
|
||||
# со старта затирался первым же per-batch пульсом. Имя оставлено: на него смотрит
|
||||
# текстовый гейт test_3168 (test_cursor_write_uses_merge_not_replace_heartbeat).
|
||||
# со старта затирался первым же per-batch пульсом. Имя оставлено как есть: теперь это
|
||||
# один объект, и переименование в runs_mod ничего не чинит и ничего не ломает.
|
||||
from scraper_kit.orchestration import runs as kit_runs
|
||||
|
||||
# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674).
|
||||
|
|
@ -131,12 +131,10 @@ async def _execute_cian_backfill(
|
|||
поведению (пометка 'zombie' на 6-м часу)."""
|
||||
nonlocal counters
|
||||
# #3384: снимок измеренного едет не только в БД, но и в `counters` — этот словарь
|
||||
# уезжает в mark_failed из общего except ниже, а тот counters ЗАМЕНЯЕТ
|
||||
# (scrape_runs.py:738 `counters = CAST(:counters AS jsonb)`; мерж `||` — только у
|
||||
# kit-копии, которую этот путь не зовёт). Пока снимок сюда не доезжал, любой отказ
|
||||
# ПОСЛЕ пройденной стадии (пул опустел между стадиями, упал SELECT домов) писал
|
||||
# поверх измеренного предынициализированные нули, и SQL-разбор простоя
|
||||
# (#3288/#3367) читал «к площадке не ходили» про прогон, который ходил.
|
||||
# уезжает в mark_failed из общего except ниже. Мерж (#3390) спасает лишь ключи,
|
||||
# которых в payload нет; одноимённые он ПЕРЕЗАПИСЫВАЕТ, поэтому предынициализированные
|
||||
# нули без этого присваивания легли бы поверх измеренного, и SQL-разбор простоя
|
||||
# (#3288/#3367) прочитал бы «к площадке не ходили» про прогон, который ходил.
|
||||
# Присваивание ДО записи в БД: сбой heartbeat'а не должен стирать сам факт замера.
|
||||
counters = _counters(progress)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ import_rosreestr_dkp (source='rosreestr_dkp_import'), шестой backfill, н
|
|||
штатным поведением).
|
||||
- Потолок возраста чекпоинта — 24ч (_DKP_CHECKPOINT_STALE_HOURS): старше — курсор
|
||||
считается негодным, прогон стартует с last_id=0, причина 'checkpoint_stale'.
|
||||
- Вердикт пишется в scrape_runs.counters через kit_runs.update_heartbeat
|
||||
(`counters || :counters` — merge), а не локальный runs_mod.update_heartbeat
|
||||
(`CAST(:counters AS jsonb)` — полная замена): иначе первый же per-batch heartbeat
|
||||
после старта стирает resume-вердикт.
|
||||
- Вердикт пишется в scrape_runs.counters через update_heartbeat, а тот counters
|
||||
МЕРЖИТ (`counters || :counters`) — иначе первый же per-batch heartbeat после старта
|
||||
стёр бы resume-вердикт. На момент #3168 мерж был только у kit-копии, поэтому запись
|
||||
шла именно kit-именем; с #3390 копия одна и семантика мержа — единственная.
|
||||
|
||||
Обратимость (см. PR summary): временный откат last_id на литерал 0 красит
|
||||
test_resume_continues_from_saved_last_id (last_id == 123456 не совпадает с 0).
|
||||
|
|
@ -34,7 +34,6 @@ test_resume_continues_from_saved_last_id (last_id == 123456 не совпада
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
|
@ -163,20 +162,15 @@ def test_checkpoint_just_under_ceiling_is_still_accepted() -> None:
|
|||
|
||||
|
||||
# ── 3. Запись курсора не затирает посторонние ключи в counters (мерж, не замена) ────
|
||||
#
|
||||
# Текстового гейта на имя `kit_runs.update_heartbeat` здесь больше нет (#3390): пока копий
|
||||
# было две, имя выбирало семантику, и проверять его по тексту имело смысл. Теперь
|
||||
# `app.services.scrape_runs` — алиас kit'а, оба имени дают ОДИН объект, и гейт краснел бы
|
||||
# на переименовании, ничего при этом не защищая. Мерж проверяется по значению — тестом
|
||||
# ниже и test_3390_single_runs_module.py (оба пути импорта, heartbeat + финализатор).
|
||||
|
||||
|
||||
def test_cursor_write_uses_merge_not_replace_heartbeat() -> None:
|
||||
"""import_rosreestr_dkp обязан писать чекпоинт через kit_runs.update_heartbeat
|
||||
(merge: `counters || :counters`), а не локальный runs_mod.update_heartbeat (замена:
|
||||
`CAST(:counters AS jsonb)`) — иначе resume-вердикт, записанный ДО цикла, стирается
|
||||
первым же per-batch heartbeat'ом того же прогона.
|
||||
"""
|
||||
src = inspect.getsource(sched.import_rosreestr_dkp)
|
||||
assert "kit_runs.update_heartbeat" in src
|
||||
assert "runs_mod.update_heartbeat" not in src
|
||||
|
||||
|
||||
def test_kit_runs_update_heartbeat_merges_into_existing_counters() -> None:
|
||||
def test_update_heartbeat_merges_into_existing_counters() -> None:
|
||||
"""Сама примитива слияния: второй write добавляет rows_fetched/last_id, НЕ стирая
|
||||
resume_from/resume_reason, записанные первым write'ом.
|
||||
|
||||
|
|
|
|||
|
|
@ -247,10 +247,10 @@ async def test_domclick_sweep_drain_is_marked_interrupted() -> None:
|
|||
async def test_domclick_sweep_drain_keeps_inherited_checkpoint() -> None:
|
||||
"""domclick: дрейн ОБЯЗАН переписать унаследованный чекпоинт в свой payload.
|
||||
|
||||
Мержа jsonb тут нет: app-level `scrape_runs.update_heartbeat`/`mark_done` пишут
|
||||
`counters = CAST(:counters AS jsonb)` — полная перезапись, а scheduler при claim
|
||||
done_buckets не наследует. Без явного ключа дрейн-прогон закрывался бы с пустым
|
||||
чекпоинтом, и резюм пересобирал бы все шесть корзин заново.
|
||||
Мерж jsonb (#3390) тут не спасает: он сливает payload со СВОЕЙ строкой прогона, а та
|
||||
создана пустой (`create_run`) — унаследованный чекпоинт лежит в counters ПРЕДЫДУЩЕГО
|
||||
прогона, и scheduler при claim его не переносит. Без явного ключа дрейн-прогон
|
||||
закрывался бы с пустым чекпоинтом, и резюм пересобирал бы все шесть корзин заново.
|
||||
"""
|
||||
from scraper_kit.orchestration import pipeline as pl
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ break`), которая и пишет `counters.no_proxy_stop`. Общий `exce
|
|||
`httpx.AsyncClient.post` — «к площадке не ходили» проверяется, а не предполагается.
|
||||
|
||||
Третий тест — про соседний случай: пул опустел МЕЖДУ стадиями, то есть уже ПОСЛЕ
|
||||
реальной работы. Там проверяется, что в запись прогона не уезжают нули поверх
|
||||
измеренного (`mark_failed` у `app.services.scrape_runs` counters ЗАМЕНЯЕТ, а не мержит).
|
||||
реальной работы. Там проверяется, что в запись прогона не уезжают нули поверх измеренного:
|
||||
`mark_failed` counters МЕРЖИТ (#3390), но одноимённые ключи при мерже перезаписываются,
|
||||
поэтому нули в payload'е финализатора всё так же стирают замер пульса.
|
||||
|
||||
Сеть/БД замоканы; в сеть тест не ходит.
|
||||
"""
|
||||
|
|
@ -199,10 +200,10 @@ async def test_cian_empty_pool_between_stages_keeps_measured_counters() -> None:
|
|||
У циана (в отличие от avito/домклика с их живым `counters.to_dict()`) в общий
|
||||
`except` приходит СТАРЫЙ словарь: реальные значения присваиваются уже после возврата
|
||||
из `backfill_cian_history`, а отказ бывает и посреди неё — пул опустел между
|
||||
стадиями, упал SELECT домов. `runs_mod` здесь настоящий
|
||||
(`app.services.scrape_runs`), и его `mark_failed` counters ЗАМЕНЯЕТ
|
||||
(`counters = CAST(:counters AS jsonb)`, scrape_runs.py:738) — то есть в записи
|
||||
прогона остаётся ровно то, что уехало последним аргументом.
|
||||
стадиями, упал SELECT домов. `runs_mod` здесь настоящий (`app.services.scrape_runs`,
|
||||
с #3390 — алиас kit'а), и его `mark_failed` counters МЕРЖИТ; мерж спасает только
|
||||
ключи, которых в payload'е нет, а одноимённые ПЕРЕЗАПИСЫВАЕТ — нули поверх замера
|
||||
всё равно недопустимы, и проверять их надо у вызывающего.
|
||||
|
||||
Проверка по значению: смотрим jsonb-payload обоих UPDATE'ов (heartbeat и
|
||||
mark_failed), а не факт вызова.
|
||||
|
|
|
|||
|
|
@ -15,15 +15,15 @@ cian_history_backfill 6173) остались в scrape_runs со статусо
|
|||
3. hard-cancel из scheduler_main (CancelledError прямо в `asyncio.wait` дрейна) даёт тот
|
||||
же результат — это ровно прод-путь 07.09, ветка таймаута его не покрывает;
|
||||
4. `scheduler_main` после hard-cancel'а больше не печатает «drained cleanly»;
|
||||
5. пульс НЕ пишет по финализированной строке (обе копии `update_heartbeat`) — иначе
|
||||
помеченная, но ещё живая задача стирает метку следующим же ударом;
|
||||
5. пульс НЕ пишет по финализированной строке (оба пути импорта `update_heartbeat`) —
|
||||
иначе помеченная, но ещё живая задача стирает метку следующим же ударом;
|
||||
6. отмена, прилетевшая в тело тика (не в дрейн), помечает in-flight так же;
|
||||
7. отказ SQL на одном run_id не уносит остальные, а WARNING перечисляет ровно
|
||||
помеченных.
|
||||
|
||||
Фейковый `mark_done` counters ЗАМЕНЯЕТ, а не мержит — так ведёт себя боевая app-копия
|
||||
(`app.services.scrape_runs`, #3390), которая и инжектируется в прод-контекст. Голый
|
||||
`{"interrupted": 1}` на этом фейке стёр бы чекпоинт, и (1) покраснел бы.
|
||||
Фейковый `mark_done` counters ЗАМЕНЯЕТ, а не мержит — намеренно строже боевого (тот с
|
||||
#3390 мержит везде): голый `{"interrupted": 1}` на этом фейке стёр бы чекпоинт, и (1)
|
||||
покраснел бы. Дрейн обязан слать чекпоинт явно, а не полагаться на мерж в БД.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -49,8 +49,8 @@ import app.scheduler_main as sm
|
|||
from app.core import shutdown as sd
|
||||
from app.services import scrape_runs as app_runs
|
||||
|
||||
# Обе копии runs-модуля: у app counters ЗАМЕНЯЮТСЯ, у kit мержатся (#3390) — гейт по
|
||||
# статусу нужен обеим, и проверяется на обеих одним и тем же телом теста.
|
||||
# Оба пути импорта runs-модуля (с #3390 это ОДИН объект: app.services.scrape_runs —
|
||||
# алиас kit'а): гейт по статусу проверяется через каждый из них одним телом теста.
|
||||
_RUNS_MODULES = {"kit": kit_runs, "app": app_runs}
|
||||
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ class _FakeRuns:
|
|||
if row is None or row["status"] != "running":
|
||||
return # боевой UPDATE ... WHERE status = 'running' — no-op
|
||||
row["status"] = "done"
|
||||
row["counters"] = dict(counters) # app-копия ЗАМЕНЯЕТ counters (#3390)
|
||||
row["counters"] = dict(counters) # строже боевого мержа (#3390) — см. докстринг модуля
|
||||
|
||||
|
||||
def _make_sched(source: str) -> dict[str, Any]:
|
||||
|
|
@ -283,7 +283,7 @@ async def test_hard_cancel_does_not_log_drained_cleanly(
|
|||
assert "drained and exited cleanly" not in caplog.text
|
||||
|
||||
|
||||
# ── 5. Пульс не пишет по финализированной строке (обе копии update_heartbeat) ────
|
||||
# ── 5. Пульс не пишет по финализированной строке (оба пути импорта) ─────────────
|
||||
|
||||
|
||||
class _RunRowDb:
|
||||
|
|
@ -293,8 +293,8 @@ class _RunRowDb:
|
|||
Допустимые статусы вычитываются из SQL (`status = 'x'` / `status IN ('x', 'y')`), а не
|
||||
зашиты ожиданием теста: на коде без гейта (WHERE только по id) апдейт проходит по
|
||||
строке ЛЮБОГО статуса — и тест краснеет по значению, а не по отсутствию подстроки.
|
||||
Мерж jsonb (`||`) против замены (`CAST(:counters AS jsonb)`) — тоже по тексту: у
|
||||
kit- и app-копии он разный, а гейт нужен обеим.
|
||||
Мерж jsonb (`||`) против замены — тоже по тексту statement'а, а не по ожиданию:
|
||||
вернётся замена (было до #3390) — двойник это отразит, и тест покраснеет по значению.
|
||||
|
||||
SELECT'ы alert-хуков (`_alert_on_run_id`) не моделируются — они best-effort и
|
||||
возвращают пусто.
|
||||
|
|
@ -348,7 +348,8 @@ def test_heartbeat_does_not_erase_drain_mark_of_finalized_run(name: str) -> None
|
|||
Ровно эта последовательность достижима на проде: ветка таймаута `drain_inflight`
|
||||
оставляет задачу внешнему hard-cancel'у, и до него она успевает несколько итераций
|
||||
(`app/services/scheduler.py:141` — пульс на каждый батч). Без гейта по статусу
|
||||
app-копия ЗАМЕНЯЛА counters и стирала метку: оборванный прогон снова читался как
|
||||
пульс лёг бы поверх метки (одноимённые ключи мерж перезаписывает, а до #3390
|
||||
app-копия и вовсе ЗАМЕНЯЛА весь словарь): оборванный прогон снова читался бы как
|
||||
полный проход, а резюм его не подхватывал.
|
||||
"""
|
||||
mod = _RUNS_MODULES[name]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue