Skip to main content

rbt/materializer/
mod.rs

1//! `rbt::materializer`: multi-format writers including filesystem Iceberg-style tables.
2//!
3//! **Streaming materialize** ([`stream`]) is the default engine path: pull a DataFusion
4//! batch stream, write batch-by-batch, drop each batch, atomic-publish the file.
5
6pub mod iceberg_catalog;
7pub mod stream;
8
9pub use iceberg_catalog::{
10    verify_iceberg_catalog_table, write_iceberg_catalog_batches, write_iceberg_catalog_stream,
11    IcebergCatalogOptions, IcebergCatalogWriteStats,
12};
13pub use stream::{
14    atomic_publish, load_parquet_batches, materialize_stream, partial_path_for,
15    write_empty_parquet, write_parquet_batches_atomic, write_parquet_stream, MaterializeWriteOptions,
16    StreamWriteStats,
17};
18
19use crate::core::dag::OutputFormat;
20use crate::core::project::MaterializeConfig;
21use crate::testing::{Assertion, RecordBatchValidator, ValidationResult};
22use anyhow::{Context, Result};
23use arrow::record_batch::RecordBatch;
24use serde_json::{json, Value};
25use std::fs::{self, File};
26use std::io::Write;
27use std::path::{Path, PathBuf};
28use std::time::{SystemTime, UNIX_EPOCH};
29
30/// Status of a Write-Audit-Publish materialization run.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum WapStatus {
33    Staged,
34    AuditedSuccess,
35    AuditedFailure { errors: Vec<String> },
36    Published { rows_written: usize },
37    RolledBack,
38}
39
40/// Multi-format stream writer (Parquet, JSONL, CSV, filesystem Iceberg layout).
41pub struct MultiFormatWriter;
42
43impl MultiFormatWriter {
44    /// Writes RecordBatch array to target output format.
45    ///
46    /// * **Parquet / Jsonl / Csv** — single file at `destination_path`
47    /// * **Iceberg** — table directory at `destination_path` with `data/` + `metadata/`
48    /// * **ParquetAndIceberg** — flat `.parquet` sibling plus `{stem}.iceberg/` table dir
49    /// * **ZeroCopyClone** — currently materializes Parquet (clone semantics later)
50    pub fn write_batches(
51        batches: &[RecordBatch],
52        format: &OutputFormat,
53        destination_path: &Path,
54    ) -> Result<usize> {
55        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
56        if batches.is_empty() {
57            return Ok(0);
58        }
59
60        match format {
61            OutputFormat::Parquet | OutputFormat::ZeroCopyClone => {
62                write_parquet_file(batches, destination_path)?;
63            }
64            OutputFormat::Jsonl => {
65                if let Some(parent) = destination_path.parent() {
66                    fs::create_dir_all(parent)?;
67                }
68                let file = File::create(destination_path)?;
69                let mut writer = arrow::json::LineDelimitedWriter::new(file);
70                for batch in batches {
71                    writer.write(batch)?;
72                }
73                writer.finish()?;
74            }
75            OutputFormat::Csv => {
76                if let Some(parent) = destination_path.parent() {
77                    fs::create_dir_all(parent)?;
78                }
79                let file = File::create(destination_path)?;
80                let mut writer = arrow::csv::Writer::new(file);
81                for batch in batches {
82                    writer.write(batch)?;
83                }
84            }
85            OutputFormat::Iceberg => {
86                // Collect path: prefer catalog SoR (P2); FS layout only when configured.
87                let opts = MaterializeWriteOptions::from_config(
88                    &crate::core::project::MaterializeConfig::default(),
89                    true,
90                );
91                match opts.iceberg_mode {
92                    crate::core::project::IcebergWriteMode::Catalog => {
93                        write_iceberg_catalog_batches_sync(batches, destination_path, &opts)?;
94                    }
95                    crate::core::project::IcebergWriteMode::Filesystem => {
96                        write_iceberg_fs_table(batches, destination_path)?;
97                    }
98                }
99            }
100            OutputFormat::ParquetAndIceberg => {
101                let parquet_path =
102                    if destination_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
103                        destination_path.to_path_buf()
104                    } else {
105                        destination_path.with_extension("parquet")
106                    };
107                write_parquet_file(batches, &parquet_path)?;
108                write_iceberg_fs_table(batches, &sibling_iceberg_dir(&parquet_path))?;
109            }
110        }
111
112        Ok(total_rows)
113    }
114}
115
116fn write_iceberg_catalog_batches_sync(
117    batches: &[RecordBatch],
118    destination_path: &Path,
119    opts: &MaterializeWriteOptions,
120) -> Result<()> {
121    let cat_opts = IcebergCatalogOptions {
122        namespace: opts.iceberg_namespace.clone(),
123        warehouse: Some(destination_path.to_path_buf()),
124    };
125    if let Ok(handle) = tokio::runtime::Handle::try_current() {
126        // Safe when called from async via spawn_blocking/block_in_place or sync tests.
127        tokio::task::block_in_place(|| {
128            handle.block_on(write_iceberg_catalog_batches(
129                batches,
130                destination_path,
131                &cat_opts,
132            ))
133        })?;
134    } else {
135        let rt = tokio::runtime::Runtime::new()
136            .context("E_RBT_ICEBERG_CATALOG: create tokio runtime")?;
137        rt.block_on(write_iceberg_catalog_batches(
138            batches,
139            destination_path,
140            &cat_opts,
141        ))?;
142    }
143    Ok(())
144}
145
146fn write_parquet_file(batches: &[RecordBatch], path: &Path) -> Result<()> {
147    if batches.is_empty() {
148        anyhow::bail!(
149            "write_parquet_file: empty batch list for {}",
150            path.display()
151        );
152    }
153    let schema = batches[0].schema();
154    for (i, b) in batches.iter().enumerate().skip(1) {
155        if b.schema().as_ref() != schema.as_ref() {
156            anyhow::bail!(
157                "write_parquet_file: schema mismatch at batch {} for {}",
158                i,
159                path.display()
160            );
161        }
162    }
163    let opts = MaterializeWriteOptions::from_config(&MaterializeConfig::default(), true);
164    write_parquet_batches_atomic(batches, path, &opts).map(|_| ())
165}
166
167/// Sibling Iceberg table directory for dual-write: `foo.parquet` → `foo.iceberg/`.
168pub fn sibling_iceberg_dir(parquet_path: &Path) -> PathBuf {
169    let stem = parquet_path.with_extension("");
170    PathBuf::from(format!("{}.iceberg", stem.display()))
171}
172
173/// Write an Iceberg-style filesystem table (data files + metadata snapshot).
174///
175/// Layout:
176/// ```text
177/// table_root/
178///   data/part-00000.parquet
179///   metadata/
180///     v1.metadata.json
181///     version-hint.text
182/// ```
183///
184/// This is a **full-refresh** replace of the table root (not multi-snapshot OCC yet).
185/// Compatible enough for rbt CLI `--format iceberg` and dual-write demos; full REST
186/// catalog commit remains a follow-on.
187pub fn write_iceberg_fs_table(batches: &[RecordBatch], table_root: &Path) -> Result<usize> {
188    let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
189    if batches.is_empty() {
190        return Ok(0);
191    }
192
193    // Full refresh: clear previous table root if present.
194    if table_root.exists() {
195        fs::remove_dir_all(table_root)
196            .with_context(|| format!("clear iceberg table {}", table_root.display()))?;
197    }
198    let data_dir = table_root.join("data");
199    let meta_dir = table_root.join("metadata");
200    fs::create_dir_all(&data_dir)?;
201    fs::create_dir_all(&meta_dir)?;
202
203    let data_file_name = "part-00000.parquet";
204    let data_path = data_dir.join(data_file_name);
205    write_parquet_file(batches, &data_path)?;
206
207    let schema = batches[0].schema();
208    let mut fields = Vec::new();
209    for (i, f) in schema.fields().iter().enumerate() {
210        fields.push(json!({
211            "id": i + 1,
212            "name": f.name(),
213            "required": !f.is_nullable(),
214            "type": arrow_type_to_iceberg_json(f.data_type()),
215        }));
216    }
217
218    let now_ms = SystemTime::now()
219        .duration_since(UNIX_EPOCH)
220        .map(|d| d.as_millis() as u64)
221        .unwrap_or(0);
222    let snapshot_id = now_ms;
223    let location = table_root
224        .canonicalize()
225        .unwrap_or_else(|_| table_root.to_path_buf());
226    let location_uri = format!("file://{}", location.display());
227
228    let metadata = json!({
229        "format-version": 2,
230        "table-uuid": format!("{:032x}", snapshot_id),
231        "location": location_uri,
232        "last-sequence-number": 1,
233        "last-updated-ms": now_ms,
234        "last-column-id": fields.len(),
235        "current-schema-id": 0,
236        "schemas": [{
237            "type": "struct",
238            "schema-id": 0,
239            "fields": fields,
240        }],
241        "default-spec-id": 0,
242        "partition-specs": [{ "spec-id": 0, "fields": [] }],
243        "last-partition-id": 0,
244        "default-sort-order-id": 0,
245        "sort-orders": [{ "order-id": 0, "fields": [] }],
246        "properties": {
247            "rbt.writer": "rbt",
248            "rbt.layout": "filesystem-iceberg-v1",
249            "write.format.default": "parquet"
250        },
251        "current-snapshot-id": snapshot_id,
252        "snapshots": [{
253            "snapshot-id": snapshot_id,
254            "sequence-number": 1,
255            "timestamp-ms": now_ms,
256            "summary": {
257                "operation": "overwrite",
258                "rbt.added-records": total_rows.to_string(),
259                "rbt.added-data-files": "1",
260                "rbt.data-file": format!("data/{}", data_file_name)
261            },
262            "schema-id": 0
263        }],
264        "snapshot-log": [{
265            "timestamp-ms": now_ms,
266            "snapshot-id": snapshot_id
267        }],
268        "metadata-log": [],
269        "rbt": {
270            "note": "Filesystem Iceberg-style table written by rbt (full refresh). Not a full catalog OCC commit.",
271            "data_files": [format!("data/{}", data_file_name)],
272            "row_count": total_rows
273        }
274    });
275
276    let meta_path = meta_dir.join("v1.metadata.json");
277    let mut meta_file = File::create(&meta_path)?;
278    writeln!(meta_file, "{}", serde_json::to_string_pretty(&metadata)?)?;
279
280    let mut hint = File::create(meta_dir.join("version-hint.text"))?;
281    writeln!(hint, "1")?;
282
283    // Convenience pointer for tools that look for metadata.json
284    fs::copy(&meta_path, meta_dir.join("metadata.json"))?;
285
286    tracing::info!(
287        "Iceberg FS table written: {} ({} rows, data/{})",
288        table_root.display(),
289        total_rows,
290        data_file_name
291    );
292    Ok(total_rows)
293}
294
295fn arrow_type_to_iceberg_json(dt: &arrow::datatypes::DataType) -> Value {
296    use arrow::datatypes::DataType;
297    match dt {
298        DataType::Boolean => json!("boolean"),
299        DataType::Int32 => json!("int"),
300        DataType::Int64 => json!("long"),
301        DataType::Float32 => json!("float"),
302        DataType::Float64 => json!("double"),
303        DataType::Utf8 | DataType::LargeUtf8 => json!("string"),
304        DataType::Binary | DataType::LargeBinary => json!("binary"),
305        DataType::Date32 | DataType::Date64 => json!("date"),
306        DataType::Timestamp(_, _) => json!("timestamptz"),
307        other => json!(format!("string /* arrow:{:?} */", other)),
308    }
309}
310
311/// Write-Audit-Publish (WAP) Materializer — audit helpers (catalog branch publish later).
312pub struct WapMaterializer {
313    pub target_table: String,
314    pub wap_branch: String,
315    pub staging_dir: PathBuf,
316}
317
318impl WapMaterializer {
319    pub fn new(
320        target_table: impl Into<String>,
321        wap_branch: impl Into<String>,
322        staging_dir: impl AsRef<Path>,
323    ) -> Self {
324        Self {
325            target_table: target_table.into(),
326            wap_branch: wap_branch.into(),
327            staging_dir: staging_dir.as_ref().to_path_buf(),
328        }
329    }
330
331    pub async fn write_stage(&self, batches: &[RecordBatch]) -> Result<usize> {
332        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
333        tracing::info!(
334            "WAP WRITE: Staging {} rows into branch '{}' for table '{}'",
335            total_rows,
336            self.wap_branch,
337            self.target_table
338        );
339        tokio::fs::create_dir_all(&self.staging_dir).await?;
340        // Materialize staging snapshot as Iceberg FS table under staging_dir.
341        if !batches.is_empty() {
342            write_iceberg_fs_table(batches, &self.staging_dir.join("table"))?;
343        }
344        Ok(total_rows)
345    }
346
347    pub fn audit_stage(
348        &self,
349        batches: &[RecordBatch],
350        assertions: &[Assertion],
351    ) -> Result<ValidationResult> {
352        tracing::info!(
353            "WAP AUDIT: Executing {} assertions on branch '{}'",
354            assertions.len(),
355            self.wap_branch
356        );
357        let validation = RecordBatchValidator::validate_batches(batches, assertions);
358        if validation.failed_assertions > 0 {
359            tracing::error!(
360                "WAP AUDIT FAILED: {} assertions failed on branch '{}': {:?}",
361                validation.failed_assertions,
362                self.wap_branch,
363                validation.errors
364            );
365        } else {
366            tracing::info!(
367                "WAP AUDIT PASSED: All {} assertions passed on branch '{}'",
368                validation.passed_assertions,
369                self.wap_branch
370            );
371        }
372        Ok(validation)
373    }
374
375    pub async fn publish_stage(&self, audit_result: &ValidationResult) -> Result<WapStatus> {
376        if audit_result.failed_assertions > 0 {
377            return Ok(WapStatus::RolledBack);
378        }
379        Ok(WapStatus::Published {
380            rows_written: audit_result.total_rows,
381        })
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use arrow::array::{Int64Array, StringArray};
389    use arrow::datatypes::{DataType, Field, Schema};
390    use std::sync::Arc;
391
392    fn sample_batch() -> RecordBatch {
393        let schema = Arc::new(Schema::new(vec![
394            Field::new("id", DataType::Int64, false),
395            Field::new("name", DataType::Utf8, true),
396        ]));
397        RecordBatch::try_new(
398            schema,
399            vec![
400                Arc::new(Int64Array::from(vec![1, 2, 3])),
401                Arc::new(StringArray::from(vec![Some("a"), None, Some("c")])),
402            ],
403        )
404        .unwrap()
405    }
406
407    #[test]
408    fn test_multi_format_writer() -> Result<()> {
409        let temp_dir = tempfile::tempdir()?;
410        let batch = sample_batch();
411
412        let parquet_file = temp_dir.path().join("output.parquet");
413        let rows = MultiFormatWriter::write_batches(
414            std::slice::from_ref(&batch),
415            &OutputFormat::Parquet,
416            &parquet_file,
417        )?;
418        assert_eq!(rows, 3);
419        assert!(parquet_file.exists());
420        assert!(parquet_file.metadata()?.len() > 0);
421
422        let iceberg_dir = temp_dir.path().join("tbl");
423        // Explicit FS layout path for dual-compat tests; catalog SoR has its own unit test.
424        let irows = write_iceberg_fs_table(std::slice::from_ref(&batch), &iceberg_dir)?;
425        assert_eq!(irows, 3);
426        assert!(iceberg_dir.join("data/part-00000.parquet").exists());
427        assert!(iceberg_dir.join("metadata/v1.metadata.json").exists());
428        assert!(iceberg_dir.join("metadata/version-hint.text").exists());
429        assert!(iceberg_dir.join("metadata/metadata.json").exists());
430
431        let meta: Value = serde_json::from_str(&fs::read_to_string(
432            iceberg_dir.join("metadata/v1.metadata.json"),
433        )?)?;
434        assert_eq!(meta["format-version"], 2);
435        assert_eq!(meta["rbt"]["row_count"], 3);
436
437        let dual = temp_dir.path().join("dual.parquet");
438        MultiFormatWriter::write_batches(&[batch], &OutputFormat::ParquetAndIceberg, &dual)?;
439        assert!(dual.exists());
440        assert!(sibling_iceberg_dir(&dual)
441            .join("data/part-00000.parquet")
442            .exists());
443
444        Ok(())
445    }
446
447    #[test]
448    fn iceberg_full_refresh_replaces() -> Result<()> {
449        let temp = tempfile::tempdir()?;
450        let root = temp.path().join("t");
451        let b = sample_batch();
452        write_iceberg_fs_table(std::slice::from_ref(&b), &root)?;
453        // plant a junk file then rewrite
454        fs::write(root.join("data/junk.txt"), b"x")?;
455        write_iceberg_fs_table(std::slice::from_ref(&b), &root)?;
456        assert!(!root.join("data/junk.txt").exists());
457        assert!(root.join("data/part-00000.parquet").exists());
458        Ok(())
459    }
460
461    #[test]
462    fn empty_batches_ok() -> Result<()> {
463        let temp = tempfile::tempdir()?;
464        let n = MultiFormatWriter::write_batches(
465            &[],
466            &OutputFormat::Parquet,
467            &temp.path().join("empty.parquet"),
468        )?;
469        assert_eq!(n, 0);
470        assert!(!temp.path().join("empty.parquet").exists());
471        Ok(())
472    }
473
474    #[test]
475    fn sibling_iceberg_dir_name() {
476        assert_eq!(
477            sibling_iceberg_dir(Path::new("/lake/gold/fact.parquet")),
478            PathBuf::from("/lake/gold/fact.iceberg")
479        );
480    }
481}