Fix UniqueViolation in equivalent_identifiers_refresh cache rebuild (PP-4786)#3564
Conversation
|
Claude finished @jonathangreen's task in 6m 13s —— View job Code Review
SummaryThis is a clean, well-targeted fix. Routing every The new tests ( |
The refresh task rebuilds recursiveequivalentscache with delete-then-insert per parent identifier, assuming it is the only writer. It is not: the Identifier creation listener inserts (id, id) self-references from webapp transactions, and a redelivered task (acks_late + reject_on_worker_lost, plus self-replacement across thousands of batches) can run a second refresh chain concurrently on overlapping chains. A row committed by either between our delete and our flush collides with the constraint and aborts the whole task. Route all cache inserts through a shared _insert_cache_rows helper using INSERT ... ON CONFLICT DO NOTHING. Since every parent's rows are recomputed from the same source equivalencies, a row slipped in under us holds the same value we were about to write, so skipping the conflict converges on the correct chain. Also collapse the per-parent deletes into a single sorted IN (...) statement to reduce deadlock risk between racing refreshes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2b943c9 to
64a7bfe
Compare
Greptile SummaryThis PR fixes a production
Confidence Score: 5/5Safe to merge — the change is narrowly scoped to the cache rebuild path, the fix is mechanically correct, and the new tests directly reproduce and verify the production failure scenario. The ON CONFLICT DO NOTHING target matches the UniqueConstraint(parent_identifier_id, identifier_id) defined in the model, the sorted insertion order is the correct deadlock-prevention technique, itertools.batched is available in the project's minimum Python 3.12 baseline, and the computed is_parent column requires no explicit value in the INSERT dicts. Tests cover all new code paths. No files require special attention. Important Files Changed
Reviews (3): Last reviewed commit: "Update comments [skip claude]" | Re-trigger Greptile |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3564 +/- ##
=======================================
Coverage 93.46% 93.47%
=======================================
Files 512 512
Lines 46611 46619 +8
Branches 6352 6353 +1
=======================================
+ Hits 43567 43575 +8
Misses 1968 1968
Partials 1076 1076 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Sort rows by (parent_identifier_id, identifier_id) in _insert_cache_rows so that two refreshes inserting overlapping keys acquire the unique-index entry locks in the same order rather than deadlocking. Drop the sorted() on the delete's parent IDs, which never controlled lock acquisition order (the single-statement delete already scans in index order), along with its misleading comment.
Add coverage for the two paths that only trigger above a batch boundary: add_identity_equivalents spanning multiple yield_per partitions (inserting while the streaming cursor is open), and _insert_cache_rows spanning multiple INSERT_CHUNK_SIZE chunks. Both previously only ran with a single partition/chunk in tests.
Add tests for the empty-guard returns in _delete_cache_rows and process_identifier_ids, plus the no-equivalencies path in refresh_equivalent_identifiers, bringing the module to 100% coverage.
|
I did a bit of cleanup here and re-opened the PR not from a fork, so it looks like this PR is from me, but its really @dbernstein in #3553. So I'm going to merge this one since he is out on vacation. |
Description
Routes all
recursiveequivalentscacheinserts in theequivalent_identifiers_refreshtask through a shared_insert_cache_rowshelper that uses PostgreSQLINSERT ... ON CONFLICT (parent_identifier_id, identifier_id) DO NOTHING, so a concurrently-committed cache row no longer aborts the task. Also collapses the per-parent deletes into a single sortedWHERE parent_identifier_id IN (...)statement and chunks/streams the inserts.Motivation and Context
JIRA: PP-4786
Production is throwing:
The refresh task rebuilds the cache with a delete-then-insert per parent identifier, implicitly assuming it is the only writer of the table. It is not:
Identifiercreation listener inserts(id, id)self-reference rows from ordinary webapp/import transactions, independent of this task.task_acks_late=True/task_reject_on_worker_lost=Trueand re-queues itself viatask.replace()across thousands of batches (replaced_task_nesting: 5348in the failing log). A lost worker or a lapsed visibility timeout lets the broker redeliver, and a second refresh chain can run concurrently on overlapping identifier chains. The RedisTaskLockonly guards against the beat schedule starting a second run — a redelivery carries the sameroot_id, so it re-acquires the same lock and slips through.When either writer commits a conflicting row between our delete and our flush, the batched
INSERT ... RETURNINGhits the unique constraint and the whole task aborts. Since every parent's rows are recomputed from the same source equivalencies, a row slipped in under us holds the same value we were about to write, soON CONFLICT DO NOTHINGconverges on the correct chain instead of failing.How Has This Been Tested?
TestInsertCacheRowsandTestProcessIdentifierIds::test_tolerates_rows_the_delete_did_not_remove. Confirmed both reproduce the productionIntegrityErrorwhen theON CONFLICTclause is removed, and pass with it in place.test_refresh_equivalents.py,test_equivalents.py, andtest_listeners.pypasses.mypyand pre-commit checks are clean.Checklist
🤖 Generated with Claude Code