Skip to main content

reddb_server/
catalog.rs

1//! Logical catalog structures for the unified multi-structure model.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::time::SystemTime;
5
6use crate::api::{CatalogSnapshot, CollectionStats};
7use crate::index::{IndexCatalog, IndexCatalogSnapshot, IndexKind};
8use crate::physical::{
9    CollectionContract, PhysicalAnalyticsJob, PhysicalGraphProjection, PhysicalIndexState,
10};
11use crate::storage::queue::QueueMode;
12use crate::storage::unified::UnifiedStore;
13use crate::storage::{EntityKind, SegmentManager, UnifiedEntity};
14use reddb_types::Value;
15
16pub use reddb_engine::catalog::{AnalyticalStorageConfig, SchemaMode};
17
18// Catalog AST-leaf descriptors re-homed to the neutral keystone crate (ADR
19// 0053, RQL Phase 2 S4b) so the canonical SQL AST
20// (`CreateTableQuery.{subscriptions, analytics_config}`) resolves them without a
21// `reddb-server` edge. This shim keeps `crate::catalog::{...}` valid for
22// existing call-sites.
23pub use reddb_types::catalog::{
24    AiPolicy, AnalyticsOutput, AnalyticsViewDescriptor, CollectionModel, EmbedPolicy,
25    ModerateDegradedMode, ModeratePolicy, ModerateRejectAction, SubscriptionDescriptor,
26    SubscriptionOperation, VisionPolicy,
27};
28
29#[derive(Debug, Clone)]
30pub struct CollectionDescriptor {
31    pub name: String,
32    pub model: CollectionModel,
33    pub schema_mode: SchemaMode,
34    pub contract_present: bool,
35    pub contract_origin: Option<crate::physical::ContractOrigin>,
36    pub declared_model: Option<CollectionModel>,
37    pub observed_model: CollectionModel,
38    pub queue_mode: Option<QueueMode>,
39    /// MAX_ATTEMPTS per-queue policy. Hot-fields tier — populated for
40    /// `model = queue` from the catalog snapshot for sub-ms reads by
41    /// `QueueLifecycle`. `None` for non-queue collections.
42    pub queue_max_attempts: Option<u32>,
43    /// LOCK_DEADLINE_MS per-queue policy. Hot-fields tier.
44    pub queue_lock_deadline_ms: Option<u64>,
45    /// IN_FLIGHT_CAP_PER_GROUP per-queue policy. Hot-fields tier.
46    pub queue_in_flight_cap_per_group: Option<u32>,
47    /// DLQ target collection name. `None` means on-max drop.
48    pub queue_dlq_target: Option<String>,
49    pub vector_dimension: Option<usize>,
50    pub vector_metric: Option<crate::storage::engine::distance::DistanceMetric>,
51    /// `WITH SESSION_KEY <col>` declared on the timeseries collection.
52    /// `None` for non-timeseries and for timeseries without the clause.
53    /// Issue #576 slice 1.
54    pub session_key: Option<String>,
55    /// `SESSION_GAP <duration>` in milliseconds declared on the
56    /// timeseries collection. Issue #576 slice 1.
57    pub session_gap_ms: Option<u64>,
58    /// Declarative retention duration in milliseconds. `None` means no
59    /// retention policy is enforced. Set via `ALTER COLLECTION ... SET
60    /// RETENTION <duration>`. Issue #580 slice 1.
61    pub retention_duration_ms: Option<u64>,
62    pub declared_schema_mode: Option<SchemaMode>,
63    pub observed_schema_mode: SchemaMode,
64    pub entities: usize,
65    pub cross_refs: usize,
66    pub segments: usize,
67    pub indices: Vec<String>,
68    pub declared_indices: Vec<String>,
69    pub operational_indices: Vec<String>,
70    pub indexes_in_sync: bool,
71    pub missing_operational_indices: Vec<String>,
72    pub undeclared_operational_indices: Vec<String>,
73    pub queryable_index_count: usize,
74    pub indexes_requiring_rebuild_count: usize,
75    pub queryable_graph_projection_count: usize,
76    pub graph_projections_requiring_rematerialization_count: usize,
77    pub executable_analytics_job_count: usize,
78    pub analytics_jobs_requiring_rerun_count: usize,
79    pub subscriptions: Vec<SubscriptionDescriptor>,
80    /// Analytics views declared by `WITH ANALYTICS (...)`. Empty for
81    /// collections without the clause. Sourced from the persisted
82    /// `CollectionContract` so it survives restarts (issue #800).
83    pub analytics_config: Vec<AnalyticsViewDescriptor>,
84    /// Per-collection AI policy declared by `WITH (EMBED|MODERATE|VISION
85    /// (...))`. `None` when no AI clause is present. Sourced from the
86    /// persisted `CollectionContract` so introspection survives restarts
87    /// (issue #1271).
88    pub ai_policy: Option<AiPolicy>,
89    pub resources_in_sync: bool,
90    pub attention_required: bool,
91    pub attention_score: usize,
92    pub attention_reasons: Vec<String>,
93}
94
95#[derive(Debug, Clone)]
96pub struct CatalogModelSnapshot {
97    pub summary: CatalogSnapshot,
98    pub collections: Vec<CollectionDescriptor>,
99    pub indices: IndexCatalogSnapshot,
100    pub declared_indexes: Vec<PhysicalIndexState>,
101    pub declared_graph_projections: Vec<PhysicalGraphProjection>,
102    pub declared_analytics_jobs: Vec<PhysicalAnalyticsJob>,
103    pub operational_indexes: Vec<PhysicalIndexState>,
104    pub operational_graph_projections: Vec<PhysicalGraphProjection>,
105    pub operational_analytics_jobs: Vec<PhysicalAnalyticsJob>,
106    pub index_statuses: Vec<CatalogIndexStatus>,
107    pub graph_projection_statuses: Vec<CatalogGraphProjectionStatus>,
108    pub analytics_job_statuses: Vec<CatalogAnalyticsJobStatus>,
109    pub queryable_index_count: usize,
110    pub indexes_requiring_rebuild_count: usize,
111    pub queryable_graph_projection_count: usize,
112    pub graph_projections_requiring_rematerialization_count: usize,
113    pub executable_analytics_job_count: usize,
114    pub analytics_jobs_requiring_rerun_count: usize,
115}
116
117#[derive(Debug, Clone)]
118pub struct CatalogAttentionSummary {
119    pub collections_requiring_attention: usize,
120    pub indexes_requiring_attention: usize,
121    pub graph_projections_requiring_attention: usize,
122    pub analytics_jobs_requiring_attention: usize,
123    pub top_collection: Option<CollectionDescriptor>,
124    pub top_index: Option<CatalogIndexStatus>,
125    pub top_graph_projection: Option<CatalogGraphProjectionStatus>,
126    pub top_analytics_job: Option<CatalogAnalyticsJobStatus>,
127}
128
129#[derive(Debug, Clone, Default)]
130pub struct CatalogDeclarations {
131    pub declared_indexes: Vec<PhysicalIndexState>,
132    pub declared_graph_projections: Vec<PhysicalGraphProjection>,
133    pub declared_analytics_jobs: Vec<PhysicalAnalyticsJob>,
134    pub operational_indexes: Vec<PhysicalIndexState>,
135    pub operational_graph_projections: Vec<PhysicalGraphProjection>,
136    pub operational_analytics_jobs: Vec<PhysicalAnalyticsJob>,
137}
138
139#[derive(Debug, Clone)]
140pub struct CatalogGraphProjectionStatus {
141    pub name: String,
142    pub source: Option<String>,
143    pub lifecycle_state: String,
144    pub declared: bool,
145    pub operational: bool,
146    pub in_sync: bool,
147    pub last_materialized_sequence: Option<u64>,
148    pub queryable: bool,
149    pub requires_rematerialization: bool,
150    pub dependent_job_count: usize,
151    pub active_dependent_job_count: usize,
152    pub stale_dependent_job_count: usize,
153    pub dependent_jobs_in_sync: bool,
154    pub rerun_required: bool,
155    pub attention_score: usize,
156    pub attention_reasons: Vec<String>,
157}
158
159#[derive(Debug, Clone)]
160pub struct CatalogIndexStatus {
161    pub name: String,
162    pub collection: Option<String>,
163    pub kind: String,
164    pub declared: bool,
165    pub operational: bool,
166    pub enabled: bool,
167    pub build_state: Option<String>,
168    pub artifact_state: crate::physical::ArtifactState,
169    pub lifecycle_state: String,
170    pub in_sync: bool,
171    pub queryable: bool,
172    pub requires_rebuild: bool,
173    pub attention_score: usize,
174    pub attention_reasons: Vec<String>,
175}
176
177#[derive(Debug, Clone)]
178pub struct CatalogAnalyticsJobStatus {
179    pub id: String,
180    pub kind: String,
181    pub projection: Option<String>,
182    pub state: String,
183    pub lifecycle_state: String,
184    pub declared: bool,
185    pub operational: bool,
186    pub in_sync: bool,
187    pub last_run_sequence: Option<u64>,
188    pub projection_declared: Option<bool>,
189    pub projection_operational: Option<bool>,
190    pub projection_lifecycle_state: Option<String>,
191    pub dependency_in_sync: Option<bool>,
192    pub executable: bool,
193    pub requires_rerun: bool,
194    pub attention_score: usize,
195    pub attention_reasons: Vec<String>,
196}
197
198#[derive(Debug, Clone, Default)]
199pub struct CatalogConsistencyReport {
200    pub declared_index_count: usize,
201    pub operational_index_count: usize,
202    pub declared_graph_projection_count: usize,
203    pub operational_graph_projection_count: usize,
204    pub declared_analytics_job_count: usize,
205    pub operational_analytics_job_count: usize,
206    pub missing_operational_indexes: Vec<String>,
207    pub undeclared_operational_indexes: Vec<String>,
208    pub missing_operational_graph_projections: Vec<String>,
209    pub undeclared_operational_graph_projections: Vec<String>,
210    pub missing_operational_analytics_jobs: Vec<String>,
211    pub undeclared_operational_analytics_jobs: Vec<String>,
212}
213
214pub fn snapshot_store(
215    name: &str,
216    store: &UnifiedStore,
217    index_catalog: Option<&IndexCatalog>,
218) -> CatalogModelSnapshot {
219    snapshot_store_with_declarations(name, store, index_catalog, None, None)
220}
221
222pub fn snapshot_store_with_declarations(
223    name: &str,
224    store: &UnifiedStore,
225    index_catalog: Option<&IndexCatalog>,
226    declarations: Option<&CatalogDeclarations>,
227    contracts: Option<&[CollectionContract]>,
228) -> CatalogModelSnapshot {
229    let queue_policies = store
230        .get_collection("red_queue_meta")
231        .map(|manager| queue_policies_from_entities(&manager.query_all(|_| true)))
232        .unwrap_or_default();
233    let result = snapshot_store_observations(
234        name,
235        store,
236        index_catalog,
237        declarations,
238        contracts,
239        queue_policies,
240        |manager| Ok::<_, std::convert::Infallible>(collection_observations(manager)),
241    );
242    match result {
243        Ok(snapshot) => snapshot,
244        Err(never) => match never {},
245    }
246}
247
248/// Authoritative catalog reads include the immutable disk base and the resident
249/// overlay. The caller admits the per-row decode allowance before scanning.
250pub fn try_snapshot_store_with_declarations(
251    name: &str,
252    store: &UnifiedStore,
253    index_catalog: Option<&IndexCatalog>,
254    declarations: Option<&CatalogDeclarations>,
255    contracts: Option<&[CollectionContract]>,
256    read_bytes_max: usize,
257) -> Result<CatalogModelSnapshot, crate::storage::StoreError> {
258    // Keep base/overlay shadowing stable without an O(dirty IDs) workspace.
259    // Admission is performed by the caller before taking the publication fence.
260    let _publication = store.vault_publication_lock.lock();
261    let mut queue_policies = HashMap::new();
262    if let Some(manager) = store.get_collection("red_queue_meta") {
263        manager.try_for_each_entity_unordered(read_bytes_max, |entity| {
264            if let Some((queue, policy)) = queue_policy_from_entity(entity) {
265                queue_policies.insert(queue, policy);
266            }
267            true
268        })?;
269    }
270    snapshot_store_observations(
271        name,
272        store,
273        index_catalog,
274        declarations,
275        contracts,
276        queue_policies,
277        |manager| {
278            let mut observations = CollectionObservations::default();
279            manager.try_for_each_entity_unordered(read_bytes_max, |entity| {
280                observations.observe(entity);
281                true
282            })?;
283            Ok(observations.finish())
284        },
285    )
286}
287
288fn snapshot_store_observations<E>(
289    name: &str,
290    store: &UnifiedStore,
291    index_catalog: Option<&IndexCatalog>,
292    declarations: Option<&CatalogDeclarations>,
293    contracts: Option<&[CollectionContract]>,
294    queue_policies: HashMap<String, QueuePolicySnapshot>,
295    mut observe: impl FnMut(&SegmentManager) -> Result<(CollectionModel, usize, usize), E>,
296) -> Result<CatalogModelSnapshot, E> {
297    let index_statuses = index_statuses(declarations);
298    let graph_projection_statuses = graph_projection_statuses(declarations);
299    let analytics_job_statuses = analytics_job_statuses(declarations);
300
301    let mut stats_by_collection = BTreeMap::new();
302    let mut collections = Vec::new();
303
304    for collection_name in store.list_collections() {
305        let manager = store.get_collection(&collection_name);
306        let (inferred_model, entity_count, cross_refs) = manager
307            .as_ref()
308            .map(|manager| observe(manager))
309            .transpose()?
310            .unwrap_or((CollectionModel::Mixed, 0, 0));
311        let inferred_schema_mode = infer_schema_mode(inferred_model);
312        let contract = collection_contract(&collection_name, contracts);
313        let model = contract
314            .map(|contract| contract.declared_model)
315            .unwrap_or(inferred_model);
316        let manager_stats = manager.map(|manager| manager.stats());
317        let segments = manager_stats
318            .map(|stats| stats.growing_count + stats.sealed_count + stats.archived_count)
319            .unwrap_or(0);
320
321        stats_by_collection.insert(
322            collection_name.clone(),
323            CollectionStats {
324                entities: entity_count,
325                cross_refs,
326                segments,
327            },
328        );
329
330        let declared_indices = declared_indices(&collection_name, declarations);
331        let operational_indices =
332            operational_indices(&collection_name, index_catalog, declarations);
333        let (indexes_in_sync, missing_operational_indices, undeclared_operational_indices) =
334            collection_index_consistency(&declared_indices, &operational_indices);
335        let (queryable_index_count, indexes_requiring_rebuild_count, indexes_locally_in_sync) =
336            collection_index_readiness(&collection_name, &index_statuses);
337        let (
338            queryable_graph_projection_count,
339            graph_projections_requiring_rematerialization_count,
340            graph_projections_locally_in_sync,
341        ) = collection_graph_projection_readiness(&collection_name, &graph_projection_statuses);
342        let (
343            executable_analytics_job_count,
344            analytics_jobs_requiring_rerun_count,
345            analytics_jobs_locally_in_sync,
346        ) = collection_analytics_job_readiness(
347            &collection_name,
348            &graph_projection_statuses,
349            &analytics_job_statuses,
350        );
351        let resources_in_sync = indexes_in_sync
352            && indexes_locally_in_sync
353            && graph_projections_locally_in_sync
354            && analytics_jobs_locally_in_sync;
355        let attention_required = !resources_in_sync
356            || indexes_requiring_rebuild_count > 0
357            || graph_projections_requiring_rematerialization_count > 0
358            || analytics_jobs_requiring_rerun_count > 0;
359        let mut attention_reasons = Vec::new();
360        if !indexes_in_sync || !indexes_locally_in_sync {
361            attention_reasons.push("index_drift".to_string());
362        }
363        if indexes_requiring_rebuild_count > 0 {
364            attention_reasons.push("indexes_require_rebuild".to_string());
365        }
366        if !graph_projections_locally_in_sync {
367            attention_reasons.push("graph_projection_drift".to_string());
368        }
369        if graph_projections_requiring_rematerialization_count > 0 {
370            attention_reasons.push("graph_projections_require_rematerialization".to_string());
371        }
372        if !analytics_jobs_locally_in_sync {
373            attention_reasons.push("analytics_job_drift".to_string());
374        }
375        if analytics_jobs_requiring_rerun_count > 0 {
376            attention_reasons.push("analytics_jobs_require_rerun".to_string());
377        }
378        let attention_score = indexes_requiring_rebuild_count.saturating_mul(3)
379            + graph_projections_requiring_rematerialization_count.saturating_mul(4)
380            + analytics_jobs_requiring_rerun_count.saturating_mul(2)
381            + usize::from(!resources_in_sync);
382
383        collections.push(CollectionDescriptor {
384            name: collection_name.clone(),
385            model,
386            schema_mode: contract
387                .map(|contract| contract.schema_mode)
388                .unwrap_or(inferred_schema_mode),
389            contract_present: contract.is_some(),
390            contract_origin: contract.map(|contract| contract.origin),
391            declared_model: contract.map(|contract| contract.declared_model),
392            observed_model: inferred_model,
393            queue_mode: if model == CollectionModel::Queue {
394                Some(
395                    queue_policies
396                        .get(&collection_name)
397                        .map(|p| p.mode)
398                        .unwrap_or_default(),
399                )
400            } else {
401                None
402            },
403            queue_max_attempts: if model == CollectionModel::Queue {
404                Some(
405                    queue_policies
406                        .get(&collection_name)
407                        .map(|p| p.max_attempts)
408                        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS),
409                )
410            } else {
411                None
412            },
413            queue_lock_deadline_ms: if model == CollectionModel::Queue {
414                Some(
415                    queue_policies
416                        .get(&collection_name)
417                        .map(|p| p.lock_deadline_ms)
418                        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS),
419                )
420            } else {
421                None
422            },
423            queue_in_flight_cap_per_group: if model == CollectionModel::Queue {
424                Some(
425                    queue_policies
426                        .get(&collection_name)
427                        .map(|p| p.in_flight_cap_per_group)
428                        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP),
429                )
430            } else {
431                None
432            },
433            queue_dlq_target: if model == CollectionModel::Queue {
434                queue_policies
435                    .get(&collection_name)
436                    .and_then(|p| p.dlq_target.clone())
437            } else {
438                None
439            },
440            vector_dimension: contract.and_then(|contract| contract.vector_dimension),
441            vector_metric: contract.and_then(|contract| contract.vector_metric),
442            session_key: contract.and_then(|contract| contract.session_key.clone()),
443            session_gap_ms: contract.and_then(|contract| contract.session_gap_ms),
444            retention_duration_ms: contract.and_then(|contract| contract.retention_duration_ms),
445            declared_schema_mode: contract.map(|contract| contract.schema_mode),
446            observed_schema_mode: inferred_schema_mode,
447            entities: entity_count,
448            cross_refs,
449            segments,
450            indices: infer_indices(&collection_name, model, index_catalog, declarations),
451            declared_indices,
452            operational_indices,
453            indexes_in_sync,
454            missing_operational_indices,
455            undeclared_operational_indices,
456            queryable_index_count,
457            indexes_requiring_rebuild_count,
458            queryable_graph_projection_count,
459            graph_projections_requiring_rematerialization_count,
460            executable_analytics_job_count,
461            analytics_jobs_requiring_rerun_count,
462            subscriptions: contract
463                .map(|contract| contract.subscriptions.clone())
464                .unwrap_or_default(),
465            analytics_config: contract
466                .map(|contract| contract.analytics_config.clone())
467                .unwrap_or_default(),
468            ai_policy: contract.and_then(|contract| contract.ai_policy.clone()),
469            resources_in_sync,
470            attention_required,
471            attention_score,
472            attention_reasons,
473        });
474    }
475
476    collections.sort_by(|left, right| left.name.cmp(&right.name));
477
478    let summary = CatalogSnapshot {
479        name: name.to_string(),
480        total_entities: stats_by_collection
481            .values()
482            .map(|stats| stats.entities)
483            .sum(),
484        total_collections: stats_by_collection.len(),
485        stats_by_collection,
486        updated_at: SystemTime::now(),
487    };
488
489    Ok(CatalogModelSnapshot {
490        summary,
491        collections,
492        indices: index_catalog
493            .map(IndexCatalog::snapshot)
494            .unwrap_or_default(),
495        declared_indexes: declarations
496            .map(|declarations| declarations.declared_indexes.clone())
497            .unwrap_or_default(),
498        declared_graph_projections: declarations
499            .map(|declarations| declarations.declared_graph_projections.clone())
500            .unwrap_or_default(),
501        declared_analytics_jobs: declarations
502            .map(|declarations| declarations.declared_analytics_jobs.clone())
503            .unwrap_or_default(),
504        operational_indexes: declarations
505            .map(|declarations| declarations.operational_indexes.clone())
506            .unwrap_or_default(),
507        operational_graph_projections: declarations
508            .map(|declarations| declarations.operational_graph_projections.clone())
509            .unwrap_or_default(),
510        operational_analytics_jobs: declarations
511            .map(|declarations| declarations.operational_analytics_jobs.clone())
512            .unwrap_or_default(),
513        queryable_index_count: index_statuses
514            .iter()
515            .filter(|status| status.queryable)
516            .count(),
517        indexes_requiring_rebuild_count: index_statuses
518            .iter()
519            .filter(|status| status.requires_rebuild)
520            .count(),
521        queryable_graph_projection_count: graph_projection_statuses
522            .iter()
523            .filter(|status| status.queryable)
524            .count(),
525        graph_projections_requiring_rematerialization_count: graph_projection_statuses
526            .iter()
527            .filter(|status| status.requires_rematerialization)
528            .count(),
529        executable_analytics_job_count: analytics_job_statuses
530            .iter()
531            .filter(|status| status.executable)
532            .count(),
533        analytics_jobs_requiring_rerun_count: analytics_job_statuses
534            .iter()
535            .filter(|status| status.requires_rerun)
536            .count(),
537        index_statuses,
538        graph_projection_statuses,
539        analytics_job_statuses,
540    })
541}
542
543/// Hot-fields snapshot of per-queue policy, materialised at catalog
544/// snapshot time so `QueueLifecycle` (and the descriptor) can read
545/// values without re-scanning `red_queue_meta`.
546#[derive(Debug, Clone)]
547pub struct QueuePolicySnapshot {
548    pub mode: QueueMode,
549    pub max_attempts: u32,
550    pub lock_deadline_ms: u64,
551    pub in_flight_cap_per_group: u32,
552    pub dlq_target: Option<String>,
553}
554
555fn queue_policies_from_entities(meta: &[UnifiedEntity]) -> HashMap<String, QueuePolicySnapshot> {
556    meta.iter().filter_map(queue_policy_from_entity).collect()
557}
558
559fn queue_policy_from_entity(entity: &UnifiedEntity) -> Option<(String, QueuePolicySnapshot)> {
560    let row = entity.data.as_row()?;
561    if row_text(row, "kind").as_deref() != Some("queue_config") {
562        return None;
563    }
564    let queue = row_text(row, "queue")?;
565    let mode = row_text(row, "mode")
566        .as_deref()
567        .and_then(QueueMode::parse)
568        .unwrap_or_default();
569    let max_attempts = row_u64(row, "max_attempts")
570        .map(|v| v as u32)
571        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS);
572    let lock_deadline_ms =
573        row_u64(row, "lock_deadline_ms").unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS);
574    let in_flight_cap_per_group = row_u64(row, "in_flight_cap_per_group")
575        .map(|v| v as u32)
576        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP);
577    let dlq_target = row_text(row, "dlq");
578    Some((
579        queue,
580        QueuePolicySnapshot {
581            mode,
582            max_attempts,
583            lock_deadline_ms,
584            in_flight_cap_per_group,
585            dlq_target,
586        },
587    ))
588}
589
590fn row_u64(row: &crate::storage::unified::entity::RowData, key: &str) -> Option<u64> {
591    match row.get_field(key) {
592        Some(Value::UnsignedInteger(v)) => Some(*v),
593        Some(Value::Integer(v)) if *v >= 0 => Some(*v as u64),
594        _ => None,
595    }
596}
597
598fn row_text(row: &crate::storage::unified::entity::RowData, key: &str) -> Option<String> {
599    match row.get_field(key) {
600        Some(Value::Text(value)) => Some(value.to_string()),
601        _ => None,
602    }
603}
604
605fn collection_contract<'a>(
606    collection_name: &str,
607    contracts: Option<&'a [CollectionContract]>,
608) -> Option<&'a CollectionContract> {
609    contracts.and_then(|contracts| {
610        contracts
611            .iter()
612            .find(|contract| contract.name == collection_name)
613    })
614}
615
616#[derive(Default)]
617struct CollectionObservations {
618    has_table: bool,
619    has_graph: bool,
620    has_vector: bool,
621    entities: usize,
622    cross_refs: usize,
623}
624
625impl CollectionObservations {
626    fn observe(&mut self, entity: &UnifiedEntity) {
627        self.entities += 1;
628        self.cross_refs += entity.cross_refs().len();
629        match &entity.kind {
630            EntityKind::TableRow { .. } => self.has_table = true,
631            EntityKind::GraphNode(_) | EntityKind::GraphEdge(_) => self.has_graph = true,
632            EntityKind::Vector { .. } => self.has_vector = true,
633            EntityKind::TimeSeriesPoint(_) | EntityKind::QueueMessage { .. } => {}
634        }
635    }
636
637    fn finish(self) -> (CollectionModel, usize, usize) {
638        let model = match (self.has_table, self.has_graph, self.has_vector) {
639            (true, false, false) => CollectionModel::Table,
640            (false, true, false) => CollectionModel::Graph,
641            (false, false, true) => CollectionModel::Vector,
642            _ => CollectionModel::Mixed,
643        };
644        (model, self.entities, self.cross_refs)
645    }
646}
647
648fn collection_observations(manager: &SegmentManager) -> (CollectionModel, usize, usize) {
649    let mut observations = CollectionObservations::default();
650    manager.for_each_entity(|entity| {
651        observations.observe(entity);
652        true
653    });
654    observations.finish()
655}
656
657fn infer_schema_mode(model: CollectionModel) -> SchemaMode {
658    match model {
659        CollectionModel::Table => SchemaMode::Strict,
660        CollectionModel::Graph | CollectionModel::Vector => SchemaMode::SemiStructured,
661        CollectionModel::Document
662        | CollectionModel::Hll
663        | CollectionModel::Sketch
664        | CollectionModel::Filter
665        | CollectionModel::Kv
666        | CollectionModel::Config
667        | CollectionModel::Vault
668        | CollectionModel::Mixed => SchemaMode::Dynamic,
669        CollectionModel::TimeSeries | CollectionModel::Metrics => SchemaMode::SemiStructured,
670        CollectionModel::Queue => SchemaMode::Dynamic,
671    }
672}
673
674fn infer_indices(
675    collection_name: &str,
676    model: CollectionModel,
677    index_catalog: Option<&IndexCatalog>,
678    declarations: Option<&CatalogDeclarations>,
679) -> Vec<String> {
680    let declared = declared_indices(collection_name, declarations);
681    if !declared.is_empty() {
682        return declared;
683    }
684
685    let available = index_catalog
686        .map(IndexCatalog::snapshot)
687        .unwrap_or_default();
688    let mut selected = Vec::new();
689
690    for metric in available {
691        let relevant = matches!(
692            (model, metric.kind),
693            (_, IndexKind::BTree)
694                | (CollectionModel::Graph, IndexKind::GraphAdjacency)
695                | (CollectionModel::Vector, IndexKind::VectorHnsw)
696                | (CollectionModel::Vector, IndexKind::VectorInverted)
697                | (CollectionModel::Vector, IndexKind::VectorTurbo)
698                | (CollectionModel::Document, IndexKind::FullText)
699                | (CollectionModel::Document, IndexKind::DocumentPathValue)
700                | (CollectionModel::Kv, IndexKind::Hash)
701                | (CollectionModel::Config, IndexKind::Hash)
702                | (CollectionModel::Vault, IndexKind::Hash)
703                | (CollectionModel::Mixed, _)
704        );
705
706        if relevant && metric.enabled {
707            selected.push(metric.name);
708        }
709    }
710
711    selected
712}
713
714fn declared_indices(
715    collection_name: &str,
716    declarations: Option<&CatalogDeclarations>,
717) -> Vec<String> {
718    let mut selected = declarations
719        .map(|declarations| {
720            declarations
721                .declared_indexes
722                .iter()
723                .filter(|index| index.collection.as_deref() == Some(collection_name))
724                .map(|index| index.name.clone())
725                .collect::<Vec<_>>()
726        })
727        .unwrap_or_default();
728    selected.sort();
729    selected.dedup();
730    selected
731}
732
733fn operational_indices(
734    collection_name: &str,
735    index_catalog: Option<&IndexCatalog>,
736    declarations: Option<&CatalogDeclarations>,
737) -> Vec<String> {
738    if let Some(declarations) = declarations {
739        let mut selected = declarations
740            .operational_indexes
741            .iter()
742            .filter(|index| index.collection.as_deref() == Some(collection_name))
743            .map(|index| index.name.clone())
744            .collect::<Vec<_>>();
745        selected.sort();
746        selected.dedup();
747        return selected;
748    }
749
750    let mut selected = index_catalog
751        .map(IndexCatalog::snapshot)
752        .unwrap_or_default()
753        .into_iter()
754        .filter(|metric| metric.enabled)
755        .filter_map(|metric| {
756            if metric.name.starts_with(collection_name) {
757                Some(metric.name)
758            } else {
759                None
760            }
761        })
762        .collect::<Vec<_>>();
763    selected.sort();
764    selected.dedup();
765    selected
766}
767
768fn collection_index_consistency(
769    declared_indices: &[String],
770    operational_indices: &[String],
771) -> (bool, Vec<String>, Vec<String>) {
772    let declared = declared_indices.iter().cloned().collect::<BTreeSet<_>>();
773    let operational = operational_indices.iter().cloned().collect::<BTreeSet<_>>();
774    let missing_operational_indices = declared
775        .difference(&operational)
776        .cloned()
777        .collect::<Vec<_>>();
778    let undeclared_operational_indices = operational
779        .difference(&declared)
780        .cloned()
781        .collect::<Vec<_>>();
782    (
783        missing_operational_indices.is_empty() && undeclared_operational_indices.is_empty(),
784        missing_operational_indices,
785        undeclared_operational_indices,
786    )
787}
788
789fn collection_index_readiness(
790    collection_name: &str,
791    statuses: &[CatalogIndexStatus],
792) -> (usize, usize, bool) {
793    let local = statuses
794        .iter()
795        .filter(|status| status.collection.as_deref() == Some(collection_name))
796        .collect::<Vec<_>>();
797    let queryable_count = local.iter().filter(|status| status.queryable).count();
798    let requires_rebuild_count = local
799        .iter()
800        .filter(|status| status.requires_rebuild)
801        .count();
802    let locally_in_sync = local.iter().all(|status| status.in_sync);
803    (queryable_count, requires_rebuild_count, locally_in_sync)
804}
805
806fn collection_graph_projection_readiness(
807    collection_name: &str,
808    statuses: &[CatalogGraphProjectionStatus],
809) -> (usize, usize, bool) {
810    let local = statuses
811        .iter()
812        .filter(|status| status.source.as_deref() == Some(collection_name))
813        .collect::<Vec<_>>();
814    let queryable_count = local.iter().filter(|status| status.queryable).count();
815    let requires_rematerialization_count = local
816        .iter()
817        .filter(|status| status.requires_rematerialization)
818        .count();
819    let locally_in_sync = local
820        .iter()
821        .all(|status| status.in_sync && status.dependent_jobs_in_sync);
822    (
823        queryable_count,
824        requires_rematerialization_count,
825        locally_in_sync,
826    )
827}
828
829fn collection_analytics_job_readiness(
830    collection_name: &str,
831    projection_statuses: &[CatalogGraphProjectionStatus],
832    job_statuses: &[CatalogAnalyticsJobStatus],
833) -> (usize, usize, bool) {
834    let local = job_statuses
835        .iter()
836        .filter(|status| {
837            let Some(projection_name) = status.projection.as_deref() else {
838                return false;
839            };
840            projection_statuses
841                .iter()
842                .find(|projection| projection.name == projection_name)
843                .and_then(|projection| projection.source.as_deref())
844                == Some(collection_name)
845        })
846        .collect::<Vec<_>>();
847    let executable_count = local.iter().filter(|status| status.executable).count();
848    let requires_rerun_count = local.iter().filter(|status| status.requires_rerun).count();
849    let locally_in_sync = local
850        .iter()
851        .all(|status| status.in_sync && status.dependency_in_sync.unwrap_or(true));
852    (executable_count, requires_rerun_count, locally_in_sync)
853}
854
855fn graph_projection_statuses(
856    declarations: Option<&CatalogDeclarations>,
857) -> Vec<CatalogGraphProjectionStatus> {
858    let declared = declarations
859        .map(|declarations| declarations.declared_graph_projections.as_slice())
860        .unwrap_or(&[]);
861    let operational = declarations
862        .map(|declarations| declarations.operational_graph_projections.as_slice())
863        .unwrap_or(&[]);
864    let declared_jobs = declarations
865        .map(|declarations| declarations.declared_analytics_jobs.as_slice())
866        .unwrap_or(&[]);
867    let operational_jobs = declarations
868        .map(|declarations| declarations.operational_analytics_jobs.as_slice())
869        .unwrap_or(&[]);
870
871    let mut names = declared
872        .iter()
873        .map(|projection| projection.name.clone())
874        .chain(operational.iter().map(|projection| projection.name.clone()))
875        .collect::<Vec<_>>();
876    names.sort();
877    names.dedup();
878
879    names
880        .into_iter()
881        .map(|name| {
882            let declared_projection = declared.iter().find(|projection| projection.name == name);
883            let operational_projection = operational
884                .iter()
885                .find(|projection| projection.name == name);
886            let declared_present = declared_projection.is_some();
887            let operational_present = operational_projection.is_some();
888            let mut dependent_job_ids = BTreeSet::new();
889            let mut dependent_job_count = 0usize;
890            let mut active_dependent_job_count = 0usize;
891            let mut stale_dependent_job_count = 0usize;
892            for job in declared_jobs
893                .iter()
894                .chain(operational_jobs.iter())
895                .filter(|job| job.projection.as_deref() == Some(name.as_str()))
896            {
897                if !dependent_job_ids.insert(job.id.clone()) {
898                    continue;
899                }
900                dependent_job_count += 1;
901                if matches!(job.state.as_str(), "queued" | "running" | "completed") {
902                    active_dependent_job_count += 1;
903                }
904                if job.state == "stale" {
905                    stale_dependent_job_count += 1;
906                }
907            }
908            let lifecycle_state = match (
909                declared_present,
910                operational_present,
911                declared_projection
912                    .map(|projection| projection.state.as_str())
913                    .or_else(|| operational_projection.map(|projection| projection.state.as_str()))
914                    .unwrap_or_default(),
915            ) {
916                (true, _, "failed") => "failed",
917                (true, true, "stale") => "stale",
918                (true, _, "materializing") => "materializing",
919                (true, true, "materialized") => "materialized",
920                (true, true, _) => "materialized",
921                (false, true, _) => "orphaned-operational",
922                (true, false, _) => "declared",
923                (false, false, _) => "unknown",
924            }
925            .to_string();
926            let queryable = declared_present
927                && operational_present
928                && matches!(
929                    declared_projection
930                        .map(|projection| projection.state.as_str())
931                        .or_else(
932                            || operational_projection.map(|projection| projection.state.as_str())
933                        )
934                        .unwrap_or_default(),
935                    "materialized"
936                );
937            let requires_rematerialization = matches!(
938                declared_projection
939                    .map(|projection| projection.state.as_str())
940                    .or_else(|| operational_projection.map(|projection| projection.state.as_str()))
941                    .unwrap_or_default(),
942                "declared" | "materializing" | "failed" | "stale"
943            );
944            let mut attention_reasons = Vec::new();
945            if !declared_present || !operational_present {
946                attention_reasons.push("declaration_drift".to_string());
947            }
948            if requires_rematerialization {
949                attention_reasons.push("requires_rematerialization".to_string());
950            }
951            if stale_dependent_job_count > 0 {
952                attention_reasons.push("dependent_jobs_stale".to_string());
953            }
954            let attention_score = stale_dependent_job_count.saturating_mul(2)
955                + usize::from(requires_rematerialization).saturating_mul(4)
956                + usize::from(!declared_present || !operational_present)
957                + usize::from(!queryable);
958            CatalogGraphProjectionStatus {
959                name,
960                source: declared_projection
961                    .map(|projection| projection.source.clone())
962                    .or_else(|| operational_projection.map(|projection| projection.source.clone())),
963                lifecycle_state,
964                declared: declared_present,
965                operational: operational_present,
966                in_sync: declared_present == operational_present,
967                last_materialized_sequence: declared_projection
968                    .and_then(|projection| projection.last_materialized_sequence)
969                    .or_else(|| {
970                        operational_projection
971                            .and_then(|projection| projection.last_materialized_sequence)
972                    }),
973                queryable,
974                requires_rematerialization,
975                dependent_job_count,
976                active_dependent_job_count,
977                stale_dependent_job_count,
978                dependent_jobs_in_sync: stale_dependent_job_count == 0,
979                rerun_required: stale_dependent_job_count > 0,
980                attention_score,
981                attention_reasons,
982            }
983        })
984        .collect()
985}
986
987fn index_statuses(declarations: Option<&CatalogDeclarations>) -> Vec<CatalogIndexStatus> {
988    let declared = declarations
989        .map(|declarations| declarations.declared_indexes.as_slice())
990        .unwrap_or(&[]);
991    let operational = declarations
992        .map(|declarations| declarations.operational_indexes.as_slice())
993        .unwrap_or(&[]);
994
995    let mut names = declared
996        .iter()
997        .map(|index| index.name.clone())
998        .chain(operational.iter().map(|index| index.name.clone()))
999        .collect::<Vec<_>>();
1000    names.sort();
1001    names.dedup();
1002
1003    names
1004        .into_iter()
1005        .map(|name| {
1006            let declared_index = declared.iter().find(|index| index.name == name);
1007            let operational_index = operational.iter().find(|index| index.name == name);
1008            let collection = declared_index
1009                .and_then(|index| index.collection.clone())
1010                .or_else(|| operational_index.and_then(|index| index.collection.clone()));
1011            let kind = declared_index
1012                .map(|index| index_kind_string(index.kind))
1013                .or_else(|| operational_index.map(|index| index_kind_string(index.kind)))
1014                .unwrap_or_default();
1015            let enabled = declared_index
1016                .map(|index| index.enabled)
1017                .or_else(|| operational_index.map(|index| index.enabled))
1018                .unwrap_or(false);
1019            let build_state = operational_index
1020                .map(|index| index.build_state.clone())
1021                .or_else(|| declared_index.map(|index| index.build_state.clone()));
1022            let declared_present = declared_index.is_some();
1023            let operational_present = operational_index.is_some();
1024            let lifecycle_state = index_lifecycle_state(
1025                declared_present,
1026                operational_present,
1027                enabled,
1028                build_state.as_deref(),
1029            );
1030
1031            let mut attention_reasons = Vec::new();
1032            if declared_present != operational_present {
1033                attention_reasons.push("declaration_drift".to_string());
1034            }
1035            if !enabled && declared_present {
1036                attention_reasons.push("disabled".to_string());
1037            }
1038            if matches!(build_state.as_deref().unwrap_or_default(), "failed") {
1039                attention_reasons.push("failed".to_string());
1040            }
1041            if matches!(build_state.as_deref().unwrap_or_default(), "stale") {
1042                attention_reasons.push("stale".to_string());
1043            }
1044            if matches!(
1045                build_state.as_deref().unwrap_or_default(),
1046                "building" | "declared-unbuilt"
1047            ) {
1048                attention_reasons.push("requires_rebuild".to_string());
1049            }
1050            let artifact_state = crate::physical::ArtifactState::from_build_state(
1051                build_state.as_deref().unwrap_or("declared-unbuilt"),
1052                enabled,
1053            );
1054            let queryable =
1055                declared_present && operational_present && artifact_state.is_queryable();
1056            let requires_rebuild = matches!(
1057                build_state.as_deref().unwrap_or_default(),
1058                "declared-unbuilt" | "building" | "stale" | "failed"
1059            );
1060            let attention_score = usize::from(requires_rebuild).saturating_mul(3)
1061                + usize::from(!enabled && declared_present)
1062                + usize::from(declared_present != operational_present)
1063                + usize::from(!queryable);
1064
1065            CatalogIndexStatus {
1066                name,
1067                collection,
1068                kind,
1069                declared: declared_present,
1070                operational: operational_present,
1071                enabled,
1072                build_state,
1073                artifact_state,
1074                lifecycle_state,
1075                in_sync: declared_present == operational_present,
1076                queryable,
1077                requires_rebuild,
1078                attention_score,
1079                attention_reasons,
1080            }
1081        })
1082        .collect()
1083}
1084
1085fn index_lifecycle_state(
1086    declared: bool,
1087    operational: bool,
1088    enabled: bool,
1089    build_state: Option<&str>,
1090) -> String {
1091    if !declared && operational {
1092        return "orphaned-operational".to_string();
1093    }
1094    if declared && !enabled {
1095        return "disabled".to_string();
1096    }
1097    if !declared {
1098        return "unknown".to_string();
1099    }
1100    if !operational {
1101        return "declared".to_string();
1102    }
1103
1104    match build_state.unwrap_or_default() {
1105        "ready" | "catalog-derived" | "registry-loaded" => "ready".to_string(),
1106        "failed" => "failed".to_string(),
1107        "stale" => "stale".to_string(),
1108        "declared-unbuilt" => "declared".to_string(),
1109        "metadata-only" | "artifact-published" => "building".to_string(),
1110        _ => "building".to_string(),
1111    }
1112}
1113
1114fn index_kind_string(kind: IndexKind) -> String {
1115    match kind {
1116        IndexKind::BTree => "btree",
1117        IndexKind::Hash => "hash",
1118        IndexKind::Bitmap => "bitmap",
1119        IndexKind::VectorHnsw => "vector.hnsw",
1120        IndexKind::VectorInverted => "vector.inverted",
1121        IndexKind::VectorTurbo => "vector.turbo",
1122        IndexKind::GraphAdjacency => "graph.adjacency",
1123        IndexKind::FullText => "text.fulltext",
1124        IndexKind::DocumentPathValue => "document.pathvalue",
1125        IndexKind::HybridSearch => "search.hybrid",
1126    }
1127    .to_string()
1128}
1129
1130fn analytics_job_statuses(
1131    declarations: Option<&CatalogDeclarations>,
1132) -> Vec<CatalogAnalyticsJobStatus> {
1133    let declared = declarations
1134        .map(|declarations| declarations.declared_analytics_jobs.as_slice())
1135        .unwrap_or(&[]);
1136    let operational = declarations
1137        .map(|declarations| declarations.operational_analytics_jobs.as_slice())
1138        .unwrap_or(&[]);
1139    let declared_projections = declarations
1140        .map(|declarations| declarations.declared_graph_projections.as_slice())
1141        .unwrap_or(&[]);
1142    let operational_projections = declarations
1143        .map(|declarations| declarations.operational_graph_projections.as_slice())
1144        .unwrap_or(&[]);
1145
1146    let mut ids = declared
1147        .iter()
1148        .map(|job| job.id.clone())
1149        .chain(operational.iter().map(|job| job.id.clone()))
1150        .collect::<Vec<_>>();
1151    ids.sort();
1152    ids.dedup();
1153
1154    ids.into_iter()
1155        .map(|id| {
1156            let declared_job = declared.iter().find(|job| job.id == id);
1157            let operational_job = operational.iter().find(|job| job.id == id);
1158            let kind = declared_job
1159                .map(|job| job.kind.clone())
1160                .or_else(|| operational_job.map(|job| job.kind.clone()))
1161                .unwrap_or_default();
1162            let projection = declared_job
1163                .and_then(|job| job.projection.clone())
1164                .or_else(|| operational_job.and_then(|job| job.projection.clone()));
1165            let state = declared_job
1166                .map(|job| job.state.clone())
1167                .or_else(|| operational_job.map(|job| job.state.clone()))
1168                .unwrap_or_default();
1169            let declared_present = declared_job.is_some();
1170            let operational_present = operational_job.is_some();
1171            let last_run_sequence = declared_job
1172                .and_then(|job| job.last_run_sequence)
1173                .or_else(|| operational_job.and_then(|job| job.last_run_sequence));
1174            let projection_declared = projection.as_ref().map(|projection_name| {
1175                declared_projections
1176                    .iter()
1177                    .any(|projection| projection.name == *projection_name)
1178            });
1179            let projection_operational = projection.as_ref().map(|projection_name| {
1180                operational_projections
1181                    .iter()
1182                    .any(|projection| projection.name == *projection_name)
1183            });
1184            let projection_lifecycle = projection.as_ref().and_then(|projection_name| {
1185                declared_projections
1186                    .iter()
1187                    .find(|projection| projection.name == *projection_name)
1188                    .map(|projection| projection.state.as_str())
1189                    .or_else(|| {
1190                        operational_projections
1191                            .iter()
1192                            .find(|projection| projection.name == *projection_name)
1193                            .map(|projection| projection.state.as_str())
1194                    })
1195            });
1196            let dependency_in_sync = projection.as_ref().map(|_| {
1197                matches!(projection_lifecycle, Some("materialized"))
1198                    && projection_operational == Some(true)
1199            });
1200            let lifecycle_state = match (declared_present, operational_present, state.as_str()) {
1201                (true, false, _) => "declared",
1202                (true, true, _)
1203                    if matches!(
1204                        projection_lifecycle,
1205                        Some("stale" | "failed" | "materializing" | "declared")
1206                    ) =>
1207                {
1208                    "stale"
1209                }
1210                (true, true, "completed") => "completed",
1211                (true, true, "running") => "running",
1212                (true, true, "failed") => "failed",
1213                (true, true, "queued") => "queued",
1214                (true, true, "stale") => "stale",
1215                (true, true, _) => "materialized",
1216                (false, true, _) => "orphaned-operational",
1217                (false, false, _) => "unknown",
1218            }
1219            .to_string();
1220            let executable = declared_present
1221                && operational_present
1222                && !matches!(state.as_str(), "failed" | "stale")
1223                && dependency_in_sync.unwrap_or(true);
1224            let requires_rerun =
1225                matches!(state.as_str(), "stale" | "failed") || dependency_in_sync == Some(false);
1226            let mut attention_reasons = Vec::new();
1227            if declared_present != operational_present {
1228                attention_reasons.push("declaration_drift".to_string());
1229            }
1230            if matches!(state.as_str(), "failed") {
1231                attention_reasons.push("failed".to_string());
1232            }
1233            if matches!(state.as_str(), "stale") {
1234                attention_reasons.push("stale".to_string());
1235            }
1236            if dependency_in_sync == Some(false) {
1237                attention_reasons.push("dependency_drift".to_string());
1238            }
1239            if requires_rerun {
1240                attention_reasons.push("requires_rerun".to_string());
1241            }
1242            let attention_score = usize::from(requires_rerun).saturating_mul(3)
1243                + usize::from(dependency_in_sync == Some(false)).saturating_mul(2)
1244                + usize::from(declared_present != operational_present)
1245                + usize::from(!executable);
1246            CatalogAnalyticsJobStatus {
1247                id,
1248                kind,
1249                projection,
1250                state: state.clone(),
1251                lifecycle_state,
1252                declared: declared_present,
1253                operational: operational_present,
1254                in_sync: declared_present == operational_present,
1255                last_run_sequence,
1256                projection_declared,
1257                projection_operational,
1258                projection_lifecycle_state: projection_lifecycle.map(str::to_string),
1259                dependency_in_sync,
1260                executable,
1261                requires_rerun,
1262                attention_score,
1263                attention_reasons,
1264            }
1265        })
1266        .collect()
1267}
1268
1269pub fn consistency_report(snapshot: &CatalogModelSnapshot) -> CatalogConsistencyReport {
1270    let declared_indexes = snapshot
1271        .declared_indexes
1272        .iter()
1273        .map(|index| index.name.clone())
1274        .collect::<BTreeSet<_>>();
1275    let operational_indexes = snapshot
1276        .operational_indexes
1277        .iter()
1278        .map(|index| index.name.clone())
1279        .collect::<BTreeSet<_>>();
1280    let declared_graph_projections = snapshot
1281        .declared_graph_projections
1282        .iter()
1283        .map(|projection| projection.name.clone())
1284        .collect::<BTreeSet<_>>();
1285    let operational_graph_projections = snapshot
1286        .operational_graph_projections
1287        .iter()
1288        .map(|projection| projection.name.clone())
1289        .collect::<BTreeSet<_>>();
1290    let declared_analytics_jobs = snapshot
1291        .declared_analytics_jobs
1292        .iter()
1293        .map(|job| job.id.clone())
1294        .collect::<BTreeSet<_>>();
1295    let operational_analytics_jobs = snapshot
1296        .operational_analytics_jobs
1297        .iter()
1298        .map(|job| job.id.clone())
1299        .collect::<BTreeSet<_>>();
1300
1301    CatalogConsistencyReport {
1302        declared_index_count: declared_indexes.len(),
1303        operational_index_count: operational_indexes.len(),
1304        declared_graph_projection_count: declared_graph_projections.len(),
1305        operational_graph_projection_count: operational_graph_projections.len(),
1306        declared_analytics_job_count: declared_analytics_jobs.len(),
1307        operational_analytics_job_count: operational_analytics_jobs.len(),
1308        missing_operational_indexes: declared_indexes
1309            .difference(&operational_indexes)
1310            .cloned()
1311            .collect(),
1312        undeclared_operational_indexes: operational_indexes
1313            .difference(&declared_indexes)
1314            .cloned()
1315            .collect(),
1316        missing_operational_graph_projections: declared_graph_projections
1317            .difference(&operational_graph_projections)
1318            .cloned()
1319            .collect(),
1320        undeclared_operational_graph_projections: operational_graph_projections
1321            .difference(&declared_graph_projections)
1322            .cloned()
1323            .collect(),
1324        missing_operational_analytics_jobs: declared_analytics_jobs
1325            .difference(&operational_analytics_jobs)
1326            .cloned()
1327            .collect(),
1328        undeclared_operational_analytics_jobs: operational_analytics_jobs
1329            .difference(&declared_analytics_jobs)
1330            .cloned()
1331            .collect(),
1332    }
1333}
1334
1335pub fn attention_summary(snapshot: &CatalogModelSnapshot) -> CatalogAttentionSummary {
1336    CatalogAttentionSummary {
1337        collections_requiring_attention: snapshot
1338            .collections
1339            .iter()
1340            .filter(|collection| collection.attention_required)
1341            .count(),
1342        indexes_requiring_attention: snapshot
1343            .index_statuses
1344            .iter()
1345            .filter(|status| status.attention_score > 0)
1346            .count(),
1347        graph_projections_requiring_attention: snapshot
1348            .graph_projection_statuses
1349            .iter()
1350            .filter(|status| status.attention_score > 0)
1351            .count(),
1352        analytics_jobs_requiring_attention: snapshot
1353            .analytics_job_statuses
1354            .iter()
1355            .filter(|status| status.attention_score > 0)
1356            .count(),
1357        top_collection: snapshot
1358            .collections
1359            .iter()
1360            .filter(|collection| collection.attention_score > 0)
1361            .max_by_key(|collection| collection.attention_score)
1362            .cloned(),
1363        top_index: snapshot
1364            .index_statuses
1365            .iter()
1366            .filter(|status| status.attention_score > 0)
1367            .max_by_key(|status| status.attention_score)
1368            .cloned(),
1369        top_graph_projection: snapshot
1370            .graph_projection_statuses
1371            .iter()
1372            .filter(|status| status.attention_score > 0)
1373            .max_by_key(|status| status.attention_score)
1374            .cloned(),
1375        top_analytics_job: snapshot
1376            .analytics_job_statuses
1377            .iter()
1378            .filter(|status| status.attention_score > 0)
1379            .max_by_key(|status| status.attention_score)
1380            .cloned(),
1381    }
1382}
1383
1384pub fn collection_attention(snapshot: &CatalogModelSnapshot) -> Vec<CollectionDescriptor> {
1385    let mut collections = snapshot
1386        .collections
1387        .iter()
1388        .filter(|collection| collection.attention_required)
1389        .cloned()
1390        .collect::<Vec<_>>();
1391    collections.sort_by(|left, right| {
1392        right
1393            .attention_score
1394            .cmp(&left.attention_score)
1395            .then_with(|| left.name.cmp(&right.name))
1396    });
1397    collections
1398}
1399
1400pub fn index_attention(snapshot: &CatalogModelSnapshot) -> Vec<CatalogIndexStatus> {
1401    let mut statuses = snapshot
1402        .index_statuses
1403        .iter()
1404        .filter(|status| status.attention_score > 0)
1405        .cloned()
1406        .collect::<Vec<_>>();
1407    statuses.sort_by(|left, right| {
1408        right
1409            .attention_score
1410            .cmp(&left.attention_score)
1411            .then_with(|| left.name.cmp(&right.name))
1412    });
1413    statuses
1414}
1415
1416pub fn graph_projection_attention(
1417    snapshot: &CatalogModelSnapshot,
1418) -> Vec<CatalogGraphProjectionStatus> {
1419    let mut statuses = snapshot
1420        .graph_projection_statuses
1421        .iter()
1422        .filter(|status| status.attention_score > 0)
1423        .cloned()
1424        .collect::<Vec<_>>();
1425    statuses.sort_by(|left, right| {
1426        right
1427            .attention_score
1428            .cmp(&left.attention_score)
1429            .then_with(|| left.name.cmp(&right.name))
1430    });
1431    statuses
1432}
1433
1434pub fn analytics_job_attention(snapshot: &CatalogModelSnapshot) -> Vec<CatalogAnalyticsJobStatus> {
1435    let mut statuses = snapshot
1436        .analytics_job_statuses
1437        .iter()
1438        .filter(|status| status.attention_score > 0)
1439        .cloned()
1440        .collect::<Vec<_>>();
1441    statuses.sort_by(|left, right| {
1442        right
1443            .attention_score
1444            .cmp(&left.attention_score)
1445            .then_with(|| left.id.cmp(&right.id))
1446    });
1447    statuses
1448}
1449
1450#[cfg(test)]
1451mod observation_tests {
1452    use super::*;
1453    use crate::storage::{CrossRef, EntityId, RefType};
1454
1455    fn btree_index(name: &str, build_state: &str) -> PhysicalIndexState {
1456        PhysicalIndexState {
1457            name: name.to_string(),
1458            kind: IndexKind::BTree,
1459            collection: Some("events".to_string()),
1460            enabled: true,
1461            entries: 1,
1462            estimated_memory_bytes: 64,
1463            last_refresh_ms: Some(1),
1464            backend: "btree".to_string(),
1465            artifact_kind: None,
1466            artifact_root_page: None,
1467            artifact_checksum: None,
1468            build_state: build_state.to_string(),
1469        }
1470    }
1471
1472    #[test]
1473    fn reopened_operational_btree_indexes_remain_queryable() {
1474        for recovered_state in ["catalog-derived", "registry-loaded"] {
1475            let statuses = index_statuses(Some(&CatalogDeclarations {
1476                declared_indexes: vec![btree_index("idx_events_id", "ready")],
1477                operational_indexes: vec![btree_index("idx_events_id", recovered_state)],
1478                ..Default::default()
1479            }));
1480            let status = statuses.first().expect("index status");
1481            assert_eq!(status.lifecycle_state, "ready", "{recovered_state}");
1482            assert!(status.queryable, "{recovered_state}");
1483            assert_eq!(status.artifact_state, crate::physical::ArtifactState::Ready);
1484            assert!(!status.requires_rebuild, "{recovered_state}");
1485        }
1486    }
1487
1488    #[test]
1489    fn borrowed_catalog_preserves_models_counts_and_cross_references() {
1490        let store = UnifiedStore::new();
1491        for name in ["rows", "graph", "vectors", "mixed", "empty"] {
1492            store.create_collection(name).expect("collection");
1493        }
1494        let mut row = UnifiedEntity::table_row(
1495            EntityId::new(1),
1496            "rows",
1497            1,
1498            vec![Value::text("payload".repeat(10_000))],
1499        );
1500        row.add_cross_ref(CrossRef::new(
1501            EntityId::new(1),
1502            EntityId::new(2),
1503            "graph",
1504            RefType::RowToNode,
1505        ));
1506        store.insert("rows", row).expect("row");
1507        store
1508            .insert(
1509                "graph",
1510                UnifiedEntity::graph_node(EntityId::new(2), "label", "type", HashMap::new()),
1511            )
1512            .expect("node");
1513        store
1514            .insert(
1515                "vectors",
1516                UnifiedEntity::vector(EntityId::new(3), "vectors", vec![1.0, 0.0]),
1517            )
1518            .expect("vector");
1519        store
1520            .insert(
1521                "mixed",
1522                UnifiedEntity::table_row(EntityId::new(4), "mixed", 4, vec![Value::Integer(4)]),
1523            )
1524            .expect("mixed row");
1525        store
1526            .insert(
1527                "mixed",
1528                UnifiedEntity::vector(EntityId::new(5), "mixed", vec![0.0, 1.0]),
1529            )
1530            .expect("mixed vector");
1531
1532        let snapshot = snapshot_store("test", &store, None);
1533        assert_eq!(snapshot.summary.total_entities, 5);
1534        for (name, model, count, references) in [
1535            ("rows", CollectionModel::Table, 1, 1),
1536            ("graph", CollectionModel::Graph, 1, 0),
1537            ("vectors", CollectionModel::Vector, 1, 0),
1538            ("mixed", CollectionModel::Mixed, 2, 0),
1539            ("empty", CollectionModel::Mixed, 0, 0),
1540        ] {
1541            let descriptor = snapshot
1542                .collections
1543                .iter()
1544                .find(|item| item.name == name)
1545                .expect("descriptor");
1546            assert_eq!(
1547                (
1548                    descriptor.observed_model,
1549                    descriptor.entities,
1550                    descriptor.cross_refs
1551                ),
1552                (model, count, references)
1553            );
1554        }
1555    }
1556}