1use crate::core::dag::{ModelDag, ModelNode};
19use crate::core::frontmatter::{SourceFormat, StagingFrontmatter};
20use crate::scan::{LakeScanner, ScanRequest};
21use anyhow::{bail, Context, Result};
22use async_trait::async_trait;
23use datafusion::arrow::datatypes::SchemaRef;
24use datafusion::catalog::Session;
25use datafusion::catalog::TableProvider;
26use datafusion::common::TableReference;
27use datafusion::datasource::MemTable;
28use datafusion::error::Result as DFResult;
29use datafusion::execution::context::SessionContext;
30use datafusion::execution::options::ArrowReadOptions;
31use datafusion::logical_expr::{Expr, TableType};
32use datafusion::physical_plan::ExecutionPlan;
33use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
34use std::any::Any;
35use std::collections::HashSet;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38
39#[derive(Debug, Clone)]
41pub struct BronzeSourceMeta {
42 pub model_name: String,
43 pub source_schema: String,
44 pub source_table: String,
45 pub format: SourceFormat,
46 pub scan_path: PathBuf,
47 pub registration_mode: BronzeRegistrationMode,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum BronzeRegistrationMode {
52 DataFusionListing,
54 ScanMemTable,
56 ScanSpillParquet,
58}
59
60#[derive(Debug)]
62pub struct BronzeTableProvider {
63 pub meta: BronzeSourceMeta,
64 inner: Arc<dyn TableProvider>,
65}
66
67impl BronzeTableProvider {
68 pub fn wrap(inner: Arc<dyn TableProvider>, meta: BronzeSourceMeta) -> Self {
69 Self { meta, inner }
70 }
71
72 pub fn inner(&self) -> &Arc<dyn TableProvider> {
73 &self.inner
74 }
75}
76
77#[async_trait]
78impl TableProvider for BronzeTableProvider {
79 fn as_any(&self) -> &dyn Any {
80 self
81 }
82
83 fn schema(&self) -> SchemaRef {
84 self.inner.schema()
85 }
86
87 fn table_type(&self) -> TableType {
88 self.inner.table_type()
89 }
90
91 async fn scan(
92 &self,
93 state: &dyn Session,
94 projection: Option<&Vec<usize>>,
95 filters: &[Expr],
96 limit: Option<usize>,
97 ) -> DFResult<Arc<dyn ExecutionPlan>> {
98 self.inner.scan(state, projection, filters, limit).await
99 }
100
101 fn supports_filters_pushdown(
102 &self,
103 filters: &[&Expr],
104 ) -> DFResult<Vec<datafusion::logical_expr::TableProviderFilterPushDown>> {
105 self.inner.supports_filters_pushdown(filters)
106 }
107}
108
109pub async fn register_bronze_sources_for_dag(
114 ctx: &SessionContext,
115 dag: &ModelDag,
116 project_dir: &Path,
117 registered: &mut HashSet<(String, String)>,
118 config: &crate::core::project::RbtProjectConfig,
119) -> Result<usize> {
120 let mut count = 0;
121 for idx in dag.graph.node_indices() {
122 let node = &dag.graph[idx];
123 if let Some(n) =
124 register_bronze_for_model(ctx, node, project_dir, registered, config).await?
125 {
126 count += n;
127 }
128 }
129 Ok(count)
130}
131
132pub async fn register_bronze_for_model(
134 ctx: &SessionContext,
135 node: &ModelNode,
136 project_dir: &Path,
137 registered: &mut HashSet<(String, String)>,
138 config: &crate::core::project::RbtProjectConfig,
139) -> Result<Option<usize>> {
140 let Some(fm) = node.frontmatter.as_ref() else {
141 return Ok(None);
142 };
143 if !fm.has_scan_contract() {
144 return Ok(None);
145 }
146
147 let (schema_name, table_name) = ModelDag::bronze_source_ident(node).with_context(|| {
148 format!(
149 "model '{}': frontmatter has scan_path but no source identity \
150 (add source() in SQL or source_name/source_table in frontmatter)",
151 node.name
152 )
153 })?;
154
155 let key = (schema_name.clone(), table_name.clone());
156 if registered.contains(&key) {
157 tracing::debug!(
158 "Bronze source {}.{} already registered; skipping model '{}'",
159 schema_name,
160 table_name,
161 node.name
162 );
163 return Ok(None);
164 }
165
166 ensure_schema(ctx, &schema_name).await?;
167
168 let format = fm
169 .resolve_format()
170 .with_context(|| format!("model '{}': cannot resolve source_format", node.name))?;
171
172 let raw_scan = fm.scan_path.as_deref().unwrap();
173 let resolved = crate::core::paths::resolve_project_path(project_dir, raw_scan, &config.roots)
174 .with_context(|| {
175 format!(
176 "E_RBT_BRONZE_PATH: model '{}': cannot resolve scan_path '{}'. \
177 Check absolute paths and `roots:` templates in rbt_project.yml.",
178 node.name, raw_scan
179 )
180 })?;
181 if !resolved.exists() && !crate::core::frontmatter::is_remote_uri(raw_scan) {
182 bail!(
183 "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND: model '{}': bronze scan_path does not exist: {} \
184 (resolved {}). Hint: verify the lake path and `$root` expansion.",
185 node.name,
186 raw_scan,
187 resolved.display()
188 );
189 }
190
191 let path_str = resolved.to_string_lossy().to_string();
192 let use_scan = should_use_scan_path(fm, format);
193
194 let (inner, mode) = if use_scan {
195 if should_spill_to_parquet(format, config) {
196 let provider = scan_spill_to_listing(ctx, project_dir, fm, format, config, &schema_name, &table_name)
197 .await
198 .with_context(|| {
199 format!(
200 "model '{}': bronze spill→parquet failed (format={})",
201 node.name, format
202 )
203 })?;
204 (provider, BronzeRegistrationMode::ScanSpillParquet)
205 } else {
206 let provider = scan_to_memtable(project_dir, fm, format, config)
207 .await
208 .with_context(|| format!("model '{}': bronze scan failed", node.name))?;
209 (provider, BronzeRegistrationMode::ScanMemTable)
210 }
211 } else {
212 let provider = listing_table_provider(ctx, &path_str, format)
213 .await
214 .with_context(|| {
215 format!(
216 "model '{}': DataFusion listing registration failed for {}",
217 node.name, path_str
218 )
219 })?;
220 (provider, BronzeRegistrationMode::DataFusionListing)
221 };
222
223 let meta = BronzeSourceMeta {
224 model_name: node.name.clone(),
225 source_schema: schema_name.clone(),
226 source_table: table_name.clone(),
227 format,
228 scan_path: resolved,
229 registration_mode: mode,
230 };
231
232 let bronze = Arc::new(BronzeTableProvider::wrap(inner, meta));
233 let table_ref = TableReference::partial(schema_name.clone(), table_name.clone());
234
235 let _ = ctx.deregister_table(table_ref.clone());
237 ctx.register_table(table_ref, bronze)
238 .map_err(|e| anyhow::anyhow!("register {}.{}: {}", schema_name, table_name, e))?;
239
240 registered.insert(key);
241 tracing::info!(
242 "Registered bronze source {}.{} from model '{}' ({:?}, format={})",
243 schema_name,
244 table_name,
245 node.name,
246 mode,
247 format
248 );
249 Ok(Some(1))
250}
251
252fn should_use_scan_path(fm: &StagingFrontmatter, format: SourceFormat) -> bool {
253 if fm.force_scan.unwrap_or(false) {
254 return true;
255 }
256 if fm
259 .partition_by
260 .as_ref()
261 .map(|p| !p.is_empty())
262 .unwrap_or(false)
263 || fm
264 .require_partitions
265 .as_ref()
266 .map(|p| !p.is_empty())
267 .unwrap_or(false)
268 || fm
269 .path_glob
270 .as_ref()
271 .map(|p| !p.is_empty())
272 .unwrap_or(false)
273 || fm.inject_source_path.unwrap_or(false)
274 {
275 return true;
276 }
277 if matches!(format, SourceFormat::Jsonl | SourceFormat::Json)
279 && fm.paths.as_ref().map(|p| !p.is_empty()).unwrap_or(false)
280 {
281 return true;
282 }
283 matches!(
285 format,
286 SourceFormat::Log
287 | SourceFormat::Txt
288 | SourceFormat::Toml
289 | SourceFormat::ArrowIpc
290 | SourceFormat::ArrowIpcStream
291 | SourceFormat::Protobuf
292 )
293}
294
295async fn ensure_schema(ctx: &SessionContext, schema_name: &str) -> Result<()> {
296 let sql = format!(
298 "CREATE SCHEMA IF NOT EXISTS \"{}\"",
299 schema_name.replace('"', "")
300 );
301 ctx.sql(&sql)
302 .await
303 .with_context(|| format!("CREATE SCHEMA {}", schema_name))?
304 .collect()
305 .await
306 .with_context(|| format!("CREATE SCHEMA {} collect", schema_name))?;
307 Ok(())
308}
309
310async fn listing_table_provider(
312 ctx: &SessionContext,
313 path: &str,
314 format: SourceFormat,
315) -> Result<Arc<dyn TableProvider>> {
316 let tmp = format!(
318 "__rbt_bronze_tmp_{}",
319 std::time::SystemTime::now()
320 .duration_since(std::time::UNIX_EPOCH)
321 .map(|d| d.as_nanos())
322 .unwrap_or(0)
323 );
324
325 match format {
326 SourceFormat::Parquet => {
327 ctx.register_parquet(&tmp, path, ParquetReadOptions::default())
328 .await?;
329 }
330 SourceFormat::Csv => {
331 ctx.register_csv(&tmp, path, CsvReadOptions::default())
332 .await?;
333 }
334 SourceFormat::Jsonl => {
335 let opts = JsonReadOptions::default()
336 .file_extension(".jsonl")
337 .newline_delimited(true);
338 if let Err(e) = ctx.register_json(&tmp, path, opts).await {
340 tracing::debug!("jsonl register with .jsonl failed ({e}); retrying default");
342 ctx.register_json(&tmp, path, JsonReadOptions::default())
343 .await?;
344 }
345 }
346 SourceFormat::Json => {
347 let opts = JsonReadOptions::default().newline_delimited(false);
348 ctx.register_json(&tmp, path, opts).await?;
349 }
350 SourceFormat::ArrowIpc => {
351 ctx.register_arrow(&tmp, path, ArrowReadOptions::default())
352 .await?;
353 }
354 other => bail!("listing_table_provider does not support format {}", other),
355 }
356
357 let provider = ctx
358 .table_provider(TableReference::bare(tmp.as_str()))
359 .await
360 .with_context(|| format!("lookup temp bronze table {}", tmp))?;
361 let _ = ctx.deregister_table(TableReference::bare(tmp.as_str()))?;
362 Ok(provider)
363}
364
365fn should_spill_to_parquet(
366 format: SourceFormat,
367 config: &crate::core::project::RbtProjectConfig,
368) -> bool {
369 config.scan.spill_arrow_ipc
370 && matches!(
371 format,
372 SourceFormat::ArrowIpc | SourceFormat::ArrowIpcStream
373 )
374}
375
376async fn scan_to_memtable(
377 project_dir: &Path,
378 fm: &StagingFrontmatter,
379 format: SourceFormat,
380 config: &crate::core::project::RbtProjectConfig,
381) -> Result<Arc<dyn TableProvider>> {
382 let mut req = ScanRequest::from_frontmatter_with_config(
383 project_dir,
384 fm,
385 config.roots.clone(),
386 &config.scan,
387 )?;
388 req.format = format;
389 let scanner = LakeScanner::from_request(&req);
390 let batches = scanner.scan(&req).await?;
391 if batches.is_empty() {
392 bail!(
393 "E_RBT_BRONZE_SCAN_EMPTY: bronze scan produced zero batches for {}",
394 req.resolved_path()?.display()
395 );
396 }
397 let schema = batches[0].schema();
398 let mem = MemTable::try_new(schema, vec![batches])
400 .map_err(|e| anyhow::anyhow!("MemTable::try_new: {}", e))?;
401 Ok(Arc::new(mem))
402}
403
404async fn scan_spill_to_listing(
406 ctx: &SessionContext,
407 project_dir: &Path,
408 fm: &StagingFrontmatter,
409 format: SourceFormat,
410 config: &crate::core::project::RbtProjectConfig,
411 schema_name: &str,
412 table_name: &str,
413) -> Result<Arc<dyn TableProvider>> {
414 let mut req = ScanRequest::from_frontmatter_with_config(
415 project_dir,
416 fm,
417 config.roots.clone(),
418 &config.scan,
419 )?;
420 req.format = format;
421 let scanner = LakeScanner::from_request(&req);
422
423 let spill_root = crate::core::paths::resolve_project_path(
424 project_dir,
425 &config.scan.spill_dir,
426 &config.roots,
427 )
428 .with_context(|| {
429 format!(
430 "E_RBT_BRONZE_SPILL: resolve spill_dir '{}'",
431 config.scan.spill_dir
432 )
433 })?;
434 std::fs::create_dir_all(&spill_root).with_context(|| {
435 format!(
436 "E_RBT_BRONZE_SPILL: mkdir {}",
437 spill_root.display()
438 )
439 })?;
440 let safe = format!(
441 "{}__{}.parquet",
442 schema_name.replace('/', "_"),
443 table_name.replace('/', "_")
444 );
445 let spill_path = spill_root.join(safe);
446
447 let opts = crate::materializer::MaterializeWriteOptions::from_config(&config.materialize, true);
448 let stats = scanner
449 .scan_spill_to_parquet(&req, &spill_path, &opts)
450 .with_context(|| {
451 format!(
452 "E_RBT_BRONZE_SPILL: spill to {}",
453 spill_path.display()
454 )
455 })?;
456 tracing::info!(
457 "Bronze {}.{} spilled {} rows ({} batches) → {}",
458 schema_name,
459 table_name,
460 stats.rows,
461 stats.batches,
462 spill_path.display()
463 );
464
465 listing_table_provider(
466 ctx,
467 spill_path.to_str().unwrap_or_default(),
468 SourceFormat::Parquet,
469 )
470 .await
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use crate::core::dag::{Materialization, ModelDag, OutputFormat};
477
478 #[tokio::test]
479 async fn register_arrow_ipc_spills_to_parquet() -> Result<()> {
480 use arrow::array::Int64Array;
481 use arrow::datatypes::{DataType, Field, Schema};
482 use arrow::ipc::writer::FileWriter;
483 use arrow::record_batch::RecordBatch;
484 use std::sync::Arc;
485
486 let temp = tempfile::tempdir()?;
487 let bronze = temp.path().join("lake/bronze/symbol=X/timeframe=1m");
488 std::fs::create_dir_all(&bronze)?;
489 let schema = Arc::new(Schema::new(vec![
490 Field::new("symbol", DataType::Utf8, false),
491 Field::new("v", DataType::Int64, false),
492 ]));
493 let batch = RecordBatch::try_new(
494 schema.clone(),
495 vec![
496 Arc::new(arrow::array::StringArray::from(vec!["X", "X"])),
497 Arc::new(Int64Array::from(vec![1, 2])),
498 ],
499 )?;
500 let f = std::fs::File::create(bronze.join("chunk.arrow"))?;
501 let mut w = FileWriter::try_new(f, &schema)?;
502 w.write(&batch)?;
503 w.finish()?;
504
505 let sql = r#"---
506source_format: arrow_ipc
507scan_path: "lake/bronze"
508path_glob: "**/*.arrow"
509partition_by: [symbol, timeframe]
510require_partitions:
511 timeframe: "1m"
512inject_source_path: true
513---
514SELECT symbol, timeframe, v FROM {{ source('bronze', 'ohlcv') }}
515"#;
516 let mut dag = ModelDag::new();
517 dag.add_model_with_format(
518 "stg_ohlcv",
519 sql,
520 Materialization::Table,
521 OutputFormat::Parquet,
522 None,
523 "",
524 )?;
525 dag.build_graph()?;
526
527 let ctx = SessionContext::new();
528 let mut registered = HashSet::new();
529 let cfg = crate::core::project::RbtProjectConfig::default();
530 assert!(cfg.scan.spill_arrow_ipc);
531 let n = register_bronze_sources_for_dag(&ctx, &dag, temp.path(), &mut registered, &cfg)
532 .await?;
533 assert_eq!(n, 1);
534
535 let spill = temp
536 .path()
537 .join(".rbt/bronze_spill/bronze__ohlcv.parquet");
538 assert!(
539 spill.exists(),
540 "expected spill parquet at {}",
541 spill.display()
542 );
543
544 let df = ctx
545 .sql("SELECT COUNT(*) AS c FROM bronze.ohlcv")
546 .await?;
547 let batches = df.collect().await?;
548 let c = batches[0]
550 .column(0)
551 .as_any()
552 .downcast_ref::<Int64Array>()
553 .unwrap()
554 .value(0);
555 assert_eq!(c, 2);
556 Ok(())
557 }
558
559 #[tokio::test]
560 async fn register_jsonl_from_frontmatter() -> Result<()> {
561 let temp = tempfile::tempdir()?;
562 let bronze = temp.path().join("raw.jsonl");
563 std::fs::write(
564 &bronze,
565 r#"{"ticker":"NVDA","price":1.5}
566{"ticker":"AAPL","price":2.5}
567"#,
568 )?;
569
570 let sql = format!(
571 r#"---
572source_format: jsonl
573scan_path: "{}"
574---
575SELECT ticker, price FROM {{{{ source('bronze', 'raw_trades') }}}}
576"#,
577 bronze.file_name().unwrap().to_string_lossy()
578 );
579
580 let mut dag = ModelDag::new();
581 dag.add_model_with_format(
582 "stg_trades",
583 &sql,
584 Materialization::Table,
585 OutputFormat::Parquet,
586 None,
587 "",
588 )?;
589 dag.build_graph()?;
590
591 let engine_ctx = SessionContext::new();
592 let mut registered = HashSet::new();
593 let cfg = crate::core::project::RbtProjectConfig::default();
594 let n =
595 register_bronze_sources_for_dag(&engine_ctx, &dag, temp.path(), &mut registered, &cfg)
596 .await?;
597 assert_eq!(n, 1);
598
599 let df = engine_ctx
600 .sql("SELECT COUNT(*) AS c FROM bronze.raw_trades")
601 .await?;
602 let batches = df.collect().await?;
603 assert_eq!(batches[0].num_rows(), 1);
604 Ok(())
605 }
606}