From 00411f16239f3a72e643b9c136adc278e26927da Mon Sep 17 00:00:00 2001 From: Szymon Malewski Date: Tue, 31 Mar 2026 09:46:43 +0200 Subject: [PATCH] vector-store: fix index size counter desync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usearch's remove() returns Ok(usize) where the value is the number of entries actually removed (0 if the key was not found). The previous wrapper converted this to Ok(()) unconditionally, causing size.fetch_sub(1) to be called even when nothing was removed from the index. This manifests when an item is first seen during the range scan with an invalid vector (e.g. wrong dimensionality), causing the usearch add() to return Err. The item is still recorded in primary_ids and vector_timestamps with ETValue::Some — intentionally, to advance the timestamp for CDC deduplication. When a later valid update arrives via CDC, the Entry::Occupied path emits RemoveBeforeAddVector (because vector_already_exists=true) followed by AddVector. The remove hits a key that was never in usearch, returns Ok(0), but the size counter was decremented anyway. The subsequent successful add only brought it back to the pre-update value, so the count never advanced. Fix: UsearchIndex::remove() now returns Ok(bool) and size is decremented only when an entry was actually removed. --- crates/vector-store/src/index/usearch.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/vector-store/src/index/usearch.rs b/crates/vector-store/src/index/usearch.rs index 5c6eed6b..6dcaabd0 100644 --- a/crates/vector-store/src/index/usearch.rs +++ b/crates/vector-store/src/index/usearch.rs @@ -144,7 +144,7 @@ trait UsearchIndex { fn size(&self) -> usize; fn capacity(&self) -> usize; fn add(&self, primary_id: PrimaryId, vector: &Vector) -> anyhow::Result<()>; - fn remove(&self, primary_id: PrimaryId) -> anyhow::Result<()>; + fn remove(&self, primary_id: PrimaryId) -> anyhow::Result; fn search( &self, vector: &Vector, @@ -201,8 +201,8 @@ impl UsearchIndex for ThreadedUsearchIndex { Ok(self.inner.add(primary_id.into(), vector.as_slice())?) } - fn remove(&self, primary_id: PrimaryId) -> anyhow::Result<()> { - Ok(self.inner.remove(primary_id.into()).map(|_| ())?) + fn remove(&self, primary_id: PrimaryId) -> anyhow::Result { + Ok(self.inner.remove(primary_id.into())? != 0) } fn search( @@ -392,14 +392,14 @@ impl UsearchIndex for RwLock { } #[hotpath::measure] - fn remove(&self, row_id: PrimaryId) -> anyhow::Result<()> { + fn remove(&self, row_id: PrimaryId) -> anyhow::Result { let start = Instant::now(); let sim = self.read().unwrap(); - sim.keys.write().unwrap().remove(&row_id); + let removed = sim.keys.write().unwrap().remove(&row_id); sim.wait_add_remove(start); - Ok(()) + Ok(removed) } #[hotpath::measure] @@ -1056,10 +1056,12 @@ fn add(idx: &impl UsearchIndex, primary_id: PrimaryId, embedding: &Vector, size: #[hotpath::measure] fn remove(idx: &impl UsearchIndex, row_id: PrimaryId, size: &AtomicUsize) { - if let Err(err) = idx.remove(row_id) { - warn!("remove: unable to remove embeddings: {err}"); - } else { - size.fetch_sub(1, Ordering::Relaxed); + match idx.remove(row_id) { + Err(err) => warn!("remove: unable to remove embeddings: {err}"), + Ok(false) => {} + Ok(true) => { + size.fetch_sub(1, Ordering::Relaxed); + } } }