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}