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
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:
parent
0a8785d548
commit
a49ee2ccfc
2 changed files with 67 additions and 2 deletions
|
|
@ -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")
|
||||
|
|
|
|||
57
backend/tests/services/scrapers/test_domrf_kn_normalize.py
Normal file
57
backend/tests/services/scrapers/test_domrf_kn_normalize.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue