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