Skip to content

Commit 6f27677

Browse files
aeneasrclaude
andcommitted
fix(index): serialize concurrent Index/EnsureFresh calls to prevent duplicate key errors
Two concurrent MCP search queries on the same unindexed project both received the same *Indexer from the cache, then both passed the stale check and entered indexWithTree simultaneously, causing UNIQUE constraint violations on chunk inserts. Add sync.Mutex to Indexer and lock around the hash-check + indexing critical section in both Index and EnsureFresh. The merkle tree build stays outside the lock (it is read-only and can be slow for large repos); the second goroutine that acquires the lock will find the hash already up to date and return early without re-indexing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d1666d1 commit 6f27677

1 file changed

Lines changed: 11 additions & 0 deletions

File tree

internal/index/index.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"os"
2323
"path/filepath"
2424
"strconv"
25+
"sync"
2526
"time"
2627

2728
"github.com/aeneasr/lumen/internal/chunker"
@@ -55,6 +56,7 @@ type StatusInfo struct {
5556

5657
// Indexer orchestrates chunking, embedding, and storage for a code index.
5758
type Indexer struct {
59+
mu sync.Mutex
5860
store *store.Store
5961
emb embedder.Embedder
6062
chunker chunker.Chunker
@@ -85,10 +87,15 @@ func (idx *Indexer) Close() error {
8587
// Index indexes the project at projectDir. If force is true, all files are
8688
// re-indexed regardless of whether they have changed.
8789
func (idx *Indexer) Index(ctx context.Context, projectDir string, force bool, progress ProgressFunc) (Stats, error) {
90+
// Build tree outside the lock: it is read-only and can be slow for large projects.
8891
curTree, err := merkle.BuildTree(projectDir, merkle.MakeSkip(projectDir, chunker.SupportedExtensions()))
8992
if err != nil {
9093
return Stats{}, fmt.Errorf("build merkle tree: %w", err)
9194
}
95+
96+
idx.mu.Lock()
97+
defer idx.mu.Unlock()
98+
9299
// If not forcing, check root hash before doing any work.
93100
if !force {
94101
storedHash, err := idx.store.GetMeta("root_hash")
@@ -105,11 +112,15 @@ func (idx *Indexer) Index(ctx context.Context, projectDir string, force bool, pr
105112
// EnsureFresh checks if the index is stale and re-indexes if needed.
106113
// Returns whether a re-index occurred, the stats, and any error.
107114
func (idx *Indexer) EnsureFresh(ctx context.Context, projectDir string, progress ProgressFunc) (bool, Stats, error) {
115+
// Build tree outside the lock: it is read-only and can be slow for large projects.
108116
curTree, err := merkle.BuildTree(projectDir, merkle.MakeSkip(projectDir, chunker.SupportedExtensions()))
109117
if err != nil {
110118
return false, Stats{}, fmt.Errorf("build merkle tree: %w", err)
111119
}
112120

121+
idx.mu.Lock()
122+
defer idx.mu.Unlock()
123+
113124
storedHash, err := idx.store.GetMeta("root_hash")
114125
if err != nil && err != sql.ErrNoRows {
115126
return false, Stats{}, fmt.Errorf("get root_hash: %w", err)

0 commit comments

Comments
 (0)