@@ -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.
479572func keyPrefix (namespace string ) string {
0 commit comments