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::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/// Result of a successful stream materialize.
24#[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/// Options for stream / collect writers (derived from [`MaterializeConfig`]).
34#[derive(Debug, Clone)]
35pub struct MaterializeWriteOptions {
36    pub max_row_group_rows: usize,
37    pub max_row_group_bytes: usize,
38    /// Abort on first assertion failure (default true for fail_on_error models).
39    pub fail_fast_assertions: bool,
40    /// Iceberg write backend for `OutputFormat::Iceberg`.
41    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
76/// Staging path for atomic publish: `dir/.name.ext.rbt-partial`.
77pub 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
96/// Atomically replace `dest` with `partial` (same filesystem). Cleans partial on failure.
97pub 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    // Replace existing destination (full refresh).
103    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
131/// Stream a DataFusion result into `destination_path` for the given format.
132///
133/// On any error, partial artifacts are deleted (previous successful dest is left intact
134/// until a successful atomic replace).
135pub 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            // Dual-write: stream once into parquet, then re-read path for iceberg layout
174            // would double IO. For dual-write we buffer is bad — write parquet stream,
175            // then copy data file into iceberg layout + metadata (metadata only needs schema+rows).
176            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            // Build iceberg sidecar from written parquet (schema + row count) without re-materializing batches.
185            write_iceberg_sidecar_from_parquet(&parquet_path, stats.rows, &stats.path)?;
186            Ok(stats)
187        }
188    }
189}
190
191/// Stream write Parquet with atomic publish + optional streaming assertions.
192pub 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    // Large buffer reduces syscalls on multi-million-row writes.
214    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            // Soft flush when in-progress row group grows large.
246            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            // batch dropped here
255        }
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    // Data file is full-refresh; metadata versions are retained for a local snapshot log
392    // (not multi-writer OCC / REST catalog — honest FS Iceberg-style history).
393    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    // Preserve prior metadata JSON files into staging for history.
405    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    // Preserve prior metadata JSON into a temp list of copies.
502    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
661/// Collect-mode helper: write batches with same Parquet props / atomic publish as stream.
662pub 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
693/// Load small Parquet file into memory for optional MemTable ref() after stream write.
694pub 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
711/// Empty schema-only Parquet (0 rows) so ref() registration has a file.
712pub 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        // Produce multiple small batches via UNION ALL chain
747        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        // prior v1 preserved
795        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}