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