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}