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