From c3f574e28aa134defe4c1d3f665ddf4e5e841048 Mon Sep 17 00:00:00 2001 From: David Hu Date: Tue, 19 May 2026 16:02:29 +0800 Subject: [PATCH] fix: detect concurrent MarkDirty during patrol slice to prevent stale ANN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a merge/split fires MarkDirty while a patrol slice is already running, the slice would finish and overwrite CursorTargetID with lastTarget.ID, silently discarding the cursor reset (to 0) that MarkDirty had set. The next "empty-targets" slice would then see cursor > all IDs and mark dirty=false, so the re-scan from scratch (with a fresh ANN built after the concurrent write) never happened. Add DirtyGeneration to personMergeSuggestionState, incremented on every MarkDirty call. At the end of each slice, only advance CursorTargetID if the generation is unchanged; otherwise keep cursor=0 so the next run starts a fresh re-scan with an up-to-date ANN index. Observed symptom: person 264884 (84.5% similarity with 271495/牛牛) was missing from merge suggestions because 271495 was merged at 15:33 CST while the patrol ANN was being built, and the resulting MarkDirty was overwritten by the slice completion at 15:41 CST. Co-Authored-By: Claude Sonnet 4.6 --- .../person_merge_suggestion_service.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/person_merge_suggestion_service.go b/backend/internal/service/person_merge_suggestion_service.go index 5e46fe1..b6c89d1 100644 --- a/backend/internal/service/person_merge_suggestion_service.go +++ b/backend/internal/service/person_merge_suggestion_service.go @@ -37,10 +37,11 @@ type PersonMergeSuggestionService interface { } type personMergeSuggestionState struct { - Paused bool `json:"paused"` - Dirty bool `json:"dirty"` - CursorTargetID uint `json:"cursor_target_id"` - LastRunAt time.Time `json:"last_run_at,omitempty"` + Paused bool `json:"paused"` + Dirty bool `json:"dirty"` + CursorTargetID uint `json:"cursor_target_id"` + LastRunAt time.Time `json:"last_run_at,omitempty"` + DirtyGeneration uint64 `json:"dirty_generation,omitempty"` } type personMergeSuggestionService struct { @@ -306,6 +307,7 @@ func (s *personMergeSuggestionService) MarkDirty(reason string) error { s.state.Dirty = true s.state.CursorTargetID = 0 + s.state.DirtyGeneration++ s.annMu.Lock() s.annDirty = true s.annMu.Unlock() @@ -360,6 +362,7 @@ func (s *personMergeSuggestionService) RunBackgroundSlice() error { // 读取状态后释放锁 cursor := s.state.CursorTargetID + dirtyGen := s.state.DirtyGeneration s.mu.Unlock() // Use background-dedicated repos for the heavy work in this slice. @@ -424,7 +427,13 @@ func (s *personMergeSuggestionService) RunBackgroundSlice() error { } s.task.ProcessedPairs += int64(processedPairs) - s.state.CursorTargetID = targets[len(targets)-1].ID + // Only advance cursor if MarkDirty was not called during this slice. + // A concurrent MarkDirty resets cursor to 0 and bumps DirtyGeneration; if we + // detect the bump, keep cursor at 0 so the next run re-scans from scratch with + // a fresh ANN index built after the concurrent write. + if s.state.DirtyGeneration == dirtyGen { + s.state.CursorTargetID = targets[len(targets)-1].ID + } s.finishSliceLocked(now, processedPairs, fmt.Sprintf("完成 %d 个目标人物巡检", len(targets))) return nil }