Skip to content

Commit 8495470

Browse files
jyxjjjcodexPIKACHUIM
authored
fix(search): serialize index updates by parent (#2827)
- Prevent concurrent updates from inserting duplicate index entries - Preserve parallel indexing across different parent paths - Add serialization, concurrency, and lock cleanup tests Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
1 parent fe56ca4 commit 8495470

3 files changed

Lines changed: 99 additions & 0 deletions

File tree

internal/search/build.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ func Update(ctx context.Context, parent string, objs []model.Obj) {
225225
return
226226
}
227227

228+
unlock := lockUpdate(parent)
229+
defer unlock()
230+
228231
nodes, err := instance.Get(ctx, parent)
229232
if err != nil {
230233
log.Errorf("update search index error while get nodes: %+v", err)

internal/search/build_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package search
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestLockUpdateSerializesSameParent(t *testing.T) {
9+
unlockFirst := lockUpdate("/same-parent")
10+
secondStarted := make(chan struct{})
11+
secondAcquired := make(chan struct{})
12+
secondReleased := make(chan struct{})
13+
go func() {
14+
close(secondStarted)
15+
unlockSecond := lockUpdate("/same-parent")
16+
close(secondAcquired)
17+
unlockSecond()
18+
close(secondReleased)
19+
}()
20+
<-secondStarted
21+
22+
select {
23+
case <-secondAcquired:
24+
t.Fatal("second update acquired the same parent lock")
25+
case <-time.After(20 * time.Millisecond):
26+
}
27+
28+
unlockFirst()
29+
select {
30+
case <-secondReleased:
31+
case <-time.After(time.Second):
32+
t.Fatal("second update did not acquire the released parent lock")
33+
}
34+
35+
updateLocksMu.Lock()
36+
defer updateLocksMu.Unlock()
37+
if len(updateLocks) != 0 {
38+
t.Fatalf("update locks were not cleaned up: %d", len(updateLocks))
39+
}
40+
}
41+
42+
func TestLockUpdateAllowsDifferentParents(t *testing.T) {
43+
unlockFirst := lockUpdate("/first-parent")
44+
defer unlockFirst()
45+
46+
secondAcquired := make(chan struct{})
47+
go func() {
48+
unlockSecond := lockUpdate("/second-parent")
49+
unlockSecond()
50+
close(secondAcquired)
51+
}()
52+
53+
select {
54+
case <-secondAcquired:
55+
case <-time.After(time.Second):
56+
t.Fatal("update for a different parent was blocked")
57+
}
58+
}

internal/search/update_lock.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package search
2+
3+
import "sync"
4+
5+
var (
6+
updateLocksMu sync.Mutex
7+
updateLocks = make(map[string]*updateLock)
8+
)
9+
10+
type updateLock struct {
11+
mu sync.Mutex
12+
refs uint
13+
}
14+
15+
// lockUpdate serializes index updates for the same parent while allowing
16+
// unrelated directories to update concurrently.
17+
func lockUpdate(parent string) func() {
18+
updateLocksMu.Lock()
19+
lock, ok := updateLocks[parent]
20+
if !ok {
21+
lock = &updateLock{}
22+
updateLocks[parent] = lock
23+
}
24+
lock.refs++
25+
updateLocksMu.Unlock()
26+
27+
lock.mu.Lock()
28+
return func() {
29+
lock.mu.Unlock()
30+
31+
updateLocksMu.Lock()
32+
lock.refs--
33+
if lock.refs == 0 {
34+
delete(updateLocks, parent)
35+
}
36+
updateLocksMu.Unlock()
37+
}
38+
}

0 commit comments

Comments
 (0)