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(_) = self.path() else {
268            return Err("database path is not available".into());
269        };
270
271        // Paged contracts live in an internal record protected by the WAL.
272        // Native artifact summaries are not a complete contract representation.
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        self.persist_physical_metadata_record(&metadata)?;
279        Ok(result)
280    }
281
282    /// Supply missing system contracts during read-only boot without creating
283    /// a metadata sidecar. Runtime schema rehydration consumes this private view.
284    pub(crate) fn cache_read_only_bootstrap_contract(
285        &self,
286        contract: crate::physical::CollectionContract,
287    ) {
288        assert!(self.options.read_only);
289        self.contract_cache_map();
290        self.collection_contract_cache.insert(contract);
291    }
292
293    pub fn save_collection_contract(
294        &self,
295        contract: crate::physical::CollectionContract,
296    ) -> Result<crate::physical::CollectionContract, Box<dyn std::error::Error>> {
297        let required_capabilities =
298            reddb_engine::storage::entity_codec::required_file_capabilities_for_contract(&contract);
299        self.store()
300            .fence_required_capabilities(required_capabilities)?;
301        let _publication = self.store.vault_publication_lock.lock();
302        self.save_collection_contract_locked(contract)
303    }
304
305    pub fn save_generated_descriptions_if_version(
306        &self,
307        collection: &str,
308        expected_version: u32,
309        expected_created_at_unix_ms: u128,
310        description: Option<String>,
311        columns: &std::collections::HashMap<String, String>,
312    ) -> Result<bool, Box<dyn std::error::Error>> {
313        let _publication = self.store.vault_publication_lock.lock();
314        let Some(mut contract) = self.collection_contract(collection) else {
315            return Ok(false);
316        };
317        if contract.version != expected_version
318            || contract.created_at_unix_ms != expected_created_at_unix_ms
319            || self.store.get_collection(collection).is_none()
320        {
321            return Ok(false);
322        }
323        let mut changed = false;
324        if (contract.description.is_none() || contract.description_generated)
325            && description.as_ref().is_some_and(|text| !text.is_empty())
326        {
327            contract.description = description;
328            contract.description_generated = true;
329            changed = true;
330        }
331        for column in &mut contract.declared_columns {
332            if let Some(text) = columns.get(&column.name) {
333                if !text.is_empty()
334                    && (column.description.is_none() || column.description_generated)
335                {
336                    column.description = Some(text.clone());
337                    column.description_generated = true;
338                    changed = true;
339                }
340            }
341        }
342        if changed {
343            contract.version = contract.version.saturating_add(1);
344            contract.updated_at_unix_ms = std::time::SystemTime::now()
345                .duration_since(std::time::UNIX_EPOCH)
346                .unwrap_or_default()
347                .as_millis();
348            self.save_collection_contract_locked(contract)?;
349        }
350        Ok(changed)
351    }
352
353    fn save_collection_contract_locked(
354        &self,
355        contract: crate::physical::CollectionContract,
356    ) -> Result<crate::physical::CollectionContract, Box<dyn std::error::Error>> {
357        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
358            if let Some(ttl_ms) = contract.default_ttl_ms {
359                defaults.insert(contract.name.clone(), ttl_ms);
360            } else {
361                defaults.remove(&contract.name);
362            }
363        }
364
365        self.store()
366            .context_index()
367            .set_collection_enabled(&contract.name, contract.context_index_enabled);
368        if let Some(manager) = self.store().get_collection(&contract.name) {
369            let columns = contract
370                .declared_columns
371                .iter()
372                .map(|column| column.name.clone())
373                .collect();
374            manager.set_column_schema_if_empty(columns);
375        }
376
377        // Existing paged files can be opened with embedded options. Their
378        // checkpoints do not encode the binary dump's auxiliary contract blob.
379        if self.options.storage_profile.deploy_profile == crate::storage::DeployProfile::Embedded
380            && self.options.storage_profile.packaging
381                == crate::storage::StoragePackaging::SingleFile
382            && !self.paged_mode
383        {
384            self.collection_contract_cache.insert(contract.clone());
385            self.sync_single_file_contract_blob()?;
386            return Ok(contract);
387        }
388
389        self.invalidate_collection_contract_cache();
390
391        let saved = self.update_collection_contract_metadata(|metadata| {
392            if let Some(existing) = metadata
393                .collection_contracts
394                .iter_mut()
395                .find(|existing| existing.name == contract.name)
396            {
397                *existing = contract.clone();
398            } else {
399                metadata.collection_contracts.push(contract.clone());
400            }
401            metadata
402                .collection_contracts
403                .sort_by(|left, right| left.name.cmp(&right.name));
404
405            if let Some(ttl_ms) = contract.default_ttl_ms {
406                metadata
407                    .collection_ttl_defaults_ms
408                    .insert(contract.name.clone(), ttl_ms);
409            } else {
410                metadata.collection_ttl_defaults_ms.remove(&contract.name);
411            }
412
413            contract.clone()
414        })?;
415        self.invalidate_collection_contract_cache();
416        Ok(saved)
417    }
418
419    pub fn remove_collection_contract(
420        &self,
421        collection: &str,
422    ) -> Result<Option<crate::physical::CollectionContract>, Box<dyn std::error::Error>> {
423        if self.options.read_only {
424            return Err("physical metadata operations are not allowed in read-only mode".into());
425        }
426        let _publication = self.store.vault_publication_lock.lock();
427        if let Ok(mut defaults) = self.collection_ttl_defaults_ms.write() {
428            defaults.remove(collection);
429        }
430
431        self.store()
432            .context_index()
433            .set_collection_enabled(collection, false);
434
435        if self.options.storage_profile.deploy_profile == crate::storage::DeployProfile::Embedded
436            && self.options.storage_profile.packaging
437                == crate::storage::StoragePackaging::SingleFile
438            && !self.paged_mode
439        {
440            let removed = self.collection_contract_cache.remove(collection);
441            self.sync_single_file_contract_blob()?;
442            return Ok(removed);
443        }
444
445        self.invalidate_collection_contract_cache();
446
447        if self.paged_mode {
448            let Some(_) = self.path() else {
449                return Err("database path is not available".into());
450            };
451            // DROP already published the native collection registry before it
452            // reaches this contract edit.  Rebuild only the small physical
453            // metadata catalogue from its last durable roots, removing the
454            // dropped root and descriptors explicitly.  Calling the generic
455            // metadata updater here used to fingerprint every row in changed
456            // internal collections (notably append-heavy red.config) while
457            // holding the publication lock, turning a tiny DROP after cold
458            // boot into work proportional to the database's history.
459            //
460            // Other collection fingerprints describe their last checkpoint,
461            // just as they do between ordinary DML and the next checkpoint;
462            // the canonical checkpoint path remains responsible for
463            // recomputing changed fingerprints.
464            let previous = self.load_or_bootstrap_physical_metadata(false)?;
465            let mut roots = previous.superblock.collection_roots.clone();
466            roots.remove(collection);
467            let indexes = previous
468                .indexes
469                .iter()
470                .filter(|index| index.collection.as_deref() != Some(collection))
471                .cloned()
472                .collect();
473            let mut metadata = PhysicalMetadataFile::from_state(
474                self.options.clone(),
475                self.catalog_snapshot(),
476                roots,
477                indexes,
478                Some(&previous),
479            );
480            let removed = metadata
481                .collection_contracts
482                .iter()
483                .position(|contract| contract.name == collection)
484                .map(|index| metadata.collection_contracts.remove(index));
485            metadata.collection_ttl_defaults_ms.remove(collection);
486            self.persist_physical_metadata_record(&metadata)?;
487            self.persist_native_physical_header(&metadata)?;
488            self.invalidate_collection_contract_cache();
489            return Ok(removed);
490        }
491
492        let removed = self.update_collection_contract_metadata(|metadata| {
493            let removed = metadata
494                .collection_contracts
495                .iter()
496                .position(|contract| contract.name == collection)
497                .map(|index| metadata.collection_contracts.remove(index));
498            metadata.collection_ttl_defaults_ms.remove(collection);
499            metadata
500                .indexes
501                .retain(|index| index.collection.as_deref() != Some(collection));
502            removed
503        })?;
504        self.invalidate_collection_contract_cache();
505        Ok(removed)
506    }
507
508    pub(crate) fn collection_ttl_defaults_snapshot(&self) -> BTreeMap<String, u64> {
509        self.collection_ttl_defaults_ms
510            .read()
511            .map(|defaults| {
512                defaults
513                    .iter()
514                    .map(|(collection, ttl_ms)| (collection.clone(), *ttl_ms))
515                    .collect()
516            })
517            .unwrap_or_default()
518    }
519
520    pub(crate) fn load_collection_ttl_defaults_from_metadata(&self) {
521        let metadata = self.physical_metadata();
522        let defaults = metadata
523            .as_ref()
524            .map(|m| m.collection_ttl_defaults_ms.clone())
525            .unwrap_or_default();
526
527        if let Ok(mut current) = self.collection_ttl_defaults_ms.write() {
528            current.clear();
529            current.extend(defaults);
530        }
531
532        if let Some(metadata) = metadata {
533            let store = self.store();
534            let index = store.context_index();
535            for contract in &metadata.collection_contracts {
536                index.set_collection_enabled(&contract.name, contract.context_index_enabled);
537            }
538        }
539    }
540
541    /// Snapshot the live hypertable registry (specs + every chunk)
542    /// into the sidecar-serialisable form. Called from
543    /// [`persist_metadata`] so the chunk routing spine is durable on
544    /// the same metadata path as collection contracts — issue #866.
545    /// Returns an empty vec (and touches nothing) when no hypertable
546    /// has ever been declared, so non-hypertable databases pay no
547    /// persist cost.
548    pub(crate) fn hypertable_registry_snapshot(&self) -> Vec<crate::physical::PhysicalHypertable> {
549        let registry = self.hypertables();
550        if registry.is_empty() {
551            return Vec::new();
552        }
553        use std::collections::BTreeMap;
554        let mut chunks_by_table: BTreeMap<String, Vec<crate::physical::PhysicalHypertableChunk>> =
555            BTreeMap::new();
556        for chunk in registry.snapshot_chunks() {
557            chunks_by_table
558                .entry(chunk.id.hypertable.clone())
559                .or_default()
560                .push(crate::physical::PhysicalHypertableChunk {
561                    start_ns: chunk.id.start_ns,
562                    end_ns_exclusive: chunk.end_ns_exclusive,
563                    row_count: chunk.row_count,
564                    min_ts_ns: chunk.min_ts_ns,
565                    max_ts_ns: chunk.max_ts_ns,
566                    sealed: chunk.sealed,
567                    ttl_override_ns: chunk.ttl_override_ns,
568                    columnar_page: chunk.columnar_page,
569                    columnar_derived: chunk.columnar_page.is_some(),
570                });
571        }
572        registry
573            .list()
574            .into_iter()
575            .map(|spec| crate::physical::PhysicalHypertable {
576                chunks: chunks_by_table.remove(&spec.name).unwrap_or_default(),
577                name: spec.name,
578                time_column: spec.time_column,
579                chunk_interval_ns: spec.chunk_interval_ns,
580                default_ttl_ns: spec.default_ttl_ns,
581            })
582            .collect()
583    }
584
585    /// Rehydrate the hypertable registry from the physical metadata
586    /// sidecar at boot. Mirror of
587    /// [`load_collection_ttl_defaults_from_metadata`] — specs and
588    /// chunks are restored verbatim so bounds / routing / TTL are
589    /// identical to the pre-restart state (issue #866).
590    pub(crate) fn load_hypertables_from_metadata(&self) {
591        let Some(metadata) = self.physical_metadata() else {
592            return;
593        };
594        if metadata.hypertables.is_empty() {
595            return;
596        }
597        let registry = self.hypertables();
598        for hypertable in &metadata.hypertables {
599            let mut spec = crate::storage::timeseries::HypertableSpec::new(
600                hypertable.name.clone(),
601                hypertable.time_column.clone(),
602                hypertable.chunk_interval_ns,
603            );
604            if let Some(ttl) = hypertable.default_ttl_ns {
605                spec = spec.with_ttl_ns(ttl);
606            }
607            registry.register(spec);
608            for chunk in &hypertable.chunks {
609                let meta = crate::storage::timeseries::ChunkMeta {
610                    id: crate::storage::timeseries::ChunkId {
611                        hypertable: hypertable.name.clone(),
612                        start_ns: chunk.start_ns,
613                    },
614                    end_ns_exclusive: chunk.end_ns_exclusive,
615                    row_count: chunk.row_count,
616                    min_ts_ns: chunk.min_ts_ns,
617                    max_ts_ns: chunk.max_ts_ns,
618                    sealed: chunk.sealed,
619                    ttl_override_ns: chunk.ttl_override_ns,
620                    columnar_page: chunk.columnar_page,
621                };
622                let id = meta.id.clone();
623                let columnar_page = meta.columnar_page;
624                registry.restore_chunk(meta);
625                if let Some(loc) = columnar_page {
626                    if let Ok(Some(bytes)) = self.read_column_block_page(loc) {
627                        registry.restore_columnar_block(&id, bytes);
628                    }
629                }
630            }
631        }
632    }
633
634    pub fn run_maintenance(&self) -> Result<(), Box<dyn std::error::Error>> {
635        self.store.run_maintenance()?;
636        self.persist_metadata()?;
637        if self.paged_mode {
638            if let Some(path) = self.path() {
639                self.remove_imported_metadata_sidecars(path)?;
640            }
641        }
642        Ok(())
643    }
644
645    /// Path to the physical metadata sidecar, if persistent.
646    pub fn metadata_path(&self) -> Option<PathBuf> {
647        self.path
648            .as_ref()
649            .map(|path| PhysicalMetadataFile::metadata_path_for(path))
650    }
651
652    /// Inspect the current physical metadata view without publishing repairs.
653    pub fn physical_metadata(&self) -> Option<PhysicalMetadataFile> {
654        self.load_or_bootstrap_physical_metadata(false).ok()
655    }
656
657    /// Physical index registry derived for the current database state.
658    pub fn physical_indexes(&self) -> Vec<PhysicalIndexState> {
659        let indexes = self
660            .physical_metadata()
661            .map(|metadata| metadata.indexes)
662            .unwrap_or_default();
663        self.physical_indexes_from_declarations(indexes)
664    }
665
666    pub(crate) fn physical_indexes_from_declarations(
667        &self,
668        indexes: Vec<PhysicalIndexState>,
669    ) -> Vec<PhysicalIndexState> {
670        let indexes = Some(indexes)
671            .filter(|indexes| !indexes.is_empty())
672            .or_else(|| {
673                self.native_physical_state()
674                    .map(|state| self.physical_index_state_from_native_state(&state, None))
675            })
676            .unwrap_or_else(|| self.physical_index_state());
677        self.reconcile_index_states_with_native_artifacts(indexes)
678    }
679
680    /// List registered named exports from the current physical metadata view.
681    pub fn exports(&self) -> Vec<ExportDescriptor> {
682        self.physical_metadata()
683            .map(|metadata| metadata.exports)
684            .or_else(|| {
685                self.native_physical_state()
686                    .map(|state| self.exports_from_native_state(&state))
687            })
688            .unwrap_or_default()
689    }
690
691    /// List recorded snapshots from the current physical metadata view.
692    pub fn snapshots(&self) -> Vec<crate::physical::SnapshotDescriptor> {
693        if let Some(metadata) = self.physical_metadata() {
694            if !metadata.snapshots.is_empty() {
695                return metadata.snapshots;
696            }
697        }
698        self.native_physical_state()
699            .map(|state| self.snapshots_from_native_state(&state))
700            .unwrap_or_default()
701    }
702
703    /// List persisted named graph projections from the current physical metadata view.
704    pub fn graph_projections(&self) -> Vec<PhysicalGraphProjection> {
705        self.physical_metadata()
706            .map(|metadata| metadata.graph_projections)
707            .or_else(|| {
708                self.native_physical_state()
709                    .map(|state| self.graph_projections_from_native_state(&state))
710            })
711            .unwrap_or_default()
712    }
713
714    /// List graph projections declared in the catalog view.
715    pub fn declared_graph_projections(&self) -> Vec<PhysicalGraphProjection> {
716        self.catalog_model_snapshot().declared_graph_projections
717    }
718
719    /// List graph projections currently observed in the operational view.
720    pub fn operational_graph_projections(&self) -> Vec<PhysicalGraphProjection> {
721        self.graph_projections()
722            .into_iter()
723            .filter(|projection| {
724                projection.last_materialized_sequence.is_some()
725                    || matches!(projection.state.as_str(), "materialized" | "stale")
726            })
727            .collect()
728    }
729
730    /// List persisted analytics job metadata from the current physical metadata view.
731    pub fn analytics_jobs(&self) -> Vec<PhysicalAnalyticsJob> {
732        self.physical_metadata()
733            .map(|metadata| metadata.analytics_jobs)
734            .or_else(|| {
735                self.native_physical_state()
736                    .map(|state| self.analytics_jobs_from_native_state(&state))
737            })
738            .unwrap_or_default()
739    }
740
741    /// List analytics jobs declared in the catalog view.
742    pub fn declared_analytics_jobs(&self) -> Vec<PhysicalAnalyticsJob> {
743        self.catalog_model_snapshot().declared_analytics_jobs
744    }
745
746    /// List analytics jobs currently observed in the operational view.
747    pub fn operational_analytics_jobs(&self) -> Vec<PhysicalAnalyticsJob> {
748        self.analytics_jobs()
749            .into_iter()
750            .filter(|job| {
751                job.last_run_sequence.is_some()
752                    || matches!(
753                        job.state.as_str(),
754                        "running" | "completed" | "failed" | "queued" | "stale"
755                    )
756            })
757            .collect()
758    }
759
760    /// List indexes declared in the catalog view.
761    pub fn declared_indexes(&self) -> Vec<PhysicalIndexState> {
762        self.catalog_model_snapshot().declared_indexes
763    }
764
765    /// List indexes currently observed in the operational view.
766    pub fn operational_indexes(&self) -> Vec<PhysicalIndexState> {
767        self.catalog_model_snapshot().operational_indexes
768    }
769
770    /// List reconciled index status entries from the catalog snapshot.
771    pub fn index_statuses(&self) -> Vec<CatalogIndexStatus> {
772        self.catalog_model_snapshot().index_statuses
773    }
774
775    /// Resolve one index status entry from the catalog snapshot.
776    pub fn index_status(&self, name: &str) -> Option<CatalogIndexStatus> {
777        self.catalog_model_snapshot()
778            .index_statuses
779            .into_iter()
780            .find(|status| status.name == name)
781    }
782
783    /// Upsert a named graph projection in the persisted physical metadata.
784    pub fn save_graph_projection(
785        &self,
786        name: impl Into<String>,
787        node_labels: Vec<String>,
788        node_types: Vec<String>,
789        edge_labels: Vec<String>,
790        source: impl Into<String>,
791    ) -> Result<PhysicalGraphProjection, Box<dyn std::error::Error>> {
792        let name = name.into();
793        let source = source.into();
794        self.update_physical_metadata(|metadata| {
795            let now = SystemTime::now()
796                .duration_since(UNIX_EPOCH)
797                .unwrap_or_default()
798                .as_millis();
799            let projection = if let Some(existing) = metadata
800                .graph_projections
801                .iter_mut()
802                .find(|projection| projection.name == name)
803            {
804                existing.updated_at_unix_ms = now;
805                existing.state = "declared".to_string();
806                existing.source = source.clone();
807                existing.node_labels = node_labels.clone();
808                existing.node_types = node_types.clone();
809                existing.edge_labels = edge_labels.clone();
810                existing.last_materialized_sequence = None;
811                existing.clone()
812            } else {
813                let projection = PhysicalGraphProjection {
814                    name: name.clone(),
815                    created_at_unix_ms: now,
816                    updated_at_unix_ms: now,
817                    state: "declared".to_string(),
818                    source: source.clone(),
819                    node_labels: node_labels.clone(),
820                    node_types: node_types.clone(),
821                    edge_labels: edge_labels.clone(),
822                    last_materialized_sequence: None,
823                };
824                metadata.graph_projections.push(projection.clone());
825                projection
826            };
827
828            Self::mark_projection_dependent_jobs_stale(metadata, &name, now);
829
830            metadata
831                .graph_projections
832                .sort_by(|left, right| left.name.cmp(&right.name));
833            projection
834        })
835    }
836
837    /// Mark a declared graph projection as materialized in the current physical metadata.
838    pub fn materialize_graph_projection(
839        &self,
840        name: &str,
841    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
842        self.update_physical_metadata(|metadata| {
843            let now = SystemTime::now()
844                .duration_since(UNIX_EPOCH)
845                .unwrap_or_default()
846                .as_millis();
847            let idx = metadata
848                .graph_projections
849                .iter()
850                .position(|projection| projection.name == name);
851            if let Some(idx) = idx {
852                metadata.graph_projections[idx].updated_at_unix_ms = now;
853                metadata.graph_projections[idx].state = "materialized".to_string();
854                metadata.graph_projections[idx].last_materialized_sequence =
855                    Some(metadata.superblock.sequence);
856                let result = metadata.graph_projections[idx].clone();
857                Self::rearm_projection_dependent_jobs_declared(metadata, name, now);
858                return Some(result);
859            }
860            None
861        })
862    }
863
864    /// Mark a declared graph projection as materializing.
865    pub fn mark_graph_projection_materializing(
866        &self,
867        name: &str,
868    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
869        self.update_physical_metadata(|metadata| {
870            let now = SystemTime::now()
871                .duration_since(UNIX_EPOCH)
872                .unwrap_or_default()
873                .as_millis();
874            let idx = metadata
875                .graph_projections
876                .iter()
877                .position(|projection| projection.name == name);
878            if let Some(idx) = idx {
879                metadata.graph_projections[idx].updated_at_unix_ms = now;
880                metadata.graph_projections[idx].state = "materializing".to_string();
881                metadata.graph_projections[idx].last_materialized_sequence = None;
882                let result = metadata.graph_projections[idx].clone();
883                Self::mark_projection_dependent_jobs_stale(metadata, name, now);
884                return Some(result);
885            }
886            None
887        })
888    }
889
890    /// Mark a graph projection as failed.
891    pub fn fail_graph_projection(
892        &self,
893        name: &str,
894    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
895        self.update_physical_metadata(|metadata| {
896            let now = SystemTime::now()
897                .duration_since(UNIX_EPOCH)
898                .unwrap_or_default()
899                .as_millis();
900            let idx = metadata
901                .graph_projections
902                .iter()
903                .position(|projection| projection.name == name);
904            if let Some(idx) = idx {
905                metadata.graph_projections[idx].updated_at_unix_ms = now;
906                metadata.graph_projections[idx].state = "failed".to_string();
907                metadata.graph_projections[idx].last_materialized_sequence = None;
908                let result = metadata.graph_projections[idx].clone();
909                Self::mark_projection_dependent_jobs_stale(metadata, name, now);
910                return Some(result);
911            }
912            None
913        })
914    }
915
916    /// Mark a graph projection as stale while preserving any last materialized sequence.
917    pub fn mark_graph_projection_stale(
918        &self,
919        name: &str,
920    ) -> Result<Option<PhysicalGraphProjection>, Box<dyn std::error::Error>> {
921        self.update_physical_metadata(|metadata| {
922            let now = SystemTime::now()
923                .duration_since(UNIX_EPOCH)
924                .unwrap_or_default()
925                .as_millis();
926            let idx = metadata
927                .graph_projections
928                .iter()
929                .position(|projection| projection.name == name);
930            if let Some(idx) = idx {
931                metadata.graph_projections[idx].updated_at_unix_ms = now;
932                metadata.graph_projections[idx].state = "stale".to_string();
933                let result = metadata.graph_projections[idx].clone();
934                Self::mark_projection_dependent_jobs_stale(metadata, name, now);
935                return Some(result);
936            }
937            None
938        })
939    }
940
941    fn mark_projection_dependent_jobs_stale(
942        metadata: &mut PhysicalMetadataFile,
943        projection_name: &str,
944        now: u128,
945    ) {
946        for job in metadata.analytics_jobs.iter_mut() {
947            if job.projection.as_deref() == Some(projection_name) && job.state != "declared" {
948                job.state = "stale".to_string();
949                job.updated_at_unix_ms = now;
950            }
951        }
952    }
953
954    fn rearm_projection_dependent_jobs_declared(
955        metadata: &mut PhysicalMetadataFile,
956        projection_name: &str,
957        now: u128,
958    ) {
959        for job in metadata.analytics_jobs.iter_mut() {
960            if job.projection.as_deref() == Some(projection_name) && job.state == "stale" {
961                job.state = "declared".to_string();
962                job.last_run_sequence = None;
963                job.updated_at_unix_ms = now;
964            }
965        }
966    }
967
968    /// Declare or update analytics job metadata without marking it as executed.
969    pub fn save_analytics_job(
970        &self,
971        kind: impl Into<String>,
972        projection: Option<String>,
973        metadata_entries: BTreeMap<String, String>,
974    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
975        let kind = kind.into();
976        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
977        if let Some(projection_name) = projection.as_deref() {
978            if !self.graph_projection_is_declared(projection_name) {
979                return Err(format!(
980                    "graph projection '{projection_name}' is not declared in physical metadata"
981                )
982                .into());
983            }
984        }
985
986        self.update_physical_metadata(|metadata| {
987            let now = SystemTime::now()
988                .duration_since(UNIX_EPOCH)
989                .unwrap_or_default()
990                .as_millis();
991
992            let job = if let Some(existing) = metadata
993                .analytics_jobs
994                .iter_mut()
995                .find(|job| job.id == job_id)
996            {
997                existing.kind = kind.clone();
998                existing.projection = projection.clone();
999                existing.updated_at_unix_ms = now;
1000                existing.metadata = metadata_entries.clone();
1001                if existing.last_run_sequence.is_none() {
1002                    existing.state = "declared".to_string();
1003                }
1004                existing.clone()
1005            } else {
1006                let job = PhysicalAnalyticsJob {
1007                    id: job_id.clone(),
1008                    kind: kind.clone(),
1009                    state: "declared".to_string(),
1010                    projection: projection.clone(),
1011                    created_at_unix_ms: now,
1012                    updated_at_unix_ms: now,
1013                    last_run_sequence: None,
1014                    metadata: metadata_entries.clone(),
1015                };
1016                metadata.analytics_jobs.push(job.clone());
1017                job
1018            };
1019
1020            metadata
1021                .analytics_jobs
1022                .sort_by(|left, right| left.id.cmp(&right.id));
1023            job
1024        })
1025    }
1026
1027    /// Record or update analytics job metadata in the persisted physical metadata.
1028    pub fn record_analytics_job(
1029        &self,
1030        kind: impl Into<String>,
1031        projection: Option<String>,
1032        metadata_entries: BTreeMap<String, String>,
1033    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1034        let kind = kind.into();
1035        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1036        if let Some(projection_name) = projection.as_deref() {
1037            if !self.graph_projection_is_declared(projection_name) {
1038                return Err(format!(
1039                    "graph projection '{projection_name}' is not declared in physical metadata"
1040                )
1041                .into());
1042            }
1043            if !self.graph_projection_is_operational(projection_name) {
1044                return Err(format!(
1045                    "graph projection '{projection_name}' is declared but not operationally materialized"
1046                )
1047                .into());
1048            }
1049        }
1050
1051        self.update_physical_metadata(|metadata| {
1052            let now = SystemTime::now()
1053                .duration_since(UNIX_EPOCH)
1054                .unwrap_or_default()
1055                .as_millis();
1056
1057            let existing = metadata
1058                .analytics_jobs
1059                .iter_mut()
1060                .find(|job| job.id == job_id)?;
1061            existing.state = "completed".to_string();
1062            existing.kind = kind.clone();
1063            existing.projection = projection.clone();
1064            existing.updated_at_unix_ms = now;
1065            existing.last_run_sequence = Some(metadata.superblock.sequence);
1066            existing.metadata = metadata_entries.clone();
1067            let job = existing.clone();
1068
1069            metadata
1070                .analytics_jobs
1071                .sort_by(|left, right| left.id.cmp(&right.id));
1072            Some(job)
1073        })
1074        .and_then(|job| {
1075            job.ok_or_else(|| {
1076                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1077            })
1078        })
1079    }
1080
1081    /// Mark a declared analytics job as running.
1082    pub fn queue_analytics_job(
1083        &self,
1084        kind: impl Into<String>,
1085        projection: Option<String>,
1086        metadata_entries: BTreeMap<String, String>,
1087    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1088        let kind = kind.into();
1089        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1090        if let Some(projection_name) = projection.as_deref() {
1091            if !self.graph_projection_is_declared(projection_name) {
1092                return Err(format!(
1093                    "graph projection '{projection_name}' is not declared in physical metadata"
1094                )
1095                .into());
1096            }
1097            if !self.graph_projection_is_operational(projection_name) {
1098                return Err(format!(
1099                    "graph projection '{projection_name}' is declared but not operationally materialized"
1100                )
1101                .into());
1102            }
1103        }
1104
1105        self.update_physical_metadata(|metadata| {
1106            let now = SystemTime::now()
1107                .duration_since(UNIX_EPOCH)
1108                .unwrap_or_default()
1109                .as_millis();
1110
1111            let existing = metadata
1112                .analytics_jobs
1113                .iter_mut()
1114                .find(|job| job.id == job_id)?;
1115            existing.state = "queued".to_string();
1116            existing.kind = kind.clone();
1117            existing.projection = projection.clone();
1118            existing.updated_at_unix_ms = now;
1119            existing.metadata = metadata_entries.clone();
1120            let job = existing.clone();
1121
1122            metadata
1123                .analytics_jobs
1124                .sort_by(|left, right| left.id.cmp(&right.id));
1125            Some(job)
1126        })
1127        .and_then(|job| {
1128            job.ok_or_else(|| {
1129                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1130            })
1131        })
1132    }
1133
1134    /// Mark a declared analytics job as running.
1135    pub fn start_analytics_job(
1136        &self,
1137        kind: impl Into<String>,
1138        projection: Option<String>,
1139        metadata_entries: BTreeMap<String, String>,
1140    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1141        let kind = kind.into();
1142        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1143        if let Some(projection_name) = projection.as_deref() {
1144            if !self.graph_projection_is_declared(projection_name) {
1145                return Err(format!(
1146                    "graph projection '{projection_name}' is not declared in physical metadata"
1147                )
1148                .into());
1149            }
1150            if !self.graph_projection_is_operational(projection_name) {
1151                return Err(format!(
1152                    "graph projection '{projection_name}' is declared but not operationally materialized"
1153                )
1154                .into());
1155            }
1156        }
1157
1158        self.update_physical_metadata(|metadata| {
1159            let now = SystemTime::now()
1160                .duration_since(UNIX_EPOCH)
1161                .unwrap_or_default()
1162                .as_millis();
1163
1164            let existing = metadata
1165                .analytics_jobs
1166                .iter_mut()
1167                .find(|job| job.id == job_id)?;
1168            existing.state = "running".to_string();
1169            existing.kind = kind.clone();
1170            existing.projection = projection.clone();
1171            existing.updated_at_unix_ms = now;
1172            existing.metadata = metadata_entries.clone();
1173            let job = existing.clone();
1174
1175            metadata
1176                .analytics_jobs
1177                .sort_by(|left, right| left.id.cmp(&right.id));
1178            Some(job)
1179        })
1180        .and_then(|job| {
1181            job.ok_or_else(|| {
1182                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1183            })
1184        })
1185    }
1186
1187    /// Mark a declared analytics job as failed.
1188    pub fn fail_analytics_job(
1189        &self,
1190        kind: impl Into<String>,
1191        projection: Option<String>,
1192        metadata_entries: BTreeMap<String, String>,
1193    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1194        let kind = kind.into();
1195        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1196
1197        self.update_physical_metadata(|metadata| {
1198            let now = SystemTime::now()
1199                .duration_since(UNIX_EPOCH)
1200                .unwrap_or_default()
1201                .as_millis();
1202
1203            let existing = metadata
1204                .analytics_jobs
1205                .iter_mut()
1206                .find(|job| job.id == job_id)?;
1207            existing.state = "failed".to_string();
1208            existing.kind = kind.clone();
1209            existing.projection = projection.clone();
1210            existing.updated_at_unix_ms = now;
1211            existing.metadata = metadata_entries.clone();
1212            let job = existing.clone();
1213
1214            metadata
1215                .analytics_jobs
1216                .sort_by(|left, right| left.id.cmp(&right.id));
1217            Some(job)
1218        })
1219        .and_then(|job| {
1220            job.ok_or_else(|| {
1221                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1222            })
1223        })
1224    }
1225
1226    /// Mark a declared analytics job as stale.
1227    pub fn mark_analytics_job_stale(
1228        &self,
1229        kind: impl Into<String>,
1230        projection: Option<String>,
1231        metadata_entries: BTreeMap<String, String>,
1232    ) -> Result<PhysicalAnalyticsJob, Box<dyn std::error::Error>> {
1233        let kind = kind.into();
1234        let job_id = Self::analytics_job_id(&kind, projection.as_deref());
1235
1236        self.update_physical_metadata(|metadata| {
1237            let now = SystemTime::now()
1238                .duration_since(UNIX_EPOCH)
1239                .unwrap_or_default()
1240                .as_millis();
1241
1242            let existing = metadata
1243                .analytics_jobs
1244                .iter_mut()
1245                .find(|job| job.id == job_id)?;
1246            existing.state = "stale".to_string();
1247            existing.kind = kind.clone();
1248            existing.projection = projection.clone();
1249            existing.updated_at_unix_ms = now;
1250            existing.metadata = metadata_entries.clone();
1251            let job = existing.clone();
1252
1253            metadata
1254                .analytics_jobs
1255                .sort_by(|left, right| left.id.cmp(&right.id));
1256            Some(job)
1257        })
1258        .and_then(|job| {
1259            job.ok_or_else(|| {
1260                format!("analytics job '{job_id}' is not declared in physical metadata").into()
1261            })
1262        })
1263    }
1264
1265    /// Create a named export by copying the current database file, including its internal metadata.
1266    pub fn create_named_export(
1267        &self,
1268        name: impl Into<String>,
1269    ) -> Result<ExportDescriptor, Box<dyn std::error::Error>> {
1270        let name = name.into();
1271        if self.options.mode != StorageMode::Persistent {
1272            return Err("exports require persistent mode".into());
1273        }
1274        let Some(path) = self.path() else {
1275            return Err("database path is not available".into());
1276        };
1277
1278        self.flush()?;
1279
1280        let _publication = self.store.vault_publication_lock.lock();
1281        let mut metadata = match self.load_or_bootstrap_physical_metadata(true) {
1282            Ok(metadata) => metadata,
1283            Err(_)
1284                if self.options.storage_profile.deploy_profile
1285                    == crate::storage::DeployProfile::Embedded
1286                    && self.options.storage_profile.packaging
1287                        == crate::storage::StoragePackaging::SingleFile =>
1288            {
1289                crate::physical::PhysicalMetadataFile::from_state(
1290                    self.options.clone(),
1291                    self.catalog_snapshot(),
1292                    self.physical_collection_roots(),
1293                    self.physical_index_state(),
1294                    None,
1295                )
1296            }
1297            Err(err) => return Err(err),
1298        };
1299        let export_data_path = reddb_file::copy_physical_export_data_file(path, &name)?;
1300
1301        let descriptor = ExportDescriptor {
1302            name: name.clone(),
1303            created_at_unix_ms: SystemTime::now()
1304                .duration_since(UNIX_EPOCH)
1305                .unwrap_or_default()
1306                .as_millis(),
1307            snapshot_id: metadata
1308                .snapshots
1309                .last()
1310                .map(|snapshot| snapshot.snapshot_id),
1311            superblock_sequence: metadata.superblock.sequence,
1312            data_path: export_data_path.display().to_string(),
1313            metadata_path: export_data_path.display().to_string(),
1314            collection_count: metadata.catalog.total_collections,
1315            total_entities: metadata.catalog.total_entities,
1316        };
1317
1318        metadata
1319            .exports
1320            .retain(|export| export.name != descriptor.name);
1321        metadata.exports.push(descriptor.clone());
1322        self.prune_export_registry(&mut metadata.exports);
1323        self.persist_physical_metadata_record(&metadata)?;
1324
1325        Ok(descriptor)
1326    }
1327
1328    /// Enable or disable a physical index entry in the persisted registry.
1329    pub fn set_index_enabled(
1330        &self,
1331        name: &str,
1332        enabled: bool,
1333    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1334        let Some(status) = self.index_status(name) else {
1335            return Err(format!("index '{name}' is not present in catalog status").into());
1336        };
1337        if !status.declared {
1338            return Err(format!("index '{name}' is not declared in physical metadata").into());
1339        }
1340        self.update_physical_metadata(|metadata| {
1341            let now = SystemTime::now()
1342                .duration_since(UNIX_EPOCH)
1343                .unwrap_or_default()
1344                .as_millis();
1345            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1346                index.enabled = enabled;
1347                if !enabled {
1348                    index.build_state = "disabled".to_string();
1349                } else if index.build_state == "disabled" {
1350                    index.build_state = if index.artifact_root_page.is_some() {
1351                        "ready".to_string()
1352                    } else {
1353                        "declared-unbuilt".to_string()
1354                    };
1355                }
1356                index.last_refresh_ms = Some(now);
1357                return Some(index.clone());
1358            }
1359            None
1360        })
1361    }
1362
1363    /// Mark a declared physical index as building in the persisted registry.
1364    pub fn mark_index_building(
1365        &self,
1366        name: &str,
1367    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1368        let Some(status) = self.index_status(name) else {
1369            return Err(format!("index '{name}' is not present in catalog status").into());
1370        };
1371        if !status.declared {
1372            return Err(format!("index '{name}' is not declared in physical metadata").into());
1373        }
1374        if status.lifecycle_state == "disabled" {
1375            return Err(format!("index '{name}' is disabled").into());
1376        }
1377        self.update_physical_metadata(|metadata| {
1378            let now = SystemTime::now()
1379                .duration_since(UNIX_EPOCH)
1380                .unwrap_or_default()
1381                .as_millis();
1382            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1383                index.build_state = "building".to_string();
1384                index.last_refresh_ms = Some(now);
1385                return Some(index.clone());
1386            }
1387            None
1388        })
1389    }
1390
1391    /// Mark a declared physical index as failed in the persisted registry.
1392    pub fn fail_index(
1393        &self,
1394        name: &str,
1395    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1396        let Some(status) = self.index_status(name) else {
1397            return Err(format!("index '{name}' is not present in catalog status").into());
1398        };
1399        if !status.declared {
1400            return Err(format!("index '{name}' is not declared in physical metadata").into());
1401        }
1402        self.update_physical_metadata(|metadata| {
1403            let now = SystemTime::now()
1404                .duration_since(UNIX_EPOCH)
1405                .unwrap_or_default()
1406                .as_millis();
1407            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1408                index.build_state = "failed".to_string();
1409                index.last_refresh_ms = Some(now);
1410                return Some(index.clone());
1411            }
1412            None
1413        })
1414    }
1415
1416    /// Mark a declared physical index as stale in the persisted registry.
1417    pub fn mark_index_stale(
1418        &self,
1419        name: &str,
1420    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1421        let Some(status) = self.index_status(name) else {
1422            return Err(format!("index '{name}' is not present in catalog status").into());
1423        };
1424        if !status.declared {
1425            return Err(format!("index '{name}' is not declared in physical metadata").into());
1426        }
1427        self.update_physical_metadata(|metadata| {
1428            let now = SystemTime::now()
1429                .duration_since(UNIX_EPOCH)
1430                .unwrap_or_default()
1431                .as_millis();
1432            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1433                index.build_state = if index.enabled {
1434                    "stale".to_string()
1435                } else {
1436                    "disabled".to_string()
1437                };
1438                index.last_refresh_ms = Some(now);
1439                return Some(index.clone());
1440            }
1441            None
1442        })
1443    }
1444
1445    /// Mark a declared physical index as ready in the persisted registry.
1446    pub fn mark_index_ready(
1447        &self,
1448        name: &str,
1449    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1450        self.warmup_index(name)
1451    }
1452
1453    /// Mark a physical index as warmed up/refreshed in the persisted registry.
1454    pub fn warmup_index(
1455        &self,
1456        name: &str,
1457    ) -> Result<Option<PhysicalIndexState>, Box<dyn std::error::Error>> {
1458        let Some(status) = self.index_status(name) else {
1459            return Err(format!("index '{name}' is not present in catalog status").into());
1460        };
1461        if !status.declared {
1462            return Err(format!("index '{name}' is not declared in physical metadata").into());
1463        }
1464        if status.lifecycle_state == "disabled" {
1465            return Err(format!("index '{name}' is disabled").into());
1466        }
1467        if !status.operational {
1468            return Err(
1469                format!("index '{name}' is declared but not operationally materialized").into(),
1470            );
1471        }
1472        let warmed_artifact = self
1473            .physical_indexes()
1474            .into_iter()
1475            .find(|index| index.name == name)
1476            .map(|mut index| {
1477                self.warmup_native_vector_artifact_for_index(&index)?;
1478                self.apply_runtime_native_artifact_to_index_state(&mut index)?;
1479                Ok::<_, String>(index)
1480            })
1481            .transpose()
1482            .map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;
1483
1484        self.update_physical_metadata(|metadata| {
1485            let now = SystemTime::now()
1486                .duration_since(UNIX_EPOCH)
1487                .unwrap_or_default()
1488                .as_millis();
1489            if let Some(index) = metadata.indexes.iter_mut().find(|index| index.name == name) {
1490                if let Some(warmed) = warmed_artifact.as_ref() {
1491                    index.entries = warmed.entries;
1492                    index.estimated_memory_bytes = warmed.estimated_memory_bytes;
1493                    index.backend = warmed.backend.clone();
1494                    index.build_state = "ready".to_string();
1495                }
1496                index.last_refresh_ms = Some(now);
1497                return Some(index.clone());
1498            }
1499            None
1500        })
1501    }
1502
1503    /// Rebuild physical index metadata from the current catalog, optionally restricted to one collection.
1504    pub fn rebuild_index_registry(
1505        &self,
1506        collection: Option<&str>,
1507    ) -> Result<Vec<PhysicalIndexState>, Box<dyn std::error::Error>> {
1508        let fresh = self.reconcile_index_states_with_native_artifacts(self.physical_index_state());
1509        self.update_physical_metadata(|metadata| {
1510            let now = SystemTime::now()
1511                .duration_since(UNIX_EPOCH)
1512                .unwrap_or_default()
1513                .as_millis();
1514
1515            let mut affected = Vec::new();
1516            let declared = metadata.indexes.clone();
1517            for declared_index in declared {
1518                let matches_collection = collection.is_none_or(|collection_name| {
1519                    declared_index.collection.as_deref() == Some(collection_name)
1520                });
1521                if !matches_collection {
1522                    continue;
1523                }
1524
1525                let mut rebuilt = fresh
1526                    .iter()
1527                    .find(|index| index.name == declared_index.name)
1528                    .cloned()
1529                    .unwrap_or_else(|| {
1530                        let mut index = declared_index.clone();
1531                        index.build_state = "declared-unbuilt".to_string();
1532                        index
1533                    });
1534                rebuilt.enabled = declared_index.enabled;
1535                rebuilt.artifact_kind = rebuilt
1536                    .artifact_kind
1537                    .or_else(|| declared_index.artifact_kind.clone());
1538                rebuilt.artifact_root_page = rebuilt
1539                    .artifact_root_page
1540                    .or(declared_index.artifact_root_page);
1541                rebuilt.artifact_checksum = rebuilt
1542                    .artifact_checksum
1543                    .or(declared_index.artifact_checksum);
1544                rebuilt.build_state =
1545                    Self::finalize_rebuilt_index_build_state(&declared_index, &rebuilt);
1546                rebuilt.last_refresh_ms = Some(now);
1547
1548                if let Some(existing) = metadata
1549                    .indexes
1550                    .iter_mut()
1551                    .find(|index| index.name == rebuilt.name)
1552                {
1553                    *existing = rebuilt.clone();
1554                } else {
1555                    metadata.indexes.push(rebuilt.clone());
1556                }
1557
1558                affected.push(rebuilt);
1559            }
1560
1561            affected
1562        })
1563    }
1564
1565    fn finalize_rebuilt_index_build_state(
1566        declared: &PhysicalIndexState,
1567        rebuilt: &PhysicalIndexState,
1568    ) -> String {
1569        if !rebuilt.enabled {
1570            return "disabled".to_string();
1571        }
1572
1573        if declared.build_state == "failed" || rebuilt.build_state == "failed" {
1574            return "failed".to_string();
1575        }
1576
1577        let native_artifact_family = Self::native_artifact_kind_for_index(rebuilt.kind).is_some();
1578        if native_artifact_family {
1579            if rebuilt.artifact_root_page.is_some() && rebuilt.artifact_checksum.is_some() {
1580                return "ready".to_string();
1581            }
1582            if declared.artifact_root_page.is_some()
1583                || declared.artifact_checksum.is_some()
1584                || declared.artifact_kind.is_some()
1585            {
1586                return "stale".to_string();
1587            }
1588            return "declared-unbuilt".to_string();
1589        }
1590
1591        if rebuilt.entries > 0 {
1592            return "ready".to_string();
1593        }
1594
1595        if matches!(
1596            declared.build_state.as_str(),
1597            "stale" | "artifact-published" | "registry-loaded"
1598        ) {
1599            return "stale".to_string();
1600        }
1601
1602        "declared-unbuilt".to_string()
1603    }
1604}