diff --git a/pkg/vectorindex/hnsw/build.go b/pkg/vectorindex/hnsw/build.go index e2db1fc6e9ca2..6e1df4c99ce09 100644 --- a/pkg/vectorindex/hnsw/build.go +++ b/pkg/vectorindex/hnsw/build.go @@ -36,11 +36,37 @@ type HnswBuild[T types.RealNumbers] struct { indexes []*HnswModel[T] nthread int add_chan chan AddItem[T] - err_chan chan error wg sync.WaitGroup once sync.Once mutex sync.Mutex count atomic.Int64 + + // Worker-error propagation for the multi-threaded build. `stopped` is closed + // once the first worker fails (or the context is cancelled); producers select on + // it so an enqueue never blocks forever after the workers are gone, and finalizers + // surface the recorded error instead of finishing a build as if it succeeded. + stopOnce sync.Once + stopped chan struct{} + errMu sync.Mutex + workerErr error +} + +// recordWorkerErr stores the first worker error and wakes any blocked producer / +// finalizer. First-error-wins: the root failure is the most useful to report. +func (h *HnswBuild[T]) recordWorkerErr(err error) { + h.stopOnce.Do(func() { + h.errMu.Lock() + h.workerErr = err + h.errMu.Unlock() + close(h.stopped) + }) +} + +// WorkerErr returns the recorded worker error (nil if none). Safe to call any time. +func (h *HnswBuild[T]) WorkerErr() error { + h.errMu.Lock() + defer h.errMu.Unlock() + return h.workerErr } type AddItem[T types.RealNumbers] struct { @@ -52,30 +78,25 @@ type AddItem[T types.RealNumbers] struct { func NewHnswBuild[T types.RealNumbers](sqlproc *sqlexec.SqlProcess, uid string, nworker int32, cfg vectorindex.IndexConfig, tblcfg vectorindex.IndexTableConfig) (info *HnswBuild[T], err error) { - /* - // estimate the number of worker threads - nthread := 0 - if nworker <= 1 { - // single database thread and set nthread to ThreadsBuild - nthread = int(vectorindex.GetConcurrency(tblcfg.ThreadsBuild)) - } else { - // multiple database worker threads - threadsbuild := vectorindex.GetConcurrencyForBuild(tblcfg.ThreadsBuild) - nthread = int(float64(threadsbuild) / float64(nworker)) - } - if nthread < 1 { - nthread = 1 - } - */ - - // MatrixOne #24849 / USearch #735 (open): concurrent add() can orphan nodes — - // the vector is stored (contains() returns true) but the HNSW graph never links - // it, so search() can never reach it, producing flaky recall@1 (an exact match - // is intermittently missed). This is a real build race, not just HNSW - // approximation. Reproduced in pkg/vectorindex/hnsw/zz_orphan_test.go: - // multi-threaded build orphans ~1/30, single-threaded 0/30. Until the upstream - // race is fixed, force a single build thread for correctness. - nthread := 1 + // estimate the number of worker threads + // + // MatrixOne #24849 / USearch #735: concurrent add() used to orphan nodes (a + // vector stored but never linked into the HNSW graph, so search() could not + // reach it — flaky recall@1). That race is fixed in our usearch build (the + // two-pass add: all forward links before any reverse link), so concurrent + // builds now match single-threaded reachability. Multi-threaded build restored. + nthread := 0 + if nworker <= 1 { + // single database thread and set nthread to ThreadsBuild + nthread = int(vectorindex.GetConcurrency(tblcfg.ThreadsBuild)) + } else { + // multiple database worker threads + threadsbuild := vectorindex.GetConcurrencyForBuild(tblcfg.ThreadsBuild) + nthread = int(float64(threadsbuild) / float64(nworker)) + } + if nthread < 1 { + nthread = 1 + } info = &HnswBuild[T]{ uid: uid, @@ -87,7 +108,7 @@ func NewHnswBuild[T types.RealNumbers](sqlproc *sqlexec.SqlProcess, uid string, if nthread > 1 { info.add_chan = make(chan AddItem[T], nthread*4) - info.err_chan = make(chan error, nthread) + info.stopped = make(chan struct{}) // create multi-threads worker for add for i := 0; i < info.nthread; i++ { @@ -95,12 +116,13 @@ func NewHnswBuild[T types.RealNumbers](sqlproc *sqlexec.SqlProcess, uid string, info.wg.Add(1) go func() { defer info.wg.Done() - var err0 error - closed := false - for !closed { - closed, err0 = info.addFromChannel(sqlproc) + for { + closed, err0 := info.addFromChannel(sqlproc) if err0 != nil { - info.err_chan <- err0 + info.recordWorkerErr(err0) + return + } + if closed { return } } @@ -134,13 +156,17 @@ func (h *HnswBuild[T]) addFromChannel(sqlproc *sqlexec.SqlProcess) (stream_close return false, nil } -func (h *HnswBuild[T]) CloseAndWait() { +// CloseAndWait closes the work queue, waits for all workers to drain it, and +// returns the first worker error (nil on success). It is idempotent; later calls +// return the same recorded error. +func (h *HnswBuild[T]) CloseAndWait() error { if h.nthread > 1 { h.once.Do(func() { close(h.add_chan) h.wg.Wait() }) } + return h.WorkerErr() } // destroy @@ -148,7 +174,9 @@ func (h *HnswBuild[T]) Destroy() error { var errs error - h.CloseAndWait() + if err := h.CloseAndWait(); err != nil { + errs = errors.Join(errs, err) + } for _, idx := range h.indexes { err := idx.Destroy() @@ -162,18 +190,20 @@ func (h *HnswBuild[T]) Destroy() error { func (h *HnswBuild[T]) Add(key int64, vec []T) error { if h.nthread > 1 { - + // copy the []T slice. + item := AddItem[T]{key, append(make([]T, 0, len(vec)), vec...)} select { - case err := <-h.err_chan: - return err - default: + case h.add_chan <- item: + return nil + case <-h.stopped: + // A worker failed or the context was cancelled. Stop feeding the queue + // (the send would otherwise block forever once workers are gone) and + // surface the recorded error. recordWorkerErr stores the error before + // closing `stopped`, so WorkerErr() is non-nil here. + return h.WorkerErr() } - // copy the []float32 slice. - h.add_chan <- AddItem[T]{key, append(make([]T, 0, len(vec)), vec...)} - return nil - } else { - return h.addVector(key, vec) } + return h.addVector(key, vec) } func (h *HnswBuild[T]) createIndexUniqueKey(id int64) string { @@ -217,6 +247,11 @@ func (h *HnswBuild[T]) getIndexForAdd() (idx *HnswModel[T], save_idx *HnswModel[ } h.count.Add(1) + // Reserve an in-flight slot on the index this add will go to, under the same lock + // that decides rollover. A later rollover that hands this index back as save_idx + // will wait for these to drain before SaveToFile() saves+destroys it. + idx.inflight.Add(1) + return idx, save_idx, nil } @@ -224,19 +259,20 @@ func (h *HnswBuild[T]) getIndexForAdd() (idx *HnswModel[T], save_idx *HnswModel[ // it will check the current index is full and add the vector to available index // sync version for multi-thread func (h *HnswBuild[T]) addVectorSync(key int64, vec []T) error { - var err error - var idx *HnswModel[T] - var save_idx *HnswModel[T] - - idx, save_idx, err = h.getIndexForAddSync() + idx, save_idx, err := h.getIndexForAddSync() if err != nil { return err } + defer idx.inflight.Done() if save_idx != nil { - // save the current index to file - err = save_idx.SaveToFile() - if err != nil { + // Wait for every add already assigned to the rolled-over index to finish before + // saving+destroying it. Otherwise SaveToFile() could persist a partial index or + // free the usearch index while a peer worker is still calling idx.Add() on it. + // This index receives no new adds (rollover already swapped in the next index + // under the lock), so the wait converges. + save_idx.inflight.Wait() + if err = save_idx.SaveToFile(); err != nil { return err } } @@ -248,21 +284,19 @@ func (h *HnswBuild[T]) addVectorSync(key int64, vec []T) error { // it will check the current index is full and add the vector to available index // single-threaded version. func (h *HnswBuild[T]) addVector(key int64, vec []T) error { - var err error - var idx *HnswModel[T] - var save_idx *HnswModel[T] - h.mutex.Lock() defer h.mutex.Unlock() - idx, save_idx, err = h.getIndexForAdd() + idx, save_idx, err := h.getIndexForAdd() if err != nil { return err } + defer idx.inflight.Done() if save_idx != nil { - // save the current index to file - err = save_idx.SaveToFile() - if err != nil { + // Single-threaded: the rolled-over index has no in-flight adds (each add + // completes before the next), so this is a no-op barrier kept for symmetry. + save_idx.inflight.Wait() + if err = save_idx.SaveToFile(); err != nil { return err } } @@ -275,7 +309,12 @@ func (h *HnswBuild[T]) addVector(key int64, vec []T) error { // 2. sync the index file to index table func (h *HnswBuild[T]) ToInsertSql(ts int64) ([]string, error) { - h.CloseAndWait() + // Surface any worker error from the multi-threaded build. Without this a worker + // that failed on the last queued vector (after Add already returned nil) would be + // silently dropped and the build finalized as if it succeeded. + if err := h.CloseAndWait(); err != nil { + return nil, err + } if len(h.indexes) == 0 { return []string{}, nil diff --git a/pkg/vectorindex/hnsw/build_test.go b/pkg/vectorindex/hnsw/build_test.go index c218ee4bf9b0b..78502b3459268 100644 --- a/pkg/vectorindex/hnsw/build_test.go +++ b/pkg/vectorindex/hnsw/build_test.go @@ -350,3 +350,140 @@ func runBuildSingleThread[T types.RealNumbers](t *testing.T) { require.True(t, (recall > 0.96)) } + +// TestBuildMultiWorker exercises NewHnswBuild with nworker > 1, where the +// per-build thread count is derived from GetConcurrencyForBuild / nworker +// (the multi-database-worker branch) rather than the single-worker path. +func TestBuildMultiWorker(t *testing.T) { + m := mpool.MustNewZero() + proc := testutil.NewProcessWithMPool(t, "", m) + sqlproc := sqlexec.NewSqlProcess(proc) + + ndim := 8 + nitem := 100 + + idxcfg := vectorindex.IndexConfig{Type: "hnsw", Usearch: usearch.DefaultConfig(uint(ndim))} + idxcfg.Usearch.Metric = usearch.L2sq + idxcfg.IndexCapacity = MaxIndexCapacity + tblcfg := vectorindex.IndexTableConfig{DbName: "db", SrcTable: "src", + MetadataTable: "__secondary_meta", IndexTable: "__secondary_index", + ThreadsSearch: 4, + ThreadsBuild: 4} + + uid := fmt.Sprintf("%s:%d:%d", "localhost", 1, 0) + // nworker = 2 selects the GetConcurrencyForBuild / nworker branch + build, err := NewHnswBuild[float32](sqlproc, uid, 2, idxcfg, tblcfg) + require.Nil(t, err) + defer build.Destroy() + + r := rand.New(rand.NewSource(99)) + for i := 0; i < nitem; i++ { + vec := make([]float32, ndim) + for j := 0; j < ndim; j++ { + vec[j] = r.Float32() + } + err := build.Add(int64(i), vec) + require.Nil(t, err) + } + + sqls, err := build.ToInsertSql(time.Now().UnixMicro()) + require.Nil(t, err) + require.True(t, len(sqls) > 0) +} + +// TestBuildMultiWorkerLastItemError is a regression for the worker-error-loss bug +// that multi-threaded build re-enables. Add() only polls for an earlier worker error +// before enqueueing, so when a worker fails on the LAST queued vector that Add() has +// already returned nil. Finalization (CloseAndWait/ToInsertSql) must drain and return +// that error instead of finalizing the build as if it had succeeded. +func TestBuildMultiWorkerLastItemError(t *testing.T) { + m := mpool.MustNewZero() + proc := testutil.NewProcessWithMPool(t, "", m) + sqlproc := sqlexec.NewSqlProcess(proc) + + ndim := 8 + idxcfg := vectorindex.IndexConfig{Type: "hnsw", Usearch: usearch.DefaultConfig(uint(ndim))} + idxcfg.Usearch.Metric = usearch.L2sq + idxcfg.IndexCapacity = MaxIndexCapacity + tblcfg := vectorindex.IndexTableConfig{DbName: "db", SrcTable: "src", + MetadataTable: "__secondary_meta", IndexTable: "__secondary_index", + ThreadsSearch: 4, ThreadsBuild: 4} + + uid := fmt.Sprintf("%s:%d:%d", "localhost", 1, 0) + // nworker = 1 with ThreadsBuild = 4 selects the multi-threaded build path. + build, err := NewHnswBuild[float32](sqlproc, uid, 1, idxcfg, tblcfg) + require.Nil(t, err) + require.Greater(t, build.nthread, 1, "test needs the multi-threaded build path") + defer build.Destroy() + + r := rand.New(rand.NewSource(7)) + for i := 0; i < 64; i++ { + vec := make([]float32, ndim) + for j := range vec { + vec[j] = r.Float32() + } + require.Nil(t, build.Add(int64(i), vec)) + } + + // The last vector has the wrong dimension; the worker fails on it. Add() may well + // return nil here (the item is enqueued before any worker touches it) — that is + // exactly the scenario where the error would otherwise be lost. + bad := make([]float32, ndim+1) + _ = build.Add(int64(64), bad) + + _, err = build.ToInsertSql(time.Now().UnixMicro()) + require.NotNil(t, err, "worker error on the last queued vector must surface at finalization") + require.Contains(t, err.Error(), "dimension not match") +} + +// TestBuildMultiWorkerRollover is a regression for the capacity-rollover race that +// multi-threaded build re-enables. getIndexForAddSync() reserves a slot under the lock +// but idx.Add() runs after the lock is released; when a peer worker crosses +// IndexCapacity it receives the previous index as save_idx and SaveToFile() saves then +// destroys it. Without an in-flight barrier that save+destroy can race a peer worker's +// idx.Add() on the same index (use-after-destroy / partial save). With the barrier all +// keys survive and finalization succeeds. Run with -race to exercise the race directly. +func TestBuildMultiWorkerRollover(t *testing.T) { + m := mpool.MustNewZero() + proc := testutil.NewProcessWithMPool(t, "", m) + sqlproc := sqlexec.NewSqlProcess(proc) + + ndim := 8 + nitem := 1000 + capacity := int64(20) // small -> force many concurrent rollovers + + idxcfg := vectorindex.IndexConfig{Type: "hnsw", Usearch: usearch.DefaultConfig(uint(ndim))} + idxcfg.Usearch.Metric = usearch.L2sq + idxcfg.IndexCapacity = capacity + tblcfg := vectorindex.IndexTableConfig{DbName: "db", SrcTable: "src", + MetadataTable: "__secondary_meta", IndexTable: "__secondary_index", + ThreadsSearch: 8, ThreadsBuild: 8} + + uid := fmt.Sprintf("%s:%d:%d", "localhost", 1, 0) + build, err := NewHnswBuild[float32](sqlproc, uid, 1, idxcfg, tblcfg) + require.Nil(t, err) + require.Greater(t, build.nthread, 1, "test needs the multi-threaded build path") + defer build.Destroy() + + r := rand.New(rand.NewSource(11)) + for i := 0; i < nitem; i++ { + vec := make([]float32, ndim) + for j := range vec { + vec[j] = r.Float32() + } + require.Nil(t, build.Add(int64(i), vec)) + } + + sqls, err := build.ToInsertSql(time.Now().UnixMicro()) + require.Nil(t, err) + require.True(t, len(sqls) > 0) + + // All adds survived: the per-index add counters sum to nitem, and rollover created + // exactly ceil(nitem/capacity) indexes (none was destroyed mid-flight). + var total int64 + for _, idx := range build.indexes { + total += idx.Len.Load() + } + require.Equal(t, int64(nitem), total) + require.Equal(t, (nitem+int(capacity)-1)/int(capacity), len(build.indexes)) +} diff --git a/pkg/vectorindex/hnsw/model.go b/pkg/vectorindex/hnsw/model.go index 6e07e80685acf..44a9ece1adabf 100644 --- a/pkg/vectorindex/hnsw/model.go +++ b/pkg/vectorindex/hnsw/model.go @@ -50,6 +50,13 @@ type HnswModel[T types.RealNumbers] struct { MaxCapacity uint NThread uint + // inflight counts adds that have been ASSIGNED to this index (a slot reserved + // under HnswBuild.mutex) but not yet completed. A concurrent capacity rollover + // must wait for this to drain before SaveToFile() saves+destroys the index, so an + // in-flight worker never adds to a destroyed usearch index or persists a partial + // one. Build-only; unused for Search/Sync. + inflight sync.WaitGroup + // from metadata. info required for search Timestamp int64 Checksum string diff --git a/pkg/vectorindex/hnsw/sync.go b/pkg/vectorindex/hnsw/sync.go index ef046717a9c97..ce87a34f9dbf6 100644 --- a/pkg/vectorindex/hnsw/sync.go +++ b/pkg/vectorindex/hnsw/sync.go @@ -93,10 +93,7 @@ func NewHnswSync[T types.RealNumbers](sqlproc *sqlexec.SqlProcess, if err != nil { return nil, err } - // Force single-thread build until USearch #735 is fixed (concurrent add() - // orphans HNSW graph nodes -> flaky recall@1). See - // vectorindex.GetConcurrencyForSingleThreadBuild for the one-line revert. - idxtblcfg.ThreadsBuild = vectorindex.GetConcurrencyForSingleThreadBuild(val.(int64)) + idxtblcfg.ThreadsBuild = vectorindex.GetConcurrencyForBuild(val.(int64)) idxcap, err := sqlproc.GetResolveVariableFunc()("hnsw_max_index_capacity", true, false) if err != nil { @@ -105,10 +102,7 @@ func NewHnswSync[T types.RealNumbers](sqlproc *sqlexec.SqlProcess, indexCapacity = idxcap.(int64) } else { - // Force single-thread build until USearch #735 is fixed (concurrent add() - // orphans HNSW graph nodes -> flaky recall@1). See - // vectorindex.GetConcurrencyForSingleThreadBuild for the one-line revert. - idxtblcfg.ThreadsBuild = vectorindex.GetConcurrencyForSingleThreadBuild(0) + idxtblcfg.ThreadsBuild = vectorindex.GetConcurrencyForBuild(0) indexCapacity = 1000000 } diff --git a/pkg/vectorindex/hnsw/zz_orphan_test.go b/pkg/vectorindex/hnsw/zz_orphan_test.go index 035f0fc7a999f..2efc09a449a36 100644 --- a/pkg/vectorindex/hnsw/zz_orphan_test.go +++ b/pkg/vectorindex/hnsw/zz_orphan_test.go @@ -19,7 +19,6 @@ import ( "compress/gzip" "fmt" "os" - "runtime" "strconv" "strings" "sync" @@ -64,9 +63,10 @@ func zzBuild(keys []usearch.Key, vecs [][]float32, dim int, threads uint) *usear c := usearch.DefaultConfig(uint(dim)) c.Quantization = usearch.F32 c.Metric = usearch.L2sq + // Match the BVT t2 case (vector_hnsw_async.sql): M 64 EF_CONSTRUCTION 200 EF_SEARCH 200. c.Connectivity = 64 - c.ExpansionAdd = 500 - c.ExpansionSearch = 1000 + c.ExpansionAdd = 200 + c.ExpansionSearch = 200 idx, _ := usearch.NewIndex(c) idx.Reserve(uint(len(keys))) idx.ChangeThreadsAdd(threads) @@ -99,20 +99,31 @@ func zzBuild(keys []usearch.Key, vecs [][]float32, dim int, threads uint) *usear return idx } -// TestZZBuildOrphan is a reference reproducer for USearch #735 (concurrent add() -// orphans nodes): a multi-threaded build occasionally leaves id 0 unreachable in -// search despite contains()==true; single-threaded never does. Kept to verify the -// single-thread build workaround (build.go) and any upstream fix. Slow; needs SIFT. +// TestZZBuildOrphan is a regression guard for USearch #735 (concurrent add() +// orphans nodes): a multi-threaded build used to occasionally leave id 0 +// unreachable in search despite contains()==true. Our patched libusearch +// (two-pass add: all forward links before any reverse link) fixes the race, so +// an 8-thread build must now report 0 orphans — this asserts that and fails if a +// future libusearch regresses it. Builds 30x with the same params as the BVT t2 +// case (M 64, EF_CONSTRUCTION 200, EF_SEARCH 200). Auto-skips when the SIFT data +// file is absent (see zzLoadSift). func TestZZBuildOrphan(t *testing.T) { - t.Skip("USearch #735 reference repro; skipped by default — comment out this line to run manually") keys, vecs, dim := zzLoadSift(t) t.Logf("loaded %d vectors dim=%d id0=%d", len(keys), dim, keys[0]) q := vecs[0] const iters = 30 - for _, threads := range []uint{uint(runtime.NumCPU()), 1} { + for _, threads := range []uint{8} { notTop1, missing, notContained := 0, 0, 0 for it := 0; it < iters; it++ { - idx := zzBuild(keys, vecs, dim, threads) + // Rotate the insertion order each iteration so a different key lands + // first and the thread chunks shift — exercises different concurrent + // add interleavings, like `load data ... parallel 'true'` loading rows + // in a non-deterministic order. Deterministic (no RNG); keys stay + // aligned with vecs. + off := (it * (len(keys) / iters)) % len(keys) + ik := append(append([]usearch.Key(nil), keys[off:]...), keys[:off]...) + iv := append(append([][]float32(nil), vecs[off:]...), vecs[:off]...) + idx := zzBuild(ik, iv, dim, threads) contained, _ := idx.Contains(0) rk, _, _ := idx.Search(q, 10) rank := -1 @@ -141,5 +152,13 @@ func TestZZBuildOrphan(t *testing.T) { idx.Destroy() } fmt.Printf("\n*** threads=%d : id0_not_top1=%d/%d id0_missing_top10=%d/%d id0_not_in_index=%d/%d ***\n", threads, notTop1, iters, missing, iters, notContained, iters) + // #735 regression guard: with the patched libusearch the build must never + // orphan id 0, at any thread count. notContained==0 always held (the vector + // is stored); the race only broke reachability, so missing/notTop1 are the + // real signal. + if missing > 0 || notTop1 > 0 || notContained > 0 { + t.Errorf("USearch #735 regression: threads=%d orphaned id0 — not_top1=%d/%d missing_top10=%d/%d not_in_index=%d/%d", + threads, notTop1, iters, missing, iters, notContained, iters) + } } } diff --git a/pkg/vectorindex/types.go b/pkg/vectorindex/types.go index c655cb7d7ab6a..33f875a8463ec 100644 --- a/pkg/vectorindex/types.go +++ b/pkg/vectorindex/types.go @@ -373,15 +373,3 @@ func SimulateDevices(devices []int, n int64) []int { // all zeros -> every logical rank maps to physical device 0 return sim } - -// GetConcurrencyForSingleThreadBuild returns the build concurrency for the HNSW -// write paths (CDC/sync). While MatrixOne #24849 / USearch #735 (open) is -// unresolved, concurrent USearch add() can orphan graph nodes — the vector is -// stored (contains() is true) but never linked into the HNSW graph, so search() -// can never reach it, producing flaky recall@1. So every HNSW build/sync path -// must add from a single thread (the model is likewise pinned to -// ChangeThreadsAdd(1) in NewHnswModelForBuild). When usearch fixes the race, -// this is a one-line revert: `return GetConcurrencyForBuild(nthread)`. -func GetConcurrencyForSingleThreadBuild(nthread int64) int64 { - return 1 -} diff --git a/test/distributed/cases/pessimistic_transaction/vector/vector_hnsw_async.result b/test/distributed/cases/pessimistic_transaction/vector/vector_hnsw_async.result index d8e1e090215d6..27d0f40c4b87b 100644 --- a/test/distributed/cases/pessimistic_transaction/vector/vector_hnsw_async.result +++ b/test/distributed/cases/pessimistic_transaction/vector/vector_hnsw_async.result @@ -27,8 +27,8 @@ load data infile {'filepath'='$resources/vector/sift128_base_10k.csv.gz', 'compr select count(*) from t2; count(*) 10000 -select sleep(20); -sleep(20) +select sleep(30); +sleep(30) 0 select * from t2 order by L2_DISTANCE(b, "[14, 2, 0, 0, 0, 2, 42, 55, 9, 1, 0, 0, 18, 100, 77, 32, 89, 1, 0, 0, 19, 85, 15, 68, 52, 4, 0, 0, 0, 0, 2, 28, 34, 13, 5, 12, 49, 40, 39, 37, 24, 2, 0, 0, 34, 83, 88, 28, 119, 20, 0, 0, 41, 39, 13, 62, 119, 16, 2, 0, 0, 0, 10, 42, 9, 46, 82, 79, 64, 19, 2, 5, 10, 35, 26, 53, 84, 32, 34, 9, 119, 119, 21, 3, 3, 11, 17, 14, 119, 25, 8, 5, 0, 0, 11, 22, 23, 17, 42, 49, 17, 12, 5, 5, 12, 78, 119, 90, 27, 0, 4, 2, 48, 92, 112, 85, 15, 0, 2, 7, 50, 36, 15, 11, 1, 0, 0, 7]") ASC LIMIT 1; a b diff --git a/test/distributed/cases/pessimistic_transaction/vector/vector_hnsw_async.sql b/test/distributed/cases/pessimistic_transaction/vector/vector_hnsw_async.sql index c77745a21d437..df56ec20cd1ea 100644 --- a/test/distributed/cases/pessimistic_transaction/vector/vector_hnsw_async.sql +++ b/test/distributed/cases/pessimistic_transaction/vector/vector_hnsw_async.sql @@ -47,7 +47,7 @@ load data infile {'filepath'='$resources/vector/sift128_base_10k.csv.gz', 'compr select count(*) from t2; -select sleep(20); +select sleep(30); select * from t2 order by L2_DISTANCE(b, "[14, 2, 0, 0, 0, 2, 42, 55, 9, 1, 0, 0, 18, 100, 77, 32, 89, 1, 0, 0, 19, 85, 15, 68, 52, 4, 0, 0, 0, 0, 2, 28, 34, 13, 5, 12, 49, 40, 39, 37, 24, 2, 0, 0, 34, 83, 88, 28, 119, 20, 0, 0, 41, 39, 13, 62, 119, 16, 2, 0, 0, 0, 10, 42, 9, 46, 82, 79, 64, 19, 2, 5, 10, 35, 26, 53, 84, 32, 34, 9, 119, 119, 21, 3, 3, 11, 17, 14, 119, 25, 8, 5, 0, 0, 11, 22, 23, 17, 42, 49, 17, 12, 5, 5, 12, 78, 119, 90, 27, 0, 4, 2, 48, 92, 112, 85, 15, 0, 2, 7, 50, 36, 15, 11, 1, 0, 0, 7]") ASC LIMIT 1; diff --git a/thirdparties/usearch-2.25.3.tar.gz b/thirdparties/usearch-2.25.3.tar.gz index 74dbcf19711bc..d8ed20ca1fa61 100644 Binary files a/thirdparties/usearch-2.25.3.tar.gz and b/thirdparties/usearch-2.25.3.tar.gz differ