Skip to main content

nodedb_lite/engine/htap/
bridge.rs

1//! HTAP brige: CDC from strict document collections to columnar materialized views.
2//!
3//! When a materialized view is created, every INSERT/UPDATE/DELETE on the source
4//! strict collection is replicated to the target columnar collection. In Lite,
5//! this happens synchronously at the API level (no background WAL reader needed,
6//! since the KV store handles durability).
7//!
8//! The bridge tracks:
9//! - Source → target collection mapping
10//! - Last replicated timestamp (for lag measurement)
11//! - Row count delta (for consistency checks)
12//!
13//! All methods take `&self`; the view map lives behind a `Mutex` that is
14//! held only briefly and never across `.await`. The bridge is therefore
15//! natively `Send + Sync` and is stored as `Arc<HtapBridge>` (no outer
16//! `Mutex<HtapBridge>` needed).
17
18use std::collections::HashMap;
19use std::sync::Mutex;
20
21use nodedb_types::value::Value;
22
23use crate::engine::columnar::ColumnarEngine;
24use crate::storage::engine::StorageEngine;
25
26/// Metadata for a single materialized view.
27#[derive(Debug, Clone)]
28pub struct MaterializedView {
29    /// Source strict collection name.
30    pub source: String,
31    /// Target columnar collection name.
32    pub target: String,
33    /// Timestamp of the last replicated change (millis since epoch).
34    pub last_replicated_ms: u64,
35    /// Number of rows replicated.
36    pub rows_replicated: u64,
37}
38
39/// Manages CDC bridges between strict document collections and columnar
40/// materialized views.
41pub struct HtapBridge {
42    /// Source collection name → list of materialized views.
43    views: Mutex<HashMap<String, Vec<MaterializedView>>>,
44}
45
46impl HtapBridge {
47    /// Create an empty bridge with no materialized views.
48    pub fn new() -> Self {
49        Self {
50            views: Mutex::new(HashMap::new()),
51        }
52    }
53
54    fn lock_views(&self) -> std::sync::MutexGuard<'_, HashMap<String, Vec<MaterializedView>>> {
55        match self.views.lock() {
56            Ok(g) => g,
57            Err(p) => p.into_inner(),
58        }
59    }
60
61    /// Register a new materialized view.
62    pub fn register_view(&self, source: &str, target: &str) {
63        let view = MaterializedView {
64            source: source.to_string(),
65            target: target.to_string(),
66            last_replicated_ms: crate::runtime::now_millis(),
67            rows_replicated: 0,
68        };
69        self.lock_views()
70            .entry(source.to_string())
71            .or_default()
72            .push(view);
73    }
74
75    /// Remove a materialized view by target name.
76    pub fn remove_view(&self, target: &str) {
77        let mut g = self.lock_views();
78        for views in g.values_mut() {
79            views.retain(|v| v.target != target);
80        }
81        g.retain(|_, views| !views.is_empty());
82    }
83
84    /// Get all materialized views for a source collection (returns clones).
85    pub fn views_for_source(&self, source: &str) -> Vec<MaterializedView> {
86        self.lock_views().get(source).cloned().unwrap_or_default()
87    }
88
89    /// Get a materialized view by target name (returns clone).
90    pub fn view_by_target(&self, target: &str) -> Option<MaterializedView> {
91        self.lock_views()
92            .values()
93            .flatten()
94            .find(|v| v.target == target)
95            .cloned()
96    }
97
98    /// List all materialized view target names.
99    pub fn all_targets(&self) -> Vec<String> {
100        self.lock_views()
101            .values()
102            .flatten()
103            .map(|v| v.target.clone())
104            .collect()
105    }
106
107    /// Replicate an INSERT from a source strict collection to all its
108    /// materialized columnar views.
109    pub fn replicate_insert<S: StorageEngine>(
110        &self,
111        source: &str,
112        values: &[Value],
113        columnar: &ColumnarEngine<S>,
114    ) {
115        let mut g = self.lock_views();
116        let Some(views) = g.get_mut(source) else {
117            return;
118        };
119        for view in views.iter_mut() {
120            if columnar.insert(&view.target, values).is_ok() {
121                view.rows_replicated += 1;
122                view.last_replicated_ms = crate::runtime::now_millis();
123            }
124        }
125    }
126
127    /// Replicate a DELETE from a source strict collection to all its
128    /// materialized columnar views.
129    pub fn replicate_delete<S: StorageEngine>(
130        &self,
131        source: &str,
132        pk: &Value,
133        columnar: &ColumnarEngine<S>,
134    ) {
135        let mut g = self.lock_views();
136        let Some(views) = g.get_mut(source) else {
137            return;
138        };
139        for view in views.iter_mut() {
140            if columnar.delete(&view.target, pk).unwrap_or(false) {
141                view.last_replicated_ms = crate::runtime::now_millis();
142            }
143        }
144    }
145
146    /// Get the replication lag in milliseconds for a materialized view.
147    pub fn lag_ms(&self, target: &str) -> u64 {
148        self.view_by_target(target)
149            .map(|v| crate::runtime::now_millis().saturating_sub(v.last_replicated_ms))
150            .unwrap_or(0)
151    }
152
153    /// Whether any materialized views exist.
154    pub fn is_empty(&self) -> bool {
155        self.lock_views().is_empty()
156    }
157}
158
159impl Default for HtapBridge {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn register_and_lookup_view() {
171        let bridge = HtapBridge::new();
172        bridge.register_view("customers", "customer_analytics");
173
174        assert!(!bridge.is_empty());
175        assert_eq!(bridge.views_for_source("customers").len(), 1);
176        assert_eq!(
177            bridge.views_for_source("customers")[0].target,
178            "customer_analytics"
179        );
180        assert!(bridge.view_by_target("customer_analytics").is_some());
181        assert!(bridge.view_by_target("nonexistent").is_none());
182    }
183
184    #[test]
185    fn remove_view() {
186        let bridge = HtapBridge::new();
187        bridge.register_view("customers", "analytics_1");
188        bridge.register_view("customers", "analytics_2");
189
190        assert_eq!(bridge.views_for_source("customers").len(), 2);
191
192        bridge.remove_view("analytics_1");
193        assert_eq!(bridge.views_for_source("customers").len(), 1);
194        assert_eq!(
195            bridge.views_for_source("customers")[0].target,
196            "analytics_2"
197        );
198    }
199
200    #[test]
201    fn multiple_sources() {
202        let bridge = HtapBridge::new();
203        bridge.register_view("orders", "order_analytics");
204        bridge.register_view("customers", "customer_analytics");
205
206        assert_eq!(bridge.all_targets().len(), 2);
207    }
208}