Skip to main content

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

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