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