Skip to main content

reddb_server/storage/unified/devx/reddb/
impl_metadata.rs

1use super::*;
2use crate::storage::unified::metadata::{MetadataFilter, MetadataValue};
3
4#[cfg(test)]
5fn metadata_qualification_phase(started: &std::time::Instant, name: &str) {
6    if std::env::var_os("REDDB_QUALIFICATION_CHECKPOINT_PHASES").is_some() {
7        eprintln!(
8            "runtime qualification phase=checkpoint_metadata name={name} elapsed_us={}",
9            started.elapsed().as_micros()
10        );
11    }
12}
13
14impl RedDB {
15    pub fn is_replica_role(&self) -> bool {
16        matches!(
17            self.options.replication.role,
18            crate::replication::ReplicationRole::Replica { .. }
19        )
20    }
21
22    pub fn enforce_retention_policy(&self) -> Result<(), Box<dyn std::error::Error>> {
23        if self.options.read_only || self.is_replica_role() {
24            return Ok(());
25        }
26
27        // Export pruning is only meaningful for persistent mode where we
28        // have a metadata sidecar that tracks file-backed export artifacts.
29        if self.options.mode == StorageMode::Persistent {
30            let Some(path) = self.path() else {
31                return Ok(());
32            };
33
34            let _publication = self.store.vault_publication_lock.lock();
35            let Ok(mut metadata) = self.load_or_bootstrap_physical_metadata(true) else {
36                return Ok(());
37            };
38
39            self.prune_export_registry(&mut metadata.exports);
40            metadata.save_for_data_path(path)?;
41        }
42
43        let _ = self.sweep_ttl_expired_entities()?;
44
45        Ok(())
46    }
47
48    pub(crate) fn ttl_expired_entities_now(
49        &self,
50    ) -> Result<Vec<(String, EntityId)>, Box<dyn std::error::Error>> {
51        self.ttl_expired_entities_at(current_unix_ms())
52    }
53
54    pub fn replica_allows_entity_at_read(
55        &self,
56        collection: &str,
57        entity: &crate::storage::UnifiedEntity,
58    ) -> bool {
59        if !self.is_replica_role() {
60            return true;
61        }
62        !self.entity_expired_at(collection, entity, current_unix_ms())
63    }
64
65    fn sweep_ttl_expired_entities(&self) -> Result<usize, Box<dyn std::error::Error>> {
66        let to_delete = self.ttl_expired_entities_now()?;
67
68        let mut deleted = 0usize;
69        for (collection, id) in to_delete {
70            match self.store.delete(&collection, id) {
71                Ok(true) => deleted = deleted.saturating_add(1),
72                Ok(false) => {}
73                Err(err) => {
74                    return Err(format!(
75                        "failed deleting expired entity {id} from collection '{collection}': {err:?}"
76                    )
77                    .into());
78                }
79            }
80        }
81
82        Ok(deleted)
83    }
84
85    fn ttl_expired_entities_at(
86        &self,
87        now_ms: u64,
88    ) -> Result<Vec<(String, EntityId)>, Box<dyn std::error::Error>> {
89        let mut to_delete = Vec::<(String, EntityId)>::new();
90
91        let mut absolute_expired = self.expired_entities_by_expires_at(now_ms)?;
92        to_delete.append(&mut absolute_expired);
93
94        let mut relative_expired = self.expired_entities_by_ttl(now_ms)?;
95        to_delete.append(&mut relative_expired);
96
97        to_delete.sort_unstable();
98        to_delete.dedup();
99
100        Ok(to_delete)
101    }
102
103    fn entity_expired_at(
104        &self,
105        collection: &str,
106        entity: &crate::storage::UnifiedEntity,
107        now_ms: u64,
108    ) -> bool {
109        let Some(metadata) = self.store.get_metadata(collection, entity.id) else {
110            return false;
111        };
112
113        Self::entity_metadata_expired_at(entity, &metadata, now_ms)
114    }
115
116    pub(crate) fn entity_metadata_expired_at(
117        entity: &crate::storage::UnifiedEntity,
118        metadata: &crate::storage::unified::metadata::Metadata,
119        now_ms: u64,
120    ) -> bool {
121        if metadata
122            .get("_expires_at")
123            .and_then(Self::metadata_u64)
124            .is_some_and(|expires_at_ms| expires_at_ms <= now_ms)
125        {
126            return true;
127        }
128
129        let ttl_ms = metadata.get("_ttl_ms").and_then(Self::metadata_u64);
130        let ttl_secs = if ttl_ms.is_none() {
131            metadata.get("_ttl").and_then(|value| {
132                Self::metadata_u64(value).and_then(|value_secs| value_secs.checked_mul(1000))
133            })
134        } else {
135            None
136        };
137
138        let Some(ttl_ms) = ttl_ms.or(ttl_secs) else {
139            return false;
140        };
141        entity
142            .created_at
143            .saturating_mul(1000)
144            .saturating_add(ttl_ms)
145            <= now_ms
146    }
147
148    fn expired_entities_by_expires_at(
149        &self,
150        now_ms: u64,
151    ) -> Result<Vec<(String, EntityId)>, Box<dyn std::error::Error>> {
152        let mut ids = self.store.filter_metadata_all(&[(
153            "_expires_at".to_string(),
154            MetadataFilter::Le(MetadataValue::Timestamp(now_ms)),
155        )]);
156
157        if let Ok(now_ms_i64) = i64::try_from(now_ms) {
158            ids.extend(self.store.filter_metadata_all(&[(
159                "_expires_at".to_string(),
160                MetadataFilter::Le(MetadataValue::Int(now_ms_i64)),
161            )]));
162        }
163
164        let now_ms_f64 = now_ms as f64;
165        if now_ms_f64.is_finite() {
166            ids.extend(self.store.filter_metadata_all(&[(
167                "_expires_at".to_string(),
168                MetadataFilter::Le(MetadataValue::Float(now_ms_f64)),
169            )]));
170        }
171
172        Ok(ids)
173    }
174
175    fn expired_entities_by_ttl(
176        &self,
177        now_ms: u64,
178    ) -> Result<Vec<(String, EntityId)>, Box<dyn std::error::Error>> {
179        let mut candidates = Vec::<(String, EntityId)>::new();
180
181        let ttl_ms_candidates = self
182            .store
183            .filter_metadata_all(&[("_ttl_ms".to_string(), MetadataFilter::IsNotNull)]);
184        candidates.extend(ttl_ms_candidates);
185
186        let ttl_candidates = self
187            .store
188            .filter_metadata_all(&[("_ttl".to_string(), MetadataFilter::IsNotNull)]);
189        candidates.extend(ttl_candidates);
190
191        if candidates.is_empty() {
192            return Ok(Vec::new());
193        }
194
195        candidates.sort_unstable();
196        candidates.dedup();
197
198        let mut expired = Vec::<(String, EntityId)>::new();
199        for (collection, entity_id) in candidates {
200            let Some(entity) = self.store.get(&collection, entity_id) else {
201                continue;
202            };
203
204            let Some(metadata) = self.store.get_metadata(&collection, entity_id) else {
205                continue;
206            };
207
208            let ttl_ms = metadata.get("_ttl_ms").and_then(Self::metadata_u64);
209            let ttl_secs = if ttl_ms.is_none() {
210                metadata.get("_ttl").and_then(|value| {
211                    Self::metadata_u64(value).and_then(|value_secs| value_secs.checked_mul(1000))
212                })
213            } else {
214                None
215            };
216
217            let Some(ttl_ms) = ttl_ms.or(ttl_secs) else {
218                continue;
219            };
220
221            let created_at_ms = entity.created_at.saturating_mul(1000);
222            let expiry_ms = created_at_ms.saturating_add(ttl_ms);
223            if expiry_ms <= now_ms {
224                expired.push((collection, entity_id));
225            }
226        }
227
228        Ok(expired)
229    }
230
231    fn metadata_u64(value: &MetadataValue) -> Option<u64> {
232        match value {
233            MetadataValue::Int(v) if *v >= 0 => Some(*v as u64),
234            MetadataValue::Timestamp(v) => Some(*v),
235            MetadataValue::Float(v) => {
236                if !v.is_finite() || !v.is_sign_positive() || v.fract().abs() >= f64::EPSILON {
237                    return None;
238                }
239                if *v > u64::MAX as f64 {
240                    return None;
241                }
242                Some(v.trunc() as u64)
243            }
244            MetadataValue::String(v) => v.parse::<u64>().ok(),
245            _ => None,
246        }
247    }
248
249    // ========================================================================
250    // Builder Methods - Create Entities
251    // ========================================================================
252
253    /// Start building a graph node
254    ///
255    /// # Example
256    /// ```ignore
257    /// let host = db.node("hosts", "Host")
258    ///     .property("ip", "192.168.1.1")
259    ///     .save()?;
260    /// ```
261    pub fn node(&self, collection: impl Into<String>, label: impl Into<String>) -> NodeBuilder {
262        NodeBuilder::new(
263            self.store.clone(),
264            self.preprocessors.clone(),
265            collection,
266            label,
267        )
268    }
269
270    /// Start building a graph edge
271    ///
272    /// # Example
273    /// ```ignore
274    /// let edge = db.edge("connections", "CONNECTS_TO")
275    ///     .from(host_a)
276    ///     .to(host_b)
277    ///     .weight(0.95)
278    ///     .property("protocol", "TCP")
279    ///     .save()?;
280    /// ```
281    pub fn edge(&self, collection: impl Into<String>, label: impl Into<String>) -> EdgeBuilder {
282        EdgeBuilder::new(
283            self.store.clone(),
284            self.preprocessors.clone(),
285            collection,
286            label,
287        )
288    }
289
290    /// Start building a vector entry
291    ///
292    /// # Example
293    /// ```ignore
294    /// let vec = db.vector("embeddings")
295    ///     .dense(embedding)
296    ///     .content("Original text content")
297    ///     .metadata("source", "document.pdf")
298    ///     .save()?;
299    /// ```
300    pub fn vector(&self, collection: impl Into<String>) -> VectorBuilder {
301        VectorBuilder::new(self.store.clone(), self.preprocessors.clone(), collection)
302    }
303
304    /// Start building a table row
305    ///
306    /// # Example
307    /// ```ignore
308    /// let row = db.row("scans", vec![
309    ///     ("timestamp", Value::Timestamp(now)),
310    ///     ("target", Value::text("192.168.1.0/24")),
311    ///     ("findings", Value::Integer(42)),
312    /// ]).save()?;
313    /// ```
314    pub fn row(&self, table: impl Into<String>, columns: Vec<(&str, Value)>) -> RowBuilder {
315        RowBuilder::new(
316            self.store.clone(),
317            self.preprocessors.clone(),
318            table,
319            columns,
320        )
321    }
322
323    /// Start building a document
324    ///
325    /// Documents are stored as enriched table rows with a full JSON body
326    /// field and flattened top-level keys for filtering.
327    ///
328    /// # Example
329    /// ```ignore
330    /// let doc = db.doc("articles")
331    ///     .field("title", "Hello World")
332    ///     .field("views", 42)
333    ///     .metadata("source", "web")
334    ///     .save()?;
335    /// ```
336    pub fn doc(&self, collection: impl Into<String>) -> DocumentBuilder {
337        DocumentBuilder::new(self.store.clone(), self.preprocessors.clone(), collection)
338    }
339
340    /// Start building a key-value pair
341    ///
342    /// KV pairs are stored as table rows with named fields `key` and `value`.
343    ///
344    /// # Example
345    /// ```ignore
346    /// let id = db.kv("config", "theme", Value::text("dark"))
347    ///     .metadata("updated_by", "admin")
348    ///     .save()?;
349    /// ```
350    pub fn kv(
351        &self,
352        collection: impl Into<String>,
353        key: impl Into<String>,
354        value: Value,
355    ) -> KvBuilder {
356        KvBuilder::new(
357            self.store.clone(),
358            self.preprocessors.clone(),
359            collection,
360            key,
361            value,
362        )
363    }
364
365    /// Get a key-value pair by key, returning the value and entity id
366    ///
367    /// Clone only the selected value. Legacy positional rows without an
368    /// indexed key use their collection schema in the borrowed fallback scan.
369    pub fn get_kv(&self, collection: &str, key: &str) -> Option<(Value, EntityId)> {
370        let manager = self.store.get_collection(collection)?;
371        if let Some(value) = manager.project_key_entity(key, None, false, |entity| {
372            (
373                entity
374                    .data
375                    .as_row()
376                    .and_then(|row| row.get_field("value"))
377                    .cloned()
378                    .unwrap_or(Value::Null),
379                entity.id,
380            )
381        }) {
382            return Some(value);
383        }
384        let columns = manager.column_schema();
385        let key_index = columns
386            .as_ref()
387            .and_then(|names| names.iter().position(|name| name == "key"));
388        let value_index = columns
389            .as_ref()
390            .and_then(|names| names.iter().position(|name| name == "value"));
391        let mut found = None;
392        manager.for_each_entity(|entity| {
393            let EntityData::Row(row) = &entity.data else {
394                return true;
395            };
396            let legacy = row.named.is_none() && row.schema.is_none();
397            let stored_key = row.get_field("key").or_else(|| {
398                if legacy {
399                    key_index.and_then(|index| row.columns.get(index))
400                } else {
401                    None
402                }
403            });
404            if matches!(stored_key, Some(Value::Text(stored)) if stored.as_ref() == key) {
405                let value = row.get_field("value").or_else(|| {
406                    if legacy {
407                        value_index.and_then(|index| row.columns.get(index))
408                    } else {
409                        None
410                    }
411                });
412                found = Some((value.cloned().unwrap_or(Value::Null), entity.id));
413                return false;
414            }
415            true
416        });
417        found
418    }
419
420    /// Delete a key-value pair by key, returning whether it was found and removed
421    pub fn delete_kv(
422        &self,
423        collection: &str,
424        key: &str,
425    ) -> Result<bool, super::super::error::DevXError> {
426        let Some((_, id)) = self.get_kv(collection, key) else {
427            return Ok(false);
428        };
429        self.store
430            .delete(collection, id)
431            .map_err(|err| super::super::error::DevXError::Storage(format!("{err:?}")))?;
432        Ok(true)
433    }
434
435    pub(crate) fn with_initialized_metadata(self) -> Result<Self, Box<dyn std::error::Error>> {
436        if self.options.mode == StorageMode::Persistent {
437            // Load metadata without persisting (avoids blocking catalog snapshot on boot)
438            match self.load_or_bootstrap_physical_metadata(false) {
439                Ok(metadata) => {
440                    crate::reserved_fields::validate_physical_metadata_contracts(&metadata)
441                        .map_err(|err| err.to_string())?;
442                }
443                Err(error)
444                    if error
445                        .downcast_ref::<std::io::Error>()
446                        .is_some_and(crate::physical::is_collection_contract_decode_error) =>
447                {
448                    return Err(error)
449                }
450                Err(_) => {}
451            }
452            // Skip repair on boot — deferred to first explicit persist_metadata() call.
453            // This avoids the recursive catalog_model_snapshot → physical_metadata loop
454            // that caused stack overflow / 12-second hang on startup.
455        }
456        self.load_collection_ttl_defaults_from_metadata();
457        // Single-file artifact: collection contracts ride the store's binary
458        // dump, not the metadata sidecar (which is never written in this
459        // profile). Seed the contract cache from that blob so each
460        // collection's declared model survives a restart — otherwise recovery
461        // re-infers the model from the stored entities and a KV collection
462        // comes back as a table.
463        if self.options.storage_profile.deploy_profile == crate::storage::DeployProfile::Embedded
464            && self.options.storage_profile.packaging
465                == crate::storage::StoragePackaging::SingleFile
466            && !self.paged_mode
467        {
468            self.seed_contract_cache_from_store_aux()?;
469        }
470        // Issue #866 — rehydrate the hypertable chunk spine before the
471        // API opens so chunk routing / pruning / TTL work immediately
472        // after a restart.
473        self.load_hypertables_from_metadata();
474        // Pending deliveries retain their persisted lock deadlines on recovery
475        // (ADR 0020). The lifecycle lazily reclaims expired locks on delivery;
476        // deleting every pending row here would redeliver still-owned work.
477        Ok(self)
478    }
479
480    pub(crate) fn persist_metadata(&self) -> Result<(), Box<dyn std::error::Error>> {
481        if self.options.mode != StorageMode::Persistent || self.options.read_only {
482            return Ok(());
483        }
484        // Default embedded options can reopen an existing paged file. Its
485        // native headers and index artifacts still require publication.
486        if !self.paged_mode
487            && self.options.storage_profile.deploy_profile
488                == crate::storage::DeployProfile::Embedded
489            && self.options.storage_profile.packaging
490                == crate::storage::StoragePackaging::SingleFile
491        {
492            return Ok(());
493        }
494        let Some(path) = self.path() else {
495            return Ok(());
496        };
497
498        #[cfg(test)]
499        let qualification_started = std::time::Instant::now();
500        let _publication = self.store.vault_publication_lock.lock();
501        #[cfg(test)]
502        metadata_qualification_phase(&qualification_started, "publication-lock");
503        let previous = self.load_or_bootstrap_physical_metadata(false).ok();
504        #[cfg(test)]
505        metadata_qualification_phase(&qualification_started, "previous-metadata");
506        let collection_roots = self.physical_collection_roots();
507        #[cfg(test)]
508        metadata_qualification_phase(&qualification_started, "collection-roots");
509        let native_state = self.native_physical_state();
510        #[cfg(test)]
511        metadata_qualification_phase(&qualification_started, "native-state");
512        let mut indexes = native_state
513            .map(|state| self.physical_index_state_from_native_state(&state, previous.as_ref()))
514            .unwrap_or_else(|| self.physical_index_state());
515        if let Some(previous) = previous.as_ref() {
516            // Legacy paged contract edits publish the sidecar first. Do not
517            // resurrect a dropped collection from the older native registry.
518            // Preserve unrelated orphan descriptors without a removal proof.
519            let absent = |name: &str| {
520                !collection_roots.contains_key(name)
521                    && !previous
522                        .collection_contracts
523                        .iter()
524                        .any(|contract| contract.name == name)
525            };
526            let mut removed: BTreeSet<&str> = previous
527                .superblock
528                .collection_roots
529                .keys()
530                .map(String::as_str)
531                .filter(|name| absent(name))
532                .collect();
533            let mut seen = BTreeSet::new();
534            for event in previous.manifest_events.iter().rev() {
535                if seen.insert(event.collection.as_str())
536                    && matches!(&event.kind, crate::physical::ManifestEventKind::Remove)
537                    && absent(&event.collection)
538                {
539                    removed.insert(event.collection.as_str());
540                }
541            }
542            indexes.retain(|index| {
543                !index
544                    .collection
545                    .as_deref()
546                    .is_some_and(|collection| removed.contains(collection))
547            });
548        }
549        #[cfg(test)]
550        metadata_qualification_phase(&qualification_started, "index-state");
551        let mut metadata = PhysicalMetadataFile::from_state(
552            self.options.clone(),
553            self.catalog_snapshot(),
554            collection_roots,
555            indexes,
556            previous.as_ref(),
557        );
558        metadata.collection_ttl_defaults_ms = self.collection_ttl_defaults_snapshot();
559        // Issue #866 — persist the hypertable chunk spine (specs +
560        // chunk bounds / routing / TTL) onto the same metadata path as
561        // collection contracts so a restart recovers it identically.
562        metadata.hypertables = self.hypertable_registry_snapshot();
563        #[cfg(test)]
564        metadata_qualification_phase(&qualification_started, "metadata-assembled");
565        metadata.save_for_data_path(path)?;
566        #[cfg(test)]
567        metadata_qualification_phase(&qualification_started, "sidecar-save");
568        self.persist_native_physical_header(&metadata)?;
569        #[cfg(test)]
570        metadata_qualification_phase(&qualification_started, "native-header");
571        Ok(())
572    }
573
574    fn bootstrap_metadata_from_native_state(&self) -> Result<bool, Box<dyn std::error::Error>> {
575        if self.options.mode != StorageMode::Persistent || self.options.read_only {
576            return Ok(false);
577        }
578        let Some(path) = self.path() else {
579            return Ok(false);
580        };
581        let Some(native_state) = self.native_physical_state() else {
582            return Ok(false);
583        };
584        if !Self::native_state_is_bootstrap_complete(&native_state) {
585            return Ok(false);
586        }
587
588        let _publication = self.store.vault_publication_lock.lock();
589        let previous = PhysicalMetadataFile::load_for_data_path(path).ok();
590        let metadata = self.metadata_from_native_state(&native_state, previous.as_ref());
591        metadata.save_for_data_path(path)?;
592        self.persist_native_physical_header(&metadata)?;
593        Ok(true)
594    }
595
596    /// Rebuild the external physical metadata view from the native state published in the
597    /// paged database file.
598    pub fn rebuild_physical_metadata_from_native_state(
599        &self,
600    ) -> Result<bool, Box<dyn std::error::Error>> {
601        self.bootstrap_metadata_from_native_state()
602    }
603
604    pub(crate) fn native_state_is_bootstrap_complete(native_state: &NativePhysicalState) -> bool {
605        let registry_complete = native_state.registry.as_ref().map(|registry| {
606            registry.collections_complete
607                && registry.indexes_complete
608                && registry.graph_projections_complete
609                && registry.analytics_jobs_complete
610                && registry.vector_artifacts_complete
611        });
612        let recovery_complete = native_state
613            .recovery
614            .as_ref()
615            .map(|recovery| recovery.snapshots_complete && recovery.exports_complete);
616        let catalog_complete = native_state
617            .catalog
618            .as_ref()
619            .map(|catalog| catalog.collections_complete);
620
621        registry_complete == Some(true)
622            && recovery_complete == Some(true)
623            && catalog_complete == Some(true)
624    }
625
626    pub(crate) fn load_or_bootstrap_physical_metadata(
627        &self,
628        persist_bootstrapped: bool,
629    ) -> Result<PhysicalMetadataFile, Box<dyn std::error::Error>> {
630        if self.options.mode != StorageMode::Persistent {
631            return Err("physical metadata requires persistent mode".into());
632        }
633        let Some(path) = self.path() else {
634            return Err("database path is not available".into());
635        };
636        let _publication = self.store.vault_publication_lock.lock();
637        let native_state = self.native_physical_state();
638
639        let metadata = if persist_bootstrapped && !self.options.read_only {
640            PhysicalMetadataFile::load_for_data_path(path)
641        } else {
642            PhysicalMetadataFile::inspect_for_data_path(path)
643        };
644        match metadata {
645            Ok(metadata) => {
646                if let Some(native_state) = native_state.as_ref() {
647                    let inspection = Self::inspect_native_header_against_metadata(
648                        native_state.header,
649                        &metadata,
650                    );
651                    if Self::repair_policy_for_inspection(&inspection)
652                        == NativeHeaderRepairPolicy::NativeAheadOfMetadata
653                    {
654                        let bootstrapped =
655                            self.metadata_from_native_state(native_state, Some(&metadata));
656                        if persist_bootstrapped && !self.options.read_only {
657                            bootstrapped.save_for_data_path(path)?;
658                            self.persist_native_physical_header(&bootstrapped)?;
659                        }
660                        return Ok(bootstrapped);
661                    }
662                }
663                Ok(metadata)
664            }
665            Err(err) => {
666                if crate::physical::is_collection_contract_decode_error(&err) {
667                    return Err(err.into());
668                }
669                let Some(native_state) = native_state else {
670                    return Err(err.into());
671                };
672                // Accept the bootstrap when the native state is either
673                // (a) fully populated and consistent (the original
674                // contract), or (b) trivially empty — a freshly created
675                // database with no collections written yet. Without (b)
676                // a brand-new data file can never reach
677                // `readiness_for_query = true`, because the bootstrap
678                // refuses to run until the registry/catalog/recovery
679                // structures are "complete", which they never become
680                // until the bootstrap has already run once.
681                //
682                // The emptiness check is conservative: header.sequence
683                // must still be at its initial value AND all three
684                // physical state summaries must be absent. Anything
685                // else falls through to the original error so we never
686                // paper over partially corrupted files.
687                let is_fresh_empty = native_state.header.sequence == 0
688                    && native_state.registry.is_none()
689                    && native_state.catalog.is_none()
690                    && native_state.recovery.is_none();
691                if !is_fresh_empty && !Self::native_state_is_bootstrap_complete(&native_state) {
692                    return Err(err.into());
693                }
694                let metadata = self.metadata_from_native_state(&native_state, None);
695                if persist_bootstrapped && !self.options.read_only {
696                    metadata.save_for_data_path(path)?;
697                    self.persist_native_physical_header(&metadata)?;
698                }
699                Ok(metadata)
700            }
701        }
702    }
703
704    pub(crate) fn physical_metadata_preference(&self) -> Option<&'static str> {
705        let path = self.path()?;
706        let native_state = self.native_physical_state();
707        let metadata = PhysicalMetadataFile::inspect_for_data_path(path).ok();
708
709        match (metadata, native_state) {
710            (Some(metadata), Some(native_state)) => {
711                let inspection =
712                    Self::inspect_native_header_against_metadata(native_state.header, &metadata);
713                match Self::repair_policy_for_inspection(&inspection) {
714                    NativeHeaderRepairPolicy::InSync => Some("sidecar_current"),
715                    NativeHeaderRepairPolicy::RepairNativeFromMetadata => Some("sidecar_current"),
716                    NativeHeaderRepairPolicy::NativeAheadOfMetadata => Some("native_ahead"),
717                }
718            }
719            (Some(_), None) => Some("sidecar_only"),
720            (None, Some(_)) => Some("sidecar_missing_native_available"),
721            (None, None) => Some("sidecar_missing_no_native"),
722        }
723    }
724
725    fn metadata_from_native_state(
726        &self,
727        native_state: &NativePhysicalState,
728        previous: Option<&PhysicalMetadataFile>,
729    ) -> PhysicalMetadataFile {
730        let now = SystemTime::now()
731            .duration_since(UNIX_EPOCH)
732            .unwrap_or_default()
733            .as_millis();
734        let catalog = self.catalog_snapshot();
735        let catalog_name = catalog.name.clone();
736        let catalog_total_entities = catalog.total_entities;
737        let catalog_total_collections = catalog.total_collections;
738        let indexes = self.physical_index_state_from_native_state(native_state, previous);
739
740        let mut manifest =
741            crate::api::SchemaManifest::now(self.options.clone(), catalog.total_collections);
742        manifest.updated_at_unix_ms = now;
743
744        let manifest_events = native_state
745            .manifest
746            .as_ref()
747            .map(|summary| {
748                summary
749                    .recent_events
750                    .iter()
751                    .map(|event| crate::physical::ManifestEvent {
752                        collection: event.collection.clone(),
753                        object_key: event.object_key.clone(),
754                        kind: match event.kind.as_str() {
755                            "insert" => crate::physical::ManifestEventKind::Insert,
756                            "update" => crate::physical::ManifestEventKind::Update,
757                            "remove" => crate::physical::ManifestEventKind::Remove,
758                            _ => crate::physical::ManifestEventKind::Checkpoint,
759                        },
760                        block: crate::physical::BlockReference {
761                            index: event.block_index,
762                            checksum: event.block_checksum,
763                        },
764                        snapshot_min: event.snapshot_min,
765                        snapshot_max: event.snapshot_max,
766                    })
767                    .collect()
768            })
769            .unwrap_or_default();
770
771        let graph_projections = native_state
772            .registry
773            .as_ref()
774            .and_then(|registry| {
775                registry.graph_projections_complete.then(|| {
776                    registry
777                        .graph_projections
778                        .iter()
779                        .map(|projection| crate::physical::PhysicalGraphProjection {
780                            name: projection.name.clone(),
781                            created_at_unix_ms: projection.created_at_unix_ms,
782                            updated_at_unix_ms: projection.updated_at_unix_ms,
783                            state: "materialized".to_string(),
784                            source: projection.source.clone(),
785                            node_labels: projection.node_labels.clone(),
786                            node_types: projection.node_types.clone(),
787                            edge_labels: projection.edge_labels.clone(),
788                            last_materialized_sequence: projection.last_materialized_sequence,
789                        })
790                        .collect()
791                })
792            })
793            .or_else(|| previous.map(|metadata| metadata.graph_projections.clone()))
794            .unwrap_or_default();
795
796        let analytics_jobs = native_state
797            .registry
798            .as_ref()
799            .and_then(|registry| {
800                registry.analytics_jobs_complete.then(|| {
801                    registry
802                        .analytics_jobs
803                        .iter()
804                        .map(|job| crate::physical::PhysicalAnalyticsJob {
805                            id: job.id.clone(),
806                            kind: job.kind.clone(),
807                            state: job.state.clone(),
808                            projection: job.projection.clone(),
809                            created_at_unix_ms: job.created_at_unix_ms,
810                            updated_at_unix_ms: job.updated_at_unix_ms,
811                            last_run_sequence: job.last_run_sequence,
812                            metadata: job.metadata.clone(),
813                        })
814                        .collect()
815                })
816            })
817            .or_else(|| previous.map(|metadata| metadata.analytics_jobs.clone()))
818            .unwrap_or_default();
819
820        let exports = native_state
821            .recovery
822            .as_ref()
823            .and_then(|recovery| {
824                recovery.exports_complete.then(|| {
825                    recovery
826                        .exports
827                        .iter()
828                        .map(|export| crate::physical::ExportDescriptor {
829                            name: export.name.clone(),
830                            created_at_unix_ms: export.created_at_unix_ms,
831                            snapshot_id: export.snapshot_id,
832                            superblock_sequence: export.superblock_sequence,
833                            data_path: self
834                                .path()
835                                .map(|path| {
836                                    crate::physical::PhysicalMetadataFile::export_data_path_for(
837                                        path,
838                                        &export.name,
839                                    )
840                                    .display()
841                                    .to_string()
842                                })
843                                .unwrap_or_default(),
844                            metadata_path: self
845                                .path()
846                                .map(|path| {
847                                    let export_data_path =
848                                        crate::physical::PhysicalMetadataFile::export_data_path_for(
849                                            path,
850                                            &export.name,
851                                        );
852                                    crate::physical::PhysicalMetadataFile::metadata_path_for(
853                                        &export_data_path,
854                                    )
855                                    .display()
856                                    .to_string()
857                                })
858                                .unwrap_or_default(),
859                            collection_count: export.collection_count as usize,
860                            total_entities: export.total_entities as usize,
861                        })
862                        .collect()
863                })
864            })
865            .or_else(|| previous.map(|metadata| metadata.exports.clone()))
866            .unwrap_or_default();
867
868        let snapshots = native_state
869            .recovery
870            .as_ref()
871            .and_then(|recovery| {
872                recovery.snapshots_complete.then(|| {
873                    recovery
874                        .snapshots
875                        .iter()
876                        .map(|snapshot| crate::physical::SnapshotDescriptor {
877                            snapshot_id: snapshot.snapshot_id,
878                            created_at_unix_ms: snapshot.created_at_unix_ms,
879                            superblock_sequence: snapshot.superblock_sequence,
880                            collection_count: snapshot.collection_count as usize,
881                            total_entities: snapshot.total_entities as usize,
882                        })
883                        .collect()
884                })
885            })
886            .or_else(|| previous.map(|metadata| metadata.snapshots.clone()))
887            .unwrap_or_else(|| {
888                vec![crate::physical::SnapshotDescriptor {
889                    snapshot_id: native_state.header.sequence,
890                    created_at_unix_ms: now,
891                    superblock_sequence: native_state.header.sequence,
892                    collection_count: catalog_total_collections,
893                    total_entities: catalog_total_entities,
894                }]
895            });
896
897        let catalog_stats = native_state
898            .catalog
899            .as_ref()
900            .and_then(|native_catalog| {
901                native_catalog.collections_complete.then(|| {
902                    native_catalog
903                        .collections
904                        .iter()
905                        .map(|collection| {
906                            (
907                                collection.name.clone(),
908                                crate::api::CollectionStats {
909                                    entities: collection.entities as usize,
910                                    cross_refs: collection.cross_refs as usize,
911                                    segments: collection.segments as usize,
912                                },
913                            )
914                        })
915                        .collect::<BTreeMap<_, _>>()
916                })
917            })
918            .or_else(|| previous.map(|metadata| metadata.catalog.stats_by_collection.clone()))
919            .unwrap_or_else(|| catalog.stats_by_collection.clone());
920
921        PhysicalMetadataFile {
922            protocol_version: crate::physical::PHYSICAL_METADATA_PROTOCOL_VERSION.to_string(),
923            generated_at_unix_ms: now,
924            last_loaded_from: Some("native_bootstrap".to_string()),
925            last_healed_at_unix_ms: Some(now),
926            manifest,
927            catalog: crate::api::CatalogSnapshot {
928                name: catalog_name,
929                total_entities: native_state
930                    .catalog
931                    .as_ref()
932                    .map(|summary| summary.total_entities as usize)
933                    .unwrap_or(catalog_total_entities),
934                total_collections: native_state
935                    .catalog
936                    .as_ref()
937                    .map(|summary| summary.collection_count as usize)
938                    .unwrap_or(catalog_total_collections),
939                stats_by_collection: catalog_stats,
940                updated_at: SystemTime::now(),
941            },
942            manifest_events,
943            collection_ttl_defaults_ms: previous
944                .map(|metadata| metadata.collection_ttl_defaults_ms.clone())
945                .unwrap_or_default(),
946            collection_contracts: previous
947                .map(|metadata| metadata.collection_contracts.clone())
948                .unwrap_or_default(),
949            hypertables: previous
950                .map(|metadata| metadata.hypertables.clone())
951                .unwrap_or_default(),
952            tree_definitions: previous
953                .map(|metadata| metadata.tree_definitions.clone())
954                .unwrap_or_default(),
955            indexes,
956            graph_projections,
957            analytics_jobs,
958            exports,
959            superblock: crate::physical::SuperblockHeader {
960                format_version: native_state.header.format_version,
961                sequence: native_state.header.sequence,
962                copies: crate::physical::DEFAULT_SUPERBLOCK_COPIES,
963                manifest: crate::physical::ManifestPointers {
964                    oldest: crate::physical::BlockReference {
965                        index: native_state.header.manifest_oldest_root,
966                        checksum: 0,
967                    },
968                    newest: crate::physical::BlockReference {
969                        index: native_state.header.manifest_root,
970                        checksum: 0,
971                    },
972                },
973                free_set: crate::physical::BlockReference {
974                    index: native_state.header.free_set_root,
975                    checksum: 0,
976                },
977                collection_roots: native_state.collection_roots.clone(),
978            },
979            snapshots,
980        }
981    }
982
983    pub(crate) fn reconcile_index_states_with_native_artifacts(
984        &self,
985        mut indexes: Vec<PhysicalIndexState>,
986    ) -> Vec<PhysicalIndexState> {
987        let native_state = self.native_physical_state();
988        let native_artifacts = native_state
989            .as_ref()
990            .and_then(|state| state.registry.as_ref())
991            .map(|registry| registry.vector_artifacts.as_slice())
992            .unwrap_or_default();
993        let artifact_pages = native_state
994            .as_ref()
995            .and_then(|state| state.vector_artifact_pages.as_ref());
996        for index in &mut indexes {
997            let Some(collection) = index.collection.as_deref() else {
998                continue;
999            };
1000            let Some(artifact_kind) = Self::native_artifact_kind_for_index(index.kind) else {
1001                continue;
1002            };
1003            let Some(artifact) = native_artifacts.iter().find(|artifact| {
1004                artifact.collection == collection && artifact.artifact_kind == artifact_kind
1005            }) else {
1006                index.build_state = if index.backend == "memory-budget-deferred" {
1007                    "deferred-memory-budget"
1008                } else {
1009                    "metadata-only"
1010                }
1011                .to_string();
1012                continue;
1013            };
1014            index.entries = artifact.vector_count as usize;
1015            index.estimated_memory_bytes = artifact.serialized_bytes;
1016            index.backend = format!("{}+native-artifact", index_backend_name(index.kind));
1017            index.artifact_kind = Some(artifact.artifact_kind.clone());
1018            index.artifact_checksum = Some(artifact.checksum);
1019            index.build_state = "artifact-published".to_string();
1020            if let Some(pages) = artifact_pages {
1021                index.artifact_root_page = pages
1022                    .iter()
1023                    .find(|page| {
1024                        page.collection == artifact.collection
1025                            && page.artifact_kind == artifact.artifact_kind
1026                    })
1027                    .map(|page| page.root_page);
1028            }
1029        }
1030        indexes
1031    }
1032
1033    pub(crate) fn warmup_native_vector_artifact_for_index(
1034        &self,
1035        index: &PhysicalIndexState,
1036    ) -> Result<(), String> {
1037        let Some(collection) = index.collection.as_deref() else {
1038            return Ok(());
1039        };
1040        let Some(artifact_kind) = Self::native_artifact_kind_for_index(index.kind) else {
1041            return Ok(());
1042        };
1043        self.warmup_native_vector_artifact(collection, Some(artifact_kind))?;
1044        Ok(())
1045    }
1046
1047    pub(crate) fn apply_runtime_native_artifact_to_index_state(
1048        &self,
1049        index: &mut PhysicalIndexState,
1050    ) -> Result<(), String> {
1051        let Some(collection) = index.collection.as_deref() else {
1052            return Ok(());
1053        };
1054        let Some(artifact_kind) = Self::native_artifact_kind_for_index(index.kind) else {
1055            return Ok(());
1056        };
1057        let artifact = self.inspect_native_vector_artifact(collection, Some(artifact_kind))?;
1058        index.entries = artifact
1059            .graph_edge_count
1060            .or(artifact.text_posting_count)
1061            .unwrap_or(artifact.node_count) as usize;
1062        index.estimated_memory_bytes = artifact.byte_len;
1063        index.backend = format!("{}+native-artifact", index_backend_name(index.kind));
1064        index.artifact_kind = Some(artifact.artifact_kind.clone());
1065        index.artifact_checksum = Some(artifact.checksum);
1066        index.build_state = "ready".to_string();
1067        index.artifact_root_page = self
1068            .native_vector_artifact_pages()
1069            .and_then(|pages| {
1070                pages.into_iter().find(|page| {
1071                    page.collection == artifact.collection
1072                        && page.artifact_kind == artifact.artifact_kind
1073                })
1074            })
1075            .map(|page| page.root_page);
1076        Ok(())
1077    }
1078
1079    pub(crate) fn physical_index_state_from_native_state(
1080        &self,
1081        native_state: &NativePhysicalState,
1082        previous: Option<&PhysicalMetadataFile>,
1083    ) -> Vec<PhysicalIndexState> {
1084        let mut fresh = self.physical_index_state();
1085        let Some(registry) = native_state.registry.as_ref() else {
1086            if let Some(previous) = previous {
1087                for index in &previous.indexes {
1088                    if !fresh.iter().any(|candidate| candidate.name == index.name) {
1089                        fresh.push(index.clone());
1090                    }
1091                }
1092            }
1093            return fresh;
1094        };
1095
1096        for index in &mut fresh {
1097            if let Some(native) = registry
1098                .indexes
1099                .iter()
1100                .find(|candidate| candidate.name == index.name)
1101            {
1102                index.enabled = native.enabled;
1103                index.last_refresh_ms = native.last_refresh_ms;
1104                index.backend = native.backend.clone();
1105                index.entries = native.entries as usize;
1106                index.estimated_memory_bytes = native.estimated_memory_bytes;
1107                if index.artifact_kind.is_none() {
1108                    index.artifact_kind = Self::native_artifact_kind_for_index(index.kind)
1109                        .map(|value| value.to_string());
1110                }
1111                if index.build_state == "catalog-derived" {
1112                    index.build_state = "registry-loaded".to_string();
1113                }
1114            }
1115            if index.backend == "memory-budget-deferred"
1116                || previous.is_some_and(|metadata| {
1117                    metadata.indexes.iter().any(|previous| {
1118                        previous.name == index.name && previous.backend == "memory-budget-deferred"
1119                    })
1120                })
1121            {
1122                index.backend = "memory-budget-deferred".to_string();
1123                index.build_state = "deferred-memory-budget".to_string();
1124                index.artifact_root_page = None;
1125                index.artifact_checksum = None;
1126            }
1127        }
1128
1129        for native in &registry.indexes {
1130            if fresh.iter().any(|index| index.name == native.name) {
1131                continue;
1132            }
1133            let Some(kind) = Self::index_kind_from_str(&native.kind) else {
1134                continue;
1135            };
1136            fresh.push(PhysicalIndexState {
1137                name: native.name.clone(),
1138                kind,
1139                collection: native.collection.clone(),
1140                enabled: native.enabled,
1141                entries: native.entries as usize,
1142                estimated_memory_bytes: native.estimated_memory_bytes,
1143                last_refresh_ms: native.last_refresh_ms,
1144                backend: native.backend.clone(),
1145                artifact_kind: Self::native_artifact_kind_for_index(kind)
1146                    .map(|value| value.to_string()),
1147                artifact_root_page: None,
1148                artifact_checksum: None,
1149                build_state: "registry-loaded".to_string(),
1150            });
1151        }
1152
1153        if !registry.indexes_complete {
1154            if let Some(previous) = previous {
1155                for index in &previous.indexes {
1156                    if !fresh.iter().any(|candidate| candidate.name == index.name) {
1157                        fresh.push(index.clone());
1158                    }
1159                }
1160            }
1161        }
1162
1163        fresh
1164    }
1165
1166    pub(crate) fn graph_projections_from_native_state(
1167        &self,
1168        native_state: &NativePhysicalState,
1169    ) -> Vec<PhysicalGraphProjection> {
1170        native_state
1171            .registry
1172            .as_ref()
1173            .map(|registry| {
1174                registry
1175                    .graph_projections
1176                    .iter()
1177                    .map(|projection| PhysicalGraphProjection {
1178                        name: projection.name.clone(),
1179                        created_at_unix_ms: projection.created_at_unix_ms,
1180                        updated_at_unix_ms: projection.updated_at_unix_ms,
1181                        state: "materialized".to_string(),
1182                        source: projection.source.clone(),
1183                        node_labels: projection.node_labels.clone(),
1184                        node_types: projection.node_types.clone(),
1185                        edge_labels: projection.edge_labels.clone(),
1186                        last_materialized_sequence: projection.last_materialized_sequence,
1187                    })
1188                    .collect()
1189            })
1190            .unwrap_or_default()
1191    }
1192
1193    pub(crate) fn analytics_jobs_from_native_state(
1194        &self,
1195        native_state: &NativePhysicalState,
1196    ) -> Vec<PhysicalAnalyticsJob> {
1197        native_state
1198            .registry
1199            .as_ref()
1200            .map(|registry| {
1201                registry
1202                    .analytics_jobs
1203                    .iter()
1204                    .map(|job| PhysicalAnalyticsJob {
1205                        id: job.id.clone(),
1206                        kind: job.kind.clone(),
1207                        state: job.state.clone(),
1208                        projection: job.projection.clone(),
1209                        created_at_unix_ms: job.created_at_unix_ms,
1210                        updated_at_unix_ms: job.updated_at_unix_ms,
1211                        last_run_sequence: job.last_run_sequence,
1212                        metadata: job.metadata.clone(),
1213                    })
1214                    .collect()
1215            })
1216            .unwrap_or_default()
1217    }
1218
1219    pub(crate) fn exports_from_native_state(
1220        &self,
1221        native_state: &NativePhysicalState,
1222    ) -> Vec<ExportDescriptor> {
1223        native_state
1224            .recovery
1225            .as_ref()
1226            .map(|recovery| {
1227                recovery
1228                    .exports
1229                    .iter()
1230                    .map(|export| ExportDescriptor {
1231                        name: export.name.clone(),
1232                        created_at_unix_ms: export.created_at_unix_ms,
1233                        snapshot_id: export.snapshot_id,
1234                        superblock_sequence: export.superblock_sequence,
1235                        data_path: self
1236                            .path()
1237                            .map(|path| {
1238                                crate::physical::PhysicalMetadataFile::export_data_path_for(
1239                                    path,
1240                                    &export.name,
1241                                )
1242                                .display()
1243                                .to_string()
1244                            })
1245                            .unwrap_or_default(),
1246                        metadata_path: self
1247                            .path()
1248                            .map(|path| {
1249                                let export_data_path =
1250                                    crate::physical::PhysicalMetadataFile::export_data_path_for(
1251                                        path,
1252                                        &export.name,
1253                                    );
1254                                crate::physical::PhysicalMetadataFile::metadata_path_for(
1255                                    &export_data_path,
1256                                )
1257                                .display()
1258                                .to_string()
1259                            })
1260                            .unwrap_or_default(),
1261                        collection_count: export.collection_count as usize,
1262                        total_entities: export.total_entities as usize,
1263                    })
1264                    .collect()
1265            })
1266            .unwrap_or_default()
1267    }
1268
1269    pub(crate) fn snapshots_from_native_state(
1270        &self,
1271        native_state: &NativePhysicalState,
1272    ) -> Vec<crate::physical::SnapshotDescriptor> {
1273        let snapshots: Vec<_> = native_state
1274            .recovery
1275            .as_ref()
1276            .map(|recovery| {
1277                recovery
1278                    .snapshots
1279                    .iter()
1280                    .map(|snapshot| crate::physical::SnapshotDescriptor {
1281                        snapshot_id: snapshot.snapshot_id,
1282                        created_at_unix_ms: snapshot.created_at_unix_ms,
1283                        superblock_sequence: snapshot.superblock_sequence,
1284                        collection_count: snapshot.collection_count as usize,
1285                        total_entities: snapshot.total_entities as usize,
1286                    })
1287                    .collect()
1288            })
1289            .unwrap_or_default();
1290        if !snapshots.is_empty() {
1291            return snapshots;
1292        }
1293
1294        let now = SystemTime::now()
1295            .duration_since(UNIX_EPOCH)
1296            .unwrap_or_default()
1297            .as_millis();
1298        let (collection_count, total_entities) = native_state
1299            .catalog
1300            .as_ref()
1301            .map(|catalog| {
1302                (
1303                    catalog.collection_count as usize,
1304                    catalog.total_entities as usize,
1305                )
1306            })
1307            .unwrap_or_else(|| {
1308                let catalog = self.catalog_snapshot();
1309                (catalog.total_collections, catalog.total_entities)
1310            });
1311
1312        vec![crate::physical::SnapshotDescriptor {
1313            snapshot_id: native_state.header.sequence,
1314            created_at_unix_ms: now,
1315            superblock_sequence: native_state.header.sequence,
1316            collection_count,
1317            total_entities,
1318        }]
1319    }
1320
1321    fn index_kind_from_str(value: &str) -> Option<crate::index::IndexKind> {
1322        match value {
1323            "btree" => Some(crate::index::IndexKind::BTree),
1324            "vector.hnsw" => Some(crate::index::IndexKind::VectorHnsw),
1325            "vector.inverted" => Some(crate::index::IndexKind::VectorInverted),
1326            "vector.turbo" => Some(crate::index::IndexKind::VectorTurbo),
1327            "graph.adjacency" => Some(crate::index::IndexKind::GraphAdjacency),
1328            "text.fulltext" => Some(crate::index::IndexKind::FullText),
1329            "document.pathvalue" => Some(crate::index::IndexKind::DocumentPathValue),
1330            "search.hybrid" => Some(crate::index::IndexKind::HybridSearch),
1331            _ => None,
1332        }
1333    }
1334
1335    pub(crate) fn native_artifact_kind_for_index(kind: IndexKind) -> Option<&'static str> {
1336        match kind {
1337            IndexKind::VectorHnsw => Some("hnsw"),
1338            IndexKind::VectorInverted => Some("ivf"),
1339            IndexKind::VectorTurbo => Some("turboquant"),
1340            IndexKind::GraphAdjacency => Some("graph.adjacency"),
1341            IndexKind::FullText => Some("text.fulltext"),
1342            IndexKind::DocumentPathValue => Some("document.pathvalue"),
1343            _ => None,
1344        }
1345    }
1346
1347    fn index_is_declared(&self, name: &str) -> bool {
1348        self.physical_metadata()
1349            .map(|metadata| metadata.indexes.iter().any(|index| index.name == name))
1350            .unwrap_or(false)
1351    }
1352
1353    pub(crate) fn graph_projection_is_declared(&self, name: &str) -> bool {
1354        self.physical_metadata()
1355            .map(|metadata| {
1356                metadata
1357                    .graph_projections
1358                    .iter()
1359                    .any(|projection| projection.name == name)
1360            })
1361            .unwrap_or(false)
1362    }
1363
1364    pub(crate) fn graph_projection_is_operational(&self, name: &str) -> bool {
1365        self.operational_graph_projections()
1366            .into_iter()
1367            .any(|projection| projection.name == name && projection.state == "materialized")
1368    }
1369
1370    pub(crate) fn analytics_job_id(kind: &str, projection: Option<&str>) -> String {
1371        match projection {
1372            Some(projection) => format!("{kind}::{projection}"),
1373            None => format!("{kind}::global"),
1374        }
1375    }
1376
1377    pub(crate) fn update_physical_metadata<T, F>(
1378        &self,
1379        mutator: F,
1380    ) -> Result<T, Box<dyn std::error::Error>>
1381    where
1382        F: FnOnce(&mut PhysicalMetadataFile) -> T,
1383    {
1384        if self.options.mode != StorageMode::Persistent {
1385            return Err("physical metadata operations require persistent mode".into());
1386        }
1387        if self.options.read_only {
1388            return Err("physical metadata operations are not allowed in read-only mode".into());
1389        }
1390        let Some(path) = self.path() else {
1391            return Err("database path is not available".into());
1392        };
1393
1394        // #2376: the metadata sidecar and the native header pages are
1395        // published read-modify-write. Two unserialised publishers lost each
1396        // other's collection contracts and double-freed the header pages
1397        // (a freed page came back as a B-tree page). Every publisher takes
1398        // the store's reentrant publication lock, which `persist()` already
1399        // takes, so this adds no lock-order edge.
1400        let _publication = self.store.vault_publication_lock.lock();
1401        let mut metadata = self.load_or_bootstrap_physical_metadata(true)?;
1402
1403        if metadata.indexes.is_empty() {
1404            metadata.indexes = self
1405                .native_physical_state()
1406                .map(|state| self.physical_index_state_from_native_state(&state, Some(&metadata)))
1407                .unwrap_or_else(|| self.physical_index_state());
1408        }
1409        metadata.superblock.collection_roots = self.physical_collection_roots();
1410
1411        let result = mutator(&mut metadata);
1412        metadata.save_for_data_path(path)?;
1413        self.persist_native_physical_header(&metadata)?;
1414        Ok(result)
1415    }
1416
1417    pub(crate) fn persist_native_physical_header(
1418        &self,
1419        metadata: &PhysicalMetadataFile,
1420    ) -> Result<(), Box<dyn std::error::Error>> {
1421        if !self.paged_mode {
1422            return Ok(());
1423        }
1424
1425        let _publication = self.store.vault_publication_lock.lock();
1426        let existing_page = self
1427            .store
1428            .physical_file_header()
1429            .map(|header| header.collection_roots_page)
1430            .filter(|page| *page != 0);
1431        let existing_registry_page = self
1432            .store
1433            .physical_file_header()
1434            .map(|header| header.registry_page)
1435            .filter(|page| *page != 0);
1436        let existing_recovery_page = self
1437            .store
1438            .physical_file_header()
1439            .map(|header| header.recovery_page)
1440            .filter(|page| *page != 0);
1441        let existing_catalog_page = self
1442            .store
1443            .physical_file_header()
1444            .map(|header| header.catalog_page)
1445            .filter(|page| *page != 0);
1446        let existing_metadata_state_page = self
1447            .store
1448            .physical_file_header()
1449            .map(|header| header.metadata_state_page)
1450            .filter(|page| *page != 0);
1451        let existing_vector_artifact_page = self
1452            .store
1453            .physical_file_header()
1454            .map(|header| header.vector_artifact_page)
1455            .filter(|page| *page != 0);
1456        let existing_manifest_page = self
1457            .store
1458            .physical_file_header()
1459            .map(|header| header.manifest_page)
1460            .filter(|page| *page != 0);
1461        let (manifest_page, manifest_checksum) = self.store.write_native_manifest_summary(
1462            metadata.superblock.sequence,
1463            &metadata.manifest_events,
1464            existing_manifest_page,
1465        )?;
1466        let (collection_roots_page, collection_roots_checksum) = self
1467            .store
1468            .write_native_collection_roots(&metadata.superblock.collection_roots, existing_page)?;
1469        // Build payload artifacts once, on the publication path. Observers use
1470        // the persisted summaries instead of rebuilding search indexes.
1471        let build = self.native_vector_artifact_records()?;
1472        let vector_artifact_records = build.records;
1473        let mut metadata = metadata.clone();
1474        for index in &mut metadata.indexes {
1475            let Some(kind) = Self::native_artifact_kind_for_index(index.kind) else {
1476                continue;
1477            };
1478            let Some(collection) = &index.collection else {
1479                continue;
1480            };
1481            if build.deferred_collections.contains(collection) {
1482                index.backend = "memory-budget-deferred".to_string();
1483                index.build_state = "deferred-memory-budget".to_string();
1484                index.artifact_kind = Some(kind.to_string());
1485                index.artifact_root_page = None;
1486                index.artifact_checksum = None;
1487            } else if let Some((summary, _)) =
1488                vector_artifact_records.iter().find(|(summary, _)| {
1489                    &summary.collection == collection && summary.artifact_kind == kind
1490                })
1491            {
1492                index.backend = format!("{}+native-artifact", index_backend_name(index.kind));
1493                index.build_state = "artifact-published".to_string();
1494                index.artifact_kind = Some(kind.to_string());
1495                index.artifact_checksum = Some(summary.checksum);
1496            }
1497        }
1498        let metadata = &metadata;
1499        let artifact_summaries = vector_artifact_records
1500            .iter()
1501            .map(|(summary, _)| summary.clone())
1502            .collect::<Vec<_>>();
1503        let registry_summary = self.native_registry_summary_from_metadata(
1504            metadata,
1505            &artifact_summaries,
1506            artifact_summaries.len(),
1507        );
1508        let (registry_page, registry_checksum) = self
1509            .store
1510            .write_native_registry_summary(&registry_summary, existing_registry_page)?;
1511        let recovery_summary = Self::native_recovery_summary_from_metadata(metadata);
1512        let (recovery_page, recovery_checksum) = self
1513            .store
1514            .write_native_recovery_summary(&recovery_summary, existing_recovery_page)?;
1515        let catalog_summary = Self::native_catalog_summary_from_metadata(metadata);
1516        let (catalog_page, catalog_checksum) = self
1517            .store
1518            .write_native_catalog_summary(&catalog_summary, existing_catalog_page)?;
1519        let metadata_state_summary = Self::native_metadata_state_summary_from_metadata(metadata);
1520        let (metadata_state_page, metadata_state_checksum) =
1521            self.store.write_native_metadata_state_summary(
1522                &metadata_state_summary,
1523                existing_metadata_state_page,
1524            )?;
1525        let vector_artifact_payloads = vector_artifact_records
1526            .iter()
1527            .map(|(summary, bytes)| {
1528                (
1529                    summary.collection.clone(),
1530                    summary.artifact_kind.clone(),
1531                    bytes.clone(),
1532                )
1533            })
1534            .collect::<Vec<_>>();
1535        let (vector_artifact_page, vector_artifact_checksum, _vector_artifact_pages) =
1536            self.store.write_native_vector_artifact_store(
1537                &vector_artifact_payloads,
1538                existing_vector_artifact_page,
1539            )?;
1540        let mut header = Self::native_header_from_metadata(metadata);
1541        header.manifest_page = manifest_page;
1542        header.manifest_checksum = manifest_checksum;
1543        header.collection_roots_page = collection_roots_page;
1544        header.collection_roots_checksum = collection_roots_checksum;
1545        header.registry_page = registry_page;
1546        header.registry_checksum = registry_checksum;
1547        header.recovery_page = recovery_page;
1548        header.recovery_checksum = recovery_checksum;
1549        header.catalog_page = catalog_page;
1550        header.catalog_checksum = catalog_checksum;
1551        header.metadata_state_page = metadata_state_page;
1552        header.metadata_state_checksum = metadata_state_checksum;
1553        header.vector_artifact_page = vector_artifact_page;
1554        header.vector_artifact_checksum = vector_artifact_checksum;
1555        if let Some(path) = self.path() {
1556            metadata.save_for_data_path(path)?;
1557        }
1558        self.store.update_physical_file_header(header)?;
1559        self.store.persist()?;
1560        self.store.publish_detached_native_blob_pages()?;
1561        Ok(())
1562    }
1563
1564    pub(crate) fn native_header_from_metadata(
1565        metadata: &PhysicalMetadataFile,
1566    ) -> PhysicalFileHeader {
1567        PhysicalFileHeader {
1568            format_version: metadata.superblock.format_version,
1569            sequence: metadata.superblock.sequence,
1570            manifest_oldest_root: metadata.superblock.manifest.oldest.index,
1571            manifest_root: metadata.superblock.manifest.newest.index,
1572            free_set_root: metadata.superblock.free_set.index,
1573            manifest_page: 0,
1574            manifest_checksum: 0,
1575            collection_roots_page: 0,
1576            collection_roots_checksum: 0,
1577            collection_root_count: metadata.superblock.collection_roots.len() as u32,
1578            snapshot_count: metadata.snapshots.len() as u32,
1579            index_count: metadata.indexes.len() as u32,
1580            catalog_collection_count: metadata.catalog.total_collections as u32,
1581            catalog_total_entities: metadata.catalog.total_entities as u64,
1582            export_count: metadata.exports.len() as u32,
1583            graph_projection_count: metadata.graph_projections.len() as u32,
1584            analytics_job_count: metadata.analytics_jobs.len() as u32,
1585            manifest_event_count: metadata.manifest_events.len() as u32,
1586            registry_page: 0,
1587            registry_checksum: 0,
1588            recovery_page: 0,
1589            recovery_checksum: 0,
1590            catalog_page: 0,
1591            catalog_checksum: 0,
1592            metadata_state_page: 0,
1593            metadata_state_checksum: 0,
1594            vector_artifact_page: 0,
1595            vector_artifact_checksum: 0,
1596        }
1597    }
1598}
1599
1600fn current_unix_ms() -> u64 {
1601    SystemTime::now()
1602        .duration_since(UNIX_EPOCH)
1603        .unwrap_or_default()
1604        .as_millis()
1605        .min(u128::from(u64::MAX)) as u64
1606}
1607
1608#[cfg(test)]
1609mod catalog_observation_tests {
1610    use super::*;
1611
1612    #[test]
1613    fn legacy_paged_drop_does_not_resurrect_derived_index_descriptors() {
1614        let directory = tempfile::tempdir().expect("temporary database");
1615        let path = directory.path().join("drop-descriptors.rdb");
1616        let options = RedDBOptions::persistent(&path)
1617            .with_memory_budget(64 * 1024 * 1024)
1618            .with_storage_profile(crate::StorageDeployPreset::PrimaryReplicaDev.selection())
1619            .expect("paged profile");
1620        {
1621            let runtime = crate::RedDBRuntime::with_options(options).expect("paged runtime");
1622            runtime
1623                .execute_query("CREATE TABLE retained_source (value INTEGER)")
1624                .expect("retained table");
1625            runtime
1626                .execute_query("INSERT INTO retained_source (value) VALUES (17)")
1627                .expect("retained row");
1628            runtime.checkpoint().expect("initial checkpoint");
1629        }
1630        // Managed legacy files also reopen with the default embedded options.
1631        // That path edits contracts without rebuilding every native artifact.
1632        let options = RedDBOptions::persistent(&path).with_memory_budget(64 * 1024 * 1024);
1633        {
1634            let runtime =
1635                crate::RedDBRuntime::with_options(options.clone()).expect("legacy reopen");
1636            assert!(runtime.db().is_paged());
1637            assert_eq!(
1638                runtime.db().options().storage_profile.deploy_profile,
1639                crate::storage::DeployProfile::Embedded
1640            );
1641            // Exercise both distinct names and recreation after a removal proof.
1642            for cycle in 0..6 {
1643                let table = format!("discarded_source_{}", cycle % 4);
1644                runtime
1645                    .execute_query(&format!("CREATE TABLE {table} (value INTEGER)"))
1646                    .expect("temporary table");
1647                runtime.checkpoint().expect("publish derived descriptors");
1648                let before = runtime.db().physical_metadata().expect("physical metadata");
1649                assert!(before
1650                    .indexes
1651                    .iter()
1652                    .any(|index| index.collection.as_deref() == Some(table.as_str())));
1653                runtime
1654                    .execute_query(&format!("DROP TABLE {table}"))
1655                    .expect("drop temporary table");
1656                for _ in 0..2 {
1657                    runtime.checkpoint().expect("checkpoint after drop");
1658                    let metadata = runtime.db().physical_metadata().expect("physical metadata");
1659                    assert!(
1660                        !metadata.indexes.iter().any(|index| index
1661                            .collection
1662                            .as_deref()
1663                            .is_some_and(|name| name.starts_with("discarded_source_"))),
1664                        "a dropped collection's derived descriptors were resurrected"
1665                    );
1666                    let native = runtime
1667                        .db()
1668                        .native_registry_summary()
1669                        .expect("native registry");
1670                    assert!(!native.indexes.iter().any(|index| index
1671                        .collection
1672                        .as_deref()
1673                        .is_some_and(|name| name.starts_with("discarded_source_"))));
1674                }
1675                if cycle == 0 {
1676                    // Model the consistent sidecar/native pair left by an older
1677                    // writer. Its final Remove event is a durable removal proof.
1678                    let ghost = before
1679                        .indexes
1680                        .iter()
1681                        .find(|index| index.collection.as_deref() == Some(table.as_str()))
1682                        .expect("old derived descriptor")
1683                        .clone();
1684                    runtime
1685                        .db()
1686                        .update_physical_metadata(|metadata| {
1687                            assert!(metadata.manifest_events.iter().any(|event| {
1688                                event.collection == table
1689                                    && matches!(
1690                                        &event.kind,
1691                                        crate::physical::ManifestEventKind::Remove
1692                                    )
1693                            }));
1694                            metadata.indexes.push(ghost.clone());
1695                            let mut orphan = ghost.clone();
1696                            orphan.name = "audited_legacy_orphan".into();
1697                            orphan.collection = Some("audited_missing_source".into());
1698                            orphan.artifact_root_page = None;
1699                            orphan.artifact_checksum = None;
1700                            metadata.indexes.push(orphan.clone());
1701                            orphan.name = "global_descriptor".into();
1702                            orphan.collection = None;
1703                            metadata.indexes.push(orphan);
1704                        })
1705                        .expect("simulate old metadata without deleting any rows");
1706                    runtime
1707                        .checkpoint()
1708                        .expect("repair a confirmed old removal");
1709                }
1710                let metadata = runtime.db().physical_metadata().expect("physical metadata");
1711                assert!(metadata
1712                    .indexes
1713                    .iter()
1714                    .any(|index| index.name == "audited_legacy_orphan"));
1715                assert!(metadata
1716                    .indexes
1717                    .iter()
1718                    .any(|index| index.name == "global_descriptor"));
1719            }
1720        }
1721        let runtime = crate::RedDBRuntime::with_options(options).expect("second legacy reopen");
1722        assert_eq!(
1723            runtime
1724                .db()
1725                .store()
1726                .get_collection("retained_source")
1727                .expect("retained source")
1728                .count(),
1729            1
1730        );
1731        let metadata = runtime.db().physical_metadata().expect("physical metadata");
1732        assert!(!metadata.indexes.iter().any(|index| index
1733            .collection
1734            .as_deref()
1735            .is_some_and(|name| name.starts_with("discarded_source_"))));
1736        assert!(metadata
1737            .indexes
1738            .iter()
1739            .any(|index| index.collection.as_deref() == Some("retained_source")));
1740        assert!(metadata
1741            .indexes
1742            .iter()
1743            .any(|index| index.name == "audited_legacy_orphan"));
1744        assert!(metadata
1745            .indexes
1746            .iter()
1747            .any(|index| index.name == "global_descriptor"));
1748    }
1749
1750    #[test]
1751    fn index_reconciliation_reads_one_native_observation() {
1752        let directory = tempfile::tempdir().expect("temporary database");
1753        let runtime = crate::RedDBRuntime::with_options(
1754            RedDBOptions::persistent(directory.path().join("catalog.rdb"))
1755                .with_storage_profile(crate::StorageDeployPreset::Serverless.selection())
1756                .expect("paged profile")
1757                .with_capability(Capability::FullText),
1758        )
1759        .expect("runtime");
1760        for number in 0..4 {
1761            runtime
1762                .execute_query(&format!("CREATE TABLE source_{number} (payload TEXT)"))
1763                .expect("table");
1764            runtime
1765                .execute_query(&format!(
1766                    "INSERT INTO source_{number} (payload) VALUES ('searchable payload')"
1767                ))
1768                .expect("row");
1769        }
1770        runtime.checkpoint().expect("published artifacts");
1771        let db = runtime.db();
1772        let pager = db.store.pager().expect("paged store");
1773        let reads = || {
1774            let stats = pager.cache_stats();
1775            stats.hits + stats.misses
1776        };
1777        let before = reads();
1778        let native = db.native_physical_state().expect("native state");
1779        let observation_reads = reads() - before;
1780        let artifacts = native.registry.expect("registry").vector_artifacts;
1781        let pages = native.vector_artifact_pages.expect("artifact pages");
1782        let indexes: Vec<_> = artifacts
1783            .iter()
1784            .filter(|artifact| artifact.artifact_kind == "text.fulltext")
1785            .map(|artifact| PhysicalIndexState {
1786                name: format!("{}::fulltext", artifact.collection),
1787                kind: IndexKind::FullText,
1788                collection: Some(artifact.collection.clone()),
1789                enabled: true,
1790                entries: 0,
1791                estimated_memory_bytes: 0,
1792                last_refresh_ms: None,
1793                backend: "catalog-derived".into(),
1794                artifact_kind: None,
1795                artifact_root_page: None,
1796                artifact_checksum: None,
1797                build_state: "catalog-derived".into(),
1798            })
1799            .collect();
1800        assert!(indexes.len() >= 4, "fixture must publish all four indexes");
1801        let declarations = indexes.clone();
1802        let before = reads();
1803        let indexes = db.reconcile_index_states_with_native_artifacts(indexes);
1804        let reconciliation_reads = reads() - before;
1805        for index in indexes {
1806            let artifact = artifacts
1807                .iter()
1808                .find(|artifact| {
1809                    Some(&artifact.collection) == index.collection.as_ref()
1810                        && artifact.artifact_kind == "text.fulltext"
1811                })
1812                .expect("published artifact");
1813            let page = pages
1814                .iter()
1815                .find(|page| {
1816                    page.collection == artifact.collection
1817                        && page.artifact_kind == artifact.artifact_kind
1818                })
1819                .expect("artifact root");
1820            assert_eq!(index.artifact_root_page, Some(page.root_page));
1821            assert_eq!(index.artifact_checksum, Some(artifact.checksum));
1822            assert_eq!(index.entries, artifact.vector_count as usize);
1823            assert_eq!(index.build_state, "artifact-published");
1824        }
1825        assert_eq!(
1826            reconciliation_reads, observation_reads,
1827            "catalog reconciliation must not reread artifact pages per index"
1828        );
1829
1830        // An undecodable artifact directory must retain the existing fallback,
1831        // not reuse the successful observation from the preceding call.
1832        let page_id = native.header.vector_artifact_page;
1833        let mut page = pager.read_page(page_id).expect("artifact directory");
1834        page.as_bytes_mut()[crate::storage::engine::HEADER_SIZE..].fill(0);
1835        pager
1836            .write_page(page_id, page)
1837            .expect("corrupt artifact directory");
1838        assert!(db.native_vector_artifact_pages().is_none());
1839        let indexes = db.reconcile_index_states_with_native_artifacts(declarations);
1840        for index in indexes {
1841            assert_eq!(index.artifact_root_page, None);
1842            assert!(
1843                index.artifact_checksum.is_some(),
1844                "registry remains available"
1845            );
1846            assert_eq!(index.build_state, "artifact-published");
1847        }
1848    }
1849}