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