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