fix(domrf_kn): детерминированный sha256-fallback для flat_id (#1208) #1262

Merged
bot-backend merged 1 commit from fix/domrf-kn-deterministic-flat-id-fallback into main 2026-06-13 06:09:55 +00:00
2 changed files with 67 additions and 2 deletions

View file

@ -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")

View file

@ -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