Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 53 additions & 10 deletions search/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions search/index_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package search

import (
"bytes"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -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))
}
}
Loading