Files
LocalAI/tests/integration/valkey_stores_test.go
localai-org-maint-bot 9bfd71387b feat(stores): add Valkey Search vector store backend (#11196)
* feat: add Valkey Search vector store backend

Add a new built-in Go gRPC store backend 'valkey-store' that implements the
four Stores RPCs (Set/Get/Delete/Find) against the Valkey Search module (FT.*)
using the pure-Go github.com/valkey-io/valkey-go client. It is selected via the
existing per-request 'backend' field on /stores, so there is no proto or HTTP
API change, and it mirrors the in-memory local-store while adding persistence
across restarts and opt-in HNSW.

Each vector is a Valkey HASH keyed by hex(little-endian float32); the index is
created lazily on first Set (FLAT+COSINE by default), cosine similarity is
derived as 1-distance, and namespaces get a collision-resistant token. Includes
unit tests (valkey-go mock) and env-gated integration tests against
valkey/valkey-bundle, plus build/matrix/gallery wiring and docs.

Assisted-by: Kiro:claude-opus-4.8 golangci-lint
Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* Address review feedback: recover persisted index dimension, harden Find

- Load now recovers the persisted vector DIM from FT.INFO (not just index
  existence), so a post-restart Set/Find validates against the real DIM
  instead of silently re-learning a wrong one and dropping mismatched
  vectors from the index. This also restores Find's dimension check after
  a restart.
- StoresFind treats a dropped/missing index as an empty store (empty
  result, no error) and clears the stale indexCreated flag, matching
  local-store's empty-store behaviour.
- StoresSet reuses checkDims for its per-key length check so the four RPCs
  share one dimension-guard implementation.
- Add unit tests for FT.INFO dimension recovery, loadIndexState, and the
  dropped-index Find path.

Assisted-by: Kiro:claude-opus-4.8
Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* Address review feedback: TLS ServerName/CA, Find nil-check, config fail-fast

Addresses external review comments on the valkey-store backend:

- StoresFind now rejects a nil/empty query Key before dereferencing it,
  so a malformed gRPC request can no longer panic the backend.
- TLS: derive ServerName (SNI) from the VALKEY_ADDR host so certificate
  verification works for IP-addressed endpoints, and add VALKEY_TLS_CA_CERT
  (custom CA bundle) and VALKEY_TLS_SKIP_VERIFY (testing-only) knobs.
- Config integer parsing now fails fast on a malformed value (e.g.
  VALKEY_HNSW_M=1x6) instead of silently defaulting, matching the
  fail-fast behaviour of the index-algo/distance-metric validation.
- Add VALKEY_DB (SELECT n) support for logical-DB isolation.
- Cap the human-readable part of a namespace token at 64 chars so a very
  long model name cannot produce an unbounded key prefix / index name
  (the appended short hash keeps distinct namespaces collision-free).
- Document the KNN-query injection-safety invariant (fields are constants)
  and why StoresGet uses a single aggregate DoMulti deadline for reads.
- Unit tests for the Find nil/empty-key guard, fail-fast HNSW parsing,
  and VALKEY_DB parsing/validation; docs + .env updated for the new vars.

Assisted-by: Kiro:claude-opus-4.8 golangci-lint
Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* Address review feedback: configure valkey-store via model config

richiejp asked that the valkey-store backend take its configuration from
a model config rather than process-wide VALKEY_* environment variables,
so multiple stores can each have their own Valkey config within one
LocalAI process. This removes every env access from the backend and
routes config through the model-config seam every other backend uses.

- config.go: loadConfig(opts *pb.ModelOptions) now parses the model
  config `options:` list (key:value strings, split on the first ':')
  instead of os.Getenv. Option keys mirror the old VALKEY_* names without
  the prefix (addr, index_algo, distance_metric, ...). Defaults, fail-fast
  validation and the mandatory client name are unchanged.
- store.go: Load threads opts into loadConfig; TLS comments/errors renamed
  off the VALKEY_* names.
- core/backend/stores.go: StoreBackend and NewVectorStore take a
  *config.ModelConfigLoader, resolve the per-store ModelConfig by store
  name, and pass its Options (and Backend when unset) to the backend via
  WithLoadGRPCLoadModelOpts. No config -> default backend + built-in
  defaults, preserving the zero-config experience.
- Endpoints/routes/application: thread the config loader to StoreBackend.
- Unit + integration tests: configure via options; the integration test
  passes addr through the model-config path (VALKEY_ADDR is now only the
  test harness locating the server).
- docs + .env: document the model-config options, drop the env var table.

Assisted-by: Kiro:claude-opus-4.8
Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* Remove valkey-store informational comment from .env The backend is configured via model config, not env vars — the comment was unnecessary noise in .env. The configuration is already documented in docs/content/features/stores.md.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* feat(valkey-store): gate Load on NamespacePrefix to refuse autoload probing Mirror local-store's pattern: reject model names without store.NamespacePrefix so the model loader's greedy autoload probe cannot bind an arbitrary model name to the vector store backend (the #9287 failure mode). Also adds unit tests for the gate covering: prefixed namespace, prefix alone, unprefixed model name, empty model, and nil opts.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* feat(valkey-store): add username_env/password_env credential indirection Add support for resolving Valkey credentials from environment variables named in the model config, mirroring cloud-proxy's api_key_env pattern. This keeps secrets out of model YAML files and lets distinct store configs each reference their own credentials. Options: username_env / password_env name the env var holding the value. The direct username / password options still work and take precedence when both are set (backward compatible). Includes 5 unit tests and updated stores.md documentation.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* fix: correct rebase artifacts in backend-matrix.yml and Makefile Fix two issues introduced by the conflict-resolution script during the rebase onto master: 1. .github/backend-matrix.yml: valkey-store entries were merged INTO the cloud-proxy entries (duplicate keys in same YAML map items) instead of being separate list items. This broke cloud-proxy Linux builds and the cloud-proxy darwin entry lost its build-type/lang. Fixed by making them standalone entries and restoring cloud-proxy exactly as on master. 2. Makefile: duplicated .NOTPARALLEL and docker-build-backends lines. Collapsed to single lines that are master's current content plus the valkey-store additions. Also adds the three optional pickups from #10801: - /valkey-store in .gitignore (the built binary) - valkey-store row in docs/content/reference/compatibility-table.md - valkey-store line in backend/README.md

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

---------

Signed-off-by: Daria Korenieva <daric2612@gmail.com>
Co-authored-by: Daria Korenieva <daric2612@gmail.com>
2026-07-29 20:12:29 +02:00

414 lines
14 KiB
Go

package integration_test
// Integration tests for the valkey-store gRPC backend. They mirror the
// local-store specs one-for-one and add the two capabilities Valkey provides
// that local-store cannot: persistence across a backend restart and an
// identifiable client name.
//
// These require a running Valkey Search server (valkey/valkey-bundle:9.1.0,
// which ships the FT.* module) reachable at $VALKEY_ADDR. When VALKEY_ADDR is
// unset the whole suite is skipped, so the unit CI never needs the container:
//
// podman run -d --name valkey-store-it -p 6379:6379 valkey/valkey-bundle:9.1.0
// make backends/valkey-store
// VALKEY_ADDR=localhost:6379 make test-valkey-store
//
// Index back-fill is asynchronous, so every Find is preceded by a bounded poll
// (eventuallyFindable) on the search result rather than a fixed sleep.
import (
"context"
"fmt"
"math"
"math/rand/v2"
"os"
"strings"
"sync/atomic"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
valkey "github.com/valkey-io/valkey-go"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/store"
"github.com/mudler/LocalAI/pkg/system"
)
// namespaceCounter gives each spec a fresh namespace so the persistent Valkey
// server does not leak state between tests sharing one instance.
var namespaceCounter atomic.Int64
func valkeyNormalize(vecs [][]float32) {
for i, k := range vecs {
norm := float64(0)
for _, x := range k {
norm += float64(x * x)
}
norm = math.Sqrt(norm)
for j, x := range k {
vecs[i][j] = x / float32(norm)
}
}
}
// eventuallyFindable polls Find until at least want results come back, absorbing
// the asynchronous index back-fill without a hard-coded sleep.
func eventuallyFindable(sc grpc.Backend, query []float32, want int) {
EventuallyWithOffset(1, func() int {
keys, _, _, err := store.Find(context.Background(), sc, query, want)
if err != nil {
return -1
}
return len(keys)
}, 15*time.Second, 200*time.Millisecond).Should(BeNumerically(">=", want))
}
var _ = Describe("Integration tests for the valkey-store backend", Label("stores"), Label("valkey"), func() {
Context("Valkey Search get, set, delete and find", func() {
var sl *model.ModelLoader
var sc grpc.Backend
var tmpdir string
var namespace string
var valkeyAddr string
var backendsPath string
loadStore := func(ns string) grpc.Backend {
storeOpts := []model.Option{
model.WithBackendString(model.ValkeyStoreBackend),
model.WithModel(ns),
// Configure the store the LocalAI-native way: the connection
// address is threaded to the backend's LoadModel via the model
// config options (ModelOptions.Options), exactly as core's
// StoreBackend() passes a store's `options:` list. The backend
// no longer reads any VALKEY_* env var. $VALKEY_ADDR here is
// only the integration harness locating the test server.
model.WithLoadGRPCLoadModelOpts(&pb.ModelOptions{
Options: []string{"addr:" + valkeyAddr},
}),
}
backend, err := sl.Load(storeOpts...)
Expect(err).ToNot(HaveOccurred())
Expect(backend).ToNot(BeNil())
return backend
}
// initLoader builds a fresh model loader that can discover the
// valkey-store gRPC binary. The backend is registered from BACKENDS_PATH
// exactly as the real application does at startup
// (core/application/startup.go), so sl.Load(WithBackendString(...)) can
// resolve the run.sh and spawn the process.
initLoader := func() {
systemState, err := system.GetSystemState(
system.WithModelPath(tmpdir),
system.WithBackendPath(backendsPath),
)
Expect(err).ToNot(HaveOccurred())
sl = model.NewModelLoader(systemState)
Expect(gallery.RegisterBackends(systemState, sl)).To(Succeed())
}
BeforeEach(func() {
valkeyAddr = os.Getenv("VALKEY_ADDR")
if valkeyAddr == "" {
Skip("VALKEY_ADDR is not set; skipping Valkey integration tests")
}
backendsPath = os.Getenv("BACKENDS_PATH")
if backendsPath == "" {
Skip("BACKENDS_PATH is not set; build the backend and point BACKENDS_PATH at it (see make test-valkey-store)")
}
var err error
tmpdir, err = os.MkdirTemp("", "")
Expect(err).ToNot(HaveOccurred())
namespace = fmt.Sprintf("it-%d-%d", GinkgoRandomSeed(), namespaceCounter.Add(1))
initLoader()
sc = loadStore(namespace)
})
AfterEach(func() {
if sl != nil {
err := sl.StopAllGRPC()
Expect(err).ToNot(HaveOccurred())
}
if tmpdir != "" {
_ = os.RemoveAll(tmpdir)
}
})
It("should be able to set a key", func() {
err := store.SetSingle(context.Background(), sc, []float32{0.1, 0.2, 0.3}, []byte("test"))
Expect(err).ToNot(HaveOccurred())
})
It("should be able to set keys", func() {
err := store.SetCols(context.Background(), sc, [][]float32{{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}}, [][]byte{[]byte("test1"), []byte("test2")})
Expect(err).ToNot(HaveOccurred())
err = store.SetCols(context.Background(), sc, [][]float32{{0.7, 0.8, 0.9}, {0.10, 0.11, 0.12}}, [][]byte{[]byte("test3"), []byte("test4")})
Expect(err).ToNot(HaveOccurred())
})
It("should be able to get a key", func() {
err := store.SetSingle(context.Background(), sc, []float32{0.1, 0.2, 0.3}, []byte("test"))
Expect(err).ToNot(HaveOccurred())
val, err := store.GetSingle(context.Background(), sc, []float32{0.1, 0.2, 0.3})
Expect(err).ToNot(HaveOccurred())
Expect(val).To(Equal([]byte("test")))
})
It("should be able to get keys", func() {
err := store.SetCols(context.Background(), sc, [][]float32{{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}, {0.7, 0.8, 0.9}}, [][]byte{[]byte("test1"), []byte("test2"), []byte("test3")})
Expect(err).ToNot(HaveOccurred())
keys, vals, err := store.GetCols(context.Background(), sc, [][]float32{{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}, {0.7, 0.8, 0.9}})
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(3))
Expect(vals).To(HaveLen(3))
for i, k := range keys {
v := vals[i]
switch {
case k[0] == 0.1 && k[1] == 0.2 && k[2] == 0.3:
Expect(v).To(Equal([]byte("test1")))
case k[0] == 0.4 && k[1] == 0.5 && k[2] == 0.6:
Expect(v).To(Equal([]byte("test2")))
default:
Expect(k).To(Equal([]float32{0.7, 0.8, 0.9}))
Expect(v).To(Equal([]byte("test3")))
}
}
keys, vals, err = store.GetCols(context.Background(), sc, [][]float32{{0.7, 0.8, 0.9}, {0.1, 0.2, 0.3}})
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(2))
Expect(vals).To(HaveLen(2))
})
It("should be able to delete a key", func() {
err := store.SetSingle(context.Background(), sc, []float32{0.1, 0.2, 0.3}, []byte("test"))
Expect(err).ToNot(HaveOccurred())
err = store.DeleteSingle(context.Background(), sc, []float32{0.1, 0.2, 0.3})
Expect(err).ToNot(HaveOccurred())
val, _ := store.GetSingle(context.Background(), sc, []float32{0.1, 0.2, 0.3})
Expect(val).To(BeNil())
})
It("should be able to delete keys", func() {
err := store.SetCols(context.Background(), sc, [][]float32{{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}, {0.7, 0.8, 0.9}}, [][]byte{[]byte("test1"), []byte("test2"), []byte("test3")})
Expect(err).ToNot(HaveOccurred())
err = store.DeleteCols(context.Background(), sc, [][]float32{{0.1, 0.2, 0.3}, {0.7, 0.8, 0.9}})
Expect(err).ToNot(HaveOccurred())
keys, vals, err := store.GetCols(context.Background(), sc, [][]float32{{0.4, 0.5, 0.6}})
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(1))
Expect(vals).To(HaveLen(1))
Expect(keys[0]).To(Equal([]float32{0.4, 0.5, 0.6}))
Expect(vals[0]).To(Equal([]byte("test2")))
keys, vals, err = store.GetCols(context.Background(), sc, [][]float32{{0.1, 0.2, 0.3}, {0.7, 0.8, 0.9}})
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(0))
Expect(vals).To(HaveLen(0))
})
It("should be able to find similar keys", func() {
err := store.SetCols(context.Background(), sc, [][]float32{{0.5, 0.5, 0.5}, {0.6, 0.6, -0.6}, {0.7, -0.7, -0.7}}, [][]byte{[]byte("test1"), []byte("test2"), []byte("test3")})
Expect(err).ToNot(HaveOccurred())
eventuallyFindable(sc, []float32{0.1, 0.3, 0.5}, 2)
keys, vals, sims, err := store.Find(context.Background(), sc, []float32{0.1, 0.3, 0.5}, 2)
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(2))
Expect(vals).To(HaveLen(2))
Expect(sims).To(HaveLen(2))
Expect(keys[0]).To(Equal([]float32{0.5, 0.5, 0.5}))
Expect(vals[0]).To(Equal([]byte("test1")))
Expect(keys[1]).To(Equal([]float32{0.6, 0.6, -0.6}))
})
It("should be able to find similar normalized keys", func() {
keys := [][]float32{{0.1, 0.3, 0.5}, {0.5, 0.5, 0.5}, {0.6, 0.6, -0.6}, {0.7, -0.7, -0.7}}
vals := [][]byte{[]byte("test0"), []byte("test1"), []byte("test2"), []byte("test3")}
valkeyNormalize(keys)
err := store.SetCols(context.Background(), sc, keys, vals)
Expect(err).ToNot(HaveOccurred())
eventuallyFindable(sc, keys[0], 3)
ks, _, sims, err := store.Find(context.Background(), sc, keys[0], 3)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(HaveLen(3))
Expect(sims).To(HaveLen(3))
Expect(ks[0]).To(Equal(keys[0]))
Expect(sims[0]).To(BeNumerically("~", 1, 0.0001))
})
It("produces the correct cosine similarities for orthogonal and opposite unit vectors", func() {
keys := [][]float32{{1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}, {0.0, 0.0, 1.0}, {-1.0, 0.0, 0.0}}
vals := [][]byte{[]byte("x"), []byte("y"), []byte("z"), []byte("-z")}
err := store.SetCols(context.Background(), sc, keys, vals)
Expect(err).ToNot(HaveOccurred())
eventuallyFindable(sc, keys[0], 4)
_, _, sims, err := store.Find(context.Background(), sc, keys[0], 4)
Expect(err).ToNot(HaveOccurred())
Expect(sims).To(HaveLen(4))
Expect(sims[0]).To(BeNumerically("~", 1, 0.0001))
Expect(sims[1]).To(BeNumerically("~", 0, 0.0001))
Expect(sims[2]).To(BeNumerically("~", 0, 0.0001))
Expect(sims[3]).To(BeNumerically("~", -1, 0.0001))
})
It("produces the correct cosine similarities for orthogonal and opposite vectors", func() {
keys := [][]float32{{1.0, 0.0, 1.0}, {0.0, 2.0, 0.0}, {0.0, 0.0, -1.0}, {-1.0, 0.0, -1.0}}
vals := [][]byte{[]byte("x"), []byte("y"), []byte("z"), []byte("-z")}
err := store.SetCols(context.Background(), sc, keys, vals)
Expect(err).ToNot(HaveOccurred())
eventuallyFindable(sc, keys[0], 4)
_, _, sims, err := store.Find(context.Background(), sc, keys[0], 4)
Expect(err).ToNot(HaveOccurred())
Expect(sims[0]).To(BeNumerically("~", 1, 0.1))
Expect(sims[1]).To(BeNumerically("~", 0, 0.1))
Expect(sims[2]).To(BeNumerically("~", -0.7, 0.1))
Expect(sims[3]).To(BeNumerically("~", -1, 0.1))
})
expectTriangleEq := func(keys [][]float32, vals [][]byte) {
eventuallyFindable(sc, keys[0], len(keys))
sims := map[string]map[string]float32{}
for i, k := range keys {
_, valsk, simsk, err := store.Find(context.Background(), sc, k, len(keys))
Expect(err).ToNot(HaveOccurred())
for j, v := range valsk {
p := string(vals[i])
q := string(v)
if sims[p] == nil {
sims[p] = map[string]float32{}
}
sims[p][q] = simsk[j]
}
}
for _, simsu := range sims {
for w, simw := range simsu {
uws := math.Acos(clampUnit(float64(simw)))
for v := range simsu {
uvws := math.Acos(clampUnit(float64(simsu[v]))) + math.Acos(clampUnit(float64(sims[v][w])))
Expect(uws).To(BeNumerically("<=", uvws+0.0001))
}
}
}
}
It("obeys the triangle inequality for normalized values", func() {
keys := [][]float32{
{1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}, {0.0, 0.0, 1.0},
{-1.0, 0.0, 0.0}, {0.0, -1.0, 0.0}, {0.0, 0.0, -1.0},
{2.0, 3.0, 4.0}, {9.0, 7.0, 1.0}, {0.0, -1.2, 2.3},
}
vals := [][]byte{
[]byte("x"), []byte("y"), []byte("z"),
[]byte("-x"), []byte("-y"), []byte("-z"),
[]byte("u"), []byte("v"), []byte("w"),
}
valkeyNormalize(keys[6:])
err := store.SetCols(context.Background(), sc, keys, vals)
Expect(err).ToNot(HaveOccurred())
expectTriangleEq(keys, vals)
})
It("obeys the triangle inequality for random 768-d vectors", func() {
rnd := rand.New(rand.NewPCG(151, 0))
keys := make([][]float32, 20)
vals := make([][]byte, 20)
for i := range keys {
k := make([]float32, 768)
for j := range k {
k[j] = rnd.Float32()
}
keys[i] = k
}
c := byte('a')
for i := range vals {
vals[i] = []byte{c}
c++
}
err := store.SetCols(context.Background(), sc, keys, vals)
Expect(err).ToNot(HaveOccurred())
expectTriangleEq(keys, vals)
})
It("persists data across a backend restart", func() {
// This is the capability local-store lacks: after the backend
// process is torn down and a fresh one is spawned (same Valkey
// server, same namespace), the data is still there.
err := store.SetCols(context.Background(), sc,
[][]float32{{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}},
[][]byte{[]byte("persisted1"), []byte("persisted2")})
Expect(err).ToNot(HaveOccurred())
Expect(sl.StopAllGRPC()).ToNot(HaveOccurred())
// Fresh loader/process, same namespace → same index/prefix.
initLoader()
sc = loadStore(namespace)
val, err := store.GetSingle(context.Background(), sc, []float32{0.1, 0.2, 0.3})
Expect(err).ToNot(HaveOccurred())
Expect(val).To(Equal([]byte("persisted1")))
eventuallyFindable(sc, []float32{0.1, 0.2, 0.3}, 2)
keys, _, _, err := store.Find(context.Background(), sc, []float32{0.1, 0.2, 0.3}, 2)
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(2))
})
It("identifies its connection with the mandatory client name", func() {
// Seed a key so the backend has certainly opened a connection.
err := store.SetSingle(context.Background(), sc, []float32{0.1, 0.2, 0.3}, []byte("test"))
Expect(err).ToNot(HaveOccurred())
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{valkeyAddr},
DisableCache: true,
})
Expect(err).ToNot(HaveOccurred())
defer client.Close()
list, err := client.Do(context.Background(), client.B().ClientList().Build()).ToString()
Expect(err).ToNot(HaveOccurred())
Expect(strings.Contains(list, "name=localai-valkey-store")).To(BeTrue(), "expected a connection named localai-valkey-store in CLIENT LIST")
})
})
})
// clampUnit keeps a similarity inside [-1, 1] before math.Acos, guarding against
// float rounding that would otherwise push |x| slightly over 1 and yield NaN.
func clampUnit(x float64) float64 {
if x > 1 {
return 1
}
if x < -1 {
return -1
}
return x
}