Skip to main content

hermes_core/index/
metadata.rs

1//! Unified index metadata - segments list + vector index state
2//!
3//! This module manages all index-level metadata in a single `metadata.json` file:
4//! - List of committed segments
5//! - Vector index state per field (Flat/Built)
6//! - Trained centroids/codebooks paths
7//!
8//! The workflow is:
9//! 1. During accumulation: segments store Flat vectors, state is Flat
10//! 2. When threshold crossed: train ONCE, update state to Built
11//! 3. On index open: load metadata, skip re-training if already built
12
13use 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
21/// Metadata file name at index level
22pub const INDEX_META_FILENAME: &str = "metadata.json";
23/// Temp file for atomic writes (write here, then rename to INDEX_META_FILENAME)
24const INDEX_META_TMP_FILENAME: &str = "metadata.json.tmp";
25
26/// State of vector index for a field
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
28pub enum VectorIndexState {
29    /// Accumulating vectors - using Flat (brute-force) search
30    #[default]
31    Flat,
32    /// Index structures built - using ANN search
33    Built {
34        /// Total vector count when training happened
35        vector_count: usize,
36        /// Number of clusters used
37        num_clusters: usize,
38    },
39}
40
41fn default_true() -> bool {
42    true
43}
44
45/// Per-segment metadata stored in index metadata
46/// This allows merge decisions without loading segment files
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SegmentMetaInfo {
49    /// Number of documents in this segment
50    pub num_docs: u32,
51    /// Parent segment IDs that were merged to produce this segment (empty for fresh segments)
52    pub ancestors: Vec<String>,
53    /// Merge generation: 0 for fresh segments, max(parent generations) + 1 for merged segments
54    pub generation: u32,
55    /// Whether this segment has been reordered via Recursive Graph Bisection (BP).
56    /// Fresh segments and block-copy merges are not reordered. Only segments that have
57    /// been explicitly reordered (via background optimizer or reorder command) are marked true.
58    #[serde(default)]
59    pub reordered: bool,
60    /// Whether the last BP reorder pass ran to natural convergence. False when
61    /// a wall-clock BP budget ended the pass early — the segment is ordered
62    /// better than before, and a later warm-started pass can deepen it.
63    /// Old metadata (field absent) deserializes as converged.
64    #[serde(default = "default_true")]
65    pub bp_converged: bool,
66    /// Number of consecutive budget-exhausted BP rewrites in this segment's
67    /// current reordered lineage. Carried across replacement IDs so the
68    /// optimizer can impose a hard follow-up bound instead of rewriting forever.
69    #[serde(default)]
70    pub bp_unconverged_passes: u32,
71}
72
73/// Per-field vector index metadata
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct FieldVectorMeta {
76    /// Field ID
77    pub field_id: u32,
78    /// Configured index type (target type when built)
79    pub index_type: VectorIndexType,
80    /// Current state
81    pub state: VectorIndexState,
82    /// Path to centroids file (relative to index dir)
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub centroids_file: Option<String>,
85    /// Path to codebook file (relative to index dir, for ScaNN)
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub codebook_file: Option<String>,
88}
89
90/// Unified index metadata - single source of truth for index state
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct IndexMetadata {
93    /// Version for compatibility
94    pub version: u32,
95    /// Index schema
96    pub schema: Schema,
97    /// Segment metadata: segment_id -> info (doc count, etc.)
98    /// Using HashMap allows O(1) lookup and stores doc counts for merge decisions
99    #[serde(default)]
100    pub segment_metas: HashMap<String, SegmentMetaInfo>,
101    /// Per-field vector index metadata
102    #[serde(default)]
103    pub vector_fields: HashMap<u32, FieldVectorMeta>,
104    /// Total vectors across all segments (updated on commit)
105    #[serde(default)]
106    pub total_vectors: usize,
107}
108
109impl IndexMetadata {
110    /// Create new metadata with schema
111    pub fn new(schema: Schema) -> Self {
112        Self {
113            version: 1,
114            schema,
115            segment_metas: HashMap::new(),
116            vector_fields: HashMap::new(),
117            total_vectors: 0,
118        }
119    }
120
121    /// Get segment IDs as a sorted Vec (deterministic ordering)
122    pub fn segment_ids(&self) -> Vec<String> {
123        let mut ids: Vec<String> = self.segment_metas.keys().cloned().collect();
124        ids.sort();
125        ids
126    }
127
128    /// Add a fresh segment (gen=0, no ancestors, not reordered)
129    pub fn add_segment(&mut self, segment_id: String, num_docs: u32) {
130        self.segment_metas.insert(
131            segment_id,
132            SegmentMetaInfo {
133                num_docs,
134                ancestors: Vec::new(),
135                generation: 0,
136                reordered: false,
137                bp_converged: true,
138                bp_unconverged_passes: 0,
139            },
140        );
141    }
142
143    /// Add a merged segment with lineage info
144    pub fn add_merged_segment(
145        &mut self,
146        segment_id: String,
147        num_docs: u32,
148        ancestors: Vec<String>,
149        generation: u32,
150        reordered: bool,
151        bp_converged: bool,
152    ) {
153        self.add_segment_meta(
154            segment_id,
155            SegmentMetaInfo {
156                num_docs,
157                ancestors,
158                generation,
159                reordered,
160                bp_converged,
161                bp_unconverged_passes: 0,
162            },
163        );
164    }
165
166    /// Insert fully constructed lifecycle metadata. Merge/reorder code uses
167    /// this to carry bounded BP lineage; ordinary callers use the safer
168    /// constructors above, which start a fresh lineage.
169    pub(crate) fn add_segment_meta(&mut self, segment_id: String, info: SegmentMetaInfo) {
170        self.segment_metas.insert(segment_id, info);
171    }
172
173    /// Remove a segment
174    pub fn remove_segment(&mut self, segment_id: &str) {
175        self.segment_metas.remove(segment_id);
176    }
177
178    /// Check if segment exists
179    pub fn has_segment(&self, segment_id: &str) -> bool {
180        self.segment_metas.contains_key(segment_id)
181    }
182
183    /// Get segment doc count
184    pub fn segment_doc_count(&self, segment_id: &str) -> Option<u32> {
185        self.segment_metas.get(segment_id).map(|m| m.num_docs)
186    }
187
188    /// Check if a field has been built
189    pub fn is_field_built(&self, field_id: u32) -> bool {
190        self.vector_fields
191            .get(&field_id)
192            .map(|f| matches!(f.state, VectorIndexState::Built { .. }))
193            .unwrap_or(false)
194    }
195
196    /// Get field metadata
197    pub fn get_field_meta(&self, field_id: u32) -> Option<&FieldVectorMeta> {
198        self.vector_fields.get(&field_id)
199    }
200
201    /// Initialize field metadata (called when field is first seen)
202    pub fn init_field(&mut self, field_id: u32, index_type: VectorIndexType) {
203        self.vector_fields
204            .entry(field_id)
205            .or_insert(FieldVectorMeta {
206                field_id,
207                index_type,
208                state: VectorIndexState::Flat,
209                centroids_file: None,
210                codebook_file: None,
211            });
212    }
213
214    /// Mark field as built with trained structures
215    pub fn mark_field_built(
216        &mut self,
217        field_id: u32,
218        vector_count: usize,
219        num_clusters: usize,
220        centroids_file: String,
221        codebook_file: Option<String>,
222    ) {
223        if let Some(field) = self.vector_fields.get_mut(&field_id) {
224            field.state = VectorIndexState::Built {
225                vector_count,
226                num_clusters,
227            };
228            field.centroids_file = Some(centroids_file);
229            field.codebook_file = codebook_file;
230        }
231    }
232
233    /// Check if field should be built based on threshold
234    pub fn should_build_field(&self, field_id: u32, threshold: usize) -> bool {
235        // Don't build if already built
236        if self.is_field_built(field_id) {
237            return false;
238        }
239        // Build if we have enough vectors
240        self.total_vectors >= threshold
241    }
242
243    /// Load from directory
244    ///
245    /// If `metadata.json` is missing but `metadata.json.tmp` exists (crash
246    /// between write and rename), recovers from the temp file.
247    pub async fn load<D: crate::directories::Directory>(dir: &D) -> Result<Self> {
248        let path = Path::new(INDEX_META_FILENAME);
249        match dir.open_read(path).await {
250            Ok(slice) => {
251                let bytes = slice.read_bytes().await?;
252                serde_json::from_slice(bytes.as_slice())
253                    .map_err(|e| Error::Serialization(e.to_string()))
254            }
255            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
256                // Try recovering from temp file (crash between write and rename)
257                let tmp_path = Path::new(INDEX_META_TMP_FILENAME);
258                let slice = dir.open_read(tmp_path).await?;
259                let bytes = slice.read_bytes().await?;
260                let meta: Self = serde_json::from_slice(bytes.as_slice())
261                    .map_err(|e| Error::Serialization(e.to_string()))?;
262                log::warn!("Recovered metadata from temp file (previous crash during save)");
263                Ok(meta)
264            }
265            Err(e) => Err(Error::Io(e)),
266        }
267    }
268
269    /// Save to directory (atomic: write temp file, then rename)
270    ///
271    /// Uses write-then-rename so a crash mid-write won't corrupt the
272    /// existing metadata file. On POSIX, rename is atomic.
273    pub async fn save<D: crate::directories::DirectoryWriter>(&self, dir: &D) -> Result<()> {
274        let bytes = self.serialize_to_bytes()?;
275        Self::save_bytes(dir, &bytes).await
276    }
277
278    /// Serialize metadata to bytes (cheap, no I/O).
279    /// Useful when you need to release a lock before doing disk I/O.
280    pub fn serialize_to_bytes(&self) -> Result<Vec<u8>> {
281        serde_json::to_vec_pretty(self).map_err(|e| Error::Serialization(e.to_string()))
282    }
283
284    /// Write pre-serialized metadata bytes to directory (atomic rename + fsync).
285    ///
286    /// The fsync ensures durability: without it, a power failure after rename
287    /// could lose the metadata update on systems with volatile write caches.
288    pub async fn save_bytes<D: crate::directories::DirectoryWriter>(
289        dir: &D,
290        bytes: &[u8],
291    ) -> Result<()> {
292        let tmp_path = Path::new(INDEX_META_TMP_FILENAME);
293        let final_path = Path::new(INDEX_META_FILENAME);
294        // Metadata is tiny, but `DirectoryWriter::write` does not guarantee
295        // the file contents themselves are fsynced. Finish the streaming
296        // writer first (filesystem implementations call `File::sync_all`),
297        // then atomically publish the durable temp file by rename.
298        let mut writer = dir.streaming_writer(tmp_path).await.map_err(Error::Io)?;
299        writer.write_all(bytes).map_err(Error::Io)?;
300        writer.finish().map_err(Error::Io)?;
301        // Rename is the logical commit point: after it succeeds, readers can
302        // observe the new generation and callers must publish the matching
303        // in-memory/tracker state. Directory fsync only strengthens crash
304        // durability. It cannot safely turn an already-visible rename into a
305        // reported pre-commit failure, because cleanup could then delete files
306        // referenced by the metadata now on disk.
307        dir.rename(tmp_path, final_path).await.map_err(Error::Io)?;
308        if let Err(error) = dir.sync().await {
309            log::error!(
310                "[metadata] directory fsync failed after committed rename: {}. \
311                 Continuing with the renamed generation; crash durability is not guaranteed",
312                error,
313            );
314        }
315        Ok(())
316    }
317
318    /// Load trained structures from a vector_fields map.
319    /// Accepts a pre-cloned map so callers can release locks before disk I/O.
320    pub async fn load_trained_from_fields<D: crate::directories::Directory>(
321        vector_fields: &HashMap<u32, FieldVectorMeta>,
322        dir: &D,
323    ) -> Option<crate::segment::TrainedVectorStructures> {
324        use std::sync::Arc;
325
326        let mut centroids = rustc_hash::FxHashMap::default();
327        let mut codebooks = rustc_hash::FxHashMap::default();
328
329        log::debug!(
330            "[trained] loading trained structures, vector_fields={:?}",
331            vector_fields.keys().collect::<Vec<_>>()
332        );
333
334        for (field_id, field_meta) in vector_fields {
335            log::debug!(
336                "[trained] field {} state={:?} centroids_file={:?} codebook_file={:?}",
337                field_id,
338                field_meta.state,
339                field_meta.centroids_file,
340                field_meta.codebook_file,
341            );
342            if !matches!(field_meta.state, VectorIndexState::Built { .. }) {
343                log::debug!("[trained] field {} skipped (not Built)", field_id);
344                continue;
345            }
346
347            // Load centroids
348            match &field_meta.centroids_file {
349                None => {
350                    log::warn!(
351                        "[trained] field {} is Built but has no centroids_file",
352                        field_id
353                    );
354                }
355                Some(file) => match dir.open_read(Path::new(file)).await {
356                    Err(e) => {
357                        log::warn!(
358                            "[trained] field {} failed to open centroids file '{}': {}",
359                            field_id,
360                            file,
361                            e
362                        );
363                    }
364                    Ok(slice) => match slice.read_bytes().await {
365                        Err(e) => {
366                            log::warn!(
367                                "[trained] field {} failed to read centroids file '{}': {}",
368                                field_id,
369                                file,
370                                e
371                            );
372                        }
373                        Ok(bytes) => {
374                            match bincode::serde::decode_from_slice::<
375                                crate::structures::CoarseCentroids,
376                                _,
377                            >(
378                                bytes.as_slice(), bincode::config::standard()
379                            )
380                            .map(|(v, _)| v)
381                            {
382                                Err(e) => {
383                                    log::warn!(
384                                        "[trained] field {} failed to deserialize centroids from '{}': {}",
385                                        field_id,
386                                        file,
387                                        e
388                                    );
389                                }
390                                Ok(c) => {
391                                    log::debug!(
392                                        "[trained] field {} loaded centroids ({} clusters)",
393                                        field_id,
394                                        c.num_clusters
395                                    );
396                                    centroids.insert(*field_id, Arc::new(c));
397                                }
398                            }
399                        }
400                    },
401                },
402            }
403
404            // Load codebook (for ScaNN)
405            match &field_meta.codebook_file {
406                None => {} // optional, not all index types use codebooks
407                Some(file) => match dir.open_read(Path::new(file)).await {
408                    Err(e) => {
409                        log::warn!(
410                            "[trained] field {} failed to open codebook file '{}': {}",
411                            field_id,
412                            file,
413                            e
414                        );
415                    }
416                    Ok(slice) => match slice.read_bytes().await {
417                        Err(e) => {
418                            log::warn!(
419                                "[trained] field {} failed to read codebook file '{}': {}",
420                                field_id,
421                                file,
422                                e
423                            );
424                        }
425                        Ok(bytes) => {
426                            match bincode::serde::decode_from_slice::<
427                                crate::structures::PQCodebook,
428                                _,
429                            >(
430                                bytes.as_slice(), bincode::config::standard()
431                            )
432                            .map(|(v, _)| v)
433                            {
434                                Err(e) => {
435                                    log::warn!(
436                                        "[trained] field {} failed to deserialize codebook from '{}': {}",
437                                        field_id,
438                                        file,
439                                        e
440                                    );
441                                }
442                                Ok(c) => {
443                                    log::debug!("[trained] field {} loaded codebook", field_id);
444                                    codebooks.insert(*field_id, Arc::new(c));
445                                }
446                            }
447                        }
448                    },
449                },
450            }
451        }
452
453        if centroids.is_empty() {
454            None
455        } else {
456            Some(crate::segment::TrainedVectorStructures {
457                centroids,
458                codebooks,
459            })
460        }
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[derive(Clone, Default)]
469    struct SyncFailDirectory(crate::directories::RamDirectory);
470
471    #[async_trait::async_trait]
472    impl crate::directories::Directory for SyncFailDirectory {
473        async fn exists(&self, path: &Path) -> std::io::Result<bool> {
474            self.0.exists(path).await
475        }
476
477        async fn file_size(&self, path: &Path) -> std::io::Result<u64> {
478            self.0.file_size(path).await
479        }
480
481        async fn open_read(&self, path: &Path) -> std::io::Result<crate::directories::FileHandle> {
482            self.0.open_read(path).await
483        }
484
485        async fn read_range(
486            &self,
487            path: &Path,
488            range: std::ops::Range<u64>,
489        ) -> std::io::Result<crate::directories::OwnedBytes> {
490            self.0.read_range(path, range).await
491        }
492
493        async fn list_files(&self, prefix: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
494            self.0.list_files(prefix).await
495        }
496
497        async fn open_lazy(&self, path: &Path) -> std::io::Result<crate::directories::FileHandle> {
498            self.0.open_lazy(path).await
499        }
500    }
501
502    #[async_trait::async_trait]
503    impl crate::directories::DirectoryWriter for SyncFailDirectory {
504        async fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
505            self.0.write(path, data).await
506        }
507
508        async fn delete(&self, path: &Path) -> std::io::Result<()> {
509            self.0.delete(path).await
510        }
511
512        async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
513            self.0.rename(from, to).await
514        }
515
516        async fn sync(&self) -> std::io::Result<()> {
517            Err(std::io::Error::other("injected directory fsync failure"))
518        }
519
520        async fn streaming_writer(
521            &self,
522            path: &Path,
523        ) -> std::io::Result<Box<dyn crate::directories::StreamingWriter>> {
524            self.0.streaming_writer(path).await
525        }
526    }
527
528    fn test_schema() -> Schema {
529        Schema::default()
530    }
531
532    #[test]
533    fn test_metadata_init() {
534        let mut meta = IndexMetadata::new(test_schema());
535        assert_eq!(meta.total_vectors, 0);
536        assert!(meta.segment_metas.is_empty());
537        assert!(!meta.is_field_built(0));
538
539        meta.init_field(0, VectorIndexType::IvfRaBitQ);
540        assert!(!meta.is_field_built(0));
541        assert!(meta.vector_fields.contains_key(&0));
542    }
543
544    #[tokio::test]
545    async fn save_treats_post_rename_sync_failure_as_committed() {
546        let directory = SyncFailDirectory::default();
547        let mut metadata = IndexMetadata::new(test_schema());
548        metadata.add_segment("committed".to_string(), 7);
549
550        metadata.save(&directory).await.unwrap();
551
552        let loaded = IndexMetadata::load(&directory).await.unwrap();
553        assert_eq!(loaded.segment_doc_count("committed"), Some(7));
554    }
555
556    #[test]
557    fn test_metadata_segments() {
558        let mut meta = IndexMetadata::new(test_schema());
559        meta.add_segment("abc123".to_string(), 50);
560        meta.add_segment("def456".to_string(), 100);
561        assert_eq!(meta.segment_metas.len(), 2);
562        assert_eq!(meta.segment_doc_count("abc123"), Some(50));
563        assert_eq!(meta.segment_doc_count("def456"), Some(100));
564
565        // Overwrites existing
566        meta.add_segment("abc123".to_string(), 75);
567        assert_eq!(meta.segment_metas.len(), 2);
568        assert_eq!(meta.segment_doc_count("abc123"), Some(75));
569
570        meta.remove_segment("abc123");
571        assert_eq!(meta.segment_metas.len(), 1);
572        assert!(meta.has_segment("def456"));
573        assert!(!meta.has_segment("abc123"));
574    }
575
576    #[test]
577    fn test_mark_field_built() {
578        let mut meta = IndexMetadata::new(test_schema());
579        meta.init_field(0, VectorIndexType::IvfRaBitQ);
580        meta.total_vectors = 10000;
581
582        assert!(!meta.is_field_built(0));
583
584        meta.mark_field_built(0, 10000, 256, "field_0_centroids.bin".to_string(), None);
585
586        assert!(meta.is_field_built(0));
587        let field = meta.get_field_meta(0).unwrap();
588        assert_eq!(
589            field.centroids_file.as_deref(),
590            Some("field_0_centroids.bin")
591        );
592    }
593
594    #[test]
595    fn test_should_build_field() {
596        let mut meta = IndexMetadata::new(test_schema());
597        meta.init_field(0, VectorIndexType::IvfRaBitQ);
598
599        // Below threshold
600        meta.total_vectors = 500;
601        assert!(!meta.should_build_field(0, 1000));
602
603        // Above threshold
604        meta.total_vectors = 1500;
605        assert!(meta.should_build_field(0, 1000));
606
607        // Already built - should not build again
608        meta.mark_field_built(0, 1500, 256, "centroids.bin".to_string(), None);
609        assert!(!meta.should_build_field(0, 1000));
610    }
611
612    #[test]
613    fn test_serialization() {
614        let mut meta = IndexMetadata::new(test_schema());
615        meta.add_segment("seg1".to_string(), 100);
616        meta.init_field(0, VectorIndexType::IvfRaBitQ);
617        meta.total_vectors = 5000;
618
619        let json = serde_json::to_string_pretty(&meta).unwrap();
620        let loaded: IndexMetadata = serde_json::from_str(&json).unwrap();
621
622        assert_eq!(loaded.segment_ids().len(), meta.segment_ids().len());
623        assert_eq!(loaded.segment_doc_count("seg1"), Some(100));
624        assert_eq!(loaded.total_vectors, meta.total_vectors);
625        assert!(loaded.vector_fields.contains_key(&0));
626    }
627
628    #[test]
629    fn old_metadata_defaults_the_bp_retry_counter() {
630        let mut meta = IndexMetadata::new(test_schema());
631        meta.add_segment("legacy".to_string(), 10);
632        let mut json = serde_json::to_value(&meta).unwrap();
633        json["segment_metas"]["legacy"]
634            .as_object_mut()
635            .unwrap()
636            .remove("bp_unconverged_passes");
637
638        let loaded: IndexMetadata = serde_json::from_value(json).unwrap();
639        assert_eq!(loaded.segment_metas["legacy"].bp_unconverged_passes, 0);
640    }
641
642    #[test]
643    fn test_merged_segment_lineage() {
644        let mut meta = IndexMetadata::new(test_schema());
645        meta.add_segment("a".to_string(), 50);
646        meta.add_segment("b".to_string(), 75);
647
648        // Fresh segments: gen=0, no ancestors
649        assert_eq!(meta.segment_metas["a"].generation, 0);
650        assert!(meta.segment_metas["a"].ancestors.is_empty());
651
652        // Merge a+b → c
653        meta.add_merged_segment(
654            "c".to_string(),
655            125,
656            vec!["a".to_string(), "b".to_string()],
657            1,
658            false,
659            true,
660        );
661        assert_eq!(meta.segment_metas["c"].generation, 1);
662        assert_eq!(meta.segment_metas["c"].ancestors, vec!["a", "b"]);
663        assert_eq!(meta.segment_doc_count("c"), Some(125));
664
665        // Merge c+d → e (gen should be 2)
666        meta.add_segment("d".to_string(), 30);
667        meta.add_merged_segment(
668            "e".to_string(),
669            155,
670            vec!["c".to_string(), "d".to_string()],
671            2,
672            false,
673            true,
674        );
675        assert_eq!(meta.segment_metas["e"].generation, 2);
676    }
677}