Skip to main content

rbt/engine/
mod.rs

1//! `rbt::engine`: Apache DataFusion query engine integration, bronze registration, and DAG execution.
2
3pub mod bronze;
4
5use crate::core::dag::{ModelDag, OutputFormat};
6use crate::core::project::{MaterializeConfig, RbtProjectConfig, RefBackend};
7use crate::materializer::{sibling_iceberg_dir, MultiFormatWriter};
8use crate::testing::{assertions_from_model_tests, RecordBatchValidator};
9use anyhow::{bail, Context, Result};
10use arrow::record_batch::RecordBatch;
11use datafusion::datasource::MemTable;
12use datafusion::execution::context::SessionContext;
13use datafusion::physical_plan::SendableRecordBatchStream;
14use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
15use iceberg::Catalog;
16use iceberg_datafusion::IcebergCatalogProvider;
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex};
20
21pub use bronze::{
22    register_bronze_for_model, register_bronze_sources_for_dag, BronzeRegistrationMode,
23    BronzeSourceMeta, BronzeTableProvider,
24};
25
26/// Execution metric summary for a executed model DAG.
27#[derive(Debug, Clone)]
28pub struct DagExecutionSummary {
29    pub models_executed: usize,
30    pub total_rows_produced: usize,
31    pub bronze_sources_registered: usize,
32}
33
34/// Fluent Builder for configuring and launching `TransformationEngine` instances.
35#[derive(Default)]
36pub struct RbtEngineBuilder {
37    catalogs: Vec<(String, Arc<dyn Catalog>)>,
38}
39
40impl RbtEngineBuilder {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    pub fn with_catalog(mut self, name: impl Into<String>, catalog: Arc<dyn Catalog>) -> Self {
46        self.catalogs.push((name.into(), catalog));
47        self
48    }
49
50    pub async fn build(self) -> Result<TransformationEngine> {
51        let engine = TransformationEngine::new();
52        for (name, cat) in self.catalogs {
53            engine.register_iceberg_catalog(&name, cat).await?;
54        }
55        Ok(engine)
56    }
57}
58
59pub struct TransformationEngine {
60    pub ctx: SessionContext,
61    /// Cached project config keyed by canonical project_dir (roots, materialize, scan limits).
62    ///
63    /// Avoids re-reading `rbt_project.yml` once per bronze model on large DAGs.
64    project_cache: Mutex<Option<(PathBuf, Arc<RbtProjectConfig>)>>,
65}
66
67impl Default for TransformationEngine {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl TransformationEngine {
74    pub fn new() -> Self {
75        Self {
76            ctx: SessionContext::new(),
77            project_cache: Mutex::new(None),
78        }
79    }
80
81    /// Load (or reuse cached) project config for `project_dir`.
82    pub fn load_project_config(&self, project_dir: &Path) -> Result<Arc<RbtProjectConfig>> {
83        let key = project_dir
84            .canonicalize()
85            .unwrap_or_else(|_| project_dir.to_path_buf());
86        let mut guard = self
87            .project_cache
88            .lock()
89            .map_err(|_| anyhow::anyhow!("E_RBT_ENGINE: project config cache lock poisoned"))?;
90        if let Some((ref cached_dir, ref cfg)) = *guard {
91            if *cached_dir == key {
92                return Ok(Arc::clone(cfg));
93            }
94        }
95        let cfg = Arc::new(RbtProjectConfig::load(project_dir).with_context(|| {
96            format!(
97                "E_RBT_PROJECT_LOAD: failed loading rbt_project.yml under {}",
98                project_dir.display()
99            )
100        })?);
101        *guard = Some((key, Arc::clone(&cfg)));
102        Ok(cfg)
103    }
104
105    /// Clear cached project config (tests / multi-project hosts).
106    pub fn clear_project_cache(&self) {
107        if let Ok(mut guard) = self.project_cache.lock() {
108            *guard = None;
109        }
110    }
111
112    /// Registers an Apache Iceberg catalog directly into the DataFusion query context.
113    pub async fn register_iceberg_catalog(
114        &self,
115        catalog_name: &str,
116        catalog: Arc<dyn Catalog>,
117    ) -> Result<()> {
118        tracing::info!(
119            "Registering Iceberg catalog '{}' into DataFusion SessionContext",
120            catalog_name
121        );
122        let provider = IcebergCatalogProvider::try_new(catalog).await?;
123        self.ctx.register_catalog(catalog_name, Arc::new(provider));
124        Ok(())
125    }
126
127    /// Executes a SQL transform query against registered tables.
128    pub async fn execute_sql(&self, sql: &str) -> Result<SendableRecordBatchStream> {
129        tracing::info!(
130            "Executing SQL transform via Apache DataFusion engine: {}",
131            sql
132        );
133        let df = self.ctx.sql(sql).await?;
134        let stream = df.execute_stream().await?;
135        Ok(stream)
136    }
137
138    /// Executes a full pipeline DAG tier by tier.
139    ///
140    /// Loads `materialize:` policy from `rbt_project.yml` when present (defaults to
141    /// lake-as-truth Parquet re-read for `ref()`).
142    ///
143    /// Before any model SQL runs, bronze sources declared in staging frontmatter are
144    /// registered via [`register_bronze_sources_for_dag`].
145    pub async fn execute_dag(
146        &self,
147        dag: &ModelDag,
148        project_dir: impl AsRef<Path>,
149        output_dir: impl AsRef<Path>,
150    ) -> Result<DagExecutionSummary> {
151        let project_dir = project_dir.as_ref();
152        let config = self.load_project_config(project_dir)?;
153        self.execute_dag_with_config(dag, project_dir, output_dir, &config)
154            .await
155    }
156
157    /// Like [`execute_dag`] but with an explicit [`MaterializeConfig`] (tests / library).
158    pub async fn execute_dag_with_materialize(
159        &self,
160        dag: &ModelDag,
161        project_dir: impl AsRef<Path>,
162        output_dir: impl AsRef<Path>,
163        materialize: &MaterializeConfig,
164    ) -> Result<DagExecutionSummary> {
165        let project_dir = project_dir.as_ref();
166        let mut config = (*self.load_project_config(project_dir)?).clone();
167        config.materialize = materialize.clone();
168        self.execute_dag_with_config(dag, project_dir, output_dir, &config)
169            .await
170    }
171
172    /// Full DAG execution with a pre-loaded project config (roots, scan limits, materialize).
173    pub async fn execute_dag_with_config(
174        &self,
175        dag: &ModelDag,
176        project_dir: impl AsRef<Path>,
177        output_dir: impl AsRef<Path>,
178        config: &RbtProjectConfig,
179    ) -> Result<DagExecutionSummary> {
180        let project_dir = project_dir.as_ref();
181        let output_base = output_dir.as_ref();
182        let materialize = &config.materialize;
183        tokio::fs::create_dir_all(output_base).await?;
184
185        let mut registered = HashSet::new();
186        let bronze_sources_registered =
187            register_bronze_sources_for_dag(&self.ctx, dag, project_dir, &mut registered, config)
188                .await
189                .context("frontmatter-driven bronze registration failed")?;
190
191        let tiers = dag.execution_tiers()?;
192        let mut models_executed = 0;
193        let mut total_rows_produced = 0;
194
195        for (tier_idx, tier) in tiers.iter().enumerate() {
196            tracing::info!(
197                "Executing DAG Tier {} with {} parallel models",
198                tier_idx,
199                tier.len()
200            );
201
202            for model in tier {
203                tracing::info!("Executing model '{}'...", model.name);
204
205                // Late-bind: if this model carries frontmatter not registered yet
206                register_bronze_for_model(&self.ctx, model, project_dir, &mut registered, config)
207                    .await?;
208
209                let df = self.ctx.sql(&model.compiled_sql).await.with_context(|| {
210                    format!(
211                        "SQL execution failed for model '{}' (compiled: {})",
212                        model.name, model.compiled_sql
213                    )
214                })?;
215                let batches = df
216                    .collect()
217                    .await
218                    .with_context(|| format!("collect failed for model '{}'", model.name))?;
219                let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
220
221                let dest_path = model
222                    .output_path
223                    .as_ref()
224                    .map(PathBuf::from)
225                    .unwrap_or_else(|| match model.output_format {
226                        OutputFormat::Iceberg => output_base.join(&model.name),
227                        OutputFormat::Jsonl => output_base.join(format!("{}.jsonl", model.name)),
228                        OutputFormat::Csv => output_base.join(format!("{}.csv", model.name)),
229                        _ => output_base.join(format!("{}.parquet", model.name)),
230                    });
231
232                if let Some(parent) = dest_path.parent() {
233                    std::fs::create_dir_all(parent)?;
234                }
235
236                MultiFormatWriter::write_batches(&batches, &model.output_format, &dest_path)?;
237
238                // Frontmatter-declared tests (staging grain / not_null / unique_key).
239                if let Some(fm) = model.frontmatter.as_ref() {
240                    if let Some(tests) = fm.tests.as_ref() {
241                        if !tests.is_empty() {
242                            let unique = tests
243                                .unique
244                                .clone()
245                                .or_else(|| fm.unique_key.clone())
246                                .or_else(|| fm.grain.clone());
247                            let assertions = assertions_from_model_tests(
248                                tests.not_null.as_deref(),
249                                unique.as_deref(),
250                                tests.accepted_values.as_ref(),
251                            );
252                            if !assertions.is_empty() {
253                                let result =
254                                    RecordBatchValidator::validate_batches(&batches, &assertions);
255                                if result.failed_assertions > 0 {
256                                    let msg = format!(
257                                        "model '{}' failed {} test(s): {}",
258                                        model.name,
259                                        result.failed_assertions,
260                                        result.errors.join("; ")
261                                    );
262                                    if tests.should_fail_on_error() {
263                                        bail!(msg);
264                                    }
265                                    tracing::warn!("{}", msg);
266                                } else {
267                                    tracing::info!(
268                                        "model '{}': {} assertion(s) passed ({} rows)",
269                                        model.name,
270                                        result.passed_assertions,
271                                        result.total_rows
272                                    );
273                                }
274                            }
275                        }
276                    } else if let Some(uk) = fm
277                        .unique_key
278                        .as_ref()
279                        .or(fm.grain.as_ref())
280                        .filter(|v| !v.is_empty())
281                    {
282                        // Implicit unique_key/grain check when no tests: block declared
283                        let assertions =
284                            assertions_from_model_tests(None, Some(uk.as_slice()), None);
285                        let result = RecordBatchValidator::validate_batches(&batches, &assertions);
286                        if result.failed_assertions > 0 {
287                            bail!(
288                                "model '{}' grain/unique_key violated: {}",
289                                model.name,
290                                result.errors.join("; ")
291                            );
292                        }
293                    }
294                }
295
296                // Expose model for downstream {{ ref() }} per project materialize policy.
297                if !batches.is_empty() {
298                    let backend = materialize.choose_ref_backend(row_count);
299                    register_model_for_ref(
300                        &self.ctx,
301                        &model.name,
302                        &model.output_format,
303                        &dest_path,
304                        &batches,
305                        backend,
306                    )
307                    .await
308                    .with_context(|| {
309                        format!(
310                            "register model '{}' for ref() (backend={:?}, rows={})",
311                            model.name, backend, row_count
312                        )
313                    })?;
314                    tracing::debug!(
315                        model = %model.name,
316                        rows = row_count,
317                        ?backend,
318                        strategy = ?materialize.ref_strategy,
319                        "registered model for ref()"
320                    );
321                }
322
323                models_executed += 1;
324                total_rows_produced += row_count;
325            }
326        }
327
328        Ok(DagExecutionSummary {
329            models_executed,
330            total_rows_produced,
331            bronze_sources_registered,
332        })
333    }
334}
335
336/// Path used to re-read a model from the lake after materialize.
337fn lake_read_path(format: &OutputFormat, dest_path: &Path) -> PathBuf {
338    match format {
339        OutputFormat::Iceberg => dest_path.join("data/part-00000.parquet"),
340        OutputFormat::ParquetAndIceberg => {
341            // Flat parquet is the primary dual-write artifact for ref().
342            if dest_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
343                dest_path.to_path_buf()
344            } else {
345                dest_path.with_extension("parquet")
346            }
347        }
348        _ => dest_path.to_path_buf(),
349    }
350}
351
352/// Register a completed model so later SQL `ref('name')` resolves.
353async fn register_model_for_ref(
354    ctx: &SessionContext,
355    name: &str,
356    format: &OutputFormat,
357    dest_path: &Path,
358    batches: &[RecordBatch],
359    backend: RefBackend,
360) -> Result<()> {
361    let _ = ctx.deregister_table(name);
362
363    match backend {
364        RefBackend::MemTable => {
365            let schema = batches[0].schema();
366            let mem_table = MemTable::try_new(schema, vec![batches.to_vec()])
367                .map_err(|e| anyhow::anyhow!("MemTable::try_new: {e}"))?;
368            ctx.register_table(name, Arc::new(mem_table))
369                .map_err(|e| anyhow::anyhow!("register_table MemTable: {e}"))?;
370        }
371        RefBackend::LakeFile => match format {
372            OutputFormat::Parquet
373            | OutputFormat::ZeroCopyClone
374            | OutputFormat::Iceberg
375            | OutputFormat::ParquetAndIceberg => {
376                let mut path = lake_read_path(format, dest_path);
377                if !path.exists() && matches!(format, OutputFormat::ParquetAndIceberg) {
378                    let alt = sibling_iceberg_dir(dest_path).join("data/part-00000.parquet");
379                    if alt.exists() {
380                        path = alt;
381                    }
382                }
383                if !path.exists() {
384                    bail!(
385                        "lake file missing for ref('{}'): expected {}",
386                        name,
387                        path.display()
388                    );
389                }
390                ctx.register_parquet(
391                    name,
392                    path.to_str().unwrap_or_default(),
393                    ParquetReadOptions::default(),
394                )
395                .await
396                .map_err(|e| anyhow::anyhow!("register_parquet {}: {e}", path.display()))?;
397            }
398            OutputFormat::Jsonl => {
399                let p = dest_path.to_str().unwrap_or_default();
400                let opts = JsonReadOptions::default()
401                    .file_extension(".jsonl")
402                    .newline_delimited(true);
403                if let Err(e) = ctx.register_json(name, p, opts).await {
404                    tracing::debug!("jsonl register failed ({e}); retry default");
405                    ctx.register_json(name, p, JsonReadOptions::default())
406                        .await
407                        .map_err(|e| anyhow::anyhow!("register_json: {e}"))?;
408                }
409            }
410            OutputFormat::Csv => {
411                ctx.register_csv(
412                    name,
413                    dest_path.to_str().unwrap_or_default(),
414                    CsvReadOptions::default(),
415                )
416                .await
417                .map_err(|e| anyhow::anyhow!("register_csv: {e}"))?;
418            }
419        },
420    }
421    Ok(())
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::core::dag::{Materialization, ModelDag, OutputFormat};
428
429    #[tokio::test]
430    async fn test_engine_initialization() -> Result<()> {
431        let engine = TransformationEngine::new();
432        let df = engine.ctx.sql("SELECT 1 AS col").await?;
433        let batches = df.collect().await?;
434        assert_eq!(batches.len(), 1);
435        assert_eq!(batches[0].num_rows(), 1);
436        Ok(())
437    }
438
439    #[tokio::test]
440    async fn test_dag_execution_multi_format() -> Result<()> {
441        let temp_dir = tempfile::tempdir()?;
442        let engine = TransformationEngine::new();
443
444        let mut dag = ModelDag::new();
445        dag.add_model_with_format(
446            "users",
447            "SELECT 1 AS id, 'Alice' AS name",
448            Materialization::Table,
449            OutputFormat::Jsonl,
450            None,
451            "",
452        )?;
453        dag.add_model_with_format(
454            "active_users",
455            "SELECT * FROM {{ ref('users') }} WHERE id = 1",
456            Materialization::Table,
457            OutputFormat::Parquet,
458            None,
459            "",
460        )?;
461        dag.build_graph()?;
462
463        let summary = engine
464            .execute_dag(&dag, temp_dir.path(), temp_dir.path())
465            .await?;
466        assert_eq!(summary.models_executed, 2);
467        assert_eq!(summary.total_rows_produced, 2);
468        assert!(temp_dir.path().join("users.jsonl").exists());
469        assert!(temp_dir.path().join("active_users.parquet").exists());
470        Ok(())
471    }
472
473    #[tokio::test]
474    async fn test_frontmatter_bronze_end_to_end() -> Result<()> {
475        let temp = tempfile::tempdir()?;
476        let bronze_dir = temp.path().join("lake/bronze");
477        std::fs::create_dir_all(&bronze_dir)?;
478        std::fs::write(
479            bronze_dir.join("raw_stock_trades.jsonl"),
480            r#"{"ticker":"NVDA","timestamp":"2026-07-24T09:30:01Z","price":125.5,"volume":100}
481{"ticker":"AAPL","timestamp":"2026-07-24T09:30:05Z","price":190.0,"volume":50}
482"#,
483        )?;
484
485        let sql = r#"---
486source_format: jsonl
487scan_path: "lake/bronze/raw_stock_trades.jsonl"
488---
489SELECT ticker, price, volume FROM {{ source('bronze', 'raw_stock_trades') }}
490"#;
491
492        let mut dag = ModelDag::new();
493        dag.add_model_with_format(
494            "stg_stock_trades",
495            sql,
496            Materialization::Table,
497            OutputFormat::Parquet,
498            Some(
499                temp.path()
500                    .join("lake/silver/stg_stock_trades.parquet")
501                    .to_string_lossy()
502                    .into(),
503            ),
504            "",
505        )?;
506        dag.build_graph()?;
507
508        let engine = TransformationEngine::new();
509        let summary = engine
510            .execute_dag(&dag, temp.path(), temp.path().join("out"))
511            .await?;
512        assert_eq!(summary.bronze_sources_registered, 1);
513        assert_eq!(summary.models_executed, 1);
514        assert_eq!(summary.total_rows_produced, 2);
515        assert!(temp
516            .path()
517            .join("lake/silver/stg_stock_trades.parquet")
518            .exists());
519        Ok(())
520    }
521
522    #[tokio::test]
523    async fn test_ref_via_parquet_reread_default() -> Result<()> {
524        use crate::core::project::{MaterializeConfig, RefStrategy};
525
526        let temp = tempfile::tempdir()?;
527        let mut dag = ModelDag::new();
528        dag.add_model_with_format(
529            "stg_a",
530            "SELECT 1 AS id, 10 AS v UNION ALL SELECT 2, 20",
531            Materialization::Table,
532            OutputFormat::Parquet,
533            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
534            "",
535        )?;
536        dag.add_model_with_format(
537            "tf_b",
538            "SELECT id, v * 2 AS v2 FROM {{ ref('stg_a') }}",
539            Materialization::Table,
540            OutputFormat::Parquet,
541            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
542            "",
543        )?;
544        dag.build_graph()?;
545
546        let mat = MaterializeConfig {
547            ref_strategy: RefStrategy::Parquet,
548            memtable_max_rows: 50_000,
549        };
550        let engine = TransformationEngine::new();
551        let summary = engine
552            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
553            .await?;
554        assert_eq!(summary.models_executed, 2);
555        assert_eq!(summary.total_rows_produced, 4);
556        assert!(temp.path().join("tf_b.parquet").exists());
557        Ok(())
558    }
559
560    #[tokio::test]
561    async fn test_ref_via_memtable_when_configured() -> Result<()> {
562        use crate::core::project::{MaterializeConfig, RefStrategy};
563
564        let temp = tempfile::tempdir()?;
565        let mut dag = ModelDag::new();
566        dag.add_model_with_format(
567            "stg_a",
568            "SELECT 1 AS id UNION ALL SELECT 2",
569            Materialization::Table,
570            OutputFormat::Parquet,
571            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
572            "",
573        )?;
574        dag.add_model_with_format(
575            "tf_b",
576            "SELECT count(*) AS c FROM {{ ref('stg_a') }}",
577            Materialization::Table,
578            OutputFormat::Parquet,
579            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
580            "",
581        )?;
582        dag.build_graph()?;
583
584        let mat = MaterializeConfig {
585            ref_strategy: RefStrategy::Memtable,
586            memtable_max_rows: 50_000,
587        };
588        let engine = TransformationEngine::new();
589        let summary = engine
590            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
591            .await?;
592        assert_eq!(summary.models_executed, 2);
593        assert!(temp.path().join("tf_b.parquet").exists());
594        Ok(())
595    }
596
597    #[tokio::test]
598    async fn test_memtable_falls_back_to_lake_above_cutoff() -> Result<()> {
599        use crate::core::project::{MaterializeConfig, RefStrategy};
600
601        // Cutoff 1 → 2-row model must use lake re-read.
602        let temp = tempfile::tempdir()?;
603        let mut dag = ModelDag::new();
604        dag.add_model_with_format(
605            "stg_a",
606            "SELECT 1 AS id UNION ALL SELECT 2",
607            Materialization::Table,
608            OutputFormat::Parquet,
609            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
610            "",
611        )?;
612        dag.add_model_with_format(
613            "tf_b",
614            "SELECT * FROM {{ ref('stg_a') }}",
615            Materialization::Table,
616            OutputFormat::Parquet,
617            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
618            "",
619        )?;
620        dag.build_graph()?;
621
622        let mat = MaterializeConfig {
623            ref_strategy: RefStrategy::Memtable,
624            memtable_max_rows: 1,
625        };
626        let engine = TransformationEngine::new();
627        let summary = engine
628            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
629            .await?;
630        assert_eq!(summary.models_executed, 2);
631        assert_eq!(summary.total_rows_produced, 4);
632        Ok(())
633    }
634}