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