1use 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct AnalyticalStorageConfig {
30 pub columnar: bool,
33 pub time_key: String,
35 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
47pub 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 pub queue_max_attempts: Option<u32>,
72 pub queue_lock_deadline_ms: Option<u64>,
74 pub queue_in_flight_cap_per_group: Option<u32>,
76 pub queue_dlq_target: Option<String>,
78 pub vector_dimension: Option<usize>,
79 pub vector_metric: Option<crate::storage::engine::distance::DistanceMetric>,
80 pub session_key: Option<String>,
84 pub session_gap_ms: Option<u64>,
87 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 pub analytics_config: Vec<AnalyticsViewDescriptor>,
113 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#[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}