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