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 77728893 feat: support native shard column updates in core (#1099)
77728893 is described below
commit 77728893e9dd1a6dd553253a01316ba0e6b3808d
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 13:33:35 2026 +0800
feat: support native shard column updates in core (#1099)
---
bindings/python/README.md | 19 +
.../python/python/pypaimon_rust/datafusion.pyi | 28 +
bindings/python/src/context.rs | 1 +
bindings/python/src/lib.rs | 1 +
bindings/python/src/read.rs | 8 +
bindings/python/src/shard_update.rs | 80 +++
bindings/python/src/write.rs | 52 +-
crates/paimon/src/table/data_evolution_writer.rs | 10 +
crates/paimon/src/table/mod.rs | 2 +
crates/paimon/src/table/shard_table_updater.rs | 437 +++++++++++++
.../paimon/src/table/shard_table_updater/tests.rs | 714 +++++++++++++++++++++
crates/paimon/src/table/table_update.rs | 28 +
12 files changed, 1378 insertions(+), 2 deletions(-)
diff --git a/bindings/python/README.md b/bindings/python/README.md
index b4710b85..76ea23d8 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -132,6 +132,25 @@ print(f"\nRead: {batches_tt[0].num_rows} rows")
print(batches_tt[0])
```
+### Shard column updates
+
+The batch and stream update objects expose
+`new_shard_updator(shard_num, total_shard_count, read_type=...)`. The optional
+`read_type` is a Paimon ROW type encoded as JSON, preserving field IDs and
nested
+read metadata. It belongs to this shard operation and is independent of columns
+configured by `with_update_type`. Omitting it reads all table fields; an empty
+ROW reads zero columns with row counts preserved. Core captures one snapshot
and
+balances complete Data Evolution file groups by row ID. The returned
+`arrow_reader()` yields projected batches; `update_by_arrow_batch()` receives
+replacement columns in that reader's visible row order, including when deletion
+vectors remove physical rows. Input batch boundaries may differ from read
batches.
+
+Finish the reader and supply exactly the same number of rows before calling
+`prepare_commit()`. Core buffers one file group, reuses the normal
partial-column
+writer and returns messages with the captured snapshot's conflict check.
Repeated
+preparation reuses those messages. `close()` preserves prepared files; commit
the
+messages with the matching builder's committer.
+
### Prepared MERGE operations
Batch and stream update objects expose `merge_into` to prepare data-evolution
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index a0c81bfa..585f0f71 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -334,6 +334,15 @@ class BatchTableUpdate:
def update_by_arrow_batches_with_row_id(
self, tables: Iterable[pyarrow.Table]
) -> List[CommitMessage]: ...
+ def new_shard_updator(
+ self, shard_num: int, total_shard_count: int, *, read_type:
Optional[str] = None
+ ) -> ShardTableUpdator:
+ """Create a shard operation with a Paimon ROW read type encoded as
JSON.
+
+ None reads all table fields; an empty ROW reads zero columns while
+ preserving row counts. Read fields are independent of with_update_type.
+ """
+ ...
def with_update_type(self, update_cols: List[str]) -> "BatchTableUpdate":
...
def delete_by_row_id(self, row_ids: Sequence[int]) -> List[CommitMessage]:
...
def upsert_by_arrow_with_key(
@@ -377,6 +386,15 @@ class StreamTableUpdate:
def update_by_arrow_with_row_id(
self, table: pyarrow.Table, commit_identifier: int
) -> List[CommitMessage]: ...
+ def new_shard_updator(
+ self, shard_num: int, total_shard_count: int, *, read_type:
Optional[str] = None
+ ) -> ShardTableUpdator:
+ """Create a shard operation with a Paimon ROW read type encoded as
JSON.
+
+ None reads all table fields; an empty ROW reads zero columns while
+ preserving row counts. Read fields are independent of with_update_type.
+ """
+ ...
def with_update_type(self, update_cols: List[str]) -> "StreamTableUpdate":
...
def delete_by_row_id(
self, row_ids: Sequence[int], commit_identifier: int
@@ -660,3 +678,13 @@ class FullTextRead:
class FullTextSearchResult:
def __len__(self) -> int: ...
def row_ids(self) -> Dict[int, float]: ...
+
+class ShardTableUpdator:
+ def arrow_reader(self) -> RecordBatchReader:
+ """Read one captured shard once; values supplied later follow this
reader's order."""
+ ...
+ def update_by_arrow_batch(self, data: pyarrow.RecordBatch) -> None: ...
+ def prepare_commit(self) -> List[CommitMessage]:
+ """Require a finished read and exactly as many update rows; repeated
calls reuse messages."""
+ ...
+ def close(self) -> None: ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index ba8c3d87..117dc05e 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -760,6 +760,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<crate::write::PyBatchTableWrite>()?;
this.add_class::<crate::write::PyBatchTableUpdate>()?;
this.add_class::<crate::write::PyTableUpdateByRowId>()?;
+ this.add_class::<crate::shard_update::PyShardTableUpdator>()?;
this.add_class::<crate::write::PyStreamTableUpdate>()?;
this.add_class::<crate::write::PyStreamTableWrite>()?;
this.add_class::<crate::write::PyBatchTableCommit>()?;
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 87a3362c..86e7341b 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -29,6 +29,7 @@ mod oss_cpp;
mod predicate;
mod read;
mod schema;
+mod shard_update;
mod table;
mod udf;
mod update_assignment;
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index deb87eca..e101e93b 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -776,6 +776,14 @@ pub struct PyRecordBatchReader {
}
impl PyRecordBatchReader {
+ pub(crate) fn from_stream(stream: ArrowRecordBatchStream) -> Self {
+ Self {
+ stream: Mutex::new(Some(stream)),
+ closed: AtomicBool::new(false),
+ close_notify: Notify::new(),
+ }
+ }
+
fn next_record_batch(&self) -> PyResult<Option<RecordBatch>> {
let mut stream = self
.stream
diff --git a/bindings/python/src/shard_update.rs
b/bindings/python/src/shard_update.rs
new file mode 100644
index 00000000..dade9828
--- /dev/null
+++ b/bindings/python/src/shard_update.rs
@@ -0,0 +1,80 @@
+// 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.
+
+//! Arrow transport for core positional shard updates.
+
+use arrow::pyarrow::FromPyArrow;
+use arrow::record_batch::RecordBatch;
+use paimon::table::ShardTableUpdater;
+use paimon_datafusion::runtime::runtime;
+use pyo3::prelude::*;
+
+use crate::error::to_py_err;
+use crate::read::PyRecordBatchReader;
+use crate::write::{wrap_messages, PyCommitMessage};
+
+#[pyclass(name = "ShardTableUpdator", module = "pypaimon_rust.datafusion")]
+pub struct PyShardTableUpdator {
+ inner: ShardTableUpdater,
+ table_location: String,
+ commit_user: String,
+}
+
+impl PyShardTableUpdator {
+ pub(crate) fn new(
+ inner: ShardTableUpdater,
+ table_location: String,
+ commit_user: String,
+ ) -> Self {
+ Self {
+ inner,
+ table_location,
+ commit_user,
+ }
+ }
+}
+
+#[pymethods]
+impl PyShardTableUpdator {
+ fn arrow_reader(&mut self) -> PyResult<PyRecordBatchReader> {
+ self.inner
+ .arrow_reader()
+ .map(PyRecordBatchReader::from_stream)
+ .map_err(to_py_err)
+ }
+
+ fn update_by_arrow_batch(&mut self, py: Python<'_>, data: &Bound<'_,
PyAny>) -> PyResult<()> {
+ let batch = RecordBatch::from_pyarrow_bound(data)?;
+ py.detach(||
runtime().block_on(self.inner.update_by_arrow_batch(batch)))
+ .map_err(to_py_err)
+ }
+
+ fn prepare_commit(&mut self, py: Python<'_>) ->
PyResult<Vec<PyCommitMessage>> {
+ let messages = py
+ .detach(|| runtime().block_on(self.inner.prepare_commit()))
+ .map_err(to_py_err)?;
+ Ok(wrap_messages(
+ messages,
+ &self.table_location,
+ &self.commit_user,
+ ))
+ }
+
+ fn close(&mut self) {
+ self.inner.close();
+ }
+}
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 6f6901d4..7f1d0fd8 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -21,7 +21,7 @@ use std::sync::{Arc, Mutex};
use arrow::datatypes::Schema as ArrowSchema;
use arrow::pyarrow::FromPyArrow;
use arrow::record_batch::RecordBatch;
-use paimon::spec::{BlobConsumer, BlobDescriptor, CoreOptions, DataType, Datum};
+use paimon::spec::{BlobConsumer, BlobDescriptor, CoreOptions, DataType, Datum,
RowType};
use paimon::table::{
CommitMessage, PostponeBucketPlan, PostponeFixedBucketTableWrite, Table,
TableCommit,
TableUpdate, TableUpdateByRowId, TableWrite,
COMMIT_MESSAGE_SERIALIZER_VERSION,
@@ -516,7 +516,7 @@ impl BlobConsumer for PythonBlobConsumer {
}
}
-fn wrap_messages(
+pub(crate) fn wrap_messages(
messages: Vec<CommitMessage>,
table_location: &str,
commit_user: &str,
@@ -736,6 +736,30 @@ impl UpdateContext {
))
}
+ fn new_shard_updator(
+ &self,
+ py: Python<'_>,
+ shard: usize,
+ count: usize,
+ read_type: Option<&str>,
+ ) -> PyResult<crate::shard_update::PyShardTableUpdator> {
+ let read_type = read_type
+ .map(|json| {
+ serde_json::from_str::<RowType>(json)
+ .map(|row| row.fields().to_vec())
+ .map_err(|e| PyValueError::new_err(format!("invalid Paimon
read type: {e}")))
+ })
+ .transpose()?;
+ let inner = py
+ .detach(|| runtime().block_on(self.inner.new_shard_updater(shard,
count, read_type)))
+ .map_err(to_py_err)?;
+ Ok(crate::shard_update::PyShardTableUpdator::new(
+ inner,
+ self.table_location.clone(),
+ self.commit_user.clone(),
+ ))
+ }
+
fn new_update_by_row_id(&self, py: Python<'_>) ->
PyResult<PyTableUpdateByRowId> {
let inner = py
.detach(|| runtime().block_on(self.inner.new_update_by_row_id()))
@@ -988,6 +1012,18 @@ impl PyStreamTableUpdate {
self.context.new_update_by_row_id(py)
}
+ #[pyo3(signature = (shard_num, total_shard_count, *, read_type=None))]
+ fn new_shard_updator(
+ &self,
+ py: Python<'_>,
+ shard_num: usize,
+ total_shard_count: usize,
+ read_type: Option<&str>,
+ ) -> PyResult<crate::shard_update::PyShardTableUpdator> {
+ self.context
+ .new_shard_updator(py, shard_num, total_shard_count, read_type)
+ }
+
fn with_update_type<'py>(
mut slf: PyRefMut<'py, Self>,
update_cols: Vec<String>,
@@ -1079,6 +1115,18 @@ impl PyBatchTableUpdate {
self.context.new_update_by_row_id(py)
}
+ #[pyo3(signature = (shard_num, total_shard_count, *, read_type=None))]
+ fn new_shard_updator(
+ &self,
+ py: Python<'_>,
+ shard_num: usize,
+ total_shard_count: usize,
+ read_type: Option<&str>,
+ ) -> PyResult<crate::shard_update::PyShardTableUpdator> {
+ self.context
+ .new_shard_updator(py, shard_num, total_shard_count, read_type)
+ }
+
fn with_update_type<'py>(
mut slf: PyRefMut<'py, Self>,
update_cols: Vec<String>,
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index 05bf3ee5..a2069eb8 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -1316,6 +1316,16 @@ impl RowIdFileIndex {
})
}
+ /// Anchor of the normal-file group owning a visible row.
+ pub(super) fn first_row_id_for(&self, row_id: i64) -> Result<i64> {
+ find_owning_file(&self.files, row_id)
+ .map(|(_, file)| file.first_row_id)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("No file found for _ROW_ID {row_id}"),
+ source: None,
+ })
+ }
+
pub(super) fn matched_first_row_ids(&self, batches: &[RecordBatch]) ->
Result<HashSet<i64>> {
if batches.iter().all(|batch| batch.num_rows() == 0) {
return Ok(HashSet::new());
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index ce1b01f9..cf1f164a 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -137,6 +137,7 @@ mod row_position_selection;
mod row_sidecar;
mod scan_trace;
pub(crate) mod schema_manager;
+mod shard_table_updater;
pub(crate) mod snapshot_commit;
mod snapshot_manager;
mod sort_merge;
@@ -216,6 +217,7 @@ pub use read_builder::ReadBuilder;
pub use rest_env::RESTEnv;
pub use scan_trace::ScanTrace;
pub use schema_manager::SchemaManager;
+pub use shard_table_updater::ShardTableUpdater;
pub use snapshot_commit::{RESTSnapshotCommit, RenamingSnapshotCommit,
SnapshotCommit};
pub use snapshot_manager::SnapshotManager;
pub use source::{
diff --git a/crates/paimon/src/table/shard_table_updater.rs
b/crates/paimon/src/table/shard_table_updater.rs
new file mode 100644
index 00000000..0c4853be
--- /dev/null
+++ b/crates/paimon/src/table/shard_table_updater.rs
@@ -0,0 +1,437 @@
+// 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.
+
+//! Positional column updates over a shard of complete Data Evolution file
groups.
+
+use std::collections::{HashMap, HashSet, VecDeque};
+use std::sync::{Arc, Mutex};
+
+use arrow_array::{Array, ArrayRef, Int64Array, RecordBatch};
+use arrow_schema::{DataType, Field, Schema, SchemaRef};
+use async_stream::try_stream;
+use futures::TryStreamExt;
+
+use crate::spec::{row_id_data_field, DataField};
+
+use super::data_evolution_writer::RowIdFileIndex;
+use super::source::is_data_evolution_normal_file;
+use super::update_input::{cast_update_value, unique_column_index, CastMode};
+use super::{ArrowRecordBatchStream, CommitMessage, DataEvolutionWriter,
DataSplit, Table};
+
+const ROW_ID: &str = "_ROW_ID";
+
+fn invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+#[derive(Default)]
+struct ReadState {
+ pending: VecDeque<i64>,
+ complete: bool,
+ failed: bool,
+ closed: bool,
+}
+
+/// Read and replace columns of one balanced shard without exposing row-ID
mapping
+/// to the caller. The read uses one captured snapshot. Updates follow reader
order
+/// and may use different batch boundaries; deleted rows never consume input
values.
+///
+/// Finish the reader and supply exactly one output row per visible input row
before
+/// preparing. Core reuses the row-ID update writer, preserving Java's
column-file
+/// metadata, original physical ranges and conflict checks. Buffer one file
group
+/// at a time; returned messages belong to the caller and are never removed on
close.
+pub struct ShardTableUpdater {
+ table: Table,
+ splits: Vec<DataSplit>,
+ read_type: Vec<DataField>,
+ write_schema: SchemaRef,
+ index: RowIdFileIndex,
+ writer: Option<DataEvolutionWriter>,
+ write_columns: Vec<String>,
+ current_group: Option<i64>,
+ finished_groups: HashSet<i64>,
+ staged: Vec<CommitMessage>,
+ blob_uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+ state: Arc<Mutex<ReadState>>,
+ reader_started: bool,
+ failed: bool,
+ messages: Option<Vec<CommitMessage>>,
+}
+
+impl ShardTableUpdater {
+ pub(super) async fn new(
+ table: &Table,
+ read_type: Option<Vec<DataField>>,
+ write_columns: Vec<String>,
+ shard: usize,
+ shard_count: usize,
+ ) -> crate::Result<Self> {
+ if shard_count == 0 || shard >= shard_count {
+ return Err(invalid(
+ "Shard index must be smaller than a positive shard count",
+ ));
+ }
+ if write_columns.is_empty() {
+ return Err(invalid("Shard update columns must not be empty"));
+ }
+ let writer = DataEvolutionWriter::new(table, write_columns.clone())?;
+ table.schema().core_options().ensure_read_authorized()?;
+ let snapshot = super::time_travel::resolve_snapshot(table).await?;
+ let table = table
+ .copy_with_options(HashMap::from([(
+ "scalar-index.search-mode".into(),
+ "FULL".into(),
+ )]))
+ .copy_with_pinned_snapshot(snapshot.as_ref());
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .with_scan_all_files()
+ .plan()
+ .await?;
+ let splits = select_shard(plan.splits(), shard, shard_count)?;
+ let index = RowIdFileIndex::from_splits(table.clone(), &splits)?;
+ let read_type = read_type.unwrap_or_else(||
table.schema().fields().to_vec());
+ // Validate the same canonical request used by ordinary table readers.
+ table
+ .new_read_builder()
+ .with_read_type(with_row_id(&read_type)?)
+ .new_read()?;
+ let fields = super::data_evolution_fields::project_by_paths(
+ table.schema().fields(),
+ &write_columns,
+ )?;
+ let write_schema = crate::arrow::build_target_arrow_schema(&fields)?;
+ let state = ReadState {
+ complete: splits.is_empty(),
+ ..Default::default()
+ };
+ Ok(Self {
+ table,
+ splits,
+ read_type,
+ write_schema,
+ index,
+ writer: Some(writer),
+ write_columns,
+ current_group: None,
+ finished_groups: HashSet::new(),
+ staged: Vec::new(),
+ blob_uri_reader_factory: None,
+ state: Arc::new(Mutex::new(state)),
+ reader_started: false,
+ failed: false,
+ messages: None,
+ })
+ }
+
+ fn ensure_active(&self) -> crate::Result<()> {
+ let state = self
+ .state
+ .lock()
+ .map_err(|_| invalid("Shard reader state poisoned"))?;
+ if state.closed || state.failed || self.failed ||
self.messages.is_some() {
+ return Err(invalid(
+ "Shard updater is closed, failed or already prepared",
+ ));
+ }
+ Ok(())
+ }
+
+ pub(super) fn with_blob_uri_reader_factory(
+ &mut self,
+ factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+ ) {
+ self.writer
+ .as_mut()
+ .unwrap()
+ .with_blob_uri_reader_factory(factory.clone());
+ self.blob_uri_reader_factory = factory;
+ }
+
+ /// Open the shard's single reader. Only requested columns are returned;
+ /// `_ROW_ID` is retained internally even when absent from the read type.
+ pub fn arrow_reader(&mut self) -> crate::Result<ArrowRecordBatchStream> {
+ self.ensure_active()?;
+ if self.reader_started {
+ return Err(invalid("Shard reader can only be opened once"));
+ }
+ let mut builder = self.table.new_read_builder();
+ builder.with_read_type(with_row_id(&self.read_type)?);
+ let mut input = builder.new_read()?.to_arrow(&self.splits)?;
+ let state = self.state.clone();
+ let output_columns = self.read_type.len();
+ self.reader_started = true;
+ Ok(Box::pin(try_stream! {
+ loop {
+ let next = input.try_next().await;
+ if next.is_err() { finish_read(&state, true)?; }
+ let Some(batch) = next? else { finish_read(&state, false)?;
break; };
+ let output = record_rows(&state, batch, output_columns);
+ if output.is_err() { finish_read(&state, true)?; }
+ yield output?;
+ }
+ }))
+ }
+
+ /// Supply column values in visible reader order. Validate the complete
batch
+ /// before consuming any row IDs, so a malformed batch can be corrected.
+ pub async fn update_by_arrow_batch(&mut self, data: RecordBatch) ->
crate::Result<()> {
+ self.ensure_active()?;
+ if data.num_columns() != self.write_schema.fields().len() {
+ return Err(invalid(
+ "Shard input must contain exactly the configured update
columns",
+ ));
+ }
+ let mut columns = Vec::<ArrayRef>::new();
+ for field in self.write_schema.fields() {
+ let index = unique_column_index(data.schema().as_ref(),
field.name())?;
+ columns.push(cast_update_value(
+ data.column(index),
+ field.data_type(),
+ CastMode::Assignment,
+ )?);
+ }
+ let ids = {
+ let state = self
+ .state
+ .lock()
+ .map_err(|_| invalid("Shard reader state poisoned"))?;
+ if data.num_rows() > state.pending.len() {
+ return Err(invalid(
+ "Shard update contains more rows than have been read",
+ ));
+ }
+ state
+ .pending
+ .iter()
+ .take(data.num_rows())
+ .copied()
+ .collect::<Vec<_>>()
+ };
+ // Resolve all group boundaries before staging or consuming this input.
+ let groups = ids
+ .iter()
+ .map(|id| self.index.first_row_id_for(*id))
+ .collect::<crate::Result<Vec<_>>>()?;
+ let mut arrays = vec![Arc::new(Int64Array::from(ids)) as ArrayRef];
+ arrays.extend(columns);
+ let mut fields = vec![Arc::new(Field::new(ROW_ID, DataType::Int64,
false))];
+ fields.extend(self.write_schema.fields().iter().cloned());
+ let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays)
+ .map_err(|error| invalid(error.to_string()))?;
+ let result = self.stage_groups(batch, &groups).await;
+ if result.is_err() {
+ self.failed = true;
+ }
+ result?;
+ self.state
+ .lock()
+ .map_err(|_| invalid("Shard reader state poisoned"))?
+ .pending
+ .drain(..data.num_rows());
+ Ok(())
+ }
+
+ /// Prepare after complete consumption. Repeated calls return the same
messages.
+ pub async fn prepare_commit(&mut self) ->
crate::Result<Vec<CommitMessage>> {
+ if let Some(messages) = &self.messages {
+ return Ok(messages.clone());
+ }
+ self.ensure_active()?;
+ {
+ let state = self
+ .state
+ .lock()
+ .map_err(|_| invalid("Shard reader state poisoned"))?;
+ if !state.complete || !state.pending.is_empty() {
+ return Err(invalid("Read and update exactly the same number of
rows from this shard before preparing"));
+ }
+ }
+ let result = self.flush_group().await;
+ if result.is_err() {
+ self.failed = true;
+ }
+ result?;
+ let messages = std::mem::take(&mut self.staged);
+ self.messages = Some(messages.clone());
+ Ok(messages)
+ }
+
+ async fn stage_groups(&mut self, batch: RecordBatch, groups: &[i64]) ->
crate::Result<()> {
+ let mut offset = 0;
+ while offset < groups.len() {
+ let group = groups[offset];
+ if self.current_group.is_some_and(|current| current != group) {
+ self.flush_group().await?;
+ }
+ if self.finished_groups.contains(&group) {
+ return Err(invalid(
+ "Shard reader revisited an already updated file group",
+ ));
+ }
+ if self.writer.is_none() {
+ let mut writer = DataEvolutionWriter::new(&self.table,
self.write_columns.clone())?;
+
writer.with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone());
+ self.writer = Some(writer);
+ }
+ self.current_group = Some(group);
+ let count = groups[offset..]
+ .iter()
+ .take_while(|id| **id == group)
+ .count();
+ self.writer
+ .as_mut()
+ .unwrap()
+ .add_matched_batch(batch.slice(offset, count))?;
+ offset += count;
+ }
+ Ok(())
+ }
+
+ async fn flush_group(&mut self) -> crate::Result<()> {
+ if let Some(writer) = self.writer.take() {
+ // Cancellation also drops this consuming writer. Poison first so a
+ // cancelled prepare/update cannot later return a partial success.
+ self.failed = true;
+ self.staged
+ .extend(writer.prepare_commit_with_index(&self.index).await?);
+ self.failed = false;
+ }
+ if let Some(group) = self.current_group.take() {
+ self.finished_groups.insert(group);
+ }
+ Ok(())
+ }
+
+ /// Stop this operation. Prepared CommitMessage files may already be
published.
+ pub fn close(&mut self) {
+ self.writer.take();
+ if let Ok(mut state) = self.state.lock() {
+ state.closed = true;
+ state.pending.clear();
+ }
+ }
+}
+
+/// The caller's read fields must not change the system column used for
mapping.
+fn with_row_id(read_type: &[DataField]) -> crate::Result<Vec<DataField>> {
+ let row_id = row_id_data_field();
+ let mut ids = read_type.iter().filter(|field| field.name() == ROW_ID);
+ if let Some(field) = ids.next() {
+ if ids.next().is_some()
+ || field.id() != row_id.id()
+ || !matches!(field.data_type(), crate::spec::DataType::BigInt(_))
+ {
+ return Err(invalid(
+ "Read type must use the canonical BIGINT _ROW_ID field",
+ ));
+ }
+ return Ok(read_type.to_vec());
+ }
+ let mut fields = read_type.to_vec();
+ fields.push(row_id);
+ Ok(fields)
+}
+
+fn select_shard(splits: &[DataSplit], shard: usize, count: usize) ->
crate::Result<Vec<DataSplit>> {
+ let mut ranges = splits
+ .iter()
+ .flat_map(|split| split.data_files())
+ .filter(|file| is_data_evolution_normal_file(file))
+ .map(|file| {
+ file.row_id_range()
+ .ok_or_else(|| invalid("Data Evolution file is missing its
row-ID range"))
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ ranges.sort_unstable();
+ let mut groups = Vec::<(i64, i64)>::new();
+ for (start, end) in ranges {
+ if let Some(last) = groups.last_mut().filter(|last| start <= last.1) {
+ last.1 = last.1.max(end);
+ } else {
+ groups.push((start, end));
+ }
+ }
+ let size = groups.len() / count;
+ let remainder = groups.len() % count;
+ let begin = shard * size + shard.min(remainder);
+ let end = begin + size + usize::from(shard < remainder);
+ let Some(&(first, _)) = groups.get(begin).filter(|_| begin < end) else {
+ return Ok(Vec::new());
+ };
+ let last = groups[end - 1].1;
+ // The selected groups are a contiguous slice in global row-ID order.
+ // One interval avoids a scan of every selected group for each split.
+ Ok(splits
+ .iter()
+ .filter(|split| {
+ split
+ .data_files()
+ .iter()
+ .filter(|file| is_data_evolution_normal_file(file))
+ .filter_map(|file| file.row_id_range())
+ .any(|(start, end)| first <= end && last >= start)
+ })
+ .map(|split| {
+ split
+ .clone()
+ .with_selected_row_ranges(vec![super::RowRange::new(first,
last)])
+ })
+ .collect())
+}
+
+fn record_rows(
+ state: &Mutex<ReadState>,
+ batch: RecordBatch,
+ output_columns: usize,
+) -> crate::Result<RecordBatch> {
+ let ids = batch
+ .column_by_name(ROW_ID)
+ .and_then(|array| array.as_any().downcast_ref::<Int64Array>())
+ .ok_or_else(|| invalid("Shard reader must produce Int64 row IDs"))?;
+ if ids.null_count() != 0 {
+ return Err(invalid("Shard reader produced NULL row IDs"));
+ }
+ let output = batch
+ .project(&(0..output_columns).collect::<Vec<_>>())
+ .map_err(|error| invalid(error.to_string()))?;
+ let mut state = state
+ .lock()
+ .map_err(|_| invalid("Shard reader state poisoned"))?;
+ if state.closed {
+ return Err(invalid("Shard updater was closed"));
+ }
+ state.pending.extend(ids.values().iter().copied());
+ Ok(output)
+}
+
+fn finish_read(state: &Mutex<ReadState>, failed: bool) -> crate::Result<()> {
+ let mut state = state
+ .lock()
+ .map_err(|_| invalid("Shard reader state poisoned"))?;
+ state.failed |= failed;
+ state.complete = !state.failed;
+ Ok(())
+}
+
+#[cfg(test)]
+#[path = "shard_table_updater/tests.rs"]
+mod tests;
diff --git a/crates/paimon/src/table/shard_table_updater/tests.rs
b/crates/paimon/src/table/shard_table_updater/tests.rs
new file mode 100644
index 00000000..4f2c2ffe
--- /dev/null
+++ b/crates/paimon/src/table/shard_table_updater/tests.rs
@@ -0,0 +1,714 @@
+// 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::*;
+use crate::catalog::Identifier;
+use crate::io::FileIOBuilder;
+use crate::spec::{
+ DataType as PaimonType, IntType, RowType, Schema as TableDefinition,
TableSchema, VarCharType,
+};
+use crate::table::TableCommit;
+use arrow_array::{Int32Array, StringArray, StructArray};
+
+fn table(partitioned: bool) -> Table {
+ table_with_io(partitioned, FileIOBuilder::new("memory").build().unwrap())
+}
+
+fn table_with_io(partitioned: bool, file_io: crate::io::FileIO) -> Table {
+ let mut schema = TableDefinition::builder()
+ .column("p", PaimonType::VarChar(VarCharType::string_type()))
+ .column("id", PaimonType::Int(IntType::new()))
+ .column("value", PaimonType::Int(IntType::new()))
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .option("deletion-vectors.enabled", "true");
+ if partitioned {
+ schema = schema.partition_keys(["p"]);
+ }
+ Table::new(
+ file_io,
+ Identifier::new("db", "shard"),
+ "memory:/shard".into(),
+ TableSchema::new(0, &schema.build().unwrap()),
+ None,
+ )
+}
+
+fn batch(partitions: Vec<&str>, ids: Vec<i32>, values: Vec<i32>) ->
RecordBatch {
+ RecordBatch::try_from_iter([
+ ("p", Arc::new(StringArray::from(partitions)) as ArrayRef),
+ ("id", Arc::new(Int32Array::from(ids)) as ArrayRef),
+ ("value", Arc::new(Int32Array::from(values)) as ArrayRef),
+ ])
+ .unwrap()
+}
+
+fn values(values: Vec<i32>) -> RecordBatch {
+ RecordBatch::try_from_iter([("value", Arc::new(Int32Array::from(values))
as ArrayRef)]).unwrap()
+}
+
+async fn commit(table: &Table, messages: Vec<CommitMessage>) {
+ TableCommit::new(table.clone(), "shard".into())
+ .commit(messages)
+ .await
+ .unwrap();
+}
+
+async fn seed(table: &Table, data: RecordBatch) {
+ let mut writer = table
+ .new_write_builder()
+ .with_commit_user("shard")
+ .unwrap()
+ .new_write()
+ .unwrap();
+ writer.write_arrow_batch(&data).await.unwrap();
+ commit(table, writer.prepare_commit().await.unwrap()).await;
+ writer.close().await;
+}
+
+async fn updater(table: &Table, index: usize, count: usize) ->
ShardTableUpdater {
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ update
+ .new_shard_updater(index, count,
Some(vec![table.schema().fields()[1].clone()]))
+ .await
+ .unwrap()
+}
+
+async fn rows(table: &Table) -> Vec<(i32, i32)> {
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let mut read =
builder.new_read().unwrap().to_arrow(plan.splits()).unwrap();
+ let mut result = Vec::new();
+ while let Some(batch) = read.try_next().await.unwrap() {
+ let ids = batch
+ .column_by_name("id")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let values = batch
+ .column_by_name("value")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ result.extend(
+ ids.values()
+ .iter()
+ .zip(values.values())
+ .map(|(a, b)| (*a, *b)),
+ );
+ }
+ result.sort();
+ result
+}
+
+#[tokio::test]
+async fn balanced_shards_keep_overlapping_column_files_together() {
+ let table = table(false);
+ for id in 0..5 {
+ seed(&table, batch(vec!["p"], vec![id], vec![id * 10])).await;
+ }
+ // Construct every shard against one snapshot, before any replacement
files exist.
+ let mut shards = Vec::new();
+ for shard in 0..3 {
+ shards.push(updater(&table, shard, 3).await);
+ }
+ let mut counts = Vec::new();
+ for mut shard in shards {
+ let mut read = shard.arrow_reader().unwrap();
+ let mut count = 0;
+ while let Some(batch) = read.try_next().await.unwrap() {
+ assert_eq!(batch.schema().fields().len(), 1);
+ let ids = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ count += ids.len();
+ shard
+ .update_by_arrow_batch(values(ids.values().iter().map(|id| id
+ 100).collect()))
+ .await
+ .unwrap();
+ }
+ counts.push(count);
+ let messages = shard.prepare_commit().await.unwrap();
+ assert!(messages
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(5)));
+ for message in &messages {
+ assert_eq!(message.bucket, 0);
+ assert!(message
+ .new_files
+ .iter()
+ .all(|file| file.write_cols == Some(vec!["value".into()])));
+ }
+ commit(&table, messages).await;
+ }
+ assert_eq!(counts, vec![2, 2, 1]);
+ assert_eq!(
+ rows(&table).await,
+ (0..5).map(|id| (id, id + 100)).collect::<Vec<_>>()
+ );
+ // A subsequent scan has two column files per range, which still form five
shards.
+ let mut sizes = Vec::new();
+ for shard in 0..7 {
+ let mut update = updater(&table, shard, 7).await;
+ sizes.push(
+ update
+ .arrow_reader()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap()
+ .iter()
+ .map(RecordBatch::num_rows)
+ .sum::<usize>(),
+ );
+ }
+ assert_eq!(sizes, vec![1, 1, 1, 1, 1, 0, 0]);
+}
+
+#[tokio::test]
+async fn partitioned_shard_accepts_rebatched_values_and_pins_snapshot() {
+ let table = table(true);
+ seed(
+ &table,
+ batch(vec!["a", "b", "a"], vec![1, 2, 3], vec![10, 20, 30]),
+ )
+ .await;
+ let mut update = updater(&table, 0, 1).await;
+ seed(&table, batch(vec!["a"], vec![4], vec![40])).await;
+ let data = update
+ .arrow_reader()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let ids = data
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(ids.len(), 3);
+ update
+ .update_by_arrow_batch(values(ids.iter().map(|id| id * 100).collect()))
+ .await
+ .unwrap();
+ let messages = update.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 2);
+ assert!(messages
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(1)));
+ assert!(messages.iter().all(|message| message
+ .new_files
+ .iter()
+ .all(|file| file.first_row_id.is_some())));
+ commit(&table, messages).await;
+ assert_eq!(
+ rows(&table).await,
+ vec![(1, 100), (2, 200), (3, 300), (4, 40)]
+ );
+}
+
+#[tokio::test]
+async fn deleted_rows_do_not_shift_positional_assignments() {
+ let table = table(false);
+ seed(
+ &table,
+ batch(vec!["p"; 4], vec![1, 2, 3, 4], vec![10, 20, 30, 40]),
+ )
+ .await;
+ let messages = table
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .delete_by_row_id(vec![1, 3])
+ .await
+ .unwrap();
+ commit(&table, messages).await;
+ let mut update = updater(&table, 0, 1).await;
+ let data = update
+ .arrow_reader()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(data.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
+ update
+ .update_by_arrow_batch(values(vec![111, 333]))
+ .await
+ .unwrap();
+ let messages = update.prepare_commit().await.unwrap();
+ assert!(messages
+ .iter()
+ .flat_map(|m| &m.new_files)
+ .all(|file| file.row_count == 4 && file.first_row_id == Some(0)));
+ commit(&table, messages).await;
+ assert_eq!(rows(&table).await, vec![(1, 111), (3, 333)]);
+}
+
+#[tokio::test]
+async fn count_and_schema_failures_preserve_pending_rows_for_correction() {
+ let table = table(false);
+ seed(&table, batch(vec!["p"; 3], vec![1, 2, 3], vec![10, 20, 30])).await;
+ let mut update = updater(&table, 0, 1).await;
+ assert!(update.update_by_arrow_batch(values(vec![9])).await.is_err());
+ update
+ .arrow_reader()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let bad =
+ RecordBatch::try_from_iter([("wrong",
Arc::new(Int32Array::from(vec![1])) as ArrayRef)])
+ .unwrap();
+ assert!(update.update_by_arrow_batch(bad).await.is_err());
+ assert!(update
+ .update_by_arrow_batch(values(vec![1, 2, 3, 4]))
+ .await
+ .is_err());
+ update
+ .update_by_arrow_batch(values(vec![100]))
+ .await
+ .unwrap();
+ assert!(update
+ .prepare_commit()
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("same number"));
+ update
+ .update_by_arrow_batch(values(vec![200, 300]))
+ .await
+ .unwrap();
+ let messages = update.prepare_commit().await.unwrap();
+ let repeated = update.prepare_commit().await.unwrap();
+ assert_eq!(
+ messages[0].new_files[0].file_name,
+ repeated[0].new_files[0].file_name
+ );
+ assert!(update.update_by_arrow_batch(values(vec![])).await.is_err());
+ update.close();
+ // Prepared files survive explicit close and can still be committed.
+ commit(&table, messages).await;
+ assert_eq!(rows(&table).await, vec![(1, 100), (2, 200), (3, 300)]);
+}
+
+#[tokio::test]
+async fn incomplete_reader_cannot_publish_partial_shard() {
+ let table = table(false);
+ seed(&table, batch(vec!["p"], vec![1], vec![10])).await;
+ seed(&table, batch(vec!["p"], vec![2], vec![20])).await;
+ let mut update = updater(&table, 0, 1).await;
+ let mut read = update.arrow_reader().unwrap();
+ let batch = read.try_next().await.unwrap().unwrap();
+ update
+ .update_by_arrow_batch(values(vec![99; batch.num_rows()]))
+ .await
+ .unwrap();
+ drop(read);
+ assert!(update.arrow_reader().is_err());
+ assert!(update.prepare_commit().await.is_err());
+ update.close();
+ assert!(update.prepare_commit().await.is_err());
+ assert_eq!(rows(&table).await, vec![(1, 10), (2, 20)]);
+}
+
+#[tokio::test]
+async fn empty_shards_and_invalid_configuration() {
+ let table = table(false);
+ let mut empty = updater(&table, 2, 3).await;
+ assert!(empty.prepare_commit().await.unwrap().is_empty());
+ let update = table.new_write_builder().new_update().unwrap();
+ assert!(update.new_shard_updater(0, 0, None).await.is_err());
+ assert!(update.new_shard_updater(2, 2, None).await.is_err());
+ let mut bad_columns = update;
+ bad_columns.with_update_type(vec![]).unwrap();
+ assert!(bad_columns.new_shard_updater(0, 1, None).await.is_err());
+}
+
+#[tokio::test]
+async fn returned_reader_survives_updater_scope_and_close_stops_it() {
+ let table = table(false);
+ seed(&table, batch(vec!["p"], vec![1], vec![10])).await;
+ let mut read = updater(&table, 0, 1).await.arrow_reader().unwrap();
+ assert_eq!(read.try_next().await.unwrap().unwrap().num_rows(), 1);
+ assert!(read.try_next().await.unwrap().is_none());
+ let mut update = updater(&table, 0, 1).await;
+ let mut read = update.arrow_reader().unwrap();
+ update.close();
+ assert!(read.try_next().await.is_err());
+ assert!(update.prepare_commit().await.is_err());
+}
+
+#[test]
+fn reader_eof_cannot_clear_failure() {
+ let state = Mutex::new(ReadState::default());
+ finish_read(&state, true).unwrap();
+ finish_read(&state, false).unwrap();
+ let state = state.lock().unwrap();
+ assert!(state.failed);
+ assert!(!state.complete);
+}
+
+#[derive(Debug)]
+struct ControlledIo {
+ operator: opendal::Operator,
+ mode: std::sync::atomic::AtomicU8,
+ entered: tokio::sync::Notify,
+}
+
+impl ControlledIo {
+ fn table() -> (Table, Arc<Self>) {
+ let provider = Arc::new(Self {
+ operator:
opendal::Operator::from_config(opendal::services::MemoryConfig::default())
+ .unwrap(),
+ mode: std::sync::atomic::AtomicU8::new(0),
+ entered: tokio::sync::Notify::new(),
+ });
+ let io = FileIOBuilder::new("unused")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ (table_with_io(false, io), provider)
+ }
+}
+
+#[async_trait::async_trait]
+impl crate::io::FileIOProvider for ControlledIo {
+ async fn create(&self, path: &str) -> crate::Result<(opendal::Operator,
String)> {
+ use std::sync::atomic::Ordering;
+ if path.ends_with(".parquet") {
+ match self.mode.load(Ordering::SeqCst) {
+ 1 => {
+ self.entered.notify_one();
+ std::future::pending::<()>().await;
+ }
+ 2 => {
+ self.mode.store(0, Ordering::SeqCst);
+ return Err(invalid("Injected shard I/O failure"));
+ }
+ _ => {}
+ }
+ }
+ Ok((
+ self.operator.clone(),
+ path.trim_start_matches("memory:")
+ .trim_start_matches('/')
+ .into(),
+ ))
+ }
+}
+
+#[tokio::test]
+async fn cancelled_prepare_cannot_retry_as_an_empty_success() {
+ use std::sync::atomic::Ordering;
+ let (table, provider) = ControlledIo::table();
+ seed(&table, batch(vec!["p"], vec![1], vec![10])).await;
+ let mut update = updater(&table, 0, 1).await;
+ update
+ .arrow_reader()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ update
+ .update_by_arrow_batch(values(vec![99]))
+ .await
+ .unwrap();
+ provider.mode.store(1, Ordering::SeqCst);
+ let mut prepare = Box::pin(update.prepare_commit());
+ tokio::select! {
+ result = &mut prepare => panic!("Expected stalled I/O, got
{result:?}"),
+ result = tokio::time::timeout(std::time::Duration::from_secs(5),
provider.entered.notified()) => {
+ result.expect("prepare never reached storage I/O");
+ }
+ }
+ drop(prepare);
+ provider.mode.store(0, Ordering::SeqCst);
+ assert!(update
+ .prepare_commit()
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("failed"));
+ assert!(update.update_by_arrow_batch(values(vec![])).await.is_err());
+ assert_eq!(rows(&table).await, vec![(1, 10)]);
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id(),
+ 1
+ );
+}
+
+#[tokio::test]
+async fn reader_eof_does_not_resurrect_a_failed_group_write() {
+ use std::sync::atomic::Ordering;
+ let (table, provider) = ControlledIo::table();
+ seed(&table, batch(vec!["p"], vec![1], vec![10])).await;
+ seed(&table, batch(vec!["p"], vec![2], vec![20])).await;
+ let mut update = updater(&table, 0, 1).await;
+ let mut read = update.arrow_reader().unwrap();
+ assert_eq!(read.try_next().await.unwrap().unwrap().num_rows(), 1);
+ update
+ .update_by_arrow_batch(values(vec![100]))
+ .await
+ .unwrap();
+ assert_eq!(read.try_next().await.unwrap().unwrap().num_rows(), 1);
+ provider.mode.store(2, Ordering::SeqCst);
+ let error = update
+ .update_by_arrow_batch(values(vec![200]))
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("Injected shard I/O failure"));
+ assert!(read.try_next().await.unwrap().is_none());
+ assert!(update
+ .update_by_arrow_batch(values(vec![200]))
+ .await
+ .is_err());
+ assert!(update.prepare_commit().await.is_err());
+ assert_eq!(rows(&table).await, vec![(1, 10), (2, 20)]);
+}
+
+#[tokio::test]
+async fn read_types_belong_to_each_operation_and_are_independent_of_updates() {
+ let table = table(false);
+ seed(&table, batch(vec!["p", "p"], vec![1, 2], vec![10, 20])).await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let id_field = table.schema().fields()[1].clone();
+ let mut ids = update
+ .new_shard_updater(0, 1, Some(vec![id_field]))
+ .await
+ .unwrap();
+ let mut originals = update
+ .new_shard_updater(
+ 0,
+ 1,
+ Some(vec![
+ table.schema().fields()[2].clone(),
+ row_id_data_field(),
+ ]),
+ )
+ .await
+ .unwrap();
+ let read = originals
+ .arrow_reader()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(read[0].schema().field(0).name(), "value");
+ assert_eq!(
+ read[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values(),
+ &[10, 20]
+ );
+ assert_eq!(
+ read[0]
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .values(),
+ &[0, 1]
+ );
+ originals.close();
+ let mut read = ids.arrow_reader().unwrap();
+ while let Some(batch) = read.try_next().await.unwrap() {
+ assert_eq!(batch.schema().fields().len(), 1);
+ assert_eq!(batch.schema().field(0).name(), "id");
+ let source = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ ids.update_by_arrow_batch(values(source.values().iter().map(|id| id *
100).collect()))
+ .await
+ .unwrap();
+ }
+ commit(&table, ids.prepare_commit().await.unwrap()).await;
+ assert_eq!(rows(&table).await, vec![(1, 100), (2, 200)]);
+}
+
+#[tokio::test]
+async fn default_and_empty_read_types_preserve_row_counts() {
+ let table = table(false);
+ seed(&table, batch(vec!["p", "p"], vec![1, 2], vec![10, 20])).await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let mut full = update.new_shard_updater(0, 1, None).await.unwrap();
+ let batches = full
+ .arrow_reader()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(
+ batches[0]
+ .schema()
+ .fields()
+ .iter()
+ .map(|f| f.name().as_str())
+ .collect::<Vec<_>>(),
+ vec!["p", "id", "value"]
+ );
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
+ full.close();
+ let mut empty = update.new_shard_updater(0, 1,
Some(vec![])).await.unwrap();
+ let mut read = empty.arrow_reader().unwrap();
+ while let Some(batch) = read.try_next().await.unwrap() {
+ assert_eq!(batch.num_columns(), 0);
+ assert_eq!(batch.num_rows(), 2);
+ empty
+ .update_by_arrow_batch(values(vec![99; batch.num_rows()]))
+ .await
+ .unwrap();
+ }
+ commit(&table, empty.prepare_commit().await.unwrap()).await;
+ assert_eq!(rows(&table).await, vec![(1, 99), (2, 99)]);
+}
+
+#[tokio::test]
+async fn nested_read_type_prunes_children_and_retains_their_ids() {
+ let definition = TableDefinition::builder()
+ .column("p", PaimonType::VarChar(VarCharType::string_type()))
+ .column("id", PaimonType::Int(IntType::new()))
+ .column("value", PaimonType::Int(IntType::new()))
+ .column(
+ "payload",
+ PaimonType::Row(RowType::new(vec![
+ DataField::new(0, "a".into(), PaimonType::Int(IntType::new())),
+ DataField::new(1, "b".into(), PaimonType::Int(IntType::new())),
+ ])),
+ )
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("db", "nested_shard"),
+ "memory:/nested_shard".into(),
+ TableSchema::new(0, &definition),
+ None,
+ );
+ let data = batch(vec!["p", "p"], vec![1, 2], vec![10, 20]);
+ let payload = StructArray::from(vec![
+ (
+ Arc::new(Field::new("a", DataType::Int32, true)),
+ Arc::new(Int32Array::from(vec![100, 200])) as ArrayRef,
+ ),
+ (
+ Arc::new(Field::new("b", DataType::Int32, true)),
+ Arc::new(Int32Array::from(vec![3, 4])) as ArrayRef,
+ ),
+ ]);
+ let mut fields = data.schema().fields().to_vec();
+ fields.push(Arc::new(Field::new(
+ "payload",
+ payload.data_type().clone(),
+ true,
+ )));
+ let mut columns = data.columns().to_vec();
+ columns.push(Arc::new(payload));
+ seed(
+ &table,
+ RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap(),
+ )
+ .await;
+ let source = &table.schema().fields()[3];
+ let PaimonType::Row(row) = source.data_type() else {
+ panic!("expected ROW")
+ };
+ let read_type = vec![DataField::new(
+ source.id(),
+ source.name().into(),
+ PaimonType::Row(RowType::new(vec![row.fields()[1].clone()])),
+ )];
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let mut shard = update
+ .new_shard_updater(0, 1, Some(read_type))
+ .await
+ .unwrap();
+ let mut read = shard.arrow_reader().unwrap();
+ while let Some(batch) = read.try_next().await.unwrap() {
+ let payload = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap();
+ assert_eq!(payload.num_columns(), 1);
+ assert_eq!(payload.fields()[0].name(), "b");
+ let source = payload
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ assert_eq!(source.values(), &[3, 4]);
+ shard
+ .update_by_arrow_batch(values(source.values().iter().map(|id| id *
100).collect()))
+ .await
+ .unwrap();
+ }
+ commit(&table, shard.prepare_commit().await.unwrap()).await;
+ assert_eq!(rows(&table).await, vec![(1, 300), (2, 400)]);
+}
+
+#[tokio::test]
+async fn read_type_cannot_override_the_internal_row_id() {
+ let table = table(false);
+ seed(&table, batch(vec!["p"], vec![1], vec![10])).await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ for fields in [
+ vec![DataField::new(
+ crate::spec::ROW_ID_FIELD_ID,
+ ROW_ID.into(),
+ PaimonType::Int(IntType::new()),
+ )],
+ vec![row_id_data_field().with_id(0)],
+ vec![row_id_data_field(), row_id_data_field()],
+ ] {
+ let error = update
+ .new_shard_updater(0, 1, Some(fields))
+ .await
+ .err()
+ .unwrap();
+ assert!(error.to_string().contains("canonical BIGINT _ROW_ID"));
+ }
+ assert_eq!(rows(&table).await, vec![(1, 10)]);
+}
diff --git a/crates/paimon/src/table/table_update.rs
b/crates/paimon/src/table/table_update.rs
index a516fb70..af9b283a 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -25,6 +25,7 @@ use std::collections::HashSet;
use arrow_array::RecordBatch;
+use crate::spec::DataField;
use crate::table::{CommitMessage, Table, TableUpdateByRowId};
const ROW_ID: &str = "_ROW_ID";
@@ -101,6 +102,33 @@ impl TableUpdate {
.collect()
}
+ /// Read and replace columns over one balanced shard of whole file groups.
+ ///
+ /// `read_type` configures this operation's reader, independently of the
+ /// update columns. It preserves field IDs and nested read metadata, just
+ /// like
[`ReadBuilder::with_read_type`](super::ReadBuilder::with_read_type).
+ /// `None` reads all table fields; `Some(vec![])` returns zero-column
batches
+ /// with their row counts preserved. Row IDs are tracked internally.
+ pub async fn new_shard_updater(
+ &self,
+ shard: usize,
+ shard_count: usize,
+ read_type: Option<Vec<DataField>>,
+ ) -> crate::Result<super::ShardTableUpdater> {
+ let mut updater = super::ShardTableUpdater::new(
+ &self.table,
+ read_type,
+ self.update_cols
+ .clone()
+ .unwrap_or_else(|| self.all_fields()),
+ shard,
+ shard_count,
+ )
+ .await?;
+
updater.with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone());
+ Ok(updater)
+ }
+
fn columns_for_batch(&self, batch: &RecordBatch) -> Vec<String> {
self.update_cols.clone().unwrap_or_else(|| {
batch