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::core::receipt::apply_scope_to_frontmatter;
21use crate::core::run_scope::{OnMissing, RunScope};
22use crate::scan::{LakeScanner, ScanRequest};
23use anyhow::{bail, Context, Result};
24use arrow::datatypes::SchemaRef;
25use arrow::record_batch::RecordBatch;
26use async_trait::async_trait;
27use datafusion::catalog::Session;
28use datafusion::catalog::TableProvider;
29use datafusion::common::TableReference;
30use datafusion::datasource::MemTable;
31use datafusion::error::Result as DFResult;
32use datafusion::execution::context::SessionContext;
33use datafusion::execution::options::ArrowReadOptions;
34use datafusion::logical_expr::{Expr, TableType};
35use datafusion::physical_plan::ExecutionPlan;
36use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
37use std::any::Any;
38use std::collections::HashSet;
39use std::path::{Path, PathBuf};
40use std::sync::Arc;
41
42/// Metadata retained on the bronze provider for debugging and future lineage.
43#[derive(Debug, Clone)]
44pub struct BronzeSourceMeta {
45    pub model_name: String,
46    pub source_schema: String,
47    pub source_table: String,
48    pub format: SourceFormat,
49    pub scan_path: PathBuf,
50    pub registration_mode: BronzeRegistrationMode,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BronzeRegistrationMode {
55    /// Inner provider is a DataFusion listing / external table.
56    DataFusionListing,
57    /// Inner provider is a MemTable filled by `rbt::scan` (small / non-spill formats).
58    ScanMemTable,
59    /// Arrow IPC (etc.) spilled file-by-file to Parquet then listed — bounded peak RAM.
60    ScanSpillParquet,
61}
62
63/// Thin `TableProvider` wrapper around a DataFusion listing table or MemTable.
64#[derive(Debug)]
65pub struct BronzeTableProvider {
66    pub meta: BronzeSourceMeta,
67    inner: Arc<dyn TableProvider>,
68}
69
70impl BronzeTableProvider {
71    pub fn wrap(inner: Arc<dyn TableProvider>, meta: BronzeSourceMeta) -> Self {
72        Self { meta, inner }
73    }
74
75    pub fn inner(&self) -> &Arc<dyn TableProvider> {
76        &self.inner
77    }
78}
79
80#[async_trait]
81impl TableProvider for BronzeTableProvider {
82    fn as_any(&self) -> &dyn Any {
83        self
84    }
85
86    fn schema(&self) -> SchemaRef {
87        self.inner.schema()
88    }
89
90    fn table_type(&self) -> TableType {
91        self.inner.table_type()
92    }
93
94    async fn scan(
95        &self,
96        state: &dyn Session,
97        projection: Option<&Vec<usize>>,
98        filters: &[Expr],
99        limit: Option<usize>,
100    ) -> DFResult<Arc<dyn ExecutionPlan>> {
101        self.inner.scan(state, projection, filters, limit).await
102    }
103
104    fn supports_filters_pushdown(
105        &self,
106        filters: &[&Expr],
107    ) -> DFResult<Vec<datafusion::logical_expr::TableProviderFilterPushDown>> {
108        self.inner.supports_filters_pushdown(filters)
109    }
110}
111
112/// Registers all bronze sources declared by model frontmatter into `ctx`.
113///
114/// Idempotent per `(schema, table)` within a single run (tracked by `registered`).
115/// Uses `config.roots` and `config.scan` (no re-read of yml per model).
116/// Optional [`RunScope`] expands `{var}` templates and partition binds (P5a).
117pub async fn register_bronze_sources_for_dag(
118    ctx: &SessionContext,
119    dag: &ModelDag,
120    project_dir: &Path,
121    registered: &mut HashSet<(String, String)>,
122    config: &crate::core::project::RbtProjectConfig,
123) -> Result<usize> {
124    register_bronze_sources_for_dag_scoped(ctx, dag, project_dir, registered, config, None).await
125}
126
127/// Like [`register_bronze_sources_for_dag`] with an explicit run scope.
128pub async fn register_bronze_sources_for_dag_scoped(
129    ctx: &SessionContext,
130    dag: &ModelDag,
131    project_dir: &Path,
132    registered: &mut HashSet<(String, String)>,
133    config: &crate::core::project::RbtProjectConfig,
134    scope: Option<&RunScope>,
135) -> Result<usize> {
136    let mut count = 0;
137    for idx in dag.graph.node_indices() {
138        let node = &dag.graph[idx];
139        if let Some(n) =
140            register_bronze_for_model_scoped(ctx, node, project_dir, registered, config, scope)
141                .await?
142        {
143            count += n;
144        }
145    }
146    Ok(count)
147}
148
149/// Register bronze for a single model if it has a scan contract.
150pub async fn register_bronze_for_model(
151    ctx: &SessionContext,
152    node: &ModelNode,
153    project_dir: &Path,
154    registered: &mut HashSet<(String, String)>,
155    config: &crate::core::project::RbtProjectConfig,
156) -> Result<Option<usize>> {
157    register_bronze_for_model_scoped(ctx, node, project_dir, registered, config, None).await
158}
159
160/// Register bronze with optional run scope (partition binds + var templates + on_missing).
161pub async fn register_bronze_for_model_scoped(
162    ctx: &SessionContext,
163    node: &ModelNode,
164    project_dir: &Path,
165    registered: &mut HashSet<(String, String)>,
166    config: &crate::core::project::RbtProjectConfig,
167    scope: Option<&RunScope>,
168) -> Result<Option<usize>> {
169    let Some(fm_raw) = node.frontmatter.as_ref() else {
170        return Ok(None);
171    };
172    if !fm_raw.has_scan_contract() {
173        return Ok(None);
174    }
175
176    let default_scope = RunScope::default();
177    let scope = scope.unwrap_or(&default_scope);
178    let fm = apply_scope_to_frontmatter(fm_raw, scope);
179    let fm = &fm;
180
181    let (schema_name, table_name) = ModelDag::bronze_source_ident(node).with_context(|| {
182        format!(
183            "model '{}': frontmatter has scan_path but no source identity \
184             (add source() in SQL or source_name/source_table in frontmatter)",
185            node.name
186        )
187    })?;
188
189    let key = (schema_name.clone(), table_name.clone());
190    if registered.contains(&key) {
191        tracing::debug!(
192            "Bronze source {}.{} already registered; skipping model '{}'",
193            schema_name,
194            table_name,
195            node.name
196        );
197        return Ok(None);
198    }
199
200    ensure_schema(ctx, &schema_name).await?;
201
202    let format = fm
203        .resolve_format()
204        .with_context(|| format!("model '{}': cannot resolve source_format", node.name))?;
205
206    let raw_scan = fm.scan_path.as_deref().unwrap();
207    let resolved = crate::core::paths::resolve_project_path(project_dir, raw_scan, &config.roots)
208        .with_context(|| {
209        format!(
210            "E_RBT_BRONZE_PATH: model '{}': cannot resolve scan_path '{}'. \
211                     Check absolute paths and `roots:` templates in rbt_project.yml.",
212            node.name, raw_scan
213        )
214    })?;
215    let on_missing = fm.on_missing_policy();
216    if !resolved.exists() && !crate::core::frontmatter::is_remote_uri(raw_scan) {
217        if on_missing == OnMissing::Empty {
218            let provider = empty_memtable(fm, scope).with_context(|| {
219                format!(
220                    "model '{}': on_missing empty frame failed (scan_path missing)",
221                    node.name
222                )
223            })?;
224            return finish_register(
225                ctx,
226                registered,
227                key,
228                schema_name,
229                table_name,
230                node,
231                format,
232                resolved,
233                provider,
234                BronzeRegistrationMode::ScanMemTable,
235            )
236            .await;
237        }
238        bail!(
239            "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND: model '{}': bronze scan_path does not exist: {} \
240             (resolved {}). Hint: verify the lake path and `$root` expansion, \
241             or set on_missing: empty for optional artifact families.",
242            node.name,
243            raw_scan,
244            resolved.display()
245        );
246    }
247
248    let path_str = resolved.to_string_lossy().to_string();
249
250    // P6: multi-part parquet directories (manifest-aware).
251    let parts_mode = fm.wants_parts_source()
252        || crate::scan::parts::is_parts_directory(&resolved)
253        || matches!(format, SourceFormat::Parquet)
254            && resolved.is_dir()
255            && crate::scan::parts::manifest_path(&resolved).is_file();
256
257    if parts_mode {
258        let provider = register_parts_parquet(ctx, &resolved)
259            .await
260            .with_context(|| {
261                format!(
262                    "model '{}': parts parquet registration failed for {}",
263                    node.name,
264                    resolved.display()
265                )
266            })?;
267        return finish_register(
268            ctx,
269            registered,
270            key,
271            schema_name,
272            table_name,
273            node,
274            SourceFormat::Parquet,
275            resolved,
276            provider,
277            BronzeRegistrationMode::DataFusionListing,
278        )
279        .await;
280    }
281
282    let use_scan = should_use_scan_path(fm, format);
283
284    let (inner, mode) = if use_scan {
285        if should_spill_to_parquet(format, config) {
286            match scan_spill_to_listing(
287                ctx,
288                project_dir,
289                fm,
290                format,
291                config,
292                &schema_name,
293                &table_name,
294            )
295            .await
296            {
297                Ok(provider) => (provider, BronzeRegistrationMode::ScanSpillParquet),
298                Err(e) if on_missing == OnMissing::Empty && is_empty_or_missing_err(&e) => {
299                    (
300                        empty_memtable(fm, scope)?,
301                        BronzeRegistrationMode::ScanMemTable,
302                    )
303                }
304                Err(e) => {
305                    return Err(e).with_context(|| {
306                        format!(
307                            "model '{}': bronze spill→parquet failed (format={})",
308                            node.name, format
309                        )
310                    })
311                }
312            }
313        } else {
314            match scan_to_memtable(project_dir, fm, format, config).await {
315                Ok(provider) => (provider, BronzeRegistrationMode::ScanMemTable),
316                Err(e) if on_missing == OnMissing::Empty && is_empty_or_missing_err(&e) => {
317                    (
318                        empty_memtable(fm, scope)?,
319                        BronzeRegistrationMode::ScanMemTable,
320                    )
321                }
322                Err(e) => {
323                    return Err(e).with_context(|| format!("model '{}': bronze scan failed", node.name))
324                }
325            }
326        }
327    } else {
328        let provider = listing_table_provider(ctx, &path_str, format)
329            .await
330            .with_context(|| {
331                format!(
332                    "model '{}': DataFusion listing registration failed for {}",
333                    node.name, path_str
334                )
335            })?;
336        (provider, BronzeRegistrationMode::DataFusionListing)
337    };
338
339    finish_register(
340        ctx,
341        registered,
342        key,
343        schema_name,
344        table_name,
345        node,
346        format,
347        resolved,
348        inner,
349        mode,
350    )
351    .await
352}
353
354async fn finish_register(
355    ctx: &SessionContext,
356    registered: &mut HashSet<(String, String)>,
357    key: (String, String),
358    schema_name: String,
359    table_name: String,
360    node: &ModelNode,
361    format: SourceFormat,
362    resolved: PathBuf,
363    inner: Arc<dyn TableProvider>,
364    mode: BronzeRegistrationMode,
365) -> Result<Option<usize>> {
366
367    let meta = BronzeSourceMeta {
368        model_name: node.name.clone(),
369        source_schema: schema_name.clone(),
370        source_table: table_name.clone(),
371        format,
372        scan_path: resolved,
373        registration_mode: mode,
374    };
375
376    let bronze = Arc::new(BronzeTableProvider::wrap(inner, meta));
377    let table_ref = TableReference::partial(schema_name.clone(), table_name.clone());
378
379    // Replace if present (re-runs / tests)
380    let _ = ctx.deregister_table(table_ref.clone());
381    ctx.register_table(table_ref, bronze)
382        .map_err(|e| anyhow::anyhow!("register {}.{}: {}", schema_name, table_name, e))?;
383
384    registered.insert(key);
385    tracing::info!(
386        "Registered bronze source {}.{} from model '{}' ({:?}, format={})",
387        schema_name,
388        table_name,
389        node.name,
390        mode,
391        format
392    );
393    Ok(Some(1))
394}
395
396fn is_empty_or_missing_err(e: &anyhow::Error) -> bool {
397    let s = format!("{e:#}");
398    s.contains("E_RBT_BRONZE_SCAN_EMPTY")
399        || s.contains("E_RBT_BRONZE_SCAN_PATH_NOT_FOUND")
400        || s.contains("bronze scan produced zero batches")
401}
402
403/// Zero-row MemTable with declared schema; partition keys filled from run scope when present.
404fn empty_memtable(fm: &StagingFrontmatter, scope: &RunScope) -> Result<Arc<dyn TableProvider>> {
405    let schema = fm.empty_frame_schema()?;
406    // Empty batch — partition constants are for SQL via require_partitions on non-empty scans;
407    // empty frame still exposes partition columns as nullable Utf8 for outer joins.
408    let batch = RecordBatch::new_empty(schema.clone());
409    let _ = scope; // reserved: future constant partition columns on empty frames
410    let mem = MemTable::try_new(schema, vec![vec![batch]])
411        .map_err(|e| anyhow::anyhow!("MemTable empty: {e}"))?;
412    Ok(Arc::new(mem))
413}
414
415fn should_use_scan_path(fm: &StagingFrontmatter, format: SourceFormat) -> bool {
416    if fm.force_scan.unwrap_or(false) {
417        return true;
418    }
419    // Hive partition injection / filters / source path / path_glob require the scan path
420    // (DataFusion listing does not inject path-derived columns or apply rbt globs).
421    if fm
422        .partition_by
423        .as_ref()
424        .map(|p| !p.is_empty())
425        .unwrap_or(false)
426        || fm
427            .require_partitions
428            .as_ref()
429            .map(|p| !p.is_empty())
430            .unwrap_or(false)
431        || fm
432            .path_glob
433            .as_ref()
434            .map(|p| !p.is_empty())
435            .unwrap_or(false)
436        || fm.inject_source_path.unwrap_or(false)
437    {
438        return true;
439    }
440    // jshift selective extract
441    if matches!(format, SourceFormat::Jsonl | SourceFormat::Json)
442        && fm.paths.as_ref().map(|p| !p.is_empty()).unwrap_or(false)
443    {
444        return true;
445    }
446    // Nested hive dirs, stream IPC, and opaque protobuf need the scan path.
447    matches!(
448        format,
449        SourceFormat::Log
450            | SourceFormat::Txt
451            | SourceFormat::Toml
452            | SourceFormat::ArrowIpc
453            | SourceFormat::ArrowIpcStream
454            | SourceFormat::Protobuf
455    )
456}
457
458async fn ensure_schema(ctx: &SessionContext, schema_name: &str) -> Result<()> {
459    // DataFusion accepts CREATE SCHEMA via SQL
460    let sql = format!(
461        "CREATE SCHEMA IF NOT EXISTS \"{}\"",
462        schema_name.replace('"', "")
463    );
464    ctx.sql(&sql)
465        .await
466        .with_context(|| format!("CREATE SCHEMA {}", schema_name))?
467        .collect()
468        .await
469        .with_context(|| format!("CREATE SCHEMA {} collect", schema_name))?;
470    Ok(())
471}
472
473/// Register a multi-file parquet parts directory as one table provider.
474async fn register_parts_parquet(
475    ctx: &SessionContext,
476    parts_dir: &Path,
477) -> Result<Arc<dyn TableProvider>> {
478    let files = crate::scan::parts::list_part_files(parts_dir)?;
479    tracing::info!(
480        "Parts source: {} file(s) under {} (manifest_rows={:?})",
481        files.len(),
482        parts_dir.display(),
483        crate::scan::parts::manifest_total_rows(parts_dir)
484    );
485    // DataFusion lists directories recursively; registering the directory is enough when
486    // only part-*.parquet files live there. Prefer directory registration for pushdown.
487    let path_str = parts_dir.to_string_lossy().to_string();
488    listing_table_provider(ctx, &path_str, SourceFormat::Parquet)
489        .await
490        .with_context(|| {
491            format!(
492                "E_RBT_PARTS: register_parquet on parts dir {} ({} files)",
493                parts_dir.display(),
494                files.len()
495            )
496        })
497}
498
499/// Path A: materialize a DF listing provider, then return it for wrapping.
500async fn listing_table_provider(
501    ctx: &SessionContext,
502    path: &str,
503    format: SourceFormat,
504) -> Result<Arc<dyn TableProvider>> {
505    // Register under a private temp name, extract provider, deregister.
506    let tmp = format!(
507        "__rbt_bronze_tmp_{}",
508        std::time::SystemTime::now()
509            .duration_since(std::time::UNIX_EPOCH)
510            .map(|d| d.as_nanos())
511            .unwrap_or(0)
512    );
513
514    match format {
515        SourceFormat::Parquet => {
516            ctx.register_parquet(&tmp, path, ParquetReadOptions::default())
517                .await?;
518        }
519        SourceFormat::Csv => {
520            ctx.register_csv(&tmp, path, CsvReadOptions::default())
521                .await?;
522        }
523        SourceFormat::Jsonl => {
524            let opts = JsonReadOptions::default()
525                .file_extension(".jsonl")
526                .newline_delimited(true);
527            // DF register_type_check requires path to end with extension; if directory, ok
528            if let Err(e) = ctx.register_json(&tmp, path, opts).await {
529                // Fallback: .json extension / generic
530                tracing::debug!("jsonl register with .jsonl failed ({e}); retrying default");
531                ctx.register_json(&tmp, path, JsonReadOptions::default())
532                    .await?;
533            }
534        }
535        SourceFormat::Json => {
536            let opts = JsonReadOptions::default().newline_delimited(false);
537            ctx.register_json(&tmp, path, opts).await?;
538        }
539        SourceFormat::ArrowIpc => {
540            ctx.register_arrow(&tmp, path, ArrowReadOptions::default())
541                .await?;
542        }
543        other => bail!("listing_table_provider does not support format {}", other),
544    }
545
546    let provider = ctx
547        .table_provider(TableReference::bare(tmp.as_str()))
548        .await
549        .with_context(|| format!("lookup temp bronze table {}", tmp))?;
550    let _ = ctx.deregister_table(TableReference::bare(tmp.as_str()))?;
551    Ok(provider)
552}
553
554fn should_spill_to_parquet(
555    format: SourceFormat,
556    config: &crate::core::project::RbtProjectConfig,
557) -> bool {
558    config.scan.spill_arrow_ipc
559        && matches!(
560            format,
561            SourceFormat::ArrowIpc | SourceFormat::ArrowIpcStream
562        )
563}
564
565async fn scan_to_memtable(
566    project_dir: &Path,
567    fm: &StagingFrontmatter,
568    format: SourceFormat,
569    config: &crate::core::project::RbtProjectConfig,
570) -> Result<Arc<dyn TableProvider>> {
571    let mut req = ScanRequest::from_frontmatter_with_config(
572        project_dir,
573        fm,
574        config.roots.clone(),
575        &config.scan,
576    )?;
577    req.format = format;
578    let scanner = LakeScanner::from_request(&req);
579    let batches = scanner.scan(&req).await?;
580    if batches.is_empty() {
581        if req.allow_empty {
582            let schema = fm.empty_frame_schema().unwrap_or_else(|_| {
583                Arc::new(arrow::datatypes::Schema::new(vec![arrow::datatypes::Field::new(
584                    "_empty",
585                    arrow::datatypes::DataType::Utf8,
586                    true,
587                )]))
588            });
589            let batch = RecordBatch::new_empty(schema.clone());
590            let mem = MemTable::try_new(schema, vec![vec![batch]])
591                .map_err(|e| anyhow::anyhow!("MemTable::try_new empty: {e}"))?;
592            return Ok(Arc::new(mem));
593        }
594        bail!(
595            "E_RBT_BRONZE_SCAN_EMPTY: bronze scan produced zero batches for {}",
596            req.resolved_path()?.display()
597        );
598    }
599    let schema = batches[0].schema();
600    // MemTable expects Vec<Vec<RecordBatch>> partitions
601    let mem = MemTable::try_new(schema, vec![batches])
602        .map_err(|e| anyhow::anyhow!("MemTable::try_new: {}", e))?;
603    Ok(Arc::new(mem))
604}
605
606/// Stream Arrow IPC (etc.) file-by-file into a project spill Parquet, then DF-list it.
607async fn scan_spill_to_listing(
608    ctx: &SessionContext,
609    project_dir: &Path,
610    fm: &StagingFrontmatter,
611    format: SourceFormat,
612    config: &crate::core::project::RbtProjectConfig,
613    schema_name: &str,
614    table_name: &str,
615) -> Result<Arc<dyn TableProvider>> {
616    let mut req = ScanRequest::from_frontmatter_with_config(
617        project_dir,
618        fm,
619        config.roots.clone(),
620        &config.scan,
621    )?;
622    req.format = format;
623    let scanner = LakeScanner::from_request(&req);
624
625    let spill_root = crate::core::paths::resolve_project_path(
626        project_dir,
627        &config.scan.spill_dir,
628        &config.roots,
629    )
630    .with_context(|| {
631        format!(
632            "E_RBT_BRONZE_SPILL: resolve spill_dir '{}'",
633            config.scan.spill_dir
634        )
635    })?;
636    std::fs::create_dir_all(&spill_root).with_context(|| {
637        format!(
638            "E_RBT_BRONZE_SPILL: mkdir {}",
639            spill_root.display()
640        )
641    })?;
642    let safe = format!(
643        "{}__{}.parquet",
644        schema_name.replace('/', "_"),
645        table_name.replace('/', "_")
646    );
647    let spill_path = spill_root.join(safe);
648
649    let opts = crate::materializer::MaterializeWriteOptions::from_config(&config.materialize, true);
650    let stats = scanner
651        .scan_spill_to_parquet(&req, &spill_path, &opts)
652        .with_context(|| {
653            format!(
654                "E_RBT_BRONZE_SPILL: spill to {}",
655                spill_path.display()
656            )
657        })?;
658    tracing::info!(
659        "Bronze {}.{} spilled {} rows ({} batches) → {}",
660        schema_name,
661        table_name,
662        stats.rows,
663        stats.batches,
664        spill_path.display()
665    );
666
667    listing_table_provider(
668        ctx,
669        spill_path.to_str().unwrap_or_default(),
670        SourceFormat::Parquet,
671    )
672    .await
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::core::dag::{Materialization, ModelDag, OutputFormat};
679
680    #[tokio::test]
681    async fn register_arrow_ipc_spills_to_parquet() -> Result<()> {
682        use arrow::array::Int64Array;
683        use arrow::datatypes::{DataType, Field, Schema};
684        use arrow::ipc::writer::FileWriter;
685        use arrow::record_batch::RecordBatch;
686        use std::sync::Arc;
687
688        let temp = tempfile::tempdir()?;
689        let bronze = temp.path().join("lake/bronze/symbol=X/timeframe=1m");
690        std::fs::create_dir_all(&bronze)?;
691        let schema = Arc::new(Schema::new(vec![
692            Field::new("symbol", DataType::Utf8, false),
693            Field::new("v", DataType::Int64, false),
694        ]));
695        let batch = RecordBatch::try_new(
696            schema.clone(),
697            vec![
698                Arc::new(arrow::array::StringArray::from(vec!["X", "X"])),
699                Arc::new(Int64Array::from(vec![1, 2])),
700            ],
701        )?;
702        let f = std::fs::File::create(bronze.join("chunk.arrow"))?;
703        let mut w = FileWriter::try_new(f, &schema)?;
704        w.write(&batch)?;
705        w.finish()?;
706
707        let sql = r#"---
708source_format: arrow_ipc
709scan_path: "lake/bronze"
710path_glob: "**/*.arrow"
711partition_by: [symbol, timeframe]
712require_partitions:
713  timeframe: "1m"
714inject_source_path: true
715---
716SELECT symbol, timeframe, v FROM {{ source('bronze', 'ohlcv') }}
717"#;
718        let mut dag = ModelDag::new();
719        dag.add_model_with_format(
720            "stg_ohlcv",
721            sql,
722            Materialization::Table,
723            OutputFormat::Parquet,
724            None,
725            "",
726        )?;
727        dag.build_graph()?;
728
729        let ctx = SessionContext::new();
730        let mut registered = HashSet::new();
731        let cfg = crate::core::project::RbtProjectConfig::default();
732        assert!(cfg.scan.spill_arrow_ipc);
733        let n = register_bronze_sources_for_dag(&ctx, &dag, temp.path(), &mut registered, &cfg)
734            .await?;
735        assert_eq!(n, 1);
736
737        let spill = temp
738            .path()
739            .join(".rbt/bronze_spill/bronze__ohlcv.parquet");
740        assert!(
741            spill.exists(),
742            "expected spill parquet at {}",
743            spill.display()
744        );
745
746        let df = ctx
747            .sql("SELECT COUNT(*) AS c FROM bronze.ohlcv")
748            .await?;
749        let batches = df.collect().await?;
750        // 2 data rows
751        let c = batches[0]
752            .column(0)
753            .as_any()
754            .downcast_ref::<Int64Array>()
755            .unwrap()
756            .value(0);
757        assert_eq!(c, 2);
758        Ok(())
759    }
760
761    #[tokio::test]
762    async fn register_jsonl_from_frontmatter() -> Result<()> {
763        let temp = tempfile::tempdir()?;
764        let bronze = temp.path().join("raw.jsonl");
765        std::fs::write(
766            &bronze,
767            r#"{"ticker":"NVDA","price":1.5}
768{"ticker":"AAPL","price":2.5}
769"#,
770        )?;
771
772        let sql = format!(
773            r#"---
774source_format: jsonl
775scan_path: "{}"
776---
777SELECT ticker, price FROM {{{{ source('bronze', 'raw_trades') }}}}
778"#,
779            bronze.file_name().unwrap().to_string_lossy()
780        );
781
782        let mut dag = ModelDag::new();
783        dag.add_model_with_format(
784            "stg_trades",
785            &sql,
786            Materialization::Table,
787            OutputFormat::Parquet,
788            None,
789            "",
790        )?;
791        dag.build_graph()?;
792
793        let engine_ctx = SessionContext::new();
794        let mut registered = HashSet::new();
795        let cfg = crate::core::project::RbtProjectConfig::default();
796        let n =
797            register_bronze_sources_for_dag(&engine_ctx, &dag, temp.path(), &mut registered, &cfg)
798                .await?;
799        assert_eq!(n, 1);
800
801        let df = engine_ctx
802            .sql("SELECT COUNT(*) AS c FROM bronze.raw_trades")
803            .await?;
804        let batches = df.collect().await?;
805        assert_eq!(batches[0].num_rows(), 1);
806        Ok(())
807    }
808}