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