Skip to main content

nodedb_vector/collection/
lifecycle.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! VectorCollection lifecycle: insert, delete, seal, complete_build, compact.
4//!
5//! Identity model: every vector inserted into the collection is bound to
6//! a global `Surrogate` allocated by the Control Plane before the engine
7//! sees the call. The HNSW segments keep their dense local node-ids
8//! internally for cache-locality and SIMD traversal; this file owns the
9//! `surrogate_map: HashMap<u32, Surrogate>` (global node-id → surrogate)
10//! and reverse `surrogate_to_local: HashMap<Surrogate, u32>` (for
11//! point-delete by surrogate). User-PK strings live in the catalog and
12//! are translated at the Control Plane response boundary.
13//!
14//! Insert/delete ops live in `lifecycle_insert_ops`.
15//! Compact/snapshot ops live in `lifecycle_compact`.
16
17use std::collections::HashMap;
18
19use nodedb_types::{Surrogate, VectorQuantization};
20
21use crate::flat::FlatIndex;
22use crate::hnsw::{HnswIndex, HnswParams};
23use crate::index_config::{IndexConfig, IndexType};
24
25use super::codec_dispatch::CollectionCodec;
26use super::payload_index::PayloadIndexSet;
27use super::segment::{BuildRequest, BuildingSegment, DEFAULT_SEAL_THRESHOLD, SealedSegment};
28
29/// Manages all vector segments for a single collection (one index key).
30///
31/// This type is `!Send` — owned by a single Data Plane core.
32pub struct VectorCollection {
33    /// Active growing segment (append-only, brute-force search).
34    pub(crate) growing: FlatIndex,
35    /// Base ID for the growing segment's vectors.
36    pub(crate) growing_base_id: u32,
37    /// Sealed segments with completed HNSW indexes.
38    pub(crate) sealed: Vec<SealedSegment>,
39    /// Segments being built in background (brute-force searchable).
40    pub(crate) building: Vec<BuildingSegment>,
41    /// HNSW params for this collection.
42    pub(crate) params: HnswParams,
43    /// Global vector ID counter (monotonic across all segments).
44    pub(crate) next_id: u32,
45    /// Next segment ID (monotonic).
46    pub(crate) next_segment_id: u32,
47    /// Dimensionality.
48    pub(crate) dim: usize,
49    /// Data directory for mmap segment files (L1 NVMe tier).
50    pub(crate) data_dir: Option<std::path::PathBuf>,
51    /// Memory budget for this collection's RAM vectors (bytes).
52    pub(crate) ram_budget_bytes: usize,
53    /// Count of segments that fell back to mmap due to budget exhaustion.
54    pub(crate) mmap_fallback_count: u32,
55    /// Count of segments currently backed by mmap files.
56    pub(crate) mmap_segment_count: u32,
57    /// Mapping from internal global vector ID → surrogate.
58    pub surrogate_map: HashMap<u32, Surrogate>,
59    /// Reverse map: surrogate → global vector ID. Used by point delete.
60    pub surrogate_to_local: HashMap<Surrogate, u32>,
61    /// Reverse mapping for multi-vector documents:
62    /// document_surrogate → list of global vector IDs.
63    pub multi_doc_map: HashMap<Surrogate, Vec<u32>>,
64    /// Number of vectors in the growing segment before sealing.
65    pub(crate) seal_threshold: usize,
66    /// Full index configuration (index type, PQ params, IVF params).
67    pub(crate) index_config: IndexConfig,
68    /// Optional collection-level codec-dispatch index (RaBitQ or BBQ).
69    /// Present only when the collection was built with a non-Sq8 quantization.
70    /// Coexists with sealed segments — for codec-dispatched collections the
71    /// per-segment Sq8 builder is skipped and this index is used instead.
72    pub codec_dispatch: Option<CollectionCodec>,
73    /// Quantization mode requested at collection-creation time.
74    ///
75    /// When `!= None && != Sq8`, each call to `complete_build` additionally
76    /// rebuilds `codec_dispatch` over all vectors so the codec-dispatch path
77    /// is always up-to-date after a segment seals.
78    pub(crate) quantization: VectorQuantization,
79    /// In-memory payload bitmap indexes for vector-primary collections.
80    ///
81    /// Empty (no indexes) by default; populated at construction time from
82    /// `VectorPrimaryConfig::payload_indexes`.
83    pub payload: PayloadIndexSet,
84    /// Optional dedicated memory arena index for this collection.
85    ///
86    /// Set by the Data Plane after requesting a per-collection arena from
87    /// `nodedb_mem::CollectionArenaRegistry`. Used only for stats reporting;
88    /// the actual arena pinning is handled externally.
89    pub arena_index: Option<u32>,
90    /// Highest WAL LSN whose write is already reflected in this collection's
91    /// in-memory state, as of the last checkpoint load or save. Persisted in
92    /// the checkpoint so that startup WAL replay can skip records the
93    /// restored checkpoint already absorbed — the same watermark discipline
94    /// the timeseries engine applies via `last_flushed_wal_lsn`. Set ONLY by
95    /// checkpoint load (restore) and checkpoint save; it is FROZEN during a
96    /// replay pass so all sub-records of a `TransactionRedo` record — which
97    /// share the enclosing record's single LSN — apply instead of the first
98    /// one gating its siblings. Per-apply advancement lands in
99    /// [`Self::applied_wal_lsn`] instead, and is folded into this field at
100    /// checkpoint save time. `0` means "no write recorded" (a fresh
101    /// collection or a legacy checkpoint predating this field), which never
102    /// gates replay.
103    pub(crate) checkpoint_wal_lsn: u64,
104    /// Running max of WAL LSNs applied to this collection's in-memory state
105    /// since the last checkpoint load. Advanced by [`Self::note_checkpoint_lsn`]
106    /// at every live/replay apply chokepoint. NEVER read by the replay skip
107    /// gate (that reads `checkpoint_wal_lsn`, which stays frozen during a replay
108    /// pass so all sub-records of one transaction — which share the enclosing
109    /// `TransactionRedo` record's single LSN — apply instead of the first one
110    /// gating its siblings). Folded into the persisted watermark at checkpoint
111    /// save time via `max(checkpoint_wal_lsn, applied_wal_lsn)`.
112    pub(crate) applied_wal_lsn: u64,
113}
114
115impl VectorCollection {
116    /// Create an empty collection with the default seal threshold.
117    pub fn new(dim: usize, params: HnswParams) -> Self {
118        Self::with_seal_threshold(dim, params, DEFAULT_SEAL_THRESHOLD)
119    }
120
121    /// Create an empty collection with an explicit seal threshold.
122    pub fn with_seal_threshold(dim: usize, params: HnswParams, seal_threshold: usize) -> Self {
123        let index_config = IndexConfig {
124            hnsw: params.clone(),
125            ..IndexConfig::default()
126        };
127        Self::with_seal_threshold_and_config(dim, index_config, seal_threshold)
128    }
129
130    /// Create an empty collection with a full index configuration.
131    pub fn with_index_config(dim: usize, config: IndexConfig) -> Self {
132        Self::with_seal_threshold_and_config(dim, config, DEFAULT_SEAL_THRESHOLD)
133    }
134
135    /// Create an empty collection with a full index config and custom seal threshold.
136    pub fn with_seal_threshold_and_config(
137        dim: usize,
138        config: IndexConfig,
139        seal_threshold: usize,
140    ) -> Self {
141        let params = config.hnsw.clone();
142        Self {
143            growing: FlatIndex::new(dim, params.metric),
144            growing_base_id: 0,
145            sealed: Vec::new(),
146            building: Vec::new(),
147            params,
148            next_id: 0,
149            next_segment_id: 0,
150            dim,
151            data_dir: None,
152            ram_budget_bytes: 0,
153            mmap_fallback_count: 0,
154            mmap_segment_count: 0,
155            surrogate_map: HashMap::new(),
156            surrogate_to_local: HashMap::new(),
157            multi_doc_map: HashMap::new(),
158            seal_threshold,
159            index_config: config,
160            codec_dispatch: None,
161            quantization: VectorQuantization::default(),
162            payload: PayloadIndexSet::default(),
163            arena_index: None,
164            checkpoint_wal_lsn: 0,
165            applied_wal_lsn: 0,
166        }
167    }
168
169    /// Advance the applied-watermark to `lsn` if it is higher than the
170    /// current value. Called at every apply chokepoint (live write and WAL
171    /// replay) with the WAL LSN of the applied write. `0` is ignored — an
172    /// unassigned LSN must never move the watermark. This does NOT move the
173    /// replay skip gate (`checkpoint_wal_lsn`); it is folded into that gate
174    /// only at checkpoint save time via `max(checkpoint_wal_lsn,
175    /// applied_wal_lsn)`, so the gate stays frozen across an entire replay
176    /// pass and doesn't gate a `TransactionRedo` record's own siblings.
177    pub fn note_checkpoint_lsn(&mut self, lsn: u64) {
178        if lsn > self.applied_wal_lsn {
179            self.applied_wal_lsn = lsn;
180        }
181    }
182
183    /// Highest WAL LSN already reflected in this collection's in-memory state,
184    /// as of the last checkpoint load or save.
185    ///
186    /// Startup replay skips any record whose `lsn <= checkpoint_wal_lsn()` for
187    /// this collection: the restored checkpoint already contains that write, so
188    /// re-applying it would append a duplicate HNSW node.
189    pub fn checkpoint_wal_lsn(&self) -> u64 {
190        self.checkpoint_wal_lsn
191    }
192
193    /// Highest WAL LSN applied since the last checkpoint load (the running max
194    /// `note_checkpoint_lsn` builds). Folded into the persisted watermark at
195    /// checkpoint save time; not consulted by the replay gate.
196    pub fn applied_wal_lsn(&self) -> u64 {
197        self.applied_wal_lsn
198    }
199
200    /// Create with a specific seed (for deterministic testing).
201    pub fn with_seed(dim: usize, params: HnswParams, _seed: u64) -> Self {
202        Self::with_seal_threshold(dim, params, DEFAULT_SEAL_THRESHOLD)
203    }
204
205    /// Check if the growing segment should be sealed.
206    pub fn needs_seal(&self) -> bool {
207        self.growing.len() >= self.seal_threshold
208    }
209
210    /// Seal the growing segment and return a build request.
211    pub fn seal(&mut self, key: &str) -> Option<BuildRequest> {
212        if self.growing.is_empty() {
213            return None;
214        }
215
216        let segment_id = self.next_segment_id;
217        self.next_segment_id += 1;
218
219        let count = self.growing.len();
220        let mut vectors = Vec::with_capacity(count);
221        for i in 0..count as u32 {
222            if let Some(v) = self.growing.get_vector(i) {
223                vectors.push(v.to_vec());
224            }
225        }
226
227        let old_growing = std::mem::replace(
228            &mut self.growing,
229            FlatIndex::new(self.dim, self.params.metric),
230        );
231        let old_base = self.growing_base_id;
232        self.growing_base_id = self.next_id;
233
234        self.building.push(BuildingSegment {
235            flat: old_growing,
236            base_id: old_base,
237            segment_id,
238        });
239
240        Some(BuildRequest {
241            key: key.to_string(),
242            segment_id,
243            vectors,
244            dim: self.dim,
245            params: self.params.clone(),
246        })
247    }
248
249    /// Accept a completed HNSW build from the background thread.
250    ///
251    /// After promoting the segment to sealed, rebuilds the collection-level
252    /// codec-dispatch index when `self.quantization` is `RaBitQ` or `Bbq`.
253    /// The rebuild trains over all vectors so the codec index always covers
254    /// every sealed segment.
255    pub fn complete_build(&mut self, segment_id: u32, index: HnswIndex) {
256        if let Some(pos) = self
257            .building
258            .iter()
259            .position(|b| b.segment_id == segment_id)
260        {
261            let building = self.building.remove(pos);
262            let use_codec_dispatch = matches!(
263                self.quantization,
264                VectorQuantization::RaBitQ | VectorQuantization::Bbq
265            );
266            let use_pq = !use_codec_dispatch && self.index_config.index_type == IndexType::HnswPq;
267            let (sq8, pq) = if use_codec_dispatch {
268                (None, None)
269            } else if use_pq {
270                (
271                    None,
272                    Self::build_pq_for_index(&index, self.index_config.pq_m),
273                )
274            } else {
275                (Self::build_sq8_for_index(&index), None)
276            };
277            let (tier, mmap_vectors) =
278                self.resolve_tier_for_build(segment_id, building.base_id, &index);
279
280            self.sealed.push(SealedSegment {
281                index,
282                base_id: building.base_id,
283                sq8,
284                pq,
285                tier,
286                mmap_vectors,
287            });
288
289            if use_codec_dispatch {
290                let tag = match self.quantization {
291                    VectorQuantization::RaBitQ => "rabitq",
292                    VectorQuantization::Bbq => "bbq",
293                    _ => unreachable!(
294                        "invariant: use_codec_dispatch is only true for RaBitQ and Bbq quantization variants"
295                    ),
296                };
297                self.build_codec_dispatch(tag);
298            }
299        }
300    }
301
302    /// Access sealed segments (read-only).
303    pub fn sealed_segments(&self) -> &[SealedSegment] {
304        &self.sealed
305    }
306
307    /// Access sealed segments mutably.
308    pub fn sealed_segments_mut(&mut self) -> &mut Vec<SealedSegment> {
309        &mut self.sealed
310    }
311
312    /// Whether the growing segment has no vectors.
313    pub fn growing_is_empty(&self) -> bool {
314        self.growing.is_empty()
315    }
316
317    pub fn len(&self) -> usize {
318        let mut total = self.growing.len();
319        for seg in &self.sealed {
320            total += seg.index.len();
321        }
322        for seg in &self.building {
323            total += seg.flat.len();
324        }
325        total
326    }
327
328    pub fn live_count(&self) -> usize {
329        let mut total = self.growing.live_count();
330        for seg in &self.sealed {
331            total += seg.index.live_count();
332        }
333        for seg in &self.building {
334            total += seg.flat.live_count();
335        }
336        total
337    }
338
339    pub fn is_empty(&self) -> bool {
340        self.live_count() == 0
341    }
342
343    pub fn dim(&self) -> usize {
344        self.dim
345    }
346
347    pub fn params(&self) -> &HnswParams {
348        &self.params
349    }
350
351    /// Update HNSW parameters for future builds.
352    pub fn set_params(&mut self, params: HnswParams) {
353        self.params = params;
354    }
355
356    /// Set the collection-level quantization.
357    pub fn set_quantization(&mut self, q: VectorQuantization) {
358        self.quantization = q;
359    }
360
361    /// Return the configured quantization mode.
362    pub fn quantization(&self) -> VectorQuantization {
363        self.quantization
364    }
365
366    /// Configure payload bitmap indexes from a list of field names.
367    pub fn configure_payload_indexes(&mut self, fields: &[String]) {
368        use super::payload_index::PayloadIndexKind;
369        for field in fields {
370            self.payload
371                .add_index(field.as_str(), PayloadIndexKind::Equality);
372        }
373    }
374}