Skip to main content

reddb_server/
catalog.rs

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