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