Skip to main content

rbt_engine/
lib.rs

1//! `rbt-engine`: Apache DataFusion query engine integration, bronze registration, and DAG execution.
2
3pub mod bronze;
4
5use anyhow::{Context, Result};
6use datafusion::datasource::MemTable;
7use datafusion::execution::context::SessionContext;
8use datafusion::physical_plan::SendableRecordBatchStream;
9use iceberg::Catalog;
10use iceberg_datafusion::IcebergCatalogProvider;
11use rbt_core::dag::{ModelDag, OutputFormat};
12use rbt_materializer::MultiFormatWriter;
13use rbt_testing::{assertions_from_model_tests, RecordBatchValidator};
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18pub use bronze::{
19    register_bronze_for_model, register_bronze_sources_for_dag, BronzeRegistrationMode,
20    BronzeSourceMeta, BronzeTableProvider,
21};
22
23/// Execution metric summary for a executed model DAG.
24#[derive(Debug, Clone)]
25pub struct DagExecutionSummary {
26    pub models_executed: usize,
27    pub total_rows_produced: usize,
28    pub bronze_sources_registered: usize,
29}
30
31/// Fluent Builder for configuring and launching `TransformationEngine` instances.
32#[derive(Default)]
33pub struct RbtEngineBuilder {
34    catalogs: Vec<(String, Arc<dyn Catalog>)>,
35}
36
37impl RbtEngineBuilder {
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    pub fn with_catalog(mut self, name: impl Into<String>, catalog: Arc<dyn Catalog>) -> Self {
43        self.catalogs.push((name.into(), catalog));
44        self
45    }
46
47    pub async fn build(self) -> Result<TransformationEngine> {
48        let engine = TransformationEngine::new();
49        for (name, cat) in self.catalogs {
50            engine.register_iceberg_catalog(&name, cat).await?;
51        }
52        Ok(engine)
53    }
54}
55
56pub struct TransformationEngine {
57    pub ctx: SessionContext,
58}
59
60impl Default for TransformationEngine {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl TransformationEngine {
67    pub fn new() -> Self {
68        Self {
69            ctx: SessionContext::new(),
70        }
71    }
72
73    /// Registers an Apache Iceberg catalog directly into the DataFusion query context.
74    pub async fn register_iceberg_catalog(
75        &self,
76        catalog_name: &str,
77        catalog: Arc<dyn Catalog>,
78    ) -> Result<()> {
79        tracing::info!(
80            "Registering Iceberg catalog '{}' into DataFusion SessionContext",
81            catalog_name
82        );
83        let provider = IcebergCatalogProvider::try_new(catalog).await?;
84        self.ctx.register_catalog(catalog_name, Arc::new(provider));
85        Ok(())
86    }
87
88    /// Executes a SQL transform query against registered tables.
89    pub async fn execute_sql(&self, sql: &str) -> Result<SendableRecordBatchStream> {
90        tracing::info!(
91            "Executing SQL transform via Apache DataFusion engine: {}",
92            sql
93        );
94        let df = self.ctx.sql(sql).await?;
95        let stream = df.execute_stream().await?;
96        Ok(stream)
97    }
98
99    /// Executes a full pipeline DAG tier by tier.
100    ///
101    /// Before any model SQL runs, bronze sources declared in staging frontmatter are
102    /// registered via [`register_bronze_sources_for_dag`].
103    pub async fn execute_dag(
104        &self,
105        dag: &ModelDag,
106        project_dir: impl AsRef<Path>,
107        output_dir: impl AsRef<Path>,
108    ) -> Result<DagExecutionSummary> {
109        let project_dir = project_dir.as_ref();
110        let output_base = output_dir.as_ref();
111        tokio::fs::create_dir_all(output_base).await?;
112
113        let mut registered = HashSet::new();
114        let bronze_sources_registered =
115            register_bronze_sources_for_dag(&self.ctx, dag, project_dir, &mut registered)
116                .await
117                .context("frontmatter-driven bronze registration failed")?;
118
119        let tiers = dag.execution_tiers()?;
120        let mut models_executed = 0;
121        let mut total_rows_produced = 0;
122
123        for (tier_idx, tier) in tiers.iter().enumerate() {
124            tracing::info!(
125                "Executing DAG Tier {} with {} parallel models",
126                tier_idx,
127                tier.len()
128            );
129
130            for model in tier {
131                tracing::info!("Executing model '{}'...", model.name);
132
133                // Late-bind: if this model carries frontmatter not registered yet
134                register_bronze_for_model(&self.ctx, model, project_dir, &mut registered)
135                    .await?;
136
137                let df = self
138                    .ctx
139                    .sql(&model.compiled_sql)
140                    .await
141                    .with_context(|| {
142                        format!(
143                            "SQL execution failed for model '{}' (compiled: {})",
144                            model.name, model.compiled_sql
145                        )
146                    })?;
147                let batches = df.collect().await.with_context(|| {
148                    format!("collect failed for model '{}'", model.name)
149                })?;
150                let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
151
152                let dest_path = model.output_path.as_ref().map(PathBuf::from).unwrap_or_else(
153                    || match model.output_format {
154                        OutputFormat::Iceberg => output_base.join(&model.name),
155                        OutputFormat::Jsonl => {
156                            output_base.join(format!("{}.jsonl", model.name))
157                        }
158                        OutputFormat::Csv => output_base.join(format!("{}.csv", model.name)),
159                        _ => output_base.join(format!("{}.parquet", model.name)),
160                    },
161                );
162
163                if let Some(parent) = dest_path.parent() {
164                    std::fs::create_dir_all(parent)?;
165                }
166
167                MultiFormatWriter::write_batches(&batches, &model.output_format, &dest_path)?;
168
169                // Frontmatter-declared tests (staging grain / not_null / unique_key).
170                if let Some(fm) = model.frontmatter.as_ref() {
171                    if let Some(tests) = fm.tests.as_ref() {
172                        if !tests.is_empty() {
173                            let unique = tests
174                                .unique
175                                .clone()
176                                .or_else(|| fm.unique_key.clone())
177                                .or_else(|| fm.grain.clone());
178                            let assertions = assertions_from_model_tests(
179                                tests.not_null.as_deref(),
180                                unique.as_deref(),
181                                tests.accepted_values.as_ref(),
182                            );
183                            if !assertions.is_empty() {
184                                let result =
185                                    RecordBatchValidator::validate_batches(&batches, &assertions);
186                                if result.failed_assertions > 0 {
187                                    let msg = format!(
188                                        "model '{}' failed {} test(s): {}",
189                                        model.name,
190                                        result.failed_assertions,
191                                        result.errors.join("; ")
192                                    );
193                                    if tests.should_fail_on_error() {
194                                        anyhow::bail!(msg);
195                                    }
196                                    tracing::warn!("{}", msg);
197                                } else {
198                                    tracing::info!(
199                                        "model '{}': {} assertion(s) passed ({} rows)",
200                                        model.name,
201                                        result.passed_assertions,
202                                        result.total_rows
203                                    );
204                                }
205                            }
206                        }
207                    } else if let Some(uk) = fm
208                        .unique_key
209                        .as_ref()
210                        .or(fm.grain.as_ref())
211                        .filter(|v| !v.is_empty())
212                    {
213                        // Implicit unique_key/grain check when no tests: block declared
214                        let assertions = assertions_from_model_tests(None, Some(uk.as_slice()), None);
215                        let result =
216                            RecordBatchValidator::validate_batches(&batches, &assertions);
217                        if result.failed_assertions > 0 {
218                            anyhow::bail!(
219                                "model '{}' grain/unique_key violated: {}",
220                                model.name,
221                                result.errors.join("; ")
222                            );
223                        }
224                    }
225                }
226
227                if !batches.is_empty() {
228                    let schema = batches[0].schema();
229                    let mem_table = MemTable::try_new(schema, vec![batches.clone()])?;
230                    // Allow re-runs in tests
231                    let _ = self.ctx.deregister_table(model.name.as_str());
232                    self.ctx
233                        .register_table(model.name.as_str(), Arc::new(mem_table))?;
234                }
235
236                models_executed += 1;
237                total_rows_produced += row_count;
238            }
239        }
240
241        Ok(DagExecutionSummary {
242            models_executed,
243            total_rows_produced,
244            bronze_sources_registered,
245        })
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use rbt_core::dag::{Materialization, ModelDag, OutputFormat};
253
254    #[tokio::test]
255    async fn test_engine_initialization() -> Result<()> {
256        let engine = TransformationEngine::new();
257        let df = engine.ctx.sql("SELECT 1 AS col").await?;
258        let batches = df.collect().await?;
259        assert_eq!(batches.len(), 1);
260        assert_eq!(batches[0].num_rows(), 1);
261        Ok(())
262    }
263
264    #[tokio::test]
265    async fn test_dag_execution_multi_format() -> Result<()> {
266        let temp_dir = tempfile::tempdir()?;
267        let engine = TransformationEngine::new();
268
269        let mut dag = ModelDag::new();
270        dag.add_model_with_format(
271            "users",
272            "SELECT 1 AS id, 'Alice' AS name",
273            Materialization::Table,
274            OutputFormat::Jsonl,
275            None,
276            "",
277        )?;
278        dag.add_model_with_format(
279            "active_users",
280            "SELECT * FROM {{ ref('users') }} WHERE id = 1",
281            Materialization::Table,
282            OutputFormat::Parquet,
283            None,
284            "",
285        )?;
286        dag.build_graph()?;
287
288        let summary = engine
289            .execute_dag(&dag, temp_dir.path(), temp_dir.path())
290            .await?;
291        assert_eq!(summary.models_executed, 2);
292        assert_eq!(summary.total_rows_produced, 2);
293        assert!(temp_dir.path().join("users.jsonl").exists());
294        assert!(temp_dir.path().join("active_users.parquet").exists());
295        Ok(())
296    }
297
298    #[tokio::test]
299    async fn test_frontmatter_bronze_end_to_end() -> Result<()> {
300        let temp = tempfile::tempdir()?;
301        let bronze_dir = temp.path().join("lake/bronze");
302        std::fs::create_dir_all(&bronze_dir)?;
303        std::fs::write(
304            bronze_dir.join("raw_stock_trades.jsonl"),
305            r#"{"ticker":"NVDA","timestamp":"2026-07-24T09:30:01Z","price":125.5,"volume":100}
306{"ticker":"AAPL","timestamp":"2026-07-24T09:30:05Z","price":190.0,"volume":50}
307"#,
308        )?;
309
310        let sql = r#"---
311source_format: jsonl
312scan_path: "lake/bronze/raw_stock_trades.jsonl"
313---
314SELECT ticker, price, volume FROM {{ source('bronze', 'raw_stock_trades') }}
315"#;
316
317        let mut dag = ModelDag::new();
318        dag.add_model_with_format(
319            "stg_stock_trades",
320            sql,
321            Materialization::Table,
322            OutputFormat::Parquet,
323            Some(
324                temp.path()
325                    .join("lake/silver/stg_stock_trades.parquet")
326                    .to_string_lossy()
327                    .into(),
328            ),
329            "",
330        )?;
331        dag.build_graph()?;
332
333        let engine = TransformationEngine::new();
334        let summary = engine.execute_dag(&dag, temp.path(), temp.path().join("out")).await?;
335        assert_eq!(summary.bronze_sources_registered, 1);
336        assert_eq!(summary.models_executed, 1);
337        assert_eq!(summary.total_rows_produced, 2);
338        assert!(temp
339            .path()
340            .join("lake/silver/stg_stock_trades.parquet")
341            .exists());
342        Ok(())
343    }
344}