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