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