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