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 anyhow::{bail, Context, Result};
16use async_trait::async_trait;
17use datafusion::arrow::datatypes::SchemaRef;
18use datafusion::catalog::Session;
19use datafusion::catalog::TableProvider;
20use datafusion::datasource::MemTable;
21use datafusion::error::Result as DFResult;
22use datafusion::execution::context::SessionContext;
23use datafusion::logical_expr::{Expr, TableType};
24use datafusion::physical_plan::ExecutionPlan;
25use datafusion::common::TableReference;
26use datafusion::execution::options::ArrowReadOptions;
27use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
28use rbt_core::dag::{ModelDag, ModelNode};
29use rbt_core::frontmatter::{resolve_scan_path, SourceFormat, StagingFrontmatter};
30use rbt_scan::{LakeScanner, ScanRequest};
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.resolve_format().with_context(|| {
159        format!("model '{}': cannot resolve source_format", node.name)
160    })?;
161
162    let resolved = resolve_scan_path(project_dir, fm.scan_path.as_deref().unwrap());
163    if !resolved.exists() && !rbt_core::frontmatter::is_remote_uri(fm.scan_path.as_deref().unwrap())
164    {
165        bail!(
166            "model '{}': bronze scan_path does not exist: {} (resolved {})",
167            node.name,
168            fm.scan_path.as_deref().unwrap(),
169            resolved.display()
170        );
171    }
172
173    let path_str = resolved.to_string_lossy().to_string();
174    let use_scan = should_use_scan_path(fm, format);
175
176    let (inner, mode) = if use_scan {
177        let provider = scan_to_memtable(project_dir, fm, format)
178            .await
179            .with_context(|| format!("model '{}': bronze scan failed", node.name))?;
180        (provider, BronzeRegistrationMode::ScanMemTable)
181    } else {
182        let provider = listing_table_provider(ctx, &path_str, format)
183            .await
184            .with_context(|| {
185                format!(
186                    "model '{}': DataFusion listing registration failed for {}",
187                    node.name, path_str
188                )
189            })?;
190        (provider, BronzeRegistrationMode::DataFusionListing)
191    };
192
193    let meta = BronzeSourceMeta {
194        model_name: node.name.clone(),
195        source_schema: schema_name.clone(),
196        source_table: table_name.clone(),
197        format,
198        scan_path: resolved,
199        registration_mode: mode,
200    };
201
202    let bronze = Arc::new(BronzeTableProvider::wrap(inner, meta));
203    let table_ref = TableReference::partial(schema_name.clone(), table_name.clone());
204
205    // Replace if present (re-runs / tests)
206    let _ = ctx.deregister_table(table_ref.clone());
207    ctx.register_table(table_ref, bronze)
208        .map_err(|e| anyhow::anyhow!("register {}.{}: {}", schema_name, table_name, e))?;
209
210    registered.insert(key);
211    tracing::info!(
212        "Registered bronze source {}.{} from model '{}' ({:?}, format={})",
213        schema_name,
214        table_name,
215        node.name,
216        mode,
217        format
218    );
219    Ok(Some(1))
220}
221
222fn should_use_scan_path(fm: &StagingFrontmatter, format: SourceFormat) -> bool {
223    if fm.force_scan.unwrap_or(false) {
224        return true;
225    }
226    // Hive partition injection / filters / source path require the scan path
227    // (DataFusion listing does not inject path-derived columns).
228    if fm
229        .partition_by
230        .as_ref()
231        .map(|p| !p.is_empty())
232        .unwrap_or(false)
233        || fm
234            .require_partitions
235            .as_ref()
236            .map(|p| !p.is_empty())
237            .unwrap_or(false)
238        || fm.inject_source_path.unwrap_or(false)
239    {
240        return true;
241    }
242    // jshift selective extract
243    if matches!(format, SourceFormat::Jsonl | SourceFormat::Json)
244        && fm.paths.as_ref().map(|p| !p.is_empty()).unwrap_or(false)
245    {
246        return true;
247    }
248    // Nested hive dirs + stream IPC are not reliably handled by DF listing alone.
249    matches!(
250        format,
251        SourceFormat::Log
252            | SourceFormat::Txt
253            | SourceFormat::Toml
254            | SourceFormat::ArrowIpc
255            | SourceFormat::ArrowIpcStream
256    )
257}
258
259async fn ensure_schema(ctx: &SessionContext, schema_name: &str) -> Result<()> {
260    // DataFusion accepts CREATE SCHEMA via SQL
261    let sql = format!("CREATE SCHEMA IF NOT EXISTS \"{}\"", schema_name.replace('"', ""));
262    ctx.sql(&sql)
263        .await
264        .with_context(|| format!("CREATE SCHEMA {}", schema_name))?
265        .collect()
266        .await
267        .with_context(|| format!("CREATE SCHEMA {} collect", schema_name))?;
268    Ok(())
269}
270
271/// Path A: materialize a DF listing provider, then return it for wrapping.
272async fn listing_table_provider(
273    ctx: &SessionContext,
274    path: &str,
275    format: SourceFormat,
276) -> Result<Arc<dyn TableProvider>> {
277    // Register under a private temp name, extract provider, deregister.
278    let tmp = format!(
279        "__rbt_bronze_tmp_{}",
280        std::time::SystemTime::now()
281            .duration_since(std::time::UNIX_EPOCH)
282            .map(|d| d.as_nanos())
283            .unwrap_or(0)
284    );
285
286    match format {
287        SourceFormat::Parquet => {
288            ctx.register_parquet(&tmp, path, ParquetReadOptions::default())
289                .await?;
290        }
291        SourceFormat::Csv => {
292            ctx.register_csv(&tmp, path, CsvReadOptions::default())
293                .await?;
294        }
295        SourceFormat::Jsonl => {
296            let opts = JsonReadOptions::default()
297                .file_extension(".jsonl")
298                .newline_delimited(true);
299            // DF register_type_check requires path to end with extension; if directory, ok
300            if let Err(e) = ctx.register_json(&tmp, path, opts).await {
301                // Fallback: .json extension / generic
302                tracing::debug!("jsonl register with .jsonl failed ({e}); retrying default");
303                ctx.register_json(&tmp, path, JsonReadOptions::default())
304                    .await?;
305            }
306        }
307        SourceFormat::Json => {
308            let opts = JsonReadOptions::default().newline_delimited(false);
309            ctx.register_json(&tmp, path, opts).await?;
310        }
311        SourceFormat::ArrowIpc => {
312            ctx.register_arrow(&tmp, path, ArrowReadOptions::default())
313                .await?;
314        }
315        other => bail!("listing_table_provider does not support format {}", other),
316    }
317
318    let provider = ctx
319        .table_provider(TableReference::bare(tmp.as_str()))
320        .await
321        .with_context(|| format!("lookup temp bronze table {}", tmp))?;
322    let _ = ctx.deregister_table(TableReference::bare(tmp.as_str()))?;
323    Ok(provider)
324}
325
326async fn scan_to_memtable(
327    project_dir: &Path,
328    fm: &StagingFrontmatter,
329    format: SourceFormat,
330) -> Result<Arc<dyn TableProvider>> {
331    let mut req = ScanRequest::from_frontmatter(project_dir, fm)?;
332    req.format = format;
333    let scanner = LakeScanner::from_request(&req);
334    let batches = scanner.scan(&req).await?;
335    if batches.is_empty() {
336        bail!(
337            "bronze scan produced zero batches for {}",
338            req.resolved_path().display()
339        );
340    }
341    let schema = batches[0].schema();
342    // MemTable expects Vec<Vec<RecordBatch>> partitions
343    let mem = MemTable::try_new(schema, vec![batches])
344        .map_err(|e| anyhow::anyhow!("MemTable::try_new: {}", e))?;
345    Ok(Arc::new(mem))
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use rbt_core::dag::{Materialization, ModelDag, OutputFormat};
352
353    #[tokio::test]
354    async fn register_jsonl_from_frontmatter() -> Result<()> {
355        let temp = tempfile::tempdir()?;
356        let bronze = temp.path().join("raw.jsonl");
357        std::fs::write(
358            &bronze,
359            r#"{"ticker":"NVDA","price":1.5}
360{"ticker":"AAPL","price":2.5}
361"#,
362        )?;
363
364        let sql = format!(
365            r#"---
366source_format: jsonl
367scan_path: "{}"
368---
369SELECT ticker, price FROM {{{{ source('bronze', 'raw_trades') }}}}
370"#,
371            bronze.file_name().unwrap().to_string_lossy()
372        );
373
374        let mut dag = ModelDag::new();
375        dag.add_model_with_format(
376            "stg_trades",
377            &sql,
378            Materialization::Table,
379            OutputFormat::Parquet,
380            None,
381            "",
382        )?;
383        dag.build_graph()?;
384
385        let engine_ctx = SessionContext::new();
386        let mut registered = HashSet::new();
387        let n =
388            register_bronze_sources_for_dag(&engine_ctx, &dag, temp.path(), &mut registered)
389                .await?;
390        assert_eq!(n, 1);
391
392        let df = engine_ctx
393            .sql("SELECT COUNT(*) AS c FROM bronze.raw_trades")
394            .await?;
395        let batches = df.collect().await?;
396        assert_eq!(batches[0].num_rows(), 1);
397        Ok(())
398    }
399}