1use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::io::Write;
16use std::path::Path;
17
18use crate::dsl::{Schema, VectorIndexType};
19use crate::error::{Error, Result};
20
21pub const INDEX_META_FILENAME: &str = "metadata.json";
23const INDEX_META_TMP_FILENAME: &str = "metadata.json.tmp";
25
26pub(crate) const MAX_TRAINED_ARTIFACT_BYTES: usize = 512 * 1024 * 1024;
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
34pub enum VectorIndexState {
35 #[default]
37 Flat,
38 Built {
40 vector_count: usize,
42 num_clusters: usize,
44 },
45}
46
47fn default_true() -> bool {
48 true
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SegmentMetaInfo {
55 pub num_docs: u32,
57 pub ancestors: Vec<String>,
59 pub generation: u32,
61 #[serde(default)]
65 pub reordered: bool,
66 #[serde(default = "default_true")]
71 pub bp_converged: bool,
72 #[serde(default)]
76 pub bp_unconverged_passes: u32,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct FieldVectorMeta {
82 pub field_id: u32,
84 pub index_type: VectorIndexType,
86 pub state: VectorIndexState,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub centroids_file: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub codebook_file: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct IndexMetadata {
99 pub version: u32,
101 pub schema: Schema,
103 #[serde(default)]
106 pub segment_metas: HashMap<String, SegmentMetaInfo>,
107 #[serde(default)]
109 pub vector_fields: HashMap<u32, FieldVectorMeta>,
110 #[serde(default)]
117 pub total_vectors: usize,
118}
119
120impl IndexMetadata {
121 pub fn new(schema: Schema) -> Self {
123 Self {
124 version: 1,
125 schema,
126 segment_metas: HashMap::new(),
127 vector_fields: HashMap::new(),
128 total_vectors: 0,
129 }
130 }
131
132 pub fn segment_ids(&self) -> Vec<String> {
134 let mut ids: Vec<String> = self.segment_metas.keys().cloned().collect();
135 ids.sort();
136 ids
137 }
138
139 pub fn add_segment(&mut self, segment_id: String, num_docs: u32) {
141 self.segment_metas.insert(
142 segment_id,
143 SegmentMetaInfo {
144 num_docs,
145 ancestors: Vec::new(),
146 generation: 0,
147 reordered: false,
148 bp_converged: true,
149 bp_unconverged_passes: 0,
150 },
151 );
152 }
153
154 pub fn add_merged_segment(
156 &mut self,
157 segment_id: String,
158 num_docs: u32,
159 ancestors: Vec<String>,
160 generation: u32,
161 reordered: bool,
162 bp_converged: bool,
163 ) {
164 self.add_segment_meta(
165 segment_id,
166 SegmentMetaInfo {
167 num_docs,
168 ancestors,
169 generation,
170 reordered,
171 bp_converged,
172 bp_unconverged_passes: 0,
173 },
174 );
175 }
176
177 pub(crate) fn add_segment_meta(&mut self, segment_id: String, info: SegmentMetaInfo) {
181 self.segment_metas.insert(segment_id, info);
182 }
183
184 pub fn remove_segment(&mut self, segment_id: &str) {
186 self.segment_metas.remove(segment_id);
187 }
188
189 pub fn has_segment(&self, segment_id: &str) -> bool {
191 self.segment_metas.contains_key(segment_id)
192 }
193
194 pub fn segment_doc_count(&self, segment_id: &str) -> Option<u32> {
196 self.segment_metas.get(segment_id).map(|m| m.num_docs)
197 }
198
199 pub fn is_field_built(&self, field_id: u32) -> bool {
201 self.vector_fields
202 .get(&field_id)
203 .map(|f| matches!(f.state, VectorIndexState::Built { .. }))
204 .unwrap_or(false)
205 }
206
207 pub fn get_field_meta(&self, field_id: u32) -> Option<&FieldVectorMeta> {
209 self.vector_fields.get(&field_id)
210 }
211
212 pub fn init_field(&mut self, field_id: u32, index_type: VectorIndexType) {
214 self.vector_fields
215 .entry(field_id)
216 .or_insert(FieldVectorMeta {
217 field_id,
218 index_type,
219 state: VectorIndexState::Flat,
220 centroids_file: None,
221 codebook_file: None,
222 });
223 }
224
225 pub fn mark_field_built(
227 &mut self,
228 field_id: u32,
229 vector_count: usize,
230 num_clusters: usize,
231 centroids_file: String,
232 codebook_file: Option<String>,
233 ) {
234 if let Some(field) = self.vector_fields.get_mut(&field_id) {
235 field.state = VectorIndexState::Built {
236 vector_count,
237 num_clusters,
238 };
239 field.centroids_file = Some(centroids_file);
240 field.codebook_file = codebook_file;
241 self.refresh_total_vectors();
242 }
243 }
244
245 pub(crate) fn refresh_total_vectors(&mut self) {
251 self.total_vectors = self
252 .vector_fields
253 .values()
254 .filter_map(|field| match field.state {
255 VectorIndexState::Built { vector_count, .. } => Some(vector_count),
256 VectorIndexState::Flat => None,
257 })
258 .fold(0usize, usize::saturating_add);
259 }
260
261 pub fn should_build_field(&self, field_id: u32, threshold: usize) -> bool {
263 if self.is_field_built(field_id) {
265 return false;
266 }
267 self.total_vectors >= threshold
269 }
270
271 pub async fn load<D: crate::directories::Directory>(dir: &D) -> Result<Self> {
276 let path = Path::new(INDEX_META_FILENAME);
277 match dir.open_read(path).await {
278 Ok(slice) => {
279 let bytes = slice.read_bytes().await?;
280 serde_json::from_slice(bytes.as_slice())
281 .map_err(|e| Error::Serialization(e.to_string()))
282 }
283 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
284 let tmp_path = Path::new(INDEX_META_TMP_FILENAME);
286 let slice = dir.open_read(tmp_path).await?;
287 let bytes = slice.read_bytes().await?;
288 let meta: Self = serde_json::from_slice(bytes.as_slice())
289 .map_err(|e| Error::Serialization(e.to_string()))?;
290 log::warn!("Recovered metadata from temp file (previous crash during save)");
291 Ok(meta)
292 }
293 Err(e) => Err(Error::Io(e)),
294 }
295 }
296
297 pub async fn save<D: crate::directories::DirectoryWriter>(&self, dir: &D) -> Result<()> {
302 let bytes = self.serialize_to_bytes()?;
303 Self::save_bytes(dir, &bytes).await
304 }
305
306 pub fn serialize_to_bytes(&self) -> Result<Vec<u8>> {
309 serde_json::to_vec_pretty(self).map_err(|e| Error::Serialization(e.to_string()))
310 }
311
312 pub async fn save_bytes<D: crate::directories::DirectoryWriter>(
317 dir: &D,
318 bytes: &[u8],
319 ) -> Result<()> {
320 let tmp_path = Path::new(INDEX_META_TMP_FILENAME);
321 let final_path = Path::new(INDEX_META_FILENAME);
322 let mut writer = dir.streaming_writer(tmp_path).await.map_err(Error::Io)?;
327 writer.write_all(bytes).map_err(Error::Io)?;
328 writer.finish().map_err(Error::Io)?;
329 dir.rename(tmp_path, final_path).await.map_err(Error::Io)?;
336 if let Err(error) = dir.sync().await {
337 log::error!(
338 "[metadata] directory fsync failed after committed rename: {}. \
339 Continuing with the renamed generation; crash durability is not guaranteed",
340 error,
341 );
342 }
343 Ok(())
344 }
345
346 pub async fn load_trained_from_fields<D: crate::directories::Directory>(
353 vector_fields: &HashMap<u32, FieldVectorMeta>,
354 dir: &D,
355 ) -> Option<crate::segment::TrainedVectorStructures> {
356 match Self::load_trained_from_fields_impl(vector_fields, None, dir).await {
357 Ok(trained) => trained,
358 Err(error) => {
359 log::error!("[trained] refusing incomplete/corrupt artifact set: {error}");
360 None
361 }
362 }
363 }
364
365 #[cfg_attr(not(feature = "native"), allow(dead_code))]
367 pub(crate) async fn try_load_trained_from_fields<D: crate::directories::Directory>(
368 vector_fields: &HashMap<u32, FieldVectorMeta>,
369 schema: &Schema,
370 dir: &D,
371 ) -> Result<Option<crate::segment::TrainedVectorStructures>> {
372 Self::load_trained_from_fields_impl(vector_fields, Some(schema), dir).await
373 }
374
375 async fn load_trained_from_fields_impl<D: crate::directories::Directory>(
385 vector_fields: &HashMap<u32, FieldVectorMeta>,
386 schema: Option<&Schema>,
387 dir: &D,
388 ) -> Result<Option<crate::segment::TrainedVectorStructures>> {
389 use std::sync::Arc;
390
391 let mut centroids = rustc_hash::FxHashMap::default();
392 let mut codebooks = rustc_hash::FxHashMap::default();
393 let mut built_fields: Vec<_> = vector_fields
394 .iter()
395 .filter(|(_, meta)| matches!(meta.state, VectorIndexState::Built { .. }))
396 .collect();
397 built_fields.sort_unstable_by_key(|(field_id, _)| **field_id);
398
399 log::debug!(
400 "[trained] loading trained structures, vector_fields={:?}",
401 vector_fields.keys().collect::<Vec<_>>()
402 );
403
404 for (field_id, field_meta) in built_fields {
405 log::debug!(
406 "[trained] field {} state={:?} centroids_file={:?} codebook_file={:?}",
407 field_id,
408 field_meta.state,
409 field_meta.centroids_file,
410 field_meta.codebook_file,
411 );
412 if field_meta.field_id != *field_id {
413 return Err(Error::Corruption(format!(
414 "trained vector metadata key {field_id} contains field_id {}",
415 field_meta.field_id
416 )));
417 }
418
419 let schema_config = match schema {
420 None => None,
421 Some(schema) => {
422 let entry = schema
423 .get_field_entry(crate::dsl::Field(*field_id))
424 .ok_or_else(|| {
425 Error::Corruption(format!(
426 "trained vector metadata references missing field {field_id}"
427 ))
428 })?;
429 if entry.field_type != crate::dsl::FieldType::DenseVector {
430 return Err(Error::Corruption(format!(
431 "trained vector metadata field {field_id} has non-dense schema type {:?}",
432 entry.field_type
433 )));
434 }
435 let config = entry.dense_vector_config.as_ref().ok_or_else(|| {
436 Error::Corruption(format!(
437 "trained vector metadata field {field_id} has no dense-vector configuration"
438 ))
439 })?;
440 if field_meta.index_type != config.index_type {
441 return Err(Error::Corruption(format!(
442 "trained vector metadata field {field_id} uses {:?}, schema requires {:?}",
443 field_meta.index_type, config.index_type
444 )));
445 }
446 Some(config)
447 }
448 };
449 if !matches!(
450 field_meta.index_type,
451 VectorIndexType::IvfRaBitQ | VectorIndexType::ScaNN
452 ) {
453 return Err(Error::Corruption(format!(
454 "field {field_id} is Built for {:?}, which has no index-level trained artifacts",
455 field_meta.index_type
456 )));
457 }
458
459 let expected_clusters = match field_meta.state {
460 VectorIndexState::Built { num_clusters, .. } if num_clusters > 0 => num_clusters,
461 VectorIndexState::Built { .. } => {
462 return Err(Error::Corruption(format!(
463 "trained vector metadata field {field_id} has zero clusters"
464 )));
465 }
466 VectorIndexState::Flat => unreachable!("built_fields contains only Built entries"),
467 };
468
469 let centroids_file = field_meta.centroids_file.as_deref().ok_or_else(|| {
470 Error::Corruption(format!(
471 "trained vector metadata field {field_id} is Built but has no centroids_file"
472 ))
473 })?;
474 let c: crate::structures::CoarseCentroids =
475 load_trained_artifact(dir, *field_id, "centroids", centroids_file).await?;
476 let expected_dim = schema_config.map_or(c.dim, |config| config.dim);
477 let actual_clusters = c.num_clusters as usize;
478 let expected_values = actual_clusters.checked_mul(expected_dim).ok_or_else(|| {
479 Error::Corruption(format!(
480 "trained centroid dimensions overflow for field {field_id}"
481 ))
482 })?;
483 if actual_clusters == 0
484 || actual_clusters > expected_clusters
485 || c.dim == 0
486 || c.dim != expected_dim
487 || c.centroids.len() != expected_values
488 || c.centroids.iter().any(|value| !value.is_finite())
489 {
490 return Err(Error::Corruption(format!(
491 "trained centroids for field {field_id} do not match metadata/schema: \
492 clusters={} (metadata maximum {expected_clusters}), dim={} (expected {}), \
493 values={} (expected {expected_values})",
494 c.num_clusters,
495 c.dim,
496 expected_dim,
497 c.centroids.len(),
498 )));
499 }
500 if actual_clusters < expected_clusters {
501 log::warn!(
507 "[trained] field {} legacy cluster-count clamp: metadata={}, artifact={}",
508 field_id,
509 expected_clusters,
510 actual_clusters,
511 );
512 }
513 log::debug!(
514 "[trained] field {} loaded centroids ({} clusters)",
515 field_id,
516 c.num_clusters
517 );
518
519 if field_meta.index_type == VectorIndexType::ScaNN {
520 let codebook_file = field_meta.codebook_file.as_deref().ok_or_else(|| {
521 Error::Corruption(format!(
522 "trained vector metadata field {field_id} is ScaNN Built but has no codebook_file"
523 ))
524 })?;
525 let codebook: crate::structures::PQCodebook =
526 load_trained_artifact(dir, *field_id, "codebook", codebook_file).await?;
527 codebook.validate().map_err(|error| {
528 Error::Corruption(format!(
529 "invalid trained codebook for field {field_id}: {error}"
530 ))
531 })?;
532 if codebook.config.dim != expected_dim {
533 return Err(Error::Corruption(format!(
534 "trained codebook for field {field_id} has dimension {}, expected {}",
535 codebook.config.dim, expected_dim
536 )));
537 }
538 log::debug!("[trained] field {} loaded codebook", field_id);
539 codebooks.insert(*field_id, Arc::new(codebook));
540 }
541 centroids.insert(*field_id, Arc::new(c));
542 }
543
544 if centroids.is_empty() {
545 Ok(None)
546 } else {
547 Ok(Some(crate::segment::TrainedVectorStructures {
548 centroids,
549 codebooks,
550 }))
551 }
552 }
553}
554
555fn validate_trained_artifact_path(field_id: u32, kind: &str, filename: &str) -> Result<()> {
556 use std::path::Component;
557
558 let path = Path::new(filename);
559 if filename.is_empty()
560 || path.is_absolute()
561 || path.components().any(|component| {
562 matches!(
563 component,
564 Component::ParentDir | Component::RootDir | Component::Prefix(_)
565 )
566 })
567 {
568 return Err(Error::Corruption(format!(
569 "trained {kind} path for field {field_id} is not a safe relative path: '{filename}'"
570 )));
571 }
572 Ok(())
573}
574
575async fn load_trained_artifact<T, D>(
576 dir: &D,
577 field_id: u32,
578 kind: &str,
579 filename: &str,
580) -> Result<T>
581where
582 T: serde::de::DeserializeOwned,
583 D: crate::directories::Directory,
584{
585 validate_trained_artifact_path(field_id, kind, filename)?;
586 let path = Path::new(filename);
587 let file_size = dir.file_size(path).await.map_err(|error| {
588 Error::Corruption(format!(
589 "failed to stat trained {kind} '{filename}' for field {field_id}: {error}"
590 ))
591 })?;
592 validate_trained_artifact_size(field_id, kind, filename, file_size)?;
593 let slice = dir.open_read(path).await.map_err(|error| {
594 Error::Corruption(format!(
595 "failed to open trained {kind} '{filename}' for field {field_id}: {error}"
596 ))
597 })?;
598 validate_trained_artifact_size(field_id, kind, filename, slice.len())?;
599 let bytes = slice.read_bytes().await.map_err(|error| {
600 Error::Corruption(format!(
601 "failed to read trained {kind} '{filename}' for field {field_id}: {error}"
602 ))
603 })?;
604 let (artifact, consumed) = bincode::serde::decode_from_slice::<T, _>(
605 bytes.as_slice(),
606 bincode::config::standard().with_limit::<MAX_TRAINED_ARTIFACT_BYTES>(),
607 )
608 .map_err(|error| {
609 Error::Corruption(format!(
610 "failed to deserialize trained {kind} '{filename}' for field {field_id}: {error}"
611 ))
612 })?;
613 if consumed != bytes.len() {
614 return Err(Error::Corruption(format!(
615 "trained {kind} '{filename}' for field {field_id} has {} trailing bytes",
616 bytes.len() - consumed
617 )));
618 }
619 Ok(artifact)
620}
621
622fn validate_trained_artifact_size(
623 field_id: u32,
624 kind: &str,
625 filename: &str,
626 file_size: u64,
627) -> Result<()> {
628 if file_size > MAX_TRAINED_ARTIFACT_BYTES as u64 {
629 return Err(Error::Corruption(format!(
630 "trained {kind} '{filename}' for field {field_id} is {file_size} bytes, \
631 exceeding the {MAX_TRAINED_ARTIFACT_BYTES}-byte safety limit"
632 )));
633 }
634 Ok(())
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640 use crate::directories::DirectoryWriter;
641
642 #[derive(Clone, Default)]
643 struct SyncFailDirectory(crate::directories::RamDirectory);
644
645 #[async_trait::async_trait]
646 impl crate::directories::Directory for SyncFailDirectory {
647 async fn exists(&self, path: &Path) -> std::io::Result<bool> {
648 self.0.exists(path).await
649 }
650
651 async fn file_size(&self, path: &Path) -> std::io::Result<u64> {
652 self.0.file_size(path).await
653 }
654
655 async fn open_read(&self, path: &Path) -> std::io::Result<crate::directories::FileHandle> {
656 self.0.open_read(path).await
657 }
658
659 async fn read_range(
660 &self,
661 path: &Path,
662 range: std::ops::Range<u64>,
663 ) -> std::io::Result<crate::directories::OwnedBytes> {
664 self.0.read_range(path, range).await
665 }
666
667 async fn list_files(&self, prefix: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
668 self.0.list_files(prefix).await
669 }
670
671 async fn open_lazy(&self, path: &Path) -> std::io::Result<crate::directories::FileHandle> {
672 self.0.open_lazy(path).await
673 }
674 }
675
676 #[async_trait::async_trait]
677 impl crate::directories::DirectoryWriter for SyncFailDirectory {
678 async fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
679 self.0.write(path, data).await
680 }
681
682 async fn delete(&self, path: &Path) -> std::io::Result<()> {
683 self.0.delete(path).await
684 }
685
686 async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
687 self.0.rename(from, to).await
688 }
689
690 async fn sync(&self) -> std::io::Result<()> {
691 Err(std::io::Error::other("injected directory fsync failure"))
692 }
693
694 async fn streaming_writer(
695 &self,
696 path: &Path,
697 ) -> std::io::Result<Box<dyn crate::directories::StreamingWriter>> {
698 self.0.streaming_writer(path).await
699 }
700 }
701
702 fn test_schema() -> Schema {
703 Schema::default()
704 }
705
706 fn dense_schema(index_type: VectorIndexType) -> (Schema, crate::dsl::Field) {
707 let mut builder = crate::dsl::SchemaBuilder::default();
708 let config = match index_type {
709 VectorIndexType::IvfRaBitQ => crate::dsl::DenseVectorConfig::with_ivf(2, Some(1), 1),
710 VectorIndexType::ScaNN => crate::dsl::DenseVectorConfig::with_scann(2, Some(1), 1),
711 other => panic!("unsupported trained test index type: {other:?}"),
712 };
713 let field = builder.add_dense_vector_field_with_config("embedding", true, true, config);
714 (builder.build(), field)
715 }
716
717 fn test_centroids() -> crate::structures::CoarseCentroids {
718 crate::structures::CoarseCentroids {
719 num_clusters: 1,
720 dim: 2,
721 centroids: vec![0.25, 0.75],
722 version: 7,
723 soar_config: None,
724 }
725 }
726
727 async fn write_bincode(
728 directory: &crate::directories::RamDirectory,
729 filename: &str,
730 value: &impl serde::Serialize,
731 ) {
732 let bytes = bincode::serde::encode_to_vec(value, bincode::config::standard()).unwrap();
733 directory.write(Path::new(filename), &bytes).await.unwrap();
734 }
735
736 #[test]
737 fn test_metadata_init() {
738 let mut meta = IndexMetadata::new(test_schema());
739 assert_eq!(meta.total_vectors, 0);
740 assert!(meta.segment_metas.is_empty());
741 assert!(!meta.is_field_built(0));
742
743 meta.init_field(0, VectorIndexType::IvfRaBitQ);
744 assert!(!meta.is_field_built(0));
745 assert!(meta.vector_fields.contains_key(&0));
746 }
747
748 #[tokio::test]
749 async fn save_treats_post_rename_sync_failure_as_committed() {
750 let directory = SyncFailDirectory::default();
751 let mut metadata = IndexMetadata::new(test_schema());
752 metadata.add_segment("committed".to_string(), 7);
753
754 metadata.save(&directory).await.unwrap();
755
756 let loaded = IndexMetadata::load(&directory).await.unwrap();
757 assert_eq!(loaded.segment_doc_count("committed"), Some(7));
758 }
759
760 #[tokio::test]
761 async fn trained_artifacts_load_only_when_the_complete_built_set_is_valid() {
762 let mut builder = crate::dsl::SchemaBuilder::default();
763 let config = crate::dsl::DenseVectorConfig::with_ivf(2, Some(1), 1);
764 let first = builder.add_dense_vector_field_with_config(
765 "first_embedding",
766 true,
767 true,
768 config.clone(),
769 );
770 let second =
771 builder.add_dense_vector_field_with_config("second_embedding", true, true, config);
772 let schema = builder.build();
773 let directory = crate::directories::RamDirectory::new();
774 let mut metadata = IndexMetadata::new(schema.clone());
775 metadata.init_field(first.0, VectorIndexType::IvfRaBitQ);
776 metadata.init_field(second.0, VectorIndexType::IvfRaBitQ);
777 metadata.mark_field_built(first.0, 10, 1, "field_0_centroids.bin".into(), None);
778 metadata.mark_field_built(second.0, 10, 1, "field_1_centroids.bin".into(), None);
779 write_bincode(&directory, "field_0_centroids.bin", &test_centroids()).await;
780
781 let error = IndexMetadata::try_load_trained_from_fields(
782 &metadata.vector_fields,
783 &schema,
784 &directory,
785 )
786 .await
787 .err()
788 .expect("missing artifact must fail the complete load")
789 .to_string();
790 assert!(error.contains("field_1_centroids.bin"), "{error}");
791 assert!(error.contains("field 1"), "{error}");
792 assert!(
793 IndexMetadata::load_trained_from_fields(&metadata.vector_fields, &directory)
794 .await
795 .is_none(),
796 "the compatibility API must also fail closed instead of returning the valid subset"
797 );
798 }
799
800 #[tokio::test]
801 async fn index_open_fails_closed_when_built_artifact_is_missing() {
802 let (schema, field) = dense_schema(VectorIndexType::IvfRaBitQ);
803 let directory = crate::directories::RamDirectory::new();
804 let mut metadata = IndexMetadata::new(schema);
805 metadata.init_field(field.0, VectorIndexType::IvfRaBitQ);
806 metadata.mark_field_built(field.0, 10, 1, "missing_centroids.bin".into(), None);
807 metadata.save(&directory).await.unwrap();
808
809 let error = match crate::index::Index::open(directory, crate::index::IndexConfig::default())
810 .await
811 {
812 Ok(_) => panic!("Index::open accepted a Built field with no artifact"),
813 Err(error) => error.to_string(),
814 };
815 assert!(error.contains("missing_centroids.bin"), "{error}");
816 }
817
818 #[tokio::test]
819 async fn scann_built_state_requires_a_codebook() {
820 let (schema, field) = dense_schema(VectorIndexType::ScaNN);
821 let directory = crate::directories::RamDirectory::new();
822 let mut metadata = IndexMetadata::new(schema.clone());
823 metadata.init_field(field.0, VectorIndexType::ScaNN);
824 metadata.mark_field_built(field.0, 10, 1, "field_0_centroids.bin".into(), None);
825 write_bincode(&directory, "field_0_centroids.bin", &test_centroids()).await;
826
827 let error = IndexMetadata::try_load_trained_from_fields(
828 &metadata.vector_fields,
829 &schema,
830 &directory,
831 )
832 .await
833 .err()
834 .expect("ScaNN Built state without a codebook must fail")
835 .to_string();
836 assert!(error.contains("has no codebook_file"), "{error}");
837 }
838
839 #[tokio::test]
840 async fn legacy_requested_cluster_count_accepts_a_clamped_artifact() {
841 let mut builder = crate::dsl::SchemaBuilder::default();
842 let field = builder.add_dense_vector_field_with_config(
843 "embedding",
844 true,
845 true,
846 crate::dsl::DenseVectorConfig::with_ivf(2, Some(4), 1),
847 );
848 let schema = builder.build();
849 let directory = crate::directories::RamDirectory::new();
850 let mut metadata = IndexMetadata::new(schema.clone());
851 metadata.init_field(field.0, VectorIndexType::IvfRaBitQ);
852 metadata.mark_field_built(field.0, 1, 4, "field_0_centroids.bin".into(), None);
853 write_bincode(&directory, "field_0_centroids.bin", &test_centroids()).await;
854
855 let trained = IndexMetadata::try_load_trained_from_fields(
856 &metadata.vector_fields,
857 &schema,
858 &directory,
859 )
860 .await
861 .unwrap()
862 .unwrap();
863 assert_eq!(trained.centroids[&field.0].num_clusters, 1);
864 }
865
866 #[tokio::test]
867 async fn trained_artifact_loader_rejects_trailing_data() {
868 let (schema, field) = dense_schema(VectorIndexType::IvfRaBitQ);
869 let directory = crate::directories::RamDirectory::new();
870 let mut metadata = IndexMetadata::new(schema.clone());
871 metadata.init_field(field.0, VectorIndexType::IvfRaBitQ);
872 metadata.mark_field_built(field.0, 10, 1, "field_0_centroids.bin".into(), None);
873 let mut bytes =
874 bincode::serde::encode_to_vec(test_centroids(), bincode::config::standard()).unwrap();
875 bytes.extend_from_slice(&[0xaa, 0xbb]);
876 directory
877 .write(Path::new("field_0_centroids.bin"), &bytes)
878 .await
879 .unwrap();
880
881 let error = IndexMetadata::try_load_trained_from_fields(
882 &metadata.vector_fields,
883 &schema,
884 &directory,
885 )
886 .await
887 .err()
888 .expect("trailing artifact bytes must fail validation")
889 .to_string();
890 assert!(error.contains("trailing bytes"), "{error}");
891 }
892
893 #[test]
894 fn trained_artifact_size_limit_rejects_before_reading() {
895 let error = validate_trained_artifact_size(
896 3,
897 "centroids",
898 "field_3_centroids.bin",
899 MAX_TRAINED_ARTIFACT_BYTES as u64 + 1,
900 )
901 .unwrap_err()
902 .to_string();
903 assert!(error.contains("exceeding"), "{error}");
904 assert!(error.contains("field 3"), "{error}");
905 }
906
907 #[tokio::test]
908 async fn trained_artifact_decode_limit_rejects_forged_collection_length() {
909 let (schema, field) = dense_schema(VectorIndexType::IvfRaBitQ);
910 let directory = crate::directories::RamDirectory::new();
911 let mut metadata = IndexMetadata::new(schema.clone());
912 metadata.init_field(field.0, VectorIndexType::IvfRaBitQ);
913 metadata.mark_field_built(field.0, 10, 1, "field_0_centroids.bin".into(), None);
914
915 let mut bytes = vec![1, 2, 253];
920 bytes.extend_from_slice(&u64::MAX.to_le_bytes());
921 directory
922 .write(Path::new("field_0_centroids.bin"), &bytes)
923 .await
924 .unwrap();
925
926 let error = IndexMetadata::try_load_trained_from_fields(
927 &metadata.vector_fields,
928 &schema,
929 &directory,
930 )
931 .await
932 .err()
933 .expect("forged collection length must fail the bounded decoder")
934 .to_string();
935 assert!(error.contains("failed to deserialize"), "{error}");
936 }
937
938 #[test]
939 fn test_metadata_segments() {
940 let mut meta = IndexMetadata::new(test_schema());
941 meta.add_segment("abc123".to_string(), 50);
942 meta.add_segment("def456".to_string(), 100);
943 assert_eq!(meta.segment_metas.len(), 2);
944 assert_eq!(meta.segment_doc_count("abc123"), Some(50));
945 assert_eq!(meta.segment_doc_count("def456"), Some(100));
946
947 meta.add_segment("abc123".to_string(), 75);
949 assert_eq!(meta.segment_metas.len(), 2);
950 assert_eq!(meta.segment_doc_count("abc123"), Some(75));
951
952 meta.remove_segment("abc123");
953 assert_eq!(meta.segment_metas.len(), 1);
954 assert!(meta.has_segment("def456"));
955 assert!(!meta.has_segment("abc123"));
956 }
957
958 #[test]
959 fn test_mark_field_built() {
960 let mut meta = IndexMetadata::new(test_schema());
961 meta.init_field(0, VectorIndexType::IvfRaBitQ);
962 meta.total_vectors = 10000;
963
964 assert!(!meta.is_field_built(0));
965
966 meta.mark_field_built(0, 10000, 256, "field_0_centroids.bin".to_string(), None);
967
968 assert!(meta.is_field_built(0));
969 let field = meta.get_field_meta(0).unwrap();
970 assert_eq!(
971 field.centroids_file.as_deref(),
972 Some("field_0_centroids.bin")
973 );
974 }
975
976 #[test]
977 fn total_vectors_is_aggregate_of_built_field_counts() {
978 let mut meta = IndexMetadata::new(test_schema());
979 meta.init_field(7, VectorIndexType::IvfRaBitQ);
980 meta.init_field(3, VectorIndexType::ScaNN);
981
982 meta.mark_field_built(7, 400, 20, "field_7_centroids.bin".to_string(), None);
985 assert_eq!(meta.total_vectors, 400);
986 meta.mark_field_built(
987 3,
988 250,
989 15,
990 "field_3_centroids.bin".to_string(),
991 Some("field_3_codebook.bin".to_string()),
992 );
993 assert_eq!(meta.total_vectors, 650);
994
995 meta.mark_field_built(7, 425, 20, "field_7_centroids.bin".to_string(), None);
998 assert_eq!(meta.total_vectors, 675);
999 }
1000
1001 #[test]
1002 fn test_should_build_field() {
1003 let mut meta = IndexMetadata::new(test_schema());
1004 meta.init_field(0, VectorIndexType::IvfRaBitQ);
1005
1006 meta.total_vectors = 500;
1008 assert!(!meta.should_build_field(0, 1000));
1009
1010 meta.total_vectors = 1500;
1012 assert!(meta.should_build_field(0, 1000));
1013
1014 meta.mark_field_built(0, 1500, 256, "centroids.bin".to_string(), None);
1016 assert!(!meta.should_build_field(0, 1000));
1017 }
1018
1019 #[test]
1020 fn test_serialization() {
1021 let mut meta = IndexMetadata::new(test_schema());
1022 meta.add_segment("seg1".to_string(), 100);
1023 meta.init_field(0, VectorIndexType::IvfRaBitQ);
1024 meta.total_vectors = 5000;
1025
1026 let json = serde_json::to_string_pretty(&meta).unwrap();
1027 let loaded: IndexMetadata = serde_json::from_str(&json).unwrap();
1028
1029 assert_eq!(loaded.segment_ids().len(), meta.segment_ids().len());
1030 assert_eq!(loaded.segment_doc_count("seg1"), Some(100));
1031 assert_eq!(loaded.total_vectors, meta.total_vectors);
1032 assert!(loaded.vector_fields.contains_key(&0));
1033 }
1034
1035 #[test]
1036 fn old_metadata_defaults_the_bp_retry_counter() {
1037 let mut meta = IndexMetadata::new(test_schema());
1038 meta.add_segment("legacy".to_string(), 10);
1039 let mut json = serde_json::to_value(&meta).unwrap();
1040 json["segment_metas"]["legacy"]
1041 .as_object_mut()
1042 .unwrap()
1043 .remove("bp_unconverged_passes");
1044
1045 let loaded: IndexMetadata = serde_json::from_value(json).unwrap();
1046 assert_eq!(loaded.segment_metas["legacy"].bp_unconverged_passes, 0);
1047 }
1048
1049 #[test]
1050 fn test_merged_segment_lineage() {
1051 let mut meta = IndexMetadata::new(test_schema());
1052 meta.add_segment("a".to_string(), 50);
1053 meta.add_segment("b".to_string(), 75);
1054
1055 assert_eq!(meta.segment_metas["a"].generation, 0);
1057 assert!(meta.segment_metas["a"].ancestors.is_empty());
1058
1059 meta.add_merged_segment(
1061 "c".to_string(),
1062 125,
1063 vec!["a".to_string(), "b".to_string()],
1064 1,
1065 false,
1066 true,
1067 );
1068 assert_eq!(meta.segment_metas["c"].generation, 1);
1069 assert_eq!(meta.segment_metas["c"].ancestors, vec!["a", "b"]);
1070 assert_eq!(meta.segment_doc_count("c"), Some(125));
1071
1072 meta.add_segment("d".to_string(), 30);
1074 meta.add_merged_segment(
1075 "e".to_string(),
1076 155,
1077 vec!["c".to_string(), "d".to_string()],
1078 2,
1079 false,
1080 true,
1081 );
1082 assert_eq!(meta.segment_metas["e"].generation, 2);
1083 }
1084}