fix(tradein/cian): sanitize valuation cache writes (047 UNIQUE + 026/044 cols) #523
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#523
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/cian-history-sanitize"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Sanitize Cian valuation persistence — 3 latent bugs identified in audit before activating history scrapers (next PRs):
047 migration — add
UNIQUE(listing_id, change_time)tooffer_price_history. Without it the existingON CONFLICT DO NOTHINGincian_detail.save_detail_enrichmentonly suppresses PK conflicts and silently inserts dupes. Idempotent viaDO $$ pg_constraintguard (matches 045 style)._save_to_cache— fillprice_rub/accuracy(mig 026 original columns, currently NULL for Cian rows) andlow_price/high_price(mig 044 sale band, sourced fromsale_price_from/to)._save_to_cache— accept and persisthouse_id/listing_idFK (mig 044). On cache refreshCOALESCE(EXCLUDED, existing)preserves previously-set canonical link. Plumbed throughestimate_via_cian_valuationwith default None — currentestimator.pycallsite unchanged.Why now
Blocking-precursor for PR 2 (cian-detail + cian-newbuilding admin endpoints) and PR 4 (backfill task). Both will write to
offer_price_historyand need the UNIQUE for ON CONFLICT to work; PR 4 will passhouse_id/listing_idfrom canonical matching.Files
tradein-mvp/backend/data/sql/047_cian_history_sanitize.sql— NEWtradein-mvp/backend/app/services/scrapers/cian_valuation.py—_save_to_cache/_load_from_cache/estimate_via_cian_valuationTest plan
pg_constraint)_load_from_cacheround-trip:sale_price_from/topopulated fromlow_price/high_price/estimaterequest, verify new row inexternal_valuationshasprice_rub,accuracy,low_price,high_pricenon-NULLFollow-up PRs:
/admin/scrape/cian-detail+/admin/scrape/cian-newbuildingendpoints (unblock 404 sections on /scrapers/cian)Deep Code Review — verdict
Summary
d04ee38Verified
Column count 27=27 (INSERT vs VALUES) — recounted manually:
:sp/:sacorrectly bound twice (legacy mig 026price_rub/accuracy+ new mig 029sale_price_rub/sale_accuracy) — intentional per PR description.Migration 047 idempotency — DELETE-then-ALTER wrapped in single BEGIN/COMMIT. DO
guards onpg_constraint(matches 045 style).DELETE ... WHERE a.id > b.idkeeps lowest-id of each dup group. Re-run is a no-op. Auto-applied via.forgejo/workflows/deploy-tradein.yml(for sql_file in $(ls -1 backend/data/sql/*.sql | sort)) — safe withpsql ON_ERROR_STOP.ON CONFLICT semantics — Existing
INSERT INTO offer_price_history ... ON CONFLICT DO NOTHINGincian_detail.save_detail_enrichment:255is bare (no target). Before this PR, no UNIQUE existed besides PK (bigserial → never conflicts), so the clause was dead. After 047 the newoffer_price_history_listing_change_uqis suppressed by the bare DO NOTHING. Confirmed correct.Asymmetric UPDATE —
house_id/listing_iduseCOALESCE(EXCLUDED, existing)(canonical link is sticky); other cols use bareEXCLUDED.x(refresh). Matches description.psycopg v3 — All numeric/jsonb/bigint bound via
CAST(:x AS type), no::typeshorthand. No SQL injection surface (cache_key is sha256; address from authenticated estimator).Findings
LOW — test break (not CI-gated):
tradein-mvp/backend/tests/test_cian_valuation.py:336-347buildscached_rowplain dict withoutlow_price/high_pricekeys. After this PR_load_from_cachecalls_parse_num(row["low_price"])which will raiseKeyErroron plain dict (realRowMappingwould also raise — but real DB will have the columns). No CI gate on pytest in tradein workflows, so won't block deploy. Easy follow-up: add"low_price": None, "high_price": Noneto mock dict.Note (not blocker) — latent risk for PR4: Partial unique index
external_valuations_canonical_uniq_idx(mig 044) on(source, house_id, listing_id) WHERE both NOT NULL. CurrentON CONFLICT (source, cache_key)doesn't target it. When PR4 starts fillinghouse_id+listing_idfrom canonical matching, two requests for the same listing with different(area, rooms, floor)→ different cache_keys → same(source, house_id, listing_id)→ INSERT raises unhandled IntegrityError. Address in PR4 (e.g. dedupe by canonical key first, or use a different ON CONFLICT target). Current PR is unaffected because callsite inestimator.py:566still passeshouse_id=None/listing_id=None.Positive
None, no breaking signature change.-- Dependencies: 023...)._load_from_cacheSELECT addslow_price/high_price;_save_to_cachepopulates them.Verdict
APPROVE — no production risk, 1 cosmetic test-only break to address in follow-up, 1 documented latent risk for PR4 to handle. Merging.