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
121 changes: 121 additions & 0 deletions pkg/resource/refreshable_indexes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package resource

import (
"fmt"
"sync"
)

// RefreshableRunbookIndex wraps a RunbookIndex behind an atomic swap so the
// runtime can replace it (e.g. after the proxy's embedding model changes and the
// corpus is re-embedded) without disrupting in-flight searches. While the inner
// index is nil — during a re-index — Search returns a not-ready error rather than
// scoring a new-model query against an old-model index.
type RefreshableRunbookIndex struct {
mu sync.RWMutex
idx *RunbookIndex
}

// NewRefreshableRunbookIndex wraps an initial index.
func NewRefreshableRunbookIndex(idx *RunbookIndex) *RefreshableRunbookIndex {
return &RefreshableRunbookIndex{idx: idx}
}

// Search delegates to the current index, or reports not-ready while swapped out.
func (r *RefreshableRunbookIndex) Search(query string, limit int) ([]RunbookSearchResult, error) {
r.mu.RLock()
idx := r.idx
r.mu.RUnlock()

if idx == nil {
return nil, fmt.Errorf("runbook index not ready")
}

return idx.Search(query, limit)
}

// Swap replaces the current index. Passing nil parks the index as not-ready
// (used at the start of a re-index so no search mixes embedding model spaces).
func (r *RefreshableRunbookIndex) Swap(idx *RunbookIndex) {
r.mu.Lock()
r.idx = idx
r.mu.Unlock()
}

// RefreshableEIPIndex wraps an EIPIndex behind an atomic swap. See
// RefreshableRunbookIndex for the rationale.
type RefreshableEIPIndex struct {
mu sync.RWMutex
idx *EIPIndex
}

// NewRefreshableEIPIndex wraps an initial index.
func NewRefreshableEIPIndex(idx *EIPIndex) *RefreshableEIPIndex {
return &RefreshableEIPIndex{idx: idx}
}

// Search delegates to the current index, or reports not-ready while swapped out.
func (r *RefreshableEIPIndex) Search(query string, limit int) ([]EIPSearchResult, error) {
r.mu.RLock()
idx := r.idx
r.mu.RUnlock()

if idx == nil {
return nil, fmt.Errorf("EIP index not ready")
}

return idx.Search(query, limit)
}

// Swap replaces the current index (nil parks it as not-ready).
func (r *RefreshableEIPIndex) Swap(idx *EIPIndex) {
r.mu.Lock()
r.idx = idx
r.mu.Unlock()
}

// RefreshableConsensusSpecIndex wraps a ConsensusSpecIndex behind an atomic swap.
// It delegates both the vector spec search and the lexical constant search.
type RefreshableConsensusSpecIndex struct {
mu sync.RWMutex
idx *ConsensusSpecIndex
}

// NewRefreshableConsensusSpecIndex wraps an initial index.
func NewRefreshableConsensusSpecIndex(idx *ConsensusSpecIndex) *RefreshableConsensusSpecIndex {
return &RefreshableConsensusSpecIndex{idx: idx}
}

// SearchSpecs delegates to the current index, or reports not-ready while swapped
// out (spec search is vector-based and must not mix model spaces).
func (r *RefreshableConsensusSpecIndex) SearchSpecs(query string, limit int) ([]ConsensusSpecSearchResult, error) {
r.mu.RLock()
idx := r.idx
r.mu.RUnlock()

if idx == nil {
return nil, fmt.Errorf("consensus spec index not ready")
}

return idx.SearchSpecs(query, limit)
}

// SearchConstants delegates to the current index. Constant search is lexical (no
// embedding), so it returns nil — not an error — while the index is swapped out.
func (r *RefreshableConsensusSpecIndex) SearchConstants(query string, limit int) []ConstantSearchResult {
r.mu.RLock()
idx := r.idx
r.mu.RUnlock()

if idx == nil {
return nil
}

return idx.SearchConstants(query, limit)
}

// Swap replaces the current index (nil parks it as not-ready).
func (r *RefreshableConsensusSpecIndex) Swap(idx *ConsensusSpecIndex) {
r.mu.Lock()
r.idx = idx
r.mu.Unlock()
}
49 changes: 49 additions & 0 deletions pkg/resource/refreshable_indexes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package resource

import "testing"

// The refreshable wrappers are parked (Swap(nil)) at the start of a re-index so
// that no search dot-products a new-model query against an old-model index while
// the corpus is being re-embedded. These tests pin that contract: a parked index
// reports not-ready rather than returning (stale) results.

func TestRefreshableRunbookIndex_ParkedReportsNotReady(t *testing.T) {
idx := NewRefreshableRunbookIndex(nil)
if _, err := idx.Search("query", 5); err == nil {
t.Fatal("expected not-ready error when inner index is nil")
}

idx.Swap(nil)

if _, err := idx.Search("query", 5); err == nil {
t.Fatal("expected not-ready error after Swap(nil)")
}
}

func TestRefreshableEIPIndex_ParkedReportsNotReady(t *testing.T) {
idx := NewRefreshableEIPIndex(nil)
if _, err := idx.Search("query", 5); err == nil {
t.Fatal("expected not-ready error when inner index is nil")
}

idx.Swap(nil)

if _, err := idx.Search("query", 5); err == nil {
t.Fatal("expected not-ready error after Swap(nil)")
}
}

func TestRefreshableConsensusSpecIndex_ParkedReportsNotReady(t *testing.T) {
idx := NewRefreshableConsensusSpecIndex(nil)

// Vector spec search must report not-ready while parked.
if _, err := idx.SearchSpecs("query", 5); err == nil {
t.Fatal("expected not-ready error for SearchSpecs when parked")
}

// Constant search is lexical (no embedding), so it returns nil — not an
// error — while parked.
if got := idx.SearchConstants("query", 5); got != nil {
t.Fatalf("expected nil constants while parked, got %v", got)
}
}
Loading
Loading