Skip to main content

nodedb_lite/engine/htap/
bridge.rs

1//! HTAP bridge: 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 redb 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
13use std::collections::HashMap;
14use std::sync::Mutex;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use nodedb_types::value::Value;
18
19use crate::engine::columnar::ColumnarEngine;
20use crate::storage::engine::StorageEngine;
21
22/// Metadata for a single materialized view.
23#[derive(Debug, Clone)]
24pub struct MaterializedView {
25    /// Source strict collection name.
26    pub source: String,
27    /// Target columnar collection name.
28    pub target: String,
29    /// Timestamp of the last replicated change (millis since epoch).
30    pub last_replicated_ms: u64,
31    /// Number of rows replicated.
32    pub rows_replicated: u64,
33}
34
35/// Manages CDC bridges between strict document collections and columnar
36/// materialized views.
37///
38/// Each bridge replicates changes from a source strict collection into a
39/// target columnar collection. Multiple views can be created from the same source.
40pub struct HtapBridge {
41    /// Source collection name → list of materialized views.
42    views: HashMap<String, Vec<MaterializedView>>,
43}
44
45impl HtapBridge {
46    /// Create an empty bridge with no materialized views.
47    pub fn new() -> Self {
48        Self {
49            views: HashMap::new(),
50        }
51    }
52
53    /// Register a new materialized view.
54    ///
55    /// The target columnar collection must already exist in the ColumnarEngine.
56    pub fn register_view(&mut self, source: &str, target: &str) {
57        let view = MaterializedView {
58            source: source.to_string(),
59            target: target.to_string(),
60            last_replicated_ms: now_ms(),
61            rows_replicated: 0,
62        };
63        self.views.entry(source.to_string()).or_default().push(view);
64    }
65
66    /// Remove a materialized view by target name.
67    pub fn remove_view(&mut self, target: &str) {
68        for views in self.views.values_mut() {
69            views.retain(|v| v.target != target);
70        }
71        self.views.retain(|_, views| !views.is_empty());
72    }
73
74    /// Get all materialized views for a source collection.
75    pub fn views_for_source(&self, source: &str) -> &[MaterializedView] {
76        self.views.get(source).map(|v| v.as_slice()).unwrap_or(&[])
77    }
78
79    /// Get a materialized view by target name.
80    pub fn view_by_target(&self, target: &str) -> Option<&MaterializedView> {
81        self.views.values().flatten().find(|v| v.target == target)
82    }
83
84    /// List all materialized view target names.
85    pub fn all_targets(&self) -> Vec<&str> {
86        self.views
87            .values()
88            .flatten()
89            .map(|v| v.target.as_str())
90            .collect()
91    }
92
93    /// Replicate an INSERT from a source strict collection to all its
94    /// materialized columnar views.
95    ///
96    /// Called after `strict_insert()` succeeds. Writes the same row into
97    /// each target columnar collection's memtable.
98    pub fn replicate_insert<S: StorageEngine>(
99        &mut self,
100        source: &str,
101        values: &[Value],
102        columnar: &Mutex<ColumnarEngine<S>>,
103    ) {
104        let Some(views) = self.views.get_mut(source) else {
105            return;
106        };
107
108        let mut engine = match columnar.lock() {
109            Ok(e) => e,
110            Err(p) => p.into_inner(),
111        };
112
113        for view in views.iter_mut() {
114            if engine.insert(&view.target, values).is_ok() {
115                view.rows_replicated += 1;
116                view.last_replicated_ms = now_ms();
117            }
118        }
119    }
120
121    /// Replicate a DELETE from a source strict collection to all its
122    /// materialized columnar views.
123    pub fn replicate_delete<S: StorageEngine>(
124        &mut self,
125        source: &str,
126        pk: &Value,
127        columnar: &Mutex<ColumnarEngine<S>>,
128    ) {
129        let Some(views) = self.views.get_mut(source) else {
130            return;
131        };
132
133        let mut engine = match columnar.lock() {
134            Ok(e) => e,
135            Err(p) => p.into_inner(),
136        };
137
138        for view in views.iter_mut() {
139            if engine.delete(&view.target, pk).unwrap_or(false) {
140                view.last_replicated_ms = now_ms();
141            }
142        }
143    }
144
145    /// Get the replication lag in milliseconds for a materialized view.
146    pub fn lag_ms(&self, target: &str) -> u64 {
147        self.view_by_target(target)
148            .map(|v| now_ms().saturating_sub(v.last_replicated_ms))
149            .unwrap_or(0)
150    }
151
152    /// Whether any materialized views exist.
153    pub fn is_empty(&self) -> bool {
154        self.views.is_empty()
155    }
156}
157
158impl Default for HtapBridge {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164fn now_ms() -> u64 {
165    SystemTime::now()
166        .duration_since(UNIX_EPOCH)
167        .map(|d| d.as_millis() as u64)
168        .unwrap_or(0)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn register_and_lookup_view() {
177        let mut bridge = HtapBridge::new();
178        bridge.register_view("customers", "customer_analytics");
179
180        assert!(!bridge.is_empty());
181        assert_eq!(bridge.views_for_source("customers").len(), 1);
182        assert_eq!(
183            bridge.views_for_source("customers")[0].target,
184            "customer_analytics"
185        );
186        assert!(bridge.view_by_target("customer_analytics").is_some());
187        assert!(bridge.view_by_target("nonexistent").is_none());
188    }
189
190    #[test]
191    fn remove_view() {
192        let mut bridge = HtapBridge::new();
193        bridge.register_view("customers", "analytics_1");
194        bridge.register_view("customers", "analytics_2");
195
196        assert_eq!(bridge.views_for_source("customers").len(), 2);
197
198        bridge.remove_view("analytics_1");
199        assert_eq!(bridge.views_for_source("customers").len(), 1);
200        assert_eq!(
201            bridge.views_for_source("customers")[0].target,
202            "analytics_2"
203        );
204    }
205
206    #[test]
207    fn multiple_sources() {
208        let mut bridge = HtapBridge::new();
209        bridge.register_view("orders", "order_analytics");
210        bridge.register_view("customers", "customer_analytics");
211
212        assert_eq!(bridge.all_targets().len(), 2);
213    }
214}