1use crate::core::dag::OutputFormat;
7use crate::core::project::{
8 MaterializeConfig, DEFAULT_MAX_ROW_GROUP_BYTES, DEFAULT_MAX_ROW_GROUP_ROWS,
9};
10use crate::testing::{Assertion, StreamingAssertionRunner, ValidationResult};
11use anyhow::{bail, Context, Result};
12use arrow::datatypes::SchemaRef;
13use arrow::record_batch::RecordBatch;
14use datafusion::physical_plan::SendableRecordBatchStream;
15use futures::StreamExt;
16use parquet::arrow::ArrowWriter;
17use parquet::basic::Compression;
18use parquet::file::properties::WriterProperties;
19use std::fs::{self, File};
20use std::io::{BufWriter, Write};
21use std::path::{Path, PathBuf};
22
23#[derive(Debug, Clone)]
25pub struct StreamWriteStats {
26 pub rows: usize,
27 pub batches: usize,
28 pub path: PathBuf,
29 pub bytes_written: u64,
30 pub validation: ValidationResult,
31}
32
33#[derive(Debug, Clone)]
35pub struct MaterializeWriteOptions {
36 pub max_row_group_rows: usize,
37 pub max_row_group_bytes: usize,
38 pub fail_fast_assertions: bool,
40}
41
42impl Default for MaterializeWriteOptions {
43 fn default() -> Self {
44 Self {
45 max_row_group_rows: DEFAULT_MAX_ROW_GROUP_ROWS,
46 max_row_group_bytes: DEFAULT_MAX_ROW_GROUP_BYTES,
47 fail_fast_assertions: true,
48 }
49 }
50}
51
52impl MaterializeWriteOptions {
53 pub fn from_config(cfg: &MaterializeConfig, fail_fast_assertions: bool) -> Self {
54 Self {
55 max_row_group_rows: cfg.max_row_group_rows.max(1),
56 max_row_group_bytes: cfg.max_row_group_bytes.max(1),
57 fail_fast_assertions,
58 }
59 }
60}
61
62fn parquet_props(opts: &MaterializeWriteOptions) -> WriterProperties {
63 WriterProperties::builder()
64 .set_max_row_group_row_count(Some(opts.max_row_group_rows))
65 .set_compression(Compression::SNAPPY)
66 .build()
67}
68
69pub fn partial_path_for(dest: &Path) -> PathBuf {
71 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
72 let name = dest
73 .file_name()
74 .map(|s| s.to_string_lossy().into_owned())
75 .unwrap_or_else(|| "output".into());
76 parent.join(format!(".{name}.rbt-partial"))
77}
78
79fn remove_if_exists(path: &Path) {
80 if path.exists() {
81 let _ = if path.is_dir() {
82 fs::remove_dir_all(path)
83 } else {
84 fs::remove_file(path)
85 };
86 }
87}
88
89pub fn atomic_publish(partial: &Path, dest: &Path) -> Result<()> {
91 if let Some(parent) = dest.parent() {
92 fs::create_dir_all(parent)
93 .with_context(|| format!("E_RBT_MATERIALIZE_IO: mkdir {}", parent.display()))?;
94 }
95 if dest.exists() {
97 if dest.is_dir() {
98 fs::remove_dir_all(dest).with_context(|| {
99 format!(
100 "E_RBT_MATERIALIZE_IO: remove existing dir {}",
101 dest.display()
102 )
103 })?;
104 } else {
105 fs::remove_file(dest).with_context(|| {
106 format!(
107 "E_RBT_MATERIALIZE_IO: remove existing file {}",
108 dest.display()
109 )
110 })?;
111 }
112 }
113 fs::rename(partial, dest).with_context(|| {
114 format!(
115 "E_RBT_MATERIALIZE_ATOMIC: rename {} → {} failed. \
116 Partial file left for inspection if rename partially failed.",
117 partial.display(),
118 dest.display()
119 )
120 })?;
121 Ok(())
122}
123
124pub async fn materialize_stream(
129 mut stream: SendableRecordBatchStream,
130 format: &OutputFormat,
131 destination_path: &Path,
132 opts: &MaterializeWriteOptions,
133 assertions: &[Assertion],
134) -> Result<StreamWriteStats> {
135 match format {
136 OutputFormat::Parquet | OutputFormat::ZeroCopyClone => {
137 write_parquet_stream(&mut stream, destination_path, opts, assertions).await
138 }
139 OutputFormat::Jsonl => {
140 write_line_stream(&mut stream, destination_path, opts, assertions, LineFormat::Jsonl)
141 .await
142 }
143 OutputFormat::Csv => {
144 write_line_stream(&mut stream, destination_path, opts, assertions, LineFormat::Csv)
145 .await
146 }
147 OutputFormat::Iceberg => {
148 write_iceberg_stream(&mut stream, destination_path, opts, assertions).await
149 }
150 OutputFormat::ParquetAndIceberg => {
151 let parquet_path =
155 if destination_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
156 destination_path.to_path_buf()
157 } else {
158 destination_path.with_extension("parquet")
159 };
160 let stats =
161 write_parquet_stream(&mut stream, &parquet_path, opts, assertions).await?;
162 write_iceberg_sidecar_from_parquet(&parquet_path, stats.rows, &stats.path)?;
164 Ok(stats)
165 }
166 }
167}
168
169pub async fn write_parquet_stream(
171 stream: &mut SendableRecordBatchStream,
172 destination_path: &Path,
173 opts: &MaterializeWriteOptions,
174 assertions: &[Assertion],
175) -> Result<StreamWriteStats> {
176 let schema = stream.schema();
177 let partial = partial_path_for(destination_path);
178 remove_if_exists(&partial);
179 if let Some(parent) = partial.parent() {
180 fs::create_dir_all(parent)?;
181 }
182
183 let mut runner = StreamingAssertionRunner::new(assertions, opts.fail_fast_assertions);
184 let props = parquet_props(opts);
185 let file = File::create(&partial).with_context(|| {
186 format!(
187 "E_RBT_MATERIALIZE_IO: create partial parquet {}",
188 partial.display()
189 )
190 })?;
191 let buf = BufWriter::with_capacity(8 * 1024 * 1024, file);
193 let mut writer = ArrowWriter::try_new(buf, schema.clone(), Some(props)).with_context(|| {
194 format!(
195 "E_RBT_MATERIALIZE_PARQUET: ArrowWriter::try_new for {}",
196 partial.display()
197 )
198 })?;
199
200 let mut rows = 0usize;
201 let mut batches = 0usize;
202 let result = async {
203 while let Some(item) = stream.next().await {
204 let batch = item.map_err(|e| {
205 anyhow::anyhow!("E_RBT_MATERIALIZE_STREAM: DataFusion stream error: {e}")
206 })?;
207 if batch.num_rows() == 0 && batch.num_columns() == 0 {
208 continue;
209 }
210 if !runner.is_empty() {
211 runner.observe_batch(&batch).map_err(|e| {
212 anyhow::anyhow!("E_RBT_MATERIALIZE_ASSERT: {e}")
213 })?;
214 }
215 writer.write(&batch).with_context(|| {
216 format!(
217 "E_RBT_MATERIALIZE_PARQUET: write batch #{batches} to {}",
218 partial.display()
219 )
220 })?;
221 rows += batch.num_rows();
222 batches += 1;
223 let in_progress = writer.in_progress_size();
225 if in_progress >= opts.max_row_group_bytes {
226 writer.flush().with_context(|| {
227 format!(
228 "E_RBT_MATERIALIZE_PARQUET: flush row group at {in_progress} bytes"
229 )
230 })?;
231 }
232 }
234 Ok::<(), anyhow::Error>(())
235 }
236 .await;
237
238 if let Err(e) = result {
239 let _ = writer.close();
240 remove_if_exists(&partial);
241 return Err(e);
242 }
243
244 writer.close().with_context(|| {
245 format!(
246 "E_RBT_MATERIALIZE_PARQUET: close writer {}",
247 partial.display()
248 )
249 })?;
250
251 let validation = runner.finish();
252 if validation.failed_assertions > 0 {
253 remove_if_exists(&partial);
254 bail!(
255 "E_RBT_MATERIALIZE_ASSERT: {} assertion(s) failed: {}",
256 validation.failed_assertions,
257 validation.errors.join("; ")
258 );
259 }
260
261 atomic_publish(&partial, destination_path)?;
262 let bytes_written = fs::metadata(destination_path).map(|m| m.len()).unwrap_or(0);
263
264 Ok(StreamWriteStats {
265 rows,
266 batches,
267 path: destination_path.to_path_buf(),
268 bytes_written,
269 validation,
270 })
271}
272
273#[derive(Clone, Copy)]
274enum LineFormat {
275 Jsonl,
276 Csv,
277}
278
279async fn write_line_stream(
280 stream: &mut SendableRecordBatchStream,
281 destination_path: &Path,
282 opts: &MaterializeWriteOptions,
283 assertions: &[Assertion],
284 line_fmt: LineFormat,
285) -> Result<StreamWriteStats> {
286 let partial = partial_path_for(destination_path);
287 remove_if_exists(&partial);
288 if let Some(parent) = partial.parent() {
289 fs::create_dir_all(parent)?;
290 }
291 let file = File::create(&partial).with_context(|| {
292 format!(
293 "E_RBT_MATERIALIZE_IO: create partial {}",
294 partial.display()
295 )
296 })?;
297 let mut runner = StreamingAssertionRunner::new(assertions, opts.fail_fast_assertions);
298 let mut rows = 0usize;
299 let mut batches = 0usize;
300
301 let write_result = async {
302 match line_fmt {
303 LineFormat::Jsonl => {
304 let mut writer = arrow::json::LineDelimitedWriter::new(file);
305 while let Some(item) = stream.next().await {
306 let batch = item.map_err(|e| {
307 anyhow::anyhow!("E_RBT_MATERIALIZE_STREAM: {e}")
308 })?;
309 if !runner.is_empty() {
310 runner.observe_batch(&batch)?;
311 }
312 writer.write(&batch)?;
313 rows += batch.num_rows();
314 batches += 1;
315 }
316 writer.finish()?;
317 }
318 LineFormat::Csv => {
319 let mut writer = arrow::csv::Writer::new(file);
320 while let Some(item) = stream.next().await {
321 let batch = item.map_err(|e| {
322 anyhow::anyhow!("E_RBT_MATERIALIZE_STREAM: {e}")
323 })?;
324 if !runner.is_empty() {
325 runner.observe_batch(&batch)?;
326 }
327 writer.write(&batch)?;
328 rows += batch.num_rows();
329 batches += 1;
330 }
331 }
332 }
333 Ok::<(), anyhow::Error>(())
334 }
335 .await;
336
337 if let Err(e) = write_result {
338 remove_if_exists(&partial);
339 return Err(e);
340 }
341
342 let validation = runner.finish();
343 if validation.failed_assertions > 0 {
344 remove_if_exists(&partial);
345 bail!(
346 "E_RBT_MATERIALIZE_ASSERT: {} assertion(s) failed: {}",
347 validation.failed_assertions,
348 validation.errors.join("; ")
349 );
350 }
351
352 atomic_publish(&partial, destination_path)?;
353 let bytes_written = fs::metadata(destination_path).map(|m| m.len()).unwrap_or(0);
354 Ok(StreamWriteStats {
355 rows,
356 batches,
357 path: destination_path.to_path_buf(),
358 bytes_written,
359 validation,
360 })
361}
362
363async fn write_iceberg_stream(
364 stream: &mut SendableRecordBatchStream,
365 table_root: &Path,
366 opts: &MaterializeWriteOptions,
367 assertions: &[Assertion],
368) -> Result<StreamWriteStats> {
369 let staging = table_root.with_extension("rbt-partial-table");
372 remove_if_exists(&staging);
373 let data_dir = staging.join("data");
374 let meta_dir = staging.join("metadata");
375 fs::create_dir_all(&data_dir)?;
376 fs::create_dir_all(&meta_dir)?;
377 let data_path = data_dir.join("part-00000.parquet");
378
379 let schema = stream.schema();
380 let stats = write_parquet_stream(stream, &data_path, opts, assertions).await?;
381
382 write_iceberg_metadata(&staging, &schema, stats.rows, "part-00000.parquet")?;
383
384 if table_root.exists() {
386 fs::remove_dir_all(table_root).with_context(|| {
387 format!(
388 "E_RBT_MATERIALIZE_IO: clear iceberg table {}",
389 table_root.display()
390 )
391 })?;
392 }
393 if let Some(parent) = table_root.parent() {
394 fs::create_dir_all(parent)?;
395 }
396 fs::rename(&staging, table_root).with_context(|| {
397 format!(
398 "E_RBT_MATERIALIZE_ATOMIC: rename iceberg staging {} → {}",
399 staging.display(),
400 table_root.display()
401 )
402 })?;
403
404 tracing::info!(
405 "Iceberg FS table written (stream): {} ({} rows, data/part-00000.parquet)",
406 table_root.display(),
407 stats.rows
408 );
409
410 Ok(StreamWriteStats {
411 rows: stats.rows,
412 batches: stats.batches,
413 path: table_root.to_path_buf(),
414 bytes_written: stats.bytes_written,
415 validation: stats.validation,
416 })
417}
418
419fn write_iceberg_sidecar_from_parquet(
420 parquet_path: &Path,
421 row_count: usize,
422 _stats_path: &Path,
423) -> Result<()> {
424 let table_root = super::sibling_iceberg_dir(parquet_path);
425 let file = File::open(parquet_path)
427 .with_context(|| format!("open {} for iceberg sidecar", parquet_path.display()))?;
428 let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)
429 .with_context(|| format!("parquet reader {}", parquet_path.display()))?;
430 let schema = builder.schema().clone();
431 if table_root.exists() {
433 fs::remove_dir_all(&table_root)?;
434 }
435 let data_dir = table_root.join("data");
436 let meta_dir = table_root.join("metadata");
437 fs::create_dir_all(&data_dir)?;
438 fs::create_dir_all(&meta_dir)?;
439 let data_name = "part-00000.parquet";
440 fs::copy(parquet_path, data_dir.join(data_name))?;
441 write_iceberg_metadata(&table_root, &schema, row_count, data_name)?;
442 Ok(())
443}
444
445fn write_iceberg_metadata(
446 table_root: &Path,
447 schema: &SchemaRef,
448 total_rows: usize,
449 data_file_name: &str,
450) -> Result<()> {
451 use serde_json::{json, Value};
452 use std::time::{SystemTime, UNIX_EPOCH};
453
454 let meta_dir = table_root.join("metadata");
455 fs::create_dir_all(&meta_dir)?;
456
457 let mut fields = Vec::new();
458 for (i, f) in schema.fields().iter().enumerate() {
459 fields.push(json!({
460 "id": i + 1,
461 "name": f.name(),
462 "required": !f.is_nullable(),
463 "type": arrow_type_to_iceberg_json(f.data_type()),
464 }));
465 }
466
467 let now_ms = SystemTime::now()
468 .duration_since(UNIX_EPOCH)
469 .map(|d| d.as_millis() as u64)
470 .unwrap_or(0);
471 let snapshot_id = now_ms;
472 let location = table_root
473 .canonicalize()
474 .unwrap_or_else(|_| table_root.to_path_buf());
475 let location_uri = format!("file://{}", location.display());
476
477 let metadata = json!({
478 "format-version": 2,
479 "table-uuid": format!("{:032x}", snapshot_id),
480 "location": location_uri,
481 "last-sequence-number": 1,
482 "last-updated-ms": now_ms,
483 "last-column-id": fields.len(),
484 "current-schema-id": 0,
485 "schemas": [{
486 "type": "struct",
487 "schema-id": 0,
488 "fields": fields,
489 }],
490 "default-spec-id": 0,
491 "partition-specs": [{ "spec-id": 0, "fields": [] }],
492 "last-partition-id": 0,
493 "default-sort-order-id": 0,
494 "sort-orders": [{ "order-id": 0, "fields": [] }],
495 "properties": {
496 "rbt.writer": "rbt",
497 "rbt.layout": "filesystem-iceberg-v1",
498 "write.format.default": "parquet",
499 "rbt.materialize": "stream"
500 },
501 "current-snapshot-id": snapshot_id,
502 "snapshots": [{
503 "snapshot-id": snapshot_id,
504 "sequence-number": 1,
505 "timestamp-ms": now_ms,
506 "summary": {
507 "operation": "overwrite",
508 "rbt.added-records": total_rows.to_string(),
509 "rbt.added-data-files": "1",
510 "rbt.data-file": format!("data/{data_file_name}")
511 },
512 "schema-id": 0
513 }],
514 "snapshot-log": [{
515 "timestamp-ms": now_ms,
516 "snapshot-id": snapshot_id
517 }],
518 "metadata-log": [],
519 "rbt": {
520 "note": "Filesystem Iceberg-style table written by rbt (full refresh, stream materialize).",
521 "data_files": [format!("data/{data_file_name}")],
522 "row_count": total_rows
523 }
524 });
525
526 let meta_path = meta_dir.join("v1.metadata.json");
527 let mut meta_file = File::create(&meta_path)?;
528 writeln!(meta_file, "{}", serde_json::to_string_pretty(&metadata)?)?;
529 let mut hint = File::create(meta_dir.join("version-hint.text"))?;
530 writeln!(hint, "1")?;
531 fs::copy(&meta_path, meta_dir.join("metadata.json"))?;
532
533 let _ = data_file_name;
534 Ok(())
535}
536
537fn arrow_type_to_iceberg_json(dt: &arrow::datatypes::DataType) -> serde_json::Value {
538 use arrow::datatypes::DataType;
539 use serde_json::json;
540 match dt {
541 DataType::Boolean => json!("boolean"),
542 DataType::Int32 => json!("int"),
543 DataType::Int64 => json!("long"),
544 DataType::Float32 => json!("float"),
545 DataType::Float64 => json!("double"),
546 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => json!("string"),
547 DataType::Binary | DataType::LargeBinary => json!("binary"),
548 DataType::Date32 | DataType::Date64 => json!("date"),
549 DataType::Timestamp(_, _) => json!("timestamptz"),
550 other => json!(format!("string /* arrow:{:?} */", other)),
551 }
552}
553
554pub fn write_parquet_batches_atomic(
556 batches: &[RecordBatch],
557 path: &Path,
558 opts: &MaterializeWriteOptions,
559) -> Result<usize> {
560 if batches.is_empty() {
561 return Ok(0);
562 }
563 let schema = batches[0].schema();
564 let partial = partial_path_for(path);
565 remove_if_exists(&partial);
566 if let Some(parent) = partial.parent() {
567 fs::create_dir_all(parent)?;
568 }
569 let file = File::create(&partial)?;
570 let buf = BufWriter::with_capacity(8 * 1024 * 1024, file);
571 let props = parquet_props(opts);
572 let mut writer = ArrowWriter::try_new(buf, schema, Some(props))?;
573 let mut rows = 0usize;
574 for batch in batches {
575 writer.write(batch)?;
576 rows += batch.num_rows();
577 if writer.in_progress_size() >= opts.max_row_group_bytes {
578 writer.flush()?;
579 }
580 }
581 writer.close()?;
582 atomic_publish(&partial, path)?;
583 Ok(rows)
584}
585
586pub fn load_parquet_batches(path: &Path) -> Result<Vec<RecordBatch>> {
588 let file = File::open(path)
589 .with_context(|| format!("E_RBT_REF_LOAD: open {} for MemTable", path.display()))?;
590 let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)
591 .with_context(|| format!("E_RBT_REF_LOAD: parquet builder {}", path.display()))?;
592 let reader = builder
593 .build()
594 .with_context(|| format!("E_RBT_REF_LOAD: parquet reader {}", path.display()))?;
595 let mut out = Vec::new();
596 for item in reader {
597 out.push(item.with_context(|| {
598 format!("E_RBT_REF_LOAD: read batch from {}", path.display())
599 })?);
600 }
601 Ok(out)
602}
603
604pub fn write_empty_parquet(schema: SchemaRef, path: &Path, opts: &MaterializeWriteOptions) -> Result<()> {
606 let partial = partial_path_for(path);
607 remove_if_exists(&partial);
608 if let Some(parent) = partial.parent() {
609 fs::create_dir_all(parent)?;
610 }
611 let file = File::create(&partial)?;
612 let props = parquet_props(opts);
613 let writer = ArrowWriter::try_new(file, schema, Some(props))?;
614 writer.close()?;
615 atomic_publish(&partial, path)?;
616 Ok(())
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::testing::Assertion;
623 use arrow::datatypes::{DataType, Field, Schema};
624 use datafusion::prelude::SessionContext;
625 use std::sync::Arc;
626
627 fn sample_schema() -> SchemaRef {
628 Arc::new(Schema::new(vec![
629 Field::new("id", DataType::Int64, false),
630 Field::new("name", DataType::Utf8, true),
631 ]))
632 }
633
634 #[tokio::test]
635 async fn stream_parquet_many_batches_row_count() -> Result<()> {
636 let temp = tempfile::tempdir()?;
637 let dest = temp.path().join("out.parquet");
638 let ctx = SessionContext::new();
639 let df = ctx
641 .sql(
642 "SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e')) \
643 AS t(id, name)",
644 )
645 .await?;
646 let stream = df.execute_stream().await?;
647 let opts = MaterializeWriteOptions {
648 max_row_group_rows: 2,
649 max_row_group_bytes: 1024,
650 fail_fast_assertions: true,
651 };
652 let assertions = vec![Assertion::UniqueKey {
653 columns: vec!["id".into()],
654 }];
655 let mut stream = stream;
656 let stats = write_parquet_stream(&mut stream, &dest, &opts, &assertions).await?;
657 assert_eq!(stats.rows, 5);
658 assert!(dest.exists());
659 assert!(!partial_path_for(&dest).exists());
660 let loaded = load_parquet_batches(&dest)?;
661 let n: usize = loaded.iter().map(|b| b.num_rows()).sum();
662 assert_eq!(n, 5);
663 Ok(())
664 }
665
666 #[tokio::test]
667 async fn stream_unique_failure_removes_partial() -> Result<()> {
668 let temp = tempfile::tempdir()?;
669 let dest = temp.path().join("dup.parquet");
670 let ctx = SessionContext::new();
671 let df = ctx
672 .sql("SELECT * FROM (VALUES (1), (1)) AS t(id)")
673 .await?;
674 let stream = df.execute_stream().await?;
675 let opts = MaterializeWriteOptions::default();
676 let assertions = vec![Assertion::UniqueKey {
677 columns: vec!["id".into()],
678 }];
679 let mut stream = stream;
680 let err = write_parquet_stream(&mut stream, &dest, &opts, &assertions)
681 .await
682 .unwrap_err()
683 .to_string();
684 assert!(
685 err.contains("E_RBT_MATERIALIZE_ASSERT") || err.contains("Duplicate"),
686 "got: {err}"
687 );
688 assert!(!dest.exists(), "failed assert must not publish dest");
689 assert!(
690 !partial_path_for(&dest).exists(),
691 "partial must be cleaned on assert fail"
692 );
693 Ok(())
694 }
695
696 #[test]
697 fn atomic_publish_replaces_existing() -> Result<()> {
698 let temp = tempfile::tempdir()?;
699 let dest = temp.path().join("f.parquet");
700 fs::write(&dest, b"old")?;
701 let partial = partial_path_for(&dest);
702 fs::write(&partial, b"new-data")?;
703 atomic_publish(&partial, &dest)?;
704 assert_eq!(fs::read(&dest)?, b"new-data");
705 assert!(!partial.exists());
706 Ok(())
707 }
708
709 #[test]
710 fn write_empty_parquet_ok() -> Result<()> {
711 let temp = tempfile::tempdir()?;
712 let dest = temp.path().join("empty.parquet");
713 write_empty_parquet(sample_schema(), &dest, &MaterializeWriteOptions::default())?;
714 assert!(dest.exists());
715 let batches = load_parquet_batches(&dest)?;
716 let n: usize = batches.iter().map(|b| b.num_rows()).sum();
717 assert_eq!(n, 0);
718 Ok(())
719 }
720
721}