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