feat(tradein-sql): bootstrap houses + link 98.8% of listings (Phase A+B) #527

Merged
lekss361 merged 2 commits from feat/tradein-backfill-houses-link-listings into main 2026-05-24 14:17:32 +00:00
Owner

Problem

houses table was empty (0 rows). All 18,428 active listings had house_id_fk=NULL. Cross-source listings (cian+avito+yandex для same building) не consolidate'ились в один дом.

Fix

Idempotent migration 063_backfill_houses_and_link_listings.sql with 6 steps:

  1. Avito-direct insert — 562 houses from listings with house_source=avito + house_ext_id (preserve internal Avito IDs)
  2. Derived insert — 5,945 houses from listings WITHOUT Avito IDs, grouped by normalized short address (sha256 truncated as synthetic ext_house_id). EXCLUDE addresses already covered by step 1 (no cross-source dupes).
  3. Avito-direct linkUPDATE listings.house_id_fk via (source, ext_house_id) match
  4. Fuzzy linkUPDATE listings.house_id_fk via normalized address. COALESCE prefers avito-house if same addr exists (cian/yandex lots в known Avito building линкуются к одному house)
  5. Best-effort history relinkhouse_placement_history.house_id (160 yandex_valuation orphans, raw_payload без address → 0 relinked; deferred to Phase C scraper-side)
  6. Sanity check — DO-block WARN если any normalized address has multiple houses rows

Normalizer function tradein_normalize_short_addr(text) (CREATE OR REPLACE, IMMUTABLE, PARALLEL SAFE) strips Россия/обл./г./кв./корп./оф./подъезд.

Live-run counters

Metric After migration
houses total 6,507
source=avito 562
source=derived 5,945
listings.house_id_fk NOT NULL 18,197 / 18,428 (98.8%)
Cross-source dupes 0
Same-address WARN 73 (newbuilding scrapers без house number — pre-existing data-quality gap)
house_placement_history relinked 0 (yandex_valuation raw_payload без address key — Phase C work)

Idempotency

  • INSERT … ON CONFLICT DO NOTHING (UNIQUE on (source, ext_house_id))
  • UPDATE … WHERE house_id_fk IS NULL
  • Re-run = no-op

Verified

Validated end-to-end via BEGIN; \i 063…sql; ROLLBACK; on prod tradein-postgres (no persistence). Будет применено real на deploy через apply migrations step.

Roadmap

Follow-up to [[Decision_TradeIn_DataQuality_8PR_Roadmap]]. Phase C (Avito IMV placementHistory+suggestions per house) deferred to separate Celery job. UI exposure of houses/history coming in PR 11.

