Skip to content

Commit e29eb79

Browse files
committed
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
1 parent d6da6d1 commit e29eb79

2 files changed

Lines changed: 232 additions & 26 deletions

File tree

backend/go/valkey-store/store.go

Lines changed: 118 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -155,28 +155,95 @@ func (s *ValkeyStore) Load(opts *pb.ModelOptions) error {
155155
}
156156

157157
// A durable Valkey may already hold this namespace's index from a previous
158-
// run (this is the persistence capability local-store lacks). Detect it so
159-
// Find works before this fresh process issues its first Set: otherwise
160-
// indexCreated stays false and StoresFind would short-circuit to an empty
161-
// result even though the persisted data is still searchable. We only learn
162-
// that the index EXISTS here; the dimension is re-learned lazily (keyLen
163-
// stays -1 until the next Set, and Find tolerates an unknown dimension).
164-
if s.indexExists(ctx) {
165-
s.indexCreated = true
166-
}
158+
// run (this is the persistence capability local-store lacks). Recover both
159+
// its existence AND its vector dimension so Find works before this fresh
160+
// process issues its first Set, and — critically — so a post-restart Set
161+
// validates the incoming dimension against the real persisted DIM instead
162+
// of silently re-learning a wrong one and dropping mismatched vectors from
163+
// the index (which would return success while making the entry unsearchable).
164+
s.loadIndexState(ctx)
167165

168166
// Log the sanitized index name (which identifies the namespace) rather than
169167
// the raw model-derived namespace, which could carry control characters.
170-
xlog.Info("valkey-store loaded", "addr", cfg.Addr, "index", s.indexName, "algo", cfg.IndexAlgo, "metric", cfg.DistanceMetric, "indexExists", s.indexCreated)
168+
xlog.Info("valkey-store loaded", "addr", cfg.Addr, "index", s.indexName, "algo", cfg.IndexAlgo, "metric", cfg.DistanceMetric, "indexExists", s.indexCreated, "keyLen", s.keyLen)
171169
return nil
172170
}
173171

