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