diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 87d78c84..64a1fa67 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -34,11 +34,11 @@ def match_or_create_house( ) -> tuple[int, float, str]: """Match existing house or create new canonical record. - ⚠️ KNOWN LIMITATION: Concurrent calls for the same address can race — - two scrapers calling simultaneously for an unknown address can both - miss Tier 0-3 and each INSERT a new house row. Mitigation (Stage 8.x): - gate scraper concurrency to 1 per fingerprint OR add - pg_advisory_xact_lock(hashtext(normalized_address)) at function entry. + Concurrency-safe: serializes concurrent calls for the same address fingerprint + via pg_advisory_xact_lock(42, hashtext(fp)). The lock is transaction-scoped, + released on the caller's COMMIT/ROLLBACK. Without this, two scrapers calling + for an unknown address could both miss Tier 0-3 and each INSERT a duplicate + house row. Closes finding #1 from 2026-05-24 audit. Returns: (house_id, confidence ∈ [0.0, 1.0], method ∈ { @@ -54,6 +54,18 @@ def match_or_create_house( """ cad = building_cadastral_number or cadastral_number + # Compute fingerprint early so we can acquire the advisory lock before any tier reads. + fp = address_fingerprint(address, lat, lon) + + # Serialize concurrent calls for the same address fingerprint to prevent + # racing INSERTs into houses (finding #1 from 2026-05-24 audit). + # pg_advisory_xact_lock takes (int4, int4) — namespace 42 = "tradein house matching". + # Lock is released automatically on caller's COMMIT/ROLLBACK. + db.execute( + text('SELECT pg_advisory_xact_lock(42, hashtext(:fp))'), + {'fp': fp}, + ) + # Tier 0: cadastral number exact match if cad: row = db.execute( @@ -83,9 +95,6 @@ def match_or_create_house( ext_id) return (house_id, 1.0, 'source_exact') - # Compute fingerprint once for Tier 2 and reuse for new house registration - fp = address_fingerprint(address, lat, lon) - # Tier 2: address fingerprint lookup row = db.execute( text(