174-
// indexExists reports whether the namespace's FT index is already present on
175-
// the server. FT.INFO returns an error for an unknown index, so a nil error
176-
// means the index exists. Existence is all we need — the dimension is recovered
177-
// lazily on the next Set (Find tolerates an unknown dimension after a restart).
178-
func (s *ValkeyStore) indexExists(ctx context.Context) bool {
179-
return s.client.Do(ctx, s.client.B().Arbitrary("FT.INFO").Args(s.indexName).Build()).Error() == nil
172+
// loadIndexState issues one FT.INFO at Load to recover the persisted index
173+
// state. FT.INFO returns an error for an unknown index, so a successful reply
174+
// means the index exists (indexCreated=true). We then recover the vector
175+
// dimension from the reply and seed keyLen with it: without this, keyLen would
176+
// stay -1 after a restart and the next Set would blindly re-learn whatever
177+
// dimension the caller happened to send, accepting a mismatched vector that
178+
// FT never indexes (silent search-side data loss). If the dimension can't be
179+
// parsed (e.g. an unexpected FT.INFO layout on some server version), keyLen
180+
// is left at -1 and validation degrades to the pre-restart lazy behaviour.
181+
func (s *ValkeyStore) loadIndexState(ctx context.Context) {
182+
msg, err := s.client.Do(ctx, s.client.B().FtInfo().Index(s.indexName).Build()).ToMessage()
183+
if err != nil {
184+
return
185+
}
186+
s.indexCreated = true
187+
if dim, ok := findDimensions(msg); ok && dim > 0 {
188+
s.keyLen = dim
189+
}
190+
}
191+
192+
// findDimensions walks an FT.INFO reply for the vector field's dimension. In
193+
// Valkey Search the VECTOR attribute nests its parameters under an `index`
194+
// array whose `dimensions` key holds the DIM the index was created with. The
195+
// reply is a nested array (RESP2) or map (RESP3), so we search recursively for
196+
// a `dimensions` key/token and read the value that follows it, tolerating both
197+
// integer and string-encoded values.
198+
func findDimensions(m valkey.ValkeyMessage) (int, bool) {
199+
if m.IsMap() {
200+
mp, err := m.AsMap()
201+
if err != nil {
202+
return 0, false
203+
}
204+
for k, v := range mp {
205+
if strings.EqualFold(k, "dimensions") {
206+
if n, ok := msgToInt(v); ok {
207+
return n, true
208+
}
209+
}
210+
if d, ok := findDimensions(v); ok {
211+
return d, true
212+
}
213+
}
214+
return 0, false
215+
}
216+
if m.IsArray() {
217+
arr, err := m.ToArray()
218+
if err != nil {
219+
return 0, false
220+
}
221+
for i := range arr {
222+
if s, err := arr[i].ToString(); err == nil && strings.EqualFold(s, "dimensions") && i+1 < len(arr) {
223+
if n, ok := msgToInt(arr[i+1]); ok {
224+
return n, true
225+
}
226+
}
227+
if d, ok := findDimensions(arr[i]); ok {
228+
return d, true
229+
}
230+
}
231+
}
232+
return 0, false
233+
}
234+
235+
// msgToInt reads an integer from a ValkeyMessage that may be an integer reply
236+
// or a string-encoded integer (FT.INFO mixes both across fields/versions).
237+
func msgToInt(m valkey.ValkeyMessage) (int, bool) {
238+
if n, err := m.ToInt64(); err == nil {
239+
return int(n), true
240+
}
241+
if s, err := m.ToString(); err == nil {
242+
if n, err := strconv.Atoi(s); err == nil {
243+
return n, true
244+
}
245+
}
246+
return 0, false
180247
}
181248

182249
// Free closes the Valkey client. Called by the gRPC server on shutdown.
@@ -206,14 +273,14 @@ func (s *ValkeyStore) StoresSet(opts *pb.StoresSetOptions) error {
206273
}
207274

208275
// Learn the dimension from the first key ever set (mirrors local-store's
209-
// keyLen == -1 sentinel), then reject anything that disagrees.
276+
// keyLen == -1 sentinel), then reject anything that disagrees. checkDims is
277+
// the single source of truth for the per-key length check (shared with
278+
// Get/Delete/Find) so the four RPCs cannot drift apart.
210279
if s.keyLen == -1 {
211280
s.keyLen = len(keys[0])
212281
}
213-
for i, k := range keys {
214-
if len(k) != s.keyLen {
215-
return fmt.Errorf("valkey-store: Set: key %d length %d does not match existing %d", i, len(k), s.keyLen)
216-
}
282+
if err := s.checkDims("Set", keys); err != nil {
283+
return err
217284
}
218285

219286
// The index needs the dimension up front, but local-store learns it from
@@ -341,10 +408,11 @@ func (s *ValkeyStore) StoresFind(opts *pb.StoresFindOptions) (pb.StoresFindResul
341408
if !s.indexCreated {
342409
return pb.StoresFindResult{}, nil
343410
}
344-
// Only enforce the query dimension once it is known. After a restart the
345-
// index exists (indexCreated=true) but keyLen is still -1 until the next
346-
// Set; in that case we let Valkey validate the query vector's dimension
347-
// against the persisted index rather than rejecting a legitimate query.
411+
// Enforce the query dimension against the known keyLen — recovered from
412+
// FT.INFO at Load after a restart, or learned from the first Set — so a
413+
// wrong-dimension query gets the clean local-store-style error. keyLen is
414+
// only -1 in the degraded case where FT.INFO gave no parseable dimension;
415+
// then we let Valkey's own FT.SEARCH validate the query vector.
348416
if s.keyLen != -1 && len(query) != s.keyLen {
349417
return pb.StoresFindResult{}, fmt.Errorf("valkey-store: Find: query length %d does not match existing %d", len(query), s.keyLen)
350418
}
@@ -370,6 +438,16 @@ func (s *ValkeyStore) StoresFind(opts *pb.StoresFindOptions) (pb.StoresFindResul
370438

371439
_, docs, err := s.client.Do(ctx, cmd).AsFtSearch()
372440
if err != nil {
441+
// The cached indexCreated flag can go stale: an operator runs
442+
// FT.DROPINDEX out of band, or two processes race on a fresh namespace.
443+
// If the index is gone, mirror local-store's empty-store behaviour
444+
// (empty result, no error) and clear the flag so a later Set recreates
445+
// it, rather than surfacing a hard error for what looks like an empty
446+
// store to the caller.
447+
if isNoSuchIndexErr(err) {
448+
s.indexCreated = false
449+
return pb.StoresFindResult{}, nil
450+
}
373451
return pb.StoresFindResult{}, fmt.Errorf("valkey-store: Find: FT.SEARCH: %w", err)
374452
}
375453

@@ -474,6 +552,21 @@ func isIndexExistsErr(err error) bool {
474552
return strings.Contains(strings.ToLower(err.Error()), "already exists")
475553
}
476554

555+
// isNoSuchIndexErr reports whether an FT.SEARCH error means the index no longer
556+
// exists (dropped out of band, or never really created despite a stale cached
557+
// flag). Valkey Search phrases this differently across versions, so we match
558+
// the common variants rather than one exact string.
559+
func isNoSuchIndexErr(err error) bool {
560+
msg := strings.ToLower(err.Error())
561+
if !strings.Contains(msg, "index") {
562+
return false
563+
}
564+
return strings.Contains(msg, "no such index") ||
565+
strings.Contains(msg, "not exist") ||
566+
strings.Contains(msg, "not found") ||
567+
strings.Contains(msg, "unknown index")
568+
}
569+
477570
// keyPrefix / indexName derive per-namespace identifiers from the model name so
478571
// entries and indexes never collide across namespaces on a shared server.
479572
func keyPrefix(namespace string) string {

backend/go/valkey-store/store_test.go

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,97 @@ var _ = Describe("distanceToSimilarity", func() {
306306
})
307307
})
308308

309+
var _ = Describe("findDimensions", func() {
310+
It("recovers an integer dimension from a nested FT.INFO reply", func() {
311+
dim, ok := findDimensions(ftInfoReply(mock.ValkeyInt64(768)))
312+
Expect(ok).To(BeTrue())
313+
Expect(dim).To(Equal(768))
314+
})
315+
316+
It("recovers a string-encoded dimension", func() {
317+
dim, ok := findDimensions(ftInfoReply(mock.ValkeyString("384")))
318+
Expect(ok).To(BeTrue())
319+
Expect(dim).To(Equal(384))
320+
})
321+
322+
It("reports not-found when no dimension token is present", func() {
323+
reply := mock.ValkeyArray(
324+
mock.ValkeyString("index_name"), mock.ValkeyString("idx:test"),
325+
mock.ValkeyString("num_docs"), mock.ValkeyInt64(0),
326+
)
327+
_, ok := findDimensions(reply)
328+
Expect(ok).To(BeFalse())
329+
})
330+
})
331+
332+
var _ = Describe("loadIndexState", func() {
333+
It("recovers indexCreated and keyLen from a persisted index", func() {
334+
s, c := newMockStore(testCfg())
335+
c.EXPECT().Do(gomock.Any(), gomock.Any()).DoAndReturn(
336+
func(_ context.Context, cmd valkey.Completed) valkey.ValkeyResult {
337+
Expect(cmd.Commands()[0]).To(Equal("FT.INFO"))
338+
return mock.Result(ftInfoReply(mock.ValkeyInt64(768)))
339+
}).Times(1)
340+
341+
ctx, cancel := s.ctx()
342+
defer cancel()
343+
s.loadIndexState(ctx)
344+
Expect(s.indexCreated).To(BeTrue())
345+
Expect(s.keyLen).To(Equal(768))
346+
})
347+
348+
It("leaves state untouched when the index does not exist", func() {
349+
s, c := newMockStore(testCfg())
350+
c.EXPECT().Do(gomock.Any(), gomock.Any()).Return(
351+
mock.ErrorResult(fmt.Errorf("Index with name 'idx:test' not found"))).Times(1)
352+
353+
ctx, cancel := s.ctx()
354+
defer cancel()
355+
s.loadIndexState(ctx)
356+
Expect(s.indexCreated).To(BeFalse())
357+
Expect(s.keyLen).To(Equal(-1))
358+
})
359+
360+
It("marks the index created but leaves keyLen open when the dim is unparseable", func() {
361+
s, c := newMockStore(testCfg())
362+
c.EXPECT().Do(gomock.Any(), gomock.Any()).Return(
363+
mock.Result(mock.ValkeyArray(mock.ValkeyString("index_name"), mock.ValkeyString("idx:test")))).Times(1)
364+
365+
ctx, cancel := s.ctx()
366+
defer cancel()
367+
s.loadIndexState(ctx)
368+
Expect(s.indexCreated).To(BeTrue())
369+
Expect(s.keyLen).To(Equal(-1))
370+
})
371+
})
372+
373+
var _ = Describe("StoresFind on a dropped index", func() {
374+
It("returns empty (no error) and clears the stale flag when the index is gone", func() {
375+
s, c := newMockStore(testCfg())
376+
s.keyLen = 3
377+
s.indexCreated = true
378+
c.EXPECT().Do(gomock.Any(), gomock.Any()).Return(
379+
mock.ErrorResult(fmt.Errorf("Index with name 'idx:test' not found"))).Times(1)
380+
381+
res, err := s.StoresFind(&pb.StoresFindOptions{Key: &pb.StoresKey{Floats: []float32{1, 0, 0}}, TopK: 5})
382+
Expect(err).NotTo(HaveOccurred())
383+
Expect(res.Keys).To(BeEmpty())
384+
Expect(s.indexCreated).To(BeFalse())
385+
})
386+
387+
It("still surfaces a genuine FT.SEARCH error", func() {
388+
s, c := newMockStore(testCfg())
389+
s.keyLen = 3
390+
s.indexCreated = true
391+
c.EXPECT().Do(gomock.Any(), gomock.Any()).Return(
392+
mock.ErrorResult(fmt.Errorf("timeout"))).Times(1)
393+
394+
_, err := s.StoresFind(&pb.StoresFindOptions{Key: &pb.StoresKey{Floats: []float32{1, 0, 0}}, TopK: 5})
395+
Expect(err).To(HaveOccurred())
396+
Expect(s.indexCreated).To(BeTrue())
397+
})
398+
})
399+
309400
// --- test helpers ---
310401

311402
type ftDoc struct {
@@ -333,6 +424,29 @@ func assertFTCreate(dim int, algo string) func(context.Context, valkey.Completed
333424
}
334425
}
335426

427+
// ftInfoReply builds a RESP2-shaped FT.INFO reply that mirrors Valkey Search's
428+
// nesting: the VECTOR attribute carries its params under an `index` array whose
429+
// `dimensions` key holds the DIM. dimValue is the message the parser must read
430+
// back (integer or string-encoded), so both wire shapes can be exercised.
431+
func ftInfoReply(dimValue valkey.ValkeyMessage) valkey.ValkeyMessage {
432+
vectorAttr := mock.ValkeyArray(
433+
mock.ValkeyString("identifier"), mock.ValkeyString(_vecField),
434+
mock.ValkeyString("attribute"), mock.ValkeyString(_vecField),
435+
mock.ValkeyString("type"), mock.ValkeyString("VECTOR"),
436+
mock.ValkeyString("index"), mock.ValkeyArray(
437+
mock.ValkeyString("capacity"), mock.ValkeyInt64(1000),
438+
mock.ValkeyString("dimensions"), dimValue,
439+
mock.ValkeyString("distance_metric"), mock.ValkeyString("COSINE"),
440+
mock.ValkeyString("data_type"), mock.ValkeyString("FLOAT32"),
441+
),
442+
)
443+
return mock.ValkeyArray(
444+
mock.ValkeyString("index_name"), mock.ValkeyString("idx:test"),
445+
mock.ValkeyString("attributes"), mock.ValkeyArray(vectorAttr),
446+
mock.ValkeyString("num_docs"), mock.ValkeyInt64(0),
447+
)
448+
}
449+
336450
// ftSearchReply builds a RESP2-shaped FT.SEARCH reply: [total, key, attrs, ...]
337451
// where attrs carries the returned vec/val/__score fields.
338452
func ftSearchReply(prefix string, docs []ftDoc) valkey.ValkeyMessage {
@@ -349,7 +463,6 @@ func ftSearchReply(prefix string, docs []ftDoc) valkey.ValkeyMessage {
349463
return mock.ValkeyArray(arr...)
350464
}
351465

352-
353466
var _ = Describe("namespace token", func() {
354467
It("is stable for the same namespace (so a persisted index is found again)", func() {
355468
Expect(nsToken("faces")).To(Equal(nsToken("faces")))

0 commit comments

Comments
 (0)