Skip to main content

ailake_query/
cdc.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Change Data Capture (CDC) reader for AI-Lake tables.
3//!
4//! CDC is implemented as a read-only operation over the existing snapshot history.
5//! No table-level flag is required: any AI-Lake table with multiple snapshots can
6//! produce a change stream as long as the old snapshots and data files are still
7//! reachable.
8//!
9//! The reader compares two snapshots and emits rows annotated with a change
10//! envelope (`_change_type`, `_snapshot_id`, `_sequence_number`, `_commit_timestamp`).
11
12use std::collections::{HashMap, HashSet};
13use std::ops::Sub;
14use std::sync::Arc;
15
16use ailake_catalog::{
17    manifest_commit::{list_equality_deletes_from_metadata, list_files_from_metadata},
18    read_equality_delete_values as read_eq_delete_avro, CatalogProvider, DataFileEntry,
19    EqualityDeleteFile, IcebergMetadata, IcebergSnapshot, SchemaField, SnapshotId, TableIdent,
20};
21use ailake_core::{AilakeError, AilakeResult};
22use ailake_file::AilakeFileReader;
23use ailake_store::Store;
24use arrow_array::{
25    Array, ArrayRef, FixedSizeListArray, Float32Array, Int64Array, RecordBatch, StringArray,
26    UInt32Array,
27};
28use arrow_schema::{DataType, Field, Schema, SchemaRef};
29use arrow_select::take::take;
30use roaring::RoaringBitmap;
31
32use crate::dv::load_deletion_vector;
33use crate::schema_filler::SchemaFiller;
34
35/// Type of change for a single row.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ChangeType {
38    Insert,
39    UpdateBefore,
40    UpdateAfter,
41    Delete,
42}
43
44impl ChangeType {
45    fn as_str(&self) -> &'static str {
46        match self {
47            ChangeType::Insert => "insert",
48            ChangeType::UpdateBefore => "update_before",
49            ChangeType::UpdateAfter => "update_after",
50            ChangeType::Delete => "delete",
51        }
52    }
53}
54
55/// One changed row, carrying the row data plus CDC metadata.
56#[derive(Debug, Clone)]
57pub struct ChangeRecord {
58    pub row: RecordBatch,
59    pub change_type: ChangeType,
60    pub snapshot_id: SnapshotId,
61    pub sequence_number: i64,
62    pub timestamp_ms: i64,
63}
64
65/// Configuration for `read_changes`.
66#[derive(Debug, Clone, Default)]
67pub struct ChangeReaderConfig {
68    /// Start snapshot (inclusive). When `None`, the parent of `end_snapshot_id` is used.
69    pub start_snapshot_id: Option<SnapshotId>,
70    /// End snapshot (inclusive). When `None`, the current snapshot is used.
71    pub end_snapshot_id: Option<SnapshotId>,
72    /// Primary-key column names. Required for update coalescing.
73    pub pk_columns: Vec<String>,
74    /// When `true`, convert a `DELETE` + `INSERT` pair with the same PK within the
75    /// same snapshot into `UPDATE_BEFORE` + `UPDATE_AFTER`.
76    pub coalesce_updates: bool,
77}
78
79/// Read the change stream between two snapshots of an AI-Lake table.
80///
81/// Returns a single `RecordBatch` containing all changed rows plus the CDC
82/// envelope columns `_change_type`, `_snapshot_id`, `_sequence_number`, and
83/// `_commit_timestamp`.
84///
85/// # Semantics
86/// - `INSERT`: rows in data files that appear only in the end snapshot.
87/// - `DELETE`: rows removed by equality deletes or deletion vectors that are
88///   new in the end snapshot, or rows in data files that exist only in the
89///   start snapshot.
90/// - `UPDATE_BEFORE` / `UPDATE_AFTER`: emitted only when `coalesce_updates`
91///   is enabled and a matching PK is both deleted and inserted within the
92///   same end snapshot.
93pub async fn read_changes(
94    catalog: Arc<dyn CatalogProvider>,
95    store: Arc<dyn Store>,
96    table: &TableIdent,
97    config: ChangeReaderConfig,
98) -> AilakeResult<RecordBatch> {
99    let meta = catalog.load_raw_metadata(table).await?;
100    let (start_id, end_id) =
101        resolve_snapshots(&meta, config.start_snapshot_id, config.end_snapshot_id)?;
102
103    let _start_snap = find_snapshot(&meta, start_id)?;
104    let end_snap = find_snapshot(&meta, end_id)?;
105
106    let vector_column = meta
107        .properties
108        .get("ailake.vector-column")
109        .cloned()
110        .unwrap_or_else(|| "embedding".to_string());
111    let dim = meta
112        .properties
113        .get("ailake.vector-dim")
114        .and_then(|s| s.parse::<u32>().ok())
115        .unwrap_or(0);
116
117    let schema_fields = meta.to_table_metadata().schema_fields;
118
119    let start_files = list_files_from_metadata(&*store, &meta, Some(start_id)).await?;
120    let end_files = list_files_from_metadata(&*store, &meta, Some(end_id)).await?;
121    let start_deletes = list_equality_deletes_from_metadata(&*store, &meta, Some(start_id)).await?;
122    let end_deletes = list_equality_deletes_from_metadata(&*store, &meta, Some(end_id)).await?;
123
124    let start_file_map: HashMap<&str, &DataFileEntry> =
125        start_files.iter().map(|f| (f.path.as_str(), f)).collect();
126    let end_file_map: HashMap<&str, &DataFileEntry> =
127        end_files.iter().map(|f| (f.path.as_str(), f)).collect();
128
129    let mut records: Vec<ChangeRecord> = Vec::new();
130
131    // Files only in end → all surviving rows are INSERT.
132    for file in &end_files {
133        if !start_file_map.contains_key(file.path.as_str()) {
134            let dv = load_dv(&store, file).await?;
135            let batch =
136                read_file_as_batch(&*store, file, &vector_column, dim, &schema_fields).await?;
137            let batch = apply_dv(batch, dv.as_ref())?;
138            for row in split_batch(batch)? {
139                records.push(ChangeRecord {
140                    row,
141                    change_type: ChangeType::Insert,
142                    snapshot_id: end_snap.snapshot_id,
143                    sequence_number: end_snap.sequence_number,
144                    timestamp_ms: end_snap.timestamp_ms,
145                });
146            }
147        }
148    }
149
150    // Files only in start → all rows are DELETE.
151    for file in &start_files {
152        if !end_file_map.contains_key(file.path.as_str()) {
153            let batch =
154                read_file_as_batch(&*store, file, &vector_column, dim, &schema_fields).await?;
155            for row in split_batch(batch)? {
156                records.push(ChangeRecord {
157                    row,
158                    change_type: ChangeType::Delete,
159                    snapshot_id: end_snap.snapshot_id,
160                    sequence_number: end_snap.sequence_number,
161                    timestamp_ms: end_snap.timestamp_ms,
162                });
163            }
164        }
165    }
166
167    // Files in both → detect newly deleted rows via deletion vectors.
168    for file in &end_files {
169        if let Some(start_file) = start_file_map.get(file.path.as_str()) {
170            let start_dv = load_dv(&store, start_file).await?;
171            let end_dv = load_dv(&store, file).await?;
172            if dv_bitmap(&start_dv) != dv_bitmap(&end_dv) {
173                let batch =
174                    read_file_as_batch(&*store, file, &vector_column, dim, &schema_fields).await?;
175                let deleted = diff_dv(start_dv.as_ref(), end_dv.as_ref());
176                let deleted_batch = take_rows(&batch, &deleted)?;
177                for row in split_batch(deleted_batch)? {
178                    records.push(ChangeRecord {
179                        row,
180                        change_type: ChangeType::Delete,
181                        snapshot_id: end_snap.snapshot_id,
182                        sequence_number: end_snap.sequence_number,
183                        timestamp_ms: end_snap.timestamp_ms,
184                    });
185                }
186            }
187        }
188    }
189
190    // Equality deletes added between snapshots → DELETE full rows that match.
191    // We read the raw data files (files present in both snapshots) and emit the
192    // actual deleted rows, so coalesced updates get a complete UPDATE_BEFORE.
193    let new_eq_predicates =
194        collect_equality_predicates(&store, &start_deletes, &end_deletes).await?;
195    if !new_eq_predicates.is_empty() {
196        for file in &end_files {
197            if let Some(start_file) = start_file_map.get(file.path.as_str()) {
198                let start_dv = load_dv(&store, start_file).await?;
199                let end_dv = load_dv(&store, file).await?;
200                let batch =
201                    read_file_as_batch(&*store, file, &vector_column, dim, &schema_fields).await?;
202
203                // Rows already emitted as DV deletes.
204                let dv_deleted: HashSet<u32> = diff_dv(start_dv.as_ref(), end_dv.as_ref())
205                    .into_iter()
206                    .collect();
207
208                // Rows matching new equality-delete predicates.
209                let eq_deleted =
210                    find_equality_deleted_rows(&batch, file.sequence_number, &new_eq_predicates);
211
212                let mut deleted: Vec<u32> = dv_deleted
213                    .iter()
214                    .copied()
215                    .chain(eq_deleted.into_iter().filter(|i| !dv_deleted.contains(i)))
216                    .collect();
217                deleted.sort_unstable();
218                deleted.dedup();
219
220                let deleted_batch = take_rows(&batch, &deleted)?;
221                for row in split_batch(deleted_batch)? {
222                    records.push(ChangeRecord {
223                        row,
224                        change_type: ChangeType::Delete,
225                        snapshot_id: end_snap.snapshot_id,
226                        sequence_number: end_snap.sequence_number,
227                        timestamp_ms: end_snap.timestamp_ms,
228                    });
229                }
230            }
231        }
232    }
233
234    // Optional update coalescing.
235    if config.coalesce_updates && !config.pk_columns.is_empty() {
236        records = coalesce_updates(records, &config.pk_columns)?;
237    }
238
239    build_change_batch(records)
240}
241
242/// Resolve start/end snapshot IDs, defaulting to parent/current when omitted.
243fn resolve_snapshots(
244    meta: &IcebergMetadata,
245    start: Option<SnapshotId>,
246    end: Option<SnapshotId>,
247) -> AilakeResult<(SnapshotId, SnapshotId)> {
248    let end_id = match end {
249        Some(id) => id,
250        None => meta
251            .current_snapshot_id
252            .ok_or_else(|| AilakeError::Catalog("table has no current snapshot".into()))?,
253    };
254
255    let start_id = match start {
256        Some(id) => id,
257        None => {
258            let end_snap = find_snapshot(meta, end_id)?;
259            match end_snap.parent_snapshot_id {
260                Some(id) => id,
261                None => {
262                    return Err(AilakeError::Catalog(
263                        "no start snapshot provided and end snapshot has no parent".into(),
264                    ))
265                }
266            }
267        }
268    };
269
270    Ok((start_id, end_id))
271}
272
273fn find_snapshot(meta: &IcebergMetadata, id: SnapshotId) -> AilakeResult<IcebergSnapshot> {
274    meta.snapshots
275        .iter()
276        .find(|s| s.snapshot_id == id)
277        .cloned()
278        .ok_or_else(|| AilakeError::Catalog(format!("snapshot {id} not found")))
279}
280
281async fn load_dv(
282    store: &Arc<dyn Store>,
283    file: &DataFileEntry,
284) -> AilakeResult<Option<RoaringBitmap>> {
285    match &file.deletion_vector {
286        Some(dv) => match load_deletion_vector(store, dv).await {
287            Ok(bm) => Ok(Some(bm)),
288            Err(e) => {
289                tracing::warn!("cdc: failed to load deletion vector for {}: {e}", file.path);
290                Ok(None)
291            }
292        },
293        None => Ok(None),
294    }
295}
296
297fn dv_bitmap(dv: &Option<RoaringBitmap>) -> RoaringBitmap {
298    dv.clone().unwrap_or_default()
299}
300
301fn diff_dv(start: Option<&RoaringBitmap>, end: Option<&RoaringBitmap>) -> Vec<u32> {
302    let start = start.cloned().unwrap_or_default();
303    let end = end.cloned().unwrap_or_default();
304    (end.sub(start)).iter().collect()
305}
306
307async fn read_file_as_batch(
308    store: &dyn Store,
309    file: &DataFileEntry,
310    vector_column: &str,
311    dim: u32,
312    schema_fields: &[SchemaField],
313) -> AilakeResult<RecordBatch> {
314    let bytes = store.get(&file.path).await?;
315    let reader = AilakeFileReader::new(bytes, vector_column, dim);
316    let (mut batch, vectors) = reader.read_parquet()?;
317    batch = SchemaFiller::fill(batch, schema_fields)?;
318
319    // Re-append the vector column as a FixedSizeList<Float32> if present.
320    if !vectors.is_empty() && dim > 0 {
321        batch = append_vector_column(batch, &vectors, vector_column, dim)?;
322    }
323
324    Ok(batch)
325}
326
327fn append_vector_column(
328    batch: RecordBatch,
329    vectors: &[Vec<f32>],
330    vector_column: &str,
331    dim: u32,
332) -> AilakeResult<RecordBatch> {
333    use arrow_schema::Field;
334    let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
335    let item_field = Arc::new(Field::new("item", DataType::Float32, false));
336    let values_arr = Arc::new(Float32Array::from(flat)) as ArrayRef;
337    let vec_col = FixedSizeListArray::new(Arc::clone(&item_field), dim as i32, values_arr, None);
338    let mut fields: Vec<Field> = batch
339        .schema()
340        .fields()
341        .iter()
342        .map(|f| (**f).clone())
343        .collect();
344    let mut cols: Vec<ArrayRef> = batch.columns().to_vec();
345    fields.push(Field::new(
346        vector_column,
347        DataType::FixedSizeList(Arc::clone(&item_field), dim as i32),
348        true,
349    ));
350    cols.push(Arc::new(vec_col));
351    RecordBatch::try_new(Arc::new(Schema::new(fields)), cols)
352        .map_err(|e| AilakeError::Arrow(e.to_string()))
353}
354
355fn apply_dv(batch: RecordBatch, dv: Option<&RoaringBitmap>) -> AilakeResult<RecordBatch> {
356    let Some(dv) = dv else {
357        return Ok(batch);
358    };
359    if dv.is_empty() {
360        return Ok(batch);
361    }
362    let keep: Vec<u32> = (0..batch.num_rows() as u32)
363        .filter(|i| !dv.contains(*i))
364        .collect();
365    take_rows(&batch, &keep)
366}
367
368fn take_rows(batch: &RecordBatch, indices: &[u32]) -> AilakeResult<RecordBatch> {
369    if indices.is_empty() {
370        return Ok(RecordBatch::new_empty(batch.schema()));
371    }
372    let idx_arr = UInt32Array::from(indices.to_vec());
373    let cols: Vec<ArrayRef> = batch
374        .columns()
375        .iter()
376        .map(|col| {
377            take(col.as_ref(), &idx_arr, None).map_err(|e| AilakeError::Arrow(e.to_string()))
378        })
379        .collect::<AilakeResult<_>>()?;
380    RecordBatch::try_new(batch.schema(), cols).map_err(|e| AilakeError::Arrow(e.to_string()))
381}
382
383fn split_batch(batch: RecordBatch) -> AilakeResult<Vec<RecordBatch>> {
384    let mut out = Vec::with_capacity(batch.num_rows());
385    for i in 0..batch.num_rows() {
386        let idx = UInt32Array::from(vec![i as u32]);
387        let cols: Vec<ArrayRef> = batch
388            .columns()
389            .iter()
390            .map(|col| {
391                take(col.as_ref(), &idx, None).map_err(|e| AilakeError::Arrow(e.to_string()))
392            })
393            .collect::<AilakeResult<_>>()?;
394        out.push(
395            RecordBatch::try_new(batch.schema(), cols)
396                .map_err(|e| AilakeError::Arrow(e.to_string()))?,
397        );
398    }
399    Ok(out)
400}
401
402async fn read_equality_delete_values(
403    store: &Arc<dyn Store>,
404    eq_delete: &EqualityDeleteFile,
405) -> AilakeResult<Vec<(String, String)>> {
406    // Prefer the write-path hint when available (e.g. DuckLake in-memory entries),
407    // otherwise load the Avro delete file from the store.
408    if let Some((col, vals)) = &eq_delete.inline_values {
409        return Ok(vals.iter().map(|v| (col.clone(), v.clone())).collect());
410    }
411    let bytes = store.get(&eq_delete.path).await?;
412    read_eq_delete_avro(&bytes).map_err(|e| {
413        AilakeError::Catalog(format!(
414            "failed to read equality delete {}: {e}",
415            eq_delete.path
416        ))
417    })
418}
419
420/// Build a list of new equality-delete predicates added between the start and
421/// end snapshots. Each predicate is `(column, value, delete_sequence_number)`.
422async fn collect_equality_predicates(
423    store: &Arc<dyn Store>,
424    start_deletes: &[EqualityDeleteFile],
425    end_deletes: &[EqualityDeleteFile],
426) -> AilakeResult<Vec<(String, String, i64)>> {
427    let mut predicates = Vec::new();
428    for ed in end_deletes {
429        if start_deletes.iter().any(|sed| sed.path == ed.path) {
430            continue;
431        }
432        let seq = ed.sequence_number;
433        let values = read_equality_delete_values(store, ed).await?;
434        for (col, val) in values {
435            predicates.push((col, val, seq));
436        }
437    }
438    Ok(predicates)
439}
440
441/// Return row indices in `batch` that match any equality-delete predicate whose
442/// sequence number is strictly greater than the data file's sequence number.
443fn find_equality_deleted_rows(
444    batch: &RecordBatch,
445    file_sequence_number: i64,
446    predicates: &[(String, String, i64)],
447) -> Vec<u32> {
448    let mut matches = Vec::new();
449    let schema = batch.schema();
450    for (col, val, delete_seq) in predicates {
451        if *delete_seq <= file_sequence_number {
452            continue;
453        }
454        let Ok(col_idx) = schema.index_of(col) else {
455            continue;
456        };
457        let array = batch.column(col_idx);
458        for row in 0..batch.num_rows() {
459            if array_value_to_string(array, row) == *val {
460                matches.push(row as u32);
461            }
462        }
463    }
464    matches.sort_unstable();
465    matches.dedup();
466    matches
467}
468
469fn coalesce_updates(
470    records: Vec<ChangeRecord>,
471    pk_columns: &[String],
472) -> AilakeResult<Vec<ChangeRecord>> {
473    let mut inserts_by_pk: HashMap<String, Vec<usize>> = HashMap::new();
474    let mut deletes_by_pk: HashMap<String, Vec<usize>> = HashMap::new();
475
476    for (i, rec) in records.iter().enumerate() {
477        let key = pk_key(&rec.row, pk_columns)?;
478        match rec.change_type {
479            ChangeType::Insert => inserts_by_pk.entry(key).or_default().push(i),
480            ChangeType::Delete => deletes_by_pk.entry(key).or_default().push(i),
481            _ => {}
482        }
483    }
484
485    let mut used = HashSet::new();
486    let mut pairs: Vec<(usize, usize)> = Vec::new();
487
488    // First pass: pair each DELETE with the first unpaired matching INSERT.
489    for (key, del_idxs) in deletes_by_pk {
490        for del_i in del_idxs {
491            if used.contains(&del_i) {
492                continue;
493            }
494            if let Some(ins_idxs) = inserts_by_pk.get(&key) {
495                if let Some(&ins_i) = ins_idxs.iter().find(|idx| !used.contains(*idx)) {
496                    used.insert(del_i);
497                    used.insert(ins_i);
498                    pairs.push((del_i, ins_i));
499                }
500            }
501        }
502    }
503
504    // Build output preserving original order of unpaired records, expanding each
505    // paired DELETE+INSERT into UPDATE_BEFORE + UPDATE_AFTER at the DELETE position.
506    let mut out: Vec<ChangeRecord> = Vec::with_capacity(records.len());
507    let mut pair_idx = 0;
508    for (i, rec) in records.iter().enumerate() {
509        if used.contains(&i) {
510            // If this is the DELETE of a pair, emit the coalesced update records.
511            if pair_idx < pairs.len() && pairs[pair_idx].0 == i {
512                let (_, ins_i) = pairs[pair_idx];
513                pair_idx += 1;
514                let delete_rec = &records[i];
515                let insert_rec = &records[ins_i];
516                out.push(ChangeRecord {
517                    row: delete_rec.row.clone(),
518                    change_type: ChangeType::UpdateBefore,
519                    snapshot_id: delete_rec.snapshot_id,
520                    sequence_number: delete_rec.sequence_number,
521                    timestamp_ms: delete_rec.timestamp_ms,
522                });
523                out.push(ChangeRecord {
524                    row: insert_rec.row.clone(),
525                    change_type: ChangeType::UpdateAfter,
526                    snapshot_id: insert_rec.snapshot_id,
527                    sequence_number: insert_rec.sequence_number,
528                    timestamp_ms: insert_rec.timestamp_ms,
529                });
530            }
531            continue;
532        }
533        out.push(rec.clone());
534    }
535
536    Ok(out)
537}
538
539fn pk_key(batch: &RecordBatch, pk_columns: &[String]) -> AilakeResult<String> {
540    let mut parts = Vec::with_capacity(pk_columns.len());
541    for col in pk_columns {
542        let idx = batch
543            .schema()
544            .index_of(col)
545            .map_err(|e| AilakeError::Arrow(format!("pk column {col} not found: {e}")))?;
546        let array = batch.column(idx);
547        let val = if array.is_null(0) {
548            "NULL".to_string()
549        } else {
550            array_value_to_string(array, 0)
551        };
552        parts.push(val);
553    }
554    Ok(parts.join("|"))
555}
556
557fn array_value_to_string(array: &ArrayRef, row: usize) -> String {
558    use arrow_array::cast::AsArray;
559    use arrow_schema::DataType;
560    if array.is_null(row) {
561        return "NULL".to_string();
562    }
563    match array.data_type() {
564        DataType::Utf8 => array.as_string::<i32>().value(row).to_string(),
565        DataType::LargeUtf8 => array.as_string::<i64>().value(row).to_string(),
566        DataType::Int32 => array
567            .as_primitive::<arrow_array::types::Int32Type>()
568            .value(row)
569            .to_string(),
570        DataType::Int64 => array
571            .as_primitive::<arrow_array::types::Int64Type>()
572            .value(row)
573            .to_string(),
574        DataType::Float32 => array
575            .as_primitive::<arrow_array::types::Float32Type>()
576            .value(row)
577            .to_string(),
578        DataType::Float64 => array
579            .as_primitive::<arrow_array::types::Float64Type>()
580            .value(row)
581            .to_string(),
582        DataType::Boolean => array.as_boolean().value(row).to_string(),
583        _ => format!("{:?}", array),
584    }
585}
586
587fn build_change_batch(records: Vec<ChangeRecord>) -> AilakeResult<RecordBatch> {
588    if records.is_empty() {
589        let schema = Arc::new(Schema::new(vec![
590            Field::new("_change_type", DataType::Utf8, false),
591            Field::new("_snapshot_id", DataType::Int64, false),
592            Field::new("_sequence_number", DataType::Int64, false),
593            Field::new("_commit_timestamp", DataType::Int64, false),
594        ]));
595        return Ok(RecordBatch::new_empty(schema));
596    }
597
598    // Records may have heterogeneous schemas (e.g. equality-delete DELETE rows
599    // carry only PK columns, while INSERT rows carry the full schema). Build the
600    // union schema and pad missing columns with nulls before concatenating.
601    let base_schema = union_schema(&records);
602    let records = records
603        .into_iter()
604        .map(|rec| normalize_to_schema(rec, &base_schema))
605        .collect::<AilakeResult<Vec<_>>>()?;
606
607    let mut fields: Vec<Field> = base_schema.fields().iter().map(|f| (**f).clone()).collect();
608    fields.push(Field::new("_change_type", DataType::Utf8, false));
609    fields.push(Field::new("_snapshot_id", DataType::Int64, false));
610    fields.push(Field::new("_sequence_number", DataType::Int64, false));
611    fields.push(Field::new("_commit_timestamp", DataType::Int64, false));
612    let out_schema = Arc::new(Schema::new(fields));
613
614    let mut base_cols: Vec<Vec<ArrayRef>> = vec![vec![]; base_schema.fields().len()];
615    let mut types = Vec::with_capacity(records.len());
616    let mut snap_ids = Vec::with_capacity(records.len());
617    let mut seqs = Vec::with_capacity(records.len());
618    let mut timestamps = Vec::with_capacity(records.len());
619
620    for rec in records {
621        types.push(rec.change_type.as_str());
622        snap_ids.push(rec.snapshot_id);
623        seqs.push(rec.sequence_number);
624        timestamps.push(rec.timestamp_ms);
625        for (i, col) in rec.row.columns().iter().enumerate() {
626            base_cols[i].push(col.clone());
627        }
628    }
629
630    use arrow_select::concat::concat;
631    let mut out_cols: Vec<ArrayRef> = Vec::with_capacity(base_schema.fields().len() + 4);
632    for col_parts in base_cols {
633        let arrays: Vec<&dyn Array> = col_parts.iter().map(|a| a.as_ref()).collect();
634        out_cols.push(concat(&arrays).map_err(|e| AilakeError::Arrow(e.to_string()))?);
635    }
636    out_cols.push(Arc::new(StringArray::from(types)) as ArrayRef);
637    out_cols.push(Arc::new(Int64Array::from(snap_ids)) as ArrayRef);
638    out_cols.push(Arc::new(Int64Array::from(seqs)) as ArrayRef);
639    out_cols.push(Arc::new(Int64Array::from(timestamps)) as ArrayRef);
640
641    RecordBatch::try_new(out_schema, out_cols).map_err(|e| AilakeError::Arrow(e.to_string()))
642}
643
644/// Build the union of all fields across record row schemas, preserving the
645/// order of the first occurrence of each field name. A field is made nullable
646/// if any record is missing it, because equality-delete rows (PK-only) or
647/// schema-drift inserts do not carry every column.
648fn union_schema(records: &[ChangeRecord]) -> SchemaRef {
649    let mut seen: HashMap<String, usize> = HashMap::new();
650    let mut fields: Vec<Field> = Vec::new();
651    for rec in records {
652        for f in rec.row.schema().fields() {
653            if let Some(&idx) = seen.get(f.name()) {
654                if f.is_nullable() && !fields[idx].is_nullable() {
655                    fields[idx] = Field::new(f.name(), f.data_type().clone(), true);
656                }
657            } else {
658                seen.insert(f.name().clone(), fields.len());
659                fields.push((**f).clone());
660            }
661        }
662    }
663    // Mark any field not present in every record as nullable.
664    let all_names: HashSet<String> = seen.keys().cloned().collect();
665    for rec in records {
666        let rec_names: HashSet<String> = rec
667            .row
668            .schema()
669            .fields()
670            .iter()
671            .map(|f| f.name().clone())
672            .collect();
673        for missing in all_names.difference(&rec_names) {
674            if let Some(&idx) = seen.get(missing) {
675                if !fields[idx].is_nullable() {
676                    fields[idx] =
677                        Field::new(fields[idx].name(), fields[idx].data_type().clone(), true);
678                }
679            }
680        }
681    }
682    Arc::new(Schema::new(fields))
683}
684
685/// Reorder/extend `rec.row` so it matches `schema`. Missing columns are filled
686/// with nulls of the correct Arrow type; extra columns are dropped.
687fn normalize_to_schema(rec: ChangeRecord, schema: &SchemaRef) -> AilakeResult<ChangeRecord> {
688    if rec.row.schema() == *schema {
689        return Ok(rec);
690    }
691    let n = rec.row.num_rows();
692    let mut cols: Vec<ArrayRef> = Vec::with_capacity(schema.fields().len());
693    for field in schema.fields() {
694        if let Ok(idx) = rec.row.schema().index_of(field.name()) {
695            cols.push(rec.row.column(idx).clone());
696        } else {
697            cols.push(null_array_for(field.data_type(), n));
698        }
699    }
700    let batch = RecordBatch::try_new(schema.clone(), cols)
701        .map_err(|e| AilakeError::Arrow(e.to_string()))?;
702    Ok(ChangeRecord {
703        row: batch,
704        change_type: rec.change_type,
705        snapshot_id: rec.snapshot_id,
706        sequence_number: rec.sequence_number,
707        timestamp_ms: rec.timestamp_ms,
708    })
709}
710
711fn null_array_for(data_type: &DataType, n: usize) -> ArrayRef {
712    use arrow_array::*;
713    match data_type {
714        DataType::Utf8 => Arc::new(StringArray::from(vec![None::<&str>; n])) as ArrayRef,
715        DataType::LargeUtf8 => Arc::new(LargeStringArray::from(vec![None::<&str>; n])) as ArrayRef,
716        DataType::Int32 => Arc::new(Int32Array::from(vec![None::<i32>; n])) as ArrayRef,
717        DataType::Int64 => Arc::new(Int64Array::from(vec![None::<i64>; n])) as ArrayRef,
718        DataType::UInt32 => Arc::new(UInt32Array::from(vec![None::<u32>; n])) as ArrayRef,
719        DataType::Float32 => Arc::new(Float32Array::from(vec![None::<f32>; n])) as ArrayRef,
720        DataType::Float64 => Arc::new(Float64Array::from(vec![None::<f64>; n])) as ArrayRef,
721        DataType::Boolean => Arc::new(BooleanArray::from(vec![None::<bool>; n])) as ArrayRef,
722        DataType::FixedSizeList(item, dim) => {
723            let nulls = arrow_array::NullArray::new(n);
724            let values = Arc::new(null_array_for(item.data_type(), n * *dim as usize));
725            Arc::new(FixedSizeListArray::new(
726                Arc::clone(item),
727                *dim,
728                values,
729                nulls.nulls().cloned(),
730            )) as ArrayRef
731        }
732        DataType::List(item) => {
733            let offsets = arrow_buffer::OffsetBuffer::new(arrow_buffer::ScalarBuffer::from(vec![
734                    0i32;
735                    n + 1
736                ]));
737            let values = null_array_for(item.data_type(), 0);
738            Arc::new(ListArray::new(Arc::clone(item), offsets, values, None)) as ArrayRef
739        }
740        DataType::LargeList(item) => {
741            let offsets = arrow_buffer::OffsetBuffer::new(arrow_buffer::ScalarBuffer::from(vec![
742                    0i64;
743                    n + 1
744                ]));
745            let values = null_array_for(item.data_type(), 0);
746            Arc::new(LargeListArray::new(Arc::clone(item), offsets, values, None)) as ArrayRef
747        }
748        _ => Arc::new(arrow_array::NullArray::new(n)) as ArrayRef,
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    #[test]
757    fn change_type_strings() {
758        assert_eq!(ChangeType::Insert.as_str(), "insert");
759        assert_eq!(ChangeType::Delete.as_str(), "delete");
760        assert_eq!(ChangeType::UpdateBefore.as_str(), "update_before");
761        assert_eq!(ChangeType::UpdateAfter.as_str(), "update_after");
762    }
763}