From a49ee2ccfc41f0bf2bca7a7a4d3f3aee0b4e1cb0 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 11:01:41 +0500 Subject: [PATCH] =?UTF-8?q?fix(domrf=5Fkn):=20=D0=B4=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=BC=D0=B8=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20sha256-fallback=20=D0=B4=D0=BB=D1=8F=20fla?= =?UTF-8?q?t=5Fid=20(#1208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallback flat_id в _norm_flat использовал abs(hash(elem)) % (2**63 - 1). В CPython hash(str) РАНДОМИЗИРОВАН per-process (PYTHONHASHSEED нигде в репо не зафиксирован — uvicorn/celery не выставляют его). Эффект: - При resume упавшего sweep (resume_kn_run в новом процессе воркера) до 10 объектов после checkpoint перечитываются. Квартиры без flatId получают ДРУГОЙ hash-id → ON CONFLICT (id, snapshot_date) не срабатывает → дубли строк одной квартиры в одном snapshot. - То же при повторном прогоне за ту же дату — каждый раз новый id. - Дубли инфлируют все агрегации (units_sold/price медианы, supply_layers). Patch: - abs(hash(elem)) → int.from_bytes(sha256(elem)[:8], "big") % (2**63 - 1). - sha256 стабилен между процессами/перезапусками. 8 байт → BIGINT-fit. 6 новых юнит-тестов (test_domrf_kn_normalize.py): formula matches sha256, stable across calls, distinct elems→distinct ids, flatId wins over fallback, no-id→None, BIGINT-fit. ruff clean. Closes #1208 --- backend/app/services/scrapers/domrf_kn.py | 12 +++- .../scrapers/test_domrf_kn_normalize.py | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 backend/tests/services/scrapers/test_domrf_kn_normalize.py diff --git a/backend/app/services/scrapers/domrf_kn.py b/backend/app/services/scrapers/domrf_kn.py index 9f6f2353..5cdc0094 100644 --- a/backend/app/services/scrapers/domrf_kn.py +++ b/backend/app/services/scrapers/domrf_kn.py @@ -17,6 +17,7 @@ auth shipped in their frontend bundle). from __future__ import annotations import asyncio +import hashlib import json import logging from datetime import date, datetime @@ -395,10 +396,17 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]: """ flat_id = _g(row, "flatId", "id") if flat_id is None: - # Fallback: hash of elemId (stable, fits BIGINT). Without an id we cannot UPSERT. + # Fallback: ДЕТЕРМИНИРОВАННЫЙ sha256-digest от elemId (#1208). Раньше + # использовался hash(elem), который в CPython РАНДОМИЗИРОВАН per-process + # (PYTHONHASHSEED нигде в репо не зафиксирован) → при resume упавшего + # sweep (новый процесс воркера) та же квартира получала ДРУГОЙ id → + # ON CONFLICT (id, snapshot_date) не срабатывал → дубли строк одной + # квартиры в одном snapshot, инфляция агрегаций. sha256 стабилен между + # процессами/перезапусками. Берём 8 байт (BIGINT'у достаточно). elem = _g(row, "elemId") if elem: - flat_id = abs(hash(elem)) % (2**63 - 1) + digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8] + flat_id = int.from_bytes(digest, "big") % (2**63 - 1) price_rub = _g(row, "price") price_per_m2 = _g(row, "pricePerSquareMeter") diff --git a/backend/tests/services/scrapers/test_domrf_kn_normalize.py b/backend/tests/services/scrapers/test_domrf_kn_normalize.py new file mode 100644 index 00000000..a8d98e62 --- /dev/null +++ b/backend/tests/services/scrapers/test_domrf_kn_normalize.py @@ -0,0 +1,57 @@ +"""Unit-тесты для нормализаторов scrapers/domrf_kn (#1208). + +Главный сценарий: детерминированный fallback flat_id через sha256 — НЕ +рандомизированный hash(). Раньше при resume упавшего sweep (новый процесс +воркера) та же квартира получала другой id → ON CONFLICT не срабатывал → +дубли в одном snapshot. +""" + +from __future__ import annotations + +import hashlib + +from app.services.scrapers.domrf_kn import _norm_flat + + +def _expected_sha_fallback(elem: str) -> int: + """Зеркало формулы из _norm_flat — для прямого сравнения в тестах.""" + digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8] + return int.from_bytes(digest, "big") % (2**63 - 1) + + +class TestFlatIdFallbackDeterministic: + """flat_id fallback ОБЯЗАН быть детерминированным между процессами/перезапусками.""" + + def test_fallback_matches_sha256_formula(self) -> None: + row = {"elemId": "abc123"} + out = _norm_flat(row, region_cd=66) + assert out["id"] == _expected_sha_fallback("abc123") + + def test_fallback_stable_across_calls(self) -> None: + # Тот же elemId → тот же id. (Раньше hash() мог дать другой id в новом процессе.) + row = {"elemId": "stable-elem-id"} + a = _norm_flat(row, region_cd=66)["id"] + b = _norm_flat(row, region_cd=66)["id"] + assert a == b + assert a == _expected_sha_fallback("stable-elem-id") + + def test_different_elems_give_different_ids(self) -> None: + a = _norm_flat({"elemId": "elem-A"}, region_cd=66)["id"] + b = _norm_flat({"elemId": "elem-B"}, region_cd=66)["id"] + assert a != b + + def test_flat_id_present_wins_over_fallback(self) -> None: + # Если flatId есть — используем его, fallback не активируется. + row = {"flatId": 12345, "elemId": "should-be-ignored"} + out = _norm_flat(row, region_cd=66) + assert out["id"] == 12345 + + def test_no_flat_id_no_elem_id_returns_none(self) -> None: + out = _norm_flat({}, region_cd=66) + assert out["id"] is None + + def test_fallback_fits_bigint(self) -> None: + # 8-байтовый digest усечён в pos-int, всегда < 2**63. + out = _norm_flat({"elemId": "x" * 1000}, region_cd=66) + assert out["id"] is not None + assert 0 <= out["id"] < 2**63