1use crate::core::dag::{ModelDag, ModelNode};
19use crate::core::frontmatter::{SourceFormat, StagingFrontmatter};
20use crate::core::receipt::apply_scope_to_frontmatter;
21use crate::core::run_scope::{OnMissing, RunScope};
22use crate::scan::{LakeScanner, ScanRequest};
23use anyhow::{bail, Context, Result};
24use arrow::datatypes::SchemaRef;
25use arrow::record_batch::RecordBatch;
26use async_trait::async_trait;
27use datafusion::catalog::Session;
28use datafusion::catalog::TableProvider;
29use datafusion::common::TableReference;
30use datafusion::datasource::MemTable;
31use datafusion::error::Result as DFResult;
32use datafusion::execution::context::SessionContext;
33use datafusion::execution::options::ArrowReadOptions;
34use datafusion::logical_expr::{Expr, TableType};
35use datafusion::physical_plan::ExecutionPlan;
36use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
37use std::any::Any;
38use std::collections::HashSet;
39use std::path::{Path, PathBuf};
40use std::sync::Arc;
41
42#[derive(Debug, Clone)]
44pub struct BronzeSourceMeta {
45 pub model_name: String,
46 pub source_schema: String,
47 pub source_table: String,
48 pub format: SourceFormat,
49 pub scan_path: PathBuf,
50 pub registration_mode: BronzeRegistrationMode,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BronzeRegistrationMode {
55 DataFusionListing,
57 ScanMemTable,
59 ScanSpillParquet,
61}
62
63#[derive(Debug)]
65pub struct BronzeTableProvider {
66 pub meta: BronzeSourceMeta,
67 inner: Arc<dyn TableProvider>,
68}
69
70impl BronzeTableProvider {
71 pub fn wrap(inner: Arc<dyn TableProvider>, meta: BronzeSourceMeta) -> Self {
72 Self { meta, inner }
73 }
74
75 pub fn inner(&self) -> &Arc<dyn TableProvider> {
76 &self.inner
77 }
78}
79
80#[async_trait]
81impl TableProvider for BronzeTableProvider {
82 fn as_any(&self) -> &dyn Any {
83 self
84 }
85
86 fn schema(&self) -> SchemaRef {
87 self.inner.schema()
88 }
89
90 fn table_type(&self) -> TableType {
91 self.inner.table_type()
92 }
93
94 async fn scan(
95 &self,
96 state: &dyn Session,
97 projection: Option<&Vec<usize>>,
98 filters: &[Expr],
99 limit: Option<usize>,
100 ) -> DFResult<Arc<dyn ExecutionPlan>> {
101 self.inner.scan(state, projection, filters, limit).await
102 }
103
104 fn supports_filters_pushdown(
105 &self,
106 filters: &[&Expr],
107 ) -> DFResult<Vec<datafusion::logical_expr::TableProviderFilterPushDown>> {
108 self.inner.supports_filters_pushdown(filters)
109 }
110}
111
112pub async fn register_bronze_sources_for_dag(
118 ctx: &SessionContext,
119 dag: &ModelDag,
120 project_dir: &Path,
121 registered: &mut HashSet<(String, String)>,
122 config: &crate::core::project::RbtProjectConfig,
123) -> Result<usize> {
124 register_bronze_sources_for_dag_scoped(ctx, dag, project_dir, registered, config, None).await
125}
126
127pub async fn register_bronze_sources_for_dag_scoped(
129 ctx: &SessionContext,
130 dag: &ModelDag,
131 project_dir: &Path,
132 registered: &mut HashSet<(String, String)>,
133 config: &crate::core::project::RbtProjectConfig,
134 scope: Option<&RunScope>,
135) -> Result<usize> {
136 let mut count = 0;
137 for idx in dag.graph.node_indices() {
138 let node = &dag.graph[idx];
139 if let Some(n) =
140 register_bronze_for_model_scoped(ctx, node, project_dir, registered, config, scope)
141 .await?
142 {
143 count += n;
144 }
145 }
146 Ok(count)
147}
148
149pub async fn register_bronze_for_model(
151 ctx: &SessionContext,
152 node: &ModelNode,
153 project_dir: &Path,
154 registered: &mut HashSet<(String, String)>,
155 config: &crate::core::project::RbtProjectConfig,
156) -> Result<Option<usize>> {
157 register_bronze_for_model_scoped(ctx, node, project_dir, registered, config, None).await
158}
159
160pub async fn register_bronze_for_model_scoped(
162 ctx: &SessionContext,
163 node: &ModelNode,
164 project_dir: &Path,
165 registered: &mut HashSet<(String, String)>,
166 config: &crate::core::project::RbtProjectConfig,
167 scope: Option<&RunScope>,
168) -> Result<Option<usize>> {
169 let Some(fm_raw) = node.frontmatter.as_ref() else {
170 return Ok(None);
171 };
172 if !fm_raw.has_scan_contract() {
173 return Ok(None);
174 }
175
176 let default_scope = RunScope::default();
177 let scope = scope.unwrap_or(&default_scope);
178 let fm = apply_scope_to_frontmatter(fm_raw, scope);
179 let fm = &fm;
180
181 let (schema_name, table_name) = ModelDag::bronze_source_ident(node).with_context(|| {
182 format!(
183 "model '{}': frontmatter has scan_path but no source identity \
184 (add source() in SQL or source_name/source_table in frontmatter)",
185 node.name
186 )
187 })?;
188
189 let key = (schema_name.clone(), table_name.clone());
190 if registered.contains(&key) {
191 tracing::debug!(
192 "Bronze source {}.{} already registered; skipping model '{}'",
193 schema_name,
194 table_name,
195 node.name
196 );
197 return Ok(None);
198 }
199
200 ensure_schema(ctx, &schema_name).await?;
201
202 let format = fm
203 .resolve_format()
204 .with_context(|| format!("model '{}': cannot resolve source_format", node.name))?;
205
206 let raw_scan = fm.scan_path.as_deref().unwrap();
207 let resolved = crate::core::paths::resolve_project_path(project_dir, raw_scan, &config.roots)
208 .with_context(|| {
209 format!(
210 "E_RBT_BRONZE_PATH: model '{}': cannot resolve scan_path '{}'. \
211 Check absolute paths and `roots:` templates in rbt_project.yml.",
212 node.name, raw_scan
213 )
214 })?;
215 let on_missing = fm.on_missing_policy();
216 if !resolved.exists() && !crate::core::frontmatter::is_remote_uri(raw_scan) {
217 if on_missing == OnMissing::Empty {
218 let provider = empty_memtable(fm, scope).with_context(|| {
219 format!(
220 "model '{}': on_missing empty frame failed (scan_path missing)",
221 node.name
222 )
223 })?;
224 return finish_register(
225 ctx,
226 registered,
227 key,
228 schema_name,
229 table_name,
230 node,
231 format,
232 resolved,
233 provider,
234 BronzeRegistrationMode::ScanMemTable,
235 )
236 .await;
237 }
238 bail!(
239 "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND: model '{}': bronze scan_path does not exist: {} \
240 (resolved {}). Hint: verify the lake path and `$root` expansion, \
241 or set on_missing: empty for optional artifact families.",
242 node.name,
243 raw_scan,
244 resolved.display()
245 );
246 }
247
248 let path_str = resolved.to_string_lossy().to_string();
249
250 let parts_mode = fm.wants_parts_source()
252 || crate::scan::parts::is_parts_directory(&resolved)
253 || matches!(format, SourceFormat::Parquet)
254 && resolved.is_dir()
255 && crate::scan::parts::manifest_path(&resolved).is_file();
256
257 if parts_mode {
258 let provider = register_parts_parquet(ctx, &resolved)
259 .await
260 .with_context(|| {
261 format!(
262 "model '{}': parts parquet registration failed for {}",
263 node.name,
264 resolved.display()
265 )
266 })?;
267 return finish_register(
268 ctx,
269 registered,
270 key,
271 schema_name,
272 table_name,
273 node,
274 SourceFormat::Parquet,
275 resolved,
276 provider,
277 BronzeRegistrationMode::DataFusionListing,
278 )
279 .await;
280 }
281
282 let use_scan = should_use_scan_path(fm, format);
283
284 let (inner, mode) = if use_scan {
285 if should_spill_to_parquet(format, config) {
286 match scan_spill_to_listing(
287 ctx,
288 project_dir,
289 fm,
290 format,
291 config,
292 &schema_name,
293 &table_name,
294 )
295 .await
296 {
297 Ok(provider) => (provider, BronzeRegistrationMode::ScanSpillParquet),
298 Err(e) if on_missing == OnMissing::Empty && is_empty_or_missing_err(&e) => {
299 (
300 empty_memtable(fm, scope)?,
301 BronzeRegistrationMode::ScanMemTable,
302 )
303 }
304 Err(e) => {
305 return Err(e).with_context(|| {
306 format!(
307 "model '{}': bronze spill→parquet failed (format={})",
308 node.name, format
309 )
310 })
311 }
312 }
313 } else {
314 match scan_to_memtable(project_dir, fm, format, config).await {
315 Ok(provider) => (provider, BronzeRegistrationMode::ScanMemTable),
316 Err(e) if on_missing == OnMissing::Empty && is_empty_or_missing_err(&e) => {
317 (
318 empty_memtable(fm, scope)?,
319 BronzeRegistrationMode::ScanMemTable,
320 )
321 }
322 Err(e) => {
323 return Err(e).with_context(|| format!("model '{}': bronze scan failed", node.name))
324 }
325 }
326 }
327 } else {
328 let provider = listing_table_provider(ctx, &path_str, format)
329 .await
330 .with_context(|| {
331 format!(
332 "model '{}': DataFusion listing registration failed for {}",
333 node.name, path_str
334 )
335 })?;
336 (provider, BronzeRegistrationMode::DataFusionListing)
337 };
338
339 finish_register(
340 ctx,
341 registered,
342 key,
343 schema_name,
344 table_name,
345 node,
346 format,
347 resolved,
348 inner,
349 mode,
350 )
351 .await
352}
353
354async fn finish_register(
355 ctx: &SessionContext,
356 registered: &mut HashSet<(String, String)>,
357 key: (String, String),
358 schema_name: String,
359 table_name: String,
360 node: &ModelNode,
361 format: SourceFormat,
362 resolved: PathBuf,
363 inner: Arc<dyn TableProvider>,
364 mode: BronzeRegistrationMode,
365) -> Result<Option<usize>> {
366
367 let meta = BronzeSourceMeta {
368 model_name: node.name.clone(),
369 source_schema: schema_name.clone(),
370 source_table: table_name.clone(),
371 format,
372 scan_path: resolved,
373 registration_mode: mode,
374 };
375
376 let bronze = Arc::new(BronzeTableProvider::wrap(inner, meta));
377 let table_ref = TableReference::partial(schema_name.clone(), table_name.clone());
378
379 let _ = ctx.deregister_table(table_ref.clone());
381 ctx.register_table(table_ref, bronze)
382 .map_err(|e| anyhow::anyhow!("register {}.{}: {}", schema_name, table_name, e))?;
383
384 registered.insert(key);
385 tracing::info!(
386 "Registered bronze source {}.{} from model '{}' ({:?}, format={})",
387 schema_name,
388 table_name,
389 node.name,
390 mode,
391 format
392 );
393 Ok(Some(1))
394}
395
396fn is_empty_or_missing_err(e: &anyhow::Error) -> bool {
397 let s = format!("{e:#}");
398 s.contains("E_RBT_BRONZE_SCAN_EMPTY")
399 || s.contains("E_RBT_BRONZE_SCAN_PATH_NOT_FOUND")
400 || s.contains("bronze scan produced zero batches")
401}
402
403fn empty_memtable(fm: &StagingFrontmatter, scope: &RunScope) -> Result<Arc<dyn TableProvider>> {
405 let schema = fm.empty_frame_schema()?;
406 let batch = RecordBatch::new_empty(schema.clone());
409 let _ = scope; let mem = MemTable::try_new(schema, vec![vec![batch]])
411 .map_err(|e| anyhow::anyhow!("MemTable empty: {e}"))?;
412 Ok(Arc::new(mem))
413}
414
415fn should_use_scan_path(fm: &StagingFrontmatter, format: SourceFormat) -> bool {
416 if fm.force_scan.unwrap_or(false) {
417 return true;
418 }
419 if fm
422 .partition_by
423 .as_ref()
424 .map(|p| !p.is_empty())
425 .unwrap_or(false)
426 || fm
427 .require_partitions
428 .as_ref()
429 .map(|p| !p.is_empty())
430 .unwrap_or(false)
431 || fm
432 .path_glob
433 .as_ref()
434 .map(|p| !p.is_empty())
435 .unwrap_or(false)
436 || fm.inject_source_path.unwrap_or(false)
437 {
438 return true;
439 }
440 if matches!(format, SourceFormat::Jsonl | SourceFormat::Json)
442 && fm.paths.as_ref().map(|p| !p.is_empty()).unwrap_or(false)
443 {
444 return true;
445 }
446 matches!(
448 format,
449 SourceFormat::Log
450 | SourceFormat::Txt
451 | SourceFormat::Toml
452 | SourceFormat::ArrowIpc
453 | SourceFormat::ArrowIpcStream
454 | SourceFormat::Protobuf
455 )
456}
457
458async fn ensure_schema(ctx: &SessionContext, schema_name: &str) -> Result<()> {
459 let sql = format!(
461 "CREATE SCHEMA IF NOT EXISTS \"{}\"",
462 schema_name.replace('"', "")
463 );
464 ctx.sql(&sql)
465 .await
466 .with_context(|| format!("CREATE SCHEMA {}", schema_name))?
467 .collect()
468 .await
469 .with_context(|| format!("CREATE SCHEMA {} collect", schema_name))?;
470 Ok(())
471}
472
473async fn register_parts_parquet(
475 ctx: &SessionContext,
476 parts_dir: &Path,
477) -> Result<Arc<dyn TableProvider>> {
478 let files = crate::scan::parts::list_part_files(parts_dir)?;
479 tracing::info!(
480 "Parts source: {} file(s) under {} (manifest_rows={:?})",
481 files.len(),
482 parts_dir.display(),
483 crate::scan::parts::manifest_total_rows(parts_dir)
484 );
485 let path_str = parts_dir.to_string_lossy().to_string();
488 listing_table_provider(ctx, &path_str, SourceFormat::Parquet)
489 .await
490 .with_context(|| {
491 format!(
492 "E_RBT_PARTS: register_parquet on parts dir {} ({} files)",
493 parts_dir.display(),
494 files.len()
495 )
496 })
497}
498
499async fn listing_table_provider(
501 ctx: &SessionContext,
502 path: &str,
503 format: SourceFormat,
504) -> Result<Arc<dyn TableProvider>> {
505 let tmp = format!(
507 "__rbt_bronze_tmp_{}",
508 std::time::SystemTime::now()
509 .duration_since(std::time::UNIX_EPOCH)
510 .map(|d| d.as_nanos())
511 .unwrap_or(0)
512 );
513
514 match format {
515 SourceFormat::Parquet => {
516 ctx.register_parquet(&tmp, path, ParquetReadOptions::default())
517 .await?;
518 }
519 SourceFormat::Csv => {
520 ctx.register_csv(&tmp, path, CsvReadOptions::default())
521 .await?;
522 }
523 SourceFormat::Jsonl => {
524 let opts = JsonReadOptions::default()
525 .file_extension(".jsonl")
526 .newline_delimited(true);
527 if let Err(e) = ctx.register_json(&tmp, path, opts).await {
529 tracing::debug!("jsonl register with .jsonl failed ({e}); retrying default");
531 ctx.register_json(&tmp, path, JsonReadOptions::default())
532 .await?;
533 }
534 }
535 SourceFormat::Json => {
536 let opts = JsonReadOptions::default().newline_delimited(false);
537 ctx.register_json(&tmp, path, opts).await?;
538 }
539 SourceFormat::ArrowIpc => {
540 ctx.register_arrow(&tmp, path, ArrowReadOptions::default())
541 .await?;
542 }
543 other => bail!("listing_table_provider does not support format {}", other),
544 }
545
546 let provider = ctx
547 .table_provider(TableReference::bare(tmp.as_str()))
548 .await
549 .with_context(|| format!("lookup temp bronze table {}", tmp))?;
550 let _ = ctx.deregister_table(TableReference::bare(tmp.as_str()))?;
551 Ok(provider)
552}
553
554fn should_spill_to_parquet(
555 format: SourceFormat,
556 config: &crate::core::project::RbtProjectConfig,
557) -> bool {
558 config.scan.spill_arrow_ipc
559 && matches!(
560 format,
561 SourceFormat::ArrowIpc | SourceFormat::ArrowIpcStream
562 )
563}
564
565async fn scan_to_memtable(
566 project_dir: &Path,
567 fm: &StagingFrontmatter,
568 format: SourceFormat,
569 config: &crate::core::project::RbtProjectConfig,
570) -> Result<Arc<dyn TableProvider>> {
571 let mut req = ScanRequest::from_frontmatter_with_config(
572 project_dir,
573 fm,
574 config.roots.clone(),
575 &config.scan,
576 )?;
577 req.format = format;
578 let scanner = LakeScanner::from_request(&req);
579 let batches = scanner.scan(&req).await?;
580 if batches.is_empty() {
581 if req.allow_empty {
582 let schema = fm.empty_frame_schema().unwrap_or_else(|_| {
583 Arc::new(arrow::datatypes::Schema::new(vec![arrow::datatypes::Field::new(
584 "_empty",
585 arrow::datatypes::DataType::Utf8,
586 true,
587 )]))
588 });
589 let batch = RecordBatch::new_empty(schema.clone());
590 let mem = MemTable::try_new(schema, vec![vec![batch]])
591 .map_err(|e| anyhow::anyhow!("MemTable::try_new empty: {e}"))?;
592 return Ok(Arc::new(mem));
593 }
594 bail!(
595 "E_RBT_BRONZE_SCAN_EMPTY: bronze scan produced zero batches for {}",
596 req.resolved_path()?.display()
597 );
598 }
599 let schema = batches[0].schema();
600 let mem = MemTable::try_new(schema, vec![batches])
602 .map_err(|e| anyhow::anyhow!("MemTable::try_new: {}", e))?;
603 Ok(Arc::new(mem))
604}
605
606async fn scan_spill_to_listing(
608 ctx: &SessionContext,
609 project_dir: &Path,
610 fm: &StagingFrontmatter,
611 format: SourceFormat,
612 config: &crate::core::project::RbtProjectConfig,
613 schema_name: &str,
614 table_name: &str,
615) -> Result<Arc<dyn TableProvider>> {
616 let mut req = ScanRequest::from_frontmatter_with_config(
617 project_dir,
618 fm,
619 config.roots.clone(),
620 &config.scan,
621 )?;
622 req.format = format;
623 let scanner = LakeScanner::from_request(&req);
624
625 let spill_root = crate::core::paths::resolve_project_path(
626 project_dir,
627 &config.scan.spill_dir,
628 &config.roots,
629 )
630 .with_context(|| {
631 format!(
632 "E_RBT_BRONZE_SPILL: resolve spill_dir '{}'",
633 config.scan.spill_dir
634 )
635 })?;
636 std::fs::create_dir_all(&spill_root).with_context(|| {
637 format!(
638 "E_RBT_BRONZE_SPILL: mkdir {}",
639 spill_root.display()
640 )
641 })?;
642 let safe = format!(
643 "{}__{}.parquet",
644 schema_name.replace('/', "_"),
645 table_name.replace('/', "_")
646 );
647 let spill_path = spill_root.join(safe);
648
649 let opts = crate::materializer::MaterializeWriteOptions::from_config(&config.materialize, true);
650 let stats = scanner
651 .scan_spill_to_parquet(&req, &spill_path, &opts)
652 .with_context(|| {
653 format!(
654 "E_RBT_BRONZE_SPILL: spill to {}",
655 spill_path.display()
656 )
657 })?;
658 tracing::info!(
659 "Bronze {}.{} spilled {} rows ({} batches) → {}",
660 schema_name,
661 table_name,
662 stats.rows,
663 stats.batches,
664 spill_path.display()
665 );
666
667 listing_table_provider(
668 ctx,
669 spill_path.to_str().unwrap_or_default(),
670 SourceFormat::Parquet,
671 )
672 .await
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use crate::core::dag::{Materialization, ModelDag, OutputFormat};
679
680 #[tokio::test]
681 async fn register_arrow_ipc_spills_to_parquet() -> Result<()> {
682 use arrow::array::Int64Array;
683 use arrow::datatypes::{DataType, Field, Schema};
684 use arrow::ipc::writer::FileWriter;
685 use arrow::record_batch::RecordBatch;
686 use std::sync::Arc;
687
688 let temp = tempfile::tempdir()?;
689 let bronze = temp.path().join("lake/bronze/symbol=X/timeframe=1m");
690 std::fs::create_dir_all(&bronze)?;
691 let schema = Arc::new(Schema::new(vec![
692 Field::new("symbol", DataType::Utf8, false),
693 Field::new("v", DataType::Int64, false),
694 ]));
695 let batch = RecordBatch::try_new(
696 schema.clone(),
697 vec![
698 Arc::new(arrow::array::StringArray::from(vec!["X", "X"])),
699 Arc::new(Int64Array::from(vec![1, 2])),
700 ],
701 )?;
702 let f = std::fs::File::create(bronze.join("chunk.arrow"))?;
703 let mut w = FileWriter::try_new(f, &schema)?;
704 w.write(&batch)?;
705 w.finish()?;
706
707 let sql = r#"---
708source_format: arrow_ipc
709scan_path: "lake/bronze"
710path_glob: "**/*.arrow"
711partition_by: [symbol, timeframe]
712require_partitions:
713 timeframe: "1m"
714inject_source_path: true
715---
716SELECT symbol, timeframe, v FROM {{ source('bronze', 'ohlcv') }}
717"#;
718 let mut dag = ModelDag::new();
719 dag.add_model_with_format(
720 "stg_ohlcv",
721 sql,
722 Materialization::Table,
723 OutputFormat::Parquet,
724 None,
725 "",
726 )?;
727 dag.build_graph()?;
728
729 let ctx = SessionContext::new();
730 let mut registered = HashSet::new();
731 let cfg = crate::core::project::RbtProjectConfig::default();
732 assert!(cfg.scan.spill_arrow_ipc);
733 let n = register_bronze_sources_for_dag(&ctx, &dag, temp.path(), &mut registered, &cfg)
734 .await?;
735 assert_eq!(n, 1);
736
737 let spill = temp
738 .path()
739 .join(".rbt/bronze_spill/bronze__ohlcv.parquet");
740 assert!(
741 spill.exists(),
742 "expected spill parquet at {}",
743 spill.display()
744 );
745
746 let df = ctx
747 .sql("SELECT COUNT(*) AS c FROM bronze.ohlcv")
748 .await?;
749 let batches = df.collect().await?;
750 let c = batches[0]
752 .column(0)
753 .as_any()
754 .downcast_ref::<Int64Array>()
755 .unwrap()
756 .value(0);
757 assert_eq!(c, 2);
758 Ok(())
759 }
760
761 #[tokio::test]
762 async fn register_jsonl_from_frontmatter() -> Result<()> {
763 let temp = tempfile::tempdir()?;
764 let bronze = temp.path().join("raw.jsonl");
765 std::fs::write(
766 &bronze,
767 r#"{"ticker":"NVDA","price":1.5}
768{"ticker":"AAPL","price":2.5}
769"#,
770 )?;
771
772 let sql = format!(
773 r#"---
774source_format: jsonl
775scan_path: "{}"
776---
777SELECT ticker, price FROM {{{{ source('bronze', 'raw_trades') }}}}
778"#,
779 bronze.file_name().unwrap().to_string_lossy()
780 );
781
782 let mut dag = ModelDag::new();
783 dag.add_model_with_format(
784 "stg_trades",
785 &sql,
786 Materialization::Table,
787 OutputFormat::Parquet,
788 None,
789 "",
790 )?;
791 dag.build_graph()?;
792
793 let engine_ctx = SessionContext::new();
794 let mut registered = HashSet::new();
795 let cfg = crate::core::project::RbtProjectConfig::default();
796 let n =
797 register_bronze_sources_for_dag(&engine_ctx, &dag, temp.path(), &mut registered, &cfg)
798 .await?;
799 assert_eq!(n, 1);
800
801 let df = engine_ctx
802 .sql("SELECT COUNT(*) AS c FROM bronze.raw_trades")
803 .await?;
804 let batches = df.collect().await?;
805 assert_eq!(batches[0].num_rows(), 1);
806 Ok(())
807 }
808}