EmbedAll previously ran the full text-staleness CTE on every page, paying five item_people LATERAL joins per eligible row just to detect whether an item's canonical text had drifted - even when the real work was embedding brand-new (missing) items during active backfill. Restructure EmbedAll into two passes: - Pass 1 (cheap): drain missing/model-stale items via the repurposed ItemsNeedingEmbedding query (single LEFT JOIN, no LATERAL), paged by a content_id cursor so a failed/skipped item is retried next run instead of stalling the page. - Pass 2 (expensive): only once Pass 1 fully drains, run one bounded ListEmbeddingTextCandidates scan (LIMIT embeddingTextStaleQuotaPerRun=200) to re-embed text-drifted items. Re-embedding refreshes canonical_text, so handled rows drop out next run - no Pass 2 cursor needed. Coverage-first tradeoff (documented in EmbedAll): under steady state Pass 1 drains every run so text-stale items stay fresh; only under pathological continuous heavy ingest does Pass 2 get skipped, deliberately prioritizing covering new items over re-embedding changed ones. Supporting changes: - ItemsNeedingEmbedding gains an afterID cursor + ORDER BY; SQL extracted to buildItemsNeedingEmbeddingSQL for a cheap-shape unit test (no item_people / LATERAL). - Add an `embedder` interface seam (Engine.embClient) so EmbedAll is testable with a fake; *embeddings.Client still satisfies it. - Extract embedBatch to DRY both passes, preserving quota/billing early-return, single-item fallback, ensureEmbeddingLock-before-upsert, and skip-on-store-error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
7.8 KiB
Go
224 lines
7.8 KiB
Go
package recommendations
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/recommendations/embeddings"
|
|
)
|
|
|
|
// recordingEmbedder is a fake embedder seam used to drive EmbedAll without a
|
|
// real embedding API. It records, in order, every text it is asked to embed so
|
|
// tests can assert the cheap pass drains before the expensive text-staleness
|
|
// pass runs. Returned vectors are deterministic and canonical-width so the
|
|
// embedding lock validates without a real model.
|
|
type recordingEmbedder struct {
|
|
calls [][]string // one entry per Embed call (a batch)
|
|
texts []string // flattened, in embed order
|
|
}
|
|
|
|
func (r *recordingEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
|
|
batch := make([]string, len(texts))
|
|
copy(batch, texts)
|
|
r.calls = append(r.calls, batch)
|
|
r.texts = append(r.texts, batch...)
|
|
|
|
out := make([][]float32, len(texts))
|
|
for i := range texts {
|
|
vec := make([]float32, CanonicalEmbeddingDimensions)
|
|
// A non-zero leading component keeps the vector distinguishable from the
|
|
// seeded zero vectors; the rest stay zero.
|
|
vec[0] = 0.5
|
|
out[i] = vec
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
var _ embedder = (*recordingEmbedder)(nil)
|
|
|
|
// firstIndexContaining returns the index of the first recorded text containing
|
|
// needle, or -1.
|
|
func firstIndexContaining(texts []string, needle string) int {
|
|
for i, txt := range texts {
|
|
if strings.Contains(txt, needle) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// lastIndexContaining returns the index of the last recorded text containing
|
|
// needle, or -1.
|
|
func lastIndexContaining(texts []string, needle string) int {
|
|
idx := -1
|
|
for i, txt := range texts {
|
|
if strings.Contains(txt, needle) {
|
|
idx = i
|
|
}
|
|
}
|
|
return idx
|
|
}
|
|
|
|
func newEmbedAllTestEngine(t *testing.T, pool *pgxpool.Pool, fake embedder, model string) *Engine {
|
|
t.Helper()
|
|
return &Engine{
|
|
repo: NewRepo(pool),
|
|
itemRepo: catalog.NewItemRepository(pool),
|
|
personRepo: catalog.NewPersonRepository(pool),
|
|
embClient: fake,
|
|
cfg: config.RecommendationsConfig{
|
|
EmbeddingModel: model,
|
|
EmbeddingBaseURL: "http://embed.test",
|
|
},
|
|
pool: pool,
|
|
}
|
|
}
|
|
|
|
// TestEmbedAllCheapPassDrainsBeforeTextStalePass verifies the coverage-first
|
|
// ordering: items with no embedding or a stale model (cheap Pass 1) are all
|
|
// embedded before the expensive text-staleness CTE (Pass 2) re-embeds an item
|
|
// whose only change is its canonical text.
|
|
func TestEmbedAllCheapPassDrainsBeforeTextStalePass(t *testing.T) {
|
|
pool := newEngineTestPool(t)
|
|
snapshotEmbeddingLock(t, pool)
|
|
ctx := context.Background()
|
|
|
|
const (
|
|
prefix = "t7embed-"
|
|
currentModel = "model-current"
|
|
oldModel = "model-old"
|
|
)
|
|
cleanupRecoMediaItems(t, pool, prefix)
|
|
// Start from a clean lock so the first embed establishes it for currentModel.
|
|
if _, err := pool.Exec(ctx, `DELETE FROM server_settings WHERE key = $1`, embeddingLockSettingKey); err != nil {
|
|
t.Fatalf("clear embedding lock: %v", err)
|
|
}
|
|
|
|
// Two cheap rows: one missing entirely, one under an old model. Distinct
|
|
// titles let us find their generated text in the embedder's record.
|
|
missingID := prefix + "1-missing"
|
|
modelStaleID := prefix + "2-modelstale"
|
|
seedRecoMediaItemTitled(t, pool, missingID, "movie", "matched", "CHEAPMISS Origins")
|
|
seedRecoMediaItemTitled(t, pool, modelStaleID, "movie", "matched", "CHEAPMODEL Returns")
|
|
seedRecoEmbedding(t, pool, modelStaleID, oldModel, "old text")
|
|
|
|
// One text-stale-only row: embedded under the CURRENT model, but its stored
|
|
// canonical_text does not match what BuildEmbeddingText now produces. Only
|
|
// the expensive CTE can detect this.
|
|
textStaleID := prefix + "3-textstale"
|
|
seedRecoMediaItemTitled(t, pool, textStaleID, "movie", "matched", "TEXTSTALE Reloaded")
|
|
seedRecoEmbedding(t, pool, textStaleID, currentModel, "deliberately stale canonical text")
|
|
|
|
fake := &recordingEmbedder{}
|
|
e := newEmbedAllTestEngine(t, pool, fake, currentModel)
|
|
|
|
n, err := e.EmbedAll(ctx)
|
|
if err != nil {
|
|
t.Fatalf("EmbedAll: %v", err)
|
|
}
|
|
|
|
// All three should have been (re)embedded: 2 cheap + 1 text-stale.
|
|
if n < 3 {
|
|
t.Fatalf("EmbedAll embedded %d items, want >= 3 (2 cheap + 1 text-stale)", n)
|
|
}
|
|
|
|
// End state: every row now carries the current model and matching text.
|
|
assertEmbeddingModel(t, pool, missingID, currentModel)
|
|
assertEmbeddingModel(t, pool, modelStaleID, currentModel)
|
|
assertEmbeddingModel(t, pool, textStaleID, currentModel)
|
|
|
|
// Ordering: both cheap items must be embedded before the text-stale item,
|
|
// proving Pass 2 (the expensive CTE) only ran after Pass 1 drained.
|
|
lastCheap := lastIndexContaining(fake.texts, "CHEAP")
|
|
firstTextStale := firstIndexContaining(fake.texts, "TEXTSTALE")
|
|
if lastCheap < 0 {
|
|
t.Fatalf("cheap items were never embedded; recorded texts: %v", fake.texts)
|
|
}
|
|
if firstTextStale < 0 {
|
|
t.Fatalf("text-stale item was never embedded; recorded texts: %v", fake.texts)
|
|
}
|
|
if firstTextStale <= lastCheap {
|
|
t.Fatalf("text-stale item embedded at %d before cheap drained at %d; coverage-first ordering violated:\n%v",
|
|
firstTextStale, lastCheap, fake.texts)
|
|
}
|
|
}
|
|
|
|
// TestEmbedAllSkipsTextStaleWhenAlreadyCurrent confirms Pass 2 does not waste an
|
|
// embed call on an item whose stored canonical_text already matches the freshly
|
|
// generated text (it is neither missing, model-stale, nor text-stale).
|
|
func TestEmbedAllSkipsAlreadyCurrentItems(t *testing.T) {
|
|
pool := newEngineTestPool(t)
|
|
snapshotEmbeddingLock(t, pool)
|
|
ctx := context.Background()
|
|
|
|
const (
|
|
prefix = "t7current-"
|
|
currentModel = "model-current"
|
|
)
|
|
cleanupRecoMediaItems(t, pool, prefix)
|
|
if _, err := pool.Exec(ctx, `DELETE FROM server_settings WHERE key = $1`, embeddingLockSettingKey); err != nil {
|
|
t.Fatalf("clear embedding lock: %v", err)
|
|
}
|
|
|
|
// Seed an item, then store the embedding using the SAME canonical text the
|
|
// engine will generate, so it is fully up to date.
|
|
currentID := prefix + "1-current"
|
|
seedRecoMediaItemTitled(t, pool, currentID, "movie", "matched", " alreadyCurrent")
|
|
items, err := catalog.NewItemRepository(pool).GetByIDs(ctx, []string{currentID})
|
|
if err != nil || len(items) == 0 {
|
|
t.Fatalf("load seeded item: %v", err)
|
|
}
|
|
wantText := embeddings.BuildEmbeddingText(items[0])
|
|
seedRecoEmbedding(t, pool, currentID, currentModel, wantText)
|
|
|
|
fake := &recordingEmbedder{}
|
|
e := newEmbedAllTestEngine(t, pool, fake, currentModel)
|
|
|
|
n, err := e.EmbedAll(ctx)
|
|
if err != nil {
|
|
t.Fatalf("EmbedAll: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("EmbedAll re-embedded %d up-to-date items, want 0", n)
|
|
}
|
|
if len(fake.calls) != 0 {
|
|
t.Fatalf("embedder was called %d times for an up-to-date item, want 0: %v", len(fake.calls), fake.texts)
|
|
}
|
|
}
|
|
|
|
// seedRecoMediaItemTitled inserts an embed-eligible media item via
|
|
// ItemRepository.Upsert. Using the repository (rather than a hand-written
|
|
// INSERT) guarantees every non-nullable column the scan path reads is
|
|
// populated, so the round-trip through GetByIDs in EmbedAll succeeds.
|
|
func seedRecoMediaItemTitled(t *testing.T, pool *pgxpool.Pool, contentID, mediaType, status, title string) {
|
|
t.Helper()
|
|
repo := catalog.NewItemRepository(pool)
|
|
if err := repo.Upsert(context.Background(), &models.MediaItem{
|
|
ContentID: contentID,
|
|
Type: mediaType,
|
|
Title: title,
|
|
Status: status,
|
|
}); err != nil {
|
|
t.Fatalf("seed media item %s: %v", contentID, err)
|
|
}
|
|
}
|
|
|
|
func assertEmbeddingModel(t *testing.T, pool *pgxpool.Pool, contentID, wantModel string) {
|
|
t.Helper()
|
|
var model string
|
|
err := pool.QueryRow(context.Background(),
|
|
`SELECT model FROM media_item_embeddings WHERE media_item_id = $1`, contentID).Scan(&model)
|
|
if err != nil {
|
|
t.Fatalf("read embedding model for %s: %v", contentID, err)
|
|
}
|
|
if model != wantModel {
|
|
t.Fatalf("embedding for %s has model %q, want %q", contentID, model, wantModel)
|
|
}
|
|
}
|