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