diff --git a/search/index.go b/search/index.go index c1622e1..840d80f 100644 --- a/search/index.go +++ b/search/index.go @@ -81,17 +81,9 @@ func NewIndex(indexPath string, indexNames []string) (*Index, error) { return nil, fmt.Errorf("create index: %w", err) } } else { - idx, err = bleve.Open(indexPath) + idx, err = openOrRecover(indexPath, mapping) if err != nil { - // Index corrupted — rebuild from scratch - log.Printf("warning: index corrupted (%v), rebuilding", err) - if rmErr := DeleteIndex(indexPath); rmErr != nil { - return nil, fmt.Errorf("remove corrupted index: %w", rmErr) - } - idx, err = bleve.New(indexPath, mapping) - if err != nil { - return nil, fmt.Errorf("create index after corruption: %w", err) - } + return nil, err } } @@ -127,6 +119,57 @@ func metaStale(indexPath string, indexNames []string) bool { return meta.Version != metaFileVersion || !slices.Equal(meta.Names, want) } +// openOrRecover opens an existing bleve index, recovering from corruption +// (including panics from bbolt on truncated or damaged files) and verifying +// the index is actually usable with a test query. +func openOrRecover(indexPath string, mapping mapping.IndexMapping) (bleve.Index, error) { + var idx bleve.Index + var openErr error + + // bbolt can panic on corrupted files (e.g. truncated root.bolt). + func() { + defer func() { + if r := recover(); r != nil { + openErr = fmt.Errorf("panic opening index: %v", r) + } + }() + idx, openErr = bleve.Open(indexPath) + }() + + if openErr != nil { + log.Printf("warning: index corrupted (%v), rebuilding", openErr) + if rmErr := DeleteIndex(indexPath); rmErr != nil { + return nil, fmt.Errorf("remove corrupted index: %w", rmErr) + } + idx, err := bleve.New(indexPath, mapping) + if err != nil { + return nil, fmt.Errorf("create index after corruption: %w", err) + } + return idx, nil + } + + // Verify the index is actually usable — bolt pages can be + // corrupted in ways that don't surface on Open but hang on use. + testReq := bleve.NewSearchRequest(bleve.NewMatchAllQuery()) + testReq.Size = 1 + if _, searchErr := idx.Search(testReq); searchErr != nil { + log.Printf("warning: index unhealthy (%v), rebuilding", searchErr) + if rmErr := idx.Close(); rmErr != nil { + return nil, fmt.Errorf("close unhealthy index: %w", rmErr) + } + if rmErr := DeleteIndex(indexPath); rmErr != nil { + return nil, fmt.Errorf("remove unhealthy index: %w", rmErr) + } + idx, err := bleve.New(indexPath, mapping) + if err != nil { + return nil, fmt.Errorf("create index after unhealthy: %w", err) + } + return idx, nil + } + + return idx, nil +} + // indexMapping builds an explicit mapping: text analysis for content fields, // exact keyword matching for filter fields. func indexMapping() mapping.IndexMapping { diff --git a/search/index_test.go b/search/index_test.go index 4fc82b4..def9ef7 100644 --- a/search/index_test.go +++ b/search/index_test.go @@ -1,6 +1,7 @@ package search import ( + "bytes" "os" "path/filepath" "testing" @@ -320,3 +321,53 @@ func TestIndexRecoverFromCorruption(t *testing.T) { t.Errorf("len(results) = %d, want 1", len(results)) } } + +func TestIndexRecoverFromSilentCorruption(t *testing.T) { + dir := t.TempDir() + indexPath := filepath.Join(dir, "test.bleve") + + // Create index and add a document + idx := newIndex(t, indexPath, []string{"test"}) + if err := idx.Add("test/conv-001", SearchDocument{ + Summary: "original doc", Project: "test", + }); err != nil { + t.Fatalf("Add() error: %v", err) + } + idx.Close() + + // Overwrite root.bolt with garbage — simulates a partially written + // or corrupted file where bleve.Open() may succeed but the index + // is internally broken. We overwrite rather than truncate because + // truncating an mmap'd file causes SIGBUS which is not recoverable. + boltPath := filepath.Join(indexPath, "store", "root.bolt") + if err := os.WriteFile(boltPath, bytes.Repeat([]byte{0xff}, 4096), 0600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + // Opening should detect the unhealthy index and rebuild + idx2 := newIndex(t, indexPath, []string{"test"}) + defer idx2.Close() + + // Old data is gone (rebuilt), but server should work + results, err := idx2.Query("test", "", "", 10) + if err != nil { + t.Fatalf("Query() after recovery error: %v", err) + } + if len(results) != 0 { + t.Errorf("len(results) = %d, want 0 (old data should be gone after rebuild)", len(results)) + } + + // Can add new data after recovery + if err := idx2.Add("test/conv-003", SearchDocument{ + Summary: "new doc after silent recovery", Project: "test", + }); err != nil { + t.Fatalf("Add() after recovery error: %v", err) + } + results, err = idx2.Query("test", "silent", "", 10) + if err != nil { + t.Fatalf("Query() after recovery error: %v", err) + } + if len(results) != 1 { + t.Errorf("len(results) = %d, want 1", len(results)) + } +}