Skip to main content

rbt/materializer/
stream.rs

1//! Streaming materialize: pull DF `RecordBatch` streams batch-by-batch, write, drop.
2//!
3//! Peak retained memory ≈ in-flight batch + Parquet row-group encoder + unique tracker.
4//! Never holds a full `Vec<RecordBatch>` for the model result.
5
6use 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/// Result of a successful stream materialize.
25#[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/// Options for stream / collect writers (derived from [`MaterializeConfig`]).
35#[derive(Debug, Clone)]
36pub struct MaterializeWriteOptions {
37    pub max_row_group_rows: usize,
38    pub max_row_group_bytes: usize,
39    /// Abort on first assertion failure (default true for fail_on_error models).
40    pub fail_fast_assertions: bool,
41    /// Iceberg write backend for `OutputFormat::Iceberg`.
42    pub iceberg_mode: crate::core::project::IcebergWriteMode,
43    pub iceberg_namespace: String,
44    /// When set, stamp lineage columns onto each batch before write (P6).
45    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
86/// Staging path for atomic publish: `dir/.name.ext.rbt-partial`.
87pub 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
106/// Atomically replace `dest` with `partial` (same filesystem). Cleans partial on failure.
107pub 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    // Replace existing destination (full refresh).
113    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
141/// Stream a DataFusion result into `destination_path` for the given format.
142///
143/// On any error, partial artifacts are deleted (previous successful dest is left intact
144/// until a successful atomic replace).
145pub 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            // Dual-write: stream once into parquet, then re-read path for iceberg layout
184            // would double IO. For dual-write we buffer is bad — write parquet stream,
185            // then copy data file into iceberg layout + metadata (metadata only needs schema+rows).
186            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            // Build iceberg sidecar from written parquet (schema + row count) without re-materializing batches.
195            write_iceberg_sidecar_from_parquet(&parquet_path, stats.rows, &stats.path)?;
196            Ok(stats)
197        }
198    }
199}
200
201/// Stream write Parquet with atomic publish + optional streaming assertions.
202pub 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    // Large buffer reduces syscalls on multi-million-row writes.
229    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            // Soft flush when in-progress row group grows large.
264            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            // batch dropped here
273        }
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    // Data file is full-refresh; metadata versions are retained for a local snapshot log
410    // (not multi-writer OCC / REST catalog — honest FS Iceberg-style history).
411    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    // Preserve prior metadata JSON files into staging for history.
423    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    // Preserve prior metadata JSON into a temp list of copies.
520    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
679/// Collect-mode helper: write batches with same Parquet props / atomic publish as stream.
680pub 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
711/// Load small Parquet file into memory for optional MemTable ref() after stream write.
712pub 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
729/// Empty schema-only Parquet (0 rows) so ref() registration has a file.
730pub 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        // Produce multiple small batches via UNION ALL chain
765        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        // prior v1 preserved
813        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}