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