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