`_extract_act_date` брал ПЕРВОЕ «от DD.MM.YYYY» во всём OCR-тексте.
«Сообщение о планируемом изъятии» открывается списком оснований, и первой
строкой там стоит
«Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1
«Об утверждении Генерального плана города»»
— Генплан, а не акт об изъятии. На проде это дало 11 строк из 27 с датой
2004-07-06 при проектах 2020 и 2022 годов, причём одну и ту же дату
получили ДВА разных документа (развязка на Сибирском тракте и улица
Энергостроителей). Совпадение даты у несвязанных актов и было первым
признаком, что дата не своя.
Дата принимается, только если в 120 символах перед ней стоит слово
«постановлени». Ссылки-помехи в этих документах — «Решение … Думы» и
«Приказ Министерства» — его не содержат. Окно шире самой фразы, потому
что OCR перемешивает колонки таблицы и вклинивает в неё чужой текст
(«…Администрации города документами) Екатеринбурга от 19.04.2019…»).
Если подходящей даты нет — None. Дата чужого документа хуже пустоты: по
ней нельзя ни отфильтровать актуальные изъятия, ни сверить срок, и она
неотличима от настоящей.
Калибровка не на одном образце: все пять исходных PDF загружены и
распознаны тем же трактом, что использует загрузчик (ocr_pdf_text в
прод-контейнере). Окна 80/120/160 дают одинаковые 5 из 5. Более узкое
правило (плюс «администраци») давало те же 5 из 5, но ломало законный
случай «Постановление № 509-ПП» — областной акт без слова «администрация»,
уже закреплённый тестом test_act_date_extracted_from_text; взято широкое.
Сквозная проверка: патченный код прогнан по всем пяти распознанным
текстам целиком — 27 записей, ровно столько же, сколько строк в
land_reservation; 11 меняют 2004-07-06 на настоящую дату, 16 не двигаются.
Двусторонне: против origin/main три теста красные с реальным неверным
значением ('2004-07-06'), ни одного TypeError — тесты идут через
extract_izyatie_records, чья сигнатура одинакова на обеих сторонах.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_detect_kind` проверял «резервир» первым и возвращал «резервирование»
безусловно. Постановление об изъятии, где резервирование упомянуто
вскользь — типовая формулировка «ранее зарезервированных земель», ссылка
на утративший силу акт, — классифицировалось как резервирование.
Ошибка не единичная: `kind` в `extract_reservations` один на весь
документ, поэтому неверный тип уходит в КАЖДУЮ строку land_reservation
по этому акту.
Побеждает то слово, что встретилось раньше. Тема документа стоит в
заголовке, поэтому позиция — сигнал сильнее порядка проверок, и он
симметричен: заголовок «О резервировании» так же выигрывает у «изъятия»
в теле. Простая смена порядка проверок этой симметрии не даёт — на неё
поставлен отдельный контроль.
Текущих ошибок на проде нет, и это измерено: в land_reservation 27
строк, все из источника izyatie_ekb_ocr, все «изъятие», ни в одной
выдержке слова «резервир» не встречается. Правка закрывает возможность,
а не чинит существующую порчу.
Двусторонне: против origin/main два теста красные с конкретным неверным
значением (`assert 'резервирование' == 'изъятие'`). Контроли —
симметрия заголовка, одиночные маркеры, откат к default_kind — зелёные с
обеих сторон. Формулировки в тестах взяты с прода дословно (basis_act).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Регекс статусов каталога ДОМ.РФ искал ключевые слова без защиты от
отрицания, поэтому русские отрицательные формы давали ОБРАТНЫЙ статус:
«нереализована» → содержит «реализована» → sold
«не продана» → содержит «продана» → sold
«не забронирована» → содержит «забронирована» → reserved
«не в продаже» → содержит «в продаже» → free
Свободная квартира попадала бы в domrf_kn_flats.status проданной.
Отрицание теперь гасит токен, а не переворачивает его. «Не забронирована»
не означает ни sold, ни free; «не продана → free» — это вывод, а не факт
со страницы. Лучше отсутствие статуса, чем неверный.
Заодно вылечен второй дефект того же места: разбор брал ПЕРВОЕ совпадение
в блоке, поэтому «Квартира не продана. Статус: в продаже» на main даёт
sold. Новый _status_in_text перебирает все вхождения и берёт первое
неотрицаемое — настоящий статус в блоке больше не теряется.
Текущий эффект на проде НУЛЕВОЙ, и это проверено, а не предположено:
catalog_updated_at пуст у всех 983 088 строк domrf_kn_flats (скрапер не
записал ни одной), таска scrape_kn_catalog_flats закомментирована в
beat_schedule.py из-за WAF-cooldown. Существующие значения status
(free 25 656 / sold 3 122 / booked 641) пришли из kn-API — среди них
'booked', которого нет в константах этого модуля. Правка
предупредительная: при включении пути дефект инвертировал бы статусы молча.
Двусторонне: против origin/main 7 тестов красные, каждый с конкретным
неверным значением («Нереализована» → 'sold'). Контроли (7 обычных форм
и «не» в хвосте слова «Цене») зелёные с обеих сторон. Старая сюита
парсера — 320 passed, регрессий нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`objective_scrape_runs` не подметалась ничем: `worker_ready` знал только
про `kn_scrape_runs` и `nspd_geo_jobs`. На проде 20.08.2026 в ней висело
6 строк `status='running'` с 17.05 — 94 суток, при 71 `done` и НИ ОДНОМ
`failed`. Отсутствие `failed` — след отравления сессии, из-за которого
`_finish_run(status='failed')` не мог записаться (причина починена
#2972). Причина устранена, но жёсткое убийство воркера (редеплой, OOM)
по-прежнему оставляет `running` навсегда: у Объектива нет ни своего
cleanup_zombies, ни снапшота для resume.
Тот же инвариант, что у kn: на worker_ready активных воркеров нет,
значит любая строка `running` осиротела. Resume не ставим —
возобновлять нечего.
`finished_at` ставится НЕ NOW(), а `COALESCE(heartbeat_at, started_at)`:
прогон, умерший 94 дня назад, не должен читаться как «завершён только
что». Монитору свежести это безразлично в обе стороны — `last_success_at`
и `recent_output` считаются только по `status='done'`, а
`last_attempt_at`/`last_status` — по `started_at`, так что зомби-строки
не попадают в него ни одним столбцом (проверено по коду
_FRESHNESS_SOURCES, а не предположено).
Двусторонне: против origin/main три теста красные по существу («не
трогает objective_scrape_runs», функция при этом отрабатывает 4 запроса
— то есть краснота не от отсутствующего символа). Мутационно проверены
оба контроля: снятие `WHERE status='running'` роняет
test_only_running_rows_are_touched, замена на `finished_at = NOW()`
роняет test_finished_at_is_last_sign_of_life_not_now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 тасок объявляли `max_retries=2`, но ретраи не реализовывали: ни
`autoretry_for` в декораторе, ни вызова `self.retry()` в теле. Celery в
таком виде параметр не применяет — при исключении таска падает с первой
попытки. Читающий код видит «до 3 попыток», а их одна.
Убран `max_retries` у: cbr_macro_sync, rosstat_macro_sync,
developer_registry_refresh, location_refresh, mv_sales_tracker_refresh,
refresh_analytics, refresh_layout_velocity, refresh_quarter_price_index,
scrape_objective.sync_objective_group, supply_layers_refresh,
scrape_kn.scrape_kn_region. Заодно убран `bind=True` там, где `self` не
использовался вовсе; в `scrape_kn_region` он оставлен — `self.request.id`
пишется в kn_scrape_log.
Не тронуты и не должны быть: `resume_kn_run` (max_retries=12 +
настоящий self.retry()), `nspd_sync`/`scrape_cadastre` (autoretry_for),
`nspd_geo`/`objective_etl` (max_retries=0 — честное «ретраев нет»).
Гейт `test_2464_retry_config_is_real.py` разбирает AST всех модулей
`app/workers/tasks/` и требует: если декоратор объявляет ненулевой
max_retries, в нём есть autoretry_for либо в теле функции есть
self.retry(). Три таски из одиннадцати гейт нашёл сверх списка эпика.
Проверка гейта: с фиксом зелено, при возврате `max_retries=2` в
supply_layers_refresh — красно с указанием на эту таску. Плюс два
контроля: гейт видит ≥20 тасок (не молчит из-за пустой выборки) и
признаёт обе законные формы ретраев.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create_profile/update_profile делают «снять is_default у всех → поставить новому»
двумя отдельными операторами. Между ними инвариант нарушен, и при одновременных
запросах у пользователя может оказаться ДВА профиля с is_default=TRUE. А читающий
_SELECT_DEFAULT брал LIMIT 1 БЕЗ ORDER BY — выбор молча перескакивал между ними от
запроса к запросу.
Два рубежа, а не один:
миграция 190 — частичный уникальный индекс (user_id) WHERE is_default: два
дефолта становятся невозможными на уровне БД;
ORDER BY id — детерминированный выбор, если индекс когда-нибудь снимут.
Соседние запросы этого файла тай-брейк по id уже имеют.
Индекс не мешает штатной переустановке дефолта: порядок операторов в коде уже
правильный (сначала снять у всех, потом поставить), поэтому в момент проверки
дефолтов ноль. Это отдельно проверено тестом.
Безопасность миграции: на проде нарушений нет — у admin один дефолт, у __system__
ноль, ни одного пользователя с двумя. Таблица в 4 строки, индексируется мгновенно.
lock_timeout проставлен по #2752.
Тест проверяет ПОВЕДЕНИЕ на живом Postgres: вторая установка дефолта отвергается
базой. Плюс фальсификация — без индекса два дефолта вставляются молча; без неё
зелёный тест неотличим от «оно и так не вставлялось». Плюс два контроля:
переустановка дефолта работает, разные пользователи сохраняют свои.
Тест про ORDER BY вынесен в tests/services/site_finder, а НЕ внесён в
skip_allowlist: живой БД он не требует, и пропускаться вместе с DB-тестами ему
незачем. Против origin/main он краснеет, показывая запрос без тай-брейка.
Прогоны: без БД — 648 passed rc=0; с БД — 5 passed rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Докстринг _resume_zombie_runs формулирует инвариант прямо:
No time threshold: by definition, on worker_ready ANY 'running' row is a zombie
because there is no active worker. Previously we required heartbeat … stayed in
'running' status forever and required manual cancel/resume.
А запрос добавлял `AND objects_snapshot IS NOT NULL`. Строка без снапшота в выборку
не попадала и оставалась 'running' НАВСЕГДА — ровно то состояние, ради устранения
которого функция и заводилась.
Снапшот нужен, но не для пометки, а для ВОЗОБНОВЛЕНИЯ: resume_kn_run восстанавливает
обход «using objects_snapshot» и без него упал бы. Поэтому зомби помечаются все, а
resume ставится только тем, кого есть чем возобновить; остальные получают честную
причину в error вместо тишины.
Про тест — отдельно, потому что первая версия была негодной. Двойник сессии отдавал
строки независимо от WHERE, и на origin/main главный тест («строка не помечена»)
ПРОХОДИЛ, а краснели два других — по ложной причине. Научил двойник соблюдать ровно
тот фильтр, о котором спор, и сузил совпадение до `AND objects_snapshot IS NOT NULL`:
правка выносит то же выражение в список полей SELECT, и совпадение по голой подстроке
отсекало бы строки у исправленной версии тоже.
Против origin/main теперь:
строка без снапшота не помечена zombie → падает (UPDATE вообще не выполняется)
в смешанной выборке помечены не все → падает: {1,3} вместо {1,2,3}
невозобновляемому resume не ставится — контроль, зелёный с обеих сторон
возобновляемый получает resume как раньше — контроль, зелёный с обеих сторон
Первый контроль ловит «починку», ставящую resume всем подряд.
Замер прода 20.08: строк в 'running' сейчас нет, то есть правка предотвращает, а не
чинит. Из 20 исторических 'zombie' восемь — без objects_snapshot, так что случай
не гипотетический.
Прогоны: tests/workers rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>