This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 677cc06 feat: support existing changelog incremental reads (#509)
677cc06 is described below
commit 677cc065497ca8f9e198caade5c7c3356d59420f
Author: Pandas <[email protected]>
AuthorDate: Thu Jul 16 15:27:14 2026 +0800
feat: support existing changelog incremental reads (#509)
---
crates/paimon/src/spec/schema.rs | 5 +
crates/paimon/src/table/audit_log_table.rs | 87 +++++
crates/paimon/src/table/incremental_scan.rs | 26 +-
crates/paimon/src/table/mod.rs | 2 +
crates/paimon/src/table/table_read.rs | 217 ++++++++++++-
crates/paimon/src/table/table_scan.rs | 31 ++
crates/paimon/tests/audit_log_table_test.rs | 349 +++++++++++++++++++++
crates/paimon/tests/common/incremental_helpers.rs | 22 +-
crates/paimon/tests/incremental_batch_scan_test.rs | 163 +++++++++-
9 files changed, 886 insertions(+), 16 deletions(-)
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index c00d6de..f92e5ef 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -811,6 +811,11 @@ pub const VALUE_KIND_FIELD_NAME: &str = "_VALUE_KIND";
/// Must match Java Paimon's `SpecialFields.VALUE_KIND` (Integer.MAX_VALUE -
2).
pub const VALUE_KIND_FIELD_ID: i32 = i32::MAX - 2;
+pub const ROW_KIND_FIELD_NAME: &str = "rowkind";
+
+/// Must match Java Paimon's `SpecialFields.ROW_KIND` (Integer.MAX_VALUE - 4).
+pub const ROW_KIND_FIELD_ID: i32 = i32::MAX - 4;
+
/// Data field for paimon table.
///
/// Impl Reference:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/types/DataField.java#L40>
diff --git a/crates/paimon/src/table/audit_log_table.rs
b/crates/paimon/src/table/audit_log_table.rs
new file mode 100644
index 0000000..e5ce757
--- /dev/null
+++ b/crates/paimon/src/table/audit_log_table.rs
@@ -0,0 +1,87 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::incremental_scan::{IncrementalPlan, IncrementalScan,
IncrementalScanMode};
+use super::{ArrowRecordBatchStream, Table};
+use crate::spec::{
+ BigIntType, DataField, DataType, VarCharType, ROW_KIND_FIELD_ID,
ROW_KIND_FIELD_NAME,
+ SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME,
+};
+
+/// Wrapper that exposes table rows with a leading `rowkind` audit column.
+///
+/// Incremental reads produce:
+/// - Delta: primary-key rows use physical `_VALUE_KIND`; append rows are `+I`
+/// - Changelog: kinds come from physical `_VALUE_KIND` (`+I`/`-U`/`+U`/`-D`)
+/// - Diff: not implemented in this release
+#[derive(Debug, Clone)]
+pub struct AuditLogTable {
+ wrapped: Table,
+}
+
+const TABLE_READ_SEQUENCE_NUMBER_ENABLED: &str =
"table-read.sequence-number.enabled";
+
+impl AuditLogTable {
+ pub fn new(wrapped: Table) -> Self {
+ Self { wrapped }
+ }
+
+ pub fn wrapped(&self) -> &Table {
+ &self.wrapped
+ }
+
+ /// Logical fields: `rowkind` (+ optional `_SEQUENCE_NUMBER`) then table
fields.
+ pub fn fields(&self) -> crate::Result<Vec<DataField>> {
+ let mut fields =
Vec::with_capacity(self.wrapped.schema().fields().len() + 2);
+ fields.push(DataField::new(
+ ROW_KIND_FIELD_ID,
+ ROW_KIND_FIELD_NAME.to_string(),
+ DataType::VarChar(VarCharType::string_type()),
+ ));
+ if self.sequence_number_enabled() {
+ fields.push(DataField::new(
+ SEQUENCE_NUMBER_FIELD_ID,
+ SEQUENCE_NUMBER_FIELD_NAME.to_string(),
+ DataType::BigInt(BigIntType::new()),
+ ));
+ }
+ fields.extend(self.wrapped.schema().fields().iter().cloned());
+ Ok(fields)
+ }
+
+ fn sequence_number_enabled(&self) -> bool {
+ self.wrapped
+ .schema()
+ .options()
+ .get(TABLE_READ_SEQUENCE_NUMBER_ENABLED)
+ .is_some_and(|v| v.eq_ignore_ascii_case("true"))
+ }
+
+ pub fn new_incremental_scan(
+ &self,
+ mode: IncrementalScanMode,
+ start_exclusive: i64,
+ end_inclusive: i64,
+ ) -> IncrementalScan<'_> {
+ IncrementalScan::for_table(&self.wrapped, mode, start_exclusive,
end_inclusive)
+ }
+
+ pub fn to_arrow(&self, plan: &IncrementalPlan) ->
crate::Result<ArrowRecordBatchStream> {
+ let read = self.wrapped.new_read_builder().new_read()?;
+ read.to_audit_log_arrow(plan)
+ }
+}
diff --git a/crates/paimon/src/table/incremental_scan.rs
b/crates/paimon/src/table/incremental_scan.rs
index 8088d82..3aa169e 100644
--- a/crates/paimon/src/table/incremental_scan.rs
+++ b/crates/paimon/src/table/incremental_scan.rs
@@ -26,10 +26,11 @@ use crate::spec::{CommitKind, CoreOptions};
pub enum IncrementalScanMode {
/// Read data files from APPEND snapshots in the range (delta manifests).
Delta,
- /// Read changelog manifest files in the range.
+ /// Read existing changelog manifest files in the range.
///
- /// Not fully implemented in this release; planning returns
- /// [`Error::Unsupported`](crate::Error::Unsupported).
+ /// Skips [`OVERWRITE`](crate::spec::CommitKind::OVERWRITE) snapshots and
+ /// snapshots without a `changelog_manifest_list`. Does not generate
+ /// changelogs (no compact/lookup producer path).
Changelog,
/// Resolve to [`Delta`](Self::Delta) when `changelog-producer=none`,
/// otherwise to [`Changelog`](Self::Changelog).
@@ -204,10 +205,21 @@ impl<'a> IncrementalScan<'a> {
}
async fn plan_changelog(&self, mode: IncrementalScanMode) ->
crate::Result<IncrementalPlan> {
- let _ = mode;
- Err(crate::Error::Unsupported {
- message: "Batch incremental Changelog scan is not implemented
yet".to_string(),
- })
+ let mut splits = Vec::new();
+ for snapshot_id in (self.start_exclusive + 1)..=self.end_inclusive {
+ let snapshot =
self.snapshot_manager.get_snapshot(snapshot_id).await?;
+ // OVERWRITE rewrites table contents and does not contribute
changelog
+ // files for batch incremental reads (Java
IncrementalChangelogStartingScanner).
+ if snapshot.commit_kind() == &CommitKind::OVERWRITE {
+ continue;
+ }
+ if snapshot.changelog_manifest_list().is_none() {
+ continue;
+ }
+ let plan = self.scan.plan_snapshot_changelog(&snapshot).await?;
+
splits.extend(plan.splits().iter().cloned().map(IncrementalSplit::Data));
+ }
+ Ok(IncrementalPlan::new(mode, splits))
}
async fn plan_diff(&self, mode: IncrementalScanMode) ->
crate::Result<IncrementalPlan> {
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 2a239e9..cf581aa 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -18,6 +18,7 @@
//! Table API for Apache Paimon
pub(crate) mod aggregator;
+mod audit_log_table;
pub(crate) mod bin_pack;
mod bitmap_global_index_reader;
mod blob_resolver;
@@ -85,6 +86,7 @@ mod write_builder;
use crate::Result;
use arrow_array::RecordBatch;
+pub use audit_log_table::AuditLogTable;
pub use branch_manager::BranchManager;
pub use btree_global_index_build_builder::BTreeGlobalIndexBuildBuilder;
pub use commit_message::CommitMessage;
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index f9e31ca..f3d1f64 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -22,8 +22,17 @@ use super::incremental_scan::{IncrementalPlan,
IncrementalScanMode, IncrementalS
use super::kv_file_reader::{KeyValueFileReader, KeyValueReadConfig};
use super::read_builder::split_scan_predicates;
use super::{ArrowRecordBatchStream, Table};
-use crate::spec::{CoreOptions, DataField, MergeEngine, Predicate};
+use crate::arrow::build_target_arrow_schema;
+use crate::spec::{
+ BigIntType, CoreOptions, DataField, DataType, MergeEngine, Predicate,
TinyIntType,
+ ROW_KIND_FIELD_ID, ROW_KIND_FIELD_NAME, SEQUENCE_NUMBER_FIELD_ID,
SEQUENCE_NUMBER_FIELD_NAME,
+ VALUE_KIND_FIELD_ID, VALUE_KIND_FIELD_NAME,
+};
use crate::DataSplit;
+use arrow_array::{Array, ArrayRef, RecordBatch, StringArray};
+use arrow_schema::Schema as ArrowSchema;
+use futures::StreamExt;
+use std::sync::Arc;
/// Table read: reads data from splits (e.g. produced by [TableScan::plan]).
///
@@ -124,6 +133,24 @@ impl<'a> TableRead<'a> {
}),
}
}
+
+ /// Returns an audit-log [`ArrowRecordBatchStream`] for an incremental
plan.
+ ///
+ /// Output schema is `rowkind` (+ optional `_SEQUENCE_NUMBER`) followed by
+ /// the projected user columns. Primary-key Delta and Changelog rows take
+ /// kinds from `_VALUE_KIND`; append-only Delta rows are `+I`. Diff remains
+ /// unsupported.
+ pub fn to_audit_log_arrow(
+ &self,
+ plan: &IncrementalPlan,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ match &self.0 {
+ TableReadKind::Paimon(read) => read.to_audit_log_arrow(plan),
+ TableReadKind::Format(_) => Err(crate::Error::Unsupported {
+ message: "Format tables do not support audit log batch
read".to_string(),
+ }),
+ }
+ }
}
#[derive(Debug, Clone)]
@@ -205,6 +232,111 @@ impl<'a> PaimonTableRead<'a> {
self.new_data_file_reader().read(&data_splits)
}
+ /// Returns an audit-log stream for a planned incremental scan.
+ pub fn to_audit_log_arrow(
+ &self,
+ plan: &IncrementalPlan,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ match plan.mode() {
+ IncrementalScanMode::Diff => Err(crate::Error::Unsupported {
+ message: "Batch incremental Diff audit read not yet
implemented".to_string(),
+ }),
+ IncrementalScanMode::Delta => {
+ self.audit_raw_stream(plan,
!self.table.schema().primary_keys().is_empty())
+ }
+ IncrementalScanMode::Changelog => self.audit_raw_stream(plan,
true),
+ IncrementalScanMode::Auto => unreachable!("Auto resolved during
plan()"),
+ }
+ }
+
+ fn audit_raw_stream(
+ &self,
+ plan: &IncrementalPlan,
+ has_value_kind: bool,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ let data_splits = plan.data_splits();
+ let user_read_type = self.read_type.clone();
+ let include_sequence = audit_sequence_number_enabled(self.table);
+ let audit_schema = audit_schema_for_read_type(&user_read_type,
include_sequence)?;
+
+ let mut read_type = user_read_type.clone();
+ if include_sequence {
+ read_type.insert(
+ 0,
+ DataField::new(
+ SEQUENCE_NUMBER_FIELD_ID,
+ SEQUENCE_NUMBER_FIELD_NAME.to_string(),
+ DataType::BigInt(BigIntType::new()),
+ ),
+ );
+ }
+ if has_value_kind {
+ read_type.push(DataField::new(
+ VALUE_KIND_FIELD_ID,
+ VALUE_KIND_FIELD_NAME.to_string(),
+ DataType::TinyInt(TinyIntType::new()),
+ ));
+ }
+
+ let reader = DataFileReader::new(
+ self.table.file_io.clone(),
+ self.table.schema_manager().clone(),
+ self.table.schema().id(),
+ self.table.schema.fields().to_vec(),
+ read_type,
+ self.data_predicates.clone(),
+ );
+ let raw_stream = reader.read(&data_splits)?;
+
+ Ok(Box::pin(async_stream::try_stream! {
+ futures::pin_mut!(raw_stream);
+ while let Some(batch) = raw_stream.next().await {
+ let batch = batch?;
+ let rowkind_col: ArrayRef = if has_value_kind {
+ let col = batch
+ .column_by_name(VALUE_KIND_FIELD_NAME)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Changelog audit read missing _VALUE_KIND
column".to_string(),
+ source: None,
+ })?;
+ Arc::new(rowkind_array_from_column(col)?)
+ } else {
+ let inserts: Vec<&'static str> =
(0..batch.num_rows()).map(|_| "+I").collect();
+ Arc::new(StringArray::from(inserts))
+ };
+
+ let mut columns: Vec<ArrayRef> = vec![rowkind_col];
+ if include_sequence {
+ let seq_col = batch
+ .column_by_name(SEQUENCE_NUMBER_FIELD_NAME)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Audit read missing _SEQUENCE_NUMBER
column".to_string(),
+ source: None,
+ })?;
+ columns.push(seq_col.clone());
+ }
+ for field in &user_read_type {
+ let col = batch
+ .column_by_name(field.name())
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "Audit read missing column '{}'",
+ field.name()
+ ),
+ source: None,
+ })?;
+ columns.push(col.clone());
+ }
+ yield RecordBatch::try_new(audit_schema.clone(),
columns).map_err(|e| {
+ crate::Error::UnexpectedError {
+ message: format!("Failed to build audit log batch:
{e}"),
+ source: Some(Box::new(e)),
+ }
+ })?;
+ }
+ }))
+ }
+
/// Returns an [`ArrowRecordBatchStream`].
pub fn to_arrow(&self, data_splits: &[DataSplit]) ->
crate::Result<ArrowRecordBatchStream> {
let has_primary_keys = !self.table.schema.primary_keys().is_empty();
@@ -352,6 +484,70 @@ impl<'a> PaimonTableRead<'a> {
}
}
+fn audit_schema_for_read_type(
+ read_type: &[DataField],
+ include_sequence: bool,
+) -> crate::Result<Arc<ArrowSchema>> {
+ let mut fields = Vec::with_capacity(read_type.len() + 2);
+ fields.push(DataField::new(
+ ROW_KIND_FIELD_ID,
+ ROW_KIND_FIELD_NAME.to_string(),
+ DataType::VarChar(crate::spec::VarCharType::string_type()),
+ ));
+ if include_sequence {
+ fields.push(DataField::new(
+ SEQUENCE_NUMBER_FIELD_ID,
+ SEQUENCE_NUMBER_FIELD_NAME.to_string(),
+ DataType::BigInt(BigIntType::new()),
+ ));
+ }
+ fields.extend(read_type.iter().cloned());
+ build_target_arrow_schema(&fields)
+}
+
+fn audit_sequence_number_enabled(table: &Table) -> bool {
+ table
+ .schema()
+ .options()
+ .get("table-read.sequence-number.enabled")
+ .is_some_and(|v| v.eq_ignore_ascii_case("true"))
+}
+
+fn rowkind_array_from_column(column: &dyn arrow_array::Array) ->
crate::Result<StringArray> {
+ let values = column
+ .as_any()
+ .downcast_ref::<arrow_array::Int8Array>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "AuditLogTable _VALUE_KIND column must be
Int8".to_string(),
+ source: None,
+ })?;
+ let mut strings = Vec::with_capacity(values.len());
+ for idx in 0..values.len() {
+ if values.is_null(idx) {
+ return Err(crate::Error::DataInvalid {
+ message: format!("AuditLogTable _VALUE_KIND is null at row
{idx}"),
+ source: None,
+ });
+ }
+ let rowkind = match values.value(idx) {
+ 0 => "+I",
+ 1 => "-U",
+ 2 => "+U",
+ 3 => "-D",
+ value => {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "AuditLogTable _VALUE_KIND has invalid value {value}
at row {idx}"
+ ),
+ source: None,
+ });
+ }
+ };
+ strings.push(rowkind);
+ }
+ Ok(StringArray::from(strings))
+}
+
/// Whether a primary-key split must go through the sort-merge reader.
///
/// Mirrors Java `PrimaryKeyTableRawFileSplitReadProvider#match`: a raw read
@@ -441,6 +637,25 @@ mod tests {
assert!(!pk_split_needs_merge(&dv_compacted, true));
}
+ #[test]
+ fn test_rowkind_rejects_null_value_kind() {
+ let values = arrow_array::Int8Array::from(vec![Some(0), None]);
+ assert!(matches!(
+ rowkind_array_from_column(&values),
+ Err(crate::Error::DataInvalid { ref message, .. }) if
message.contains("null at row 1")
+ ));
+ }
+
+ #[test]
+ fn test_rowkind_rejects_invalid_value_kind() {
+ let values = arrow_array::Int8Array::from(vec![4]);
+ assert!(matches!(
+ rowkind_array_from_column(&values),
+ Err(crate::Error::DataInvalid { ref message, .. })
+ if message.contains("invalid value 4 at row 0")
+ ));
+ }
+
#[test]
fn test_direct_table_read_fails_closed_when_query_auth_enabled() {
let table = query_auth_table();
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 6653423..8044590 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -716,6 +716,16 @@ impl<'a> TableScan<'a> {
}
}
+ /// Plan data splits from a snapshot's changelog manifest list only.
+ pub(crate) async fn plan_snapshot_changelog(&self, snapshot: &Snapshot) ->
crate::Result<Plan> {
+ match &self.0 {
+ TableScanKind::Paimon(scan) =>
scan.plan_snapshot_changelog(snapshot).await,
+ TableScanKind::Format(_) => Err(crate::Error::Unsupported {
+ message: "Format tables do not support incremental changelog
scan".to_string(),
+ }),
+ }
+ }
+
#[cfg(test)]
fn apply_limit_pushdown(&self, splits: Vec<DataSplit>) -> Vec<DataSplit> {
match &self.0 {
@@ -1077,6 +1087,27 @@ impl<'a> PaimonTableScan<'a> {
.await
}
+ /// Plan data splits from a snapshot's changelog manifest list.
+ ///
+ /// Reuses the same split-building path as a full snapshot plan, but only
+ /// reads the changelog manifest list and keeps ADD entries. Snapshots
+ /// without a changelog list yield an empty plan.
+ pub(crate) async fn plan_snapshot_changelog(&self, snapshot: &Snapshot) ->
crate::Result<Plan> {
+ self.ensure_query_auth_allowed()?;
+ let Some(list_name) = snapshot.changelog_manifest_list() else {
+ return Ok(Plan::new(Vec::new()));
+ };
+ let entries = self.plan_manifest_list_entries(list_name).await?;
+ let data_evolution_read_field_ids = self.projected_read_field_ids()?;
+ self.plan_snapshot_from_entries(
+ snapshot.clone(),
+ entries,
+ data_evolution_read_field_ids.as_ref(),
+ None,
+ )
+ .await
+ }
+
/// Read entries from a single manifest list (delta or changelog) with
/// partition / bucket filter pushdown matching the full scan path.
async fn plan_manifest_list_entries(
diff --git a/crates/paimon/tests/audit_log_table_test.rs
b/crates/paimon/tests/audit_log_table_test.rs
new file mode 100644
index 0000000..8839673
--- /dev/null
+++ b/crates/paimon/tests/audit_log_table_test.rs
@@ -0,0 +1,349 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::{Array, Int32Array, Int64Array, RecordBatch, StringArray};
+use futures::TryStreamExt;
+use paimon::spec::{
+ DataType, IntType, Schema, TableSchema, VarCharType, ROW_KIND_FIELD_ID,
ROW_KIND_FIELD_NAME,
+ SEQUENCE_NUMBER_FIELD_NAME,
+};
+use paimon::table::{AuditLogTable, IncrementalScanMode};
+
+use common::incremental_helpers::{
+ make_batch, make_batch_with_kinds, memory_table, persist_table_schema,
pk_schema, setup_dirs,
+ write_batch,
+};
+
+fn collect_audit_rows(batches: &[RecordBatch]) -> Vec<(String, i32, i32)> {
+ let mut rows = Vec::new();
+ for batch in batches {
+ let schema = batch.schema();
+ let kind_idx = schema.index_of("rowkind").unwrap();
+ let id_idx = schema.index_of("id").unwrap();
+ let value_idx = schema.index_of("value").unwrap();
+ let kinds = batch
+ .column(kind_idx)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ let ids = batch
+ .column(id_idx)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let values = batch
+ .column(value_idx)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ for row in 0..batch.num_rows() {
+ rows.push((
+ kinds.value(row).to_string(),
+ ids.value(row),
+ values.value(row),
+ ));
+ }
+ }
+ rows.sort_unstable();
+ rows
+}
+
+fn collect_audit_rows_with_sequence(batches: &[RecordBatch]) -> Vec<(String,
i64, i32, i32)> {
+ let mut rows = Vec::new();
+ for batch in batches {
+ let schema = batch.schema();
+ let kind_idx = schema.index_of("rowkind").unwrap();
+ let seq_idx = schema.index_of(SEQUENCE_NUMBER_FIELD_NAME).unwrap();
+ let id_idx = schema.index_of("id").unwrap();
+ let value_idx = schema.index_of("value").unwrap();
+ let kinds = batch
+ .column(kind_idx)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ let seqs = batch
+ .column(seq_idx)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ let ids = batch
+ .column(id_idx)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let values = batch
+ .column(value_idx)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ for row in 0..batch.num_rows() {
+ rows.push((
+ kinds.value(row).to_string(),
+ seqs.value(row),
+ ids.value(row),
+ values.value(row),
+ ));
+ }
+ }
+ rows.sort_unstable();
+ rows
+}
+
+#[tokio::test]
+async fn audit_log_changelog_scan_exposes_rowkind_as_first_column() {
+ let table_path = "memory:/audit_log/changelog_rowkind";
+ 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, 2, 2],
+ vec![10, 20, 25, 30],
+ vec![0, 1, 2, 3],
+ ))
+ .await
+ .unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+
+ let audit = AuditLogTable::new(table.clone());
+ let plan = audit
+ .new_incremental_scan(IncrementalScanMode::Changelog, 0, 1)
+ .plan()
+ .await
+ .unwrap();
+ let batches: Vec<RecordBatch> =
audit.to_arrow(&plan).unwrap().try_collect().await.unwrap();
+
+ assert_eq!(batches[0].schema().field(0).name(), "rowkind");
+ assert_eq!(
+ collect_audit_rows(&batches),
+ vec![
+ ("+I".to_string(), 1, 10),
+ ("+U".to_string(), 2, 25),
+ ("-D".to_string(), 2, 30),
+ ("-U".to_string(), 1, 20),
+ ]
+ );
+}
+
+#[tokio::test]
+async fn audit_log_delta_scan_emits_plus_i_for_all_rows() {
+ let table_path = "memory:/audit_log/delta_plus_i";
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .option("bucket", "1")
+ .option("bucket-key", "id")
+ .build()
+ .unwrap();
+ let (file_io, table) = memory_table(table_path, TableSchema::new(0,
&schema));
+ setup_dirs(&file_io, table_path).await;
+ persist_table_schema(&file_io, table_path, table.schema()).await;
+
+ write_batch(&table, &make_batch(vec![1, 2], vec![10, 20])).await;
+
+ let audit = AuditLogTable::new(table.clone());
+ let plan = audit
+ .new_incremental_scan(IncrementalScanMode::Delta, 0, 1)
+ .plan()
+ .await
+ .unwrap();
+ let batches: Vec<RecordBatch> =
audit.to_arrow(&plan).unwrap().try_collect().await.unwrap();
+
+ let rowkind_field = batches[0].schema().field(0).clone();
+ assert_eq!(rowkind_field.name(), ROW_KIND_FIELD_NAME);
+ assert_eq!(rowkind_field.data_type(), &arrow_schema::DataType::Utf8);
+ assert!(rowkind_field.is_nullable());
+ assert_eq!(
+ rowkind_field.metadata().get("PARQUET:field_id"),
+ Some(&ROW_KIND_FIELD_ID.to_string())
+ );
+
+ assert_eq!(
+ collect_audit_rows(&batches),
+ vec![("+I".to_string(), 1, 10), ("+I".to_string(), 2, 20),]
+ );
+}
+
+#[tokio::test]
+async fn audit_log_delta_scan_preserves_pk_row_kinds() {
+ let table_path = "memory:/audit_log/delta_rowkind";
+ 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;
+
+ let builder = table.new_write_builder();
+ let mut write = builder.new_write().unwrap();
+ write
+ .write_arrow_batch(&make_batch_with_kinds(
+ // Use distinct keys so the PK writer does not merge multiple
+ // changes for one key before the audit read sees the data file.
+ vec![1, 2, 3, 4],
+ vec![10, 20, 25, 30],
+ vec![0, 1, 2, 3],
+ ))
+ .await
+ .unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+
+ let audit = AuditLogTable::new(table.clone());
+ let plan = audit
+ .new_incremental_scan(IncrementalScanMode::Delta, 0, 1)
+ .plan()
+ .await
+ .unwrap();
+ let batches: Vec<RecordBatch> =
audit.to_arrow(&plan).unwrap().try_collect().await.unwrap();
+
+ assert_eq!(
+ collect_audit_rows(&batches),
+ vec![
+ ("+I".to_string(), 1, 10),
+ ("+U".to_string(), 3, 25),
+ ("-D".to_string(), 4, 30),
+ ("-U".to_string(), 2, 20),
+ ]
+ );
+}
+
+#[test]
+fn audit_log_rowkind_field_matches_java_special_field() {
+ let (_, table) = memory_table("memory:/audit_log/rowkind_field",
pk_schema(&[]));
+ let field = AuditLogTable::new(table).fields().unwrap().remove(0);
+
+ assert_eq!(field.id(), ROW_KIND_FIELD_ID);
+ assert_eq!(field.name(), ROW_KIND_FIELD_NAME);
+ assert!(matches!(
+ field.data_type(),
+ DataType::VarChar(varchar) if varchar.length() ==
VarCharType::MAX_LENGTH
+ ));
+}
+
+#[tokio::test]
+async fn audit_log_exposes_sequence_number_when_enabled() {
+ let table_path = "memory:/audit_log/sequence_number";
+ let (file_io, table) = memory_table(
+ table_path,
+ pk_schema(&[
+ ("changelog-producer", "input"),
+ ("merge-engine", "deduplicate"),
+ ("bucket", "1"),
+ ("table-read.sequence-number.enabled", "true"),
+ ]),
+ );
+ 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, 1]))
+ .await
+ .unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+
+ let audit = AuditLogTable::new(table.clone());
+ let field_names: Vec<String> = audit
+ .fields()
+ .unwrap()
+ .into_iter()
+ .map(|f| f.name().to_string())
+ .collect();
+ assert_eq!(
+ field_names,
+ vec![
+ "rowkind".to_string(),
+ SEQUENCE_NUMBER_FIELD_NAME.to_string(),
+ "id".to_string(),
+ "value".to_string(),
+ ]
+ );
+
+ let plan = audit
+ .new_incremental_scan(IncrementalScanMode::Changelog, 0, 1)
+ .plan()
+ .await
+ .unwrap();
+ let batches: Vec<RecordBatch> =
audit.to_arrow(&plan).unwrap().try_collect().await.unwrap();
+ let batch_schema: Vec<String> = batches[0]
+ .schema()
+ .fields()
+ .iter()
+ .map(|f| f.name().clone())
+ .collect();
+ assert_eq!(
+ batch_schema,
+ vec![
+ "rowkind".to_string(),
+ SEQUENCE_NUMBER_FIELD_NAME.to_string(),
+ "id".to_string(),
+ "value".to_string(),
+ ]
+ );
+
+ let rows = collect_audit_rows_with_sequence(&batches);
+ assert_eq!(rows.len(), 2);
+ assert!(rows.iter().all(|(_, seq, _, _)| *seq >= 0));
+}
+
+#[tokio::test]
+async fn audit_log_diff_mode_is_unsupported() {
+ let table_path = "memory:/audit_log/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 audit = AuditLogTable::new(table.clone());
+ let err = audit
+ .new_incremental_scan(IncrementalScanMode::Diff, 1, 2)
+ .plan()
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(err, paimon::Error::Unsupported { .. }),
+ "expected Unsupported for Diff audit plan, got {err:?}"
+ );
+}
diff --git a/crates/paimon/tests/common/incremental_helpers.rs
b/crates/paimon/tests/common/incremental_helpers.rs
index e4c1abf..9ef5f62 100644
--- a/crates/paimon/tests/common/incremental_helpers.rs
+++ b/crates/paimon/tests/common/incremental_helpers.rs
@@ -17,7 +17,7 @@
//! Minimal helpers for batch incremental scan tests (no compact/lookup APIs).
-use arrow_array::{Int32Array, RecordBatch, StringArray};
+use arrow_array::{Int32Array, Int8Array, RecordBatch, StringArray};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as
ArrowSchema};
use paimon::catalog::Identifier;
use paimon::io::FileIOBuilder;
@@ -108,6 +108,26 @@ pub fn make_batch(ids: Vec<i32>, values: Vec<i32>) ->
RecordBatch {
.unwrap()
}
+/// Batch with explicit `_VALUE_KIND` for `changelog-producer=input`.
+///
+/// Kind codes: 0=+I, 1=-U, 2=+U, 3=-D.
+pub fn make_batch_with_kinds(ids: Vec<i32>, values: Vec<i32>, kinds: Vec<i8>)
-> RecordBatch {
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("value", ArrowDataType::Int32, false),
+ ArrowField::new("_VALUE_KIND", ArrowDataType::Int8, false),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(ids)),
+ Arc::new(Int32Array::from(values)),
+ Arc::new(Int8Array::from(kinds)),
+ ],
+ )
+ .unwrap()
+}
+
pub fn make_partitioned_batch(pts: Vec<&str>, ids: Vec<i32>, values: Vec<i32>)
-> RecordBatch {
let schema = Arc::new(ArrowSchema::new(vec![
ArrowField::new("pt", ArrowDataType::Utf8, false),
diff --git a/crates/paimon/tests/incremental_batch_scan_test.rs
b/crates/paimon/tests/incremental_batch_scan_test.rs
index 26e9fcc..c1a0a8c 100644
--- a/crates/paimon/tests/incremental_batch_scan_test.rs
+++ b/crates/paimon/tests/incremental_batch_scan_test.rs
@@ -22,8 +22,8 @@ use futures::TryStreamExt;
use paimon::table::IncrementalScanMode;
use common::incremental_helpers::{
- make_batch, make_partitioned_batch, memory_table, partitioned_pk_schema,
persist_table_schema,
- pk_schema, setup_dirs, write_batch, write_partitioned,
+ 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)> {
@@ -241,10 +241,157 @@ async fn
incremental_delta_scan_applies_partition_filter_from_read_builder() {
assert_eq!(collect_pairs(&batches), vec![(1, 10)]);
}
-/// Changelog mode is reserved but not implemented in this PR.
+/// Changelog mode reads existing changelog_manifest_list data files.
#[tokio::test]
-async fn changelog_mode_is_unsupported() {
- let table_path = "memory:/incremental_batch/changelog_unsupported";
+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)]);
+}
+
+/// Multi-snapshot changelog range is left-open / right-closed and ordered by
snapshot id.
+#[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)]);
+}
+
+/// Auto resolves to Changelog when producer is not `none`.
+#[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)]);
+}
+
+/// Partition filter from ReadBuilder is pushed into the changelog plan path.
+#[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();
+ // Two partitions in one commit → one snapshot with both changelog files.
+ 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)]);
+}
+
+/// Diff mode remains unsupported in this PR.
+#[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(&[
@@ -256,12 +403,14 @@ async fn changelog_mode_is_unsupported() {
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::Changelog, 0, 1)
+ // Non-empty range so planning reaches plan_diff (empty range
short-circuits).
+ let err = plan_incremental(&table, IncrementalScanMode::Diff, 1, 2)
.await
.unwrap_err();
assert!(
matches!(err, paimon::Error::Unsupported { .. }),
- "expected Unsupported for Changelog, got {err:?}"
+ "expected Unsupported for Diff, got {err:?}"
);
}