All checks were successful
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 57s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
A. ДОМ.РФ year backfill: авторитетный commission_year больше не экранируется невозможным существующим year_built. Валидное существующее значение выигрывает (COALESCE-семантика #2013 сохранена), но NULL/impossible (< 1850, > текущий+2, 0) заменяется валидным commission_year → zhkh_year. parse_int_field получил sanity-гейт [min,max] — мусорный commission_year не попадает в staging (7 таких строк в текущем staging). B. propagate_listings_year: добавлен link-consistency guard. Раньше копировал houses.year_built на listings по house_id_fk без проверки — перепривязанный FK впрыскивал чужой когортный год. Теперь пропагация только если координаты объявления в пределах 500м от геометрии дома (ST_DWithin), либо (без коорд.) консервативный address-фоллбек по short_address. Live: блокирует 1 из 10 текущих кандидатов (>500м mislink). C. rosreestr_dkp_import: per-row INSERT-ошибки отделены от dedup-skip. Раньше except инкрементил тот же batch_skipped, что и легитимный ON CONFLICT — сбой маскировался под дедуп и run рапортовал success. Отдельный rows_errored + гейт по доле ошибок (> 5% → run FAILED, не silent-green). D. rosreestr_dkp_import: ON CONFLICT DO NOTHING → DO UPDATE изменяемых сырых фактов Росреестра (price/area/rooms/floor/year/deal_date/...), чтобы исправленный/переопубликованный квартал обновлялся. Обогащение (lat/lon/geom/geocode_tried_at и пр.) не в SET-списке — не затирается. IS DISTINCT FROM guard сохраняет идемпотентность resume; RETURNING (xmax=0) отличает insert от update (rows_inserted vs rows_updated).
503 lines
26 KiB
Python
503 lines
26 KiB
Python
"""Shared scheduler helpers (#2397 Part C — legacy scheduler_loop removed).
|
||
|
||
История: до #2192 этот модуль нёс полный in-app asyncio scheduler (`scheduler_loop`,
|
||
27-веточный `if/elif` dispatch, `trigger_*_run` per-source launchers, `get_due_schedules`,
|
||
`reap_zombies`, `_claim_run` advisory-lock claim, `_spawn_tracked`/`_drain_inflight`
|
||
graceful-drain machinery). #2192 представил kit-путь (`scraper_kit.orchestration.scheduler`
|
||
+ `app.services.product_handlers`) как ship-dark alternative; прод переключился на него
|
||
(`USE_KIT_SCHEDULER=true` в tradein-scraper, см. docker-compose.prod.yml). #2397 Part C
|
||
убрал legacy `scheduler_loop` и все `trigger_*`/dispatch-функции — kit теперь единственный
|
||
scheduling-путь (`app/scheduler_main.py` безусловно запускает `_run_kit_scheduler()`).
|
||
|
||
Что осталось в этом модуле — НЕ scheduler-loop, а функции с живыми потребителями вне
|
||
удалённой machinery:
|
||
- `compute_next_run_at` — читается admin.py (операторский предпросмотр "next run").
|
||
- `has_running_run` — читается admin.py (UI-индикатор "уже бежит").
|
||
- `import_rosreestr_dkp` — job-тело, вызываемое kit-handler'ом
|
||
product_handlers._job_rosreestr_dkp (lazy import).
|
||
- `_execute_cian_backfill` — job-тело, вызываемое kit-handler'ом
|
||
product_handlers._job_cian_history_backfill (lazy import).
|
||
|
||
Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
|
||
`scraper_kit/orchestration/scheduler.py` (см. test_scraper_kit_scheduler_parity.py).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import random
|
||
from datetime import UTC, datetime, time, timedelta
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.shutdown import shutdown_requested
|
||
from app.services import scrape_runs as runs_mod
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
|
||
# которой прогон помечается FAILED, а не silent-green (Fix C). Единичные битые строки
|
||
# (редкий bad row) не валят импорт; систематический сбой (≈100% ошибок) — валит.
|
||
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
|
||
|
||
|
||
def compute_next_run_at(
|
||
window_start_hour: int,
|
||
window_end_hour: int,
|
||
*,
|
||
now: datetime | None = None,
|
||
interval_days: int = 1,
|
||
) -> datetime:
|
||
"""Pick random datetime в window [start, end) UTC, через interval_days суток после now.
|
||
|
||
interval_days задаёт каденс источника: 1 (default) = daily (back-compat), 7 = weekly.
|
||
Берётся из schedule.default_params["interval_days"] вызывающим кодом; отсутствие ключа
|
||
→ 1 → прежнее ежедневное поведение.
|
||
|
||
Если window_end_hour <= window_start_hour → cross-midnight window
|
||
(например 22→3 → окно 22:00-23:59 ИЛИ 00:00-02:59).
|
||
"""
|
||
now = now or datetime.now(tz=UTC)
|
||
interval_days = max(1, int(interval_days))
|
||
# Целевая дата = now + interval_days суток (interval_days=1 → завтра, как раньше).
|
||
target = (now + timedelta(days=interval_days)).date()
|
||
|
||
if window_end_hour > window_start_hour:
|
||
# Обычное окно (например 2..5 → 02:00-04:59)
|
||
start_seconds = window_start_hour * 3600
|
||
end_seconds = window_end_hour * 3600
|
||
rand_seconds = random.randint(start_seconds, end_seconds - 1)
|
||
return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=rand_seconds)
|
||
else:
|
||
# Cross-midnight (22..3 → 22:00-23:59 + 00:00-02:59)
|
||
# Длина окна = (24-start) + end часов
|
||
total_seconds = ((24 - window_start_hour) + window_end_hour) * 3600
|
||
rand_seconds = random.randint(0, total_seconds - 1)
|
||
# Если rand попадает в первую часть (start..24)
|
||
first_half = (24 - window_start_hour) * 3600
|
||
if rand_seconds < first_half:
|
||
# interval_days=1: текущая дата (если окно ещё не наступило сегодня) или next day.
|
||
# interval_days>1: всегда целевая дата (стаггер на N суток вперёд).
|
||
today_ok = interval_days == 1 and now.hour < window_start_hour
|
||
base_date = now.date() if today_ok else target
|
||
return datetime.combine(base_date, time(0, 0), tzinfo=UTC) + timedelta(
|
||
seconds=window_start_hour * 3600 + rand_seconds
|
||
)
|
||
else:
|
||
# Во второй части (0..end), целевого дня
|
||
offset = rand_seconds - first_half
|
||
return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=offset)
|
||
|
||
|
||
def has_running_run(db: Session, source: str) -> bool:
|
||
"""Есть ли активный run для source (status='running')."""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT 1 FROM scrape_runs
|
||
WHERE source = :source AND status = 'running'
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"source": source},
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
|
||
async def _execute_cian_backfill(
|
||
db: Session,
|
||
*,
|
||
run_id: int,
|
||
params: dict[str, Any],
|
||
) -> None:
|
||
"""Orchestrate Cian history backfill with heartbeat + checkpoint.
|
||
|
||
Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat)
|
||
before and after the batch call for zombie-detection visibility.
|
||
|
||
Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS
|
||
NULL via LEFT JOIN — so re-running after a partial completion naturally skips
|
||
already-processed rows (idempotent by design).
|
||
|
||
Params (from default_params jsonb):
|
||
batch_size: int — rows per run (listings + houses counted separately).
|
||
"""
|
||
from app.tasks.cian_history_backfill import backfill_cian_history
|
||
|
||
batch_size = int(params.get("batch_size", 100))
|
||
counters: dict[str, int] = {
|
||
"listings_processed": 0,
|
||
"listings_succeeded": 0,
|
||
"listings_failed": 0,
|
||
"houses_processed": 0,
|
||
"houses_succeeded": 0,
|
||
"houses_failed": 0,
|
||
}
|
||
|
||
try:
|
||
runs_mod.update_heartbeat(db, run_id, counters)
|
||
|
||
result = await backfill_cian_history(
|
||
db,
|
||
batch_size=batch_size,
|
||
do_listings=True,
|
||
do_houses=True,
|
||
do_valuations=False,
|
||
)
|
||
|
||
counters = {
|
||
"listings_processed": result.listings_processed,
|
||
"listings_succeeded": result.listings_succeeded,
|
||
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
|
||
"houses_processed": result.houses_processed,
|
||
"houses_succeeded": result.houses_succeeded,
|
||
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
|
||
"duration_sec": int(result.duration_sec),
|
||
}
|
||
runs_mod.mark_done(db, run_id, counters)
|
||
logger.info(
|
||
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
|
||
run_id,
|
||
result.listings_succeeded,
|
||
result.listings_total,
|
||
result.houses_succeeded,
|
||
result.houses_total,
|
||
result.duration_sec,
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("scheduler: cian_history_backfill run_id=%d failed", run_id)
|
||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||
raise
|
||
|
||
|
||
def import_rosreestr_dkp(
|
||
db: Session,
|
||
run_id: int,
|
||
params: dict[str, Any],
|
||
) -> None:
|
||
"""Import ДКП-сделок из gendesign rosreestr_deals через postgres_fdw.
|
||
|
||
Python-порт import-rosreestr.sh (Variant C из #563).
|
||
|
||
Источник: foreign table gendesign_rosreestr_deals (создана в migration 072).
|
||
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
|
||
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
|
||
|
||
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург —
|
||
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
|
||
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
|
||
сидящих в source foreign table). address и deals.city строятся из реального city
|
||
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
|
||
источника (= 66 при текущем фильтре).
|
||
|
||
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
|
||
- region_code = 66 (вся Свердловская область, все города)
|
||
- city IS NOT NULL AND trim(city) != '' (непустой город → корректный address)
|
||
- realestate_type_code = '002001003000' (квартира)
|
||
- area BETWEEN 18 AND 200
|
||
- deal_price BETWEEN 1000000 AND 100000000
|
||
- street IS NOT NULL AND trim(street) != ''
|
||
- doc_type = 'ДКП' (только вторичка — #549 / Fix_Rosreestr_Dkp_Filter_May24)
|
||
- period_start_date >= since (default '2024-01-01')
|
||
|
||
dedup_hash: 'ros:dkp:' || id — плоский натуральный ключ (инъективный, без коллизий,
|
||
human-readable). До #576 здесь был md5('ros:dkp:' || id); миграция 077 конвертировала
|
||
существующие строки. source_id хранит исходный rosreestr id (дедуп переустанавливаем).
|
||
Rooms: выводятся из площади (Росреестр не отдаёт кол-во комнат).
|
||
|
||
Batch-процессинг: читаем из FDW батчами по batch_size через cursor-based пагинацию
|
||
(WHERE id > last_id ORDER BY id). Heartbeat обновляется каждый батч (= checkpoint).
|
||
SAVEPOINT per row — один сбойный row не откатывает батч.
|
||
|
||
Координаты: NULL после импорта — геокодинг остаётся follow-up (geocode-deals).
|
||
TODO (follow-up): запустить geocode backfill после import.
|
||
|
||
Cleanup: удаляет legacy строки address='Екатеринбург, реальная сделка' (pre-#549).
|
||
"""
|
||
since: str = str(params.get("since", "2024-01-01"))
|
||
batch_size: int = int(params.get("batch_size", 2000))
|
||
|
||
counters: dict[str, int] = {
|
||
"rows_fetched": 0,
|
||
"rows_inserted": 0,
|
||
# rows_updated: ON CONFLICT DO UPDATE обновил существующую строку
|
||
# (исправленный/переопубликованный квартал — Fix D).
|
||
"rows_updated": 0,
|
||
# rows_skipped: ТОЛЬКО легитимный dedup-пропуск (строка уже есть, факты
|
||
# идентичны — DO UPDATE ... WHERE distinct не сработал).
|
||
"rows_skipped": 0,
|
||
# rows_errored: реальные per-row INSERT-ошибки, отделены от dedup-skip (Fix C),
|
||
# раньше обе категории клались в rows_skipped → систематический сбой выглядел
|
||
# как обычный дедуп и прогон рапортовал success.
|
||
"rows_errored": 0,
|
||
"batches_done": 0,
|
||
}
|
||
|
||
# Cleanup legacy synthetic rows (pre-#549, idempotent)
|
||
try:
|
||
deleted = db.execute(
|
||
text("DELETE FROM deals WHERE address = CAST(:addr AS text) RETURNING id"),
|
||
{"addr": "Екатеринбург, реальная сделка"},
|
||
).fetchall()
|
||
db.commit()
|
||
if deleted:
|
||
logger.info(
|
||
"rosreestr_dkp_import run_id=%d: removed %d legacy synthetic rows",
|
||
run_id,
|
||
len(deleted),
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"rosreestr_dkp_import run_id=%d: cleanup failed (non-fatal): %s", run_id, exc
|
||
)
|
||
db.rollback()
|
||
|
||
last_id: int = 0
|
||
total_batches = 0
|
||
|
||
try:
|
||
while True:
|
||
cancelled = runs_mod.is_cancelled(db, run_id)
|
||
if cancelled or shutdown_requested():
|
||
if cancelled:
|
||
# User-cancel: семантика без изменений — mark_cancelled.
|
||
logger.info("rosreestr_dkp_import run_id=%d: cancelled by user", run_id)
|
||
runs_mod.mark_cancelled(db, run_id)
|
||
return
|
||
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
|
||
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
|
||
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run
|
||
# пере-сканирует с id=0 — INSERT ... ON CONFLICT DO UPDATE идемпотентен
|
||
# для неизменных строк (WHERE ... IS DISTINCT FROM пропускает совпадающие
|
||
# факты), поэтому повторный проход не плодит дубликаты и не трогает данные.
|
||
# mark_done выводит run из 'running' → reap_zombies его не тронет.
|
||
logger.info(
|
||
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
|
||
"(last_id=%d, batches=%d) and exiting",
|
||
run_id,
|
||
last_id,
|
||
total_batches,
|
||
)
|
||
runs_mod.update_heartbeat(db, run_id, counters)
|
||
runs_mod.mark_done(db, run_id, counters)
|
||
return
|
||
|
||
# Cursor-based pagination via foreign table gendesign_rosreestr_deals.
|
||
# FDW pushes WHERE + ORDER BY + LIMIT to gendesign-postgres automatically
|
||
# (postgres_fdw 'use_remote_estimate' is off by default but clause push-down
|
||
# still happens for simple predicates on the remote side).
|
||
batch_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT
|
||
id,
|
||
id AS source_id_src,
|
||
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
|
||
trim(city) || ', ' || trim(street) AS address,
|
||
region_code,
|
||
trim(city) AS city,
|
||
CASE
|
||
WHEN area < 30 THEN 0
|
||
WHEN area < 44 THEN 1
|
||
WHEN area < 62 THEN 2
|
||
WHEN area < 85 THEN 3
|
||
ELSE 4
|
||
END AS rooms,
|
||
round(area, 2) AS area_m2,
|
||
LEAST(
|
||
NULLIF(
|
||
-- #1525: '-?[0-9]+' сохраняет знак минус, иначе цоколь/подвал
|
||
-- '-1' импортируется как 1. Для '5/9' по-прежнему берётся 5.
|
||
substring(floor FROM '-?[0-9]+'), ''
|
||
)::int,
|
||
100
|
||
) AS floor_num,
|
||
year_build AS year_built,
|
||
round(deal_price)::bigint AS price_rub,
|
||
round(price_per_sqm)::int AS price_per_m2,
|
||
period_start_date AS deal_date
|
||
FROM gendesign_rosreestr_deals
|
||
WHERE region_code = 66
|
||
AND city IS NOT NULL AND trim(city) <> ''
|
||
AND realestate_type_code = '002001003000'
|
||
AND area BETWEEN 18 AND 200
|
||
AND deal_price BETWEEN 1000000 AND 100000000
|
||
AND street IS NOT NULL AND trim(street) <> ''
|
||
AND doc_type = 'ДКП'
|
||
AND period_start_date >= CAST(:since AS date)
|
||
AND id > CAST(:last_id AS bigint)
|
||
ORDER BY id
|
||
LIMIT CAST(:batch_size AS int)
|
||
"""),
|
||
{"since": since, "last_id": last_id, "batch_size": batch_size},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
if not batch_rows:
|
||
break
|
||
|
||
total_batches += 1
|
||
batch_inserted = 0
|
||
batch_updated = 0
|
||
batch_skipped = 0
|
||
batch_errored = 0
|
||
batch_max_id = last_id
|
||
|
||
for row in batch_rows:
|
||
row_id: int = int(row["id"])
|
||
if row_id > batch_max_id:
|
||
batch_max_id = row_id
|
||
|
||
try:
|
||
with db.begin_nested(): # SAVEPOINT per row
|
||
# Fix D: ON CONFLICT DO UPDATE вместо прежнего no-op-дедупа — при
|
||
# повторном импорте ИСПРАВЛЕННОГО/переопубликованного квартала обновляем
|
||
# сырые факты Росреестра. WHERE ... IS DISTINCT FROM оставляет
|
||
# неизменные строки нетронутыми (идемпотентность resume: строка
|
||
# без изменений → 0 returned → legit dedup-skip). source/source_id
|
||
# (identity) и dedup_hash (ключ конфликта) стабильны, не трогаем.
|
||
# Обогащение (lat/lon/geom/geocode_tried_at, cadastral_number,
|
||
# total_floors, house_type ...) НЕ в EXCLUDED-списке → сохраняется.
|
||
# RETURNING (xmax = 0): freshly-inserted → xmax=0 (was_inserted),
|
||
# обновлённая по ON CONFLICT → xmax<>0; отличаем insert от update.
|
||
result = db.execute(
|
||
text("""
|
||
INSERT INTO deals (
|
||
source, dedup_hash, source_id, address, region_code, city,
|
||
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
|
||
deal_date
|
||
)
|
||
VALUES (
|
||
'rosreestr',
|
||
CAST(:dedup_hash AS text),
|
||
CAST(:source_id AS text),
|
||
CAST(:address AS text),
|
||
CAST(:region_code AS int),
|
||
CAST(:city AS text),
|
||
CAST(:rooms AS int),
|
||
CAST(:area_m2 AS numeric),
|
||
CAST(:floor_num AS int),
|
||
CAST(:year_built AS int),
|
||
CAST(:price_rub AS bigint),
|
||
CAST(:price_per_m2 AS int),
|
||
CAST(:deal_date AS date)
|
||
)
|
||
ON CONFLICT (dedup_hash) DO UPDATE SET
|
||
address = EXCLUDED.address,
|
||
region_code = EXCLUDED.region_code,
|
||
city = EXCLUDED.city,
|
||
rooms = EXCLUDED.rooms,
|
||
area_m2 = EXCLUDED.area_m2,
|
||
floor = EXCLUDED.floor,
|
||
year_built = EXCLUDED.year_built,
|
||
price_rub = EXCLUDED.price_rub,
|
||
price_per_m2 = EXCLUDED.price_per_m2,
|
||
deal_date = EXCLUDED.deal_date
|
||
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
|
||
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
|
||
OR deals.city IS DISTINCT FROM EXCLUDED.city
|
||
OR deals.rooms IS DISTINCT FROM EXCLUDED.rooms
|
||
OR deals.area_m2 IS DISTINCT FROM EXCLUDED.area_m2
|
||
OR deals.floor IS DISTINCT FROM EXCLUDED.floor
|
||
OR deals.year_built IS DISTINCT FROM EXCLUDED.year_built
|
||
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
|
||
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
|
||
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
|
||
RETURNING (xmax = 0) AS was_inserted
|
||
"""),
|
||
{
|
||
"dedup_hash": row["dedup_hash"],
|
||
"source_id": str(row["source_id_src"]),
|
||
"address": row["address"],
|
||
"region_code": row["region_code"],
|
||
"city": row["city"],
|
||
"rooms": row["rooms"],
|
||
"area_m2": row["area_m2"],
|
||
"floor_num": row["floor_num"],
|
||
"year_built": row["year_built"],
|
||
"price_rub": row["price_rub"],
|
||
"price_per_m2": row["price_per_m2"],
|
||
"deal_date": row["deal_date"],
|
||
},
|
||
).fetchone()
|
||
if result is None:
|
||
# DO UPDATE ... WHERE distinct не сработал → строка есть и
|
||
# факты идентичны = легитимный dedup-skip (Fix C: только это
|
||
# теперь считается skip, INSERT-ошибки — отдельно ниже).
|
||
batch_skipped += 1
|
||
elif result[0]:
|
||
batch_inserted += 1
|
||
else:
|
||
batch_updated += 1
|
||
except Exception as exc:
|
||
# Fix C: реальная per-row INSERT-ошибка — ОТДЕЛЬНЫЙ счётчик, не skip.
|
||
logger.warning(
|
||
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
|
||
run_id,
|
||
row_id,
|
||
exc,
|
||
)
|
||
batch_errored += 1
|
||
|
||
last_id = batch_max_id
|
||
db.commit()
|
||
|
||
counters["rows_fetched"] += len(batch_rows)
|
||
counters["rows_inserted"] += batch_inserted
|
||
counters["rows_updated"] += batch_updated
|
||
counters["rows_skipped"] += batch_skipped
|
||
counters["rows_errored"] += batch_errored
|
||
counters["batches_done"] = total_batches
|
||
counters["last_id"] = last_id # type: ignore[assignment]
|
||
|
||
# Heartbeat = checkpoint: allows zombie detection + resume visibility
|
||
runs_mod.update_heartbeat(db, run_id, counters)
|
||
logger.info(
|
||
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
|
||
"inserted=%d updated=%d skipped=%d errored=%d last_id=%d",
|
||
run_id,
|
||
total_batches,
|
||
len(batch_rows),
|
||
batch_inserted,
|
||
batch_updated,
|
||
batch_skipped,
|
||
batch_errored,
|
||
last_id,
|
||
)
|
||
|
||
if len(batch_rows) < batch_size:
|
||
break # Last partial batch — no more rows
|
||
|
||
# Fix C: систематический per-row INSERT-сбой больше не рапортует success.
|
||
# Отделив rows_errored от rows_skipped, проверяем долю ошибок: выше порога —
|
||
# прогон FAILED (raise → внешний except → mark_failed), а не silent-green.
|
||
fetched = counters["rows_fetched"]
|
||
errored = counters["rows_errored"]
|
||
if fetched > 0 and errored / fetched > DKP_IMPORT_ERROR_RATE_THRESHOLD:
|
||
raise RuntimeError(
|
||
f"rosreestr_dkp_import: per-row INSERT error rate "
|
||
f"{errored}/{fetched} ({errored / fetched:.1%}) exceeds "
|
||
f"{DKP_IMPORT_ERROR_RATE_THRESHOLD:.0%} threshold — marking run failed"
|
||
)
|
||
|
||
runs_mod.mark_done(db, run_id, counters)
|
||
logger.info(
|
||
"rosreestr_dkp_import run_id=%d done: "
|
||
"total_fetched=%d inserted=%d updated=%d skipped=%d errored=%d batches=%d",
|
||
run_id,
|
||
counters["rows_fetched"],
|
||
counters["rows_inserted"],
|
||
counters["rows_updated"],
|
||
counters["rows_skipped"],
|
||
counters["rows_errored"],
|
||
total_batches,
|
||
)
|
||
|
||
except Exception as exc:
|
||
logger.exception("rosreestr_dkp_import run_id=%d failed at last_id=%d", run_id, last_id)
|
||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||
raise
|