fix(domrf_kn): детерминированный sha256-fallback для flat_id (#1208)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m27s
CI / backend-tests (pull_request) Successful in 6m27s

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
This commit is contained in:
Light1YT 2026-06-13 11:01:41 +05:00
parent 0a8785d548
commit a49ee2ccfc
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 from __future__ import annotations
import asyncio import asyncio
import hashlib
import json import json
import logging import logging
from datetime import date, datetime 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") flat_id = _g(row, "flatId", "id")
if flat_id is None: 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") elem = _g(row, "elemId")
if elem: 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_rub = _g(row, "price")
price_per_m2 = _g(row, "pricePerSquareMeter") 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