fix(tradein-matching): pg_advisory_xact_lock to prevent duplicate house INSERTs #501

Merged
lekss361 merged 2 commits from feat/tradein-houses-advisory-lock into main 2026-05-24 10:52:11 +00:00
Showing only changes of commit 9a6890de6a - Show all commits

View file

@ -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(