mod common;
use arrow_array::{Array, Int32Array, RecordBatch};
use futures::TryStreamExt;
use paimon::table::IncrementalScanMode;
use common::incremental_helpers::{
make_batch, make_batch_with_kinds, make_partitioned_batch, memory_table, partitioned_pk_schema,
persist_table_schema, pk_schema, setup_dirs, write_batch, write_partitioned,
};
fn collect_pairs(batches: &[RecordBatch]) -> Vec<(i32, i32)> {
let mut rows = Vec::new();
for batch in batches {
let ids = batch
.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
let values = batch
.column(1)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for row in 0..batch.num_rows() {
rows.push((ids.value(row), values.value(row)));
}
}
rows.sort_unstable();
rows
}
async fn read_incremental_pairs(
table: &paimon::table::Table,
mode: IncrementalScanMode,
start_exclusive: i64,
end_inclusive: i64,
) -> Vec<(i32, i32)> {
let builder = table.new_read_builder();
let plan = builder
.new_incremental_scan(mode, start_exclusive, end_inclusive)
.plan()
.await
.unwrap();
let read = table.new_read_builder().new_read().unwrap();
let batches: Vec<RecordBatch> = read
.to_incremental_arrow(&plan)
.unwrap()
.try_collect()
.await
.unwrap();
collect_pairs(&batches)
}
async fn read_current_pairs(table: &paimon::table::Table) -> Vec<(i32, i32)> {
let builder = table.new_read_builder();
let plan = builder.new_scan().plan().await.unwrap();
let read = table.new_read_builder().new_read().unwrap();
let batches: Vec<RecordBatch> = read
.to_arrow(plan.splits())
.unwrap()
.try_collect()
.await
.unwrap();
collect_pairs(&batches)
}
async fn plan_incremental(
table: &paimon::table::Table,
mode: IncrementalScanMode,
start_exclusive: i64,
end_inclusive: i64,
) -> Result<paimon::table::IncrementalPlan, paimon::Error> {
table
.new_read_builder()
.new_incremental_scan(mode, start_exclusive, end_inclusive)
.plan()
.await
}
#[tokio::test]
async fn delta_between_snapshots_reads_only_append_snapshots_in_left_open_range() {
let table_path = "memory:/incremental_batch/delta_range";
let (file_io, table) = memory_table(
table_path,
pk_schema(&[
("changelog-producer", "none"),
("merge-engine", "deduplicate"),
("bucket", "1"),
]),
);
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
write_batch(&table, &make_batch(vec![1], vec![10])).await;
write_batch(&table, &make_batch(vec![2], vec![20])).await;
let rows = read_incremental_pairs(&table, IncrementalScanMode::Delta, 0, 2).await;
assert_eq!(rows, vec![(1, 10), (2, 20)]);
let rows = read_incremental_pairs(&table, IncrementalScanMode::Delta, 1, 2).await;
assert_eq!(rows, vec![(2, 20)]);
}
#[tokio::test]
async fn auto_uses_delta_when_changelog_producer_is_none() {
let table_path = "memory:/incremental_batch/auto_delta";
let (file_io, table) = memory_table(
table_path,
pk_schema(&[
("changelog-producer", "none"),
("merge-engine", "deduplicate"),
("bucket", "1"),
]),
);
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
write_batch(&table, &make_batch(vec![3], vec![30])).await;
write_batch(&table, &make_batch(vec![4], vec![40])).await;
let delta = read_incremental_pairs(&table, IncrementalScanMode::Delta, 0, 2).await;
let auto = read_incremental_pairs(&table, IncrementalScanMode::Auto, 0, 2).await;
assert_eq!(auto, delta);
}
#[tokio::test]
async fn delta_empty_range_returns_no_rows() {
let table_path = "memory:/incremental_batch/delta_empty";
let (file_io, table) = memory_table(
table_path,
pk_schema(&[
("changelog-producer", "none"),
("merge-engine", "deduplicate"),
("bucket", "1"),
]),
);
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
write_batch(&table, &make_batch(vec![1], vec![10])).await;
let plan = plan_incremental(&table, IncrementalScanMode::Delta, 1, 1)
.await
.unwrap();
assert!(plan.splits().is_empty());
assert_eq!(plan.mode(), IncrementalScanMode::Delta);
let rows = read_incremental_pairs(&table, IncrementalScanMode::Delta, 1, 1).await;
assert!(rows.is_empty());
}
#[tokio::test]
async fn delta_rejects_out_of_range_snapshot_ids() {
let table_path = "memory:/incremental_batch/delta_oob";
let (file_io, table) = memory_table(
table_path,
pk_schema(&[
("changelog-producer", "none"),
("merge-engine", "deduplicate"),
("bucket", "1"),
]),
);
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
write_batch(&table, &make_batch(vec![1], vec![10])).await;
let err = plan_incremental(&table, IncrementalScanMode::Delta, 0, 99)
.await
.unwrap_err();
assert!(
matches!(err, paimon::Error::DataInvalid { .. }),
"expected DataInvalid for end > latest, got {err:?}"
);
let err = plan_incremental(&table, IncrementalScanMode::Delta, -2, 1)
.await
.unwrap_err();
assert!(
matches!(err, paimon::Error::DataInvalid { .. }),
"expected DataInvalid for start < earliest-1, got {err:?}"
);
let err = plan_incremental(&table, IncrementalScanMode::Delta, 2, 1)
.await
.unwrap_err();
assert!(
matches!(err, paimon::Error::DataInvalid { .. }),
"expected DataInvalid for start > end, got {err:?}"
);
}
#[tokio::test]
async fn incremental_delta_scan_applies_partition_filter_from_read_builder() {
use paimon::spec::{Datum, PredicateBuilder};
use std::collections::HashMap;
let table_path = "memory:/incremental_batch/delta_partition_filter";
let (file_io, mut table) = memory_table(table_path, partitioned_pk_schema("1"));
table = table.copy_with_options(HashMap::from([(
"changelog-producer".to_string(),
"none".to_string(),
)]));
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
write_partitioned(&table, make_partitioned_batch(vec!["a"], vec![1], vec![10])).await;
write_partitioned(&table, make_partitioned_batch(vec!["b"], vec![2], vec![20])).await;
let filter = PredicateBuilder::new(table.schema().fields())
.equal("pt", Datum::String("a".to_string()))
.unwrap();
let mut builder = table.new_read_builder();
builder
.with_projection(&["id", "value"])
.unwrap()
.with_filter(filter);
let plan = builder
.new_incremental_scan(IncrementalScanMode::Delta, 0, 2)
.plan()
.await
.unwrap();
let read = builder.new_read().unwrap();
let batches: Vec<RecordBatch> = read
.to_incremental_arrow(&plan)
.unwrap()
.try_collect()
.await
.unwrap();
assert_eq!(collect_pairs(&batches), vec![(1, 10)]);
}
#[tokio::test]
async fn changelog_between_snapshots_reads_changelog_manifest_files() {
let table_path = "memory:/incremental_batch/changelog_range";
let (file_io, table) = memory_table(
table_path,
pk_schema(&[
("changelog-producer", "input"),
("merge-engine", "deduplicate"),
("bucket", "1"),
]),
);
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
let builder = table.new_write_builder();
let mut write = builder.new_write().unwrap();
write
.write_arrow_batch(&make_batch_with_kinds(vec![1, 1], vec![10, 20], vec![0, 2]))
.await
.unwrap();
let messages = write.prepare_commit().await.unwrap();
builder.new_commit().commit(messages).await.unwrap();
let rows = read_incremental_pairs(&table, IncrementalScanMode::Changelog, 0, 1).await;
assert_eq!(rows, vec![(1, 10), (1, 20)]);
}
#[tokio::test]
async fn partial_update_ignore_delete_filters_data_and_input_changelog() {
let table_path = "memory:/incremental_batch/partial_update_ignore_delete";
let (file_io, table) = memory_table(
table_path,
pk_schema(&[
("changelog-producer", "input"),
("merge-engine", "partial-update"),
("partial-update.ignore-delete", "true"),
("bucket", "1"),
]),
);
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
write_batch(
&table,
&make_batch_with_kinds(vec![1, 1, 2, 1], vec![10, 999, 200, 20], vec![0, 3, 1, 2]),
)
.await;
assert_eq!(read_current_pairs(&table).await, vec![(1, 20)]);
assert_eq!(
read_incremental_pairs(&table, IncrementalScanMode::Changelog, 0, 1).await,
vec![(1, 10), (1, 20)]
);
}
#[tokio::test]
async fn changelog_multi_snapshot_range_is_ordered_and_left_open() {
let table_path = "memory:/incremental_batch/changelog_multi";
let (file_io, table) = memory_table(
table_path,
pk_schema(&[
("changelog-producer", "input"),
("merge-engine", "deduplicate"),
("bucket", "1"),
]),
);
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
write_batch(&table, &make_batch_with_kinds(vec![1], vec![10], vec![0])).await;
write_batch(&table, &make_batch_with_kinds(vec![2], vec![20], vec![0])).await;
let all = read_incremental_pairs(&table, IncrementalScanMode::Changelog, 0, 2).await;
assert_eq!(all, vec![(1, 10), (2, 20)]);
let second_only = read_incremental_pairs(&table, IncrementalScanMode::Changelog, 1, 2).await;
assert_eq!(second_only, vec![(2, 20)]);
}
#[tokio::test]
async fn auto_uses_changelog_when_producer_is_input() {
let table_path = "memory:/incremental_batch/auto_changelog";
let (file_io, table) = memory_table(
table_path,
pk_schema(&[
("changelog-producer", "input"),
("merge-engine", "deduplicate"),
("bucket", "1"),
]),
);
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
write_batch(
&table,
&make_batch_with_kinds(vec![1, 1], vec![10, 20], vec![0, 2]),
)
.await;
let plan = plan_incremental(&table, IncrementalScanMode::Auto, 0, 1)
.await
.unwrap();
assert_eq!(plan.mode(), IncrementalScanMode::Changelog);
let auto = read_incremental_pairs(&table, IncrementalScanMode::Auto, 0, 1).await;
let changelog = read_incremental_pairs(&table, IncrementalScanMode::Changelog, 0, 1).await;
assert_eq!(auto, changelog);
assert_eq!(auto, vec![(1, 10), (1, 20)]);
}
#[tokio::test]
async fn incremental_changelog_scan_applies_partition_filter_from_read_builder() {
use paimon::spec::{Datum, PredicateBuilder};
use std::collections::HashMap;
let table_path = "memory:/incremental_batch/changelog_partition_filter";
let (file_io, mut table) = memory_table(table_path, partitioned_pk_schema("1"));
table = table.copy_with_options(HashMap::from([(
"changelog-producer".to_string(),
"input".to_string(),
)]));
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
let builder = table.new_write_builder();
let mut write = builder.new_write().unwrap();
let schema = std::sync::Arc::new(arrow_schema::Schema::new(vec![
arrow_schema::Field::new("pt", arrow_schema::DataType::Utf8, false),
arrow_schema::Field::new("id", arrow_schema::DataType::Int32, false),
arrow_schema::Field::new("value", arrow_schema::DataType::Int32, false),
arrow_schema::Field::new("_VALUE_KIND", arrow_schema::DataType::Int8, false),
]));
let batch = RecordBatch::try_new(
schema,
vec![
std::sync::Arc::new(arrow_array::StringArray::from(vec!["a", "b"])),
std::sync::Arc::new(arrow_array::Int32Array::from(vec![1, 2])),
std::sync::Arc::new(arrow_array::Int32Array::from(vec![10, 20])),
std::sync::Arc::new(arrow_array::Int8Array::from(vec![0, 0])),
],
)
.unwrap();
write.write_arrow_batch(&batch).await.unwrap();
let messages = write.prepare_commit().await.unwrap();
builder.new_commit().commit(messages).await.unwrap();
let filter = PredicateBuilder::new(table.schema().fields())
.equal("pt", Datum::String("a".to_string()))
.unwrap();
let mut builder = table.new_read_builder();
builder
.with_projection(&["id", "value"])
.unwrap()
.with_filter(filter);
let plan = builder
.new_incremental_scan(IncrementalScanMode::Changelog, 0, 1)
.plan()
.await
.unwrap();
let read = builder.new_read().unwrap();
let batches: Vec<RecordBatch> = read
.to_incremental_arrow(&plan)
.unwrap()
.try_collect()
.await
.unwrap();
assert_eq!(collect_pairs(&batches), vec![(1, 10)]);
}
#[tokio::test]
async fn diff_mode_is_unsupported() {
let table_path = "memory:/incremental_batch/diff_unsupported";
let (file_io, table) = memory_table(
table_path,
pk_schema(&[
("changelog-producer", "none"),
("merge-engine", "deduplicate"),
("bucket", "1"),
]),
);
setup_dirs(&file_io, table_path).await;
persist_table_schema(&file_io, table_path, table.schema()).await;
write_batch(&table, &make_batch(vec![1], vec![10])).await;
write_batch(&table, &make_batch(vec![2], vec![20])).await;
let err = plan_incremental(&table, IncrementalScanMode::Diff, 1, 2)
.await
.unwrap_err();
assert!(
matches!(err, paimon::Error::Unsupported { .. }),
"expected Unsupported for Diff, got {err:?}"
);
}