fix: restrict DELETE scope in removeDuplicityDescribingEntities - #3478
Conversation
The DELETE query in BatchJobActivityFinalizer.removeDuplicityDescribingEntities was missing an activity_revision_id filter on the outer WHERE clause. This caused a full sequential scan of the entire activity_describing_entity table (5.5 GB in production) on every batch job completion, making the query take ~9 seconds instead of <1ms. Additionally, the missing filter meant the DELETE could affect rows from unrelated historical activity revisions that happened to share the same (entity_class, entity_id) as entities in the batch being merged, silently corrupting activity history data. Fix: add (activity_revision_id in (:revisionIds) or activity_revision_id = :activityRevisionIdToMergeInto) as the leading predicate on the outer DELETE, restricting it to only the chunk revisions being merged. This allows the existing index on (activity_revision_id, entity_class, entity_id) to be used. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe delete query in the batch job activity finalizer is expanded to remove activity entities linked to both the specified revision IDs and the merge-target revision, broadening the scope of activity cleanup during batch operations. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
backend/data/src/main/kotlin/io/tolgee/batch/BatchJobActivityFinalizer.kt (1)
173-189: Optional: reduce repeated scope predicate with a CTE.The condition
activity_revision_id in (:revisionIds) or activity_revision_id = :activityRevisionIdToMergeIntoappears three times (outer WHERE, IN-subquery, NOT IN-subquery). A CTE that materialisesrelevant_revisionsonce would eliminate the repetition and give the planner a single scan to reuse:♻️ Suggested CTE refactor
- delete from activity_describing_entity - where (activity_revision_id in (:revisionIds) or activity_revision_id = :activityRevisionIdToMergeInto) - and (entity_class, entity_id) in - (select entity_class, entity_id - from activity_describing_entity - where activity_revision_id in (:revisionIds) - or activity_revision_id = :activityRevisionIdToMergeInto - group by entity_class, entity_id - having count(*) > 1) - and - (activity_revision_id, entity_class, entity_id) not in ( - select min(activity_revision_id), entity_class, entity_id - from activity_describing_entity - where activity_revision_id in (:revisionIds) - or activity_revision_id = :activityRevisionIdToMergeInto - group by entity_class, entity_id - having count(*) > 1) + with scoped as ( + select activity_revision_id, entity_class, entity_id + from activity_describing_entity + where activity_revision_id in (:revisionIds) + or activity_revision_id = :activityRevisionIdToMergeInto + ), + duplicates as ( + select entity_class, entity_id + from scoped + group by entity_class, entity_id + having count(*) > 1 + ), + to_keep as ( + select min(activity_revision_id) as activity_revision_id, entity_class, entity_id + from scoped + where (entity_class, entity_id) in (select entity_class, entity_id from duplicates) + group by entity_class, entity_id + ) + delete from activity_describing_entity + where (activity_revision_id in (:revisionIds) or activity_revision_id = :activityRevisionIdToMergeInto) + and (entity_class, entity_id) in (select entity_class, entity_id from duplicates) + and (activity_revision_id, entity_class, entity_id) not in ( + select activity_revision_id, entity_class, entity_id from to_keep)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/data/src/main/kotlin/io/tolgee/batch/BatchJobActivityFinalizer.kt` around lines 173 - 189, The SQL in BatchJobActivityFinalizer.kt repeats the predicate "activity_revision_id in (:revisionIds) or activity_revision_id = :activityRevisionIdToMergeInto" three times; refactor the DELETE query to define a CTE (e.g., relevant_revisions) that materializes those revision ids once (selecting activity_revision_id from unnest(:revisionIds) union all select :activityRevisionIdToMergeInto) and then replace the three occurrences with joins/subqueries referencing relevant_revisions (use relevant_revisions.activity_revision_id in the outer WHERE, the IN-subquery and the NOT IN-subquery and update the GROUP BY/HAVING/select min(...) usages accordingly) so the planner can reuse a single scan and the predicate is not duplicated.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@backend/data/src/main/kotlin/io/tolgee/batch/BatchJobActivityFinalizer.kt`:
- Around line 173-189: The SQL in BatchJobActivityFinalizer.kt repeats the
predicate "activity_revision_id in (:revisionIds) or activity_revision_id =
:activityRevisionIdToMergeInto" three times; refactor the DELETE query to define
a CTE (e.g., relevant_revisions) that materializes those revision ids once
(selecting activity_revision_id from unnest(:revisionIds) union all select
:activityRevisionIdToMergeInto) and then replace the three occurrences with
joins/subqueries referencing relevant_revisions (use
relevant_revisions.activity_revision_id in the outer WHERE, the IN-subquery and
the NOT IN-subquery and update the GROUP BY/HAVING/select min(...) usages
accordingly) so the planner can reuse a single scan and the predicate is not
duplicated.
## [3.163.1](v3.163.0...v3.163.1) (2026-02-23) ### Bug Fixes * restrict DELETE scope in removeDuplicityDescribingEntities ([#3478](#3478)) ([1a2c50d](1a2c50d))
Summary
When a batch job completes,
BatchJobActivityFinalizermerges activity revisions from individual chunks into a single revision. As part of this,removeDuplicityDescribingEntitiesdeletes duplicate(entity_class, entity_id)rows to avoid PK violations before reassigning all rows to the merge target.The DELETE query was missing an
activity_revision_idfilter on its outerWHEREclause:This caused two problems:
1. Performance — without
activity_revision_idas the leading predicate, PostgreSQL cannot use the existing(activity_revision_id, entity_class, entity_id)index and falls back to a full sequential scan of the entire table on every batch job completion.2. Data correctness — the unscoped DELETE could match and delete rows from old, unrelated activity revisions that happen to reference the same
(entity_class, entity_id)as entities in the batch being merged, silently corrupting historical activity data.Fix
Add
(activity_revision_id in (:revisionIds) or activity_revision_id = :activityRevisionIdToMergeInto)as the leading predicate on the outer DELETE. This restricts the operation strictly to the chunk revisions being merged, enables index usage, and eliminates the risk of touching unrelated historical rows.Test plan
EXPLAIN ANALYZEon the DELETE query and confirm it uses an Index Scan rather than a Seq Scan🤖 Generated with Claude Code
Summary by CodeRabbit