f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
783 lines
28 KiB
Go
783 lines
28 KiB
Go
package vector
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
kitvec "go.kenn.io/kit/vector"
|
|
"go.kenn.io/kit/vector/sqlitevec"
|
|
)
|
|
|
|
// fakeBuildEncoder returns a deterministic 3-dimensional encoder that never
|
|
// fails, for tests that only care about fill/activation bookkeeping rather
|
|
// than the vectors themselves.
|
|
func fakeBuildEncoder() kitvec.EncodeFunc {
|
|
return func(_ context.Context, texts []string) ([][]float32, error) {
|
|
out := make([][]float32, len(texts))
|
|
for i := range texts {
|
|
out[i] = []float32{1, 0, 0}
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
// twoDocSource returns a fakeUnitSource with two distinct user documents
|
|
// in one session, the small corpus most build tests share.
|
|
func twoDocSource() *fakeUnitSource {
|
|
return &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("s1", "u1", 0, "hello"),
|
|
endedAt: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
unit: userDoc("s1", "u2", 1, "world"),
|
|
endedAt: "2024-01-01T00:00:01Z",
|
|
},
|
|
}}
|
|
}
|
|
|
|
func fakeGeneration(model string) kitvec.Generation {
|
|
return kitvec.Generation{Model: model, Dimensions: 3}
|
|
}
|
|
|
|
func TestBuildFirstBuildEmbedsAllAndActivates(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Activated)
|
|
assert.Equal(t, gen.Fingerprint(), result.Fingerprint)
|
|
assert.Equal(t, 2, result.Fill.Documents)
|
|
|
|
active, ok, err := ix.ActiveFingerprint(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, gen.Fingerprint(), active)
|
|
}
|
|
|
|
func TestBuildSecondBuildNoChangesFillsZero(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
_, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, result.Fill.Documents)
|
|
assert.False(t, result.Activated, "already active, no re-activation")
|
|
}
|
|
|
|
func TestBuildContentChangeReembedsExactlyOne(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
_, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
|
|
// A real edit bumps the session's ended_at, so give the changed row a
|
|
// newer timestamp than the watermark the first build advanced to, or
|
|
// the fake source's incremental scan (mimicking the real one) would
|
|
// never resurface it.
|
|
src.rows[0].unit.Content = "changed"
|
|
src.rows[0].endedAt = "2024-01-02T00:00:00Z"
|
|
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, result.Fill.Documents, "only the changed document is re-embedded")
|
|
assert.False(t, result.Activated, "target was already active")
|
|
}
|
|
|
|
func TestBuildModelChangeBuildsSecondGenerationAndRetiresOld(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
gen1 := fakeGeneration("model-a")
|
|
|
|
_, err := ix.Build(ctx, src, fakeBuildEncoder(), gen1, BuildOptions{})
|
|
require.NoError(t, err)
|
|
|
|
gen2 := fakeGeneration("model-b")
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen2, BuildOptions{})
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Activated)
|
|
assert.Equal(t, gen2.Fingerprint(), result.Fingerprint)
|
|
assert.Equal(t, 2, result.Fill.Documents)
|
|
|
|
active, ok, err := ix.ActiveFingerprint(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, gen2.Fingerprint(), active)
|
|
|
|
gens, err := ix.Generations(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, gens, 2)
|
|
var oldState string
|
|
for _, g := range gens {
|
|
if g.Fingerprint == gen1.Fingerprint() {
|
|
oldState = g.State
|
|
}
|
|
}
|
|
assert.Equal(t, string(sqlitevec.StateRetired), oldState, "old active generation is retired")
|
|
}
|
|
|
|
func TestBuildFullRebuildSameFingerprintReembedsEverything(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
_, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{FullRebuild: true})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, result.Fill.Documents, "full rebuild re-embeds every document")
|
|
assert.False(t, result.Activated, "already-active generation stays active without reactivation")
|
|
|
|
active, ok, err := ix.ActiveFingerprint(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, gen.Fingerprint(), active)
|
|
}
|
|
|
|
// TestBuildFullRebuildRetiredGenerationReembeds covers the resolveBuildTarget
|
|
// gap where a FullRebuild request targets a fingerprint that already exists
|
|
// as a retired generation (from an earlier model switch): without resetting
|
|
// it, EnsureGeneration would reuse its old stamps and Fill would find
|
|
// nothing pending, silently reactivating stale embeddings instead of
|
|
// performing the requested rebuild.
|
|
func TestBuildFullRebuildRetiredGenerationReembeds(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
genA := fakeGeneration("model-a")
|
|
genB := fakeGeneration("model-b")
|
|
|
|
_, err := ix.Build(ctx, src, fakeBuildEncoder(), genA, BuildOptions{})
|
|
require.NoError(t, err)
|
|
_, err = ix.Build(ctx, src, fakeBuildEncoder(), genB, BuildOptions{})
|
|
require.NoError(t, err, "genA is now retired, genB active")
|
|
|
|
var encodeCalls int
|
|
countingEncoder := func(_ context.Context, texts []string) ([][]float32, error) {
|
|
encodeCalls++
|
|
out := make([][]float32, len(texts))
|
|
for i := range texts {
|
|
out[i] = []float32{1, 0, 0}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
result, err := ix.Build(ctx, src, countingEncoder, genA, BuildOptions{FullRebuild: true})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, result.Fill.Documents,
|
|
"full rebuild on a retired generation must re-embed every document")
|
|
assert.Positive(t, encodeCalls, "encoder must actually be invoked, not skipped")
|
|
}
|
|
|
|
// TestBuildScopeChangeToIncludeAutomatedForcesFullRefreshAndEmbedsOlderDoc
|
|
// covers the interplay between a widening include-automated scope change and
|
|
// the refresh watermark: the automated doc is older than the first build's
|
|
// watermark (set from the human doc's later ended_at, since the automated
|
|
// doc never entered the scan at all under the default scope). Without
|
|
// scope-change detection forcing a full (since="") rescan, the second
|
|
// build's incremental scan would stay restricted to the stored watermark and
|
|
// permanently miss the now-in-scope but chronologically older document.
|
|
func TestBuildScopeChangeToIncludeAutomatedForcesFullRefreshAndEmbedsOlderDoc(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
src := &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("s1", "human", 0, "hello"),
|
|
endedAt: "2024-01-02T00:00:00Z",
|
|
},
|
|
{
|
|
unit: userDoc("s2", "auto", 0, "roborev output"),
|
|
endedAt: "2024-01-01T00:00:00Z", // older than the human doc
|
|
automated: true,
|
|
},
|
|
}}
|
|
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, result.Fill.Documents, "the automated doc is excluded by the default scope")
|
|
assert.ElementsMatch(t, []string{"u:s1:human"}, mirrorDocKeys(t, ix))
|
|
|
|
result, err = ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{IncludeAutomated: true})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, result.Fill.Documents, "only the newly in-scope automated doc is embedded")
|
|
assert.ElementsMatch(t, []string{"u:s1:human", "u:s2:auto"}, mirrorDocKeys(t, ix),
|
|
"the older automated doc must be picked up despite predating the stored refresh watermark")
|
|
}
|
|
|
|
// TestBuildScopeChangeToExcludeAutomatedRemovesOutOfScopeMirrorRow covers the
|
|
// narrowing direction: reverting to the default scope after building with
|
|
// IncludeAutomated: true must reconcile the automated document's mirror row
|
|
// (and its vectors) away, without touching the still-in-scope human
|
|
// document's existing embedding.
|
|
func TestBuildScopeChangeToExcludeAutomatedRemovesOutOfScopeMirrorRow(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
src := &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("s1", "human", 0, "hello"),
|
|
endedAt: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
unit: userDoc("s2", "auto", 0, "roborev output"),
|
|
endedAt: "2024-01-02T00:00:00Z",
|
|
automated: true,
|
|
},
|
|
}}
|
|
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{IncludeAutomated: true})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, result.Fill.Documents)
|
|
assert.ElementsMatch(t, []string{"u:s1:human", "u:s2:auto"}, mirrorDocKeys(t, ix))
|
|
|
|
result, err = ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{IncludeAutomated: false})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, result.Fill.Documents,
|
|
"no embedding work: the human doc was already embedded and stays so")
|
|
assert.Equal(t, 1, result.Refresh.Deleted,
|
|
"the now-out-of-scope automated row must be reconciled away")
|
|
assert.ElementsMatch(t, []string{"u:s1:human"}, mirrorDocKeys(t, ix))
|
|
|
|
_, ok := readMirrorRow(t, ix, "u:s2:auto")
|
|
assert.False(t, ok, "the out-of-scope mirror row must be removed")
|
|
}
|
|
|
|
// TestBuildLegacyMirrorMissingScopeKeyForcesFullRefresh covers a mirror built
|
|
// before the include-automated scope feature existed: a refresh watermark is
|
|
// already stamped (this is not a first-ever build), but
|
|
// scope_include_automated was never written since setIncludeAutomatedScope
|
|
// did not exist yet. Without treating that missing key as a scope change,
|
|
// Build would run an incremental (since=watermark) scan forever and never
|
|
// pick up a document older than the stored watermark, nor would it ever
|
|
// reconcile away now-out-of-scope automated rows a legacy mirror might carry.
|
|
func TestBuildLegacyMirrorMissingScopeKeyForcesFullRefresh(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
// Simulate the pre-scope-feature mirror state directly: a stamped
|
|
// watermark with no scope_include_automated row in vector_meta.
|
|
require.NoError(t, ix.setRefreshWatermark(ctx, "2024-06-01T00:00:00Z"))
|
|
_, hasScope, err := ix.storedIncludeAutomatedScope(ctx)
|
|
require.NoError(t, err)
|
|
require.False(t, hasScope, "test setup must not pre-seed a scope key")
|
|
|
|
src := &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("s1", "human", 0, "hello"),
|
|
endedAt: "2024-01-01T00:00:00Z", // older than the stored watermark
|
|
},
|
|
}}
|
|
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
|
|
assert.Empty(t, src.gotSince,
|
|
"a legacy mirror with no stored scope key must force a full (since=\"\") rescan")
|
|
assert.Equal(t, 1, result.Fill.Documents,
|
|
"the pre-watermark document must be picked up despite predating the stored refresh watermark")
|
|
assert.ElementsMatch(t, []string{"u:s1:human"}, mirrorDocKeys(t, ix))
|
|
|
|
storedScope, hasScope, err := ix.storedIncludeAutomatedScope(ctx)
|
|
require.NoError(t, err)
|
|
assert.True(t, hasScope,
|
|
"Build must stamp the scope key so later builds compare against a real stored value")
|
|
assert.False(t, storedScope)
|
|
}
|
|
|
|
// TestCountPendingIncludesRevisionChangedDocs covers countPending's
|
|
// BuildProgress.Total denominator: a document whose mirror content_hash
|
|
// changed since it was last stamped must still count as pending, matching
|
|
// the s.revision = d.content_hash predicate generationCoverageQuery's
|
|
// Missing column uses, or Total under-reports outstanding work.
|
|
func TestCountPendingIncludesRevisionChangedDocs(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
_, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
|
|
fp := gen.Fingerprint()
|
|
total, err := ix.countPending(ctx, fp)
|
|
require.NoError(t, err)
|
|
assert.Zero(t, total, "fully embedded generation has nothing pending")
|
|
|
|
// Simulate content changing without a mirror refresh reconciling the
|
|
// stamp: the stamp's revision no longer matches content_hash.
|
|
_, err = ix.db.ExecContext(ctx,
|
|
`UPDATE vector_messages SET content_hash = 'changed-hash' WHERE doc_key = 'u:s1:u1'`)
|
|
require.NoError(t, err)
|
|
|
|
total, err = ix.countPending(ctx, fp)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 1, total, "content-changed doc must count as pending, not complete")
|
|
}
|
|
|
|
// TestCountPendingSumsChunksAcrossMultiChunkDocuments covers the units bug
|
|
// where BuildProgress.Total counted pending documents while Done counted
|
|
// encoded chunks: a message that splits into several chunks would drive the
|
|
// reported percentage past 100%. countPending must sum chunks, matching
|
|
// Done's unit, not count the document once.
|
|
func TestCountPendingSumsChunksAcrossMultiChunkDocuments(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
longContent := strings.Repeat("word ", 2000) // far past the 4000-rune split threshold
|
|
src := &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("s1", "u1", 0, "short"),
|
|
endedAt: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
unit: userDoc("s1", "u2", 1, longContent),
|
|
endedAt: "2024-01-01T00:00:01Z",
|
|
},
|
|
}}
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
_, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
fp := gen.Fingerprint()
|
|
|
|
longChunks := len(kitvec.Split(longContent, ix.split))
|
|
require.Greater(t, longChunks, 1,
|
|
"content must actually split into multiple chunks for this test to be meaningful")
|
|
|
|
// Simulate the long document changing without a mirror refresh
|
|
// reconciling the stamp, so it counts as pending again (same technique
|
|
// as TestCountPendingIncludesRevisionChangedDocs).
|
|
_, err = ix.db.ExecContext(ctx,
|
|
`UPDATE vector_messages SET content_hash = 'changed-hash' WHERE doc_key = 'u:s1:u2'`)
|
|
require.NoError(t, err)
|
|
|
|
total, err := ix.countPending(ctx, fp)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, longChunks, total,
|
|
"Total must sum the pending document's chunks, not count it as one document")
|
|
}
|
|
|
|
func TestBuildProgressReceivesFinalDoneEqualToTotalChunks(t *testing.T) {
|
|
previous := progressInterval
|
|
progressInterval = 0
|
|
t.Cleanup(func() { progressInterval = previous })
|
|
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
var calls []BuildProgress
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{
|
|
Progress: func(p BuildProgress) { calls = append(calls, p) },
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, calls)
|
|
assert.Equal(t, "scanning", calls[0].Phase,
|
|
"the first report must announce the mirror-refresh scan, before any "+
|
|
"chunk totals exist, so consumers can render more than 0/0 chunks")
|
|
last := calls[len(calls)-1]
|
|
assert.EqualValues(t, result.Fill.Chunks, last.Done)
|
|
assert.EqualValues(t, result.Fill.Chunks, last.Total,
|
|
"Total must also be in chunks so Done/Total settles at exactly 100%")
|
|
assert.Equal(t, "embedding", last.Phase)
|
|
}
|
|
|
|
// TestBuildProgressNeverExceedsTotalWithMultiChunkMessage covers the
|
|
// regression this units fix addresses directly: a message long enough to
|
|
// split into several chunks must never push a progress call's Done past its
|
|
// Total (which would render as a percentage over 100%).
|
|
func TestBuildProgressNeverExceedsTotalWithMultiChunkMessage(t *testing.T) {
|
|
previous := progressInterval
|
|
progressInterval = 0
|
|
t.Cleanup(func() { progressInterval = previous })
|
|
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
longContent := strings.Repeat("word ", 2000)
|
|
src := &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("s1", "u1", 0, "short"),
|
|
endedAt: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
unit: userDoc("s1", "u2", 1, longContent),
|
|
endedAt: "2024-01-01T00:00:01Z",
|
|
},
|
|
}}
|
|
gen := fakeGeneration("fake-model")
|
|
require.Greater(t, len(kitvec.Split(longContent, ix.split)), 1,
|
|
"content must actually split into multiple chunks for this test to be meaningful")
|
|
|
|
var calls []BuildProgress
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{
|
|
Progress: func(p BuildProgress) { calls = append(calls, p) },
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, calls)
|
|
for _, p := range calls {
|
|
assert.LessOrEqualf(t, p.Done, p.Total, "progress must never exceed 100%%: %+v", p)
|
|
}
|
|
last := calls[len(calls)-1]
|
|
assert.EqualValues(t, result.Fill.Chunks, last.Done)
|
|
assert.EqualValues(t, result.Fill.Chunks, last.Total)
|
|
}
|
|
|
|
func TestBuildEncoderErrorAbortsAndRetryResumesWithoutReembedding(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("s1", "", 0, "one"),
|
|
endedAt: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
unit: userDoc("s1", "", 1, "bad"),
|
|
endedAt: "2024-01-01T00:00:01Z",
|
|
},
|
|
{
|
|
unit: userDoc("s1", "", 2, "three"),
|
|
endedAt: "2024-01-01T00:00:02Z",
|
|
},
|
|
}}
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
failOnBad := func(_ context.Context, texts []string) ([][]float32, error) {
|
|
if slices.Contains(texts, "bad") {
|
|
return nil, fmt.Errorf("encoder rejected input")
|
|
}
|
|
out := make([][]float32, len(texts))
|
|
for i := range texts {
|
|
out[i] = []float32{1, 0, 0}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
_, err := ix.Build(ctx, src, failOnBad, gen, BuildOptions{})
|
|
require.Error(t, err)
|
|
|
|
var stampCount int
|
|
require.NoError(t, ix.db.QueryRow(
|
|
`SELECT COUNT(*) FROM message_vectors_stamps`,
|
|
).Scan(&stampCount))
|
|
assert.Equal(t, 1, stampCount, "only the document before the failing one was stamped")
|
|
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, result.Fill.Documents, "retry embeds only the two remaining documents")
|
|
assert.True(t, result.Activated)
|
|
}
|
|
|
|
// TestBuildSkipsPermanentlyRejectedDocumentAndContinues is the fix-1
|
|
// regression test: a single document the endpoint permanently rejects
|
|
// (400) must not wedge the whole build. kit stamps it without vectors so
|
|
// the scan moves past it, later documents still embed, and the generation
|
|
// still auto-activates once every document — including the skipped one —
|
|
// is stamped.
|
|
func TestBuildSkipsPermanentlyRejectedDocumentAndContinues(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("s1", "", 0, "one"),
|
|
endedAt: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
unit: userDoc("s1", "", 1, "poison"),
|
|
endedAt: "2024-01-01T00:00:01Z",
|
|
},
|
|
{
|
|
unit: userDoc("s1", "", 2, "three"),
|
|
endedAt: "2024-01-01T00:00:02Z",
|
|
},
|
|
}}
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
var calls int
|
|
rejectPoison := func(_ context.Context, texts []string) ([][]float32, error) {
|
|
calls++
|
|
if slices.Contains(texts, "poison") {
|
|
return nil, &HTTPStatusError{Status: http.StatusBadRequest, Body: "token window overflow"}
|
|
}
|
|
out := make([][]float32, len(texts))
|
|
for i := range texts {
|
|
out[i] = []float32{1, 0, 0}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
result, err := ix.Build(ctx, src, rejectPoison, gen, BuildOptions{})
|
|
require.NoError(t, err, "a permanently-rejected document must not abort the whole build")
|
|
assert.Equal(t, 2, result.Fill.Documents, "the two good documents still embed")
|
|
assert.Equal(t, 1, result.Fill.Skipped, "the poison document is counted as skipped")
|
|
assert.True(t, result.Activated,
|
|
"coverage is complete (every document stamped) once the poison doc is stamped-skipped")
|
|
|
|
var stampCount int
|
|
require.NoError(t, ix.db.QueryRow(
|
|
`SELECT COUNT(*) FROM message_vectors_stamps`,
|
|
).Scan(&stampCount))
|
|
assert.Equal(t, 3, stampCount, "the skipped document is still stamped, just without vectors")
|
|
|
|
// kit only re-embeds a skipped document once its content_hash changes;
|
|
// a later build over unchanged content must not retry the encoder for
|
|
// it at all.
|
|
callsBefore := calls
|
|
result2, err := ix.Build(ctx, src, rejectPoison, gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, result2.Fill.Documents)
|
|
assert.Equal(t, 0, result2.Fill.Skipped)
|
|
assert.Equal(t, callsBefore, calls,
|
|
"unchanged content must not re-invoke the encoder for the already-skipped document")
|
|
}
|
|
|
|
// TestBuildConfig404EncodeErrorAbortsAndLeavesDocumentsPending covers a
|
|
// config/API failure that applies to every input, such as a wrong embeddings
|
|
// route. It must abort rather than stamp-skipping the whole corpus and
|
|
// auto-activating an empty generation.
|
|
func TestBuildConfig404EncodeErrorAbortsAndLeavesDocumentsPending(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
notFound := func(_ context.Context, _ []string) ([][]float32, error) {
|
|
return nil, &HTTPStatusError{Status: http.StatusNotFound, Body: "not found"}
|
|
}
|
|
|
|
result, err := ix.Build(ctx, src, notFound, gen, BuildOptions{})
|
|
require.Error(t, err)
|
|
assert.Equal(t, 0, result.Fill.Documents)
|
|
assert.Equal(t, 0, result.Fill.Skipped,
|
|
"route/config failures must not stamp-skip every document")
|
|
|
|
active, ok, err := ix.ActiveFingerprint(ctx)
|
|
require.NoError(t, err)
|
|
assert.False(t, ok, "an empty failed generation must not activate")
|
|
assert.Empty(t, active)
|
|
|
|
var stampCount int
|
|
require.NoError(t, ix.db.QueryRow(
|
|
`SELECT COUNT(*) FROM message_vectors_stamps`,
|
|
).Scan(&stampCount))
|
|
assert.Equal(t, 0, stampCount, "failed route/config errors leave documents retryable")
|
|
|
|
pending, err := ix.countPending(ctx, gen.Fingerprint())
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 2, pending,
|
|
"later builds must still see the same documents as pending")
|
|
}
|
|
|
|
func TestBuildSchema400EncodeErrorAbortsAndLeavesDocumentsPending(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
badSchema := func(_ context.Context, _ []string) ([][]float32, error) {
|
|
return nil, &HTTPStatusError{Status: http.StatusBadRequest, Body: "invalid input type"}
|
|
}
|
|
|
|
result, err := ix.Build(ctx, src, badSchema, gen, BuildOptions{})
|
|
require.Error(t, err)
|
|
assert.Equal(t, 0, result.Fill.Documents)
|
|
assert.Equal(t, 0, result.Fill.Skipped)
|
|
|
|
pending, err := ix.countPending(ctx, gen.Fingerprint())
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 2, pending)
|
|
}
|
|
|
|
// TestBuild5xxEncodeErrorStillAbortsFill guards the other side of the OnEncodeError
|
|
// wiring: a transient (5xx) failure must still abort the whole fill rather
|
|
// than being skipped, since it is likely to succeed on a later retry and
|
|
// permanently giving up on the document would lose it from the index.
|
|
func TestBuild5xxEncodeErrorStillAbortsFill(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("s1", "", 0, "one"),
|
|
endedAt: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
unit: userDoc("s1", "", 1, "bad"),
|
|
endedAt: "2024-01-01T00:00:01Z",
|
|
},
|
|
}}
|
|
gen := fakeGeneration("fake-model")
|
|
|
|
fail500 := func(_ context.Context, texts []string) ([][]float32, error) {
|
|
if slices.Contains(texts, "bad") {
|
|
return nil, &HTTPStatusError{Status: http.StatusInternalServerError, Body: "boom"}
|
|
}
|
|
out := make([][]float32, len(texts))
|
|
for i := range texts {
|
|
out[i] = []float32{1, 0, 0}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
_, err := ix.Build(ctx, src, fail500, gen, BuildOptions{})
|
|
require.Error(t, err, "a transient (5xx) encode error must still abort the fill")
|
|
var statusErr *HTTPStatusError
|
|
require.ErrorAs(t, err, &statusErr)
|
|
assert.Equal(t, http.StatusInternalServerError, statusErr.Status)
|
|
}
|
|
|
|
// TestResolveBuildTargetRetiresOtherBuildingGeneration is the fix-3
|
|
// regression test: a generation left in state building by an abandoned
|
|
// (config changed mid-build) first build must be retired once a new
|
|
// building generation is established, so fingerprintByState's ORDER BY
|
|
// ordinal LIMIT 1 lookup (used for both BuildingFingerprint and the
|
|
// building-percent shown to a caller with no active generation) resolves
|
|
// to the generation actually being built, not the abandoned one.
|
|
func TestResolveBuildTargetRetiresOtherBuildingGeneration(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
|
|
genA := fakeGeneration("model-a")
|
|
fpA, err := ix.EnsureGeneration(ctx, genA, sqlitevec.StateBuilding)
|
|
require.NoError(t, err)
|
|
|
|
genB := fakeGeneration("model-b")
|
|
target, wasBuilding, err := ix.resolveBuildTarget(ctx, genB, genB.Fingerprint(), false)
|
|
require.NoError(t, err)
|
|
assert.True(t, wasBuilding)
|
|
assert.Equal(t, genB.Fingerprint(), target)
|
|
|
|
gens, err := ix.Generations(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, gens, 2)
|
|
for _, g := range gens {
|
|
switch g.Fingerprint {
|
|
case fpA:
|
|
assert.Equal(t, string(sqlitevec.StateRetired), g.State,
|
|
"the abandoned generation must be retired, not left building forever")
|
|
case target:
|
|
assert.Equal(t, string(sqlitevec.StateBuilding), g.State)
|
|
}
|
|
}
|
|
|
|
building, ok, err := ix.BuildingFingerprint(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, target, building,
|
|
"the building-generation lookup must resolve to B, not the abandoned A")
|
|
}
|
|
|
|
// TestBuildRetiresAbandonedBuildingGenerationEndToEnd drives the same
|
|
// scenario through the public Build entry point: an interrupted first
|
|
// build (genA registered as building but never filled, standing in for a
|
|
// crashed process) followed by a full Build under a different config
|
|
// (genB) must retire genA rather than leave two generations in state
|
|
// building.
|
|
func TestBuildRetiresAbandonedBuildingGenerationEndToEnd(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
|
|
genA := fakeGeneration("model-a")
|
|
_, err := ix.Refresh(ctx, src, true, true)
|
|
require.NoError(t, err)
|
|
fpA, err := ix.EnsureGeneration(ctx, genA, sqlitevec.StateBuilding)
|
|
require.NoError(t, err)
|
|
|
|
genB := fakeGeneration("model-b")
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), genB, BuildOptions{})
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Activated)
|
|
|
|
gens, err := ix.Generations(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, gens, 2)
|
|
var stateA, stateB string
|
|
for _, g := range gens {
|
|
switch g.Fingerprint {
|
|
case fpA:
|
|
stateA = g.State
|
|
case genB.Fingerprint():
|
|
stateB = g.State
|
|
}
|
|
}
|
|
assert.Equal(t, string(sqlitevec.StateRetired), stateA, "abandoned generation A must be retired")
|
|
assert.Equal(t, string(sqlitevec.StateActive), stateB)
|
|
|
|
_, ok, err := ix.BuildingFingerprint(ctx)
|
|
require.NoError(t, err)
|
|
assert.False(t, ok, "no generation should remain in state building")
|
|
}
|
|
|
|
// TestBuildActiveFingerprintEarlyReturnRetiresAbandonedBuildingGeneration
|
|
// covers a gap in resolveBuildTarget's active-fingerprint early return: when
|
|
// the requested generation is already active, the target-resolution path
|
|
// never reaches EnsureGeneration, which is the only other place that retires
|
|
// abandoned building generations. Without also retiring on this path, a
|
|
// first build of some other fingerprint that registered as building and
|
|
// then failed (a crashed process, or config that got reverted back to the
|
|
// still-active fingerprint before the failed build could be retried) stays
|
|
// in state building forever once every subsequent build targets the active
|
|
// generation again.
|
|
func TestBuildActiveFingerprintEarlyReturnRetiresAbandonedBuildingGeneration(t *testing.T) {
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := twoDocSource()
|
|
genA := fakeGeneration("model-a")
|
|
|
|
_, err := ix.Build(ctx, src, fakeBuildEncoder(), genA, BuildOptions{})
|
|
require.NoError(t, err, "genA is now active")
|
|
|
|
// Simulate an abandoned first build of a different config: registered as
|
|
// building, but never filled or retired (standing in for a crashed
|
|
// process, or a config change that was reverted before the failed build
|
|
// could be cleaned up).
|
|
genB := fakeGeneration("model-b")
|
|
fpB, err := ix.EnsureGeneration(ctx, genB, sqlitevec.StateBuilding)
|
|
require.NoError(t, err)
|
|
|
|
// Build again with genA, the still-active fingerprint: this must take
|
|
// the active-fingerprint early-return path in resolveBuildTarget, not
|
|
// the EnsureGeneration path.
|
|
result, err := ix.Build(ctx, src, fakeBuildEncoder(), genA, BuildOptions{})
|
|
require.NoError(t, err)
|
|
assert.False(t, result.Activated, "already-active generation stays active without reactivation")
|
|
|
|
gens, err := ix.Generations(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, gens, 2)
|
|
for _, g := range gens {
|
|
if g.Fingerprint == fpB {
|
|
assert.Equal(t, string(sqlitevec.StateRetired), g.State,
|
|
"the abandoned building generation must be retired once a build "+
|
|
"resolves back to the active fingerprint")
|
|
}
|
|
}
|
|
|
|
_, ok, err := ix.BuildingFingerprint(ctx)
|
|
require.NoError(t, err)
|
|
assert.False(t, ok, "no generation should remain in state building")
|
|
}
|