fix(tradein-matching): pg_advisory_xact_lock to prevent duplicate house INSERTs #501
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#501
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/tradein-houses-advisory-lock"
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?
Summary
Close finding #1 from 2026-05-24 trade-in audit.
Two scrapers calling
match_or_create_house()concurrently for the same address could both miss Tier 0-3 (cadastr / source / fingerprint / geo) and each INSERT a new canonical house — splitting listings across duplicate house rows and breaking aggregations downstream.Fix: serialize via
pg_advisory_xact_lock(42, hashtext(fingerprint))acquired right after fp computation, before any tier reads. Lock is transaction-scoped (released on caller's COMMIT/ROLLBACK). After unblock, the losing scraper's Tier 2 fingerprint lookup hits the winner's alias and returns the samehouse_id.42reserves a private advisory-lock slot for trade-in house matching (no collision with other locks in the system).address_fingerprint(None, lat, lon)returns a valid 32-char hex even for None address (normalize_address(address or '')guard innormalize.py:72) — lock call is safe for all callers.KNOWN LIMITATIONblock replaced with concurrency-safe description.Test plan
housesrow per unique fingerprint (compare counts before/after).EXPLAIN (ANALYZE) SELECT pg_advisory_xact_lock(42, hashtext('test'))runs in < 1ms (sanity check, no blocking).pg_locks WHERE locktype = 'advisory'during a sweep.Deep Code Review — PR #501
Verdict: CHANGES REQUESTED
Fix is correct in design —
pg_advisory_xact_lock(42, hashtext(fp))after fp computation, before any tier reads, with two-arg form using a private namespace (cannot collide with single-arg locks likecadastre_fetch.py:225). Lock release tied to caller's COMMIT/ROLLBACK is the right tx-scope. Parametrized bind, no SQL injection surface, line length OK, psycopg v3-compatible.🔴 Blocker — tests will fail on CI
tradein-mvp/backend/tests/test_matching.pyhas 5 tests (test_match_house_tier0_cadastr,_tier1_source_exact,_tier2_fingerprint,_tier3_geo,_new) that builddb.execute.side_effectlists assuming the first execute call hits the relevant tier query. The new advisory-lock call is now the firstdb.execute(...)— every test will consume its first mock row for the lock and read off-by-one for the subsequent tier query (Tier 0 cadastr lookup getsNone, falls through, etc.).Fixup needed in each test's
_make_db([...])list — prepend aNonerow for the lock call. Example fortest_match_house_tier0_cadastr:Applies to all 5
test_match_house_*cases.test_match_listing_*tests are unaffected (different function).🟡 Recommended additions
assert db.execute.call_args_list[0][0][0].text.startswith('SELECT pg_advisory_xact_lock')would prevent future re-ordering bugs.pg_locksmonitoring) appear to have run. At least the EXPLAIN sanity check is trivial to run pre-merge.🟢 Observations (non-blocking)
tradein-mvp/backend/app/invokematch_or_create_house()yet — only the__init__.pyre-export and tests. The race described in the PR body is preemptive hardening before scrapers are wired in (perold/decisions/Cross_Source_Matching_Strategy.mdplan). This is fine, but worth noting that the fix can't be empirically validated under load until scrapers are connected.hashtext(fp)). This is the right trade-off for safety — moving the lock past Tier 1 would re-introduce a TOCTOU window — but adds ~0.3-1ms per call. Bulk scrapers @ 100k calls ≈ +30-100s aggregate. Acceptable.old/decisions/Cross_Source_Matching_Strategy.mddocuments the planned matching layer but does not yet describe this concurrency fix. Worth a/vault-writenote after merge so future readers find the rationale.Blast radius
tradein-mvp/backend/app/services/matching/houses.py(P1 — service layer, currently un-called)Next steps
_make_db([...])rows in 5test_match_house_*tests to include the lock result slot.pytest tradein-mvp/backend/tests/test_matching.py -vlocally; confirm green.⚠️ PR touches blocked scope (
backend/app/services/matching/houses.py— actuallytradein-mvp/backend/...) — leaving merge decision to human reviewer regardless of verdict.Deep Code Review — PR #501 (re-review)
Verdict: APPROVE ✅ — fixup correctly addresses the test mock execute-order issue from prior review (sha=9a6890d).
Mock execute-order verification (all 5 tests + 1 new)
Walked through each test against actual
match_or_create_housecall chain at sha=cea2af8:tier0_cadastr[None, {id:42}, None]tier1_source_exact[None, {house_id:7}]tier2_fingerprint[None, None, {house_id:15}, None]tier3_geo[None, None, None, {id:22,dist:15.5}, None, None]new[None, None, None, None, {id:99}, None, None]advisory_lock_called_first(new regression test)first_call[0][0]containspg_advisory_xact_lock, bind hasfpof 32-char hexcall_args_list[0]Advisory-lock logic regression check
fp = address_fingerprint(...)is computed once at function entry (line 58), used by lock + Tier 2 + Tier 3 alias + new-house alias — no shadowing, no recompute.fp = address_fingerprint(...)line inside Tier 2 region correctly removed (was line 86 in pre-fixup).text('SELECT pg_advisory_xact_lock(42, hashtext(:fp))')is parametrized bind, no SQL injection surface, psycopg v3-compatible.42cannot collide with single-arg locks elsewhere in repo (e.g.cadastre_fetch.py:225).Deadlock risk
Lock acquisition is the first DB operation in the function — no prior reads/writes that could create ordering inversions with other tables. No nested calls to
match_or_create_house. Concurrent calls for same fp serialize on the same lock key; different fps are fully independent. hashtext collision case = over-serialization (correctness preserved), not deadlock. Risk: low.🟢 Observations (non-blocking)
test_match_house_advisory_lock_called_firstis a well-placed regression guard — exactly what was recommended in prior review.len == 32, str) is tight enough to catch silent breakage if someone changesaddress_fingerprint()return shape.tradein-mvp/backend/app/).Vault cross-check
No new vault entry yet — recommend
/vault-writepost-merge documenting the namespace42registry convention for future two-arg advisory locks (prevents silent collision).Blast radius
tradein-mvp/backend/app/services/matching/houses.py(P1 — service layer, currently un-called by app code)tradein-mvp/backend/tests/test_matching.py(5 updated + 1 new regression test)Pre-flight
✅ ready for human merge (tests fix verified) — blocked scope (
backend/app/services/...), leaving merge to human reviewer per policy.