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