Skip to main content

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

1use super::*;
2
3impl RedDB {
4    pub(crate) fn write_column_block_page(
5        &self,
6        bytes: &[u8],
7    ) -> Result<crate::storage::engine::PageLocation, Box<dyn std::error::Error>> {
8        use crate::storage::engine::PageType;
9
10        let store = self.store();
11        let Some(pager) = store.pager() else {
12            return Ok(crate::storage::engine::PageLocation::new(
13                0,
14                0,
15                bytes.len() as u32,
16            ));
17        };
18        let content_size = crate::storage::engine::page::CONTENT_SIZE;
19        let page_count = bytes.len().max(1).div_ceil(content_size);
20        let mut first_page_id = None;
21        for page_index in 0..page_count {
22            let mut page = pager.allocate_page(PageType::ColumnBlock)?;
23            let page_id = page.page_id();
24            if let Some(first) = first_page_id {
25                let expected = first + page_index as u32;
26                if page_id != expected {
27                    return Err(format!(
28                        "column block page allocation was not contiguous: got {page_id}, expected {expected}"
29                    )
30                    .into());
31                }
32            } else {
33                first_page_id = Some(page_id);
34            }
35            let start = page_index * content_size;
36            let end = (start + content_size).min(bytes.len());
37            if start < end {
38                page.content_mut()[..end - start].copy_from_slice(&bytes[start..end]);
39            }
40            pager.write_page_encrypted(page_id, page)?;
41        }
42        Ok(crate::storage::engine::PageLocation::new(
43            first_page_id.expect("page_count is at least one"),
44            0,
45            bytes.len() as u32,
46        ))
47    }
48
49    pub(crate) fn read_column_block_page(
50        &self,
51        loc: crate::storage::engine::PageLocation,
52    ) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
53        let store = self.store();
54        let Some(pager) = store.pager() else {
55            return Ok(None);
56        };
57        let content_size = crate::storage::engine::page::CONTENT_SIZE;
58        let mut remaining = loc.length as usize;
59        let mut page_id = loc.page_id;
60        let mut offset = loc.offset as usize;
61        let mut out = Vec::with_capacity(remaining);
62        while remaining > 0 {
63            let page = pager.read_page_decrypted(page_id)?;
64            if page.page_type()? != crate::storage::engine::PageType::ColumnBlock {
65                return Err(format!("page {page_id} is not a ColumnBlock page").into());
66            }
67            if offset > content_size {
68                return Err(format!(
69                    "column block page location offset out of bounds: {offset} > {content_size}"
70                )
71                .into());
72            }
73            let available = content_size - offset;
74            let take = remaining.min(available);
75            out.extend_from_slice(&page.content()[offset..offset + take]);
76            remaining -= take;
77            page_id = page_id.saturating_add(1);
78            offset = 0;
79        }
80        Ok(Some(out))
81    }
82
83    pub fn tree_definitions(&self) -> Vec<crate::physical::PhysicalTreeDefinition> {
84        self.physical_metadata()
85            .map(|metadata| metadata.tree_definitions)
86            .unwrap_or_default()
87    }
88
89    pub fn tree_definition(
90        &self,
91        collection: &str,
92        name: &str,
93    ) -> Option<crate::physical::PhysicalTreeDefinition> {
94        self.tree_definitions()
95            .into_iter()
96            .find(|definition| definition.collection == collection && definition.name == name)
97    }
98
99    pub fn save_tree_definition(
100        &self,
101        definition: crate::physical::PhysicalTreeDefinition,
102    ) -> Result<crate::physical::PhysicalTreeDefinition, Box<dyn std::error::Error>> {
103        self.update_physical_metadata(|metadata| {
104            if let Some(existing) = metadata.tree_definitions.iter_mut().find(|existing| {
105                existing.collection == definition.collection && existing.name == definition.name
106            }) {
107                *existing = definition.clone();
108            } else {
109                metadata.tree_definitions.push(definition.clone());
110            }
111            metadata.tree_definitions.sort_by(|left, right| {
112                left.collection
113                    .cmp(&right.collection)
114                    .then_with(|| left.name.cmp(&right.name))
115            });
116            definition.clone()
117        })
118    }
119
120    pub fn remove_tree_definition(
121        &self,
122        collection: &str,
123        name: &str,
124    ) -> Result<Option<crate::physical::PhysicalTreeDefinition>, Box<dyn std::error::Error>> {
125        self.update_physical_metadata(|metadata| {
126            metadata
127                .tree_definitions
128                .iter()
129                .position(|definition| {
130                    definition.collection == collection && definition.name == name
131                })
132                .map(|index| metadata.tree_definitions.remove(index))
133        })
134    }
135
136    pub fn collection_default_ttl_ms(&self, collection: &str) -> Option<u64> {
137        self.collection_ttl_defaults_ms
138            .read()
139            .ok()
140            .and_then(|defaults| defaults.get(collection).copied())
141    }
142
143    pub fn set_collection_default_ttl_ms(&self, collection: impl Into<String>, ttl_ms: u64) {
144        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
145            defaults.insert(collection.into(), ttl_ms);
146        }
147    }
148
149    pub fn clear_collection_default_ttl_ms(&self, collection: &str) {
150        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
151            defaults.remove(collection);
152        }
153    }
154
155    pub fn collection_contracts(&self) -> Vec<crate::physical::CollectionContract> {
156        self.contract_cache_map()
157            .values()
158            .map(|arc| (**arc).clone())
159            .collect()
160    }
161
162    pub fn collection_contract(
163        &self,
164        collection: &str,
165    ) -> Option<crate::physical::CollectionContract> {
166        self.contract_cache_map()
167            .get(collection)
168            .map(|arc| (**arc).clone())
169    }
170
171    /// Arc-bumping variant — callers that only need read access skip
172    /// the per-call full `CollectionContract` clone. Deep clones cost
173    /// ~200-400 ns each (vec of declared columns + string allocs);
174    /// the UPDATE fast path hits this 2-3× per mutation.
175    pub fn collection_contract_arc(
176        &self,
177        collection: &str,
178    ) -> Option<std::sync::Arc<crate::physical::CollectionContract>> {
179        self.contract_cache_map()
180            .get(collection)
181            .map(std::sync::Arc::clone)
182    }
183
184    /// Return (or lazily build) the in-memory contract map.
185    /// Reparses PhysicalMetadataFile JSON only once per invalidation.
186    /// Called 3× per insert (ensure_model, enforce_uniqueness, normalize_fields)
187    /// so the cache is load-bearing for insert throughput.
188    fn contract_cache_map(
189        &self,
190    ) -> std::sync::Arc<
191        std::collections::HashMap<String, std::sync::Arc<crate::physical::CollectionContract>>,
192    > {
193        self.collection_contract_cache.get_or_load(|| {
194            self.physical_metadata()
195                .map(|metadata| metadata.collection_contracts)
196                .unwrap_or_default()
197        })
198    }
199
200    pub(crate) fn invalidate_collection_contract_cache(&self) {
201        self.collection_contract_cache.invalidate();
202    }
203
204    /// Single-file artifact only: mirror the current contract set into the
205    /// store's auxiliary-metadata blob so it rides the binary dump. Without
206    /// this the contracts live only in the in-memory cache and a collection's
207    /// declared model (e.g. `kv`) is lost on reopen — recovery would re-infer
208    /// it from the stored entities and a KV collection would come back as a
209    /// table. No-op for other profiles, which persist contracts via the
210    /// metadata sidecar.
211    pub(crate) fn sync_single_file_contract_blob(&self) -> Result<(), Box<dyn std::error::Error>> {
212        let contracts = self.collection_contracts();
213        let bytes = crate::physical::serialize_collection_contracts(&contracts);
214        self.store().persist_aux_metadata(bytes)?;
215        Ok(())
216    }
217
218    /// Seed the in-memory contract cache from the store's auxiliary-metadata
219    /// blob (single-file artifact). Called on open so a collection's declared
220    /// model survives a restart. Does nothing when the blob is absent (older
221    /// V9 dumps) or empty.
222    pub(crate) fn seed_contract_cache_from_store_aux(&self) -> std::io::Result<()> {
223        let bytes = self.store().aux_metadata();
224        if bytes.is_empty() {
225            return Ok(());
226        }
227        let contracts = crate::physical::deserialize_collection_contracts(&bytes)?;
228        let map: std::collections::HashMap<_, _> = contracts
229            .into_iter()
230            .map(|contract| (contract.name.clone(), std::sync::Arc::new(contract)))
231            .collect();
232        // Restore the per-collection state derived from contracts (context
233        // index enablement + default TTLs) so the full collection definition —
234        // not just the declared model — survives a restart.
235        let store = self.store();
236        let index = store.context_index();
237        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
238            for contract in map.values() {
239                index.set_collection_enabled(&contract.name, contract.context_index_enabled);
240                if let Some(ttl_ms) = contract.default_ttl_ms {
241                    defaults.insert(contract.name.clone(), ttl_ms);
242                }
243            }
244        }
245        self.collection_contract_cache.replace(map);
246        Ok(())
247    }
248
249    fn update_collection_contract_metadata<T, F>(
250        &self,
251        mutator: F,
252    ) -> Result<T, Box<dyn std::error::Error>>
253    where
254        F: FnOnce(&mut PhysicalMetadataFile) -> T,
255    {
256        if !self.paged_mode
257            || self.options.storage_profile.deploy_profile
258                != crate::storage::DeployProfile::Embedded
259            || self.options.storage_profile.packaging
260                != crate::storage::StoragePackaging::SingleFile
261        {
262            return self.update_physical_metadata(mutator);
263        }
264        if self.options.read_only {
265            return Err("physical metadata operations are not allowed in read-only mode".into());
266        }
267        let Some(path) = self.path() else {
268            return Err("database path is not available".into());
269        };
270
271        // Legacy paged files keep contracts in the durable metadata sidecar,
272        // not the embedded dump's auxiliary blob or native artifact summaries.
273        // A definition edit must not rebuild every collection's search indexes.
274        // Retain publication serialization and the canonical atomic writer.
275        let _publication = self.store.vault_publication_lock.lock();
276        let mut metadata = self.load_or_bootstrap_physical_metadata(false)?;
277        let result = mutator(&mut metadata);
278        metadata.save_for_data_path(path)?;
279        Ok(result)
280    }
281
282    pub fn save_collection_contract(
283        &self,
284        contract: crate::physical::CollectionContract,
285    ) -> Result<crate::physical::CollectionContract, Box<dyn std::error::Error>> {
286        let _publication = self.store.vault_publication_lock.lock();
287        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
288            if let Some(ttl_ms) = contract.default_ttl_ms {
289                defaults.insert(contract.name.clone(), ttl_ms);
290            } else {
291                defaults.remove(&contract.name);
292            }
293        }
294
295        self.store()
296            .context_index()
297            .set_collection_enabled(&contract.name, contract.context_index_enabled);
298        if let Some(manager) = self.store().get_collection(&contract.name) {
299            let columns = contract
300                .declared_columns
301                .iter()
302                .map(|column| column.name.clone())
303                .collect();
304            manager.set_column_schema_if_empty(columns);
305        }
306
307        // Existing paged files can be opened with embedded options. Their
308        // checkpoints do not encode the binary dump's auxiliary contract blob.
309        if self.options.storage_profile.deploy_profile == crate::storage::DeployProfile::Embedded
310            && self.options.storage_profile.packaging
311                == crate::storage::StoragePackaging::SingleFile
312            && !self.paged_mode
313        {
314            self.collection_contract_cache.insert(contract.clone());
315            self.sync_single_file_contract_blob()?;
316            return Ok(contract);
317        }
318
319        self.invalidate_collection_contract_cache();
320
321        let saved = self.update_collection_contract_metadata(|metadata| {
322            if let Some(existing) = metadata
323                .collection_contracts
324                .iter_mut()
325                .find(|existing| existing.name == contract.name)
326            {
327                *existing = contract.clone();
328            } else {
329                metadata.collection_contracts.push(contract.clone());
330            }
331            metadata
332                .collection_contracts
333                .sort_by(|left, right| left.name.cmp(&right.name));
334
335            if let Some(ttl_ms) = contract.default_ttl_ms {
336                metadata
337                    .collection_ttl_defaults_ms
338                    .insert(contract.name.clone(), ttl_ms);
339            } else {
340                metadata.collection_ttl_defaults_ms.remove(&contract.name);
341            }
342
343            contract.clone()
344        })?;
345        self.invalidate_collection_contract_cache();
346        Ok(saved)
347    }
348
349    pub fn remove_collection_contract(
350        &self,
351        collection: &str,
352    ) -> Result<Option<crate::physical::CollectionContract>, Box<dyn std::error::Error>> {
353        let _publication = self.store.vault_publication_lock.lock();
354        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
355            defaults.remove(collection);
356        }
357
358        self.store()
359            .context_index()
360            .set_collection_enabled(collection, false);
361
362        if self.options.storage_profile.deploy_profile == crate::storage::DeployProfile::Embedded
363            && self.options.storage_profile.packaging
364                == crate::storage::StoragePackaging::SingleFile
365            && !self.paged_mode
366        {
367            let removed = self.collection_contract_cache.remove(collection);
368            self.sync_single_file_contract_blob()?;
369            return Ok(removed);
370        }
371
372        self.invalidate_collection_contract_cache();
373
374        let removed = self.update_collection_contract_metadata(|metadata| {
375            let removed = metadata
376                .collection_contracts
377                .iter()
378                .position(|contract| contract.name == collection)
379                .map(|index| metadata.collection_contracts.remove(index));
380            metadata.collection_ttl_defaults_ms.remove(collection);
381            metadata
382                .indexes
383                .retain(|index| index.collection.as_deref() != Some(collection));
384            removed
385        })?;
386        self.invalidate_collection_contract_cache();
387        Ok(removed)
388    }
389
390    pub(crate) fn collection_ttl_defaults_snapshot(&self) -> BTreeMap<String, u64> {
391        self.collection_ttl_defaults_ms
392            .read()
393            .map(|defaults| {
394                defaults
395                    .iter()
396                    .map(|(collection, ttl_ms)| (collection.clone(), *ttl_ms))
397                    .collect()
398            })
399            .unwrap_or_default()
400    }
401
402    pub(crate) fn load_collection_ttl_defaults_from_metadata(&self) {
403        let metadata = self.physical_metadata();
404        let defaults = metadata
405            .as_ref()
406            .map(|m| m.collection_ttl_defaults_ms.clone())
407            .unwrap_or_default();
408
409        if let Ok(mut current) = self.collection_ttl_defaults_ms.write() {
410            current.clear();
411            current.extend(defaults);
412        }
413
414        if let Some(metadata) = metadata {
415            let store = self.store();
416            let index = store.context_index();
417            for contract in &metadata.collection_contracts {
418                index.set_collection_enabled(&contract.name, contract.context_index_enabled);
419            }
420        }
421    }
422
423    /// Snapshot the live hypertable registry (specs + every chunk)
424    /// into the sidecar-serialisable form. Called from
425    /// [`persist_metadata`] so the chunk routing spine is durable on
426    /// the same metadata path as collection contracts — issue #866.
427    /// Returns an empty vec (and touches nothing) when no hypertable
428    /// has ever been declared, so non-hypertable databases pay no
429    /// persist cost.
430    pub(crate) fn hypertable_registry_snapshot(&self) -> Vec<crate::physical::PhysicalHypertable> {
431        let registry = self.hypertables();
432        if registry.is_empty() {
433            return Vec::new();
434        }
435        use std::collections::BTreeMap;
436        let mut chunks_by_table: BTreeMap<String, Vec<crate::physical::PhysicalHypertableChunk>> =
437            BTreeMap::new();
438        for chunk in registry.snapshot_chunks() {
439            chunks_by_table
440                .entry(chunk.id.hypertable.clone())
441                .or_default()
442                .push(crate::physical::PhysicalHypertableChunk {
443                    start_ns: chunk.id.start_ns,
444                    end_ns_exclusive: chunk.end_ns_exclusive,
445                    row_count: chunk.row_count,
446                    min_ts_ns: chunk.min_ts_ns,
447                    max_ts_ns: chunk.max_ts_ns,
448                    sealed: chunk.sealed,
449                    ttl_override_ns: chunk.ttl_override_ns,
450                    columnar_page: chunk.columnar_page,
451                    columnar_derived: chunk.columnar_page.is_some(),
452                });
453        }
454        registry
455            .list()
456            .into_iter()
457            .map(|spec| crate::physical::PhysicalHypertable {
458                chunks: chunks_by_table.remove(&spec.name).unwrap_or_default(),
459                name: spec.name,
460                time_column: spec.time_column,
461                chunk_interval_ns: spec.chunk_interval_ns,
462                default_ttl_ns: spec.default_ttl_ns,
463            })
464            .collect()
465    }
466
467    /// Rehydrate the hypertable registry from the physical metadata
468    /// sidecar at boot. Mirror of
469    /// [`load_collection_ttl_defaults_from_metadata`] — specs and
470    /// chunks are restored verbatim so bounds / routing / TTL are
471    /// identical to the pre-restart state (issue #866).
472    pub(crate) fn load_hypertables_from_metadata(&self) {
473        let Some(metadata) = self.physical_metadata() else {
474            return;
475        };
476        if metadata.hypertables.is_empty() {
477            return;
478        }
479        let registry = self.hypertables();
480        for hypertable in &metadata.hypertables {
481            let mut spec = crate::storage::timeseries::HypertableSpec::new(
482                hypertable.name.clone(),
483                hypertable.time_column.clone(),
484                hypertable.chunk_interval_ns,
485            );
486            if let Some(ttl) = hypertable.default_ttl_ns {
487                spec = spec.with_ttl_ns(ttl);
488            }
489            registry.register(spec);
490            for chunk in &hypertable.chunks {
491                let meta = crate::storage::timeseries::ChunkMeta {
492                    id: crate::storage::timeseries::ChunkId {
493                        hypertable: hypertable.name.clone(),
494                        start_ns: chunk.start_ns,
495                    },
496                    end_ns_exclusive: chunk.end_ns_exclusive,
497                    row_count: chunk.row_count,
498                    min_ts_ns: chunk.min_ts_ns,
499                    max_ts_ns: chunk.max_ts_ns,
500                    sealed: chunk.sealed,
501                    ttl_override_ns: chunk.ttl_override_ns,
502                    columnar_page: chunk.columnar_page,
503                };
504                let id = meta.id.clone();
505                let columnar_page = meta.columnar_page;
506                registry.restore_chunk(meta);
507                if let Some(loc) = columnar_page {
508                    if let Ok(Some(bytes)) = self.read_column_block_page(loc) {
509                        registry.restore_columnar_block(&id, bytes);
510                    }
511                }
512            }
513        }
514    }
515
516    pub fn run_maintenance(&self) -> Result<(), Box<dyn std::error::Error>> {
517        self.store.run_maintenance()?;
518        self.persist_metadata()?;
519        Ok(())
520    }
521
522    /// Path to the physical metadata sidecar, if persistent.
523    pub fn metadata_path(&self) -> Option<PathBuf> {
524        self.path
525            .as_ref()
526            .map(|path| PhysicalMetadataFile::metadata_path_for(path))
527    }
528
529    /// Inspect the current physical metadata view without publishing repairs.
530    pub fn physical_metadata(&self) -> Option<PhysicalMetadataFile> {
531        self.load_or_bootstrap_physical_metadata(false).ok()
532    }
533
534    /// Physical index registry derived for the current database state.
535    pub fn physical_indexes(&self) -> Vec<PhysicalIndexState> {
536        let indexes = self
537            .physical_metadata()
538            .map(|metadata| metadata.indexes)
539            .unwrap_or_default();
540        self.physical_indexes_from_declarations(indexes)
541    }
542
543    pub(crate) fn physical_indexes_from_declarations(
544        &self,
545        indexes: Vec<PhysicalIndexState>,
546    ) -> Vec<PhysicalIndexState> {
547        let indexes = Some(indexes)
548            .filter(|indexes| !indexes.is_empty())
549            .or_else(|| {
550                self.native_physical_state()
551                    .map(|state| self.physical_index_state_from_native_state(&state, None))
552            })
553            .unwrap_or_else(|| self.physical_index_state());
554        self.reconcile_index_states_with_native_artifacts(indexes)
555    }
556
557    /// List registered named exports from the current physical metadata view.
558    pub fn exports(&self) -> Vec<ExportDescriptor> {
559        self.physical_metadata()
560            .map(|metadata| metadata.exports)
561            .or_else(|| {
562                self.native_physical_state()
563                    .map(|state| self.exports_from_native_state(&state))
564            })
565            .unwrap_or_default()
566    }
567
568    /// List recorded snapshots from the current physical metadata view.
569    pub fn snapshots(&self) -> Vec<crate::physical::SnapshotDescriptor> {
570        if let Some(metadata) = self.physical_metadata() {
571            if !metadata.snapshots.is_empty() {
572                return metadata.snapshots;
573            }
574        }
575        self.native_physical_state()
576            .map(|state| self.snapshots_from_native_state(&state))
577            .unwrap_or_default()
578    }
579
580    /// List persisted named graph projections from the current physical metadata view.
581    pub fn graph_projections(&self) -> Vec<PhysicalGraphProjection> {
582        self.physical_metadata()
583            .map(|metadata| metadata.graph_projections)
584            .or_else(|| {
585                self.native_physical_state()
586                    .map(|state| self.graph_projections_from_native_state(&state))
587            })
588            .unwrap_or_default()
589    }
590
591    /// List graph projections declared in the catalog view.
592    pub fn declared_graph_projections(&self) -> Vec<PhysicalGraphProjection> {
593        self.catalog_model_snapshot().declared_graph_projections
594    }
595
596    /// List graph projections currently observed in the operational view.
597    pub fn operational_graph_projections(&self) -> Vec<PhysicalGraphProjection> {
598        self.graph_projections()
599            .into_iter()
600            .filter(|projection| {
601                projection.last_materialized_sequence.is_some()
602                    || matches!(projection.state.as_str(), "materialized" | "stale")
603            })
604            .collect()
605    }
606
607    /// List persisted analytics job metadata from the current physical metadata view.
608    pub fn analytics_jobs(&self) -> Vec<PhysicalAnalyticsJob> {
609        self.physical_metadata()
610            .map(|metadata| metadata.analytics_jobs)
611            .or_else(|| {
612                self.native_physical_state()
613                    .map(|state| self.analytics_jobs_from_native_state(&state))
614            })
615            .unwrap_or_default()
616    }
617
618    /// List analytics jobs declared in the catalog view.
619    pub fn declared_analytics_jobs(&self) -> Vec<PhysicalAnalyticsJob> {
620        self.catalog_model_snapshot().declared_analytics_jobs
621    }
622
623    /// List analytics jobs currently observed in the operational view.
624    pub fn operational_analytics_jobs(&self) -> Vec<PhysicalAnalyticsJob> {
625        self.analytics_jobs()
626            .into_iter()
627            .filter(|job| {
628                job.last_run_sequence.is_some()
629                    || matches!(
630                        job.state.as_str(),
631                        "running" | "completed" | "failed" | "queued" | "stale"
632                    )
633            })
634            .collect()
635    }
636
637    /// List indexes declared in the catalog view.
638    pub fn declared_indexes(&self) -> Vec<PhysicalIndexState> {
639        self.catalog_model_snapshot().declared_indexes
640    }
641
642    /// List indexes currently observed in the operational view.
643    pub fn operational_indexes(&self) -> Vec<PhysicalIndexState> {
644        self.catalog_model_snapshot().operational_indexes
645    }
646
647    /// List reconciled index status entries from the catalog snapshot.
648    pub fn index_statuses(&self) -> Vec<CatalogIndexStatus> {
649        self.catalog_model_snapshot().index_statuses
650    }
651
652    /// Resolve one index status entry from the catalog snapshot.
653    pub fn index_status(&self, name: &str) -> Option<CatalogIndexStatus> {
654        self.catalog_model_snapshot()
655            .index_statuses
656            .into_iter()
657            .find(|status| status.name == name)
658    }
659
660    /// Upsert a named graph projection in the persisted physical metadata.
661    pub fn save_graph_projection(
662        &self,
663        name: impl Into<String>,
664        node_labels: Vec<String>,
665        node_types: Vec<String>,
666        edge_labels: Vec<String>,
667        source: impl Into<String>,
668    ) -> Result<PhysicalGraphProjection, Box<dyn std::error::Error>> {
669        let name = name.into();
670        let source = source.into();
671        self.update_physical_metadata(|metadata| {
672            let now = SystemTime::now()
673                .duration_since(UNIX_EPOCH)
674                .unwrap_or_default()
675                .as_millis();
676            let projection = if let Some(existing) = metadata
677                .graph_projections
678                .iter_mut()
679                .find(|projection| projection.name == name)
680            {
681                existing.updated_at_unix_ms = now;
682                existing.state = "declared".to_string();
683                existing.source = source.clone();
684                existing.node_labels = node_labels.clone();
685                existing.node_types = node_types.clone();
686                existing.edge_labels = edge_labels.clone();
687                existing.last_materialized_sequence = None;
688                existing.clone()
689            } else {
690                let projection = PhysicalGraphProjection {
691                    name: name.clone(),
692                    created_at_unix_ms: now,
693                    updated_at_unix_ms: now,
694                    state: "declared".to_string(),
695                    source: source.clone(),
696                    node_labels: node_labels.clone(),
697                    node_types: node_types.clone(),
698                    edge_labels: edge_labels.clone(),
699                    last_materialized_sequence: None,
700                };
701                metadata.graph_projections.push(projection.clone());
702                projection
703            };
704
705            Self::mark_projection_dependent_jobs_stale(metadata, &name, now);
706
707            metadata
708                .graph_projections
709                .sort_by(|left, right| left.name.cmp(&right.name));
710            projection
711        })
712    }
713
714    /// Mark a declared graph projection as materialized in the current physical metadata.
715    pub fn materialize_graph_projection(
716        &self,
717        name: &str,
718    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
719        self.update_physical_metadata(|metadata| {
720            let now = SystemTime::now()
721                .duration_since(UNIX_EPOCH)
722                .unwrap_or_default()
723                .as_millis();
724            let idx = metadata
725                .graph_projections
726                .iter()
727                .position(|projection| projection.name == name);
728            if let Some(idx) = idx {
729                metadata.graph_projections[idx].updated_at_unix_ms = now;
730                metadata.graph_projections[idx].state = "materialized".to_string();
731                metadata.graph_projections[idx].last_materialized_sequence =
732                    Some(metadata.superblock.sequence);
733                let result = metadata.graph_projections[idx].clone();
734                Self::rearm_projection_dependent_jobs_declared(metadata, name, now);
735                return Some(result);
736            }
737            None
738        })
739    }
740
741    /// Mark a declared graph projection as materializing.
742    pub fn mark_graph_projection_materializing(
743        &self,
744        name: &str,
745    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
746        self.update_physical_metadata(|metadata| {
747            let now = SystemTime::now()
748                .duration_since(UNIX_EPOCH)
749                .unwrap_or_default()
750                .as_millis();
751            let idx = metadata
752                .graph_projections
753                .iter()
754                .position(|projection| projection.name == name);
755            if let Some(idx) = idx {
756                metadata.graph_projections[idx].updated_at_unix_ms = now;
757                metadata.graph_projections[idx].state = "materializing".to_string();
758                metadata.graph_projections[idx].last_materialized_sequence = None;
759                let result = metadata.graph_projections[idx].clone();
760                Self::mark_projection_dependent_jobs_stale(metadata, name, now);
761                return Some(result);
762            }
763            None
764        })
765    }
766
767    /// Mark a graph projection as failed.
768    pub fn fail_graph_projection(
769        &self,
770        name: &str,
771    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
772        self.update_physical_metadata(|metadata| {
773            let now = SystemTime::now()
774                .duration_since(UNIX_EPOCH)
775                .unwrap_or_default()
776                .as_millis();
777            let idx = metadata
778                .graph_projections
779                .iter()
780                .position(|projection| projection.name == name);
781            if let Some(idx) = idx {
782                metadata.graph_projections[idx].updated_at_unix_ms = now;
783                metadata.graph_projections[idx].state = "failed".to_string();
784                metadata.graph_projections[idx].last_materialized_sequence = None;
785                let result = metadata.graph_projections[idx].clone();
786                Self::mark_projection_dependent_jobs_stale(metadata, name, now);
787                return Some(result);
788            }
789            None
790        })
791    }
792
793    /// Mark a graph projection as stale while preserving any last materialized sequence.
794    pub fn mark_graph_projection_stale(
795        &self,
796        name: &str,
797    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
798        self.update_physical_metadata(|metadata| {
799            let now = SystemTime::now()
800                .duration_since(UNIX_EPOCH)
801                .unwrap_or_default()
802                .as_millis();
803            let idx = metadata
804                .graph_projections
805                .iter()
806                .position(|projection| projection.name == name);
807            if let Some(idx) = idx {
808                metadata.graph_projections[idx].updated_at_unix_ms = now;
809                metadata.graph_projections[idx].state = "stale".to_string();
810                let result = metadata.graph_projections[idx].clone();
811                Self::mark_projection_dependent_jobs_stale(metadata, name, now);
812                return Some(result);
813            }
814            None
815        })
816    }
817
818    fn mark_projection_dependent_jobs_stale(
819        metadata: &mut PhysicalMetadataFile,
820        projection_name: &str,
821        now: u128,
822    ) {
823        for job in metadata.analytics_jobs.iter_mut() {
824            if job.projection.as_deref() == Some(projection_name) && job.state != "declared" {
825                job.state = "stale".to_string();
826                job.updated_at_unix_ms = now;
827            }
828        }
829    }
830
831    fn rearm_projection_dependent_jobs_declared(
832        metadata: &mut PhysicalMetadataFile,
833        projection_name: &str,
834        now: u128,
835    ) {
836        for job in metadata.analytics_jobs.iter_mut() {
837            if job.projection.as_deref() == Some(projection_name) && job.state == "stale" {
838                job.state = "declared".to_string();
839                job.last_run_sequence = None;
840                job.updated_at_unix_ms = now;
841            }
842        }
843    }
844
845    /// Declare or update analytics job metadata without marking it as executed.
846    pub fn save_analytics_job(
847        &self,
848        kind: impl Into<String>,
849        projection: Option<String>,
850        metadata_entries: BTreeMap<String, String>,
851    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
852        let kind = kind.into();
853        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
854        if let Some(projection_name) = projection.as_deref() {
855            if !self.graph_projection_is_declared(projection_name) {
856                return Err(format!(
857                    "graph projection '{projection_name}' is not declared in physical metadata"
858                )
859                .into());
860            }
861        }
862
863        self.update_physical_metadata(|metadata| {
864            let now = SystemTime::now()
865                .duration_since(UNIX_EPOCH)
866                .unwrap_or_default()
867                .as_millis();
868
869            let job = if let Some(existing) = metadata
870                .analytics_jobs
871                .iter_mut()
872                .find(|job| job.id == job_id)
873            {
874                existing.kind = kind.clone();
875                existing.projection = projection.clone();
876                existing.updated_at_unix_ms = now;
877                existing.metadata = metadata_entries.clone();
878                if existing.last_run_sequence.is_none() {
879                    existing.state = "declared".to_string();
880                }
881                existing.clone()
882            } else {
883                let job = PhysicalAnalyticsJob {
884                    id: job_id.clone(),
885                    kind: kind.clone(),
886                    state: "declared".to_string(),
887                    projection: projection.clone(),
888                    created_at_unix_ms: now,
889                    updated_at_unix_ms: now,
890                    last_run_sequence: None,
891                    metadata: metadata_entries.clone(),
892                };
893                metadata.analytics_jobs.push(job.clone());
894                job
895            };
896
897            metadata
898                .analytics_jobs
899                .sort_by(|left, right| left.id.cmp(&right.id));
900            job
901        })
902    }
903
904    /// Record or update analytics job metadata in the persisted physical metadata.
905    pub fn record_analytics_job(
906        &self,
907        kind: impl Into<String>,
908        projection: Option<String>,
909        metadata_entries: BTreeMap<String, String>,
910    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
911        let kind = kind.into();
912        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
913        if let Some(projection_name) = projection.as_deref() {
914            if !self.graph_projection_is_declared(projection_name) {
915                return Err(format!(
916                    "graph projection '{projection_name}' is not declared in physical metadata"
917                )
918                .into());
919            }
920            if !self.graph_projection_is_operational(projection_name) {
921                return Err(format!(
922                    "graph projection '{projection_name}' is declared but not operationally materialized"
923                )
924                .into());
925            }
926        }
927
928        self.update_physical_metadata(|metadata| {
929            let now = SystemTime::now()
930                .duration_since(UNIX_EPOCH)
931                .unwrap_or_default()
932                .as_millis();
933
934            let existing = metadata
935                .analytics_jobs
936                .iter_mut()
937                .find(|job| job.id == job_id)?;
938            existing.state = "completed".to_string();
939            existing.kind = kind.clone();
940            existing.projection = projection.clone();
941            existing.updated_at_unix_ms = now;
942            existing.last_run_sequence = Some(metadata.superblock.sequence);
943            existing.metadata = metadata_entries.clone();
944            let job = existing.clone();
945
946            metadata
947                .analytics_jobs
948                .sort_by(|left, right| left.id.cmp(&right.id));
949            Some(job)
950        })
951        .and_then(|job| {
952            job.ok_or_else(|| {
953                format!("analytics job '{job_id}' is not declared in physical metadata").into()
954            })
955        })
956    }
957
958    /// Mark a declared analytics job as running.
959    pub fn queue_analytics_job(
960        &self,
961        kind: impl Into<String>,
962        projection: Option<String>,
963        metadata_entries: BTreeMap<String, String>,
964    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
965        let kind = kind.into();
966        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
967        if let Some(projection_name) = projection.as_deref() {
968            if !self.graph_projection_is_declared(projection_name) {
969                return Err(format!(
970                    "graph projection '{projection_name}' is not declared in physical metadata"
971                )
972                .into());
973            }
974            if !self.graph_projection_is_operational(projection_name) {
975                return Err(format!(
976                    "graph projection '{projection_name}' is declared but not operationally materialized"
977                )
978                .into());
979            }
980        }
981
982        self.update_physical_metadata(|metadata| {
983            let now = SystemTime::now()
984                .duration_since(UNIX_EPOCH)
985                .unwrap_or_default()
986                .as_millis();
987
988            let existing = metadata
989                .analytics_jobs
990                .iter_mut()
991                .find(|job| job.id == job_id)?;
992            existing.state = "queued".to_string();
993            existing.kind = kind.clone();
994            existing.projection = projection.clone();
995            existing.updated_at_unix_ms = now;
996            existing.metadata = metadata_entries.clone();
997            let job = existing.clone();
998
999            metadata
1000                .analytics_jobs
1001                .sort_by(|left, right| left.id.cmp(&right.id));
1002            Some(job)
1003        })
1004        .and_then(|job| {
1005            job.ok_or_else(|| {
1006                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1007            })
1008        })
1009    }
1010
1011    /// Mark a declared analytics job as running.
1012    pub fn start_analytics_job(
1013        &self,
1014        kind: impl Into<String>,
1015        projection: Option<String>,
1016        metadata_entries: BTreeMap<String, String>,
1017    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1018        let kind = kind.into();
1019        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1020        if let Some(projection_name) = projection.as_deref() {
1021            if !self.graph_projection_is_declared(projection_name) {
1022                return Err(format!(
1023                    "graph projection '{projection_name}' is not declared in physical metadata"
1024                )
1025                .into());
1026            }
1027            if !self.graph_projection_is_operational(projection_name) {
1028                return Err(format!(
1029                    "graph projection '{projection_name}' is declared but not operationally materialized"
1030                )
1031                .into());
1032            }
1033        }
1034
1035        self.update_physical_metadata(|metadata| {
1036            let now = SystemTime::now()
1037                .duration_since(UNIX_EPOCH)
1038                .unwrap_or_default()
1039                .as_millis();
1040
1041            let existing = metadata
1042                .analytics_jobs
1043                .iter_mut()
1044                .find(|job| job.id == job_id)?;
1045            existing.state = "running".to_string();
1046            existing.kind = kind.clone();
1047            existing.projection = projection.clone();
1048            existing.updated_at_unix_ms = now;
1049            existing.metadata = metadata_entries.clone();
1050            let job = existing.clone();
1051
1052            metadata
1053                .analytics_jobs
1054                .sort_by(|left, right| left.id.cmp(&right.id));
1055            Some(job)
1056        })
1057        .and_then(|job| {
1058            job.ok_or_else(|| {
1059                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1060            })
1061        })
1062    }
1063
1064    /// Mark a declared analytics job as failed.
1065    pub fn fail_analytics_job(
1066        &self,
1067        kind: impl Into<String>,
1068        projection: Option<String>,
1069        metadata_entries: BTreeMap<String, String>,
1070    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1071        let kind = kind.into();
1072        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1073
1074        self.update_physical_metadata(|metadata| {
1075            let now = SystemTime::now()
1076                .duration_since(UNIX_EPOCH)
1077                .unwrap_or_default()
1078                .as_millis();
1079
1080            let existing = metadata
1081                .analytics_jobs
1082                .iter_mut()
1083                .find(|job| job.id == job_id)?;
1084            existing.state = "failed".to_string();
1085            existing.kind = kind.clone();
1086            existing.projection = projection.clone();
1087            existing.updated_at_unix_ms = now;
1088            existing.metadata = metadata_entries.clone();
1089            let job = existing.clone();
1090
1091            metadata
1092                .analytics_jobs
1093                .sort_by(|left, right| left.id.cmp(&right.id));
1094            Some(job)
1095        })
1096        .and_then(|job| {
1097            job.ok_or_else(|| {
1098                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1099            })
1100        })
1101    }
1102
1103    /// Mark a declared analytics job as stale.
1104    pub fn mark_analytics_job_stale(
1105        &self,
1106        kind: impl Into<String>,
1107        projection: Option<String>,
1108        metadata_entries: BTreeMap<String, String>,
1109    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1110        let kind = kind.into();
1111        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1112
1113        self.update_physical_metadata(|metadata| {
1114            let now = SystemTime::now()
1115                .duration_since(UNIX_EPOCH)
1116                .unwrap_or_default()
1117                .as_millis();
1118
1119            let existing = metadata
1120                .analytics_jobs
1121                .iter_mut()
1122                .find(|job| job.id == job_id)?;
1123            existing.state = "stale".to_string();
1124            existing.kind = kind.clone();
1125            existing.projection = projection.clone();
1126            existing.updated_at_unix_ms = now;
1127            existing.metadata = metadata_entries.clone();
1128            let job = existing.clone();
1129
1130            metadata
1131                .analytics_jobs
1132                .sort_by(|left, right| left.id.cmp(&right.id));
1133            Some(job)
1134        })
1135        .and_then(|job| {
1136            job.ok_or_else(|| {
1137                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1138            })
1139        })
1140    }
1141
1142    /// Create a named export by copying the current database file and metadata sidecar.
1143    pub fn create_named_export(
1144        &self,
1145        name: impl Into<String>,
1146    ) -> Result<ExportDescriptor, Box<dyn std::error::Error>> {
1147        let name = name.into();
1148        if self.options.mode != StorageMode::Persistent {
1149            return Err("exports require persistent mode".into());
1150        }
1151        let Some(path) = self.path() else {
1152            return Err("database path is not available".into());
1153        };
1154
1155        self.flush()?;
1156
1157        let _publication = self.store.vault_publication_lock.lock();
1158        let mut metadata = match self.load_or_bootstrap_physical_metadata(true) {
1159            Ok(metadata) => metadata,
1160            Err(_)
1161                if self.options.storage_profile.deploy_profile
1162                    == crate::storage::DeployProfile::Embedded
1163                    && self.options.storage_profile.packaging
1164                        == crate::storage::StoragePackaging::SingleFile =>
1165            {
1166                crate::physical::PhysicalMetadataFile::from_state(
1167                    self.options.clone(),
1168                    self.catalog_snapshot(),
1169                    self.physical_collection_roots(),
1170                    self.physical_index_state(),
1171                    None,
1172                )
1173            }
1174            Err(err) => return Err(err),
1175        };
1176        let export_data_path = reddb_file::copy_physical_export_data_file(path, &name)?;
1177        let export_metadata_path = PhysicalMetadataFile::metadata_path_for(&export_data_path);
1178        let export_metadata_binary_path =
1179            PhysicalMetadataFile::metadata_binary_path_for(&export_data_path);
1180
1181        let descriptor = ExportDescriptor {
1182            name: name.clone(),
1183            created_at_unix_ms: SystemTime::now()
1184                .duration_since(UNIX_EPOCH)
1185                .unwrap_or_default()
1186                .as_millis(),
1187            snapshot_id: metadata
1188                .snapshots
1189                .last()
1190                .map(|snapshot| snapshot.snapshot_id),
1191            superblock_sequence: metadata.superblock.sequence,
1192            data_path: export_data_path.display().to_string(),
1193            metadata_path: export_metadata_path.display().to_string(),
1194            collection_count: metadata.catalog.total_collections,
1195            total_entities: metadata.catalog.total_entities,
1196        };
1197
1198        metadata
1199            .exports
1200            .retain(|export| export.name != descriptor.name);
1201        metadata.exports.push(descriptor.clone());
1202        self.prune_export_registry(&mut metadata.exports);
1203        metadata.save_for_data_path(path)?;
1204        metadata.save_to_binary_path(&export_metadata_binary_path)?;
1205        metadata.save_to_path(&export_metadata_path)?;
1206
1207        Ok(descriptor)
1208    }
1209
1210    /// Enable or disable a physical index entry in the persisted registry.
1211    pub fn set_index_enabled(
1212        &self,
1213        name: &str,
1214        enabled: bool,
1215    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1216        let Some(status) = self.index_status(name) else {
1217            return Err(format!("index '{name}' is not present in catalog status").into());
1218        };
1219        if !status.declared {
1220            return Err(format!("index '{name}' is not declared in physical metadata").into());
1221        }
1222        self.update_physical_metadata(|metadata| {
1223            let now = SystemTime::now()
1224                .duration_since(UNIX_EPOCH)
1225                .unwrap_or_default()
1226                .as_millis();
1227            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1228                index.enabled = enabled;
1229                if !enabled {
1230                    index.build_state = "disabled".to_string();
1231                } else if index.build_state == "disabled" {
1232                    index.build_state = if index.artifact_root_page.is_some() {
1233                        "ready".to_string()
1234                    } else {
1235                        "declared-unbuilt".to_string()
1236                    };
1237                }
1238                index.last_refresh_ms = Some(now);
1239                return Some(index.clone());
1240            }
1241            None
1242        })
1243    }
1244
1245    /// Mark a declared physical index as building in the persisted registry.
1246    pub fn mark_index_building(
1247        &self,
1248        name: &str,
1249    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1250        let Some(status) = self.index_status(name) else {
1251            return Err(format!("index '{name}' is not present in catalog status").into());
1252        };
1253        if !status.declared {
1254            return Err(format!("index '{name}' is not declared in physical metadata").into());
1255        }
1256        if status.lifecycle_state == "disabled" {
1257            return Err(format!("index '{name}' is disabled").into());
1258        }
1259        self.update_physical_metadata(|metadata| {
1260            let now = SystemTime::now()
1261                .duration_since(UNIX_EPOCH)
1262                .unwrap_or_default()
1263                .as_millis();
1264            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1265                index.build_state = "building".to_string();
1266                index.last_refresh_ms = Some(now);
1267                return Some(index.clone());
1268            }
1269            None
1270        })
1271    }
1272
1273    /// Mark a declared physical index as failed in the persisted registry.
1274    pub fn fail_index(
1275        &self,
1276        name: &str,
1277    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1278        let Some(status) = self.index_status(name) else {
1279            return Err(format!("index '{name}' is not present in catalog status").into());
1280        };
1281        if !status.declared {
1282            return Err(format!("index '{name}' is not declared in physical metadata").into());
1283        }
1284        self.update_physical_metadata(|metadata| {
1285            let now = SystemTime::now()
1286                .duration_since(UNIX_EPOCH)
1287                .unwrap_or_default()
1288                .as_millis();
1289            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1290                index.build_state = "failed".to_string();
1291                index.last_refresh_ms = Some(now);
1292                return Some(index.clone());
1293            }
1294            None
1295        })
1296    }
1297
1298    /// Mark a declared physical index as stale in the persisted registry.
1299    pub fn mark_index_stale(
1300        &self,
1301        name: &str,
1302    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1303        let Some(status) = self.index_status(name) else {
1304            return Err(format!("index '{name}' is not present in catalog status").into());
1305        };
1306        if !status.declared {
1307            return Err(format!("index '{name}' is not declared in physical metadata").into());
1308        }
1309        self.update_physical_metadata(|metadata| {
1310            let now = SystemTime::now()
1311                .duration_since(UNIX_EPOCH)
1312                .unwrap_or_default()
1313                .as_millis();
1314            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1315                index.build_state = if index.enabled {
1316                    "stale".to_string()
1317                } else {
1318                    "disabled".to_string()
1319                };
1320                index.last_refresh_ms = Some(now);
1321                return Some(index.clone());
1322            }
1323            None
1324        })
1325    }
1326
1327    /// Mark a declared physical index as ready in the persisted registry.
1328    pub fn mark_index_ready(
1329        &self,
1330        name: &str,
1331    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1332        self.warmup_index(name)
1333    }
1334
1335    /// Mark a physical index as warmed up/refreshed in the persisted registry.
1336    pub fn warmup_index(
1337        &self,
1338        name: &str,
1339    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1340        let Some(status) = self.index_status(name) else {
1341            return Err(format!("index '{name}' is not present in catalog status").into());
1342        };
1343        if !status.declared {
1344            return Err(format!("index '{name}' is not declared in physical metadata").into());
1345        }
1346        if status.lifecycle_state == "disabled" {
1347            return Err(format!("index '{name}' is disabled").into());
1348        }
1349        if !status.operational {
1350            return Err(
1351                format!("index '{name}' is declared but not operationally materialized").into(),
1352            );
1353        }
1354        let warmed_artifact = self
1355            .physical_indexes()
1356            .into_iter()
1357            .find(|index| index.name == name)
1358            .map(|mut index| {
1359                self.warmup_native_vector_artifact_for_index(&index)?;
1360                self.apply_runtime_native_artifact_to_index_state(&mut index)?;
1361                Ok::<_, String>(index)
1362            })
1363            .transpose()
1364            .map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;
1365
1366        self.update_physical_metadata(|metadata| {
1367            let now = SystemTime::now()
1368                .duration_since(UNIX_EPOCH)
1369                .unwrap_or_default()
1370                .as_millis();
1371            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1372                if let Some(warmed) = warmed_artifact.as_ref() {
1373                    index.entries = warmed.entries;
1374                    index.estimated_memory_bytes = warmed.estimated_memory_bytes;
1375                    index.backend = warmed.backend.clone();
1376                    index.build_state = "ready".to_string();
1377                }
1378                index.last_refresh_ms = Some(now);
1379                return Some(index.clone());
1380            }
1381            None
1382        })
1383    }
1384
1385    /// Rebuild physical index metadata from the current catalog, optionally restricted to one collection.
1386    pub fn rebuild_index_registry(
1387        &self,
1388        collection: Option<&str>,
1389    ) -> Result<Vec<PhysicalIndexState>, Box<dyn std::error::Error>> {
1390        let fresh = self.reconcile_index_states_with_native_artifacts(self.physical_index_state());
1391        self.update_physical_metadata(|metadata| {
1392            let now = SystemTime::now()
1393                .duration_since(UNIX_EPOCH)
1394                .unwrap_or_default()
1395                .as_millis();
1396
1397            let mut affected = Vec::new();
1398            let declared = metadata.indexes.clone();
1399            for declared_index in declared {
1400                let matches_collection = collection.is_none_or(|collection_name| {
1401                    declared_index.collection.as_deref() == Some(collection_name)
1402                });
1403                if !matches_collection {
1404                    continue;
1405                }
1406
1407                let mut rebuilt = fresh
1408                    .iter()
1409                    .find(|index| index.name == declared_index.name)
1410                    .cloned()
1411                    .unwrap_or_else(|| {
1412                        let mut index = declared_index.clone();
1413                        index.build_state = "declared-unbuilt".to_string();
1414                        index
1415                    });
1416                rebuilt.enabled = declared_index.enabled;
1417                rebuilt.artifact_kind = rebuilt
1418                    .artifact_kind
1419                    .or_else(|| declared_index.artifact_kind.clone());
1420                rebuilt.artifact_root_page = rebuilt
1421                    .artifact_root_page
1422                    .or(declared_index.artifact_root_page);
1423                rebuilt.artifact_checksum = rebuilt
1424                    .artifact_checksum
1425                    .or(declared_index.artifact_checksum);
1426                rebuilt.build_state =
1427                    Self::finalize_rebuilt_index_build_state(&declared_index, &rebuilt);
1428                rebuilt.last_refresh_ms = Some(now);
1429
1430                if let Some(existing) = metadata
1431                    .indexes
1432                    .iter_mut()
1433                    .find(|index| index.name == rebuilt.name)
1434                {
1435                    *existing = rebuilt.clone();
1436                } else {
1437                    metadata.indexes.push(rebuilt.clone());
1438                }
1439
1440                affected.push(rebuilt);
1441            }
1442
1443            affected
1444        })
1445    }
1446
1447    fn finalize_rebuilt_index_build_state(
1448        declared: &PhysicalIndexState,
1449        rebuilt: &PhysicalIndexState,
1450    ) -> String {
1451        if !rebuilt.enabled {
1452            return "disabled".to_string();
1453        }
1454
1455        if declared.build_state == "failed" || rebuilt.build_state == "failed" {
1456            return "failed".to_string();
1457        }
1458
1459        let native_artifact_family = Self::native_artifact_kind_for_index(rebuilt.kind).is_some();
1460        if native_artifact_family {
1461            if rebuilt.artifact_root_page.is_some() && rebuilt.artifact_checksum.is_some() {
1462                return "ready".to_string();
1463            }
1464            if declared.artifact_root_page.is_some()
1465                || declared.artifact_checksum.is_some()
1466                || declared.artifact_kind.is_some()
1467            {
1468                return "stale".to_string();
1469            }
1470            return "declared-unbuilt".to_string();
1471        }
1472
1473        if rebuilt.entries > 0 {
1474            return "ready".to_string();
1475        }
1476
1477        if matches!(
1478            declared.build_state.as_str(),
1479            "stale" | "artifact-published" | "registry-loaded"
1480        ) {
1481            return "stale".to_string();
1482        }
1483
1484        "declared-unbuilt".to_string()
1485    }
1486}