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