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