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