## Problem `houses` table was empty (0 rows). All 18,428 active listings had `house_id_fk=NULL`. Cross-source listings (cian+avito+yandex для same building) не consolidate'ились в один дом. ## Fix Idempotent migration `063_backfill_houses_and_link_listings.sql` with 6 steps: 1. **Avito-direct insert** — 562 houses from listings with `house_source=avito` + `house_ext_id` (preserve internal Avito IDs) 2. **Derived insert** — 5,945 houses from listings WITHOUT Avito IDs, grouped by normalized short address (sha256 truncated as synthetic `ext_house_id`). EXCLUDE addresses already covered by step 1 (no cross-source dupes). 3. **Avito-direct link** — `UPDATE listings.house_id_fk` via `(source, ext_house_id)` match 4. **Fuzzy link** — `UPDATE listings.house_id_fk` via normalized address. `COALESCE` prefers avito-house if same addr exists (cian/yandex lots в known Avito building линкуются к одному house) 5. **Best-effort history relink** — `house_placement_history.house_id` (160 yandex_valuation orphans, raw_payload без address → 0 relinked; deferred to Phase C scraper-side) 6. **Sanity check** — DO-block WARN если any normalized address has multiple houses rows Normalizer function `tradein_normalize_short_addr(text)` (CREATE OR REPLACE, IMMUTABLE, PARALLEL SAFE) strips `Россия/обл./г./кв./корп./оф./подъезд`. ## Live-run counters | Metric | After migration | |--------|-----------------| | `houses` total | **6,507** | | — `source=avito` | 562 | | — `source=derived` | 5,945 | | `listings.house_id_fk` NOT NULL | **18,197 / 18,428 (98.8%)** | | Cross-source dupes | **0** ✅ | | Same-address WARN | 73 (newbuilding scrapers без house number — pre-existing data-quality gap) | | `house_placement_history` relinked | 0 (yandex_valuation `raw_payload` без `address` key — Phase C work) | ## Idempotency - `INSERT … ON CONFLICT DO NOTHING` (UNIQUE on `(source, ext_house_id)`) - `UPDATE … WHERE house_id_fk IS NULL` - Re-run = no-op ## Verified Validated end-to-end via `BEGIN; \i 063…sql; ROLLBACK;` on prod tradein-postgres (no persistence). Будет применено real на deploy через `apply migrations` step. ## Roadmap Follow-up to `[[Decision_TradeIn_DataQuality_8PR_Roadmap]]`. Phase C (Avito IMV `placementHistory`+`suggestions` per house) deferred to separate Celery job. UI exposure of houses/history coming in PR 11.
lekss361 added 2 commits 2026-05-24 14:11:44 +00:00
Backfill 18,428 listings against an empty houses table:
- INSERT Avito/CIAN-sourced houses (~562 unique via house_source+house_ext_id)
- INSERT derived houses for remaining listings (group by tradein_normalize_short_addr,
  synthetic ext_house_id = sha256 truncated to 32 hex chars) → ~6102 derived
- UPDATE listings.house_id_fk by both paths (direct: 2997, fuzzy: 15200)
- Best-effort relink orphan house_placement_history via raw_payload.address (0 rows)

Syntax-validated live: 6664 houses, 18197/18428 listings linked (98.8%).
Idempotent (ON CONFLICT DO NOTHING, WHERE house_id_fk IS NULL).
Logs result counters via RAISE NOTICE.

Phase C (Avito IMV history scrape per house) deferred to separate Celery job.
A physical building with both Avito ext_house_id (3 listings) and
cian/yandex listings (5 lots, no ext_id) was getting TWO houses rows
(one avito, one derived). Step 4 then split the 8 listings across them.

Fix:
- Step 2 (derived insert): NOT IN (avito-addresses) — skip if already covered
- Step 4 (fuzzy link): COALESCE prefers avito-house at same addr over derived

Adds sanity check: WARN if any normalized address has multiple houses rows.
Author
Owner

Deep Code Review — verdict APPROVE

Summary

  • Status: APPROVE
  • Files: 3 (P0: 1 SQL migration / P2: schema addition / P2: estimator wiring)
  • Lines: +337 / -1 / PR: #527

Verified contracts

  • houses (source, ext_house_id) UNIQUE present (009_houses.sql) -> ON CONFLICT DO NOTHING valid
  • houses.short_address exists (010_houses_alter.sql) -> Step 3 populates, Step 5 reads
  • pgcrypto.digest() extension required -> noted in header comment
  • Deploy order: 062_clean_avito_addresses.sql runs first via lexicographic sort in deploy-tradein.yml (ls -1 | sort)
  • ON_ERROR_STOP=on on psql invocation -> BEGIN/COMMIT atomicity preserved

Idempotency analysis

  • INSERT ... ON CONFLICT (source, ext_house_id) DO NOTHING -- re-run safe
  • Synthetic ext_house_id for derived = substring(sha256(norm_addr), 1, 32) -- deterministic per address
  • UPDATE listings SET house_id_fk = ... WHERE house_id_fk IS NULL -- re-run is no-op
  • CREATE OR REPLACE FUNCTION -- re-run safe
  • DO $$ ... $$ blocks read-only -- pure observability
  • Step 3 NOT IN (subquery filtered by address IS NOT NULL) -- confirmed normalizer cannot return NULL on non-NULL input (uses trim(... FROM regexp_replace(...)) which yields empty string worst case, not NULL) -> NOT IN not poisoned by NULL

