1pub mod bronze;
4pub mod udf;
5
6use crate::core::dag::{Materialization, ModelDag, ModelNode, OutputFormat};
7use crate::core::project::{
8 MaterializeConfig, MaterializeMode, RbtProjectConfig, RefBackend,
9};
10use crate::core::receipt::{
11 bronze_fingerprint, effective_contract_version, now_unix_ms, ModelRunResult, RunReceipt,
12 RunStatus,
13};
14use crate::core::run_scope::RunScope;
15use crate::materializer::{
16 incremental_ref_path, load_parquet_batches, materialize_incremental_append_stream,
17 materialize_stream, new_wap_run_id, sibling_iceberg_dir, wap_publish, MaterializeWriteOptions,
18 MultiFormatWriter, StreamWriteStats, WapModelPaths,
19};
20use crate::engine::udf::register_builtin_udfs;
21use crate::testing::{assertions_from_model_tests, Assertion, RecordBatchValidator};
22use anyhow::{bail, Context, Result};
23use datafusion::datasource::MemTable;
24use datafusion::execution::context::SessionContext;
25use datafusion::physical_plan::SendableRecordBatchStream;
26use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
27use iceberg::Catalog;
28use iceberg_datafusion::IcebergCatalogProvider;
29use std::collections::HashSet;
30use std::path::{Path, PathBuf};
31use std::sync::{Arc, Mutex};
32
33pub use bronze::{
34 register_bronze_for_model, register_bronze_for_model_scoped, register_bronze_sources_for_dag,
35 register_bronze_sources_for_dag_scoped, BronzeRegistrationMode, BronzeSourceMeta,
36 BronzeTableProvider,
37};
38
39#[derive(Debug, Clone)]
41pub struct DagExecutionSummary {
42 pub models_executed: usize,
43 pub total_rows_produced: usize,
44 pub bronze_sources_registered: usize,
45 pub skipped: bool,
47 pub skip_reason: Option<String>,
48 pub bronze_fingerprint: Option<String>,
49 pub run_id: Option<String>,
50 pub receipt_path: Option<PathBuf>,
51 pub model_results: Vec<ModelRunResult>,
52}
53
54impl Default for DagExecutionSummary {
55 fn default() -> Self {
56 Self {
57 models_executed: 0,
58 total_rows_produced: 0,
59 bronze_sources_registered: 0,
60 skipped: false,
61 skip_reason: None,
62 bronze_fingerprint: None,
63 run_id: None,
64 receipt_path: None,
65 model_results: Vec::new(),
66 }
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct PreviewResult {
73 pub model: String,
74 pub compiled_sql: String,
75 pub limit: usize,
76 pub rows: usize,
77 pub batches: Vec<arrow::record_batch::RecordBatch>,
78 pub ancestors_executed: usize,
79}
80
81#[derive(Default)]
83pub struct RbtEngineBuilder {
84 catalogs: Vec<(String, Arc<dyn Catalog>)>,
85}
86
87impl RbtEngineBuilder {
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn with_catalog(mut self, name: impl Into<String>, catalog: Arc<dyn Catalog>) -> Self {
93 self.catalogs.push((name.into(), catalog));
94 self
95 }
96
97 pub async fn build(self) -> Result<TransformationEngine> {
98 let engine = TransformationEngine::new();
99 for (name, cat) in self.catalogs {
100 engine.register_iceberg_catalog(&name, cat).await?;
101 }
102 Ok(engine)
103 }
104}
105
106pub struct TransformationEngine {
107 pub ctx: SessionContext,
108 project_cache: Mutex<Option<(PathBuf, Arc<RbtProjectConfig>)>>,
112}
113
114impl Default for TransformationEngine {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl TransformationEngine {
121 pub fn new() -> Self {
122 let ctx = SessionContext::new();
123 if let Err(e) = register_builtin_udfs(&ctx) {
124 tracing::warn!("E_RBT_UDF: failed to register builtins: {e}");
125 }
126 Self {
127 ctx,
128 project_cache: Mutex::new(None),
129 }
130 }
131
132 pub fn load_project_config(&self, project_dir: &Path) -> Result<Arc<RbtProjectConfig>> {
134 let key = project_dir
135 .canonicalize()
136 .unwrap_or_else(|_| project_dir.to_path_buf());
137 let mut guard = self
138 .project_cache
139 .lock()
140 .map_err(|_| anyhow::anyhow!("E_RBT_ENGINE: project config cache lock poisoned"))?;
141 if let Some((ref cached_dir, ref cfg)) = *guard {
142 if *cached_dir == key {
143 return Ok(Arc::clone(cfg));
144 }
145 }
146 let cfg = Arc::new(RbtProjectConfig::load(project_dir).with_context(|| {
147 format!(
148 "E_RBT_PROJECT_LOAD: failed loading rbt_project.yml under {}",
149 project_dir.display()
150 )
151 })?);
152 *guard = Some((key, Arc::clone(&cfg)));
153 Ok(cfg)
154 }
155
156 pub fn clear_project_cache(&self) {
158 if let Ok(mut guard) = self.project_cache.lock() {
159 *guard = None;
160 }
161 }
162
163 pub async fn register_iceberg_catalog(
165 &self,
166 catalog_name: &str,
167 catalog: Arc<dyn Catalog>,
168 ) -> Result<()> {
169 tracing::info!(
170 "Registering Iceberg catalog '{}' into DataFusion SessionContext",
171 catalog_name
172 );
173 let provider = IcebergCatalogProvider::try_new(catalog).await?;
174 self.ctx.register_catalog(catalog_name, Arc::new(provider));
175 Ok(())
176 }
177
178 pub async fn execute_sql(&self, sql: &str) -> Result<SendableRecordBatchStream> {
180 tracing::info!(
181 "Executing SQL transform via Apache DataFusion engine: {}",
182 sql
183 );
184 let df = self.ctx.sql(sql).await?;
185 let stream = df.execute_stream().await?;
186 Ok(stream)
187 }
188
189 pub async fn execute_dag(
197 &self,
198 dag: &ModelDag,
199 project_dir: impl AsRef<Path>,
200 output_dir: impl AsRef<Path>,
201 ) -> Result<DagExecutionSummary> {
202 let project_dir = project_dir.as_ref();
203 let config = self.load_project_config(project_dir)?;
204 self.execute_dag_with_config(dag, project_dir, output_dir, &config)
205 .await
206 }
207
208 pub async fn execute_dag_with_materialize(
210 &self,
211 dag: &ModelDag,
212 project_dir: impl AsRef<Path>,
213 output_dir: impl AsRef<Path>,
214 materialize: &MaterializeConfig,
215 ) -> Result<DagExecutionSummary> {
216 let project_dir = project_dir.as_ref();
217 let mut config = (*self.load_project_config(project_dir)?).clone();
218 config.materialize = materialize.clone();
219 self.execute_dag_with_config(dag, project_dir, output_dir, &config)
220 .await
221 }
222
223 pub async fn preview_model(
228 &self,
229 full_dag: &ModelDag,
230 project_dir: impl AsRef<Path>,
231 output_dir: impl AsRef<Path>,
232 model_name: &str,
233 limit: usize,
234 ) -> Result<PreviewResult> {
235 let project_dir = project_dir.as_ref();
236 let config = self.load_project_config(project_dir)?;
237 let limit = limit.clamp(1, 10_000);
238
239 let sub = full_dag
240 .apply_select(Some(model_name), crate::core::SelectMode::Execute)
241 .with_context(|| {
242 format!(
243 "E_RBT_PREVIEW: cannot select model '{model_name}' (check name / --select)"
244 )
245 })?;
246 let seq = sub.topological_sequence()?;
247 let target = seq
248 .iter()
249 .find(|m| m.name == model_name)
250 .cloned()
251 .ok_or_else(|| {
252 anyhow::anyhow!("E_RBT_PREVIEW: model '{model_name}' not found in project DAG")
253 })?;
254 self.preview_model_inner(full_dag, project_dir, output_dir, &target, limit, &config)
255 .await
256 }
257
258 async fn preview_model_inner(
259 &self,
260 full_dag: &ModelDag,
261 project_dir: &Path,
262 output_dir: impl AsRef<Path>,
263 target: &ModelNode,
264 limit: usize,
265 config: &RbtProjectConfig,
266 ) -> Result<PreviewResult> {
267 let output_dir = output_dir.as_ref();
268 let sub = full_dag
269 .apply_select(Some(&target.name), crate::core::SelectMode::Execute)?;
270 let seq = sub.topological_sequence()?;
271 let ancestor_names: Vec<String> = seq
272 .iter()
273 .map(|m| m.name.clone())
274 .filter(|n| n != &target.name)
275 .collect();
276
277 let mut ancestors_executed = 0usize;
278 if !ancestor_names.is_empty() {
279 let anc_select = ancestor_names.join(",");
280 let anc_dag = full_dag
281 .apply_select(Some(&anc_select), crate::core::SelectMode::Execute)
282 .context("E_RBT_PREVIEW: ancestor select failed")?;
283 let summary = self
284 .execute_dag_with_config(&anc_dag, project_dir, output_dir, config)
285 .await
286 .context("E_RBT_PREVIEW: ancestor materialize failed")?;
287 ancestors_executed = summary.models_executed;
288 } else {
289 let mut registered = HashSet::new();
291 register_bronze_sources_for_dag(
292 &self.ctx,
293 &sub,
294 project_dir,
295 &mut registered,
296 config,
297 )
298 .await
299 .context("E_RBT_PREVIEW: bronze registration failed")?;
300 }
301
302 let mut registered = HashSet::new();
304 register_bronze_for_model(&self.ctx, target, project_dir, &mut registered, config)
305 .await?;
306
307 let preview_sql = format!(
308 "SELECT * FROM (\n{}\n) AS _rbt_preview LIMIT {}",
309 target.compiled_sql.trim().trim_end_matches(';'),
310 limit
311 );
312 let df = self.ctx.sql(&preview_sql).await.with_context(|| {
313 format!(
314 "E_RBT_PREVIEW: SQL failed for model '{}': {preview_sql}",
315 target.name
316 )
317 })?;
318 let batches = df.collect().await.with_context(|| {
319 format!("E_RBT_PREVIEW: collect failed for model '{}'", target.name)
320 })?;
321 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
322
323 Ok(PreviewResult {
324 model: target.name.clone(),
325 compiled_sql: target.compiled_sql.clone(),
326 limit,
327 rows,
328 batches,
329 ancestors_executed,
330 })
331 }
332
333 pub async fn execute_dag_with_config(
335 &self,
336 dag: &ModelDag,
337 project_dir: impl AsRef<Path>,
338 output_dir: impl AsRef<Path>,
339 config: &RbtProjectConfig,
340 ) -> Result<DagExecutionSummary> {
341 self.execute_dag_with_scope(dag, project_dir, output_dir, config, &RunScope::default())
342 .await
343 }
344
345 pub async fn execute_dag_with_scope(
347 &self,
348 dag: &ModelDag,
349 project_dir: impl AsRef<Path>,
350 output_dir: impl AsRef<Path>,
351 config: &RbtProjectConfig,
352 scope: &RunScope,
353 ) -> Result<DagExecutionSummary> {
354 let project_dir = project_dir.as_ref();
355 let output_base = output_dir.as_ref();
356 let materialize = &config.materialize;
357 tokio::fs::create_dir_all(output_base).await?;
358
359 let started = now_unix_ms();
360 let run_id = scope.resolve_run_id();
361 let scope_key = scope.scope_key();
362 let contract = effective_contract_version(config, scope);
363 let fp = bronze_fingerprint(dag, project_dir, config, scope)
364 .context("E_RBT_FINGERPRINT: bronze fingerprint failed")?;
365
366 if scope.skip_if_fingerprint_match {
367 if let Some(prev) = RunReceipt::load_latest_for_scope(project_dir, &scope_key) {
368 if prev.status == RunStatus::Ok
369 && prev.bronze_fingerprint == fp
370 && prev.contract_version == contract
371 {
372 let finished = now_unix_ms();
373 let mut summary = DagExecutionSummary {
374 models_executed: 0,
375 total_rows_produced: 0,
376 bronze_sources_registered: 0,
377 skipped: true,
378 skip_reason: Some(
379 "bronze fingerprint + contract_version match previous successful receipt"
380 .into(),
381 ),
382 bronze_fingerprint: Some(fp.clone()),
383 run_id: Some(run_id.clone()),
384 receipt_path: None,
385 model_results: Vec::new(),
386 };
387 if scope.write_receipt {
388 let receipt = RunReceipt {
389 schema_version: RunReceipt::SCHEMA_VERSION,
390 run_id: run_id.clone(),
391 project: config.name.clone(),
392 package_version: crate::VERSION.into(),
393 contract_version: contract.clone(),
394 scope_key: scope_key.clone(),
395 vars: scope.vars.clone(),
396 status: RunStatus::Skipped,
397 skipped: true,
398 skip_reason: summary.skip_reason.clone(),
399 bronze_fingerprint: fp.clone(),
400 models_executed: 0,
401 total_rows: 0,
402 bronze_sources: 0,
403 model_results: Vec::new(),
404 started_unix_ms: started,
405 finished_unix_ms: finished,
406 wall_ms: finished.saturating_sub(started),
407 error: None,
408 };
409 summary.receipt_path = Some(receipt.write(project_dir)?);
410 }
411 tracing::info!(
412 "E_RBT_SKIP: identical bronze fingerprint for scope {scope_key}; skipping materialize"
413 );
414 return Ok(summary);
415 }
416 }
417 }
418
419 let mut registered = HashSet::new();
420 let bronze_sources_registered = register_bronze_sources_for_dag_scoped(
421 &self.ctx,
422 dag,
423 project_dir,
424 &mut registered,
425 config,
426 Some(scope),
427 )
428 .await
429 .context("frontmatter-driven bronze registration failed")?;
430
431 let tiers = dag.execution_tiers()?;
432 let mut models_executed = 0;
433 let mut total_rows_produced = 0;
434 let mut model_results: Vec<ModelRunResult> = Vec::new();
435 let wap_run_id = if materialize.wap {
436 Some(new_wap_run_id())
437 } else {
438 None
439 };
440
441 for (tier_idx, tier) in tiers.iter().enumerate() {
442 tracing::info!(
443 "Executing DAG Tier {} with {} parallel models",
444 tier_idx,
445 tier.len()
446 );
447
448 for model in tier {
449 tracing::info!("Executing model '{}'...", model.name);
450
451 register_bronze_for_model_scoped(
453 &self.ctx,
454 model,
455 project_dir,
456 &mut registered,
457 config,
458 Some(scope),
459 )
460 .await?;
461
462 let dest_path = model
463 .output_path
464 .as_ref()
465 .map(PathBuf::from)
466 .unwrap_or_else(|| match model.output_format {
467 OutputFormat::Iceberg => output_base.join(&model.name),
468 OutputFormat::Jsonl => output_base.join(format!("{}.jsonl", model.name)),
469 OutputFormat::Csv => output_base.join(format!("{}.csv", model.name)),
470 _ => output_base.join(format!("{}.parquet", model.name)),
471 });
472
473 if let Some(parent) = dest_path.parent() {
474 std::fs::create_dir_all(parent)?;
475 }
476
477 let (assertions, fail_on_error) = model_assertions(model);
478 let write_opts =
479 MaterializeWriteOptions::from_config(materialize, fail_on_error);
480 let mode = materialize.effective_mode();
481
482 let (write_path, wap_paths) = if let Some(ref run_id) = wap_run_id {
484 if matches!(
485 model.output_format,
486 OutputFormat::Parquet | OutputFormat::ZeroCopyClone
487 ) && model.materialization != Materialization::IncrementalAppend
488 {
489 let paths =
490 WapModelPaths::for_model(project_dir, run_id, &model.name, &dest_path);
491 if let Some(p) = paths.stage_path.parent() {
492 std::fs::create_dir_all(p)?;
493 }
494 (paths.stage_path.clone(), Some(paths))
495 } else {
496 (dest_path.clone(), None)
497 }
498 } else {
499 (dest_path.clone(), None)
500 };
501
502 let (row_count, write_stats) = match (
503 &model.materialization,
504 &model.output_format,
505 mode,
506 ) {
507 (
508 Materialization::IncrementalAppend,
509 OutputFormat::Parquet | OutputFormat::ZeroCopyClone,
510 MaterializeMode::Stream,
511 ) => {
512 let df = self.ctx.sql(&model.compiled_sql).await.with_context(|| {
513 format!("E_RBT_SQL: model '{}'", model.name)
514 })?;
515 let stream = df.execute_stream().await?;
516 let stats = materialize_incremental_append_stream(
517 stream,
518 &dest_path,
519 &write_opts,
520 &assertions,
521 )
522 .await
523 .with_context(|| {
524 format!(
525 "E_RBT_INCREMENTAL: model '{}' append failed",
526 model.name
527 )
528 })?;
529 log_assertion_result(model, &stats, fail_on_error)?;
530 (stats.rows, Some(stats))
531 }
532 (
533 Materialization::IncrementalMerge,
534 _,
535 _,
536 ) => {
537 bail!(
538 "E_RBT_INCREMENTAL: model '{}': incremental_merge is not implemented yet \
539 (use incremental_append for part-file appends)",
540 model.name
541 );
542 }
543 (_, _, MaterializeMode::Stream) => {
544 let stats = execute_model_stream(
545 &self.ctx,
546 model,
547 &write_path,
548 &write_opts,
549 &assertions,
550 fail_on_error,
551 )
552 .await?;
553 (stats.rows, Some(stats))
554 }
555 (_, _, MaterializeMode::Collect) => {
556 let rows = execute_model_collect(
557 &self.ctx,
558 model,
559 &write_path,
560 &write_opts,
561 &assertions,
562 fail_on_error,
563 )
564 .await?;
565 (rows, None)
566 }
567 };
568
569 if let Some(ref paths) = wap_paths {
571 let validation = write_stats
572 .as_ref()
573 .map(|s| s.validation.clone())
574 .unwrap_or_else(|| crate::testing::ValidationResult {
575 total_rows: row_count,
576 passed_assertions: 0,
577 failed_assertions: 0,
578 errors: Vec::new(),
579 });
580 wap_publish(paths, &model.name, row_count, &validation)?;
581 }
582
583 if row_count > 0
585 || matches!(
586 model.output_format,
587 OutputFormat::Parquet
588 | OutputFormat::Iceberg
589 | OutputFormat::ParquetAndIceberg
590 | OutputFormat::ZeroCopyClone
591 )
592 {
593 let backend = materialize.choose_ref_backend(row_count);
594 if row_count > 0 {
595 let ref_path = if model.materialization == Materialization::IncrementalAppend
596 && matches!(
597 model.output_format,
598 OutputFormat::Parquet | OutputFormat::ZeroCopyClone
599 ) {
600 incremental_ref_path(&dest_path)
601 } else {
602 dest_path.clone()
603 };
604 register_model_for_ref(
605 &self.ctx,
606 &model.name,
607 &model.output_format,
608 &ref_path,
609 backend,
610 )
611 .await
612 .with_context(|| {
613 format!(
614 "E_RBT_REF_REGISTER: model '{}' (backend={:?}, rows={}, mode={:?})",
615 model.name, backend, row_count, mode
616 )
617 })?;
618 tracing::debug!(
619 model = %model.name,
620 rows = row_count,
621 ?backend,
622 ?mode,
623 strategy = ?materialize.ref_strategy,
624 mat = ?model.materialization,
625 "registered model for ref()"
626 );
627 }
628 }
629
630 models_executed += 1;
631 total_rows_produced += row_count;
632 model_results.push(ModelRunResult {
633 name: model.name.clone(),
634 rows: row_count,
635 output_path: Some(dest_path.display().to_string()),
636 });
637 }
638 }
639
640 let finished = now_unix_ms();
641 let mut summary = DagExecutionSummary {
642 models_executed,
643 total_rows_produced,
644 bronze_sources_registered,
645 skipped: false,
646 skip_reason: None,
647 bronze_fingerprint: Some(fp.clone()),
648 run_id: Some(run_id.clone()),
649 receipt_path: None,
650 model_results: model_results.clone(),
651 };
652
653 if scope.write_receipt {
654 let receipt = RunReceipt {
655 schema_version: RunReceipt::SCHEMA_VERSION,
656 run_id: run_id.clone(),
657 project: config.name.clone(),
658 package_version: crate::VERSION.into(),
659 contract_version: contract,
660 scope_key,
661 vars: scope.vars.clone(),
662 status: RunStatus::Ok,
663 skipped: false,
664 skip_reason: None,
665 bronze_fingerprint: fp,
666 models_executed,
667 total_rows: total_rows_produced,
668 bronze_sources: bronze_sources_registered,
669 model_results,
670 started_unix_ms: started,
671 finished_unix_ms: finished,
672 wall_ms: finished.saturating_sub(started),
673 error: None,
674 };
675 summary.receipt_path = Some(receipt.write(project_dir)?);
676 }
677
678 Ok(summary)
679 }
680}
681
682fn model_assertions(model: &ModelNode) -> (Vec<Assertion>, bool) {
684 let mut fail_on_error = true;
685 let assertions = if let Some(fm) = model.frontmatter.as_ref() {
686 if let Some(tests) = fm.tests.as_ref() {
687 fail_on_error = tests.should_fail_on_error();
688 if tests.is_empty() {
689 Vec::new()
690 } else {
691 let unique = tests
692 .unique
693 .clone()
694 .or_else(|| fm.unique_key.clone())
695 .or_else(|| fm.grain.clone());
696 assertions_from_model_tests(
697 tests.not_null.as_deref(),
698 unique.as_deref(),
699 tests.accepted_values.as_ref(),
700 )
701 }
702 } else if let Some(uk) = fm
703 .unique_key
704 .as_ref()
705 .or(fm.grain.as_ref())
706 .filter(|v| !v.is_empty())
707 {
708 fail_on_error = true;
709 assertions_from_model_tests(None, Some(uk.as_slice()), None)
710 } else {
711 Vec::new()
712 }
713 } else {
714 Vec::new()
715 };
716 (assertions, fail_on_error)
717}
718
719fn log_assertion_result(
720 model: &ModelNode,
721 stats: &StreamWriteStats,
722 fail_on_error: bool,
723) -> Result<()> {
724 if stats.validation.failed_assertions > 0 {
725 let msg = format!(
726 "model '{}' failed {} test(s): {}",
727 model.name,
728 stats.validation.failed_assertions,
729 stats.validation.errors.join("; ")
730 );
731 if fail_on_error {
732 bail!("{msg}");
733 }
734 tracing::warn!("{msg}");
735 } else if stats.validation.passed_assertions > 0 {
736 tracing::info!(
737 "model '{}': {} assertion(s) passed ({} rows)",
738 model.name,
739 stats.validation.passed_assertions,
740 stats.rows
741 );
742 }
743 Ok(())
744}
745
746async fn execute_model_stream(
747 ctx: &SessionContext,
748 model: &ModelNode,
749 dest_path: &Path,
750 write_opts: &MaterializeWriteOptions,
751 assertions: &[Assertion],
752 fail_on_error: bool,
753) -> Result<StreamWriteStats> {
754 let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
755 format!(
756 "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
757 model.name, model.compiled_sql
758 )
759 })?;
760 let stream = df.execute_stream().await.with_context(|| {
761 format!(
762 "E_RBT_SQL: execute_stream failed for model '{}'",
763 model.name
764 )
765 })?;
766
767 let stats = materialize_stream(
768 stream,
769 &model.output_format,
770 dest_path,
771 write_opts,
772 assertions,
773 )
774 .await
775 .with_context(|| {
776 format!(
777 "E_RBT_MATERIALIZE: stream write failed for model '{}' → {}",
778 model.name,
779 dest_path.display()
780 )
781 })?;
782
783 if stats.validation.failed_assertions > 0 {
784 let msg = format!(
785 "model '{}' failed {} test(s): {}",
786 model.name,
787 stats.validation.failed_assertions,
788 stats.validation.errors.join("; ")
789 );
790 if fail_on_error {
791 bail!("{msg}");
792 }
793 tracing::warn!("{msg}");
794 } else if !assertions.is_empty() {
795 tracing::info!(
796 "model '{}': {} assertion(s) passed ({} rows, {} batches, stream)",
797 model.name,
798 stats.validation.passed_assertions,
799 stats.rows,
800 stats.batches
801 );
802 } else {
803 tracing::debug!(
804 model = %model.name,
805 rows = stats.rows,
806 batches = stats.batches,
807 bytes = stats.bytes_written,
808 "stream materialize complete"
809 );
810 }
811 Ok(stats)
812}
813
814async fn execute_model_collect(
815 ctx: &SessionContext,
816 model: &ModelNode,
817 dest_path: &Path,
818 write_opts: &MaterializeWriteOptions,
819 assertions: &[Assertion],
820 fail_on_error: bool,
821) -> Result<usize> {
822 let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
823 format!(
824 "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
825 model.name, model.compiled_sql
826 )
827 })?;
828 let batches = df.collect().await.with_context(|| {
829 format!(
830 "E_RBT_SQL: collect failed for model '{}'",
831 model.name
832 )
833 })?;
834 let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
835
836 MultiFormatWriter::write_batches(&batches, &model.output_format, dest_path)?;
837 if !assertions.is_empty() {
840 let result = RecordBatchValidator::validate_batches(&batches, assertions);
841 if result.failed_assertions > 0 {
842 let msg = format!(
843 "model '{}' failed {} test(s): {}",
844 model.name,
845 result.failed_assertions,
846 result.errors.join("; ")
847 );
848 if fail_on_error {
849 bail!("{msg}");
850 }
851 tracing::warn!("{msg}");
852 } else {
853 tracing::info!(
854 "model '{}': {} assertion(s) passed ({} rows, collect)",
855 model.name,
856 result.passed_assertions,
857 result.total_rows
858 );
859 }
860 }
861
862 let _ = write_opts; Ok(row_count)
864}
865
866fn lake_read_path(format: &OutputFormat, dest_path: &Path) -> PathBuf {
868 match format {
869 OutputFormat::Iceberg => {
870 let hint = dest_path.join(".rbt_iceberg_data");
872 if let Ok(p) = std::fs::read_to_string(&hint) {
873 let p = PathBuf::from(p.trim());
874 if p.exists() {
875 return p;
876 }
877 }
878 let preferred = dest_path.join("data/part-00000.parquet");
879 if preferred.exists() {
880 preferred
881 } else if let Some(p) = find_first_parquet_under(dest_path) {
882 p
883 } else {
884 preferred
885 }
886 }
887 OutputFormat::ParquetAndIceberg => {
888 if dest_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
890 dest_path.to_path_buf()
891 } else {
892 dest_path.with_extension("parquet")
893 }
894 }
895 _ => dest_path.to_path_buf(),
896 }
897}
898
899fn find_first_parquet_under(dir: &Path) -> Option<PathBuf> {
900 let mut stack = vec![dir.to_path_buf()];
901 while let Some(d) = stack.pop() {
902 let entries = std::fs::read_dir(&d).ok()?;
903 for e in entries.flatten() {
904 let p = e.path();
905 if p.is_dir() {
906 stack.push(p);
907 } else if p.extension().and_then(|x| x.to_str()) == Some("parquet") {
908 return Some(p);
909 }
910 }
911 }
912 None
913}
914
915async fn register_model_for_ref(
920 ctx: &SessionContext,
921 name: &str,
922 format: &OutputFormat,
923 dest_path: &Path,
924 backend: RefBackend,
925) -> Result<()> {
926 let _ = ctx.deregister_table(name);
927
928 match backend {
929 RefBackend::MemTable => {
930 let batches = match format {
931 OutputFormat::Parquet
932 | OutputFormat::ZeroCopyClone
933 | OutputFormat::Iceberg
934 | OutputFormat::ParquetAndIceberg => {
935 let path = resolve_lake_read_path(format, dest_path)?;
936 load_parquet_batches(&path).with_context(|| {
937 format!(
938 "E_RBT_REF_MEMTABLE: load {} for ref('{name}')",
939 path.display()
940 )
941 })?
942 }
943 OutputFormat::Jsonl | OutputFormat::Csv => {
944 Box::pin(async {
946 register_model_for_ref(ctx, name, format, dest_path, RefBackend::LakeFile)
948 .await?;
949 let df = ctx.table(name).await.map_err(|e| {
950 anyhow::anyhow!("E_RBT_REF_MEMTABLE: table '{name}': {e}")
951 })?;
952 df.collect().await.map_err(|e| {
953 anyhow::anyhow!("E_RBT_REF_MEMTABLE: collect '{name}': {e}")
954 })
955 })
956 .await?
957 }
958 };
959 if batches.is_empty() {
960 bail!("E_RBT_REF_MEMTABLE: no batches for ref('{name}')");
961 }
962 let _ = ctx.deregister_table(name);
963 let schema = batches[0].schema();
964 let mem_table = MemTable::try_new(schema, vec![batches])
965 .map_err(|e| anyhow::anyhow!("MemTable::try_new: {e}"))?;
966 ctx.register_table(name, Arc::new(mem_table))
967 .map_err(|e| anyhow::anyhow!("register_table MemTable: {e}"))?;
968 }
969 RefBackend::LakeFile => match format {
970 OutputFormat::Parquet
971 | OutputFormat::ZeroCopyClone
972 | OutputFormat::Iceberg
973 | OutputFormat::ParquetAndIceberg => {
974 let path = resolve_lake_read_path(format, dest_path)?;
975 ctx.register_parquet(
976 name,
977 path.to_str().unwrap_or_default(),
978 ParquetReadOptions::default(),
979 )
980 .await
981 .map_err(|e| {
982 anyhow::anyhow!(
983 "E_RBT_REF_REGISTER: register_parquet {} for '{name}': {e}",
984 path.display()
985 )
986 })?;
987 }
988 OutputFormat::Jsonl => {
989 let p = dest_path.to_str().unwrap_or_default();
990 let opts = JsonReadOptions::default()
991 .file_extension(".jsonl")
992 .newline_delimited(true);
993 if let Err(e) = ctx.register_json(name, p, opts).await {
994 tracing::debug!("jsonl register failed ({e}); retry default");
995 ctx.register_json(name, p, JsonReadOptions::default())
996 .await
997 .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_json: {e}"))?;
998 }
999 }
1000 OutputFormat::Csv => {
1001 ctx.register_csv(
1002 name,
1003 dest_path.to_str().unwrap_or_default(),
1004 CsvReadOptions::default(),
1005 )
1006 .await
1007 .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_csv: {e}"))?;
1008 }
1009 },
1010 }
1011 Ok(())
1012}
1013
1014fn resolve_lake_read_path(format: &OutputFormat, dest_path: &Path) -> Result<PathBuf> {
1015 let mut path = lake_read_path(format, dest_path);
1016 if !path.exists() && matches!(format, OutputFormat::ParquetAndIceberg) {
1017 let alt = sibling_iceberg_dir(dest_path).join("data/part-00000.parquet");
1018 if alt.exists() {
1019 path = alt;
1020 }
1021 }
1022 if !path.exists() {
1023 bail!(
1024 "E_RBT_REF_MISSING: lake file missing for ref(): expected {}",
1025 path.display()
1026 );
1027 }
1028 Ok(path)
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034 use crate::core::dag::{Materialization, ModelDag, OutputFormat};
1035
1036 #[tokio::test]
1037 async fn test_engine_initialization() -> Result<()> {
1038 let engine = TransformationEngine::new();
1039 let df = engine.ctx.sql("SELECT 1 AS col").await?;
1040 let batches = df.collect().await?;
1041 assert_eq!(batches.len(), 1);
1042 assert_eq!(batches[0].num_rows(), 1);
1043 Ok(())
1044 }
1045
1046 #[tokio::test]
1047 async fn test_dag_execution_multi_format() -> Result<()> {
1048 let temp_dir = tempfile::tempdir()?;
1049 let engine = TransformationEngine::new();
1050
1051 let mut dag = ModelDag::new();
1052 dag.add_model_with_format(
1053 "users",
1054 "SELECT 1 AS id, 'Alice' AS name",
1055 Materialization::Table,
1056 OutputFormat::Jsonl,
1057 None,
1058 "",
1059 )?;
1060 dag.add_model_with_format(
1061 "active_users",
1062 "SELECT * FROM {{ ref('users') }} WHERE id = 1",
1063 Materialization::Table,
1064 OutputFormat::Parquet,
1065 None,
1066 "",
1067 )?;
1068 dag.build_graph()?;
1069
1070 let summary = engine
1071 .execute_dag(&dag, temp_dir.path(), temp_dir.path())
1072 .await?;
1073 assert_eq!(summary.models_executed, 2);
1074 assert_eq!(summary.total_rows_produced, 2);
1075 assert!(temp_dir.path().join("users.jsonl").exists());
1076 assert!(temp_dir.path().join("active_users.parquet").exists());
1077 Ok(())
1078 }
1079
1080 #[tokio::test]
1081 async fn test_frontmatter_bronze_end_to_end() -> Result<()> {
1082 let temp = tempfile::tempdir()?;
1083 let bronze_dir = temp.path().join("lake/bronze");
1084 std::fs::create_dir_all(&bronze_dir)?;
1085 std::fs::write(
1086 bronze_dir.join("raw_stock_trades.jsonl"),
1087 r#"{"ticker":"NVDA","timestamp":"2026-07-24T09:30:01Z","price":125.5,"volume":100}
1088{"ticker":"AAPL","timestamp":"2026-07-24T09:30:05Z","price":190.0,"volume":50}
1089"#,
1090 )?;
1091
1092 let sql = r#"---
1093source_format: jsonl
1094scan_path: "lake/bronze/raw_stock_trades.jsonl"
1095---
1096SELECT ticker, price, volume FROM {{ source('bronze', 'raw_stock_trades') }}
1097"#;
1098
1099 let mut dag = ModelDag::new();
1100 dag.add_model_with_format(
1101 "stg_stock_trades",
1102 sql,
1103 Materialization::Table,
1104 OutputFormat::Parquet,
1105 Some(
1106 temp.path()
1107 .join("lake/silver/stg_stock_trades.parquet")
1108 .to_string_lossy()
1109 .into(),
1110 ),
1111 "",
1112 )?;
1113 dag.build_graph()?;
1114
1115 let engine = TransformationEngine::new();
1116 let summary = engine
1117 .execute_dag(&dag, temp.path(), temp.path().join("out"))
1118 .await?;
1119 assert_eq!(summary.bronze_sources_registered, 1);
1120 assert_eq!(summary.models_executed, 1);
1121 assert_eq!(summary.total_rows_produced, 2);
1122 assert!(temp
1123 .path()
1124 .join("lake/silver/stg_stock_trades.parquet")
1125 .exists());
1126 Ok(())
1127 }
1128
1129 #[tokio::test]
1130 async fn test_ref_via_parquet_reread_default() -> Result<()> {
1131 use crate::core::project::{MaterializeConfig, RefStrategy};
1132
1133 let temp = tempfile::tempdir()?;
1134 let mut dag = ModelDag::new();
1135 dag.add_model_with_format(
1136 "stg_a",
1137 "SELECT 1 AS id, 10 AS v UNION ALL SELECT 2, 20",
1138 Materialization::Table,
1139 OutputFormat::Parquet,
1140 Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1141 "",
1142 )?;
1143 dag.add_model_with_format(
1144 "tf_b",
1145 "SELECT id, v * 2 AS v2 FROM {{ ref('stg_a') }}",
1146 Materialization::Table,
1147 OutputFormat::Parquet,
1148 Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1149 "",
1150 )?;
1151 dag.build_graph()?;
1152
1153 let mat = MaterializeConfig {
1154 ref_strategy: RefStrategy::Parquet,
1155 memtable_max_rows: 50_000,
1156 ..Default::default()
1157 };
1158 let engine = TransformationEngine::new();
1159 let summary = engine
1160 .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1161 .await?;
1162 assert_eq!(summary.models_executed, 2);
1163 assert_eq!(summary.total_rows_produced, 4);
1164 assert!(temp.path().join("tf_b.parquet").exists());
1165 Ok(())
1166 }
1167
1168 #[tokio::test]
1169 async fn test_ref_via_memtable_when_configured() -> Result<()> {
1170 use crate::core::project::{MaterializeConfig, RefStrategy};
1171
1172 let temp = tempfile::tempdir()?;
1173 let mut dag = ModelDag::new();
1174 dag.add_model_with_format(
1175 "stg_a",
1176 "SELECT 1 AS id UNION ALL SELECT 2",
1177 Materialization::Table,
1178 OutputFormat::Parquet,
1179 Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1180 "",
1181 )?;
1182 dag.add_model_with_format(
1183 "tf_b",
1184 "SELECT count(*) AS c FROM {{ ref('stg_a') }}",
1185 Materialization::Table,
1186 OutputFormat::Parquet,
1187 Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1188 "",
1189 )?;
1190 dag.build_graph()?;
1191
1192 let mat = MaterializeConfig {
1193 ref_strategy: RefStrategy::Memtable,
1194 memtable_max_rows: 50_000,
1195 ..Default::default()
1196 };
1197 let engine = TransformationEngine::new();
1198 let summary = engine
1199 .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1200 .await?;
1201 assert_eq!(summary.models_executed, 2);
1202 assert!(temp.path().join("tf_b.parquet").exists());
1203 Ok(())
1204 }
1205
1206 #[tokio::test]
1207 async fn test_memtable_falls_back_to_lake_above_cutoff() -> Result<()> {
1208 use crate::core::project::{MaterializeConfig, RefStrategy};
1209
1210 let temp = tempfile::tempdir()?;
1212 let mut dag = ModelDag::new();
1213 dag.add_model_with_format(
1214 "stg_a",
1215 "SELECT 1 AS id UNION ALL SELECT 2",
1216 Materialization::Table,
1217 OutputFormat::Parquet,
1218 Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1219 "",
1220 )?;
1221 dag.add_model_with_format(
1222 "tf_b",
1223 "SELECT * FROM {{ ref('stg_a') }}",
1224 Materialization::Table,
1225 OutputFormat::Parquet,
1226 Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1227 "",
1228 )?;
1229 dag.build_graph()?;
1230
1231 let mat = MaterializeConfig {
1232 ref_strategy: RefStrategy::Memtable,
1233 memtable_max_rows: 1,
1234 ..Default::default()
1235 };
1236 let engine = TransformationEngine::new();
1237 let summary = engine
1238 .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1239 .await?;
1240 assert_eq!(summary.models_executed, 2);
1241 assert_eq!(summary.total_rows_produced, 4);
1242 Ok(())
1243 }
1244}