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