Skip to main content

rbt/materializer/
mod.rs

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