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