1pub 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#[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#[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 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 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 pub fn clear_project_cache(&self) {
107 if let Ok(mut guard) = self.project_cache.lock() {
108 *guard = None;
109 }
110 }
111
112 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 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 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 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 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 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 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 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 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
336fn 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 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
352async 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 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}