fix(tradein-estimator): atomic IMV link, batched yandex history, drop redundant CAST

3 fixes inside services/estimator.py from 2026-05-24 audit:

* IMV link UPDATE moved inside main tx (finding #4) — closes race where two
  concurrent estimates sharing a cache_key could overwrite each other's
  estimate_id after the main commit.
* _save_yandex_history_items batched under single try/except (finding #5) —
  prior per-item db.rollback() destroyed the parent transaction; next
  iteration could run on a rolled-back session.
* Drop redundant CAST inside IS NOT NULL predicates in _fetch_analogs
  (finding #9) — CAST(NULL AS T) IS NOT NULL is equivalent to NULL IS NOT NULL
  but evaluates per-row. THEN-branch CASTs preserved (typed arithmetic).

test: update test_save_history_items_db_error_continues → batch rollback semantics
This commit is contained in:
lekss361 2026-05-24 14:01:02 +03:00
parent 099b8e4b59
commit 872c83caf5
2 changed files with 78 additions and 66 deletions

View file

@ -343,8 +343,15 @@ def _save_yandex_history_items(
Idempotent via UNIQUE (source, ext_item_id); we synthesize ext_item_id from Idempotent via UNIQUE (source, ext_item_id); we synthesize ext_item_id from
(address|date|area|floor) hash since Yandex history items don't carry an (address|date|area|floor) hash since Yandex history items don't carry an
explicit ID. explicit ID.
Batch semantics (closes finding #5 from 2026-05-24 audit): all items
inserted under a single try/except; on any failure the whole batch is
rolled back as a unit (prior per-item rollback() destroyed the parent tx).
""" """
saved = 0 if not result.history_items:
return 0
rows = []
for item in result.history_items: for item in result.history_items:
# Synthesize stable ext_item_id (no native ID in valuation page) # Synthesize stable ext_item_id (no native ID in valuation page)
ext_seed = ( ext_seed = (
@ -352,48 +359,51 @@ def _save_yandex_history_items(
f"{item.start_price}|{item.last_price}" f"{item.start_price}|{item.last_price}"
) )
ext_item_id = hashlib.sha256(ext_seed.encode("utf-8")).hexdigest()[:32] ext_item_id = hashlib.sha256(ext_seed.encode("utf-8")).hexdigest()[:32]
try: rows.append({
db.execute( "ext_id": ext_item_id,
text( "rooms": item.rooms,
""" "area": item.area_m2,
INSERT INTO house_placement_history ( "floor": item.floor,
source, ext_item_id, "start_price": item.start_price,
rooms, area_m2, floor, "last_price": item.last_price,
start_price, start_price_date, "publish_date": item.publish_date,
last_price, last_price_date, "exposure": item.exposure_days,
exposure_days, "raw": json.dumps(item.model_dump(mode="json"), ensure_ascii=False),
raw_payload })
) VALUES (
'yandex_valuation', :ext_id, sql = text(
:rooms, :area, :floor, """
:start_price, :publish_date, INSERT INTO house_placement_history (
:last_price, :publish_date, source, ext_item_id,
:exposure, rooms, area_m2, floor,
CAST(:raw AS jsonb) start_price, start_price_date,
) last_price, last_price_date,
ON CONFLICT (source, ext_item_id) DO NOTHING exposure_days,
""" raw_payload
), ) VALUES (
{ 'yandex_valuation', :ext_id,
"ext_id": ext_item_id, :rooms, :area, :floor,
"rooms": item.rooms, :start_price, :publish_date,
"area": item.area_m2, :last_price, :publish_date,
"floor": item.floor, :exposure,
"start_price": item.start_price, CAST(:raw AS jsonb)
"last_price": item.last_price, )
"publish_date": item.publish_date, ON CONFLICT (source, ext_item_id) DO NOTHING
"exposure": item.exposure_days, """
"raw": json.dumps(item.model_dump(mode="json"), ensure_ascii=False), )
},
) try:
saved += 1 for row in rows:
except Exception as e: db.execute(sql, row)
logger.warning("yandex_valuation: failed to save history item: %s", e)
db.rollback()
continue
if saved:
db.commit() db.commit()
return saved return len(rows)
except Exception as e:
logger.warning(
"yandex_valuation: failed to save history batch (%d items): %s",
len(rows), e,
)
db.rollback()
return 0
# ── Public ─────────────────────────────────────────────────────────────────── # ── Public ───────────────────────────────────────────────────────────────────
@ -667,25 +677,25 @@ async def estimate_quality(
"expires_at": expires_at, "expires_at": expires_at,
}, },
) )
db.commit()
# Link saved IMV evaluation к этому estimate_id (для analytics joining) # Link saved IMV evaluation к этому estimate_id атомарно с основным INSERT
# (closes finding #4 from 2026-05-24 audit — prior code committed estimate first,
# then UPDATEd IMV in a separate tx, racing against concurrent estimators
# sharing the same cache_key).
if imv_eval is not None: if imv_eval is not None:
try: db.execute(
db.execute( text(
text( """
""" UPDATE avito_imv_evaluations
UPDATE avito_imv_evaluations SET estimate_id = CAST(:estimate_id AS uuid)
SET estimate_id = CAST(:estimate_id AS uuid) WHERE cache_key = :cache_key
WHERE cache_key = :cache_key AND (estimate_id IS NULL OR estimate_id = CAST(:estimate_id AS uuid))
AND (estimate_id IS NULL OR estimate_id = CAST(:estimate_id AS uuid)) """
""" ),
), {"estimate_id": str(estimate_id), "cache_key": imv_eval.cache_key},
{"estimate_id": str(estimate_id), "cache_key": imv_eval.cache_key}, )
)
db.commit() db.commit()
except Exception as e:
logger.warning("imv: failed to link estimate_id to evaluation: %s", e)
logger.info( logger.info(
"estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s%s", "estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s%s",
@ -819,13 +829,13 @@ def _fetch_analogs(
-- без типа PostgreSQL "could not determine data type of parameter" -- без типа PostgreSQL "could not determine data type of parameter"
-- (AmbiguousParameter). Явный тип снимает неоднозначность. -- (AmbiguousParameter). Явный тип снимает неоднозначность.
+ CASE + CASE
WHEN CAST(:target_year AS integer) IS NOT NULL WHEN :target_year IS NOT NULL
AND year_built IS NOT NULL AND year_built IS NOT NULL
THEN abs(year_built - CAST(:target_year AS integer)) / 12.0 THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
ELSE 0 ELSE 0
END END
+ CASE + CASE
WHEN CAST(:target_house_type AS text) IS NOT NULL WHEN :target_house_type IS NOT NULL
AND house_type IS NOT NULL AND house_type IS NOT NULL
AND house_type <> CAST(:target_house_type AS text) AND house_type <> CAST(:target_house_type AS text)
THEN 1.5 THEN 1.5
@ -838,13 +848,13 @@ def _fetch_analogs(
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
/ 1000.0 / 1000.0
+ CASE + CASE
WHEN CAST(:target_year AS integer) IS NOT NULL WHEN :target_year IS NOT NULL
AND year_built IS NOT NULL AND year_built IS NOT NULL
THEN abs(year_built - CAST(:target_year AS integer)) / 12.0 THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
ELSE 0 ELSE 0
END END
+ CASE + CASE
WHEN CAST(:target_house_type AS text) IS NOT NULL WHEN :target_house_type IS NOT NULL
AND house_type IS NOT NULL AND house_type IS NOT NULL
AND house_type <> CAST(:target_house_type AS text) AND house_type <> CAST(:target_house_type AS text)
THEN 1.5 THEN 1.5

View file

@ -188,10 +188,12 @@ def test_save_history_items_ext_id_stable_across_calls():
assert ext_ids_1 == ext_ids_2 assert ext_ids_1 == ext_ids_2
def test_save_history_items_db_error_continues(): def test_save_history_items_db_error_rolls_back_batch():
"""One item failing doesn't abort the others.""" """Any item failing rolls back the whole batch — batch semantics (finding #5)."""
db = MagicMock() db = MagicMock()
db.execute.side_effect = [RuntimeError("first row fails"), None] db.execute.side_effect = [RuntimeError("first row fails"), None]
result = _sample_result() result = _sample_result()
saved = _save_yandex_history_items(db, result) saved = _save_yandex_history_items(db, result)
assert saved == 1 # second item still saved assert saved == 0 # whole batch rolled back
db.rollback.assert_called_once()
db.commit.assert_not_called()