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