Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

414 lines
14 KiB
Go

package indexer
import (
"context"
"fmt"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/search"
)
// poolEmbedder is a fake API-style embedding provider for the pool
// tests. It reports Concurrent() == true so embedAllChunks picks the
// parallel path, records the peak number of EmbedBatch calls running
// at once, and embeds each text to a deterministic vector derived from
// the text so callers can assert results landed at the right index.
type poolEmbedder struct {
mu sync.Mutex
inFlight int
peak int
calls int32
// delay, when set, makes every EmbedBatch sleep so concurrent
// calls genuinely overlap and the peak measurement is meaningful.
delay time.Duration
// failOnText, when non-empty, makes EmbedBatch return an error for
// any batch containing that exact text — the error-injection hook.
failOnText string
}
func (p *poolEmbedder) Concurrent() bool { return true }
func (p *poolEmbedder) Dimensions() int { return 3 }
func (p *poolEmbedder) Close() error { return nil }
func (p *poolEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
out, err := p.EmbedBatch(ctx, []string{text})
if err != nil {
return nil, err
}
return out[0], nil
}
func (p *poolEmbedder) EmbedBatch(_ context.Context, texts []string) ([][]float32, error) {
atomic.AddInt32(&p.calls, 1)
p.mu.Lock()
p.inFlight++
if p.inFlight > p.peak {
p.peak = p.inFlight
}
p.mu.Unlock()
defer func() {
p.mu.Lock()
p.inFlight--
p.mu.Unlock()
}()
if p.delay > 0 {
time.Sleep(p.delay)
}
out := make([][]float32, len(texts))
for i, txt := range texts {
if p.failOnText != "" && txt == p.failOnText {
return nil, fmt.Errorf("poolEmbedder: injected failure on %q", txt)
}
// Vector encodes the text's numeric suffix so a caller can
// verify the result landed at the matching index.
out[i] = []float32{textToScalar(txt), 0, 0}
}
return out, nil
}
// textToScalar maps "t<N>" to N as a float32 so each embedded text has
// a unique, position-revealing vector.
func textToScalar(txt string) float32 {
if len(txt) > 1 && txt[0] == 't' {
if n, err := strconv.Atoi(txt[1:]); err == nil {
return float32(n)
}
}
return -1
}
// newPoolTestIndexer builds a minimal Indexer wired with the given
// embedder and API concurrency cap, for direct embedAllChunks tests.
func newPoolTestIndexer(t *testing.T, emb interface {
Embed(context.Context, string) ([]float32, error)
EmbedBatch(context.Context, []string) ([][]float32, error)
Dimensions() int
Close() error
}, concurrency int) *Indexer {
t.Helper()
idx := New(graph.New(), parser.NewRegistry(), config.Default().Index, zap.NewNop())
idx.SetEmbedder(emb)
idx.SetEmbeddingAPIConcurrency(concurrency)
return idx
}
// makeTexts builds n texts "t0".."t<n-1>".
func makeTexts(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = "t" + strconv.Itoa(i)
}
return out
}
// passthroughEmbedFn is the embedFn embedAllChunks expects — here it
// just forwards to the indexer's embedder with no retry wrapping, so
// the tests exercise the pool itself.
func passthroughEmbedFn(idx *Indexer) func(context.Context, []string) ([][]float32, error) {
return func(ctx context.Context, items []string) ([][]float32, error) {
return idx.embedder.EmbedBatch(ctx, items)
}
}
// TestEmbedAllChunks_PoolRespectsConcurrencyCap asserts the worker
// pool never runs more EmbedBatch calls at once than the configured
// cap, embeds every chunk, and returns vectors in input order.
func TestEmbedAllChunks_PoolRespectsConcurrencyCap(t *testing.T) {
emb := &poolEmbedder{delay: 15 * time.Millisecond}
const cap = 3
idx := newPoolTestIndexer(t, emb, cap)
texts := makeTexts(60) // 60 texts, batch 5 → 12 batches > cap
vectors, err := idx.embedAllChunks(texts, 5, passthroughEmbedFn(idx))
require.NoError(t, err)
require.Len(t, vectors, len(texts), "every text must be embedded")
assert.LessOrEqual(t, emb.peak, cap,
"peak in-flight EmbedBatch calls (%d) must not exceed the cap (%d)", emb.peak, cap)
assert.Greater(t, emb.peak, 1, "the pool must actually run batches in parallel")
// Order preserved: vectors[i] must encode texts[i].
for i := range texts {
require.Len(t, vectors[i], 3)
assert.Equal(t, float32(i), vectors[i][0],
"vector at index %d must correspond to text %q", i, texts[i])
}
}
// TestEmbedAllChunks_AbortsOnError asserts that a single batch failure
// aborts the whole embed: embedAllChunks returns the error and no
// partial vector slice.
func TestEmbedAllChunks_AbortsOnError(t *testing.T) {
emb := &poolEmbedder{delay: 5 * time.Millisecond, failOnText: "t37"}
idx := newPoolTestIndexer(t, emb, 4)
vectors, err := idx.embedAllChunks(makeTexts(80), 5, passthroughEmbedFn(idx))
require.Error(t, err, "one failing chunk must fail the whole embed")
assert.Nil(t, vectors, "a failed embed must return no partial result")
assert.Contains(t, err.Error(), "t37")
}
// TestBuildSearchIndex_AbortOnEmbedErrorKeepsTextOnly asserts the
// end-to-end abort contract: when embedding fails, buildSearchIndex
// leaves the search backend text-only — no HybridBackend is swapped in.
func TestBuildSearchIndex_AbortOnEmbedErrorKeepsTextOnly(t *testing.T) {
g := graph.New()
// Two function nodes; their embed metadata text is
// "function <Name> ...". poolEmbedder fails on an exact text
// match, so failing on a metadata string aborts the embed.
g.AddNode(&graph.Node{ID: "a.go::Alpha", Name: "Alpha", Kind: graph.KindFunction, FilePath: "a.go", Language: "go"})
g.AddNode(&graph.Node{ID: "a.go::Beta", Name: "Beta", Kind: graph.KindFunction, FilePath: "a.go", Language: "go"})
idx := New(g, parser.NewRegistry(), config.Default().Index, zap.NewNop())
emb := &poolEmbedder{failOnText: "function Alpha a.go"}
idx.SetEmbedder(emb)
idx.buildSearchIndex()
// The backend must NOT be a HybridBackend — embedding aborted, so
// the search stays text-only.
sw, ok := idx.Search().(*search.Swappable)
require.True(t, ok)
_, isHybrid := sw.Inner().(*search.HybridBackend)
assert.False(t, isHybrid,
"buildSearchIndex must not install a HybridBackend when embedding fails")
}
// TestEmbedAllChunks_DeterministicRegardlessOfOrder runs the pool many
// times with overlapping workers and asserts the vectors always land
// at the input-aligned indices — completion order must never reshuffle
// the result.
func TestEmbedAllChunks_DeterministicRegardlessOfOrder(t *testing.T) {
for trial := 0; trial < 8; trial++ {
emb := &poolEmbedder{delay: time.Millisecond}
idx := newPoolTestIndexer(t, emb, 6)
texts := makeTexts(50)
vectors, err := idx.embedAllChunks(texts, 3, passthroughEmbedFn(idx))
require.NoError(t, err)
require.Len(t, vectors, len(texts))
for i := range texts {
assert.Equal(t, float32(i), vectors[i][0],
"trial %d: index %d must hold the vector for %q regardless of completion order",
trial, i, texts[i])
}
}
}
// TestEmbedAllChunks_SerialForNonConcurrentEmbedder asserts an
// embedder that does not implement Concurrent() is run serially — the
// peak in-flight count stays at 1.
func TestEmbedAllChunks_SerialForNonConcurrentEmbedder(t *testing.T) {
emb := &serialOnlyEmbedder{delay: 5 * time.Millisecond}
idx := New(graph.New(), parser.NewRegistry(), config.Default().Index, zap.NewNop())
idx.SetEmbedder(emb)
idx.SetEmbeddingAPIConcurrency(8)
vectors, err := idx.embedAllChunks(makeTexts(30), 3, passthroughEmbedFn(idx))
require.NoError(t, err)
require.Len(t, vectors, 30)
assert.Equal(t, 1, emb.peak,
"an embedder without Concurrent() must be driven serially (peak in-flight 1)")
}
// TestEmbedAllChunks_ConcurrencyCapIsBinding proves the configured cap
// is the *binding* constraint, not merely an upper bound that the
// workload happens to stay under. A barrier embedder blocks each call
// until exactly `cap` calls are simultaneously in flight, then releases
// them; if the pool ran fewer than `cap` workers the barrier would
// deadlock (the test would time out), and if it ran more the peak would
// exceed the cap. Reaching the barrier and observing peak == cap proves
// the pool saturates to precisely the configured width.
func TestEmbedAllChunks_ConcurrencyCapIsBinding(t *testing.T) {
const cap = 3
emb := &barrierEmbedder{target: cap, reached: make(chan struct{})}
idx := newPoolTestIndexer(t, emb, cap)
// Enough batches that the pool must reuse workers — more batches
// than the cap guarantees the barrier is hit by the first wave.
texts := makeTexts(30) // batch 2 → 15 batches
done := make(chan error, 1)
go func() {
_, err := idx.embedAllChunks(texts, 2, passthroughEmbedFn(idx))
done <- err
}()
select {
case <-emb.reached:
// The barrier opened only because `cap` calls were in flight at
// once — the pool genuinely ran `cap` workers in parallel.
case <-time.After(5 * time.Second):
t.Fatal("pool never reached the concurrency cap — fewer than cap workers ran (deadlock)")
}
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("embedAllChunks did not finish after the barrier opened")
}
if got := emb.peakInFlight(); got != cap {
t.Fatalf("peak in-flight = %d, want exactly the cap %d", got, cap)
}
}
// TestEmbedAllChunks_ZeroCapUsesDefault asserts that a zero configured
// cap falls back to the built-in default — proving the
// SetEmbeddingAPIConcurrency(0) path resolves to defaultEmbedAPIConcurrency
// rather than serializing or panicking.
func TestEmbedAllChunks_ZeroCapUsesDefault(t *testing.T) {
emb := &poolEmbedder{delay: 10 * time.Millisecond}
idx := newPoolTestIndexer(t, emb, 0) // 0 → default (4)
// More batches than the default so the pool can saturate it.
texts := makeTexts(40) // batch 2 → 20 batches
vectors, err := idx.embedAllChunks(texts, 2, passthroughEmbedFn(idx))
require.NoError(t, err)
require.Len(t, vectors, len(texts))
assert.LessOrEqual(t, emb.peak, defaultEmbedAPIConcurrency,
"zero cap must fall back to the default (%d), peak was %d", defaultEmbedAPIConcurrency, emb.peak)
assert.Greater(t, emb.peak, 1,
"zero cap must still parallelize via the default, not serialize")
}
// TestSetEmbeddingAPIConcurrency_FlowsToPool asserts the setter wires
// the field the pool actually reads — the production path is
// cfg.Embedding.APIConcurrency → SetEmbeddingAPIConcurrency → the pool
// cap. A cap of 1 must force the serial-equivalent path (peak 1) even
// for a Concurrent() embedder, proving the setter, not the embedder,
// decides the width.
func TestSetEmbeddingAPIConcurrency_FlowsToPool(t *testing.T) {
emb := &poolEmbedder{delay: 5 * time.Millisecond}
idx := newPoolTestIndexer(t, emb, 1) // cap 1 → no overlap
vectors, err := idx.embedAllChunks(makeTexts(20), 2, passthroughEmbedFn(idx))
require.NoError(t, err)
require.Len(t, vectors, 20)
assert.Equal(t, 1, emb.peak,
"a cap of 1 must serialize even a Concurrent() embedder (peak in-flight 1)")
}
// barrierEmbedder is a Concurrent() embedder that blocks every call on a
// shared barrier until `target` calls are simultaneously in flight, then
// releases them all. It is the deterministic way to prove the pool runs
// exactly `target` workers at once: if fewer run, the barrier never
// opens and the test deadlocks.
type barrierEmbedder struct {
target int
mu sync.Mutex
inFlight int
peak int
released bool
reached chan struct{}
closeOnce sync.Once
}
func (b *barrierEmbedder) Concurrent() bool { return true }
func (b *barrierEmbedder) Dimensions() int { return 3 }
func (b *barrierEmbedder) Close() error { return nil }
func (b *barrierEmbedder) peakInFlight() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.peak
}
func (b *barrierEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
out, err := b.EmbedBatch(ctx, []string{text})
if err != nil {
return nil, err
}
return out[0], nil
}
func (b *barrierEmbedder) EmbedBatch(_ context.Context, texts []string) ([][]float32, error) {
b.mu.Lock()
b.inFlight++
if b.inFlight > b.peak {
b.peak = b.inFlight
}
if b.inFlight >= b.target {
b.released = true
b.closeOnce.Do(func() { close(b.reached) })
}
b.mu.Unlock()
// Spin until the barrier has opened so the cap-many callers overlap.
for {
b.mu.Lock()
released := b.released
b.mu.Unlock()
if released {
break
}
time.Sleep(time.Millisecond)
}
b.mu.Lock()
b.inFlight--
b.mu.Unlock()
out := make([][]float32, len(texts))
for i, txt := range texts {
out[i] = []float32{textToScalar(txt), 0, 0}
}
return out, nil
}
// serialOnlyEmbedder is a fake embedder that does NOT implement
// Concurrent(), modelling an in-process transformer backend.
type serialOnlyEmbedder struct {
mu sync.Mutex
inFlight int
peak int
delay time.Duration
}
func (e *serialOnlyEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
out, err := e.EmbedBatch(ctx, []string{text})
if err != nil {
return nil, err
}
return out[0], nil
}
func (e *serialOnlyEmbedder) EmbedBatch(_ context.Context, texts []string) ([][]float32, error) {
e.mu.Lock()
e.inFlight++
if e.inFlight > e.peak {
e.peak = e.inFlight
}
e.mu.Unlock()
defer func() {
e.mu.Lock()
e.inFlight--
e.mu.Unlock()
}()
if e.delay > 0 {
time.Sleep(e.delay)
}
out := make([][]float32, len(texts))
for i := range texts {
out[i] = []float32{1, 0, 0}
}
return out, nil
}
func (e *serialOnlyEmbedder) Dimensions() int { return 3 }
func (e *serialOnlyEmbedder) Close() error { return nil }