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}