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