1pub 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#[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
47pub struct MultiFormatWriter;
49
50impl MultiFormatWriter {
51 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 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 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
174pub fn sibling_iceberg_dir(parquet_path: &Path) -> PathBuf {
176 let stem = parquet_path.with_extension("");
177 PathBuf::from(format!("{}.iceberg", stem.display()))
178}
179
180pub 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 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 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
318pub 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 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 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 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}