Skip to main content

velesdb_core/collection/streaming/
delta.rs

1//! Delta buffer for accumulating vectors during HNSW rebuilds.
2//!
3//! The [`DeltaBuffer`] holds recently inserted vectors that have not yet been
4//! indexed into the HNSW graph (e.g., because a rebuild is in progress).
5//! The search pipeline brute-force scans this buffer and merges results with
6//! HNSW results for immediate searchability via
7//! [`super::delta_merge::merge_with_delta`].
8//!
9//! # State machine
10//!
11//! The buffer transitions through three states encoded in the internal `state` field:
12//!
13//! ```text
14//! INACTIVE (0) --activate()--> ACTIVE (1) --deactivate_and_drain()--> DRAINING (2) --> INACTIVE (0)
15//! ```
16//!
17//! - `push` / `extend`: only write when `ACTIVE`.
18//! - `search`: scan when `ACTIVE` or `DRAINING` (so concurrent searches during
19//!   drain still see the buffered vectors).
20//!
21//! [`DeltaBuffer::activate`] is an unconditional store kept for the idempotent
22//! callers in the rebuild path. [`DeltaBuffer::try_activate`] is the hardened
23//! variant: it uses `compare_exchange(INACTIVE, ACTIVE)` and returns
24//! [`ActivateError::AlreadyActive`] on re-entrance, so a double-activation bug
25//! (two rebuilds racing on the same buffer) surfaces instead of being silently
26//! swallowed (STREAM-9).
27//!
28//! # Lock ordering
29//!
30//! `DeltaBuffer` is at position **10** in the collection lock order
31//! (after `sparse_indexes` at 9). Code must never hold a delta buffer lock
32//! while acquiring a lower-numbered lock.
33
34use crate::distance::DistanceMetric;
35use parking_lot::RwLock;
36use std::collections::HashSet;
37use std::sync::atomic::{AtomicU8, Ordering};
38
39/// Buffer is inactive — not accumulating writes.
40const INACTIVE: u8 = 0;
41/// Buffer is actively accumulating writes (HNSW rebuild in progress).
42const ACTIVE: u8 = 1;
43/// Buffer is draining — no new writes accepted, but still readable for search.
44const DRAINING: u8 = 2;
45
46/// Error returned by [`DeltaBuffer::try_activate`] when activation is rejected.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
48#[non_exhaustive]
49pub enum ActivateError {
50    /// The buffer was not `INACTIVE` (it is `ACTIVE` or `DRAINING`), so a
51    /// concurrent activation/drain already owns it. Signals a double-activation
52    /// bug rather than a recoverable condition.
53    #[error("delta buffer is already active or draining; double-activation rejected")]
54    AlreadyActive,
55}
56
57/// Delta buffer for streaming inserts during HNSW rebuilds.
58///
59/// Accumulates `(point_id, vector)` pairs that are in storage but not yet in
60/// the HNSW index. When active, search methods brute-force scan the buffer
61/// and merge results with HNSW results via
62/// [`super::delta_merge::merge_with_delta`].
63pub struct DeltaBuffer {
64    /// Buffered `(point_id, vector)` pairs awaiting index insertion.
65    points: RwLock<Vec<(u64, Vec<f32>)>>,
66
67    /// State machine: `INACTIVE` | `ACTIVE` | `DRAINING`.
68    state: AtomicU8,
69}
70
71impl DeltaBuffer {
72    /// Creates an empty, inactive delta buffer.
73    #[must_use]
74    pub fn new() -> Self {
75        Self {
76            points: RwLock::new(Vec::new()),
77            state: AtomicU8::new(INACTIVE),
78        }
79    }
80
81    /// Returns `true` if the delta buffer is actively accumulating vectors
82    /// (i.e., an HNSW rebuild is in progress).
83    #[must_use]
84    pub fn is_active(&self) -> bool {
85        self.state.load(Ordering::Acquire) == ACTIVE
86    }
87
88    /// Returns true if the buffer contains data that should be merged into search results.
89    ///
90    /// This is true in both `ACTIVE` and `DRAINING` states: the buffer holds
91    /// vectors not yet present in HNSW, so searches must include them regardless
92    /// of whether new writes are still being accepted.
93    #[must_use]
94    pub fn is_searchable(&self) -> bool {
95        let s = self.state.load(Ordering::Acquire);
96        s == ACTIVE || s == DRAINING
97    }
98
99    /// Activates the delta buffer (marks a rebuild as in progress).
100    ///
101    /// While active, the drain loop will push vectors into this buffer so
102    /// that search can find them before they are indexed into HNSW.
103    ///
104    /// Idempotent: calling `activate()` when already active is a no-op.
105    pub fn activate(&self) {
106        self.state.store(ACTIVE, Ordering::Release);
107    }
108
109    /// Activates the buffer via compare-and-swap, rejecting double-activation.
110    ///
111    /// Transitions `INACTIVE → ACTIVE` atomically. Unlike [`activate`](Self::activate),
112    /// this surfaces a re-entrant activation: if the buffer is already `ACTIVE`
113    /// or `DRAINING` (i.e., not `INACTIVE`), it returns
114    /// [`ActivateError::AlreadyActive`] instead of silently overwriting the
115    /// state. Use this on the rebuild entry path so two concurrent rebuilds on
116    /// the same collection cannot both believe they own the buffer.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`ActivateError::AlreadyActive`] when the buffer is not `INACTIVE`.
121    pub fn try_activate(&self) -> Result<(), ActivateError> {
122        self.state
123            .compare_exchange(INACTIVE, ACTIVE, Ordering::AcqRel, Ordering::Acquire)
124            .map(|_| ())
125            .map_err(|_| ActivateError::AlreadyActive)
126    }
127
128    /// Deactivates the buffer and drains all buffered points.
129    ///
130    /// Transitions `ACTIVE → DRAINING`, takes the points, then sets
131    /// `INACTIVE`. Any concurrent `search` call that observes `DRAINING`
132    /// may race with this method and observe an empty buffer — that is
133    /// architecturally acceptable. The real searchable-immediately guarantee
134    /// is provided by the HNSW index rebuild completing after drain
135    /// incorporates all drained vectors. Searches racing with
136    /// `deactivate_and_drain` during the DRAINING window may miss these
137    /// vectors transiently; they will be found via HNSW once the rebuild
138    /// completes.
139    ///
140    /// Returns the accumulated `(point_id, vector)` pairs for progressive
141    /// merge into the newly rebuilt HNSW index. After this call, the buffer
142    /// is empty and inactive.
143    pub fn deactivate_and_drain(&self) -> Vec<(u64, Vec<f32>)> {
144        // Mark as DRAINING so concurrent searches can still observe the buffer
145        // while we hold the write lock.
146        self.state.store(DRAINING, Ordering::Release);
147        let mut points = self.points.write();
148        let drained = std::mem::take(&mut *points);
149        // Set INACTIVE before dropping write lock: this ensures no observable window
150        // where state == DRAINING but buffer is empty. A concurrent activate() call
151        // seeing INACTIVE will store ACTIVE, and any subsequent push() will contend
152        // for the write lock (still held here) then see the empty-but-active buffer.
153        // This is correct: the activate→push sequence works on a clean buffer.
154        self.state.store(INACTIVE, Ordering::Release);
155        drop(points);
156        drained
157    }
158
159    /// Pushes a single entry into the delta buffer (upsert semantics).
160    ///
161    /// If an entry with the same `id` already exists, it is replaced.
162    /// This prevents duplicate IDs from accumulating when the same point
163    /// is inserted multiple times during an HNSW rebuild.
164    ///
165    /// The retain-then-push is O(n) but acceptable: the buffer is bounded
166    /// by `merge_threshold` (typically 1024-4096 entries).
167    ///
168    /// No-op if the buffer is not in `ACTIVE` state. The check is performed
169    /// **inside** the write lock to close the TOCTOU window between `is_active()`
170    /// and the actual write.
171    pub fn push(&self, id: u64, vector: Vec<f32>) {
172        let mut points = self.points.write();
173        if self.state.load(Ordering::Acquire) == ACTIVE {
174            points.retain(|(existing_id, _)| *existing_id != id);
175            points.push((id, vector));
176        }
177    }
178
179    /// Extends the delta buffer with multiple entries (upsert semantics).
180    ///
181    /// For each entry, any existing entry with the same ID is replaced.
182    /// This prevents duplicate IDs from accumulating in the buffer.
183    ///
184    /// No-op if the buffer is not in `ACTIVE` state. The check is performed
185    /// **inside** the write lock to close the TOCTOU window between `is_active()`
186    /// and the actual write.
187    pub fn extend(&self, entries: impl IntoIterator<Item = (u64, Vec<f32>)>) {
188        let mut points = self.points.write();
189        if self.state.load(Ordering::Acquire) == ACTIVE {
190            let new_entries: Vec<(u64, Vec<f32>)> = entries.into_iter().collect();
191            let new_ids: HashSet<u64> = new_entries.iter().map(|(id, _)| *id).collect();
192            points.retain(|(existing_id, _)| !new_ids.contains(existing_id));
193            points.extend(new_entries);
194        }
195    }
196
197    /// Removes all entries matching the given point ID from the buffer.
198    ///
199    /// Works in any state (`ACTIVE`, `DRAINING`, or `INACTIVE`): a delete
200    /// must always purge stale data regardless of the buffer lifecycle.
201    /// This prevents ghost results where a deleted vector is still returned
202    /// by the delta brute-force scan.
203    pub fn remove(&self, id: u64) {
204        self.points.write().retain(|(eid, _)| *eid != id);
205    }
206
207    /// Returns the number of buffered entries.
208    ///
209    /// Takes a single read lock. Use [`stats`](Self::stats) when both `len`
210    /// and `is_empty` are needed to avoid two separate lock acquisitions.
211    #[must_use]
212    pub fn len(&self) -> usize {
213        self.points.read().len()
214    }
215
216    /// Returns `true` if the buffer contains no entries.
217    ///
218    /// Delegates to `len() == 0` (single lock acquisition).
219    #[must_use]
220    pub fn is_empty(&self) -> bool {
221        self.len() == 0
222    }
223
224    /// Returns `(len, is_empty)` under a single read lock.
225    ///
226    /// Prefer this over calling `len()` and `is_empty()` separately when both
227    /// values are needed, to avoid acquiring the read lock twice.
228    #[must_use]
229    pub fn stats(&self) -> (usize, bool) {
230        let len = self.points.read().len();
231        (len, len == 0)
232    }
233
234    /// Brute-force searches the delta buffer for the k nearest neighbors.
235    ///
236    /// Returns an empty `Vec` if the buffer is neither `ACTIVE` nor `DRAINING`.
237    /// Takes a brief read lock to snapshot the points, releases it, then
238    /// computes distances on the snapshot to avoid holding the lock during
239    /// potentially expensive distance calculations.
240    #[must_use]
241    pub fn search(&self, query: &[f32], k: usize, metric: DistanceMetric) -> Vec<(u64, f32)> {
242        let current_state = self.state.load(Ordering::Acquire);
243        if current_state != ACTIVE && current_state != DRAINING {
244            return Vec::new();
245        }
246
247        // Snapshot under a brief read lock, then release before computing distances.
248        let snapshot: Vec<(u64, Vec<f32>)> = self.points.read().clone();
249        if snapshot.is_empty() {
250            return Vec::new();
251        }
252
253        let mut results: Vec<(u64, f32)> = snapshot
254            .iter()
255            .map(|(id, vec)| (*id, metric.calculate(query, vec)))
256            .collect();
257
258        metric.sort_results(&mut results);
259        results.truncate(k);
260        results
261    }
262}
263
264impl Default for DeltaBuffer {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::super::delta_merge::merge_with_delta;
273    use super::*;
274
275    #[test]
276    fn test_stream_delta_buffer_compiles_and_defaults_inactive() {
277        let buf = DeltaBuffer::new();
278        assert!(
279            !buf.is_active(),
280            "new DeltaBuffer should be inactive by default"
281        );
282    }
283
284    #[test]
285    fn test_stream_delta_buffer_default_trait() {
286        let buf = DeltaBuffer::default();
287        assert!(!buf.is_active());
288    }
289
290    #[test]
291    fn test_stream_delta_push_and_search() {
292        let buf = DeltaBuffer::new();
293        buf.activate();
294        buf.push(1, vec![1.0, 0.0, 0.0]);
295        buf.push(2, vec![0.0, 1.0, 0.0]);
296        buf.push(3, vec![0.5, 0.5, 0.0]);
297
298        let query = &[1.0, 0.0, 0.0];
299        let results = buf.search(query, 2, DistanceMetric::Cosine);
300        assert_eq!(results.len(), 2, "should return at most k=2 results");
301        // Cosine: higher is better; [1,0,0] is identical to query -> highest score
302        assert_eq!(
303            results[0].0, 1,
304            "closest match should be id=1 (identical vector)"
305        );
306    }
307
308    #[test]
309    fn test_stream_delta_search_returns_empty_when_inactive() {
310        let buf = DeltaBuffer::new();
311        buf.push(1, vec![1.0, 0.0, 0.0]);
312        // buffer is NOT active — push() is a no-op when inactive
313        let results = buf.search(&[1.0, 0.0, 0.0], 10, DistanceMetric::Cosine);
314        assert!(
315            results.is_empty(),
316            "inactive delta should return no results"
317        );
318    }
319
320    #[test]
321    fn test_stream_delta_push_noop_when_inactive() {
322        let buf = DeltaBuffer::new();
323        // push and extend are no-ops when inactive (C-1 guard)
324        buf.push(1, vec![1.0, 0.0]);
325        buf.extend(vec![(2, vec![0.0, 1.0])]);
326        assert_eq!(buf.len(), 0, "push/extend should be no-ops when inactive");
327    }
328
329    #[test]
330    fn test_stream_delta_search_cosine_ordering() {
331        let buf = DeltaBuffer::new();
332        buf.activate();
333        // Vec pointing along x-axis
334        buf.push(10, vec![1.0, 0.0]);
335        // Vec pointing along y-axis (orthogonal)
336        buf.push(20, vec![0.0, 1.0]);
337        // Vec at 45 degrees
338        buf.push(30, vec![1.0, 1.0]);
339
340        let query = &[1.0, 0.0];
341        let results = buf.search(query, 3, DistanceMetric::Cosine);
342        // Cosine: higher is better. id=10 should be first (similarity ~1.0)
343        assert_eq!(results[0].0, 10);
344        // id=30 at 45 deg should be next (similarity ~0.707)
345        assert_eq!(results[1].0, 30);
346        // id=20 orthogonal should be last (similarity ~0.0)
347        assert_eq!(results[2].0, 20);
348    }
349
350    #[test]
351    fn test_stream_delta_search_euclidean_ordering() {
352        let buf = DeltaBuffer::new();
353        buf.activate();
354        buf.push(1, vec![0.0, 0.0]);
355        buf.push(2, vec![1.0, 0.0]);
356        buf.push(3, vec![3.0, 4.0]);
357
358        let query = &[0.0, 0.0];
359        let results = buf.search(query, 3, DistanceMetric::Euclidean);
360        // Euclidean: lower is better. id=1 (dist=0) should be first
361        assert_eq!(results[0].0, 1);
362        assert_eq!(results[1].0, 2);
363        assert_eq!(results[2].0, 3);
364    }
365
366    #[test]
367    fn test_stream_delta_merge_with_delta_inactive() {
368        let buf = DeltaBuffer::new();
369        // NOT active
370        let hnsw = vec![(1, 0.9), (2, 0.8)];
371        let merged = merge_with_delta(hnsw.clone(), &buf, &[1.0, 0.0], 5, DistanceMetric::Cosine);
372        assert_eq!(merged, hnsw, "inactive delta should return HNSW unchanged");
373    }
374
375    #[test]
376    fn test_stream_delta_merge_dedup_and_truncate() {
377        let buf = DeltaBuffer::new();
378        buf.activate();
379        // Delta has id=1 with a different score and id=3 (new)
380        buf.push(1, vec![0.9, 0.1]);
381        buf.push(3, vec![0.8, 0.2]);
382
383        // HNSW results (cosine scores, higher is better)
384        let hnsw = vec![(1, 0.95), (2, 0.80)];
385
386        let query = &[1.0, 0.0];
387        let merged = merge_with_delta(hnsw, &buf, query, 2, DistanceMetric::Cosine);
388
389        // Should have at most k=2 results
390        assert_eq!(merged.len(), 2);
391
392        // Delta wins for id=1 — its score should come from delta's brute-force
393        // Check no duplicate ids
394        let ids: Vec<u64> = merged.iter().map(|(id, _)| *id).collect();
395        let unique: HashSet<u64> = ids.iter().copied().collect();
396        assert_eq!(
397            ids.len(),
398            unique.len(),
399            "no duplicate IDs in merged results"
400        );
401    }
402
403    #[test]
404    fn test_stream_delta_merge_empty_delta() {
405        let buf = DeltaBuffer::new();
406        buf.activate();
407        // Delta is active but empty
408        let hnsw = vec![(1, 0.9), (2, 0.8)];
409        let merged = merge_with_delta(hnsw.clone(), &buf, &[1.0, 0.0], 5, DistanceMetric::Cosine);
410        assert_eq!(
411            merged, hnsw,
412            "empty active delta should return HNSW unchanged"
413        );
414    }
415
416    #[test]
417    fn test_stream_delta_activate_deactivate_drain() {
418        let buf = DeltaBuffer::new();
419        assert!(!buf.is_active());
420
421        buf.activate();
422        assert!(buf.is_active());
423
424        buf.push(1, vec![1.0]);
425        buf.push(2, vec![2.0]);
426        assert_eq!(buf.len(), 2);
427
428        let drained = buf.deactivate_and_drain();
429        assert!(!buf.is_active());
430        assert!(buf.is_empty());
431        assert_eq!(drained.len(), 2);
432        assert_eq!(drained[0].0, 1);
433        assert_eq!(drained[1].0, 2);
434    }
435
436    // ── STREAM-9: try_activate CAS detects double-activation ───────────
437
438    #[test]
439    fn test_delta_activate_cas_detects_double() {
440        let buf = DeltaBuffer::new();
441        // First activation on an INACTIVE buffer succeeds.
442        assert!(
443            buf.try_activate().is_ok(),
444            "first try_activate must succeed"
445        );
446        assert!(buf.is_active());
447        // Second activation must be rejected (double-activation detected).
448        assert_eq!(
449            buf.try_activate(),
450            Err(ActivateError::AlreadyActive),
451            "re-entrant try_activate must report AlreadyActive"
452        );
453        // After draining back to INACTIVE, try_activate succeeds again.
454        let _ = buf.deactivate_and_drain();
455        assert!(
456            buf.try_activate().is_ok(),
457            "try_activate must succeed again once buffer is INACTIVE"
458        );
459    }
460
461    #[test]
462    fn test_delta_try_activate_pushes_after_cas() {
463        let buf = DeltaBuffer::new();
464        buf.try_activate().expect("activation should succeed");
465        buf.push(1, vec![1.0, 0.0]);
466        assert_eq!(buf.len(), 1, "push after CAS activation must accumulate");
467    }
468
469    #[test]
470    fn test_stream_delta_extend() {
471        let buf = DeltaBuffer::new();
472        buf.activate();
473        buf.extend(vec![(1, vec![1.0]), (2, vec![2.0]), (3, vec![3.0])]);
474        assert_eq!(buf.len(), 3);
475    }
476
477    #[test]
478    fn test_stream_delta_stats() {
479        let buf = DeltaBuffer::new();
480        buf.activate();
481        buf.push(1, vec![1.0]);
482        let (len, is_empty) = buf.stats();
483        assert_eq!(len, 1);
484        assert!(!is_empty);
485    }
486
487    // ── Bug B0.1: remove() filters deleted points from search ──────────
488
489    #[test]
490    fn test_delta_remove_filters_deleted_point() {
491        let buf = DeltaBuffer::new();
492        buf.activate();
493        buf.push(1, vec![1.0, 2.0, 3.0]);
494        buf.push(2, vec![4.0, 5.0, 6.0]);
495        buf.remove(1);
496        let results = buf.search(&[1.0, 2.0, 3.0], 10, DistanceMetric::Euclidean);
497        assert!(
498            results.iter().all(|(id, _)| *id != 1),
499            "Deleted point should not appear in search results"
500        );
501        assert_eq!(results.len(), 1, "Only point 2 should remain");
502    }
503
504    #[test]
505    fn test_delta_remove_nonexistent_id_is_noop() {
506        let buf = DeltaBuffer::new();
507        buf.activate();
508        buf.push(1, vec![1.0, 2.0]);
509        buf.remove(999);
510        assert_eq!(buf.len(), 1, "Removing absent ID should not change length");
511    }
512
513    #[test]
514    fn test_delta_remove_works_in_draining_state() {
515        let buf = DeltaBuffer::new();
516        buf.activate();
517        buf.push(1, vec![1.0]);
518        buf.push(2, vec![2.0]);
519        // remove() works unconditionally (any state) — a delete must always
520        // purge stale data regardless of buffer lifecycle.
521        buf.remove(1);
522        assert_eq!(buf.len(), 1);
523    }
524
525    // ── Bug B0.4: push() deduplicates on same ID (upsert semantics) ───
526
527    #[test]
528    fn test_delta_push_deduplicates_on_same_id() {
529        let buf = DeltaBuffer::new();
530        buf.activate();
531        buf.push(1, vec![1.0, 2.0, 3.0]);
532        buf.push(1, vec![4.0, 5.0, 6.0]); // Same ID, different vector
533        assert_eq!(buf.len(), 1, "Should have deduplicated");
534        let results = buf.search(&[4.0, 5.0, 6.0], 1, DistanceMetric::Euclidean);
535        assert_eq!(results[0].0, 1);
536        // Distance should be ~0 since query matches the updated vector
537        assert!(
538            results[0].1 < 0.01,
539            "Updated vector should match query closely"
540        );
541    }
542
543    #[test]
544    fn test_delta_extend_deduplicates_on_same_id() {
545        let buf = DeltaBuffer::new();
546        buf.activate();
547        buf.push(1, vec![1.0, 0.0]);
548        buf.push(2, vec![0.0, 1.0]);
549        // Extend with updates for id=1 and a new id=3
550        buf.extend(vec![(1, vec![0.5, 0.5]), (3, vec![0.0, 0.0])]);
551        assert_eq!(buf.len(), 3, "Should have ids 1, 2, 3");
552        let results = buf.search(&[0.5, 0.5], 1, DistanceMetric::Euclidean);
553        assert_eq!(
554            results[0].0, 1,
555            "ID 1 should have updated vector [0.5, 0.5]"
556        );
557        assert!(results[0].1 < 0.01, "Updated vector should match query");
558    }
559}