Dedup correctness (1.2% unlinked = 231 rows)

  • Step 2 (Avito-direct): 562 houses from listings with house_source+house_ext_id
  • Step 3 explicit NOT IN exclusion prevents split rows when same building has both avito-typed and other-source listings
  • Step 5 COALESCE(non-derived match, derived match) consolidates cross-source listings under single house -- semantically correct for the cross-source goal
  • Same-address WARN=73 are newbuilding scrapers without house number (PR body acknowledges as pre-existing data-quality gap, not a regression)
  • Remaining 231 unlinked listings (1.2%) -- implicit cause: address normalizes to less than 5 chars OR is NULL. Step 5 WHERE house_id_fk IS NULL AND address IS NOT NULL skips null-address listings; sub-5-char normalizations also skipped per Step 3 filter. No retry queue/log table for manual review -- minor gap but acceptable for one-shot backfill.

Concurrency / advisory lock interaction (#501)

  • match_or_create_house() from #501 holds pg_advisory_xact_lock(42, hashtext(fingerprint)) only inside Python-side INSERT path
  • This migration INSERTs directly via SQL bypassing the advisory lock -- but UNIQUE (source, ext_house_id) + ON CONFLICT DO NOTHING provides safety net regardless of source
  • Scraper INSERTs during migration window will hit UNIQUE conflict and be retried/squelched by match_or_create_house logic
  • Recommendation: deploy during quiet scraper window to minimize collisions (not blocker)

Performance

  • Step 2: 18k listings scan + DISTINCT ON -> seconds
  • Step 3: subquery tradein_normalize_short_addr(address) against 562 houses, exec'd per listings row -> IMMUTABLE function, plan-optimizable. ~5-10s.
  • Step 4: indexed via listings_house_ext_id_idx (source, house_ext_id) -- fast
  • Step 5: largest concern. For ~5k unlinked listings x 6,507 houses x 2 subqueries with normalize-on-the-fly = ~65M IMMUTABLE function calls. PostgreSQL may cache IMMUTABLE results within plan but not across rows. For one-shot 18k-row backfill on prod, this is acceptable (acknowledged in inline comment). Inline comment correctly suggests CREATE INDEX IF NOT EXISTS houses_short_addr_idx for future re-runs at scale.
  • Lock on listings table held for duration of single transaction (~30-60s). Moderate impact during deploy -- readers blocked on UPDATE'd rows but scrapers throttled by Caddy reload anyway.

Rollback plan

  • All changes within single BEGIN; ... COMMIT; block -- ROLLBACK reverts cleanly
  • Validated via BEGIN; \i 063...sql; ROLLBACK; on prod tradein-postgres per PR body
  • Post-commit rollback path: DELETE FROM houses WHERE first_seen_at > '<deploy ts>' + UPDATE listings SET house_id_fk = NULL WHERE last_modified_at > '<deploy ts>' (manual, but doable since houses table started at 0 rows)
  • tradein_normalize_short_addr function is CREATE OR REPLACE -- leaving it post-rollback is harmless

Test coverage / data quality assertions

  • Step 7: WARN if same normalized address has >1 houses rows
  • Step 8: RAISE NOTICE with final counters
  • No assertion-style FAIL on data quality (e.g., RAISE EXCEPTION IF linked_pct < threshold) -- this is migration-style observability, acceptable
  • PR body documents live-run counters (6,507 houses, 18,197/18,428 linked = 98.8%, 0 cross-source dupes) -- verified end-to-end

Estimator / schema additions (cian_valuation)

  • CianValuationSummary -- purely additive, optional. Backward compat: existing frontend ignores unknown field.
  • _empty_estimate correctly initializes cian_valuation=None
  • Field mapping in estimate_quality: defensive int(...) if cian_val.sale_price_rub else None, list(cian_val.chart or []) -- handles missing chart safely
  • cian_val is CianValuationResult | None from existing scraper integration (line 702) -- contract verified

Vault cross-check

  • Follow-up to Decision_TradeIn_DataQuality_8PR_Roadmap Phase A+B per PR body
  • Phase C (Avito IMV placementHistory per house) deferred to separate Celery job -- appropriate scope discipline
  • UI exposure deferred to PR 11 -- appropriate sequencing
  1. Post-deploy: log unlinked-listings sample to dedicated review table or RAISE NOTICE with sample IDs -- currently 231 unlinked rows have no audit trail
  2. Phase C work item: extract address into house_placement_history.raw_payload so future relink (Step 6) is more than 0-row no-op
  3. If migration is run again post-scrape-burst at >100k listings scale, create houses_short_addr_idx partial index before re-run

Complexity / blast radius

  • Risk: low -- additive, idempotent, atomic via BEGIN/COMMIT, well-validated
  • Reversibility: high -- single transaction; manual cleanup trivial since houses started at 0
  • Merge window: deploy during scraper-idle window for cleanest semantics (not blocker)
<!-- gendesign-review-bot: sha=ed60fe1 verdict=approve --> ## Deep Code Review — verdict APPROVE ### Summary - Status: APPROVE - Files: 3 (P0: 1 SQL migration / P2: schema addition / P2: estimator wiring) - Lines: +337 / -1 / PR: #527 ### Verified contracts - `houses (source, ext_house_id)` UNIQUE present (009_houses.sql) -> `ON CONFLICT DO NOTHING` valid - `houses.short_address` exists (010_houses_alter.sql) -> Step 3 populates, Step 5 reads - `pgcrypto.digest()` extension required -> noted in header comment - Deploy order: 062_clean_avito_addresses.sql runs first via lexicographic sort in deploy-tradein.yml (`ls -1 | sort`) - `ON_ERROR_STOP=on` on psql invocation -> BEGIN/COMMIT atomicity preserved ### Idempotency analysis - `INSERT ... ON CONFLICT (source, ext_house_id) DO NOTHING` -- re-run safe - Synthetic ext_house_id for derived = `substring(sha256(norm_addr), 1, 32)` -- deterministic per address - `UPDATE listings SET house_id_fk = ... WHERE house_id_fk IS NULL` -- re-run is no-op - `CREATE OR REPLACE FUNCTION` -- re-run safe - `DO $$ ... $$` blocks read-only -- pure observability - Step 3 `NOT IN (subquery filtered by address IS NOT NULL)` -- confirmed normalizer cannot return NULL on non-NULL input (uses `trim(... FROM regexp_replace(...))` which yields empty string worst case, not NULL) -> `NOT IN` not poisoned by NULL ### Dedup correctness (1.2% unlinked = 231 rows) - Step 2 (Avito-direct): 562 houses from listings with house_source+house_ext_id - Step 3 explicit `NOT IN` exclusion prevents split rows when same building has both avito-typed and other-source listings - Step 5 `COALESCE(non-derived match, derived match)` consolidates cross-source listings under single house -- semantically correct for the cross-source goal - Same-address WARN=73 are newbuilding scrapers without house number (PR body acknowledges as pre-existing data-quality gap, not a regression) - Remaining 231 unlinked listings (1.2%) -- implicit cause: address normalizes to less than 5 chars OR is NULL. Step 5 `WHERE house_id_fk IS NULL AND address IS NOT NULL` skips null-address listings; sub-5-char normalizations also skipped per Step 3 filter. No retry queue/log table for manual review -- minor gap but acceptable for one-shot backfill. ### Concurrency / advisory lock interaction (#501) - `match_or_create_house()` from #501 holds `pg_advisory_xact_lock(42, hashtext(fingerprint))` only inside Python-side INSERT path - This migration INSERTs directly via SQL bypassing the advisory lock -- **but `UNIQUE (source, ext_house_id)` + `ON CONFLICT DO NOTHING` provides safety net** regardless of source - Scraper INSERTs during migration window will hit UNIQUE conflict and be retried/squelched by `match_or_create_house` logic - Recommendation: deploy during quiet scraper window to minimize collisions (not blocker) ### Performance - Step 2: 18k listings scan + DISTINCT ON -> seconds - Step 3: subquery `tradein_normalize_short_addr(address)` against 562 houses, exec'd per listings row -> IMMUTABLE function, plan-optimizable. ~5-10s. - Step 4: indexed via `listings_house_ext_id_idx (source, house_ext_id)` -- fast - Step 5: largest concern. For ~5k unlinked listings x 6,507 houses x 2 subqueries with normalize-on-the-fly = ~65M IMMUTABLE function calls. PostgreSQL may cache IMMUTABLE results within plan but not across rows. For one-shot 18k-row backfill on prod, this is acceptable (acknowledged in inline comment). Inline comment correctly suggests `CREATE INDEX IF NOT EXISTS houses_short_addr_idx` for future re-runs at scale. - Lock on `listings` table held for duration of single transaction (~30-60s). Moderate impact during deploy -- readers blocked on UPDATE'd rows but scrapers throttled by Caddy reload anyway. ### Rollback plan - All changes within single `BEGIN; ... COMMIT;` block -- `ROLLBACK` reverts cleanly - Validated via `BEGIN; \i 063...sql; ROLLBACK;` on prod tradein-postgres per PR body - Post-commit rollback path: `DELETE FROM houses WHERE first_seen_at > '<deploy ts>'` + `UPDATE listings SET house_id_fk = NULL WHERE last_modified_at > '<deploy ts>'` (manual, but doable since houses table started at 0 rows) - `tradein_normalize_short_addr` function is CREATE OR REPLACE -- leaving it post-rollback is harmless ### Test coverage / data quality assertions - Step 7: WARN if same normalized address has >1 houses rows - Step 8: RAISE NOTICE with final counters - No assertion-style FAIL on data quality (e.g., RAISE EXCEPTION IF linked_pct < threshold) -- this is migration-style observability, acceptable - PR body documents live-run counters (6,507 houses, 18,197/18,428 linked = 98.8%, 0 cross-source dupes) -- verified end-to-end ### Estimator / schema additions (cian_valuation) - `CianValuationSummary` -- purely additive, optional. Backward compat: existing frontend ignores unknown field. - `_empty_estimate` correctly initializes `cian_valuation=None` - Field mapping in `estimate_quality`: defensive `int(...) if cian_val.sale_price_rub else None`, `list(cian_val.chart or [])` -- handles missing chart safely - `cian_val` is `CianValuationResult | None` from existing scraper integration (line 702) -- contract verified ### Vault cross-check - Follow-up to `Decision_TradeIn_DataQuality_8PR_Roadmap` Phase A+B per PR body - Phase C (Avito IMV `placementHistory` per house) deferred to separate Celery job -- appropriate scope discipline - UI exposure deferred to PR 11 -- appropriate sequencing ### Recommended next steps (non-blocker) 1. Post-deploy: log unlinked-listings sample to dedicated review table or RAISE NOTICE with sample IDs -- currently 231 unlinked rows have no audit trail 2. Phase C work item: extract `address` into `house_placement_history.raw_payload` so future relink (Step 6) is more than 0-row no-op 3. If migration is run again post-scrape-burst at >100k listings scale, create `houses_short_addr_idx` partial index before re-run ### Complexity / blast radius - Risk: low -- additive, idempotent, atomic via BEGIN/COMMIT, well-validated - Reversibility: high -- single transaction; manual cleanup trivial since houses started at 0 - Merge window: deploy during scraper-idle window for cleanest semantics (not blocker)
lekss361 merged commit c6ab419100 into main 2026-05-24 14:17:32 +00:00
lekss361 deleted branch feat/tradein-backfill-houses-link-listings 2026-05-24 14:17:32 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#527
No description provided.