Skip to main content

rbt/engine/
bronze.rs

1//! Frontmatter-driven bronze source registration.
2//!
3//! ## Architecture
4//!
5//! * **Path A (DataFusion listing / external tables)** — Parquet, CSV, JSON/JSONL (no
6//!   jshift projection), Arrow IPC file: register via DataFusion native readers, then
7//!   wrap the resulting provider in [`BronzeTableProvider`]. Listing predicate
8//!   pushdown is available on this path.
9//! * **Path B (scan → MemTable)** — used when rbt must apply its own filters or
10//!   inject path-derived columns. **Any non-empty `path_glob` forces Path B** (as do
11//!   `partition_by` / `require_partitions` / `inject_source_path` / `force_scan` and
12//!   formats that require scan: log, txt, toml, Arrow IPC stream, protobuf).
13//!   DataFusion directory listing pushdown is **disabled** for that source by design.
14//!
15//! [`BronzeTableProvider`] is intentionally thin: it delegates scan/schema to the
16//! inner provider and carries bronze metadata for lineage / debugging.
17
18use 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/// Metadata retained on the bronze provider for debugging and future lineage.
40#[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    /// Inner provider is a DataFusion listing / external table.
53    DataFusionListing,
54    /// Inner provider is a MemTable filled by `rbt::scan` (small / non-spill formats).
55    ScanMemTable,
56    /// Arrow IPC (etc.) spilled file-by-file to Parquet then listed — bounded peak RAM.
57    ScanSpillParquet,
58}
59
60/// Thin `TableProvider` wrapper around a DataFusion listing table or MemTable.
61#[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
109/// Registers all bronze sources declared by model frontmatter into `ctx`.
110///
111/// Idempotent per `(schema, table)` within a single run (tracked by `registered`).
112/// Uses `config.roots` and `config.scan` (no re-read of yml per model).
113pub 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
132/// Register bronze for a single model if it has a scan contract.
133pub 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    // Replace if present (re-runs / tests)
236    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    // Hive partition injection / filters / source path / path_glob require the scan path
257    // (DataFusion listing does not inject path-derived columns or apply rbt globs).
258    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    // jshift selective extract
278    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    // Nested hive dirs, stream IPC, and opaque protobuf need the scan path.
284    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    // DataFusion accepts CREATE SCHEMA via SQL
297    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
310/// Path A: materialize a DF listing provider, then return it for wrapping.
311async fn listing_table_provider(
312    ctx: &SessionContext,
313    path: &str,
314    format: SourceFormat,
315) -> Result<Arc<dyn TableProvider>> {
316    // Register under a private temp name, extract provider, deregister.
317    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            // DF register_type_check requires path to end with extension; if directory, ok
339            if let Err(e) = ctx.register_json(&tmp, path, opts).await {
340                // Fallback: .json extension / generic
341                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    // MemTable expects Vec<Vec<RecordBatch>> partitions
399    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
404/// Stream Arrow IPC (etc.) file-by-file into a project spill Parquet, then DF-list it.
405async 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        // 2 data rows
549        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}