1use crate::core::frontmatter::{SourceFormat, StagingFrontmatter};
6use crate::core::paths::{resolve_project_path, validate_glob_patterns, PathGlobSet};
7use crate::core::project::{ScanConfig, DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES};
8use anyhow::{anyhow, bail, Context, Result};
9use arrow::array::{ArrayRef, BinaryBuilder, Int64Builder, StringBuilder};
10use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
11use arrow::record_batch::RecordBatch;
12use std::collections::HashMap;
13use std::fs::File;
14use std::io::{BufRead, BufReader, Read};
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18#[derive(Debug, Clone)]
20pub struct ScanRequest {
21 pub project_dir: PathBuf,
22 pub scan_path: String,
23 pub format: SourceFormat,
24 pub paths: Vec<String>,
26 pub toml_rows_key: Option<String>,
28 pub partition_by: Vec<String>,
30 pub require_partitions: std::collections::HashMap<String, String>,
32 pub path_glob: Vec<String>,
34 pub inject_source_path: bool,
36 pub roots: HashMap<String, String>,
38 pub protobuf_max_payload_bytes: u64,
40 pub allow_empty: bool,
42}
43
44impl ScanRequest {
45 pub fn from_frontmatter(
46 project_dir: impl AsRef<Path>,
47 fm: &StagingFrontmatter,
48 ) -> Result<Self> {
49 Self::from_frontmatter_with_roots(project_dir, fm, HashMap::new())
50 }
51
52 pub fn from_frontmatter_with_roots(
53 project_dir: impl AsRef<Path>,
54 fm: &StagingFrontmatter,
55 roots: HashMap<String, String>,
56 ) -> Result<Self> {
57 Self::from_frontmatter_with_config(project_dir, fm, roots, &ScanConfig::default())
58 }
59
60 pub fn from_frontmatter_with_config(
61 project_dir: impl AsRef<Path>,
62 fm: &StagingFrontmatter,
63 roots: HashMap<String, String>,
64 scan_cfg: &ScanConfig,
65 ) -> Result<Self> {
66 let scan_path = fm
67 .scan_path
68 .as_ref()
69 .context(
70 "E_RBT_BRONZE_SCAN_PATH_MISSING: frontmatter.scan_path is required for bronze scan",
71 )?
72 .clone();
73 let format = fm.resolve_format().with_context(|| {
74 format!("E_RBT_BRONZE_FORMAT: cannot resolve source_format for scan_path '{scan_path}'")
75 })?;
76 let path_glob = fm.path_glob.clone().unwrap_or_default();
77 validate_glob_patterns(&path_glob).with_context(|| {
78 format!("E_RBT_PATH_GLOB_INVALID: bad path_glob on scan_path '{scan_path}'")
79 })?;
80 Ok(Self {
81 project_dir: project_dir.as_ref().to_path_buf(),
82 scan_path,
83 format,
84 paths: fm.paths.clone().unwrap_or_default(),
85 toml_rows_key: fm.toml_rows_key.clone(),
86 partition_by: fm.partition_by.clone().unwrap_or_default(),
87 require_partitions: fm.require_partitions.clone().unwrap_or_default(),
88 path_glob,
89 inject_source_path: fm.inject_source_path.unwrap_or(false),
90 roots,
91 protobuf_max_payload_bytes: scan_cfg.protobuf_max_payload_bytes,
92 allow_empty: matches!(
93 fm.on_missing_policy(),
94 crate::core::run_scope::OnMissing::Empty
95 ),
96 })
97 }
98
99 pub fn resolved_path(&self) -> Result<PathBuf> {
100 resolve_project_path(&self.project_dir, &self.scan_path, &self.roots)
101 }
102}
103
104pub struct LakeScanner {
106 pub paths: Vec<String>,
107}
108
109impl LakeScanner {
110 pub fn new(paths: Vec<String>) -> Self {
111 Self { paths }
112 }
113
114 pub fn from_request(req: &ScanRequest) -> Self {
115 Self {
116 paths: req.paths.clone(),
117 }
118 }
119
120 pub fn list_files(&self, req: &ScanRequest) -> Result<(PathBuf, Vec<PathBuf>)> {
122 let root = req.resolved_path().with_context(|| {
123 format!(
124 "E_RBT_BRONZE_PATH: failed resolving scan_path '{}' \
125 (project_dir={}). Check absolute paths and `roots:` templates.",
126 req.scan_path,
127 req.project_dir.display()
128 )
129 })?;
130 if !root.exists() {
131 if req.allow_empty {
132 return Ok((root, Vec::new()));
133 }
134 bail!(
135 "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND: bronze scan_path does not exist: {} \
136 (resolved from '{}'). Hint: verify the lake path and `$root` expansion, \
137 or set on_missing: empty for optional artifact families.",
138 root.display(),
139 req.scan_path
140 );
141 }
142
143 let mut files = Vec::new();
144 collect_files_for_format(&root, req.format, &mut files)?;
145 if !req.require_partitions.is_empty() {
146 files.retain(|f| path_matches_require_partitions(f, &root, &req.require_partitions));
147 }
148 if !req.path_glob.is_empty() {
149 let glob_set = PathGlobSet::compile(&req.path_glob)?;
150 files.retain(|f| glob_set.matches(f, &root));
151 }
152 if files.is_empty() {
153 if req.allow_empty {
154 return Ok((root, Vec::new()));
155 }
156 bail!(
157 "E_RBT_BRONZE_SCAN_EMPTY: no {} files under {} after filters \
158 (require_partitions={:?}, path_glob={:?}). \
159 Hint: path_glob disables DataFusion listing pushdown and uses the \
160 scan→MemTable/spill path; check filename patterns and hive partitions, \
161 or set on_missing: empty for optional artifact families.",
162 req.format.as_str(),
163 root.display(),
164 req.require_partitions,
165 req.path_glob
166 );
167 }
168 Ok((root, files))
169 }
170
171 pub async fn scan(&self, req: &ScanRequest) -> Result<Vec<RecordBatch>> {
175 let (root, files) = self.list_files(req)?;
176 if files.is_empty() {
177 tracing::info!(
178 "Bronze scan: 0 file(s) under {} (format={}, allow_empty={})",
179 root.display(),
180 req.format,
181 req.allow_empty
182 );
183 return Ok(Vec::new());
184 }
185 tracing::info!(
186 "Bronze scan: {} file(s) under {} (format={}, globs={:?})",
187 files.len(),
188 root.display(),
189 req.format,
190 req.path_glob
191 );
192
193 let mut batches = Vec::new();
194 for file_path in files {
195 for batch in self.read_enriched(&file_path, &root, req)? {
196 batches.push(batch);
197 }
198 }
199 Ok(batches)
200 }
201
202 pub fn scan_spill_to_parquet(
207 &self,
208 req: &ScanRequest,
209 dest: &Path,
210 opts: &crate::materializer::MaterializeWriteOptions,
211 ) -> Result<crate::materializer::StreamWriteStats> {
212 use crate::materializer::{atomic_publish, partial_path_for};
213 use parquet::arrow::ArrowWriter;
214 use parquet::basic::Compression;
215 use parquet::file::properties::WriterProperties;
216 use std::fs::{self, File};
217 use std::io::BufWriter;
218
219 let (root, files) = self.list_files(req)?;
220 tracing::info!(
221 "Bronze spill→parquet: {} file(s) under {} → {} (format={})",
222 files.len(),
223 root.display(),
224 dest.display(),
225 req.format
226 );
227
228 let partial = partial_path_for(dest);
229 if partial.exists() {
230 let _ = fs::remove_file(&partial);
231 }
232 if let Some(parent) = partial.parent() {
233 fs::create_dir_all(parent)?;
234 }
235
236 let props = WriterProperties::builder()
237 .set_max_row_group_row_count(Some(opts.max_row_group_rows.max(1)))
238 .set_compression(Compression::SNAPPY)
239 .build();
240
241 let mut writer: Option<ArrowWriter<BufWriter<File>>> = None;
242 let mut schema: Option<SchemaRef> = None;
243 let mut rows = 0usize;
244 let mut batches_n = 0usize;
245
246 let mut write_loop = || -> Result<()> {
247 for file_path in &files {
248 let file_batches = self.read_enriched(file_path, &root, req).with_context(|| {
249 format!(
250 "E_RBT_BRONZE_SPILL: read {} for spill",
251 file_path.display()
252 )
253 })?;
254 for batch in file_batches {
255 if batch.num_rows() == 0 && batch.num_columns() == 0 {
256 continue;
257 }
258 if writer.is_none() {
259 schema = Some(batch.schema());
260 let file = File::create(&partial).with_context(|| {
261 format!(
262 "E_RBT_BRONZE_SPILL: create partial {}",
263 partial.display()
264 )
265 })?;
266 let buf = BufWriter::with_capacity(8 * 1024 * 1024, file);
267 writer = Some(
268 ArrowWriter::try_new(buf, batch.schema(), Some(props.clone()))
269 .with_context(|| {
270 format!(
271 "E_RBT_BRONZE_SPILL: ArrowWriter for {}",
272 partial.display()
273 )
274 })?,
275 );
276 }
277 let w = writer.as_mut().unwrap();
278 w.write(&batch).with_context(|| {
279 format!(
280 "E_RBT_BRONZE_SPILL: write batch from {}",
281 file_path.display()
282 )
283 })?;
284 rows += batch.num_rows();
285 batches_n += 1;
286 if w.in_progress_size() >= opts.max_row_group_bytes {
287 w.flush()?;
288 }
289 }
291 }
292 Ok(())
293 };
294
295 if let Err(e) = write_loop() {
296 if let Some(w) = writer.take() {
297 let _ = w.close();
298 }
299 let _ = fs::remove_file(&partial);
300 return Err(e);
301 }
302
303 if let Some(w) = writer.take() {
304 w.close().with_context(|| {
305 format!("E_RBT_BRONZE_SPILL: close writer {}", partial.display())
306 })?;
307 atomic_publish(&partial, dest)?;
308 } else {
309 let _ = schema;
311 bail!(
312 "E_RBT_BRONZE_SPILL: no batches produced for spill to {} \
313 ({} candidate files)",
314 dest.display(),
315 files.len()
316 );
317 }
318
319 let bytes_written = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
320 tracing::info!(
321 "Bronze spill complete: {} rows, {} batches, {} bytes → {}",
322 rows,
323 batches_n,
324 bytes_written,
325 dest.display()
326 );
327 Ok(crate::materializer::StreamWriteStats {
328 rows,
329 batches: batches_n,
330 path: dest.to_path_buf(),
331 bytes_written,
332 validation: crate::testing::ValidationResult {
333 total_rows: rows,
334 passed_assertions: 0,
335 failed_assertions: 0,
336 errors: Vec::new(),
337 },
338 })
339 }
340
341 fn read_enriched(
342 &self,
343 file_path: &Path,
344 root: &Path,
345 req: &ScanRequest,
346 ) -> Result<Vec<RecordBatch>> {
347 let file_batches = self
348 .read_file(file_path, req)
349 .with_context(|| format!("Failed reading bronze file {}", file_path.display()))?;
350 let mut out = Vec::with_capacity(file_batches.len());
351 for batch in file_batches {
352 let mut batch = if req.partition_by.is_empty() {
353 batch
354 } else {
355 inject_hive_partitions(batch, file_path, root, &req.partition_by)?
356 };
357 if req.inject_source_path {
358 batch = inject_source_path_column(batch, file_path)?;
359 }
360 out.push(batch);
361 }
362 Ok(out)
363 }
364
365 pub async fn scan_path(
368 &self,
369 path: impl AsRef<Path>,
370 schema: SchemaRef,
371 ) -> Result<Vec<RecordBatch>> {
372 let mut files = Vec::new();
373 collect_files(path.as_ref(), &mut files)?;
374 let mut batches = Vec::new();
375
376 for file_path in files {
377 let ext = file_path
378 .extension()
379 .and_then(|e| e.to_str())
380 .unwrap_or("")
381 .to_ascii_lowercase();
382 match ext.as_str() {
383 "parquet" => batches.extend(read_parquet(&file_path, Some(schema.clone()))?),
384 "json" | "jsonl" | "ndjson" => {
385 let bytes = std::fs::read(&file_path)?;
386 let extractor = crate::json::JShiftExtractor::new(self.paths.clone());
387 batches.push(extractor.extract_jsonl(&bytes, schema.clone())?);
388 }
389 "csv" | "tsv" => batches.extend(read_csv(&file_path, Some(schema.clone()))?),
390 "log" | "txt" | "text" | "md" => {
391 batches.push(read_line_oriented(&file_path)?);
392 }
393 "toml" => batches.push(read_toml(&file_path, None)?),
394 "arrow" | "arrows" | "ipc" | "feather" => {
395 batches.extend(read_arrow_ipc_auto(&file_path)?);
396 }
397 _ => {
398 tracing::debug!("Skipping unsupported file: {:?}", file_path);
399 }
400 }
401 }
402 Ok(batches)
403 }
404
405 fn read_file(&self, file_path: &Path, req: &ScanRequest) -> Result<Vec<RecordBatch>> {
406 match req.format {
407 SourceFormat::Parquet => read_parquet(file_path, None),
408 SourceFormat::Protobuf => Ok(vec![read_protobuf_opaque(
409 file_path,
410 req.protobuf_max_payload_bytes,
411 )?]),
412 SourceFormat::Csv => read_csv(file_path, None),
413 SourceFormat::Jsonl | SourceFormat::Json => {
414 if !self.paths.is_empty() {
415 let schema = utf8_schema_from_paths(&self.paths);
416 let bytes = std::fs::read(file_path)?;
417 let extractor = crate::json::JShiftExtractor::new(self.paths.clone());
418 if req.format == SourceFormat::Json {
420 let expanded = expand_json_document_to_jsonl(&bytes)?;
421 Ok(vec![extractor.extract_jsonl(&expanded, schema)?])
422 } else {
423 Ok(vec![extractor.extract_jsonl(&bytes, schema)?])
424 }
425 } else {
426 read_json_arrow(file_path, req.format)
428 }
429 }
430 SourceFormat::ArrowIpc | SourceFormat::ArrowIpcStream => read_arrow_ipc_auto(file_path),
432 SourceFormat::Log | SourceFormat::Txt => Ok(vec![read_line_oriented(file_path)?]),
433 SourceFormat::Toml => Ok(vec![read_toml(file_path, req.toml_rows_key.as_deref())?]),
434 }
435 }
436}
437
438fn utf8_schema_from_paths(paths: &[String]) -> SchemaRef {
439 Arc::new(Schema::new(
440 paths
441 .iter()
442 .map(|p| Field::new(p.as_str(), DataType::Utf8, true))
443 .collect::<Vec<_>>(),
444 ))
445}
446
447fn expand_json_document_to_jsonl(bytes: &[u8]) -> Result<Vec<u8>> {
448 let v: serde_json::Value =
449 serde_json::from_slice(bytes).map_err(|e| anyhow!("Invalid JSON document: {}", e))?;
450 match v {
451 serde_json::Value::Array(items) => {
452 let mut out = Vec::new();
453 for item in items {
454 out.extend(serde_json::to_vec(&item)?);
455 out.push(b'\n');
456 }
457 Ok(out)
458 }
459 other => {
460 let mut out = serde_json::to_vec(&other)?;
461 out.push(b'\n');
462 Ok(out)
463 }
464 }
465}
466
467fn collect_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
468 if path.is_file() {
469 files.push(path.to_path_buf());
470 } else if path.is_dir() {
471 for entry in std::fs::read_dir(path)? {
472 let entry = entry?;
473 collect_files(&entry.path(), files)?;
474 }
475 }
476 Ok(())
477}
478
479fn extension_matches(format: SourceFormat, ext: &str) -> bool {
480 let ext = ext.to_ascii_lowercase();
481 match format {
482 SourceFormat::Jsonl => matches!(ext.as_str(), "jsonl" | "ndjson"),
483 SourceFormat::Json => ext == "json",
484 SourceFormat::Parquet => matches!(ext.as_str(), "parquet" | "pq"),
485 SourceFormat::Csv => matches!(ext.as_str(), "csv" | "tsv"),
486 SourceFormat::ArrowIpc => matches!(ext.as_str(), "arrow" | "arrows" | "ipc" | "feather"),
487 SourceFormat::ArrowIpcStream => {
488 matches!(
489 ext.as_str(),
490 "arrow" | "arrows" | "ipc" | "arrows_stream" | "ipc_stream"
491 )
492 }
493 SourceFormat::Log => ext == "log",
494 SourceFormat::Txt => matches!(ext.as_str(), "txt" | "text" | "md"),
495 SourceFormat::Toml => ext == "toml",
496 SourceFormat::Protobuf => matches!(ext.as_str(), "pb" | "protobuf" | "protobin"),
497 }
498}
499
500fn read_protobuf_opaque(path: &Path, max_bytes: u64) -> Result<RecordBatch> {
505 let meta = std::fs::metadata(path).with_context(|| {
506 format!(
507 "E_RBT_PROTOBUF_IO: cannot stat protobuf file {}",
508 path.display()
509 )
510 })?;
511 let len = meta.len();
512 if len > max_bytes {
513 bail!(
514 "E_RBT_PROTOBUF_TOO_LARGE: file {} is {len} bytes, exceeds \
515 scan.protobuf_max_payload_bytes={max_bytes} (default {} = 1 GiB). \
516 Raise `scan.protobuf_max_payload_bytes` in rbt_project.yml only if intentional.",
517 path.display(),
518 DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES
519 );
520 }
521
522 let mut file = File::open(path)
523 .with_context(|| format!("E_RBT_PROTOBUF_IO: open protobuf file {}", path.display()))?;
524 let mut payload = Vec::with_capacity(len as usize);
525 file.read_to_end(&mut payload)
526 .with_context(|| format!("E_RBT_PROTOBUF_IO: read protobuf file {}", path.display()))?;
527
528 let schema = Arc::new(Schema::new(vec![
529 Field::new("_source_path", DataType::Utf8, false),
530 Field::new("payload", DataType::Binary, false),
531 Field::new("payload_len", DataType::Int64, false),
532 ]));
533
534 let mut path_b = StringBuilder::new();
535 let mut bin_b = BinaryBuilder::new();
536 let mut len_b = Int64Builder::new();
537 path_b.append_value(path.to_string_lossy().as_ref());
538 bin_b.append_value(&payload);
539 len_b.append_value(payload.len() as i64);
540
541 Ok(RecordBatch::try_new(
542 schema,
543 vec![
544 Arc::new(path_b.finish()),
545 Arc::new(bin_b.finish()),
546 Arc::new(len_b.finish()),
547 ],
548 )?)
549}
550
551fn collect_files_for_format(
552 path: &Path,
553 format: SourceFormat,
554 files: &mut Vec<PathBuf>,
555) -> Result<()> {
556 if path.is_file() {
557 files.push(path.to_path_buf());
558 return Ok(());
559 }
560 if path.is_dir() {
561 let mut all = Vec::new();
562 collect_files(path, &mut all)?;
563 for f in all {
564 let ext = f.extension().and_then(|e| e.to_str()).unwrap_or("");
565 if extension_matches(format, ext) {
566 files.push(f);
567 }
568 }
569 return Ok(());
572 }
573 bail!(
574 "scan path is neither file nor directory: {}",
575 path.display()
576 );
577}
578
579fn read_parquet(path: &Path, projection: Option<SchemaRef>) -> Result<Vec<RecordBatch>> {
580 let file = File::open(path)?;
581 let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)?;
582 let reader = if let Some(schema) = projection {
583 let schema_descr = builder.metadata().file_metadata().schema_descr_ptr();
584 let arrow_schema = builder.schema();
585 let mut indices = Vec::new();
586 for field in schema.fields() {
587 if let Some((idx, _)) =
588 parquet::arrow::parquet_column(&schema_descr, arrow_schema, field.name())
589 {
590 indices.push(idx);
591 }
592 }
593 let mask = parquet::arrow::ProjectionMask::leaves(&schema_descr, indices);
594 builder.with_projection(mask).build()?
595 } else {
596 builder.build()?
597 };
598 let mut batches = Vec::new();
599 for batch in reader {
600 batches.push(batch?);
601 }
602 Ok(batches)
603}
604
605fn read_csv(path: &Path, schema: Option<SchemaRef>) -> Result<Vec<RecordBatch>> {
606 let file = File::open(path)?;
607 let mut batches = Vec::new();
608 if let Some(schema) = schema {
609 let reader = arrow::csv::ReaderBuilder::new(schema)
610 .with_header(true)
611 .build(file)?;
612 for batch in reader {
613 batches.push(batch?);
614 }
615 } else {
616 let format = arrow::csv::reader::Format::default().with_header(true);
618 let (inferred, _) = format.infer_schema(File::open(path)?, Some(1024))?;
619 let schema = Arc::new(inferred);
620 let file = File::open(path)?;
621 let reader = arrow::csv::ReaderBuilder::new(schema)
622 .with_header(true)
623 .build(file)?;
624 for batch in reader {
625 batches.push(batch?);
626 }
627 }
628 Ok(batches)
629}
630
631fn read_json_arrow(path: &Path, format: SourceFormat) -> Result<Vec<RecordBatch>> {
632 let data = std::fs::read(path)?;
633 let data = if format == SourceFormat::Json {
634 expand_json_document_to_jsonl(&data)?
635 } else {
636 data
637 };
638 read_json_infer_from_bytes(&data)
639}
640
641fn read_json_infer_from_bytes(data: &[u8]) -> Result<Vec<RecordBatch>> {
642 use arrow::json::reader::infer_json_schema_from_seekable;
643 let mut cursor = std::io::Cursor::new(data);
644 let (schema, _n) = infer_json_schema_from_seekable(&mut cursor, Some(1024))?;
645 cursor.set_position(0);
646 let schema = Arc::new(schema);
647 let reader = arrow::json::ReaderBuilder::new(schema).build(cursor)?;
648 let mut batches = Vec::new();
649 for batch in reader {
650 batches.push(batch?);
651 }
652 Ok(batches)
653}
654
655fn read_arrow_ipc_file(path: &Path) -> Result<Vec<RecordBatch>> {
656 let file = File::open(path)?;
657 let reader = arrow::ipc::reader::FileReader::try_new(file, None)?;
658 let mut batches = Vec::new();
659 for batch in reader {
660 batches.push(batch?);
661 }
662 Ok(batches)
663}
664
665fn read_arrow_ipc_stream(path: &Path) -> Result<Vec<RecordBatch>> {
666 let file = File::open(path)?;
667 let reader = arrow::ipc::reader::StreamReader::try_new(file, None)?;
668 let mut batches = Vec::new();
669 for batch in reader {
670 batches.push(batch?);
671 }
672 Ok(batches)
673}
674
675fn read_arrow_ipc_auto(path: &Path) -> Result<Vec<RecordBatch>> {
677 match read_arrow_ipc_file(path) {
678 Ok(batches) => Ok(batches),
679 Err(file_err) => read_arrow_ipc_stream(path).with_context(|| {
680 format!(
681 "Arrow IPC file and stream readers both failed for {} (file error: {file_err})",
682 path.display()
683 )
684 }),
685 }
686}
687
688pub fn parse_hive_partitions(
690 file: &Path,
691 root: &Path,
692) -> std::collections::HashMap<String, String> {
693 let mut out = std::collections::HashMap::new();
694 let rel = file.strip_prefix(root).unwrap_or(file);
695 for comp in rel.components() {
696 if let std::path::Component::Normal(os) = comp {
697 let s = os.to_string_lossy();
698 if let Some((k, v)) = s.split_once('=') {
699 if !k.is_empty() {
700 out.insert(k.to_string(), v.to_string());
701 }
702 }
703 }
704 }
705 out
706}
707
708fn path_matches_require_partitions(
709 file: &Path,
710 root: &Path,
711 require: &std::collections::HashMap<String, String>,
712) -> bool {
713 if require.is_empty() {
714 return true;
715 }
716 let parts = parse_hive_partitions(file, root);
717 require
718 .iter()
719 .all(|(k, v)| parts.get(k).map(|pv| pv == v).unwrap_or(false))
720}
721
722fn inject_hive_partitions(
724 batch: RecordBatch,
725 file: &Path,
726 root: &Path,
727 partition_by: &[String],
728) -> Result<RecordBatch> {
729 if partition_by.is_empty() {
730 return Ok(batch);
731 }
732 let parts = parse_hive_partitions(file, root);
733 let n = batch.num_rows();
734 let mut fields: Vec<arrow::datatypes::Field> = batch
735 .schema()
736 .fields()
737 .iter()
738 .map(|f| f.as_ref().clone())
739 .collect();
740 let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
741
742 for key in partition_by {
743 if batch.schema().index_of(key).is_ok() {
744 continue;
746 }
747 let val = parts.get(key).map(|s| s.as_str());
748 let mut b = StringBuilder::with_capacity(n, n * 8);
749 for _ in 0..n {
750 match val {
751 Some(v) => b.append_value(v),
752 None => b.append_null(),
753 }
754 }
755 fields.push(Field::new(key.as_str(), DataType::Utf8, true));
756 columns.push(Arc::new(b.finish()) as ArrayRef);
757 }
758
759 let schema = Arc::new(Schema::new(fields));
760 Ok(RecordBatch::try_new(schema, columns)?)
761}
762
763fn inject_source_path_column(batch: RecordBatch, file: &Path) -> Result<RecordBatch> {
765 if batch.schema().index_of("_source_path").is_ok() {
766 return Ok(batch);
767 }
768 let n = batch.num_rows();
769 let path_str = file.to_string_lossy();
770 let mut b = StringBuilder::with_capacity(n, n * path_str.len().max(16));
771 for _ in 0..n {
772 b.append_value(path_str.as_ref());
773 }
774 let mut fields: Vec<Field> = batch
775 .schema()
776 .fields()
777 .iter()
778 .map(|f| f.as_ref().clone())
779 .collect();
780 fields.push(Field::new("_source_path", DataType::Utf8, false));
781 let mut columns = batch.columns().to_vec();
782 columns.push(Arc::new(b.finish()) as ArrayRef);
783 Ok(RecordBatch::try_new(
784 Arc::new(Schema::new(fields)),
785 columns,
786 )?)
787}
788
789fn read_line_oriented(path: &Path) -> Result<RecordBatch> {
792 let file = File::open(path)?;
793 let reader = BufReader::new(file);
794 let mut line_nos = Int64Builder::new();
795 let mut contents = StringBuilder::new();
796 let mut n: i64 = 0;
797 for line in reader.lines() {
798 let line = line?;
799 n += 1;
800 line_nos.append_value(n);
801 contents.append_value(line);
802 }
803 let schema = Arc::new(Schema::new(vec![
804 Field::new("line_no", DataType::Int64, false),
805 Field::new("content", DataType::Utf8, false),
806 ]));
807 Ok(RecordBatch::try_new(
808 schema,
809 vec![
810 Arc::new(line_nos.finish()) as ArrayRef,
811 Arc::new(contents.finish()) as ArrayRef,
812 ],
813 )?)
814}
815
816fn read_toml(path: &Path, rows_key: Option<&str>) -> Result<RecordBatch> {
817 let text = std::fs::read_to_string(path)?;
818 let value: toml::Value = text
819 .parse()
820 .with_context(|| format!("Invalid TOML: {}", path.display()))?;
821
822 let rows: Vec<toml::map::Map<String, toml::Value>> = match &value {
823 toml::Value::Table(table) => {
824 if let Some(key) = rows_key {
825 match table.get(key) {
826 Some(toml::Value::Array(arr)) => array_of_tables(arr)?,
827 other => bail!(
828 "toml_rows_key '{}' is not an array of tables (got {:?})",
829 key,
830 other.map(|v| v.type_str())
831 ),
832 }
833 } else if let Some((_k, toml::Value::Array(arr))) = table
834 .iter()
835 .find(|(_, v)| matches!(v, toml::Value::Array(a) if a.iter().all(|x| x.is_table())))
836 {
837 array_of_tables(arr)?
838 } else {
839 vec![table.clone()]
841 }
842 }
843 toml::Value::Array(arr) => array_of_tables(arr)?,
844 other => bail!("Unsupported top-level TOML type: {}", other.type_str()),
845 };
846
847 if rows.is_empty() {
848 let schema = Arc::new(Schema::new(vec![Field::new("empty", DataType::Utf8, true)]));
849 return Ok(RecordBatch::try_new(
850 schema,
851 vec![Arc::new(StringBuilder::new().finish()) as ArrayRef],
852 )?);
853 }
854
855 let mut col_names: Vec<String> = Vec::new();
857 for row in &rows {
858 for k in row.keys() {
859 if !col_names.iter().any(|c| c == k) {
860 col_names.push(k.clone());
861 }
862 }
863 }
864
865 let mut builders: Vec<StringBuilder> = col_names
866 .iter()
867 .map(|_| StringBuilder::with_capacity(rows.len(), rows.len() * 16))
868 .collect();
869
870 for row in &rows {
871 for (i, col) in col_names.iter().enumerate() {
872 match row.get(col) {
873 Some(v) => builders[i].append_value(toml_value_to_string(v)),
874 None => builders[i].append_null(),
875 }
876 }
877 }
878
879 let fields: Vec<Field> = col_names
880 .iter()
881 .map(|c| Field::new(c.as_str(), DataType::Utf8, true))
882 .collect();
883 let schema = Arc::new(Schema::new(fields));
884 let arrays: Vec<ArrayRef> = builders
885 .into_iter()
886 .map(|mut b| Arc::new(b.finish()) as ArrayRef)
887 .collect();
888 Ok(RecordBatch::try_new(schema, arrays)?)
889}
890
891fn array_of_tables(arr: &[toml::Value]) -> Result<Vec<toml::map::Map<String, toml::Value>>> {
892 let mut rows = Vec::with_capacity(arr.len());
893 for (i, item) in arr.iter().enumerate() {
894 match item {
895 toml::Value::Table(t) => rows.push(t.clone()),
896 other => bail!(
897 "TOML array element {} is not a table (got {})",
898 i,
899 other.type_str()
900 ),
901 }
902 }
903 Ok(rows)
904}
905
906fn toml_value_to_string(v: &toml::Value) -> String {
907 match v {
908 toml::Value::String(s) => s.clone(),
909 toml::Value::Integer(i) => i.to_string(),
910 toml::Value::Float(f) => f.to_string(),
911 toml::Value::Boolean(b) => b.to_string(),
912 toml::Value::Datetime(d) => d.to_string(),
913 other => other.to_string(),
914 }
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920 use arrow::array::{Array, Int64Array, StringArray};
921 use arrow::datatypes::{DataType, Field, Schema};
922 use std::sync::Arc;
923
924 #[tokio::test]
925 async fn test_lake_scanner_multi_format() -> Result<()> {
926 let temp_dir = tempfile::tempdir()?;
927 let dir_path = temp_dir.path();
928
929 let schema = Arc::new(Schema::new(vec![
930 Field::new("id", DataType::Int64, true),
931 Field::new("name", DataType::Utf8, true),
932 ]));
933
934 std::fs::write(
935 dir_path.join("file1.jsonl"),
936 b"{\"id\": 1, \"name\": \"Alice\"}\n{\"id\": 2, \"name\": \"Bob\"}\n",
937 )?;
938 std::fs::write(dir_path.join("file2.csv"), b"id,name\n3,Charlie\n4,Dave\n")?;
939
940 let batch = RecordBatch::try_new(
941 schema.clone(),
942 vec![
943 Arc::new(Int64Array::from(vec![5, 6])),
944 Arc::new(StringArray::from(vec!["Eve", "Frank"])),
945 ],
946 )?;
947 let file = File::create(dir_path.join("file3.parquet"))?;
948 let mut writer =
949 parquet::arrow::arrow_writer::ArrowWriter::try_new(file, schema.clone(), None)?;
950 writer.write(&batch)?;
951 writer.close()?;
952
953 std::fs::write(dir_path.join("app.log"), "info boot\nwarn disk\n")?;
954 std::fs::write(
955 dir_path.join("meta.toml"),
956 r#"
957[[records]]
958id = "1"
959name = "toml_a"
960[[records]]
961id = "2"
962name = "toml_b"
963"#,
964 )?;
965
966 let scanner = LakeScanner::new(vec!["id".to_string(), "name".to_string()]);
967 let batches = scanner.scan_path(dir_path, schema.clone()).await?;
968 let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
969 assert_eq!(total_rows, 10);
971 Ok(())
972 }
973
974 #[tokio::test]
975 async fn test_scan_request_jsonl() -> Result<()> {
976 let temp = tempfile::tempdir()?;
977 let path = temp.path().join("trades.jsonl");
978 std::fs::write(
979 &path,
980 r#"{"ticker":"NVDA","price":1.0}
981{"ticker":"AAPL","price":2.0}
982"#,
983 )?;
984
985 let fm = StagingFrontmatter {
986 source_format: Some(SourceFormat::Jsonl),
987 scan_path: Some(path.file_name().unwrap().to_string_lossy().into()),
988 ..Default::default()
989 };
990 let req = ScanRequest::from_frontmatter(temp.path(), &fm)?;
991 let scanner = LakeScanner::from_request(&req);
992 let batches = scanner.scan(&req).await?;
993 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
994 assert_eq!(rows, 2);
995 Ok(())
996 }
997
998 #[tokio::test]
999 async fn test_hive_partition_inject_and_filter() -> Result<()> {
1000 let temp = tempfile::tempdir()?;
1001 let root = temp.path();
1002 let dir = root.join("symbol=NVDA").join("timeframe=1m");
1003 std::fs::create_dir_all(&dir)?;
1004 let schema = Arc::new(Schema::new(vec![Field::new(
1006 "close",
1007 DataType::Float64,
1008 true,
1009 )]));
1010 let batch = RecordBatch::try_new(
1011 schema,
1012 vec![Arc::new(arrow::array::Float64Array::from(vec![1.0, 2.0])) as ArrayRef],
1013 )?;
1014 let path = dir.join("chunk.arrow");
1015 {
1016 let file = File::create(&path)?;
1017 let mut writer = arrow::ipc::writer::StreamWriter::try_new(file, &batch.schema())?;
1018 writer.write(&batch)?;
1019 writer.finish()?;
1020 }
1021 let other = root.join("symbol=AAPL").join("timeframe=1d");
1023 std::fs::create_dir_all(&other)?;
1024 {
1025 let file = File::create(other.join("chunk.arrow"))?;
1026 let mut writer = arrow::ipc::writer::StreamWriter::try_new(file, &batch.schema())?;
1027 writer.write(&batch)?;
1028 writer.finish()?;
1029 }
1030
1031 let mut require = std::collections::HashMap::new();
1032 require.insert("timeframe".into(), "1m".into());
1033 let fm = StagingFrontmatter {
1034 source_format: Some(SourceFormat::ArrowIpcStream),
1035 scan_path: Some(".".into()),
1036 partition_by: Some(vec!["symbol".into(), "timeframe".into()]),
1037 require_partitions: Some(require),
1038 ..Default::default()
1039 };
1040 let req = ScanRequest::from_frontmatter(root, &fm)?;
1041 let scanner = LakeScanner::from_request(&req);
1042 let batches = scanner.scan(&req).await?;
1043 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1044 assert_eq!(rows, 2);
1045 let schema = batches[0].schema();
1046 assert!(schema.index_of("timeframe").is_ok());
1047 assert!(schema.index_of("symbol").is_ok());
1048 Ok(())
1049 }
1050
1051 #[test]
1052 fn test_line_oriented_and_toml() -> Result<()> {
1053 let temp = tempfile::tempdir()?;
1054 let txt = temp.path().join("llms.txt");
1055 std::fs::write(&txt, "# Title\n\nSome doc line\n")?;
1056 let batch = read_line_oriented(&txt)?;
1057 assert_eq!(batch.num_rows(), 3);
1058 assert_eq!(batch.schema().field(0).name(), "line_no");
1059 assert_eq!(batch.schema().field(1).name(), "content");
1060
1061 let toml_path = temp.path().join("cfg.toml");
1062 std::fs::write(
1063 &toml_path,
1064 r#"
1065[[items]]
1066k = "a"
1067[[items]]
1068k = "b"
1069"#,
1070 )?;
1071 let tbatch = read_toml(&toml_path, Some("items"))?;
1072 assert_eq!(tbatch.num_rows(), 2);
1073 let col = tbatch
1074 .column(0)
1075 .as_any()
1076 .downcast_ref::<StringArray>()
1077 .unwrap();
1078 assert!(col.value(0) == "a" || col.value(1) == "a");
1079 Ok(())
1080 }
1081
1082 #[tokio::test]
1083 async fn test_path_glob_filters_artifacts() -> Result<()> {
1084 let temp = tempfile::tempdir()?;
1085 let root = temp.path();
1086 let d1 = root
1088 .join("domain=x.com")
1089 .join("report_date=2026-07-29")
1090 .join("run_id=r1")
1091 .join("raw_snoop");
1092 std::fs::create_dir_all(&d1)?;
1093 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1095 for (name, ids) in [
1096 ("crawlplan.parquet", vec![1i64]),
1097 ("other.parquet", vec![9i64]),
1098 ] {
1099 let batch =
1100 RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(ids))])?;
1101 let f = File::create(d1.join(name))?;
1102 let mut w =
1103 parquet::arrow::arrow_writer::ArrowWriter::try_new(f, schema.clone(), None)?;
1104 w.write(&batch)?;
1105 w.close()?;
1106 }
1107
1108 let fm = StagingFrontmatter {
1109 source_format: Some(SourceFormat::Parquet),
1110 scan_path: Some(".".into()),
1111 path_glob: Some(vec!["**/crawlplan.parquet".into()]),
1112 partition_by: Some(vec!["domain".into()]),
1113 inject_source_path: Some(true),
1114 ..Default::default()
1115 };
1116 let req = ScanRequest::from_frontmatter(root, &fm)?;
1117 let scanner = LakeScanner::from_request(&req);
1118 let batches = scanner.scan(&req).await?;
1119 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1120 assert_eq!(rows, 1, "only crawlplan.parquet rows");
1121 assert!(batches[0].schema().index_of("domain").is_ok());
1122 Ok(())
1123 }
1124
1125 #[tokio::test]
1126 async fn test_protobuf_opaque_scan() -> Result<()> {
1127 let temp = tempfile::tempdir()?;
1128 let pb = temp.path().join("msg.pb");
1129 std::fs::write(&pb, b"\x08\x96\x01")?; let fm = StagingFrontmatter {
1131 source_format: Some(SourceFormat::Protobuf),
1132 scan_path: Some(pb.file_name().unwrap().to_string_lossy().into()),
1133 ..Default::default()
1134 };
1135 let req = ScanRequest::from_frontmatter(temp.path(), &fm)?;
1136 assert_eq!(
1137 req.protobuf_max_payload_bytes,
1138 DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES
1139 );
1140 let scanner = LakeScanner::from_request(&req);
1141 let batches = scanner.scan(&req).await?;
1142 assert_eq!(batches.len(), 1);
1143 assert_eq!(batches[0].num_rows(), 1);
1144 assert_eq!(batches[0].schema().field(0).name(), "_source_path");
1145 assert_eq!(batches[0].schema().field(1).name(), "payload");
1146 Ok(())
1147 }
1148
1149 #[tokio::test]
1150 async fn test_protobuf_respects_max_payload_bytes() -> Result<()> {
1151 let temp = tempfile::tempdir()?;
1152 let pb = temp.path().join("big.pb");
1153 std::fs::write(&pb, vec![0u8; 64])?;
1154 let fm = StagingFrontmatter {
1155 source_format: Some(SourceFormat::Protobuf),
1156 scan_path: Some("big.pb".into()),
1157 ..Default::default()
1158 };
1159 let scan_cfg = ScanConfig {
1160 protobuf_max_payload_bytes: 16,
1161 ..Default::default()
1162 };
1163 let req = ScanRequest::from_frontmatter_with_config(
1164 temp.path(),
1165 &fm,
1166 HashMap::new(),
1167 &scan_cfg,
1168 )?;
1169 let scanner = LakeScanner::from_request(&req);
1170 let err = scanner.scan(&req).await.unwrap_err();
1171 let full = format!("{err:#}");
1173 assert!(
1174 full.contains("E_RBT_PROTOBUF_TOO_LARGE"),
1175 "expected size-cap error in chain, got: {full}"
1176 );
1177 assert!(full.contains("protobuf_max_payload_bytes"));
1178 Ok(())
1179 }
1180
1181 #[tokio::test]
1182 async fn test_path_glob_single_star_is_not_recursive() -> Result<()> {
1183 let temp = tempfile::tempdir()?;
1184 let root = temp.path();
1185 let deep = root.join("a").join("b").join("raw");
1186 std::fs::create_dir_all(&deep)?;
1187 let shallow = root.join("raw");
1188 std::fs::create_dir_all(&shallow)?;
1189 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1190 for (dir, id) in [(&deep, 1i64), (&shallow, 2i64)] {
1191 let batch =
1192 RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![id]))])?;
1193 let f = File::create(dir.join("crawlplan.parquet"))?;
1194 let mut w =
1195 parquet::arrow::arrow_writer::ArrowWriter::try_new(f, schema.clone(), None)?;
1196 w.write(&batch)?;
1197 w.close()?;
1198 }
1199 let fm = StagingFrontmatter {
1200 source_format: Some(SourceFormat::Parquet),
1201 scan_path: Some(".".into()),
1202 path_glob: Some(vec!["*/crawlplan.parquet".into()]),
1204 ..Default::default()
1205 };
1206 let req = ScanRequest::from_frontmatter(root, &fm)?;
1207 let scanner = LakeScanner::from_request(&req);
1208 let batches = scanner.scan(&req).await?;
1209 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1210 assert_eq!(rows, 1, "single-star glob must not match deep hive path");
1211 Ok(())
1212 }
1213
1214 #[tokio::test]
1215 async fn test_absolute_scan_path() -> Result<()> {
1216 let temp = tempfile::tempdir()?;
1217 let abs = temp.path().join("abs.jsonl");
1218 std::fs::write(&abs, b"{\"a\":1}\n{\"a\":2}\n")?;
1219 let fm = StagingFrontmatter {
1220 source_format: Some(SourceFormat::Jsonl),
1221 scan_path: Some(abs.to_string_lossy().into()),
1222 ..Default::default()
1223 };
1224 let proj = tempfile::tempdir()?;
1226 let req = ScanRequest::from_frontmatter(proj.path(), &fm)?;
1227 let scanner = LakeScanner::from_request(&req);
1228 let batches = scanner.scan(&req).await?;
1229 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
1230 Ok(())
1231 }
1232}