Skip to main content

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

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