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    fn native_state_is_summary_free_legacy(native_state: &NativePhysicalState) -> bool {
627        let header = native_state.header;
628        header.manifest_page == 0
629            && header.manifest_checksum == 0
630            && header.registry_page == 0
631            && header.registry_checksum == 0
632            && header.recovery_page == 0
633            && header.recovery_checksum == 0
634            && header.catalog_page == 0
635            && header.catalog_checksum == 0
636            && header.metadata_state_page == 0
637            && header.metadata_state_checksum == 0
638            && header.vector_artifact_page == 0
639            && header.vector_artifact_checksum == 0
640            && native_state.manifest.is_none()
641            && native_state.registry.is_none()
642            && native_state.recovery.is_none()
643            && native_state.catalog.is_none()
644            && native_state.metadata_state.is_none()
645            && native_state.vector_artifact_pages.is_none()
646    }
647
648    pub(crate) fn load_or_bootstrap_physical_metadata(
649        &self,
650        persist_bootstrapped: bool,
651    ) -> Result<PhysicalMetadataFile, Box<dyn std::error::Error>> {
652        if self.options.mode != StorageMode::Persistent {
653            return Err("physical metadata requires persistent mode".into());
654        }
655        let Some(path) = self.path() else {
656            return Err("database path is not available".into());
657        };
658        let _publication = self.store.vault_publication_lock.lock();
659        let native_state = self.native_physical_state();
660
661        let metadata = if persist_bootstrapped && !self.options.read_only {
662            PhysicalMetadataFile::load_for_data_path(path)
663        } else {
664            PhysicalMetadataFile::inspect_for_data_path(path)
665        };
666        match metadata {
667            Ok(metadata) => {
668                if let Some(native_state) = native_state.as_ref() {
669                    let inspection = Self::inspect_native_header_against_metadata(
670                        native_state.header,
671                        &metadata,
672                    );
673                    if Self::repair_policy_for_inspection(&inspection)
674                        == NativeHeaderRepairPolicy::NativeAheadOfMetadata
675                    {
676                        let bootstrapped =
677                            self.metadata_from_native_state(native_state, Some(&metadata));
678                        if persist_bootstrapped && !self.options.read_only {
679                            bootstrapped.save_for_data_path(path)?;
680                            self.persist_native_physical_header(&bootstrapped)?;
681                        }
682                        return Ok(bootstrapped);
683                    }
684                }
685                Ok(metadata)
686            }
687            Err(err) => {
688                if crate::physical::is_collection_contract_decode_error(&err) {
689                    return Err(err.into());
690                }
691                let Some(native_state) = native_state else {
692                    return Err(err.into());
693                };
694                // Summary-free paged files predate native registry/catalog/recovery
695                // publication. Their catalog sequence can legitimately advance during
696                // an in-place identity migration, so sequence zero is not a format
697                // discriminator. Admit only the exact legacy shape: every summary
698                // pointer and checksum is zero. A non-zero unreadable pointer remains a
699                // partial/corrupt publication and must fail closed.
700                if !Self::native_state_is_summary_free_legacy(&native_state)
701                    && !Self::native_state_is_bootstrap_complete(&native_state)
702                {
703                    return Err(err.into());
704                }
705                let metadata = self.metadata_from_native_state(&native_state, None);
706                if persist_bootstrapped && !self.options.read_only {
707                    metadata.save_for_data_path(path)?;
708                    self.persist_native_physical_header(&metadata)?;
709                }
710                Ok(metadata)
711            }
712        }
713    }
714
715    pub(crate) fn physical_metadata_preference(&self) -> Option<&'static str> {
716        let path = self.path()?;
717        let native_state = self.native_physical_state();
718        let metadata = PhysicalMetadataFile::inspect_for_data_path(path).ok();
719
720        match (metadata, native_state) {
721            (Some(metadata), Some(native_state)) => {
722                let inspection =
723                    Self::inspect_native_header_against_metadata(native_state.header, &metadata);
724                match Self::repair_policy_for_inspection(&inspection) {
725                    NativeHeaderRepairPolicy::InSync => Some("sidecar_current"),
726                    NativeHeaderRepairPolicy::RepairNativeFromMetadata => Some("sidecar_current"),
727                    NativeHeaderRepairPolicy::NativeAheadOfMetadata => Some("native_ahead"),
728                }
729            }
730            (Some(_), None) => Some("sidecar_only"),
731            (None, Some(_)) => Some("sidecar_missing_native_available"),
732            (None, None) => Some("sidecar_missing_no_native"),
733        }
734    }
735
736    fn metadata_from_native_state(
737        &self,
738        native_state: &NativePhysicalState,
739        previous: Option<&PhysicalMetadataFile>,
740    ) -> PhysicalMetadataFile {
741        let now = SystemTime::now()
742            .duration_since(UNIX_EPOCH)
743            .unwrap_or_default()
744            .as_millis();
745        let catalog = self.catalog_snapshot();
746        let catalog_name = catalog.name.clone();
747        let catalog_total_entities = catalog.total_entities;
748        let catalog_total_collections = catalog.total_collections;
749        let indexes = self.physical_index_state_from_native_state(native_state, previous);
750
751        let mut manifest =
752            crate::api::SchemaManifest::now(self.options.clone(), catalog.total_collections);
753        manifest.updated_at_unix_ms = now;
754
755        let manifest_events = native_state
756            .manifest
757            .as_ref()
758            .map(|summary| {
759                summary
760                    .recent_events
761                    .iter()
762                    .map(|event| crate::physical::ManifestEvent {
763                        collection: event.collection.clone(),
764                        object_key: event.object_key.clone(),
765                        kind: match event.kind.as_str() {
766                            "insert" => crate::physical::ManifestEventKind::Insert,
767                            "update" => crate::physical::ManifestEventKind::Update,
768                            "remove" => crate::physical::ManifestEventKind::Remove,
769                            _ => crate::physical::ManifestEventKind::Checkpoint,
770                        },
771                        block: crate::physical::BlockReference {
772                            index: event.block_index,
773                            checksum: event.block_checksum,
774                        },
775                        snapshot_min: event.snapshot_min,
776                        snapshot_max: event.snapshot_max,
777                    })
778                    .collect()
779            })
780            .unwrap_or_default();
781
782        let graph_projections = native_state
783            .registry
784            .as_ref()
785            .and_then(|registry| {
786                registry.graph_projections_complete.then(|| {
787                    registry
788                        .graph_projections
789                        .iter()
790                        .map(|projection| crate::physical::PhysicalGraphProjection {
791                            name: projection.name.clone(),
792                            created_at_unix_ms: projection.created_at_unix_ms,
793                            updated_at_unix_ms: projection.updated_at_unix_ms,
794                            state: "materialized".to_string(),
795                            source: projection.source.clone(),
796                            node_labels: projection.node_labels.clone(),
797                            node_types: projection.node_types.clone(),
798                            edge_labels: projection.edge_labels.clone(),
799                            last_materialized_sequence: projection.last_materialized_sequence,
800                        })
801                        .collect()
802                })
803            })
804            .or_else(|| previous.map(|metadata| metadata.graph_projections.clone()))
805            .unwrap_or_default();
806
807        let analytics_jobs = native_state
808            .registry
809            .as_ref()
810            .and_then(|registry| {
811                registry.analytics_jobs_complete.then(|| {
812                    registry
813                        .analytics_jobs
814                        .iter()
815                        .map(|job| crate::physical::PhysicalAnalyticsJob {
816                            id: job.id.clone(),
817                            kind: job.kind.clone(),
818                            state: job.state.clone(),
819                            projection: job.projection.clone(),
820                            created_at_unix_ms: job.created_at_unix_ms,
821                            updated_at_unix_ms: job.updated_at_unix_ms,
822                            last_run_sequence: job.last_run_sequence,
823                            metadata: job.metadata.clone(),
824                        })
825                        .collect()
826                })
827            })
828            .or_else(|| previous.map(|metadata| metadata.analytics_jobs.clone()))
829            .unwrap_or_default();
830
831        let exports = native_state
832            .recovery
833            .as_ref()
834            .and_then(|recovery| {
835                recovery.exports_complete.then(|| {
836                    recovery
837                        .exports
838                        .iter()
839                        .map(|export| crate::physical::ExportDescriptor {
840                            name: export.name.clone(),
841                            created_at_unix_ms: export.created_at_unix_ms,
842                            snapshot_id: export.snapshot_id,
843                            superblock_sequence: export.superblock_sequence,
844                            data_path: self
845                                .path()
846                                .map(|path| {
847                                    crate::physical::PhysicalMetadataFile::export_data_path_for(
848                                        path,
849                                        &export.name,
850                                    )
851                                    .display()
852                                    .to_string()
853                                })
854                                .unwrap_or_default(),
855                            metadata_path: self
856                                .path()
857                                .map(|path| {
858                                    let export_data_path =
859                                        crate::physical::PhysicalMetadataFile::export_data_path_for(
860                                            path,
861                                            &export.name,
862                                        );
863                                    crate::physical::PhysicalMetadataFile::metadata_path_for(
864                                        &export_data_path,
865                                    )
866                                    .display()
867                                    .to_string()
868                                })
869                                .unwrap_or_default(),
870                            collection_count: export.collection_count as usize,
871                            total_entities: export.total_entities as usize,
872                        })
873                        .collect()
874                })
875            })
876            .or_else(|| previous.map(|metadata| metadata.exports.clone()))
877            .unwrap_or_default();
878
879        let snapshots = native_state
880            .recovery
881            .as_ref()
882            .and_then(|recovery| {
883                recovery.snapshots_complete.then(|| {
884                    recovery
885                        .snapshots
886                        .iter()
887                        .map(|snapshot| crate::physical::SnapshotDescriptor {
888                            snapshot_id: snapshot.snapshot_id,
889                            created_at_unix_ms: snapshot.created_at_unix_ms,
890                            superblock_sequence: snapshot.superblock_sequence,
891                            collection_count: snapshot.collection_count as usize,
892                            total_entities: snapshot.total_entities as usize,
893                        })
894                        .collect()
895                })
896            })
897            .or_else(|| previous.map(|metadata| metadata.snapshots.clone()))
898            .unwrap_or_else(|| {
899                vec![crate::physical::SnapshotDescriptor {
900                    snapshot_id: native_state.header.sequence,
901                    created_at_unix_ms: now,
902                    superblock_sequence: native_state.header.sequence,
903                    collection_count: catalog_total_collections,
904                    total_entities: catalog_total_entities,
905                }]
906            });
907
908        let catalog_stats = native_state
909            .catalog
910            .as_ref()
911            .and_then(|native_catalog| {
912                native_catalog.collections_complete.then(|| {
913                    native_catalog
914                        .collections
915                        .iter()
916                        .map(|collection| {
917                            (
918                                collection.name.clone(),
919                                crate::api::CollectionStats {
920                                    entities: collection.entities as usize,
921                                    cross_refs: collection.cross_refs as usize,
922                                    segments: collection.segments as usize,
923                                },
924                            )
925                        })
926                        .collect::<BTreeMap<_, _>>()
927                })
928            })
929            .or_else(|| previous.map(|metadata| metadata.catalog.stats_by_collection.clone()))
930            .unwrap_or_else(|| catalog.stats_by_collection.clone());
931
932        PhysicalMetadataFile {
933            protocol_version: crate::physical::PHYSICAL_METADATA_PROTOCOL_VERSION.to_string(),
934            generated_at_unix_ms: now,
935            last_loaded_from: Some("native_bootstrap".to_string()),
936            last_healed_at_unix_ms: Some(now),
937            manifest,
938            catalog: crate::api::CatalogSnapshot {
939                name: catalog_name,
940                total_entities: native_state
941                    .catalog
942                    .as_ref()
943                    .map(|summary| summary.total_entities as usize)
944                    .unwrap_or(catalog_total_entities),
945                total_collections: native_state
946                    .catalog
947                    .as_ref()
948                    .map(|summary| summary.collection_count as usize)
949                    .unwrap_or(catalog_total_collections),
950                stats_by_collection: catalog_stats,
951                updated_at: SystemTime::now(),
952            },
953            manifest_events,
954            collection_ttl_defaults_ms: previous
955                .map(|metadata| metadata.collection_ttl_defaults_ms.clone())
956                .unwrap_or_default(),
957            collection_contracts: previous
958                .map(|metadata| metadata.collection_contracts.clone())
959                .unwrap_or_default(),
960            hypertables: previous
961                .map(|metadata| metadata.hypertables.clone())
962                .unwrap_or_default(),
963            tree_definitions: previous
964                .map(|metadata| metadata.tree_definitions.clone())
965                .unwrap_or_default(),
966            indexes,
967            graph_projections,
968            analytics_jobs,
969            exports,
970            superblock: crate::physical::SuperblockHeader {
971                format_version: native_state.header.format_version,
972                sequence: native_state.header.sequence,
973                copies: crate::physical::DEFAULT_SUPERBLOCK_COPIES,
974                manifest: crate::physical::ManifestPointers {
975                    oldest: crate::physical::BlockReference {
976                        index: native_state.header.manifest_oldest_root,
977                        checksum: 0,
978                    },
979                    newest: crate::physical::BlockReference {
980                        index: native_state.header.manifest_root,
981                        checksum: 0,
982                    },
983                },
984                free_set: crate::physical::BlockReference {
985                    index: native_state.header.free_set_root,
986                    checksum: 0,
987                },
988                collection_roots: native_state.collection_roots.clone(),
989            },
990            snapshots,
991        }
992    }
993
994    pub(crate) fn reconcile_index_states_with_native_artifacts(
995        &self,
996        mut indexes: Vec<PhysicalIndexState>,
997    ) -> Vec<PhysicalIndexState> {
998        let native_state = self.native_physical_state();
999        let native_artifacts = native_state
1000            .as_ref()
1001            .and_then(|state| state.registry.as_ref())
1002            .map(|registry| registry.vector_artifacts.as_slice())
1003            .unwrap_or_default();
1004        let artifact_pages = native_state
1005            .as_ref()
1006            .and_then(|state| state.vector_artifact_pages.as_ref());
1007        for index in &mut indexes {
1008            let Some(collection) = index.collection.as_deref() else {
1009                continue;
1010            };
1011            let Some(artifact_kind) = Self::native_artifact_kind_for_index(index.kind) else {
1012                continue;
1013            };
1014            let Some(artifact) = native_artifacts.iter().find(|artifact| {
1015                artifact.collection == collection && artifact.artifact_kind == artifact_kind
1016            }) else {
1017                index.build_state = if index.backend == "memory-budget-deferred" {
1018                    "deferred-memory-budget"
1019                } else {
1020                    "metadata-only"
1021                }
1022                .to_string();
1023                continue;
1024            };
1025            index.entries = artifact.vector_count as usize;
1026            index.estimated_memory_bytes = artifact.serialized_bytes;
1027            index.backend = format!("{}+native-artifact", index_backend_name(index.kind));
1028            index.artifact_kind = Some(artifact.artifact_kind.clone());
1029            index.artifact_checksum = Some(artifact.checksum);
1030            index.build_state = "artifact-published".to_string();
1031            if let Some(pages) = artifact_pages {
1032                index.artifact_root_page = pages
1033                    .iter()
1034                    .find(|page| {
1035                        page.collection == artifact.collection
1036                            && page.artifact_kind == artifact.artifact_kind
1037                    })
1038                    .map(|page| page.root_page);
1039            }
1040        }
1041        indexes
1042    }
1043
1044    pub(crate) fn warmup_native_vector_artifact_for_index(
1045        &self,
1046        index: &PhysicalIndexState,
1047    ) -> Result<(), String> {
1048        let Some(collection) = index.collection.as_deref() else {
1049            return Ok(());
1050        };
1051        let Some(artifact_kind) = Self::native_artifact_kind_for_index(index.kind) else {
1052            return Ok(());
1053        };
1054        self.warmup_native_vector_artifact(collection, Some(artifact_kind))?;
1055        Ok(())
1056    }
1057
1058    pub(crate) fn apply_runtime_native_artifact_to_index_state(
1059        &self,
1060        index: &mut PhysicalIndexState,
1061    ) -> Result<(), String> {
1062        let Some(collection) = index.collection.as_deref() else {
1063            return Ok(());
1064        };
1065        let Some(artifact_kind) = Self::native_artifact_kind_for_index(index.kind) else {
1066            return Ok(());
1067        };
1068        let artifact = self.inspect_native_vector_artifact(collection, Some(artifact_kind))?;
1069        index.entries = artifact
1070            .graph_edge_count
1071            .or(artifact.text_posting_count)
1072            .unwrap_or(artifact.node_count) as usize;
1073        index.estimated_memory_bytes = artifact.byte_len;
1074        index.backend = format!("{}+native-artifact", index_backend_name(index.kind));
1075        index.artifact_kind = Some(artifact.artifact_kind.clone());
1076        index.artifact_checksum = Some(artifact.checksum);
1077        index.build_state = "ready".to_string();
1078        index.artifact_root_page = self
1079            .native_vector_artifact_pages()
1080            .and_then(|pages| {
1081                pages.into_iter().find(|page| {
1082                    page.collection == artifact.collection
1083                        && page.artifact_kind == artifact.artifact_kind
1084                })
1085            })
1086            .map(|page| page.root_page);
1087        Ok(())
1088    }
1089
1090    pub(crate) fn physical_index_state_from_native_state(
1091        &self,
1092        native_state: &NativePhysicalState,
1093        previous: Option<&PhysicalMetadataFile>,
1094    ) -> Vec<PhysicalIndexState> {
1095        let mut fresh = self.physical_index_state();
1096        let Some(registry) = native_state.registry.as_ref() else {
1097            if let Some(previous) = previous {
1098                for index in &previous.indexes {
1099                    if !fresh.iter().any(|candidate| candidate.name == index.name) {
1100                        fresh.push(index.clone());
1101                    }
1102                }
1103            }
1104            return fresh;
1105        };
1106
1107        for index in &mut fresh {
1108            if let Some(native) = registry
1109                .indexes
1110                .iter()
1111                .find(|candidate| candidate.name == index.name)
1112            {
1113                index.enabled = native.enabled;
1114                index.last_refresh_ms = native.last_refresh_ms;
1115                index.backend = native.backend.clone();
1116                index.entries = native.entries as usize;
1117                index.estimated_memory_bytes = native.estimated_memory_bytes;
1118                if index.artifact_kind.is_none() {
1119                    index.artifact_kind = Self::native_artifact_kind_for_index(index.kind)
1120                        .map(|value| value.to_string());
1121                }
1122                if index.build_state == "catalog-derived" {
1123                    index.build_state = "registry-loaded".to_string();
1124                }
1125            }
1126            if index.backend == "memory-budget-deferred"
1127                || previous.is_some_and(|metadata| {
1128                    metadata.indexes.iter().any(|previous| {
1129                        previous.name == index.name && previous.backend == "memory-budget-deferred"
1130                    })
1131                })
1132            {
1133                index.backend = "memory-budget-deferred".to_string();
1134                index.build_state = "deferred-memory-budget".to_string();
1135                index.artifact_root_page = None;
1136                index.artifact_checksum = None;
1137            }
1138        }
1139
1140        for native in &registry.indexes {
1141            if fresh.iter().any(|index| index.name == native.name) {
1142                continue;
1143            }
1144            let Some(kind) = Self::index_kind_from_str(&native.kind) else {
1145                continue;
1146            };
1147            fresh.push(PhysicalIndexState {
1148                name: native.name.clone(),
1149                kind,
1150                collection: native.collection.clone(),
1151                enabled: native.enabled,
1152                entries: native.entries as usize,
1153                estimated_memory_bytes: native.estimated_memory_bytes,
1154                last_refresh_ms: native.last_refresh_ms,
1155                backend: native.backend.clone(),
1156                artifact_kind: Self::native_artifact_kind_for_index(kind)
1157                    .map(|value| value.to_string()),
1158                artifact_root_page: None,
1159                artifact_checksum: None,
1160                build_state: "registry-loaded".to_string(),
1161            });
1162        }
1163
1164        if !registry.indexes_complete {
1165            if let Some(previous) = previous {
1166                for index in &previous.indexes {
1167                    if !fresh.iter().any(|candidate| candidate.name == index.name) {
1168                        fresh.push(index.clone());
1169                    }
1170                }
1171            }
1172        }
1173
1174        fresh
1175    }
1176
1177    pub(crate) fn graph_projections_from_native_state(
1178        &self,
1179        native_state: &NativePhysicalState,
1180    ) -> Vec<PhysicalGraphProjection> {
1181        native_state
1182            .registry
1183            .as_ref()
1184            .map(|registry| {
1185                registry
1186                    .graph_projections
1187                    .iter()
1188                    .map(|projection| PhysicalGraphProjection {
1189                        name: projection.name.clone(),
1190                        created_at_unix_ms: projection.created_at_unix_ms,
1191                        updated_at_unix_ms: projection.updated_at_unix_ms,
1192                        state: "materialized".to_string(),
1193                        source: projection.source.clone(),
1194                        node_labels: projection.node_labels.clone(),
1195                        node_types: projection.node_types.clone(),
1196                        edge_labels: projection.edge_labels.clone(),
1197                        last_materialized_sequence: projection.last_materialized_sequence,
1198                    })
1199                    .collect()
1200            })
1201            .unwrap_or_default()
1202    }
1203
1204    pub(crate) fn analytics_jobs_from_native_state(
1205        &self,
1206        native_state: &NativePhysicalState,
1207    ) -> Vec<PhysicalAnalyticsJob> {
1208        native_state
1209            .registry
1210            .as_ref()
1211            .map(|registry| {
1212                registry
1213                    .analytics_jobs
1214                    .iter()
1215                    .map(|job| PhysicalAnalyticsJob {
1216                        id: job.id.clone(),
1217                        kind: job.kind.clone(),
1218                        state: job.state.clone(),
1219                        projection: job.projection.clone(),
1220                        created_at_unix_ms: job.created_at_unix_ms,
1221                        updated_at_unix_ms: job.updated_at_unix_ms,
1222                        last_run_sequence: job.last_run_sequence,
1223                        metadata: job.metadata.clone(),
1224                    })
1225                    .collect()
1226            })
1227            .unwrap_or_default()
1228    }
1229
1230    pub(crate) fn exports_from_native_state(
1231        &self,
1232        native_state: &NativePhysicalState,
1233    ) -> Vec<ExportDescriptor> {
1234        native_state
1235            .recovery
1236            .as_ref()
1237            .map(|recovery| {
1238                recovery
1239                    .exports
1240                    .iter()
1241                    .map(|export| ExportDescriptor {
1242                        name: export.name.clone(),
1243                        created_at_unix_ms: export.created_at_unix_ms,
1244                        snapshot_id: export.snapshot_id,
1245                        superblock_sequence: export.superblock_sequence,
1246                        data_path: self
1247                            .path()
1248                            .map(|path| {
1249                                crate::physical::PhysicalMetadataFile::export_data_path_for(
1250                                    path,
1251                                    &export.name,
1252                                )
1253                                .display()
1254                                .to_string()
1255                            })
1256                            .unwrap_or_default(),
1257                        metadata_path: self
1258                            .path()
1259                            .map(|path| {
1260                                let export_data_path =
1261                                    crate::physical::PhysicalMetadataFile::export_data_path_for(
1262                                        path,
1263                                        &export.name,
1264                                    );
1265                                crate::physical::PhysicalMetadataFile::metadata_path_for(
1266                                    &export_data_path,
1267                                )
1268                                .display()
1269                                .to_string()
1270                            })
1271                            .unwrap_or_default(),
1272                        collection_count: export.collection_count as usize,
1273                        total_entities: export.total_entities as usize,
1274                    })
1275                    .collect()
1276            })
1277            .unwrap_or_default()
1278    }
1279
1280    pub(crate) fn snapshots_from_native_state(
1281        &self,
1282        native_state: &NativePhysicalState,
1283    ) -> Vec<crate::physical::SnapshotDescriptor> {
1284        let snapshots: Vec<_> = native_state
1285            .recovery
1286            .as_ref()
1287            .map(|recovery| {
1288                recovery
1289                    .snapshots
1290                    .iter()
1291                    .map(|snapshot| crate::physical::SnapshotDescriptor {
1292                        snapshot_id: snapshot.snapshot_id,
1293                        created_at_unix_ms: snapshot.created_at_unix_ms,
1294                        superblock_sequence: snapshot.superblock_sequence,
1295                        collection_count: snapshot.collection_count as usize,
1296                        total_entities: snapshot.total_entities as usize,
1297                    })
1298                    .collect()
1299            })
1300            .unwrap_or_default();
1301        if !snapshots.is_empty() {
1302            return snapshots;
1303        }
1304
1305        let now = SystemTime::now()
1306            .duration_since(UNIX_EPOCH)
1307            .unwrap_or_default()
1308            .as_millis();
1309        let (collection_count, total_entities) = native_state
1310            .catalog
1311            .as_ref()
1312            .map(|catalog| {
1313                (
1314                    catalog.collection_count as usize,
1315                    catalog.total_entities as usize,
1316                )
1317            })
1318            .unwrap_or_else(|| {
1319                let catalog = self.catalog_snapshot();
1320                (catalog.total_collections, catalog.total_entities)
1321            });
1322
1323        vec![crate::physical::SnapshotDescriptor {
1324            snapshot_id: native_state.header.sequence,
1325            created_at_unix_ms: now,
1326            superblock_sequence: native_state.header.sequence,
1327            collection_count,
1328            total_entities,
1329        }]
1330    }
1331
1332    fn index_kind_from_str(value: &str) -> Option<crate::index::IndexKind> {
1333        match value {
1334            "btree" => Some(crate::index::IndexKind::BTree),
1335            "vector.hnsw" => Some(crate::index::IndexKind::VectorHnsw),
1336            "vector.inverted" => Some(crate::index::IndexKind::VectorInverted),
1337            "vector.turbo" => Some(crate::index::IndexKind::VectorTurbo),
1338            "graph.adjacency" => Some(crate::index::IndexKind::GraphAdjacency),
1339            "text.fulltext" => Some(crate::index::IndexKind::FullText),
1340            "document.pathvalue" => Some(crate::index::IndexKind::DocumentPathValue),
1341            "search.hybrid" => Some(crate::index::IndexKind::HybridSearch),
1342            _ => None,
1343        }
1344    }
1345
1346    pub(crate) fn native_artifact_kind_for_index(kind: IndexKind) -> Option<&'static str> {
1347        match kind {
1348            IndexKind::VectorHnsw => Some("hnsw"),
1349            IndexKind::VectorInverted => Some("ivf"),
1350            IndexKind::VectorTurbo => Some("turboquant"),
1351            IndexKind::GraphAdjacency => Some("graph.adjacency"),
1352            IndexKind::FullText => Some("text.fulltext"),
1353            IndexKind::DocumentPathValue => Some("document.pathvalue"),
1354            _ => None,
1355        }
1356    }
1357
1358    fn index_is_declared(&self, name: &str) -> bool {
1359        self.physical_metadata()
1360            .map(|metadata| metadata.indexes.iter().any(|index| index.name == name))
1361            .unwrap_or(false)
1362    }
1363
1364    pub(crate) fn graph_projection_is_declared(&self, name: &str) -> bool {
1365        self.physical_metadata()
1366            .map(|metadata| {
1367                metadata
1368                    .graph_projections
1369                    .iter()
1370                    .any(|projection| projection.name == name)
1371            })
1372            .unwrap_or(false)
1373    }
1374
1375    pub(crate) fn graph_projection_is_operational(&self, name: &str) -> bool {
1376        self.operational_graph_projections()
1377            .into_iter()
1378            .any(|projection| projection.name == name && projection.state == "materialized")
1379    }
1380
1381    pub(crate) fn analytics_job_id(kind: &str, projection: Option<&str>) -> String {
1382        match projection {
1383            Some(projection) => format!("{kind}::{projection}"),
1384            None => format!("{kind}::global"),
1385        }
1386    }
1387
1388    pub(crate) fn update_physical_metadata<T, F>(
1389        &self,
1390        mutator: F,
1391    ) -> Result<T, Box<dyn std::error::Error>>
1392    where
1393        F: FnOnce(&mut PhysicalMetadataFile) -> T,
1394    {
1395        if self.options.mode != StorageMode::Persistent {
1396            return Err("physical metadata operations require persistent mode".into());
1397        }
1398        if self.options.read_only {
1399            return Err("physical metadata operations are not allowed in read-only mode".into());
1400        }
1401        let Some(path) = self.path() else {
1402            return Err("database path is not available".into());
1403        };
1404
1405        // #2376: the metadata sidecar and the native header pages are
1406        // published read-modify-write. Two unserialised publishers lost each
1407        // other's collection contracts and double-freed the header pages
1408        // (a freed page came back as a B-tree page). Every publisher takes
1409        // the store's reentrant publication lock, which `persist()` already
1410        // takes, so this adds no lock-order edge.
1411        let _publication = self.store.vault_publication_lock.lock();
1412        let mut metadata = self.load_or_bootstrap_physical_metadata(true)?;
1413
1414        if metadata.indexes.is_empty() {
1415            metadata.indexes = self
1416                .native_physical_state()
1417                .map(|state| self.physical_index_state_from_native_state(&state, Some(&metadata)))
1418                .unwrap_or_else(|| self.physical_index_state());
1419        }
1420        metadata.superblock.collection_roots = self.physical_collection_roots();
1421
1422        let result = mutator(&mut metadata);
1423        metadata.save_for_data_path(path)?;
1424        self.persist_native_physical_header(&metadata)?;
1425        Ok(result)
1426    }
1427
1428    pub(crate) fn persist_native_physical_header(
1429        &self,
1430        metadata: &PhysicalMetadataFile,
1431    ) -> Result<(), Box<dyn std::error::Error>> {
1432        if !self.paged_mode {
1433            return Ok(());
1434        }
1435
1436        let _publication = self.store.vault_publication_lock.lock();
1437        let existing_page = self
1438            .store
1439            .physical_file_header()
1440            .map(|header| header.collection_roots_page)
1441            .filter(|page| *page != 0);
1442        let existing_registry_page = self
1443            .store
1444            .physical_file_header()
1445            .map(|header| header.registry_page)
1446            .filter(|page| *page != 0);
1447        let existing_recovery_page = self
1448            .store
1449            .physical_file_header()
1450            .map(|header| header.recovery_page)
1451            .filter(|page| *page != 0);
1452        let existing_catalog_page = self
1453            .store
1454            .physical_file_header()
1455            .map(|header| header.catalog_page)
1456            .filter(|page| *page != 0);
1457        let existing_metadata_state_page = self
1458            .store
1459            .physical_file_header()
1460            .map(|header| header.metadata_state_page)
1461            .filter(|page| *page != 0);
1462        let existing_vector_artifact_page = self
1463            .store
1464            .physical_file_header()
1465            .map(|header| header.vector_artifact_page)
1466            .filter(|page| *page != 0);
1467        let existing_manifest_page = self
1468            .store
1469            .physical_file_header()
1470            .map(|header| header.manifest_page)
1471            .filter(|page| *page != 0);
1472        let (manifest_page, manifest_checksum) = self.store.write_native_manifest_summary(
1473            metadata.superblock.sequence,
1474            &metadata.manifest_events,
1475            existing_manifest_page,
1476        )?;
1477        let (collection_roots_page, collection_roots_checksum) = self
1478            .store
1479            .write_native_collection_roots(&metadata.superblock.collection_roots, existing_page)?;
1480        // Build payload artifacts once, on the publication path. Observers use
1481        // the persisted summaries instead of rebuilding search indexes.
1482        let build = self.native_vector_artifact_records()?;
1483        let vector_artifact_records = build.records;
1484        let mut metadata = metadata.clone();
1485        for index in &mut metadata.indexes {
1486            let Some(kind) = Self::native_artifact_kind_for_index(index.kind) else {
1487                continue;
1488            };
1489            let Some(collection) = &index.collection else {
1490                continue;
1491            };
1492            if build.deferred_collections.contains(collection) {
1493                index.backend = "memory-budget-deferred".to_string();
1494                index.build_state = "deferred-memory-budget".to_string();
1495                index.artifact_kind = Some(kind.to_string());
1496                index.artifact_root_page = None;
1497                index.artifact_checksum = None;
1498            } else if let Some((summary, _)) =
1499                vector_artifact_records.iter().find(|(summary, _)| {
1500                    &summary.collection == collection && summary.artifact_kind == kind
1501                })
1502            {
1503                index.backend = format!("{}+native-artifact", index_backend_name(index.kind));
1504                index.build_state = "artifact-published".to_string();
1505                index.artifact_kind = Some(kind.to_string());
1506                index.artifact_checksum = Some(summary.checksum);
1507            }
1508        }
1509        let metadata = &metadata;
1510        let artifact_summaries = vector_artifact_records
1511            .iter()
1512            .map(|(summary, _)| summary.clone())
1513            .collect::<Vec<_>>();
1514        let registry_summary = self.native_registry_summary_from_metadata(
1515            metadata,
1516            &artifact_summaries,
1517            artifact_summaries.len(),
1518        );
1519        let (registry_page, registry_checksum) = self
1520            .store
1521            .write_native_registry_summary(&registry_summary, existing_registry_page)?;
1522        let recovery_summary = Self::native_recovery_summary_from_metadata(metadata);
1523        let (recovery_page, recovery_checksum) = self
1524            .store
1525            .write_native_recovery_summary(&recovery_summary, existing_recovery_page)?;
1526        let catalog_summary = Self::native_catalog_summary_from_metadata(metadata);
1527        let (catalog_page, catalog_checksum) = self
1528            .store
1529            .write_native_catalog_summary(&catalog_summary, existing_catalog_page)?;
1530        let metadata_state_summary = Self::native_metadata_state_summary_from_metadata(metadata);
1531        let (metadata_state_page, metadata_state_checksum) =
1532            self.store.write_native_metadata_state_summary(
1533                &metadata_state_summary,
1534                existing_metadata_state_page,
1535            )?;
1536        let vector_artifact_payloads = vector_artifact_records
1537            .iter()
1538            .map(|(summary, bytes)| {
1539                (
1540                    summary.collection.clone(),
1541                    summary.artifact_kind.clone(),
1542                    bytes.clone(),
1543                )
1544            })
1545            .collect::<Vec<_>>();
1546        let (vector_artifact_page, vector_artifact_checksum, _vector_artifact_pages) =
1547            self.store.write_native_vector_artifact_store(
1548                &vector_artifact_payloads,
1549                existing_vector_artifact_page,
1550            )?;
1551        let mut header = Self::native_header_from_metadata(metadata);
1552        header.manifest_page = manifest_page;
1553        header.manifest_checksum = manifest_checksum;
1554        header.collection_roots_page = collection_roots_page;
1555        header.collection_roots_checksum = collection_roots_checksum;
1556        header.registry_page = registry_page;
1557        header.registry_checksum = registry_checksum;
1558        header.recovery_page = recovery_page;
1559        header.recovery_checksum = recovery_checksum;
1560        header.catalog_page = catalog_page;
1561        header.catalog_checksum = catalog_checksum;
1562        header.metadata_state_page = metadata_state_page;
1563        header.metadata_state_checksum = metadata_state_checksum;
1564        header.vector_artifact_page = vector_artifact_page;
1565        header.vector_artifact_checksum = vector_artifact_checksum;
1566        if let Some(path) = self.path() {
1567            metadata.save_for_data_path(path)?;
1568        }
1569        self.store.update_physical_file_header(header)?;
1570        self.store.persist()?;
1571        self.store.publish_detached_native_blob_pages()?;
1572        Ok(())
1573    }
1574
1575    pub(crate) fn native_header_from_metadata(
1576        metadata: &PhysicalMetadataFile,
1577    ) -> PhysicalFileHeader {
1578        PhysicalFileHeader {
1579            format_version: metadata.superblock.format_version,
1580            sequence: metadata.superblock.sequence,
1581            manifest_oldest_root: metadata.superblock.manifest.oldest.index,
1582            manifest_root: metadata.superblock.manifest.newest.index,
1583            free_set_root: metadata.superblock.free_set.index,
1584            manifest_page: 0,
1585            manifest_checksum: 0,
1586            collection_roots_page: 0,
1587            collection_roots_checksum: 0,
1588            collection_root_count: metadata.superblock.collection_roots.len() as u32,
1589            snapshot_count: metadata.snapshots.len() as u32,
1590            index_count: metadata.indexes.len() as u32,
1591            catalog_collection_count: metadata.catalog.total_collections as u32,
1592            catalog_total_entities: metadata.catalog.total_entities as u64,
1593            export_count: metadata.exports.len() as u32,
1594            graph_projection_count: metadata.graph_projections.len() as u32,
1595            analytics_job_count: metadata.analytics_jobs.len() as u32,
1596            manifest_event_count: metadata.manifest_events.len() as u32,
1597            registry_page: 0,
1598            registry_checksum: 0,
1599            recovery_page: 0,
1600            recovery_checksum: 0,
1601            catalog_page: 0,
1602            catalog_checksum: 0,
1603            metadata_state_page: 0,
1604            metadata_state_checksum: 0,
1605            vector_artifact_page: 0,
1606            vector_artifact_checksum: 0,
1607        }
1608    }
1609}
1610
1611fn current_unix_ms() -> u64 {
1612    SystemTime::now()
1613        .duration_since(UNIX_EPOCH)
1614        .unwrap_or_default()
1615        .as_millis()
1616        .min(u128::from(u64::MAX)) as u64
1617}
1618
1619#[cfg(test)]
1620mod catalog_observation_tests {
1621    use super::*;
1622
1623    #[test]
1624    fn summary_free_legacy_native_state_rebuilds_a_missing_sidecar_after_catalog_publication() {
1625        let directory = tempfile::tempdir().expect("temporary database");
1626        let path = directory.path().join("legacy-summary-free.rdb");
1627        let options = RedDBOptions::persistent(&path)
1628            .with_memory_budget(64 * 1024 * 1024)
1629            .with_storage_profile(crate::StorageDeployPreset::PrimaryReplicaDev.selection())
1630            .expect("paged profile");
1631        let runtime = crate::RedDBRuntime::with_options(options).expect("paged runtime");
1632        runtime
1633            .execute_query("CREATE TABLE retained (value INTEGER)")
1634            .expect("retained table");
1635        runtime
1636            .execute_query("INSERT INTO retained (value) VALUES (17)")
1637            .expect("retained row");
1638        runtime.checkpoint().expect("publish retained row");
1639
1640        let db = runtime.db();
1641        let store = db.store();
1642        let mut header = store.physical_file_header().expect("physical header");
1643        assert_ne!(
1644            header.collection_roots_page, 0,
1645            "fixture retains native roots"
1646        );
1647        header.sequence = header
1648            .sequence
1649            .checked_add(1)
1650            .expect("next catalog sequence");
1651        header.manifest_page = 0;
1652        header.manifest_checksum = 0;
1653        header.registry_page = 0;
1654        header.registry_checksum = 0;
1655        header.recovery_page = 0;
1656        header.recovery_checksum = 0;
1657        header.catalog_page = 0;
1658        header.catalog_checksum = 0;
1659        header.metadata_state_page = 0;
1660        header.metadata_state_checksum = 0;
1661        header.vector_artifact_page = 0;
1662        header.vector_artifact_checksum = 0;
1663        store
1664            .update_physical_file_header(header)
1665            .expect("publish legacy catalog sequence");
1666
1667        let legacy = db.native_physical_state().expect("legacy native state");
1668        assert!(RedDB::native_state_is_summary_free_legacy(&legacy));
1669        let mut unreadable_publication = legacy.clone();
1670        unreadable_publication.header.registry_page = u32::MAX;
1671        assert!(
1672            !RedDB::native_state_is_summary_free_legacy(&unreadable_publication),
1673            "a non-zero unreadable summary pointer must still fail closed"
1674        );
1675
1676        let binary = PhysicalMetadataFile::metadata_binary_path_for(&path);
1677        let json = PhysicalMetadataFile::metadata_path_for(&path);
1678        std::fs::remove_file(binary).expect("remove binary sidecar");
1679        if json.exists() {
1680            std::fs::remove_file(json).expect("remove JSON sidecar");
1681        }
1682        for journal in PhysicalMetadataFile::journal_paths_for_data_path(&path)
1683            .expect("list metadata journals")
1684        {
1685            std::fs::remove_file(journal).expect("remove metadata journal");
1686        }
1687
1688        let metadata = db
1689            .load_or_bootstrap_physical_metadata(true)
1690            .expect("summary-free legacy state must rebuild its missing sidecar");
1691        assert_eq!(
1692            metadata.superblock.collection_roots.get("retained"),
1693            db.physical_collection_roots().get("retained")
1694        );
1695        assert!(PhysicalMetadataFile::metadata_binary_path_for(&path).exists());
1696        let rows = runtime
1697            .execute_query("SELECT value FROM retained")
1698            .expect("retained row remains queryable");
1699        assert_eq!(rows.result.records.len(), 1);
1700    }
1701
1702    #[test]
1703    fn legacy_paged_drop_does_not_resurrect_derived_index_descriptors() {
1704        let directory = tempfile::tempdir().expect("temporary database");
1705        let path = directory.path().join("drop-descriptors.rdb");
1706        let options = RedDBOptions::persistent(&path)
1707            .with_memory_budget(64 * 1024 * 1024)
1708            .with_storage_profile(crate::StorageDeployPreset::PrimaryReplicaDev.selection())
1709            .expect("paged profile");
1710        {
1711            let runtime = crate::RedDBRuntime::with_options(options).expect("paged runtime");
1712            runtime
1713                .execute_query("CREATE TABLE retained_source (value INTEGER)")
1714                .expect("retained table");
1715            runtime
1716                .execute_query("INSERT INTO retained_source (value) VALUES (17)")
1717                .expect("retained row");
1718            runtime.checkpoint().expect("initial checkpoint");
1719        }
1720        // Managed legacy files also reopen with the default embedded options.
1721        // That path edits contracts without rebuilding every native artifact.
1722        let options = RedDBOptions::persistent(&path).with_memory_budget(64 * 1024 * 1024);
1723        {
1724            let runtime =
1725                crate::RedDBRuntime::with_options(options.clone()).expect("legacy reopen");
1726            assert!(runtime.db().is_paged());
1727            assert_eq!(
1728                runtime.db().options().storage_profile.deploy_profile,
1729                crate::storage::DeployProfile::Embedded
1730            );
1731            // Exercise both distinct names and recreation after a removal proof.
1732            for cycle in 0..6 {
1733                let table = format!("discarded_source_{}", cycle % 4);
1734                runtime
1735                    .execute_query(&format!("CREATE TABLE {table} (value INTEGER)"))
1736                    .expect("temporary table");
1737                runtime.checkpoint().expect("publish derived descriptors");
1738                let before = runtime.db().physical_metadata().expect("physical metadata");
1739                assert!(before
1740                    .indexes
1741                    .iter()
1742                    .any(|index| index.collection.as_deref() == Some(table.as_str())));
1743                runtime
1744                    .execute_query(&format!("DROP TABLE {table}"))
1745                    .expect("drop temporary table");
1746                for _ in 0..2 {
1747                    runtime.checkpoint().expect("checkpoint after drop");
1748                    let metadata = runtime.db().physical_metadata().expect("physical metadata");
1749                    assert!(
1750                        !metadata.indexes.iter().any(|index| index
1751                            .collection
1752                            .as_deref()
1753                            .is_some_and(|name| name.starts_with("discarded_source_"))),
1754                        "a dropped collection's derived descriptors were resurrected"
1755                    );
1756                    let native = runtime
1757                        .db()
1758                        .native_registry_summary()
1759                        .expect("native registry");
1760                    assert!(!native.indexes.iter().any(|index| {
1761                        index
1762                            .collection
1763                            .as_deref()
1764                            .is_some_and(|name| name.starts_with("discarded_source_"))
1765                    }));
1766                }
1767                if cycle == 0 {
1768                    // Model the consistent sidecar/native pair left by an older
1769                    // writer. Its final Remove event is a durable removal proof.
1770                    let ghost = before
1771                        .indexes
1772                        .iter()
1773                        .find(|index| index.collection.as_deref() == Some(table.as_str()))
1774                        .expect("old derived descriptor")
1775                        .clone();
1776                    runtime
1777                        .db()
1778                        .update_physical_metadata(|metadata| {
1779                            assert!(metadata.manifest_events.iter().any(|event| {
1780                                event.collection == table
1781                                    && matches!(
1782                                        &event.kind,
1783                                        crate::physical::ManifestEventKind::Remove
1784                                    )
1785                            }));
1786                            metadata.indexes.push(ghost.clone());
1787                            let mut orphan = ghost.clone();
1788                            orphan.name = "audited_legacy_orphan".into();
1789                            orphan.collection = Some("audited_missing_source".into());
1790                            orphan.artifact_root_page = None;
1791                            orphan.artifact_checksum = None;
1792                            metadata.indexes.push(orphan.clone());
1793                            orphan.name = "global_descriptor".into();
1794                            orphan.collection = None;
1795                            metadata.indexes.push(orphan);
1796                        })
1797                        .expect("simulate old metadata without deleting any rows");
1798                    runtime
1799                        .checkpoint()
1800                        .expect("repair a confirmed old removal");
1801                }
1802                let metadata = runtime.db().physical_metadata().expect("physical metadata");
1803                assert!(metadata
1804                    .indexes
1805                    .iter()
1806                    .any(|index| index.name == "audited_legacy_orphan"));
1807                assert!(metadata
1808                    .indexes
1809                    .iter()
1810                    .any(|index| index.name == "global_descriptor"));
1811            }
1812        }
1813        let runtime = crate::RedDBRuntime::with_options(options).expect("second legacy reopen");
1814        assert_eq!(
1815            runtime
1816                .db()
1817                .store()
1818                .get_collection("retained_source")
1819                .expect("retained source")
1820                .count(),
1821            1
1822        );
1823        let metadata = runtime.db().physical_metadata().expect("physical metadata");
1824        assert!(!metadata.indexes.iter().any(|index| {
1825            index
1826                .collection
1827                .as_deref()
1828                .is_some_and(|name| name.starts_with("discarded_source_"))
1829        }));
1830        assert!(metadata
1831            .indexes
1832            .iter()
1833            .any(|index| index.collection.as_deref() == Some("retained_source")));
1834        assert!(metadata
1835            .indexes
1836            .iter()
1837            .any(|index| index.name == "audited_legacy_orphan"));
1838        assert!(metadata
1839            .indexes
1840            .iter()
1841            .any(|index| index.name == "global_descriptor"));
1842    }
1843
1844    #[test]
1845    fn index_reconciliation_reads_one_native_observation() {
1846        let directory = tempfile::tempdir().expect("temporary database");
1847        let runtime = crate::RedDBRuntime::with_options(
1848            RedDBOptions::persistent(directory.path().join("catalog.rdb"))
1849                .with_storage_profile(crate::StorageDeployPreset::Serverless.selection())
1850                .expect("paged profile")
1851                .with_capability(Capability::FullText),
1852        )
1853        .expect("runtime");
1854        for number in 0..4 {
1855            runtime
1856                .execute_query(&format!("CREATE TABLE source_{number} (payload TEXT)"))
1857                .expect("table");
1858            runtime
1859                .execute_query(&format!(
1860                    "INSERT INTO source_{number} (payload) VALUES ('searchable payload')"
1861                ))
1862                .expect("row");
1863        }
1864        runtime.checkpoint().expect("published artifacts");
1865        let db = runtime.db();
1866        let pager = db.store.pager().expect("paged store");
1867        let reads = || {
1868            let stats = pager.cache_stats();
1869            stats.hits + stats.misses
1870        };
1871        let before = reads();
1872        let native = db.native_physical_state().expect("native state");
1873        let observation_reads = reads() - before;
1874        let artifacts = native.registry.expect("registry").vector_artifacts;
1875        let pages = native.vector_artifact_pages.expect("artifact pages");
1876        let indexes: Vec<_> = artifacts
1877            .iter()
1878            .filter(|artifact| artifact.artifact_kind == "text.fulltext")
1879            .map(|artifact| PhysicalIndexState {
1880                name: format!("{}::fulltext", artifact.collection),
1881                kind: IndexKind::FullText,
1882                collection: Some(artifact.collection.clone()),
1883                enabled: true,
1884                entries: 0,
1885                estimated_memory_bytes: 0,
1886                last_refresh_ms: None,
1887                backend: "catalog-derived".into(),
1888                artifact_kind: None,
1889                artifact_root_page: None,
1890                artifact_checksum: None,
1891                build_state: "catalog-derived".into(),
1892            })
1893            .collect();
1894        assert!(indexes.len() >= 4, "fixture must publish all four indexes");
1895        let declarations = indexes.clone();
1896        let before = reads();
1897        let indexes = db.reconcile_index_states_with_native_artifacts(indexes);
1898        let reconciliation_reads = reads() - before;
1899        for index in indexes {
1900            let artifact = artifacts
1901                .iter()
1902                .find(|artifact| {
1903                    Some(&artifact.collection) == index.collection.as_ref()
1904                        && artifact.artifact_kind == "text.fulltext"
1905                })
1906                .expect("published artifact");
1907            let page = pages
1908                .iter()
1909                .find(|page| {
1910                    page.collection == artifact.collection
1911                        && page.artifact_kind == artifact.artifact_kind
1912                })
1913                .expect("artifact root");
1914            assert_eq!(index.artifact_root_page, Some(page.root_page));
1915            assert_eq!(index.artifact_checksum, Some(artifact.checksum));
1916            assert_eq!(index.entries, artifact.vector_count as usize);
1917            assert_eq!(index.build_state, "artifact-published");
1918        }
1919        assert_eq!(
1920            reconciliation_reads, observation_reads,
1921            "catalog reconciliation must not reread artifact pages per index"
1922        );
1923
1924        // An undecodable artifact directory must retain the existing fallback,
1925        // not reuse the successful observation from the preceding call.
1926        let page_id = native.header.vector_artifact_page;
1927        let mut page = pager.read_page(page_id).expect("artifact directory");
1928        page.as_bytes_mut()[crate::storage::engine::HEADER_SIZE..].fill(0);
1929        pager
1930            .write_page(page_id, page)
1931            .expect("corrupt artifact directory");
1932        assert!(db.native_vector_artifact_pages().is_none());
1933        let indexes = db.reconcile_index_states_with_native_artifacts(declarations);
1934        for index in indexes {
1935            assert_eq!(index.artifact_root_page, None);
1936            assert!(
1937                index.artifact_checksum.is_some(),
1938                "registry remains available"
1939            );
1940            assert_eq!(index.build_state, "artifact-published");
1941        }
1942    }
1943}