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 pub iceberg_mode: crate::core::project::IcebergWriteMode,
42 pub iceberg_namespace: String,
43}
44
45impl Default for MaterializeWriteOptions {
46 fn default() -> Self {
47 Self {
48 max_row_group_rows: DEFAULT_MAX_ROW_GROUP_ROWS,
49 max_row_group_bytes: DEFAULT_MAX_ROW_GROUP_BYTES,
50 fail_fast_assertions: true,
51 iceberg_mode: crate::core::project::IcebergWriteMode::Catalog,
52 iceberg_namespace: "rbt".into(),
53 }
54 }
55}
56
57impl MaterializeWriteOptions {
58 pub fn from_config(cfg: &MaterializeConfig, fail_fast_assertions: bool) -> Self {
59 Self {
60 max_row_group_rows: cfg.max_row_group_rows.max(1),
61 max_row_group_bytes: cfg.max_row_group_bytes.max(1),
62 fail_fast_assertions,
63 iceberg_mode: cfg.iceberg.mode,
64 iceberg_namespace: cfg.iceberg.namespace.clone(),
65 }
66 }
67}
68
69fn parquet_props(opts: &MaterializeWriteOptions) -> WriterProperties {
70 WriterProperties::builder()
71 .set_max_row_group_row_count(Some(opts.max_row_group_rows))
72 .set_compression(Compression::SNAPPY)
73 .build()
74}
75
76pub fn partial_path_for(dest: &Path) -> PathBuf {
78 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
79 let name = dest
80 .file_name()
81 .map(|s| s.to_string_lossy().into_owned())
82 .unwrap_or_else(|| "output".into());
83 parent.join(format!(".{name}.rbt-partial"))
84}
85
86fn remove_if_exists(path: &Path) {
87 if path.exists() {
88 let _ = if path.is_dir() {
89 fs::remove_dir_all(path)
90 } else {
91 fs::remove_file(path)
92 };
93 }
94}
95
96pub fn atomic_publish(partial: &Path, dest: &Path) -> Result<()> {
98 if let Some(parent) = dest.parent() {
99 fs::create_dir_all(parent)
100 .with_context(|| format!("E_RBT_MATERIALIZE_IO: mkdir {}", parent.display()))?;
101 }
102 if dest.exists() {
104 if dest.is_dir() {
105 fs::remove_dir_all(dest).with_context(|| {
106 format!(
107 "E_RBT_MATERIALIZE_IO: remove existing dir {}",
108 dest.display()
109 )
110 })?;
111 } else {
112 fs::remove_file(dest).with_context(|| {
113 format!(
114 "E_RBT_MATERIALIZE_IO: remove existing file {}",
115 dest.display()
116 )
117 })?;
118 }
119 }
120 fs::rename(partial, dest).with_context(|| {
121 format!(
122 "E_RBT_MATERIALIZE_ATOMIC: rename {} → {} failed. \
123 Partial file left for inspection if rename partially failed.",
124 partial.display(),
125 dest.display()
126 )
127 })?;
128 Ok(())
129}
130
131pub async fn materialize_stream(
136 mut stream: SendableRecordBatchStream,
137 format: &OutputFormat,
138 destination_path: &Path,
139 opts: &MaterializeWriteOptions,
140 assertions: &[Assertion],
141) -> Result<StreamWriteStats> {
142 match format {
143 OutputFormat::Parquet | OutputFormat::ZeroCopyClone => {
144 write_parquet_stream(&mut stream, destination_path, opts, assertions).await
145 }
146 OutputFormat::Jsonl => {
147 write_line_stream(&mut stream, destination_path, opts, assertions, LineFormat::Jsonl)
148 .await
149 }
150 OutputFormat::Csv => {
151 write_line_stream(&mut stream, destination_path, opts, assertions, LineFormat::Csv)
152 .await
153 }
154 OutputFormat::Iceberg => match opts.iceberg_mode {
155 crate::core::project::IcebergWriteMode::Catalog => {
156 crate::materializer::iceberg_catalog::write_iceberg_catalog_stream(
157 &mut stream,
158 destination_path,
159 &crate::materializer::iceberg_catalog::IcebergCatalogOptions {
160 namespace: opts.iceberg_namespace.clone(),
161 warehouse: Some(destination_path.to_path_buf()),
162 },
163 opts,
164 assertions,
165 )
166 .await
167 }
168 crate::core::project::IcebergWriteMode::Filesystem => {
169 write_iceberg_stream(&mut stream, destination_path, opts, assertions).await
170 }
171 },
172 OutputFormat::ParquetAndIceberg => {
173 let parquet_path =
177 if destination_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
178 destination_path.to_path_buf()
179 } else {
180 destination_path.with_extension("parquet")
181 };
182 let stats =
183 write_parquet_stream(&mut stream, &parquet_path, opts, assertions).await?;
184 write_iceberg_sidecar_from_parquet(&parquet_path, stats.rows, &stats.path)?;
186 Ok(stats)
187 }
188 }
189}
190
191pub async fn write_parquet_stream(
193 stream: &mut SendableRecordBatchStream,
194 destination_path: &Path,
195 opts: &MaterializeWriteOptions,
196 assertions: &[Assertion],
197) -> Result<StreamWriteStats> {
198 let schema = stream.schema();
199 let partial = partial_path_for(destination_path);
200 remove_if_exists(&partial);
201 if let Some(parent) = partial.parent() {
202 fs::create_dir_all(parent)?;
203 }
204
205 let mut runner = StreamingAssertionRunner::new(assertions, opts.fail_fast_assertions);
206 let props = parquet_props(opts);
207 let file = File::create(&partial).with_context(|| {
208 format!(
209 "E_RBT_MATERIALIZE_IO: create partial parquet {}",
210 partial.display()
211 )
212 })?;
213 let buf = BufWriter::with_capacity(8 * 1024 * 1024, file);
215 let mut writer = ArrowWriter::try_new(buf, schema.clone(), Some(props)).with_context(|| {
216 format!(
217 "E_RBT_MATERIALIZE_PARQUET: ArrowWriter::try_new for {}",
218 partial.display()
219 )
220 })?;
221
222 let mut rows = 0usize;
223 let mut batches = 0usize;
224 let result = async {
225 while let Some(item) = stream.next().await {
226 let batch = item.map_err(|e| {
227 anyhow::anyhow!("E_RBT_MATERIALIZE_STREAM: DataFusion stream error: {e}")
228 })?;
229 if batch.num_rows() == 0 && batch.num_columns() == 0 {
230 continue;
231 }
232 if !runner.is_empty() {
233 runner.observe_batch(&batch).map_err(|e| {
234 anyhow::anyhow!("E_RBT_MATERIALIZE_ASSERT: {e}")
235 })?;
236 }
237 writer.write(&batch).with_context(|| {
238 format!(
239 "E_RBT_MATERIALIZE_PARQUET: write batch #{batches} to {}",
240 partial.display()
241 )
242 })?;
243 rows += batch.num_rows();
244 batches += 1;
245 let in_progress = writer.in_progress_size();
247 if in_progress >= opts.max_row_group_bytes {
248 writer.flush().with_context(|| {
249 format!(
250 "E_RBT_MATERIALIZE_PARQUET: flush row group at {in_progress} bytes"
251 )
252 })?;
253 }
254 }
256 Ok::<(), anyhow::Error>(())
257 }
258 .await;
259
260 if let Err(e) = result {
261 let _ = writer.close();
262 remove_if_exists(&partial);
263 return Err(e);
264 }
265
266 writer.close().with_context(|| {
267 format!(
268 "E_RBT_MATERIALIZE_PARQUET: close writer {}",
269 partial.display()
270 )
271 })?;
272
273 let validation = runner.finish();
274 if validation.failed_assertions > 0 {
275 remove_if_exists(&partial);
276 bail!(
277 "E_RBT_MATERIALIZE_ASSERT: {} assertion(s) failed: {}",
278 validation.failed_assertions,
279 validation.errors.join("; ")
280 );
281 }
282
283 atomic_publish(&partial, destination_path)?;
284 let bytes_written = fs::metadata(destination_path).map(|m| m.len()).unwrap_or(0);
285
286 Ok(StreamWriteStats {
287 rows,
288 batches,
289 path: destination_path.to_path_buf(),
290 bytes_written,
291 validation,
292 })
293}
294
295#[derive(Clone, Copy)]
296enum LineFormat {
297 Jsonl,
298 Csv,
299}
300
301async fn write_line_stream(
302 stream: &mut SendableRecordBatchStream,
303 destination_path: &Path,
304 opts: &MaterializeWriteOptions,
305 assertions: &[Assertion],
306 line_fmt: LineFormat,
307) -> Result<StreamWriteStats> {
308 let partial = partial_path_for(destination_path);
309 remove_if_exists(&partial);
310 if let Some(parent) = partial.parent() {
311 fs::create_dir_all(parent)?;
312 }
313 let file = File::create(&partial).with_context(|| {
314 format!(
315 "E_RBT_MATERIALIZE_IO: create partial {}",
316 partial.display()
317 )
318 })?;
319 let mut runner = StreamingAssertionRunner::new(assertions, opts.fail_fast_assertions);
320 let mut rows = 0usize;
321 let mut batches = 0usize;
322
323 let write_result = async {
324 match line_fmt {
325 LineFormat::Jsonl => {
326 let mut writer = arrow::json::LineDelimitedWriter::new(file);
327 while let Some(item) = stream.next().await {
328 let batch = item.map_err(|e| {
329 anyhow::anyhow!("E_RBT_MATERIALIZE_STREAM: {e}")
330 })?;
331 if !runner.is_empty() {
332 runner.observe_batch(&batch)?;
333 }
334 writer.write(&batch)?;
335 rows += batch.num_rows();
336 batches += 1;
337 }
338 writer.finish()?;
339 }
340 LineFormat::Csv => {
341 let mut writer = arrow::csv::Writer::new(file);
342 while let Some(item) = stream.next().await {
343 let batch = item.map_err(|e| {
344 anyhow::anyhow!("E_RBT_MATERIALIZE_STREAM: {e}")
345 })?;
346 if !runner.is_empty() {
347 runner.observe_batch(&batch)?;
348 }
349 writer.write(&batch)?;
350 rows += batch.num_rows();
351 batches += 1;
352 }
353 }
354 }
355 Ok::<(), anyhow::Error>(())
356 }
357 .await;
358
359 if let Err(e) = write_result {
360 remove_if_exists(&partial);
361 return Err(e);
362 }
363
364 let validation = runner.finish();
365 if validation.failed_assertions > 0 {
366 remove_if_exists(&partial);
367 bail!(
368 "E_RBT_MATERIALIZE_ASSERT: {} assertion(s) failed: {}",
369 validation.failed_assertions,
370 validation.errors.join("; ")
371 );
372 }
373
374 atomic_publish(&partial, destination_path)?;
375 let bytes_written = fs::metadata(destination_path).map(|m| m.len()).unwrap_or(0);
376 Ok(StreamWriteStats {
377 rows,
378 batches,
379 path: destination_path.to_path_buf(),
380 bytes_written,
381 validation,
382 })
383}
384
385async fn write_iceberg_stream(
386 stream: &mut SendableRecordBatchStream,
387 table_root: &Path,
388 opts: &MaterializeWriteOptions,
389 assertions: &[Assertion],
390) -> Result<StreamWriteStats> {
391 let prior = read_iceberg_version_hint(table_root);
394 let next_version = prior.map(|v| v + 1).unwrap_or(1);
395 let mut meta_log = prior_metadata_log(table_root, prior);
396
397 let staging = table_root.with_extension("rbt-partial-table");
398 remove_if_exists(&staging);
399 let data_dir = staging.join("data");
400 let meta_dir = staging.join("metadata");
401 fs::create_dir_all(&data_dir)?;
402 fs::create_dir_all(&meta_dir)?;
403
404 if let Some(old_meta) = table_root.join("metadata").exists().then(|| table_root.join("metadata"))
406 {
407 if let Ok(entries) = fs::read_dir(&old_meta) {
408 for e in entries.flatten() {
409 let p = e.path();
410 if p.extension().and_then(|x| x.to_str()) == Some("json") {
411 if let Some(name) = p.file_name() {
412 let _ = fs::copy(&p, meta_dir.join(name));
413 }
414 }
415 }
416 }
417 }
418
419 let data_path = data_dir.join("part-00000.parquet");
420 let schema = stream.schema();
421 let stats = write_parquet_stream(stream, &data_path, opts, assertions).await?;
422
423 write_iceberg_metadata(
424 &staging,
425 &schema,
426 stats.rows,
427 "part-00000.parquet",
428 next_version,
429 &mut meta_log,
430 )?;
431
432 if table_root.exists() {
433 fs::remove_dir_all(table_root).with_context(|| {
434 format!(
435 "E_RBT_MATERIALIZE_IO: clear iceberg table {}",
436 table_root.display()
437 )
438 })?;
439 }
440 if let Some(parent) = table_root.parent() {
441 fs::create_dir_all(parent)?;
442 }
443 fs::rename(&staging, table_root).with_context(|| {
444 format!(
445 "E_RBT_MATERIALIZE_ATOMIC: rename iceberg staging {} → {}",
446 staging.display(),
447 table_root.display()
448 )
449 })?;
450
451 tracing::info!(
452 "Iceberg FS table written (stream): {} ({} rows, metadata v{}, data/part-00000.parquet)",
453 table_root.display(),
454 stats.rows,
455 next_version
456 );
457
458 Ok(StreamWriteStats {
459 rows: stats.rows,
460 batches: stats.batches,
461 path: table_root.to_path_buf(),
462 bytes_written: stats.bytes_written,
463 validation: stats.validation,
464 })
465}
466
467fn read_iceberg_version_hint(table_root: &Path) -> Option<u64> {
468 let hint = table_root.join("metadata/version-hint.text");
469 let s = fs::read_to_string(hint).ok()?;
470 s.trim().parse().ok()
471}
472
473fn prior_metadata_log(table_root: &Path, prior: Option<u64>) -> Vec<serde_json::Value> {
474 use serde_json::json;
475 let mut log = Vec::new();
476 if let Some(v) = prior {
477 let meta_path = table_root.join(format!("metadata/v{v}.metadata.json"));
478 if meta_path.exists() {
479 let now_ms = std::time::SystemTime::now()
480 .duration_since(std::time::UNIX_EPOCH)
481 .map(|d| d.as_millis() as u64)
482 .unwrap_or(0);
483 log.push(json!({
484 "timestamp-ms": now_ms,
485 "metadata-file": format!("v{v}.metadata.json"),
486 }));
487 }
488 }
489 log
490}
491
492fn write_iceberg_sidecar_from_parquet(
493 parquet_path: &Path,
494 row_count: usize,
495 _stats_path: &Path,
496) -> Result<()> {
497 let table_root = super::sibling_iceberg_dir(parquet_path);
498 let prior = read_iceberg_version_hint(&table_root);
499 let next = prior.map(|v| v + 1).unwrap_or(1);
500 let mut log = prior_metadata_log(&table_root, prior);
501 let mut prior_meta_files: Vec<(String, Vec<u8>)> = Vec::new();
503 let old_meta = table_root.join("metadata");
504 if old_meta.is_dir() {
505 if let Ok(entries) = fs::read_dir(&old_meta) {
506 for e in entries.flatten() {
507 let p = e.path();
508 if p.extension().and_then(|x| x.to_str()) == Some("json") {
509 if let (Some(name), Ok(bytes)) = (
510 p.file_name().map(|n| n.to_string_lossy().into_owned()),
511 fs::read(&p),
512 ) {
513 prior_meta_files.push((name, bytes));
514 }
515 }
516 }
517 }
518 }
519
520 let file = File::open(parquet_path)
521 .with_context(|| format!("open {} for iceberg sidecar", parquet_path.display()))?;
522 let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)
523 .with_context(|| format!("parquet reader {}", parquet_path.display()))?;
524 let schema = builder.schema().clone();
525
526 if table_root.exists() {
527 fs::remove_dir_all(&table_root)?;
528 }
529 let data_dir = table_root.join("data");
530 let meta_dir = table_root.join("metadata");
531 fs::create_dir_all(&data_dir)?;
532 fs::create_dir_all(&meta_dir)?;
533 for (name, bytes) in prior_meta_files {
534 let _ = fs::write(meta_dir.join(name), bytes);
535 }
536 let data_name = "part-00000.parquet";
537 fs::copy(parquet_path, data_dir.join(data_name))?;
538 write_iceberg_metadata(
539 &table_root,
540 &schema,
541 row_count,
542 data_name,
543 next,
544 &mut log,
545 )?;
546 Ok(())
547}
548
549fn write_iceberg_metadata(
550 table_root: &Path,
551 schema: &SchemaRef,
552 total_rows: usize,
553 data_file_name: &str,
554 version: u64,
555 metadata_log: &mut Vec<serde_json::Value>,
556) -> Result<()> {
557 use serde_json::json;
558 use std::time::{SystemTime, UNIX_EPOCH};
559
560 let meta_dir = table_root.join("metadata");
561 fs::create_dir_all(&meta_dir)?;
562
563 let mut fields = Vec::new();
564 for (i, f) in schema.fields().iter().enumerate() {
565 fields.push(json!({
566 "id": i + 1,
567 "name": f.name(),
568 "required": !f.is_nullable(),
569 "type": arrow_type_to_iceberg_json(f.data_type()),
570 }));
571 }
572
573 let now_ms = SystemTime::now()
574 .duration_since(UNIX_EPOCH)
575 .map(|d| d.as_millis() as u64)
576 .unwrap_or(0);
577 let snapshot_id = now_ms.wrapping_add(version);
578 let location = table_root
579 .canonicalize()
580 .unwrap_or_else(|_| table_root.to_path_buf());
581 let location_uri = format!("file://{}", location.display());
582
583 let metadata = json!({
584 "format-version": 2,
585 "table-uuid": format!("{:032x}", snapshot_id),
586 "location": location_uri,
587 "last-sequence-number": version,
588 "last-updated-ms": now_ms,
589 "last-column-id": fields.len(),
590 "current-schema-id": 0,
591 "schemas": [{
592 "type": "struct",
593 "schema-id": 0,
594 "fields": fields,
595 }],
596 "default-spec-id": 0,
597 "partition-specs": [{ "spec-id": 0, "fields": [] }],
598 "last-partition-id": 0,
599 "default-sort-order-id": 0,
600 "sort-orders": [{ "order-id": 0, "fields": [] }],
601 "properties": {
602 "rbt.writer": "rbt",
603 "rbt.layout": "filesystem-iceberg-v1",
604 "write.format.default": "parquet",
605 "rbt.materialize": "stream",
606 "rbt.metadata-version": version.to_string()
607 },
608 "current-snapshot-id": snapshot_id,
609 "snapshots": [{
610 "snapshot-id": snapshot_id,
611 "sequence-number": version,
612 "timestamp-ms": now_ms,
613 "summary": {
614 "operation": "overwrite",
615 "rbt.added-records": total_rows.to_string(),
616 "rbt.added-data-files": "1",
617 "rbt.data-file": format!("data/{data_file_name}")
618 },
619 "schema-id": 0
620 }],
621 "snapshot-log": [{
622 "timestamp-ms": now_ms,
623 "snapshot-id": snapshot_id
624 }],
625 "metadata-log": metadata_log,
626 "rbt": {
627 "note": "Filesystem Iceberg-style table (full-refresh data, versioned metadata). Not REST/Glue OCC.",
628 "data_files": [format!("data/{data_file_name}")],
629 "row_count": total_rows,
630 "metadata_version": version
631 }
632 });
633
634 let meta_name = format!("v{version}.metadata.json");
635 let meta_path = meta_dir.join(&meta_name);
636 let mut meta_file = File::create(&meta_path)?;
637 writeln!(meta_file, "{}", serde_json::to_string_pretty(&metadata)?)?;
638 let mut hint = File::create(meta_dir.join("version-hint.text"))?;
639 writeln!(hint, "{version}")?;
640 fs::copy(&meta_path, meta_dir.join("metadata.json"))?;
641 Ok(())
642}
643
644fn arrow_type_to_iceberg_json(dt: &arrow::datatypes::DataType) -> serde_json::Value {
645 use arrow::datatypes::DataType;
646 use serde_json::json;
647 match dt {
648 DataType::Boolean => json!("boolean"),
649 DataType::Int32 => json!("int"),
650 DataType::Int64 => json!("long"),
651 DataType::Float32 => json!("float"),
652 DataType::Float64 => json!("double"),
653 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => json!("string"),
654 DataType::Binary | DataType::LargeBinary => json!("binary"),
655 DataType::Date32 | DataType::Date64 => json!("date"),
656 DataType::Timestamp(_, _) => json!("timestamptz"),
657 other => json!(format!("string /* arrow:{:?} */", other)),
658 }
659}
660
661pub fn write_parquet_batches_atomic(
663 batches: &[RecordBatch],
664 path: &Path,
665 opts: &MaterializeWriteOptions,
666) -> Result<usize> {
667 if batches.is_empty() {
668 return Ok(0);
669 }
670 let schema = batches[0].schema();
671 let partial = partial_path_for(path);
672 remove_if_exists(&partial);
673 if let Some(parent) = partial.parent() {
674 fs::create_dir_all(parent)?;
675 }
676 let file = File::create(&partial)?;
677 let buf = BufWriter::with_capacity(8 * 1024 * 1024, file);
678 let props = parquet_props(opts);
679 let mut writer = ArrowWriter::try_new(buf, schema, Some(props))?;
680 let mut rows = 0usize;
681 for batch in batches {
682 writer.write(batch)?;
683 rows += batch.num_rows();
684 if writer.in_progress_size() >= opts.max_row_group_bytes {
685 writer.flush()?;
686 }
687 }
688 writer.close()?;
689 atomic_publish(&partial, path)?;
690 Ok(rows)
691}
692
693pub fn load_parquet_batches(path: &Path) -> Result<Vec<RecordBatch>> {
695 let file = File::open(path)
696 .with_context(|| format!("E_RBT_REF_LOAD: open {} for MemTable", path.display()))?;
697 let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)
698 .with_context(|| format!("E_RBT_REF_LOAD: parquet builder {}", path.display()))?;
699 let reader = builder
700 .build()
701 .with_context(|| format!("E_RBT_REF_LOAD: parquet reader {}", path.display()))?;
702 let mut out = Vec::new();
703 for item in reader {
704 out.push(item.with_context(|| {
705 format!("E_RBT_REF_LOAD: read batch from {}", path.display())
706 })?);
707 }
708 Ok(out)
709}
710
711pub fn write_empty_parquet(schema: SchemaRef, path: &Path, opts: &MaterializeWriteOptions) -> Result<()> {
713 let partial = partial_path_for(path);
714 remove_if_exists(&partial);
715 if let Some(parent) = partial.parent() {
716 fs::create_dir_all(parent)?;
717 }
718 let file = File::create(&partial)?;
719 let props = parquet_props(opts);
720 let writer = ArrowWriter::try_new(file, schema, Some(props))?;
721 writer.close()?;
722 atomic_publish(&partial, path)?;
723 Ok(())
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use crate::testing::Assertion;
730 use arrow::datatypes::{DataType, Field, Schema};
731 use datafusion::prelude::SessionContext;
732 use std::sync::Arc;
733
734 fn sample_schema() -> SchemaRef {
735 Arc::new(Schema::new(vec![
736 Field::new("id", DataType::Int64, false),
737 Field::new("name", DataType::Utf8, true),
738 ]))
739 }
740
741 #[tokio::test]
742 async fn stream_parquet_many_batches_row_count() -> Result<()> {
743 let temp = tempfile::tempdir()?;
744 let dest = temp.path().join("out.parquet");
745 let ctx = SessionContext::new();
746 let df = ctx
748 .sql(
749 "SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e')) \
750 AS t(id, name)",
751 )
752 .await?;
753 let stream = df.execute_stream().await?;
754 let opts = MaterializeWriteOptions {
755 max_row_group_rows: 2,
756 max_row_group_bytes: 1024,
757 fail_fast_assertions: true,
758 ..Default::default()
759 };
760 let assertions = vec![Assertion::UniqueKey {
761 columns: vec!["id".into()],
762 }];
763 let mut stream = stream;
764 let stats = write_parquet_stream(&mut stream, &dest, &opts, &assertions).await?;
765 assert_eq!(stats.rows, 5);
766 assert!(dest.exists());
767 assert!(!partial_path_for(&dest).exists());
768 let loaded = load_parquet_batches(&dest)?;
769 let n: usize = loaded.iter().map(|b| b.num_rows()).sum();
770 assert_eq!(n, 5);
771 Ok(())
772 }
773
774 #[tokio::test]
775 async fn iceberg_stream_versions_metadata() -> Result<()> {
776 let temp = tempfile::tempdir()?;
777 let root = temp.path().join("tbl");
778 let ctx = SessionContext::new();
779 let opts = MaterializeWriteOptions::default();
780
781 let df1 = ctx.sql("SELECT 1 AS id").await?;
782 let mut s1 = df1.execute_stream().await?;
783 write_iceberg_stream(&mut s1, &root, &opts, &[]).await?;
784 assert!(root.join("metadata/v1.metadata.json").exists());
785 assert_eq!(
786 fs::read_to_string(root.join("metadata/version-hint.text"))?.trim(),
787 "1"
788 );
789
790 let df2 = ctx.sql("SELECT 2 AS id").await?;
791 let mut s2 = df2.execute_stream().await?;
792 write_iceberg_stream(&mut s2, &root, &opts, &[]).await?;
793 assert!(root.join("metadata/v2.metadata.json").exists());
794 assert!(root.join("metadata/v1.metadata.json").exists());
796 assert_eq!(
797 fs::read_to_string(root.join("metadata/version-hint.text"))?.trim(),
798 "2"
799 );
800 Ok(())
801 }
802
803 #[tokio::test]
804 async fn stream_unique_failure_removes_partial() -> Result<()> {
805 let temp = tempfile::tempdir()?;
806 let dest = temp.path().join("dup.parquet");
807 let ctx = SessionContext::new();
808 let df = ctx
809 .sql("SELECT * FROM (VALUES (1), (1)) AS t(id)")
810 .await?;
811 let stream = df.execute_stream().await?;
812 let opts = MaterializeWriteOptions::default();
813 let assertions = vec![Assertion::UniqueKey {
814 columns: vec!["id".into()],
815 }];
816 let mut stream = stream;
817 let err = write_parquet_stream(&mut stream, &dest, &opts, &assertions)
818 .await
819 .unwrap_err()
820 .to_string();
821 assert!(
822 err.contains("E_RBT_MATERIALIZE_ASSERT") || err.contains("Duplicate"),
823 "got: {err}"
824 );
825 assert!(!dest.exists(), "failed assert must not publish dest");
826 assert!(
827 !partial_path_for(&dest).exists(),
828 "partial must be cleaned on assert fail"
829 );
830 Ok(())
831 }
832
833 #[test]
834 fn atomic_publish_replaces_existing() -> Result<()> {
835 let temp = tempfile::tempdir()?;
836 let dest = temp.path().join("f.parquet");
837 fs::write(&dest, b"old")?;
838 let partial = partial_path_for(&dest);
839 fs::write(&partial, b"new-data")?;
840 atomic_publish(&partial, &dest)?;
841 assert_eq!(fs::read(&dest)?, b"new-data");
842 assert!(!partial.exists());
843 Ok(())
844 }
845
846 #[test]
847 fn write_empty_parquet_ok() -> Result<()> {
848 let temp = tempfile::tempdir()?;
849 let dest = temp.path().join("empty.parquet");
850 write_empty_parquet(sample_schema(), &dest, &MaterializeWriteOptions::default())?;
851 assert!(dest.exists());
852 let batches = load_parquet_batches(&dest)?;
853 let n: usize = batches.iter().map(|b| b.num_rows()).sum();
854 assert_eq!(n, 0);
855 Ok(())
856 }
857
858}