Fix UniqueViolation in equivalent_identifiers_refresh cache rebuild (PP-4786)#3553
Conversation
Greptile SummaryThis PR makes recursive equivalency cache refreshes tolerate concurrent cache writes.
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (2): Last reviewed commit: "Fix UniqueViolation in equivalent_identi..." | Re-trigger Greptile |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3553 +/- ##
==========================================
- Coverage 93.46% 93.46% -0.01%
==========================================
Files 512 512
Lines 46614 46618 +4
Branches 6352 6353 +1
==========================================
+ Hits 43570 43571 +1
- Misses 1968 1969 +1
- Partials 1076 1078 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Note: why we did not pursue "align the broker/lock timeouts"While reviewing this fix we considered whether raising
Net: |
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>
6c6b376 to
720d6c3
Compare
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