Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

427 lines
14 KiB
Go

package vector
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
kitvec "go.kenn.io/kit/vector"
"go.kenn.io/kit/vector/sqlitevec"
)
// soloEncoders wraps enc as a single-server EncoderSet, the shape most
// Manager tests need.
func soloEncoders(enc kitvec.EncodeFunc) EncoderSet {
return EncoderSet{Default: "test", ByName: map[string]ManagedEncoder{
"test": {Encode: enc, Settings: EncodeSettings{BatchSize: 10}},
}}
}
// blockingEncoder returns an encoder that blocks until release is closed,
// letting tests observe a Manager while its build is still in flight.
func blockingEncoder(release <-chan struct{}) kitvec.EncodeFunc {
return func(_ context.Context, texts []string) ([][]float32, error) {
<-release
out := make([][]float32, len(texts))
for i := range texts {
out[i] = []float32{1, 0, 0}
}
return out, nil
}
}
// waitFor polls cond until it returns true or the deadline passes, failing
// the test otherwise. Used instead of a fixed sleep to avoid flakiness.
func waitFor(t *testing.T, cond func() bool, msg string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(time.Millisecond)
}
require.Fail(t, "timed out waiting for condition", msg)
}
// generationIDByFingerprint looks up a generation's CLI-facing ordinal ID
// from its fingerprint, for tests that need to Activate/Retire a specific
// generation by ID.
func generationIDByFingerprint(t *testing.T, ix *Index, fp string) int64 {
t.Helper()
gens, err := ix.Generations(context.Background())
require.NoError(t, err)
for _, g := range gens {
if g.Fingerprint == fp {
return g.ID
}
}
require.Fail(t, "generation not found for fingerprint", fp)
return 0
}
// countingEncoder returns a working encoder that counts its calls, so
// tests can tell which EncoderSet entry a build actually used.
func countingEncoder(calls *atomic.Int64) kitvec.EncodeFunc {
return func(_ context.Context, texts []string) ([][]float32, error) {
calls.Add(1)
out := make([][]float32, len(texts))
for i := range texts {
out[i] = []float32{1, 0, 0}
}
return out, nil
}
}
func TestManagerBuildUsingSelectsNamedEncoder(t *testing.T) {
ix := openTestIndex(t)
src := twoDocSource()
gen := fakeGeneration("fake-model")
var localCalls, remoteCalls atomic.Int64
encoders := EncoderSet{Default: "local", ByName: map[string]ManagedEncoder{
"local": {Encode: countingEncoder(&localCalls), Settings: EncodeSettings{BatchSize: 10}},
"remote": {Encode: countingEncoder(&remoteCalls), Settings: EncodeSettings{BatchSize: 10}},
}}
m := NewManager(ix, src, encoders, gen)
started, err := m.TryBuild(context.Background(), BuildRequest{Using: "remote"})
require.NoError(t, err)
require.True(t, started)
assert.Positive(t, remoteCalls.Load(), "build with Using must encode on the named server")
assert.Zero(t, localCalls.Load(), "the default server must stay idle")
started, err = m.TryBuild(context.Background(), BuildRequest{FullRebuild: true})
require.NoError(t, err)
require.True(t, started)
assert.Positive(t, localCalls.Load(), "a build without Using must encode on the default server")
}
func TestManagerBuildUnknownUsingFailsBeforeStarting(t *testing.T) {
ix := openTestIndex(t)
src := twoDocSource()
gen := fakeGeneration("fake-model")
m := NewManager(ix, src, soloEncoders(fakeBuildEncoder()), gen)
err := m.StartBuild(BuildRequest{Using: "nope"})
require.ErrorContains(t, err, `no embeddings server named "nope"`)
assert.ErrorIs(t, err, ErrUnknownServer,
"callers map unknown-server errors to a client error via the sentinel")
assert.False(t, m.Status().Running, "a failed resolve must not leave the manager running")
started, err := m.TryBuild(context.Background(), BuildRequest{Using: "nope"})
require.ErrorContains(t, err, `no embeddings server named "nope"`)
assert.ErrorIs(t, err, ErrUnknownServer)
assert.False(t, started)
}
func TestManagerStartBuildSetsRunningAndConcurrentStartReturnsErrBuildRunning(t *testing.T) {
ix := openTestIndex(t)
src := twoDocSource()
gen := fakeGeneration("fake-model")
release := make(chan struct{})
m := NewManager(ix, src, soloEncoders(blockingEncoder(release)), gen)
require.NoError(t, m.StartBuild(BuildRequest{}))
waitFor(t, func() bool { return m.Status().Running }, "build never reported running")
err := m.StartBuild(BuildRequest{})
assert.ErrorIs(t, err, ErrBuildRunning)
close(release)
waitFor(t, func() bool { return !m.Status().Running }, "build never finished")
assert.Empty(t, m.Status().LastError)
}
func TestManagerTryBuildReturnsFalseWhileRunning(t *testing.T) {
ix := openTestIndex(t)
src := twoDocSource()
gen := fakeGeneration("fake-model")
release := make(chan struct{})
m := NewManager(ix, src, soloEncoders(blockingEncoder(release)), gen)
require.NoError(t, m.StartBuild(BuildRequest{}))
waitFor(t, func() bool { return m.Status().Running }, "build never reported running")
started, err := m.TryBuild(context.Background(), BuildRequest{})
assert.False(t, started, "TryBuild must drop rather than queue while running")
assert.NoError(t, err)
close(release)
waitFor(t, func() bool { return !m.Status().Running }, "build never finished")
}
func TestManagerStatusTransitionsToLastResultOnCompletion(t *testing.T) {
ix := openTestIndex(t)
src := twoDocSource()
gen := fakeGeneration("fake-model")
m := NewManager(ix, src, soloEncoders(fakeBuildEncoder()), gen)
require.NoError(t, m.StartBuild(BuildRequest{}))
waitFor(t, func() bool { return !m.Status().Running }, "build never finished")
status := m.Status()
require.NotNil(t, status.LastResult)
assert.Equal(t, gen.Fingerprint(), status.LastResult.Fingerprint)
assert.True(t, status.LastResult.Activated)
assert.Empty(t, status.LastError)
}
func TestManagerStatusSetsLastErrorOnEncoderFailure(t *testing.T) {
ix := openTestIndex(t)
src := twoDocSource()
gen := fakeGeneration("fake-model")
failingEncoder := func(_ context.Context, _ []string) ([][]float32, error) {
return nil, fmt.Errorf("encoder rejected input")
}
m := NewManager(ix, src, soloEncoders(failingEncoder), gen)
require.NoError(t, m.StartBuild(BuildRequest{}))
waitFor(t, func() bool { return !m.Status().Running }, "build never finished")
status := m.Status()
assert.Contains(t, status.LastError, "encoder rejected input")
assert.Nil(t, status.LastResult)
}
func TestManagerStatusStampsBuildIdentityAndSpace(t *testing.T) {
ix := openTestIndex(t)
src := twoDocSource()
gen := fakeGeneration("fake-model")
m := NewManager(ix, src, soloEncoders(fakeBuildEncoder()), gen)
base := time.Date(2026, 7, 11, 10, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base }
status := m.Status()
assert.Zero(t, status.BuildID, "no build has started yet")
assert.Empty(t, status.StartedAt)
assert.Equal(t, "fake-model", status.Model,
"the configured space is reported even before any build")
assert.Equal(t, 3, status.Dimension)
started, err := m.TryBuild(context.Background(), BuildRequest{})
require.NoError(t, err)
require.True(t, started)
status = m.Status()
assert.Equal(t, int64(1), status.BuildID)
assert.Equal(t, "2026-07-11T10:00:00Z", status.StartedAt)
m.now = func() time.Time { return base.Add(time.Minute) }
started, err = m.TryBuild(context.Background(), BuildRequest{FullRebuild: true})
require.NoError(t, err)
require.True(t, started)
status = m.Status()
assert.Equal(t, int64(2), status.BuildID,
"each build start must get a fresh identity so pollers can tell builds apart")
assert.Equal(t, "2026-07-11T10:01:00Z", status.StartedAt)
}
func TestManagerGenerationsDelegatesToIndex(t *testing.T) {
ix := openTestIndex(t)
ctx := context.Background()
src := twoDocSource()
gen := fakeGeneration("fake-model")
m := NewManager(ix, src, soloEncoders(fakeBuildEncoder()), gen)
_, err := m.TryBuild(ctx, BuildRequest{})
require.NoError(t, err)
want, err := ix.Generations(ctx)
require.NoError(t, err)
got, err := m.Generations(ctx)
require.NoError(t, err)
assert.Equal(t, want, got)
}
func TestManagerActivateForceRefusalMatrix(t *testing.T) {
ix := openTestIndex(t)
ctx := context.Background()
src := twoDocSource()
genA := fakeGeneration("model-a")
m := NewManager(ix, src, soloEncoders(fakeBuildEncoder()), genA)
_, err := m.TryBuild(ctx, BuildRequest{})
require.NoError(t, err, "genA becomes active")
genB := fakeGeneration("model-b")
fpB, err := ix.EnsureGeneration(ctx, genB, sqlitevec.StateBuilding)
require.NoError(t, err, "genB registered but never filled, so it has Missing > 0")
idB := generationIDByFingerprint(t, ix, fpB)
err = m.Activate(ctx, idB, false)
require.Error(t, err, "refuses activation of an incompletely embedded generation")
assert.Contains(t, err.Error(), fmt.Sprintf("generation %d still has", idB))
assert.Contains(t, err.Error(), "use --force")
require.NoError(t, m.Activate(ctx, idB, true), "force overrides the refusal")
active, ok, err := ix.ActiveFingerprint(ctx)
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, fpB, active, "genB is now active")
idA := generationIDByFingerprint(t, ix, genA.Fingerprint())
gens, err := ix.Generations(ctx)
require.NoError(t, err)
var stateA string
for _, g := range gens {
if g.ID == idA {
stateA = g.State
}
}
assert.Equal(t, string(sqlitevec.StateRetired), stateA, "activating genB retires the old active genA")
}
func TestManagerRetireRefusesActiveGenerationWithoutForce(t *testing.T) {
ix := openTestIndex(t)
ctx := context.Background()
src := twoDocSource()
gen := fakeGeneration("fake-model")
m := NewManager(ix, src, soloEncoders(fakeBuildEncoder()), gen)
_, err := m.TryBuild(ctx, BuildRequest{})
require.NoError(t, err)
id := generationIDByFingerprint(t, ix, gen.Fingerprint())
err = m.Retire(ctx, id, false)
require.Error(t, err, "refuses retiring the active generation without force")
require.NoError(t, m.Retire(ctx, id, true), "force overrides the refusal")
gens, err := ix.Generations(ctx)
require.NoError(t, err)
require.Len(t, gens, 1)
assert.Equal(t, string(sqlitevec.StateRetired), gens[0].State)
}
// countActiveGenerations returns how many generations are currently in the
// active state, the invariant concurrent Activate calls must preserve (== 1
// once any generation has been activated).
func countActiveGenerations(t *testing.T, ix *Index) int {
t.Helper()
gens, err := ix.Generations(context.Background())
require.NoError(t, err)
active := 0
for _, g := range gens {
if g.State == string(sqlitevec.StateActive) {
active++
}
}
return active
}
// TestManagerConcurrentActivateNeverLeavesTwoActive guards the
// retire-then-activate invariant: Activate must use the single-transaction
// activateGeneration primitive and serialize against other Activate calls,
// or two racing Activates on different generations can interleave their
// retire and activate steps and leave both generations active.
func TestManagerConcurrentActivateNeverLeavesTwoActive(t *testing.T) {
ix := openTestIndex(t)
ctx := context.Background()
src := twoDocSource()
genA := fakeGeneration("model-a")
genB := fakeGeneration("model-b")
m := NewManager(ix, src, soloEncoders(fakeBuildEncoder()), genA)
_, err := ix.Build(ctx, src, fakeBuildEncoder(), genA, BuildOptions{})
require.NoError(t, err)
_, err = ix.Build(ctx, src, fakeBuildEncoder(), genB, BuildOptions{})
require.NoError(t, err, "both generations fully embedded; genB active, genA retired")
idA := generationIDByFingerprint(t, ix, genA.Fingerprint())
idB := generationIDByFingerprint(t, ix, genB.Fingerprint())
for i := range 25 {
var wg sync.WaitGroup
var errA, errB error
wg.Add(2)
go func() {
defer wg.Done()
errA = m.Activate(ctx, idA, false)
}()
go func() {
defer wg.Done()
errB = m.Activate(ctx, idB, false)
}()
wg.Wait()
require.NoError(t, errA, "iteration %d", i)
require.NoError(t, errB, "iteration %d", i)
require.Equal(t, 1, countActiveGenerations(t, ix),
"iteration %d: exactly one generation must be active after racing Activates", i)
}
}
// TestManagerStartBuildRecoversPanickedEncoder guards the daemon against a
// panic in the caller-supplied encoder (a network client): StartBuild's
// detached goroutine must recover, record the panic in LastError, and clear
// the running state instead of crashing the process.
func TestManagerStartBuildRecoversPanickedEncoder(t *testing.T) {
ix := openTestIndex(t)
src := twoDocSource()
gen := fakeGeneration("fake-model")
panickingEncoder := func(_ context.Context, _ []string) ([][]float32, error) {
panic("encoder exploded")
}
m := NewManager(ix, src, soloEncoders(panickingEncoder), gen)
require.NoError(t, m.StartBuild(BuildRequest{}))
waitFor(t, func() bool { return !m.Status().Running }, "build never finished after panic")
status := m.Status()
assert.Contains(t, status.LastError, "panicked")
assert.Contains(t, status.LastError, "encoder exploded")
assert.Nil(t, status.LastResult)
require.NoError(t, m.StartBuild(BuildRequest{}),
"manager must accept a new build after a panicked one")
waitFor(t, func() bool { return !m.Status().Running }, "second build never finished")
}
// TestManagerActivateAndRetireUnknownIDPropagateNotFound guards the HTTP
// route mapping (embeddingsActionError in internal/server): Activate and
// Retire must propagate GenerationByID's ErrGenerationNotFound unwrapped
// enough for errors.Is to still match it, rather than losing the sentinel
// on the way up.
func TestManagerActivateAndRetireUnknownIDPropagateNotFound(t *testing.T) {
ix := openTestIndex(t)
ctx := context.Background()
src := twoDocSource()
gen := fakeGeneration("fake-model")
m := NewManager(ix, src, soloEncoders(fakeBuildEncoder()), gen)
err := m.Activate(ctx, 999, false)
assert.ErrorIs(t, err, ErrGenerationNotFound)
err = m.Retire(ctx, 999, false)
assert.ErrorIs(t, err, ErrGenerationNotFound)
}
func TestManagerActivateAndRetireRefuseWhileBuildRunning(t *testing.T) {
ix := openTestIndex(t)
ctx := context.Background()
src := twoDocSource()
gen := fakeGeneration("fake-model")
release := make(chan struct{})
m := NewManager(ix, src, soloEncoders(blockingEncoder(release)), gen)
require.NoError(t, m.StartBuild(BuildRequest{}))
waitFor(t, func() bool { return m.Status().Running }, "build never reported running")
err := m.Activate(ctx, 1, true)
assert.ErrorIs(t, err, ErrBuildRunning)
err = m.Retire(ctx, 1, true)
assert.ErrorIs(t, err, ErrBuildRunning)
close(release)
waitFor(t, func() bool { return !m.Status().Running }, "build never finished")
}