Skip to main content

nodedb_vector/collection/
lifecycle.rs

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