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
709#[cfg(test)]
710thread_local! {
711 static OBSERVED_ENTITIES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
712}
713
714#[cfg(test)]
715pub(crate) fn take_observed_entities_for_test() -> usize {
716 OBSERVED_ENTITIES.with(|count| count.replace(0))
717}
718
719fn collection_observations(manager: &SegmentManager) -> (CollectionModel, usize, usize) {
720 let mut observations = CollectionObservations::default();
721 manager.for_each_entity(|entity| {
722 #[cfg(test)]
723 OBSERVED_ENTITIES.with(|count| count.set(count.get() + 1));
724 observations.observe(entity);
725 true
726 });
727 observations.finish()
728}
729
730fn infer_schema_mode(model: CollectionModel) -> SchemaMode {
731 match model {
732 CollectionModel::Table => SchemaMode::Strict,
733 CollectionModel::Graph | CollectionModel::Vector => SchemaMode::SemiStructured,
734 CollectionModel::Document
735 | CollectionModel::Hll
736 | CollectionModel::Sketch
737 | CollectionModel::Filter
738 | CollectionModel::Kv
739 | CollectionModel::Config
740 | CollectionModel::Vault
741 | CollectionModel::Mixed => SchemaMode::Dynamic,
742 CollectionModel::TimeSeries | CollectionModel::Metrics => SchemaMode::SemiStructured,
743 CollectionModel::Queue => SchemaMode::Dynamic,
744 }
745}
746
747fn infer_indices(
748 collection_name: &str,
749 model: CollectionModel,
750 index_catalog: Option<&IndexCatalog>,
751 declarations: Option<&CatalogDeclarations>,
752) -> Vec<String> {
753 let declared = declared_indices(collection_name, declarations);
754 if !declared.is_empty() {
755 return declared;
756 }
757
758 let available = index_catalog
759 .map(IndexCatalog::snapshot)
760 .unwrap_or_default();
761 let mut selected = Vec::new();
762
763 for metric in available {
764 let relevant = matches!(
765 (model, metric.kind),
766 (_, IndexKind::BTree)
767 | (CollectionModel::Graph, IndexKind::GraphAdjacency)
768 | (CollectionModel::Vector, IndexKind::VectorHnsw)
769 | (CollectionModel::Vector, IndexKind::VectorInverted)
770 | (CollectionModel::Vector, IndexKind::VectorTurbo)
771 | (CollectionModel::Document, IndexKind::FullText)
772 | (CollectionModel::Document, IndexKind::DocumentPathValue)
773 | (CollectionModel::Kv, IndexKind::Hash)
774 | (CollectionModel::Config, IndexKind::Hash)
775 | (CollectionModel::Vault, IndexKind::Hash)
776 | (CollectionModel::Mixed, _)
777 );
778
779 if relevant && metric.enabled {
780 selected.push(metric.name);
781 }
782 }
783
784 selected
785}
786
787fn declared_indices(
788 collection_name: &str,
789 declarations: Option<&CatalogDeclarations>,
790) -> Vec<String> {
791 let mut selected = declarations
792 .map(|declarations| {
793 declarations
794 .declared_indexes
795 .iter()
796 .filter(|index| index.collection.as_deref() == Some(collection_name))
797 .map(|index| index.name.clone())
798 .collect::<Vec<_>>()
799 })
800 .unwrap_or_default();
801 selected.sort();
802 selected.dedup();
803 selected
804}
805
806fn operational_indices(
807 collection_name: &str,
808 index_catalog: Option<&IndexCatalog>,
809 declarations: Option<&CatalogDeclarations>,
810) -> Vec<String> {
811 if let Some(declarations) = declarations {
812 let mut selected = declarations
813 .operational_indexes
814 .iter()
815 .filter(|index| index.collection.as_deref() == Some(collection_name))
816 .map(|index| index.name.clone())
817 .collect::<Vec<_>>();
818 selected.sort();
819 selected.dedup();
820 return selected;
821 }
822
823 let mut selected = index_catalog
824 .map(IndexCatalog::snapshot)
825 .unwrap_or_default()
826 .into_iter()
827 .filter(|metric| metric.enabled)
828 .filter_map(|metric| {
829 if metric.name.starts_with(collection_name) {
830 Some(metric.name)
831 } else {
832 None
833 }
834 })
835 .collect::<Vec<_>>();
836 selected.sort();
837 selected.dedup();
838 selected
839}
840
841fn collection_index_consistency(
842 declared_indices: &[String],
843 operational_indices: &[String],
844) -> (bool, Vec<String>, Vec<String>) {
845 let declared = declared_indices.iter().cloned().collect::<BTreeSet<_>>();
846 let operational = operational_indices.iter().cloned().collect::<BTreeSet<_>>();
847 let missing_operational_indices = declared
848 .difference(&operational)
849 .cloned()
850 .collect::<Vec<_>>();
851 let undeclared_operational_indices = operational
852 .difference(&declared)
853 .cloned()
854 .collect::<Vec<_>>();
855 (
856 missing_operational_indices.is_empty() && undeclared_operational_indices.is_empty(),
857 missing_operational_indices,
858 undeclared_operational_indices,
859 )
860}
861
862fn collection_index_readiness(
863 collection_name: &str,
864 statuses: &[CatalogIndexStatus],
865) -> (usize, usize, bool) {
866 let local = statuses
867 .iter()
868 .filter(|status| status.collection.as_deref() == Some(collection_name))
869 .collect::<Vec<_>>();
870 let queryable_count = local.iter().filter(|status| status.queryable).count();
871 let requires_rebuild_count = local
872 .iter()
873 .filter(|status| status.requires_rebuild)
874 .count();
875 let locally_in_sync = local.iter().all(|status| status.in_sync);
876 (queryable_count, requires_rebuild_count, locally_in_sync)
877}
878
879fn collection_graph_projection_readiness(
880 collection_name: &str,
881 statuses: &[CatalogGraphProjectionStatus],
882) -> (usize, usize, bool) {
883 let local = statuses
884 .iter()
885 .filter(|status| status.source.as_deref() == Some(collection_name))
886 .collect::<Vec<_>>();
887 let queryable_count = local.iter().filter(|status| status.queryable).count();
888 let requires_rematerialization_count = local
889 .iter()
890 .filter(|status| status.requires_rematerialization)
891 .count();
892 let locally_in_sync = local
893 .iter()
894 .all(|status| status.in_sync && status.dependent_jobs_in_sync);
895 (
896 queryable_count,
897 requires_rematerialization_count,
898 locally_in_sync,
899 )
900}
901
902fn collection_analytics_job_readiness(
903 collection_name: &str,
904 projection_statuses: &[CatalogGraphProjectionStatus],
905 job_statuses: &[CatalogAnalyticsJobStatus],
906) -> (usize, usize, bool) {
907 let local = job_statuses
908 .iter()
909 .filter(|status| {
910 let Some(projection_name) = status.projection.as_deref() else {
911 return false;
912 };
913 projection_statuses
914 .iter()
915 .find(|projection| projection.name == projection_name)
916 .and_then(|projection| projection.source.as_deref())
917 == Some(collection_name)
918 })
919 .collect::<Vec<_>>();
920 let executable_count = local.iter().filter(|status| status.executable).count();
921 let requires_rerun_count = local.iter().filter(|status| status.requires_rerun).count();
922 let locally_in_sync = local
923 .iter()
924 .all(|status| status.in_sync && status.dependency_in_sync.unwrap_or(true));
925 (executable_count, requires_rerun_count, locally_in_sync)
926}
927
928fn graph_projection_statuses(
929 declarations: Option<&CatalogDeclarations>,
930) -> Vec<CatalogGraphProjectionStatus> {
931 let declared = declarations
932 .map(|declarations| declarations.declared_graph_projections.as_slice())
933 .unwrap_or(&[]);
934 let operational = declarations
935 .map(|declarations| declarations.operational_graph_projections.as_slice())
936 .unwrap_or(&[]);
937 let declared_jobs = declarations
938 .map(|declarations| declarations.declared_analytics_jobs.as_slice())
939 .unwrap_or(&[]);
940 let operational_jobs = declarations
941 .map(|declarations| declarations.operational_analytics_jobs.as_slice())
942 .unwrap_or(&[]);
943
944 let mut names = declared
945 .iter()
946 .map(|projection| projection.name.clone())
947 .chain(operational.iter().map(|projection| projection.name.clone()))
948 .collect::<Vec<_>>();
949 names.sort();
950 names.dedup();
951
952 names
953 .into_iter()
954 .map(|name| {
955 let declared_projection = declared.iter().find(|projection| projection.name == name);
956 let operational_projection = operational
957 .iter()
958 .find(|projection| projection.name == name);
959 let declared_present = declared_projection.is_some();
960 let operational_present = operational_projection.is_some();
961 let mut dependent_job_ids = BTreeSet::new();
962 let mut dependent_job_count = 0usize;
963 let mut active_dependent_job_count = 0usize;
964 let mut stale_dependent_job_count = 0usize;
965 for job in declared_jobs
966 .iter()
967 .chain(operational_jobs.iter())
968 .filter(|job| job.projection.as_deref() == Some(name.as_str()))
969 {
970 if !dependent_job_ids.insert(job.id.clone()) {
971 continue;
972 }
973 dependent_job_count += 1;
974 if matches!(job.state.as_str(), "queued" | "running" | "completed") {
975 active_dependent_job_count += 1;
976 }
977 if job.state == "stale" {
978 stale_dependent_job_count += 1;
979 }
980 }
981 let lifecycle_state = match (
982 declared_present,
983 operational_present,
984 declared_projection
985 .map(|projection| projection.state.as_str())
986 .or_else(|| operational_projection.map(|projection| projection.state.as_str()))
987 .unwrap_or_default(),
988 ) {
989 (true, _, "failed") => "failed",
990 (true, true, "stale") => "stale",
991 (true, _, "materializing") => "materializing",
992 (true, true, "materialized") => "materialized",
993 (true, true, _) => "materialized",
994 (false, true, _) => "orphaned-operational",
995 (true, false, _) => "declared",
996 (false, false, _) => "unknown",
997 }
998 .to_string();
999 let queryable = declared_present
1000 && operational_present
1001 && matches!(
1002 declared_projection
1003 .map(|projection| projection.state.as_str())
1004 .or_else(
1005 || operational_projection.map(|projection| projection.state.as_str())
1006 )
1007 .unwrap_or_default(),
1008 "materialized"
1009 );
1010 let requires_rematerialization = matches!(
1011 declared_projection
1012 .map(|projection| projection.state.as_str())
1013 .or_else(|| operational_projection.map(|projection| projection.state.as_str()))
1014 .unwrap_or_default(),
1015 "declared" | "materializing" | "failed" | "stale"
1016 );
1017 let mut attention_reasons = Vec::new();
1018 if !declared_present || !operational_present {
1019 attention_reasons.push("declaration_drift".to_string());
1020 }
1021 if requires_rematerialization {
1022 attention_reasons.push("requires_rematerialization".to_string());
1023 }
1024 if stale_dependent_job_count > 0 {
1025 attention_reasons.push("dependent_jobs_stale".to_string());
1026 }
1027 let attention_score = stale_dependent_job_count.saturating_mul(2)
1028 + usize::from(requires_rematerialization).saturating_mul(4)
1029 + usize::from(!declared_present || !operational_present)
1030 + usize::from(!queryable);
1031 CatalogGraphProjectionStatus {
1032 name,
1033 source: declared_projection
1034 .map(|projection| projection.source.clone())
1035 .or_else(|| operational_projection.map(|projection| projection.source.clone())),
1036 lifecycle_state,
1037 declared: declared_present,
1038 operational: operational_present,
1039 in_sync: declared_present == operational_present,
1040 last_materialized_sequence: declared_projection
1041 .and_then(|projection| projection.last_materialized_sequence)
1042 .or_else(|| {
1043 operational_projection
1044 .and_then(|projection| projection.last_materialized_sequence)
1045 }),
1046 queryable,
1047 requires_rematerialization,
1048 dependent_job_count,
1049 active_dependent_job_count,
1050 stale_dependent_job_count,
1051 dependent_jobs_in_sync: stale_dependent_job_count == 0,
1052 rerun_required: stale_dependent_job_count > 0,
1053 attention_score,
1054 attention_reasons,
1055 }
1056 })
1057 .collect()
1058}
1059
1060fn index_statuses(declarations: Option<&CatalogDeclarations>) -> Vec<CatalogIndexStatus> {
1061 let declared = declarations
1062 .map(|declarations| declarations.declared_indexes.as_slice())
1063 .unwrap_or(&[]);
1064 let operational = declarations
1065 .map(|declarations| declarations.operational_indexes.as_slice())
1066 .unwrap_or(&[]);
1067
1068 let mut names = declared
1069 .iter()
1070 .map(|index| index.name.clone())
1071 .chain(operational.iter().map(|index| index.name.clone()))
1072 .collect::<Vec<_>>();
1073 names.sort();
1074 names.dedup();
1075
1076 names
1077 .into_iter()
1078 .map(|name| {
1079 let declared_index = declared.iter().find(|index| index.name == name);
1080 let operational_index = operational.iter().find(|index| index.name == name);
1081 let collection = declared_index
1082 .and_then(|index| index.collection.clone())
1083 .or_else(|| operational_index.and_then(|index| index.collection.clone()));
1084 let kind = declared_index
1085 .map(|index| index_kind_string(index.kind))
1086 .or_else(|| operational_index.map(|index| index_kind_string(index.kind)))
1087 .unwrap_or_default();
1088 let enabled = declared_index
1089 .map(|index| index.enabled)
1090 .or_else(|| operational_index.map(|index| index.enabled))
1091 .unwrap_or(false);
1092 let build_state = operational_index
1093 .map(|index| index.build_state.clone())
1094 .or_else(|| declared_index.map(|index| index.build_state.clone()));
1095 let declared_present = declared_index.is_some();
1096 let operational_present = operational_index.is_some();
1097 let lifecycle_state = index_lifecycle_state(
1098 declared_present,
1099 operational_present,
1100 enabled,
1101 build_state.as_deref(),
1102 );
1103
1104 let mut attention_reasons = Vec::new();
1105 if declared_present != operational_present {
1106 attention_reasons.push("declaration_drift".to_string());
1107 }
1108 if !enabled && declared_present {
1109 attention_reasons.push("disabled".to_string());
1110 }
1111 if matches!(build_state.as_deref().unwrap_or_default(), "failed") {
1112 attention_reasons.push("failed".to_string());
1113 }
1114 if matches!(build_state.as_deref().unwrap_or_default(), "stale") {
1115 attention_reasons.push("stale".to_string());
1116 }
1117 if matches!(
1118 build_state.as_deref().unwrap_or_default(),
1119 "building" | "declared-unbuilt"
1120 ) {
1121 attention_reasons.push("requires_rebuild".to_string());
1122 }
1123 let artifact_state = crate::physical::ArtifactState::from_build_state(
1124 build_state.as_deref().unwrap_or("declared-unbuilt"),
1125 enabled,
1126 );
1127 let queryable =
1128 declared_present && operational_present && artifact_state.is_queryable();
1129 let requires_rebuild = matches!(
1130 build_state.as_deref().unwrap_or_default(),
1131 "declared-unbuilt" | "building" | "stale" | "failed"
1132 );
1133 let attention_score = usize::from(requires_rebuild).saturating_mul(3)
1134 + usize::from(!enabled && declared_present)
1135 + usize::from(declared_present != operational_present)
1136 + usize::from(!queryable);
1137
1138 CatalogIndexStatus {
1139 name,
1140 collection,
1141 kind,
1142 declared: declared_present,
1143 operational: operational_present,
1144 enabled,
1145 build_state,
1146 artifact_state,
1147 lifecycle_state,
1148 in_sync: declared_present == operational_present,
1149 queryable,
1150 requires_rebuild,
1151 attention_score,
1152 attention_reasons,
1153 }
1154 })
1155 .collect()
1156}
1157
1158fn index_lifecycle_state(
1159 declared: bool,
1160 operational: bool,
1161 enabled: bool,
1162 build_state: Option<&str>,
1163) -> String {
1164 if !declared && operational {
1165 return "orphaned-operational".to_string();
1166 }
1167 if declared && !enabled {
1168 return "disabled".to_string();
1169 }
1170 if !declared {
1171 return "unknown".to_string();
1172 }
1173 if !operational {
1174 return "declared".to_string();
1175 }
1176
1177 match build_state.unwrap_or_default() {
1178 "ready" | "catalog-derived" | "registry-loaded" => "ready".to_string(),
1179 "failed" => "failed".to_string(),
1180 "stale" => "stale".to_string(),
1181 "declared-unbuilt" => "declared".to_string(),
1182 "metadata-only" | "artifact-published" => "building".to_string(),
1183 _ => "building".to_string(),
1184 }
1185}
1186
1187fn index_kind_string(kind: IndexKind) -> String {
1188 match kind {
1189 IndexKind::BTree => "btree",
1190 IndexKind::Hash => "hash",
1191 IndexKind::Bitmap => "bitmap",
1192 IndexKind::VectorHnsw => "vector.hnsw",
1193 IndexKind::VectorInverted => "vector.inverted",
1194 IndexKind::VectorTurbo => "vector.turbo",
1195 IndexKind::GraphAdjacency => "graph.adjacency",
1196 IndexKind::FullText => "text.fulltext",
1197 IndexKind::DocumentPathValue => "document.pathvalue",
1198 IndexKind::HybridSearch => "search.hybrid",
1199 }
1200 .to_string()
1201}
1202
1203fn analytics_job_statuses(
1204 declarations: Option<&CatalogDeclarations>,
1205) -> Vec<CatalogAnalyticsJobStatus> {
1206 let declared = declarations
1207 .map(|declarations| declarations.declared_analytics_jobs.as_slice())
1208 .unwrap_or(&[]);
1209 let operational = declarations
1210 .map(|declarations| declarations.operational_analytics_jobs.as_slice())
1211 .unwrap_or(&[]);
1212 let declared_projections = declarations
1213 .map(|declarations| declarations.declared_graph_projections.as_slice())
1214 .unwrap_or(&[]);
1215 let operational_projections = declarations
1216 .map(|declarations| declarations.operational_graph_projections.as_slice())
1217 .unwrap_or(&[]);
1218
1219 let mut ids = declared
1220 .iter()
1221 .map(|job| job.id.clone())
1222 .chain(operational.iter().map(|job| job.id.clone()))
1223 .collect::<Vec<_>>();
1224 ids.sort();
1225 ids.dedup();
1226
1227 ids.into_iter()
1228 .map(|id| {
1229 let declared_job = declared.iter().find(|job| job.id == id);
1230 let operational_job = operational.iter().find(|job| job.id == id);
1231 let kind = declared_job
1232 .map(|job| job.kind.clone())
1233 .or_else(|| operational_job.map(|job| job.kind.clone()))
1234 .unwrap_or_default();
1235 let projection = declared_job
1236 .and_then(|job| job.projection.clone())
1237 .or_else(|| operational_job.and_then(|job| job.projection.clone()));
1238 let state = declared_job
1239 .map(|job| job.state.clone())
1240 .or_else(|| operational_job.map(|job| job.state.clone()))
1241 .unwrap_or_default();
1242 let declared_present = declared_job.is_some();
1243 let operational_present = operational_job.is_some();
1244 let last_run_sequence = declared_job
1245 .and_then(|job| job.last_run_sequence)
1246 .or_else(|| operational_job.and_then(|job| job.last_run_sequence));
1247 let projection_declared = projection.as_ref().map(|projection_name| {
1248 declared_projections
1249 .iter()
1250 .any(|projection| projection.name == *projection_name)
1251 });
1252 let projection_operational = projection.as_ref().map(|projection_name| {
1253 operational_projections
1254 .iter()
1255 .any(|projection| projection.name == *projection_name)
1256 });
1257 let projection_lifecycle = projection.as_ref().and_then(|projection_name| {
1258 declared_projections
1259 .iter()
1260 .find(|projection| projection.name == *projection_name)
1261 .map(|projection| projection.state.as_str())
1262 .or_else(|| {
1263 operational_projections
1264 .iter()
1265 .find(|projection| projection.name == *projection_name)
1266 .map(|projection| projection.state.as_str())
1267 })
1268 });
1269 let dependency_in_sync = projection.as_ref().map(|_| {
1270 matches!(projection_lifecycle, Some("materialized"))
1271 && projection_operational == Some(true)
1272 });
1273 let lifecycle_state = match (declared_present, operational_present, state.as_str()) {
1274 (true, false, _) => "declared",
1275 (true, true, _)
1276 if matches!(
1277 projection_lifecycle,
1278 Some("stale" | "failed" | "materializing" | "declared")
1279 ) =>
1280 {
1281 "stale"
1282 }
1283 (true, true, "completed") => "completed",
1284 (true, true, "running") => "running",
1285 (true, true, "failed") => "failed",
1286 (true, true, "queued") => "queued",
1287 (true, true, "stale") => "stale",
1288 (true, true, _) => "materialized",
1289 (false, true, _) => "orphaned-operational",
1290 (false, false, _) => "unknown",
1291 }
1292 .to_string();
1293 let executable = declared_present
1294 && operational_present
1295 && !matches!(state.as_str(), "failed" | "stale")
1296 && dependency_in_sync.unwrap_or(true);
1297 let requires_rerun =
1298 matches!(state.as_str(), "stale" | "failed") || dependency_in_sync == Some(false);
1299 let mut attention_reasons = Vec::new();
1300 if declared_present != operational_present {
1301 attention_reasons.push("declaration_drift".to_string());
1302 }
1303 if matches!(state.as_str(), "failed") {
1304 attention_reasons.push("failed".to_string());
1305 }
1306 if matches!(state.as_str(), "stale") {
1307 attention_reasons.push("stale".to_string());
1308 }
1309 if dependency_in_sync == Some(false) {
1310 attention_reasons.push("dependency_drift".to_string());
1311 }
1312 if requires_rerun {
1313 attention_reasons.push("requires_rerun".to_string());
1314 }
1315 let attention_score = usize::from(requires_rerun).saturating_mul(3)
1316 + usize::from(dependency_in_sync == Some(false)).saturating_mul(2)
1317 + usize::from(declared_present != operational_present)
1318 + usize::from(!executable);
1319 CatalogAnalyticsJobStatus {
1320 id,
1321 kind,
1322 projection,
1323 state: state.clone(),
1324 lifecycle_state,
1325 declared: declared_present,
1326 operational: operational_present,
1327 in_sync: declared_present == operational_present,
1328 last_run_sequence,
1329 projection_declared,
1330 projection_operational,
1331 projection_lifecycle_state: projection_lifecycle.map(str::to_string),
1332 dependency_in_sync,
1333 executable,
1334 requires_rerun,
1335 attention_score,
1336 attention_reasons,
1337 }
1338 })
1339 .collect()
1340}
1341
1342pub fn consistency_report(snapshot: &CatalogModelSnapshot) -> CatalogConsistencyReport {
1343 let declared_indexes = snapshot
1344 .declared_indexes
1345 .iter()
1346 .map(|index| index.name.clone())
1347 .collect::<BTreeSet<_>>();
1348 let operational_indexes = snapshot
1349 .operational_indexes
1350 .iter()
1351 .map(|index| index.name.clone())
1352 .collect::<BTreeSet<_>>();
1353 let declared_graph_projections = snapshot
1354 .declared_graph_projections
1355 .iter()
1356 .map(|projection| projection.name.clone())
1357 .collect::<BTreeSet<_>>();
1358 let operational_graph_projections = snapshot
1359 .operational_graph_projections
1360 .iter()
1361 .map(|projection| projection.name.clone())
1362 .collect::<BTreeSet<_>>();
1363 let declared_analytics_jobs = snapshot
1364 .declared_analytics_jobs
1365 .iter()
1366 .map(|job| job.id.clone())
1367 .collect::<BTreeSet<_>>();
1368 let operational_analytics_jobs = snapshot
1369 .operational_analytics_jobs
1370 .iter()
1371 .map(|job| job.id.clone())
1372 .collect::<BTreeSet<_>>();
1373
1374 CatalogConsistencyReport {
1375 declared_index_count: declared_indexes.len(),
1376 operational_index_count: operational_indexes.len(),
1377 declared_graph_projection_count: declared_graph_projections.len(),
1378 operational_graph_projection_count: operational_graph_projections.len(),
1379 declared_analytics_job_count: declared_analytics_jobs.len(),
1380 operational_analytics_job_count: operational_analytics_jobs.len(),
1381 missing_operational_indexes: declared_indexes
1382 .difference(&operational_indexes)
1383 .cloned()
1384 .collect(),
1385 undeclared_operational_indexes: operational_indexes
1386 .difference(&declared_indexes)
1387 .cloned()
1388 .collect(),
1389 missing_operational_graph_projections: declared_graph_projections
1390 .difference(&operational_graph_projections)
1391 .cloned()
1392 .collect(),
1393 undeclared_operational_graph_projections: operational_graph_projections
1394 .difference(&declared_graph_projections)
1395 .cloned()
1396 .collect(),
1397 missing_operational_analytics_jobs: declared_analytics_jobs
1398 .difference(&operational_analytics_jobs)
1399 .cloned()
1400 .collect(),
1401 undeclared_operational_analytics_jobs: operational_analytics_jobs
1402 .difference(&declared_analytics_jobs)
1403 .cloned()
1404 .collect(),
1405 }
1406}
1407
1408pub fn attention_summary(snapshot: &CatalogModelSnapshot) -> CatalogAttentionSummary {
1409 CatalogAttentionSummary {
1410 collections_requiring_attention: snapshot
1411 .collections
1412 .iter()
1413 .filter(|collection| collection.attention_required)
1414 .count(),
1415 indexes_requiring_attention: snapshot
1416 .index_statuses
1417 .iter()
1418 .filter(|status| status.attention_score > 0)
1419 .count(),
1420 graph_projections_requiring_attention: snapshot
1421 .graph_projection_statuses
1422 .iter()
1423 .filter(|status| status.attention_score > 0)
1424 .count(),
1425 analytics_jobs_requiring_attention: snapshot
1426 .analytics_job_statuses
1427 .iter()
1428 .filter(|status| status.attention_score > 0)
1429 .count(),
1430 top_collection: snapshot
1431 .collections
1432 .iter()
1433 .filter(|collection| collection.attention_score > 0)
1434 .max_by_key(|collection| collection.attention_score)
1435 .cloned(),
1436 top_index: snapshot
1437 .index_statuses
1438 .iter()
1439 .filter(|status| status.attention_score > 0)
1440 .max_by_key(|status| status.attention_score)
1441 .cloned(),
1442 top_graph_projection: snapshot
1443 .graph_projection_statuses
1444 .iter()
1445 .filter(|status| status.attention_score > 0)
1446 .max_by_key(|status| status.attention_score)
1447 .cloned(),
1448 top_analytics_job: snapshot
1449 .analytics_job_statuses
1450 .iter()
1451 .filter(|status| status.attention_score > 0)
1452 .max_by_key(|status| status.attention_score)
1453 .cloned(),
1454 }
1455}
1456
1457pub fn collection_attention(snapshot: &CatalogModelSnapshot) -> Vec<CollectionDescriptor> {
1458 let mut collections = snapshot
1459 .collections
1460 .iter()
1461 .filter(|collection| collection.attention_required)
1462 .cloned()
1463 .collect::<Vec<_>>();
1464 collections.sort_by(|left, right| {
1465 right
1466 .attention_score
1467 .cmp(&left.attention_score)
1468 .then_with(|| left.name.cmp(&right.name))
1469 });
1470 collections
1471}
1472
1473pub fn index_attention(snapshot: &CatalogModelSnapshot) -> Vec<CatalogIndexStatus> {
1474 let mut statuses = snapshot
1475 .index_statuses
1476 .iter()
1477 .filter(|status| status.attention_score > 0)
1478 .cloned()
1479 .collect::<Vec<_>>();
1480 statuses.sort_by(|left, right| {
1481 right
1482 .attention_score
1483 .cmp(&left.attention_score)
1484 .then_with(|| left.name.cmp(&right.name))
1485 });
1486 statuses
1487}
1488
1489pub fn graph_projection_attention(
1490 snapshot: &CatalogModelSnapshot,
1491) -> Vec<CatalogGraphProjectionStatus> {
1492 let mut statuses = snapshot
1493 .graph_projection_statuses
1494 .iter()
1495 .filter(|status| status.attention_score > 0)
1496 .cloned()
1497 .collect::<Vec<_>>();
1498 statuses.sort_by(|left, right| {
1499 right
1500 .attention_score
1501 .cmp(&left.attention_score)
1502 .then_with(|| left.name.cmp(&right.name))
1503 });
1504 statuses
1505}
1506
1507pub fn analytics_job_attention(snapshot: &CatalogModelSnapshot) -> Vec<CatalogAnalyticsJobStatus> {
1508 let mut statuses = snapshot
1509 .analytics_job_statuses
1510 .iter()
1511 .filter(|status| status.attention_score > 0)
1512 .cloned()
1513 .collect::<Vec<_>>();
1514 statuses.sort_by(|left, right| {
1515 right
1516 .attention_score
1517 .cmp(&left.attention_score)
1518 .then_with(|| left.id.cmp(&right.id))
1519 });
1520 statuses
1521}
1522
1523#[cfg(test)]
1524mod observation_tests {
1525 use super::*;
1526 use crate::storage::{CrossRef, EntityId, RefType};
1527
1528 fn btree_index(name: &str, build_state: &str) -> PhysicalIndexState {
1529 PhysicalIndexState {
1530 name: name.to_string(),
1531 kind: IndexKind::BTree,
1532 collection: Some("events".to_string()),
1533 enabled: true,
1534 entries: 1,
1535 estimated_memory_bytes: 64,
1536 last_refresh_ms: Some(1),
1537 backend: "btree".to_string(),
1538 artifact_kind: None,
1539 artifact_root_page: None,
1540 artifact_checksum: None,
1541 build_state: build_state.to_string(),
1542 }
1543 }
1544
1545 #[test]
1546 fn reopened_operational_btree_indexes_remain_queryable() {
1547 for recovered_state in ["catalog-derived", "registry-loaded"] {
1548 let statuses = index_statuses(Some(&CatalogDeclarations {
1549 declared_indexes: vec![btree_index("idx_events_id", "ready")],
1550 operational_indexes: vec![btree_index("idx_events_id", recovered_state)],
1551 ..Default::default()
1552 }));
1553 let status = statuses.first().expect("index status");
1554 assert_eq!(status.lifecycle_state, "ready", "{recovered_state}");
1555 assert!(status.queryable, "{recovered_state}");
1556 assert_eq!(status.artifact_state, crate::physical::ArtifactState::Ready);
1557 assert!(!status.requires_rebuild, "{recovered_state}");
1558 }
1559 }
1560
1561 #[test]
1562 fn borrowed_catalog_preserves_models_counts_and_cross_references() {
1563 let store = UnifiedStore::new();
1564 for name in ["rows", "graph", "vectors", "mixed", "empty"] {
1565 store.create_collection(name).expect("collection");
1566 }
1567 let mut row = UnifiedEntity::table_row(
1568 EntityId::new(1),
1569 "rows",
1570 1,
1571 vec![Value::text("payload".repeat(10_000))],
1572 );
1573 row.add_cross_ref(CrossRef::new(
1574 EntityId::new(1),
1575 EntityId::new(2),
1576 "graph",
1577 RefType::RowToNode,
1578 ));
1579 store.insert("rows", row).expect("row");
1580 store
1581 .insert(
1582 "graph",
1583 UnifiedEntity::graph_node(EntityId::new(2), "label", "type", HashMap::new()),
1584 )
1585 .expect("node");
1586 store
1587 .insert(
1588 "vectors",
1589 UnifiedEntity::vector(EntityId::new(3), "vectors", vec![1.0, 0.0]),
1590 )
1591 .expect("vector");
1592 store
1593 .insert(
1594 "mixed",
1595 UnifiedEntity::table_row(EntityId::new(4), "mixed", 4, vec![Value::Integer(4)]),
1596 )
1597 .expect("mixed row");
1598 store
1599 .insert(
1600 "mixed",
1601 UnifiedEntity::vector(EntityId::new(5), "mixed", vec![0.0, 1.0]),
1602 )
1603 .expect("mixed vector");
1604
1605 let snapshot = snapshot_store("test", &store, None);
1606 assert_eq!(snapshot.summary.total_entities, 5);
1607 for (name, model, count, references) in [
1608 ("rows", CollectionModel::Table, 1, 1),
1609 ("graph", CollectionModel::Graph, 1, 0),
1610 ("vectors", CollectionModel::Vector, 1, 0),
1611 ("mixed", CollectionModel::Mixed, 2, 0),
1612 ("empty", CollectionModel::Mixed, 0, 0),
1613 ] {
1614 let descriptor = snapshot
1615 .collections
1616 .iter()
1617 .find(|item| item.name == name)
1618 .expect("descriptor");
1619 assert_eq!(
1620 (
1621 descriptor.observed_model,
1622 descriptor.entities,
1623 descriptor.cross_refs
1624 ),
1625 (model, count, references)
1626 );
1627 }
1628 }
1629}