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`.
55    ScanMemTable,
56}
57
58/// Thin `TableProvider` wrapper around a DataFusion listing table or MemTable.
59#[derive(Debug)]
60pub struct BronzeTableProvider {
61    pub meta: BronzeSourceMeta,
62    inner: Arc<dyn TableProvider>,
63}
64
65impl BronzeTableProvider {
66    pub fn wrap(inner: Arc<dyn TableProvider>, meta: BronzeSourceMeta) -> Self {
67        Self { meta, inner }
68    }
69
70    pub fn inner(&self) -> &Arc<dyn TableProvider> {
71        &self.inner
72    }
73}
74
75#[async_trait]
76impl TableProvider for BronzeTableProvider {
77    fn as_any(&self) -> &dyn Any {
78        self
79    }
80
81    fn schema(&self) -> SchemaRef {
82        self.inner.schema()
83    }
84
85    fn table_type(&self) -> TableType {
86        self.inner.table_type()
87    }
88
89    async fn scan(
90        &self,
91        state: &dyn Session,
92        projection: Option<&Vec<usize>>,
93        filters: &[Expr],
94        limit: Option<usize>,
95    ) -> DFResult<Arc<dyn ExecutionPlan>> {
96        self.inner.scan(state, projection, filters, limit).await
97    }
98
99    fn supports_filters_pushdown(
100        &self,
101        filters: &[&Expr],
102    ) -> DFResult<Vec<datafusion::logical_expr::TableProviderFilterPushDown>> {
103        self.inner.supports_filters_pushdown(filters)
104    }
105}
106
107/// Registers all bronze sources declared by model frontmatter into `ctx`.
108///
109/// Idempotent per `(schema, table)` within a single run (tracked by `registered`).
110/// Uses `config.roots` and `config.scan` (no re-read of yml per model).
111pub async fn register_bronze_sources_for_dag(
112    ctx: &SessionContext,
113    dag: &ModelDag,
114    project_dir: &Path,
115    registered: &mut HashSet<(String, String)>,
116    config: &crate::core::project::RbtProjectConfig,
117) -> Result<usize> {
118    let mut count = 0;
119    for idx in dag.graph.node_indices() {
120        let node = &dag.graph[idx];
121        if let Some(n) =
122            register_bronze_for_model(ctx, node, project_dir, registered, config).await?
123        {
124            count += n;
125        }
126    }
127    Ok(count)
128}
129
130/// Register bronze for a single model if it has a scan contract.
131pub async fn register_bronze_for_model(
132    ctx: &SessionContext,
133    node: &ModelNode,
134    project_dir: &Path,
135    registered: &mut HashSet<(String, String)>,
136    config: &crate::core::project::RbtProjectConfig,
137) -> Result<Option<usize>> {
138    let Some(fm) = node.frontmatter.as_ref() else {
139        return Ok(None);
140    };
141    if !fm.has_scan_contract() {
142        return Ok(None);
143    }
144
145    let (schema_name, table_name) = ModelDag::bronze_source_ident(node).with_context(|| {
146        format!(
147            "model '{}': frontmatter has scan_path but no source identity \
148             (add source() in SQL or source_name/source_table in frontmatter)",
149            node.name
150        )
151    })?;
152
153    let key = (schema_name.clone(), table_name.clone());
154    if registered.contains(&key) {
155        tracing::debug!(
156            "Bronze source {}.{} already registered; skipping model '{}'",
157            schema_name,
158            table_name,
159            node.name
160        );
161        return Ok(None);
162    }
163
164    ensure_schema(ctx, &schema_name).await?;
165
166    let format = fm
167        .resolve_format()
168        .with_context(|| format!("model '{}': cannot resolve source_format", node.name))?;
169
170    let raw_scan = fm.scan_path.as_deref().unwrap();
171    let resolved = crate::core::paths::resolve_project_path(project_dir, raw_scan, &config.roots)
172        .with_context(|| {
173        format!(
174            "E_RBT_BRONZE_PATH: model '{}': cannot resolve scan_path '{}'. \
175                     Check absolute paths and `roots:` templates in rbt_project.yml.",
176            node.name, raw_scan
177        )
178    })?;
179    if !resolved.exists() && !crate::core::frontmatter::is_remote_uri(raw_scan) {
180        bail!(
181            "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND: model '{}': bronze scan_path does not exist: {} \
182             (resolved {}). Hint: verify the lake path and `$root` expansion.",
183            node.name,
184            raw_scan,
185            resolved.display()
186        );
187    }
188
189    let path_str = resolved.to_string_lossy().to_string();
190    let use_scan = should_use_scan_path(fm, format);
191
192    let (inner, mode) = if use_scan {
193        let provider = scan_to_memtable(project_dir, fm, format, config)
194            .await
195            .with_context(|| format!("model '{}': bronze scan failed", node.name))?;
196        (provider, BronzeRegistrationMode::ScanMemTable)
197    } else {
198        let provider = listing_table_provider(ctx, &path_str, format)
199            .await
200            .with_context(|| {
201                format!(
202                    "model '{}': DataFusion listing registration failed for {}",
203                    node.name, path_str
204                )
205            })?;
206        (provider, BronzeRegistrationMode::DataFusionListing)
207    };
208
209    let meta = BronzeSourceMeta {
210        model_name: node.name.clone(),
211        source_schema: schema_name.clone(),
212        source_table: table_name.clone(),
213        format,
214        scan_path: resolved,
215        registration_mode: mode,
216    };
217
218    let bronze = Arc::new(BronzeTableProvider::wrap(inner, meta));
219    let table_ref = TableReference::partial(schema_name.clone(), table_name.clone());
220
221    // Replace if present (re-runs / tests)
222    let _ = ctx.deregister_table(table_ref.clone());
223    ctx.register_table(table_ref, bronze)
224        .map_err(|e| anyhow::anyhow!("register {}.{}: {}", schema_name, table_name, e))?;
225
226    registered.insert(key);
227    tracing::info!(
228        "Registered bronze source {}.{} from model '{}' ({:?}, format={})",
229        schema_name,
230        table_name,
231        node.name,
232        mode,
233        format
234    );
235    Ok(Some(1))
236}
237
238fn should_use_scan_path(fm: &StagingFrontmatter, format: SourceFormat) -> bool {
239    if fm.force_scan.unwrap_or(false) {
240        return true;
241    }
242    // Hive partition injection / filters / source path / path_glob require the scan path
243    // (DataFusion listing does not inject path-derived columns or apply rbt globs).
244    if fm
245        .partition_by
246        .as_ref()
247        .map(|p| !p.is_empty())
248        .unwrap_or(false)
249        || fm
250            .require_partitions
251            .as_ref()
252            .map(|p| !p.is_empty())
253            .unwrap_or(false)
254        || fm
255            .path_glob
256            .as_ref()
257            .map(|p| !p.is_empty())
258            .unwrap_or(false)
259        || fm.inject_source_path.unwrap_or(false)
260    {
261        return true;
262    }
263    // jshift selective extract
264    if matches!(format, SourceFormat::Jsonl | SourceFormat::Json)
265        && fm.paths.as_ref().map(|p| !p.is_empty()).unwrap_or(false)
266    {
267        return true;
268    }
269    // Nested hive dirs, stream IPC, and opaque protobuf need the scan path.
270    matches!(
271        format,
272        SourceFormat::Log
273            | SourceFormat::Txt
274            | SourceFormat::Toml
275            | SourceFormat::ArrowIpc
276            | SourceFormat::ArrowIpcStream
277            | SourceFormat::Protobuf
278    )
279}
280
281async fn ensure_schema(ctx: &SessionContext, schema_name: &str) -> Result<()> {
282    // DataFusion accepts CREATE SCHEMA via SQL
283    let sql = format!(
284        "CREATE SCHEMA IF NOT EXISTS \"{}\"",
285        schema_name.replace('"', "")
286    );
287    ctx.sql(&sql)
288        .await
289        .with_context(|| format!("CREATE SCHEMA {}", schema_name))?
290        .collect()
291        .await
292        .with_context(|| format!("CREATE SCHEMA {} collect", schema_name))?;
293    Ok(())
294}
295
296/// Path A: materialize a DF listing provider, then return it for wrapping.
297async fn listing_table_provider(
298    ctx: &SessionContext,
299    path: &str,
300    format: SourceFormat,
301) -> Result<Arc<dyn TableProvider>> {
302    // Register under a private temp name, extract provider, deregister.
303    let tmp = format!(
304        "__rbt_bronze_tmp_{}",
305        std::time::SystemTime::now()
306            .duration_since(std::time::UNIX_EPOCH)
307            .map(|d| d.as_nanos())
308            .unwrap_or(0)
309    );
310
311    match format {
312        SourceFormat::Parquet => {
313            ctx.register_parquet(&tmp, path, ParquetReadOptions::default())
314                .await?;
315        }
316        SourceFormat::Csv => {
317            ctx.register_csv(&tmp, path, CsvReadOptions::default())
318                .await?;
319        }
320        SourceFormat::Jsonl => {
321            let opts = JsonReadOptions::default()
322                .file_extension(".jsonl")
323                .newline_delimited(true);
324            // DF register_type_check requires path to end with extension; if directory, ok
325            if let Err(e) = ctx.register_json(&tmp, path, opts).await {
326                // Fallback: .json extension / generic
327                tracing::debug!("jsonl register with .jsonl failed ({e}); retrying default");
328                ctx.register_json(&tmp, path, JsonReadOptions::default())
329                    .await?;
330            }
331        }
332        SourceFormat::Json => {
333            let opts = JsonReadOptions::default().newline_delimited(false);
334            ctx.register_json(&tmp, path, opts).await?;
335        }
336        SourceFormat::ArrowIpc => {
337            ctx.register_arrow(&tmp, path, ArrowReadOptions::default())
338                .await?;
339        }
340        other => bail!("listing_table_provider does not support format {}", other),
341    }
342
343    let provider = ctx
344        .table_provider(TableReference::bare(tmp.as_str()))
345        .await
346        .with_context(|| format!("lookup temp bronze table {}", tmp))?;
347    let _ = ctx.deregister_table(TableReference::bare(tmp.as_str()))?;
348    Ok(provider)
349}
350
351async fn scan_to_memtable(
352    project_dir: &Path,
353    fm: &StagingFrontmatter,
354    format: SourceFormat,
355    config: &crate::core::project::RbtProjectConfig,
356) -> Result<Arc<dyn TableProvider>> {
357    let mut req = ScanRequest::from_frontmatter_with_config(
358        project_dir,
359        fm,
360        config.roots.clone(),
361        &config.scan,
362    )?;
363    req.format = format;
364    let scanner = LakeScanner::from_request(&req);
365    let batches = scanner.scan(&req).await?;
366    if batches.is_empty() {
367        bail!(
368            "E_RBT_BRONZE_SCAN_EMPTY: bronze scan produced zero batches for {}",
369            req.resolved_path()?.display()
370        );
371    }
372    let schema = batches[0].schema();
373    // MemTable expects Vec<Vec<RecordBatch>> partitions
374    let mem = MemTable::try_new(schema, vec![batches])
375        .map_err(|e| anyhow::anyhow!("MemTable::try_new: {}", e))?;
376    Ok(Arc::new(mem))
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::core::dag::{Materialization, ModelDag, OutputFormat};
383
384    #[tokio::test]
385    async fn register_jsonl_from_frontmatter() -> Result<()> {
386        let temp = tempfile::tempdir()?;
387        let bronze = temp.path().join("raw.jsonl");
388        std::fs::write(
389            &bronze,
390            r#"{"ticker":"NVDA","price":1.5}
391{"ticker":"AAPL","price":2.5}
392"#,
393        )?;
394
395        let sql = format!(
396            r#"---
397source_format: jsonl
398scan_path: "{}"
399---
400SELECT ticker, price FROM {{{{ source('bronze', 'raw_trades') }}}}
401"#,
402            bronze.file_name().unwrap().to_string_lossy()
403        );
404
405        let mut dag = ModelDag::new();
406        dag.add_model_with_format(
407            "stg_trades",
408            &sql,
409            Materialization::Table,
410            OutputFormat::Parquet,
411            None,
412            "",
413        )?;
414        dag.build_graph()?;
415
416        let engine_ctx = SessionContext::new();
417        let mut registered = HashSet::new();
418        let cfg = crate::core::project::RbtProjectConfig::default();
419        let n =
420            register_bronze_sources_for_dag(&engine_ctx, &dag, temp.path(), &mut registered, &cfg)
421                .await?;
422        assert_eq!(n, 1);
423
424        let df = engine_ctx
425            .sql("SELECT COUNT(*) AS c FROM bronze.raw_trades")
426            .await?;
427        let batches = df.collect().await?;
428        assert_eq!(batches[0].num_rows(), 1);
429        Ok(())
430    }
431}