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