Skip to main content

rbt_materializer/
lib.rs

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