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 ecbf5806 [core] Add captured mutation and index build plans with
structured row-id conflicts (#1202)
ecbf5806 is described below
commit ecbf5806c2daa3c013752db46526085b0f4747f4
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 08:47:39 2026 +0800
[core] Add captured mutation and index build plans with structured row-id
conflicts (#1202)
---
.../python/python/pypaimon_rust/datafusion.pyi | 58 +++
bindings/python/src/context.rs | 3 +
bindings/python/src/error.rs | 153 +++++++-
bindings/python/src/index_build.rs | 106 +++++-
bindings/python/src/read.rs | 56 ++-
bindings/python/src/write.rs | 243 ++++++++++++
crates/paimon/src/error.rs | 52 +++
crates/paimon/src/table/authorized_update_tests.rs | 184 +++++++++
crates/paimon/src/table/data_evolution_writer.rs | 160 +++++++-
.../paimon/src/table/global_index_build_builder.rs | 42 +++
.../src/table/global_index_build_common/vector.rs | 2 +-
crates/paimon/src/table/global_index_build_plan.rs | 151 ++++++++
crates/paimon/src/table/mod.rs | 4 +-
crates/paimon/src/table/source.rs | 12 +-
crates/paimon/src/table/table_commit.rs | 107 +++++-
.../table/table_commit/row_id_conflict_tests.rs | 222 +++++++++++
crates/paimon/src/table/table_update.rs | 49 ++-
crates/paimon/src/table/table_update_by_row_id.rs | 101 ++++-
crates/paimon/src/table/table_update_plan_tests.rs | 418 +++++++++++++++++++++
.../paimon/src/table/vindex_index_build_builder.rs | 149 +++++---
.../src/table/vindex_index_build_builder/tests.rs | 151 ++++++++
21 files changed, 2343 insertions(+), 80 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 427e9810..f50293ef 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -57,6 +57,9 @@ class Split:
def serialize(self) -> bytes: ...
class Plan:
+ def __init__(self, snapshot_id: Optional[int], splits: Iterable[Split]) ->
None:
+ """Capture typed split metadata; constructing a plan does not
authorize execution."""
+ ...
def snapshot_id(self) -> Optional[int]:
"""Selected snapshot, preserved for empty plans; None if no snapshot
was selected."""
...
@@ -66,6 +69,16 @@ class Plan:
...
def __len__(self) -> int: ...
+class RowIdPlan:
+ def __init__(self, plan: Plan) -> None:
+ """Group complete data-evolution files from plan_all without reading
manifests."""
+ ...
+ def snapshot_id(self) -> Optional[int]: ...
+ def groups(self) -> List[Tuple[int, int, Split]]:
+ """Inclusive row-ID bounds and all physical files of each logical
group."""
+ ...
+ def plan(self) -> Plan: ...
+
class ScanStats:
"""Read-only counters from normal metadata planning."""
@property
@@ -417,6 +430,14 @@ class Table:
"""
...
+class GlobalIndexBuildPlan:
+ def snapshot_id(self) -> Optional[int]: ...
+ def shards(self) -> List["GlobalIndexBuildShard"]: ...
+
+class GlobalIndexBuildShard:
+ def snapshot_id(self) -> int: ...
+ def row_range(self) -> Tuple[int, int]: ...
+
class GlobalIndexBuildBuilder:
@staticmethod
def supports_index_type(index_type: str) -> bool: ...
@@ -425,6 +446,16 @@ class GlobalIndexBuildBuilder:
def with_index_type(self, index_type: str) -> "GlobalIndexBuildBuilder":
...
def with_options(self, options: Dict[str, str]) ->
"GlobalIndexBuildBuilder": ...
def with_partition_filter(self, predicate: Dict[str, Any]) ->
"GlobalIndexBuildBuilder": ...
+ def plan(self) -> GlobalIndexBuildPlan:
+ """Capture independently executable vindex shards at one snapshot."""
+ ...
+ def build_shard(self, shard: GlobalIndexBuildShard) ->
List["CommitMessage"]:
+ """Build one captured shard with fresh worker authorization.
+
+ The shard supplies its schema, index configuration and options;
+ this builder supplies the worker table and I/O environment.
+ """
+ ...
def build(self) -> List["CommitMessage"]:
"""Build unpublished index files; the caller decides whether to commit
or abort."""
...
@@ -477,6 +508,17 @@ class BatchTableWrite:
...
class BatchTableUpdate:
+ def with_plan(self, plan: Plan) -> "BatchTableUpdate":
+ """Reuse complete plan_all file groups for row-ID updates and deletes.
+
+ Workers reauthorize against the table; a plan is metadata, not a grant.
+ Data manifests are not replanned. Deletes still read the captured index
+ manifest to preserve deletion vectors for every file in the bucket.
+ Assign all deletes for one partition/bucket to the same worker.
+ Selected row ranges, scores and streaming splits are not mutation
plans.
+ Other update operations reject this configuration before consuming
input.
+ """
+ ...
def with_blob_uri_reader_factory(
self, factory: Optional[UriReaderFactory]
) -> "BatchTableUpdate": ...
@@ -540,6 +582,17 @@ class TableUpdateByRowId:
def _abort(self) -> None: ...
class StreamTableUpdate:
+ def with_plan(self, plan: Plan) -> "StreamTableUpdate":
+ """Reuse complete plan_all file groups for row-ID updates and deletes.
+
+ Workers reauthorize against the table; a plan is metadata, not a grant.
+ Data manifests are not replanned. Deletes still read the captured index
+ manifest to preserve deletion vectors for every file in the bucket.
+ Assign all deletes for one partition/bucket to the same worker.
+ Selected row ranges, scores and streaming splits are not mutation
plans.
+ Other update operations reject this configuration before consuming
input.
+ """
+ ...
def with_blob_uri_reader_factory(
self, factory: Optional[UriReaderFactory]
) -> "StreamTableUpdate": ...
@@ -707,6 +760,11 @@ class BatchTableCommit:
) -> None:
"""Commit once, using the builder's overwrite configuration and batch
identifier."""
...
+ def check_row_id_conflicts(
+ self, messages: Iterable[CommitMessage], latest_snapshot_id: int,
check_from_snapshot: int
+ ) -> None:
+ """Check captured DML history without publishing; compactions are
ignored."""
+ ...
def truncate_table(self) -> None:
"""Truncate all data; shares the one-time guard with commit()."""
...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 7e56ceda..33fdd265 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -993,6 +993,8 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<crate::table::PyTable>()?;
this.add_class::<crate::system_table::PySystemRead>()?;
this.add_class::<crate::index_build::PyGlobalIndexBuildBuilder>()?;
+ this.add_class::<crate::index_build::PyGlobalIndexBuildPlan>()?;
+ this.add_class::<crate::index_build::PyGlobalIndexBuildShard>()?;
this.add_class::<crate::index_build::PyGlobalIndexDropBuilder>()?;
this.add_class::<crate::full_text_search::PyFullTextSearchBuilder>()?;
this.add_class::<crate::full_text_search::PyFullTextScan>()?;
@@ -1019,6 +1021,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<crate::read::PyTableScan>()?;
this.add_class::<crate::read::PyStreamTableScan>()?;
this.add_class::<crate::read::PyPlan>()?;
+ this.add_class::<crate::read::PyRowIdPlan>()?;
this.add_class::<crate::read::PyFileScanPlan>()?;
this.add_class::<crate::read::PyScanStats>()?;
this.add_class::<crate::read::PyTableRead>()?;
diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs
index 4e9b277a..d5570ccd 100644
--- a/bindings/python/src/error.rs
+++ b/bindings/python/src/error.rs
@@ -32,9 +32,20 @@ pyo3::create_exception!(
"A catalog failure with code, resource_type, resource_name and message
attributes."
);
+pyo3::create_exception!(
+ pypaimon_rust,
+ RowIdExistenceConflict,
+ PyRuntimeError,
+ "A staged file no longer matches the current row-ID layout."
+);
+
pub fn register_module(py: Python<'_>, module: &Bound<'_, PyModule>) ->
PyResult<()> {
module.add("ForkSafetyError", py.get_type::<ForkSafetyError>())?;
- module.add("CatalogError", py.get_type::<CatalogError>())
+ module.add("CatalogError", py.get_type::<CatalogError>())?;
+ module.add(
+ "RowIdExistenceConflict",
+ py.get_type::<RowIdExistenceConflict>(),
+ )
}
/// Preserve the catalog error contract across the FFI without parsing display
strings.
@@ -134,6 +145,28 @@ pub fn to_py_err(err: paimon::Error) -> PyErr {
if err.is_process_fork_unsupported() {
return ForkSafetyError::new_err(err.to_string());
}
+ if let paimon::Error::RowIdExistenceConflict {
+ file_name,
+ first_row_id,
+ row_count,
+ bucket,
+ current_first_row_id,
+ current_row_count,
+ } = &err
+ {
+ return Python::attach(|py| {
+ let exception = RowIdExistenceConflict::new_err(err.to_string());
+ let value = exception.value(py);
+ value.setattr("file_name", file_name)?;
+ value.setattr("first_row_id", first_row_id)?;
+ value.setattr("row_count", row_count)?;
+ value.setattr("bucket", bucket)?;
+ value.setattr("current_first_row_id", current_first_row_id)?;
+ value.setattr("current_row_count", current_row_count)?;
+ Ok::<_, PyErr>(exception)
+ })
+ .unwrap_or_else(|error| error);
+ }
match err {
// Unimplemented scan semantics: distinct from malformed input so upper
// layers can catch NotImplementedError and decide on a fallback.
@@ -170,6 +203,124 @@ pub fn df_to_py_err(err:
datafusion::error::DataFusionError) -> PyErr {
mod tests {
use super::*;
+ #[test]
+ fn row_id_existence_error_preserves_structured_layout() {
+ Python::attach(|py| {
+ let error = to_py_err(paimon::Error::RowIdExistenceConflict {
+ file_name: "opaque '(4): file.parquet".into(),
+ first_row_id: Some(4),
+ row_count: 8,
+ bucket: 3,
+ current_first_row_id: None,
+ current_row_count: Some(9),
+ });
+ assert!(error.is_instance_of::<RowIdExistenceConflict>(py));
+ let value = error.value(py);
+ assert_eq!(
+ value
+ .getattr("file_name")
+ .unwrap()
+ .extract::<String>()
+ .unwrap(),
+ "opaque '(4): file.parquet"
+ );
+ assert_eq!(
+ value
+ .getattr("first_row_id")
+ .unwrap()
+ .extract::<i64>()
+ .unwrap(),
+ 4
+ );
+ assert_eq!(
+ value
+ .getattr("row_count")
+ .unwrap()
+ .extract::<i64>()
+ .unwrap(),
+ 8
+ );
+ assert_eq!(
+ value.getattr("bucket").unwrap().extract::<i32>().unwrap(),
+ 3
+ );
+ assert!(value.getattr("current_first_row_id").unwrap().is_none());
+ assert_eq!(
+ value
+ .getattr("current_row_count")
+ .unwrap()
+ .extract::<i64>()
+ .unwrap(),
+ 9
+ );
+ });
+ }
+
+ #[test]
+ fn row_id_existence_error_survives_python_worker_serialization() {
+ Python::attach(|py| {
+ let modules = py
+ .import("sys")
+ .unwrap()
+ .getattr("modules")
+ .unwrap()
+ .cast_into::<pyo3::types::PyDict>()
+ .unwrap();
+ let previous = modules.get_item("pypaimon_rust").unwrap();
+ let module = PyModule::new(py, "pypaimon_rust").unwrap();
+ register_module(py, &module).unwrap();
+ modules.set_item("pypaimon_rust", &module).unwrap();
+ let result = (|| -> PyResult<()> {
+ let error = to_py_err(paimon::Error::RowIdExistenceConflict {
+ file_name: "worker:staged.parquet".into(),
+ first_row_id: Some(4),
+ row_count: 8,
+ bucket: 3,
+ current_first_row_id: None,
+ current_row_count: Some(9),
+ });
+ let pickle = py.import("pickle")?;
+ let bytes = pickle.call_method1("dumps", (error.value(py),))?;
+ let restored = pickle.call_method1("loads", (bytes,))?;
+ assert!(restored.is_instance_of::<RowIdExistenceConflict>());
+ assert_eq!(
+ restored.getattr("file_name")?.extract::<String>()?,
+ "worker:staged.parquet"
+ );
+
assert_eq!(restored.getattr("first_row_id")?.extract::<i64>()?, 4);
+ assert_eq!(restored.getattr("row_count")?.extract::<i64>()?,
8);
+ assert_eq!(restored.getattr("bucket")?.extract::<i32>()?, 3);
+ assert!(restored.getattr("current_first_row_id")?.is_none());
+
assert_eq!(restored.getattr("current_row_count")?.extract::<i64>()?, 9);
+ Ok(())
+ })();
+ match previous {
+ Some(previous) => modules.set_item("pypaimon_rust",
previous).unwrap(),
+ None => modules.del_item("pypaimon_rust").unwrap(),
+ }
+ result.unwrap();
+ });
+ }
+
+ #[test]
+ fn unknown_commit_outcome_does_not_expose_a_rewritable_conflict() {
+ Python::attach(|py| {
+ let error = to_py_err(paimon::Error::UnexpectedError {
+ message: "Commit outcome may be unknown; retain prepared
files".into(),
+ source: Some(Box::new(paimon::Error::RowIdExistenceConflict {
+ file_name: "staged.parquet".into(),
+ first_row_id: Some(0),
+ row_count: 10,
+ bucket: 0,
+ current_first_row_id: None,
+ current_row_count: None,
+ })),
+ });
+ assert!(error.is_instance_of::<PyValueError>(py));
+ assert!(!error.is_instance_of::<RowIdExistenceConflict>(py));
+ });
+ }
+
#[test]
fn process_fork_error_has_distinct_python_type() {
Python::attach(|py| {
diff --git a/bindings/python/src/index_build.rs
b/bindings/python/src/index_build.rs
index ff5079c6..b9e016a6 100644
--- a/bindings/python/src/index_build.rs
+++ b/bindings/python/src/index_build.rs
@@ -21,10 +21,14 @@ use std::collections::HashMap;
use std::sync::Arc;
use paimon::spec::Predicate;
-use paimon::table::{GlobalIndexBuildBuilder, GlobalIndexDropBuilder, Table};
+use paimon::table::{
+ GlobalIndexBuildBuilder, GlobalIndexBuildPlan, GlobalIndexBuildShard,
GlobalIndexDropBuilder,
+ Table,
+};
use paimon_datafusion::runtime::runtime;
+use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
-use pyo3::types::PyDict;
+use pyo3::types::{PyBytes, PyDict};
use crate::error::to_py_err;
use crate::predicate::dict_to_table_predicate;
@@ -35,6 +39,84 @@ enum PyIndexColumns {
Columns(Vec<String>),
}
+#[pyclass(
+ name = "GlobalIndexBuildPlan",
+ module = "pypaimon_rust.datafusion",
+ frozen
+)]
+pub struct PyGlobalIndexBuildPlan {
+ inner: GlobalIndexBuildPlan,
+}
+
+#[pyclass(
+ name = "GlobalIndexBuildShard",
+ module = "pypaimon_rust.datafusion",
+ frozen
+)]
+pub struct PyGlobalIndexBuildShard {
+ inner: GlobalIndexBuildShard,
+}
+
+// Runtime handles stay in the worker's Table; pickle carries domain metadata.
+macro_rules! build_plan_pickle {
+ ($class:ident, { $($methods:item)* }) => {
+ #[pymethods]
+ impl $class {
+ $($methods)*
+
+ fn _serialize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py,
PyBytes>> {
+ let bytes = serde_json::to_vec(&self.inner)
+ .map_err(|error|
PyValueError::new_err(error.to_string()))?;
+ Ok(PyBytes::new(py, &bytes))
+ }
+
+ #[staticmethod]
+ fn _deserialize(state: &Bound<'_, PyBytes>) -> PyResult<Self> {
+ serde_json::from_slice(state.as_bytes())
+ .map(|inner| Self { inner })
+ .map_err(|error| {
+ PyValueError::new_err(format!(
+ "Invalid global index build metadata: {error}"
+ ))
+ })
+ }
+
+ fn __reduce__<'py>(
+ slf: &Bound<'py, Self>,
+ py: Python<'py>,
+ ) -> PyResult<(Py<PyAny>, (Py<PyBytes>,))> {
+ let bytes = slf.borrow()._serialize(py)?.unbind();
+ Ok((slf.get_type().getattr("_deserialize")?.unbind(),
(bytes,)))
+ }
+ }
+ };
+}
+
+build_plan_pickle!(PyGlobalIndexBuildPlan, {
+ fn snapshot_id(&self) -> Option<i64> {
+ self.inner.snapshot_id()
+ }
+
+ fn shards(&self) -> Vec<PyGlobalIndexBuildShard> {
+ self.inner
+ .shards()
+ .into_iter()
+ .map(|inner| PyGlobalIndexBuildShard { inner })
+ .collect()
+ }
+});
+
+build_plan_pickle!(PyGlobalIndexBuildShard, {
+ fn snapshot_id(&self) -> i64 {
+ self.inner.snapshot_id()
+ }
+
+ fn row_range(&self) -> (i64, i64) {
+ let range = self.inner.row_range();
+ (range.from(), range.to())
+ }
+});
+
#[pyclass(name = "GlobalIndexBuildBuilder", module =
"pypaimon_rust.datafusion")]
pub struct PyGlobalIndexBuildBuilder {
table: Arc<Table>,
@@ -225,6 +307,26 @@ impl PyGlobalIndexBuildBuilder {
Ok(messages.into_iter().map(PyCommitMessage::new).collect())
}
+ fn plan(&self, py: Python<'_>) -> PyResult<PyGlobalIndexBuildPlan> {
+ let builder = self.builder().map_err(to_py_err)?;
+ let inner = py
+ .detach(|| runtime().block_on(builder.plan()))
+ .map_err(to_py_err)?;
+ Ok(PyGlobalIndexBuildPlan { inner })
+ }
+
+ fn build_shard(
+ &self,
+ py: Python<'_>,
+ shard: &PyGlobalIndexBuildShard,
+ ) -> PyResult<Vec<PyCommitMessage>> {
+ let builder = self.table.new_global_index_build_builder();
+ let messages = py
+ .detach(|| runtime().block_on(builder.build_shard(&shard.inner)))
+ .map_err(to_py_err)?;
+ Ok(messages.into_iter().map(PyCommitMessage::new).collect())
+ }
+
fn execute(&self, py: Python<'_>) -> PyResult<usize> {
let builder = self.builder().map_err(to_py_err)?;
py.detach(|| runtime().block_on(builder.execute()))
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 05996167..3545ff39 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -1170,7 +1170,7 @@ impl PyRecordBatchReader {
#[pyclass(name = "Plan", module = "pypaimon_rust.datafusion")]
pub struct PyPlan {
- inner: paimon::table::Plan,
+ pub(crate) inner: paimon::table::Plan,
}
#[pyclass(name = "FileScanPlan", module = "pypaimon_rust.datafusion", frozen)]
@@ -1197,6 +1197,20 @@ impl From<paimon::table::Plan> for PyPlan {
#[pymethods]
impl PyPlan {
+ /// Restore captured planning metadata using typed splits. Execution
+ /// authorizes the transported plan independently.
+ #[new]
+ #[pyo3(signature = (snapshot_id, splits))]
+ fn new(snapshot_id: Option<i64>, splits: &Bound<'_, PyAny>) ->
PyResult<Self> {
+ let splits = splits
+ .try_iter()?
+ .map(|split| Ok(split?.extract::<PyRef<'_,
PySplit>>()?.inner.clone()))
+ .collect::<PyResult<Vec<_>>>()?;
+ Ok(Self {
+ inner: paimon::table::Plan::from_splits(snapshot_id, splits),
+ })
+ }
+
/// Snapshot selected by the scan, even when pruning produces no splits.
/// `None` means no snapshot was selected, including snapshot-free format
tables.
fn snapshot_id(&self) -> Option<i64> {
@@ -1223,6 +1237,46 @@ impl PyPlan {
}
}
+/// Row-tracking routing metadata; constructing it performs no I/O.
+#[pyclass(name = "RowIdPlan", module = "pypaimon_rust.datafusion")]
+pub struct PyRowIdPlan {
+ inner: paimon::table::RowIdPlan,
+}
+
+#[pymethods]
+impl PyRowIdPlan {
+ #[new]
+ fn new(plan: &PyPlan) -> PyResult<Self> {
+ Ok(Self {
+ inner:
paimon::table::RowIdPlan::from_plan(plan.inner.clone()).map_err(to_py_err)?,
+ })
+ }
+
+ fn snapshot_id(&self) -> Option<i64> {
+ self.inner.snapshot_id()
+ }
+
+ fn groups(&self) -> Vec<(i64, i64, PySplit)> {
+ self.inner
+ .groups()
+ .iter()
+ .map(|(first, last, split)| {
+ (
+ *first,
+ *last,
+ PySplit {
+ inner: split.clone(),
+ },
+ )
+ })
+ .collect()
+ }
+
+ fn plan(&self) -> PyPlan {
+ self.inner.plan().clone().into()
+ }
+}
+
#[pyclass(name = "Split", module = "pypaimon_rust.datafusion")]
pub struct PySplit {
pub(crate) inner: DataSplit,
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 299730b0..e5688b9b 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -616,12 +616,24 @@ fn arrow_upsert_batches(input: &Bound<'_, PyAny>) ->
PyResult<Vec<RecordBatch>>
struct UpdateContext {
inner: TableUpdate,
+ planned: bool,
table: Arc<Table>,
table_location: String,
commit_user: String,
}
impl UpdateContext {
+ // Reject a mismatched operation before consuming a Python input iterator
+ // or converting callbacks. Core independently enforces the same contract.
+ fn ensure_unplanned(&self) -> PyResult<()> {
+ if self.planned {
+ return Err(PyValueError::new_err(
+ "with_plan supports only row-ID update and delete operations",
+ ));
+ }
+ Ok(())
+ }
+
fn with_blob_uri_reader_factory(
&mut self,
factory: Option<Py<PyAny>>,
@@ -634,6 +646,7 @@ impl UpdateContext {
fn new(context: &WriteContext) -> PyResult<Self> {
Ok(Self {
+ planned: false,
inner: context
.table
.new_write_builder()
@@ -670,6 +683,7 @@ impl UpdateContext {
when_matched: &Bound<'_, PyAny>,
when_not_matched: &Bound<'_, PyAny>,
) -> PyResult<Vec<PyCommitMessage>> {
+ self.ensure_unplanned()?;
let source = match source {
Some(source) => match
source.extract::<PyRef<crate::table::PyTable>>() {
Ok(table) =>
paimon::table::MergeSource::Table(table.inner.clone()),
@@ -706,6 +720,7 @@ impl UpdateContext {
assignments: &Bound<'_, PyDict>,
read_columns: Option<Vec<String>>,
) -> PyResult<Vec<PyCommitMessage>> {
+ self.ensure_unplanned()?;
let predicate = predicate
.map(|predicate| dict_to_table_predicate(predicate,
self.table.schema(), true))
.transpose()?;
@@ -795,6 +810,7 @@ impl UpdateContext {
count: usize,
read_type: Option<&str>,
) -> PyResult<crate::shard_update::PyShardTableUpdator> {
+ self.ensure_unplanned()?;
let read_type = read_type
.map(|json| {
serde_json::from_str::<RowType>(json)
@@ -829,6 +845,7 @@ impl UpdateContext {
input: &Bound<'_, PyAny>,
keys: Vec<String>,
) -> PyResult<Vec<PyCommitMessage>> {
+ self.ensure_unplanned()?;
let batches = arrow_upsert_batches(input)?;
let messages = py
.detach(||
runtime().block_on(self.inner.upsert_by_arrow_with_key(batches, keys)))
@@ -845,6 +862,7 @@ impl UpdateContext {
py: Python<'_>,
predicate: Option<&Bound<'_, PyDict>>,
) -> PyResult<Vec<PyCommitMessage>> {
+ self.ensure_unplanned()?;
let predicate = predicate
.map(|predicate| dict_to_table_predicate(predicate,
self.table.schema(), true))
.transpose()?;
@@ -1064,6 +1082,18 @@ pub struct PyStreamTableUpdate {
#[pymethods]
impl PyStreamTableUpdate {
+ fn with_plan<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ plan: &crate::read::PyPlan,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ slf.context
+ .inner
+ .with_plan(plan.inner.clone())
+ .map_err(to_py_err)?;
+ slf.context.planned = true;
+ Ok(slf)
+ }
+
#[pyo3(signature = (factory))]
fn with_blob_uri_reader_factory(
mut slf: PyRefMut<'_, Self>,
@@ -1184,6 +1214,18 @@ impl PyStreamTableUpdate {
#[pymethods]
impl PyBatchTableUpdate {
+ fn with_plan<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ plan: &crate::read::PyPlan,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ slf.context
+ .inner
+ .with_plan(plan.inner.clone())
+ .map_err(to_py_err)?;
+ slf.context.planned = true;
+ Ok(slf)
+ }
+
#[pyo3(signature = (predicate))]
fn delete_by_predicate(
&self,
@@ -1615,6 +1657,27 @@ impl PyBatchTableCommit {
.map_err(to_callback_error)
}
+ /// Validate logical DML conflicts without publishing or deleting any
files.
+ fn check_row_id_conflicts(
+ &self,
+ py: Python<'_>,
+ messages: &Bound<'_, PyAny>,
+ latest_snapshot_id: i64,
+ check_from_snapshot: i64,
+ ) -> PyResult<()> {
+ let messages = self
+ .context
+ .messages(messages, "check_row_id_conflicts", false)?;
+ py.detach(|| {
+ runtime().block_on(self.context.inner.check_row_id_conflicts(
+ &messages,
+ latest_snapshot_id,
+ check_from_snapshot,
+ ))
+ })
+ .map_err(to_py_err)
+ }
+
fn truncate_table(&mut self, py: Python<'_>) -> PyResult<()> {
self.check_committed()?;
py.detach(|| runtime().block_on(self.context.inner.truncate_table()))
@@ -1784,6 +1847,186 @@ mod tests {
use paimon::spec::{BigIntType, IntType, Schema, TableSchema, VarCharType};
use pyo3::ffi::c_str;
+ #[test]
+ fn
captured_plan_rejects_other_operations_before_python_input_consumption() {
+ Python::attach(|py| {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Arc::new(Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "planned_binding_boundary"),
+ "memory:/planned_binding_boundary".into(),
+ TableSchema::new(0, &schema),
+ None,
+ ));
+ let locals = PyDict::new(py);
+ locals
+ .set_item(
+ "batch",
+ Py::new(py,
PyBatchWriteBuilder::new(table.clone())).unwrap(),
+ )
+ .unwrap();
+ locals
+ .set_item(
+ "stream",
+ Py::new(py, PyStreamWriteBuilder::new(table)).unwrap(),
+ )
+ .unwrap();
+ locals
+ .set_item("Plan", py.get_type::<crate::read::PyPlan>())
+ .unwrap();
+ py.run(c_str!(r#"
+class ExplodingInput:
+ def __iter__(self):
+ raise AssertionError('the supplied plan must reject input before
iteration')
+
+for builder, identifier in [(batch, None), (stream, 7)]:
+ update = builder.new_update()
+ assert update.with_plan(Plan(None, [])) is update
+ assert update.with_plan(Plan(None, [])) is update
+ options = {} if identifier is None else {'commit_identifier': identifier}
+ operations = [
+ lambda: update.upsert_by_arrow_with_key(ExplodingInput(), ['id'],
**options),
+ lambda: update.merge_into(ExplodingInput(), on=[], when_matched=[],
when_not_matched=[], **options),
+ ]
+ for operation in operations:
+ try:
+ operation()
+ except ValueError as error:
+ assert 'with_plan supports only row-ID' in str(error), str(error)
+ else:
+ raise AssertionError('captured plan was silently ignored')
+"#),Some(&locals),Some(&locals)).unwrap();
+ });
+ }
+
+ #[test]
+ fn row_id_conflict_check_does_not_publish_or_consume_batch_commit() {
+ use arrow::array::{Array, Int32Array, Int64Array};
+ use futures::TryStreamExt;
+
+ Python::attach(|py| {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Arc::new(Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "conflict_check_binding"),
+ "memory:/conflict_check_binding".into(),
+ TableSchema::new(0, &schema),
+ None,
+ ));
+ let messages = py.detach(|| {
+ runtime().block_on(async {
+ let mut writer =
table.new_write_builder().new_write().unwrap();
+ let batch = RecordBatch::try_new(
+
paimon::arrow::build_target_arrow_schema(table.schema().fields()).unwrap(),
+ vec![Arc::new(Int32Array::from(vec![1, 2]))],
+ )
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ writer.close().await;
+ let updates = RecordBatch::try_from_iter(vec![
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![0])) as
arrow::array::ArrayRef,
+ ),
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![9])) as
arrow::array::ArrayRef,
+ ),
+ ])
+ .unwrap();
+ table
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![updates])
+ .await
+ .unwrap()
+ })
+ });
+ let messages = messages
+ .into_iter()
+ .map(PyCommitMessage::new)
+ .collect::<Vec<_>>();
+ let builder = PyBatchWriteBuilder::new(table.clone());
+ let commit = Py::new(py, builder.new_commit().unwrap()).unwrap();
+ let locals = PyDict::new(py);
+ locals.set_item("commit", commit).unwrap();
+ locals.set_item("messages", messages).unwrap();
+ py.run(
+ c_str!("commit.check_row_id_conflicts(messages, 1, 1)"),
+ Some(&locals),
+ Some(&locals),
+ )
+ .unwrap();
+ let snapshot = py
+ .detach(||
runtime().block_on(table.snapshot_manager().get_latest_snapshot()))
+ .unwrap()
+ .unwrap();
+ assert_eq!(snapshot.id(), 1, "the independent check must not
publish");
+ py.run(
+ c_str!("commit.commit(messages)"),
+ Some(&locals),
+ Some(&locals),
+ )
+ .unwrap();
+ py.detach(|| {
+ runtime().block_on(async {
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ snapshot.id(),
+ 2,
+ "the check must not consume the batch boundary"
+ );
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let mut values = batches
+ .iter()
+ .flat_map(|batch| {
+ let values = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ assert_eq!(values.null_count(), 0);
+ values.values().to_vec()
+ })
+ .collect::<Vec<_>>();
+ values.sort_unstable();
+ assert_eq!(values, [2, 9]);
+ })
+ });
+ });
+ }
+
#[test]
fn public_uri_factory_and_commit_user_setters_are_chainable() {
Python::attach(|py| {
diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index 01f877aa..f4eec826 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -31,6 +31,30 @@ pub enum Error {
/// A configured resource budget could not admit the requested reservation.
#[snafu(display("Paimon resource exhausted: {}", message))]
ResourceExhausted { message: String },
+ /// A staged row-ID range no longer matches the current data-file layout.
+ ///
+ /// `current_row_count` is present only for a DELETE whose physical file
+ /// still exists with different row-ID metadata. Logical DML conflicts and
+ /// uncertain publication outcomes must not be classified as this error.
+ #[snafu(display(
+ "{}",
+ row_id_existence_conflict_message(
+ file_name,
+ first_row_id,
+ row_count,
+ bucket,
+ current_first_row_id,
+ current_row_count
+ )
+ ))]
+ RowIdExistenceConflict {
+ file_name: String,
+ first_row_id: Option<i64>,
+ row_count: i64,
+ bucket: i32,
+ current_first_row_id: Option<i64>,
+ current_row_count: Option<i64>,
+ },
#[snafu(whatever, display("Paimon data invalid for {}: {:?}", message,
source))]
DataInvalid {
message: String,
@@ -151,6 +175,34 @@ pub enum Error {
},
}
+fn row_id_existence_conflict_message(
+ file_name: &str,
+ first_row_id: &Option<i64>,
+ row_count: &i64,
+ bucket: &i32,
+ current_first_row_id: &Option<i64>,
+ current_row_count: &Option<i64>,
+) -> String {
+ let first_row_id = first_row_id.map_or_else(|| "null".into(), |value|
value.to_string());
+ if let Some(current_row_count) = current_row_count {
+ let current_first_row_id =
+ current_first_row_id.map_or_else(|| "null".into(), |value|
value.to_string());
+ format!(
+ "Row ID existence conflict: DELETE for file '{file_name}'
references \
+ firstRowId={first_row_id}, rowCount={row_count} in bucket
{bucket}, but the current \
+ file has firstRowId={current_first_row_id},
rowCount={current_row_count}. The file \
+ may have been reassigned by a concurrent commit."
+ )
+ } else {
+ format!(
+ "Row ID existence conflict: file '{file_name}' references
firstRowId={first_row_id}, \
+ rowCount={row_count} in bucket {bucket}, but no matching file
exists in the current \
+ snapshot. The referenced file may have been rewritten by a
concurrent compaction \
+ or removed by an overwrite."
+ )
+ }
+}
+
impl From<opendal::Error> for Error {
fn from(source: opendal::Error) -> Self {
Error::from_opendal_with_context(source, "IO operation failed on
underlying storage")
diff --git a/crates/paimon/src/table/authorized_update_tests.rs
b/crates/paimon/src/table/authorized_update_tests.rs
index eb24ea09..6de0fdc4 100644
--- a/crates/paimon/src/table/authorized_update_tests.rs
+++ b/crates/paimon/src/table/authorized_update_tests.rs
@@ -26,6 +26,190 @@ const EVOLUTION_OPTIONS: &[(&str, &str)] = &[
("deletion-vectors.enabled", "true"),
];
+#[tokio::test]
+async fn
transported_row_id_plans_keep_the_rest_snapshot_after_concurrent_append() {
+ for delete in [false, true] {
+ let fixture = RestTable::new(false, EVOLUTION_OPTIONS).await;
+ fixture.append("seed", &[(1, "a", 10)], false).await;
+ fixture.state.deny_query.store(false, Ordering::SeqCst);
+ let plan = fixture
+ .table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ assert_eq!(plan.snapshot_id(), Some(1));
+ fixture.append("concurrent", &[(2, "a", 20)], false).await;
+ let requests = fixture.state.query_requests.load(Ordering::SeqCst);
+ let mut update =
fixture.table.new_write_builder().new_update().unwrap();
+ update.with_plan(plan).unwrap();
+ let messages = if delete {
+ update.delete_by_row_id(vec![0]).await.unwrap()
+ } else {
+ update
+ .update_by_arrow_with_row_id(vec![row_update(0, 99)])
+ .await
+ .unwrap()
+ };
+ assert_eq!(messages[0].check_from_snapshot, Some(1));
+ assert!(fixture.state.query_requests.load(Ordering::SeqCst) >
requests);
+ fixture
+ .table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+ assert_eq!(
+ fixture.stored_rows().await,
+ if delete {
+ vec![(2, "a".into(), 20)]
+ } else {
+ vec![(1, "a".into(), 99), (2, "a".into(), 20)]
+ }
+ );
+ fixture
+ .table
+ .snapshot_manager()
+ .delete_snapshot(1)
+ .await
+ .unwrap();
+ let error = if delete {
+ update.delete_by_row_id(vec![0]).await.unwrap_err()
+ } else {
+ update
+ .update_by_arrow_with_row_id(vec![row_update(0, 100)])
+ .await
+ .unwrap_err()
+ };
+ assert!(matches!(
+ error,
+ crate::Error::SnapshotNotExist { snapshot_id: 1 }
+ ));
+ }
+}
+
+#[tokio::test]
+async fn
transported_row_id_plans_refresh_whole_table_authorization_without_snapshot_files()
{
+ for delete in [false, true] {
+ for response in [None]
+ .into_iter()
+ .chain(restricted_responses().into_iter().map(Some))
+ {
+ let fixture = RestTable::new(false, EVOLUTION_OPTIONS).await;
+ fixture.append("seed", &[(1, "a", 10)], false).await;
+ fixture.state.deny_query.store(false, Ordering::SeqCst);
+ let plan = fixture
+ .table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ let requests = fixture.state.query_requests.load(Ordering::SeqCst);
+ if let Some(response) = response {
+ *fixture.state.query_response.lock().await = response;
+ } else {
+ fixture.state.deny_query.store(true, Ordering::SeqCst);
+ }
+ let before = fixture
+ .table
+ .file_io()
+ .list_status_recursive(fixture.table.location())
+ .await
+ .unwrap()
+ .len();
+ let mut update =
fixture.table.new_write_builder().new_update().unwrap();
+ update.with_plan(plan).unwrap();
+ let result = if delete {
+ update.delete_by_row_id(vec![0]).await
+ } else {
+ update
+ .update_by_arrow_with_row_id(vec![row_update(0, 99)])
+ .await
+ };
+ assert!(
+ result.is_err(),
+ "captured grant must not survive a policy change"
+ );
+ assert!(fixture.state.query_requests.load(Ordering::SeqCst) >
requests);
+ assert_eq!(
+ fixture
+ .table
+ .file_io()
+ .list_status_recursive(fixture.table.location())
+ .await
+ .unwrap()
+ .len(),
+ before
+ );
+ }
+ }
+ let fixture = RestTable::new(false, EVOLUTION_OPTIONS).await;
+ fixture.append("seed", &[(1, "a", 10)], false).await;
+ fixture.state.deny_query.store(false, Ordering::SeqCst);
+ let plan = fixture
+ .table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ fixture
+ .table
+ .file_io()
+ .delete_dir(&format!("{}/snapshot", fixture.table.location()))
+ .await
+ .unwrap();
+ assert!(fixture
+ .table
+ .snapshot_manager()
+ .get_snapshot(1)
+ .await
+ .is_err());
+ let mut update = fixture.table.new_write_builder().new_update().unwrap();
+ update.with_plan(plan).unwrap();
+ let messages = update
+ .update_by_arrow_with_row_id(vec![row_update(0, 99)])
+ .await
+ .unwrap();
+ assert_eq!(messages[0].check_from_snapshot, Some(1));
+ assert_eq!(
+ update.delete_by_row_id(vec![0]).await.unwrap()[0].check_from_snapshot,
+ Some(1)
+ );
+}
+
+#[tokio::test]
+async fn
restricted_read_plans_cannot_be_repurposed_as_mutation_plans_after_policy_relaxes()
{
+ let fixture = RestTable::new(false, EVOLUTION_OPTIONS).await;
+ fixture.append("seed", &[(1, "a", 10)], false).await;
+ fixture.state.deny_query.store(false, Ordering::SeqCst);
+ *fixture.state.query_response.lock().await =
restricted_responses().remove(0);
+ let plan = fixture
+ .table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap();
+ *fixture.state.query_response.lock().await = serde_json::json!({});
+ let mut update = fixture.table.new_write_builder().new_update().unwrap();
+ update.with_plan(plan).unwrap();
+ let error = update
+ .update_by_arrow_with_row_id(vec![row_update(0, 99)])
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("filtered or masked read plan"));
+ assert!(update
+ .delete_by_row_id(vec![0])
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("filtered or masked read plan"));
+}
+
fn row_update(row_id: i64, value: i32) -> RecordBatch {
RecordBatch::try_from_iter([
(
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index 2df6a785..470f7aa0 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -39,7 +39,7 @@ use crate::table::index_file_path::IndexFileLocation;
use crate::table::source::{data_evolution_anchor_file,
is_data_evolution_normal_file};
use crate::table::stats_filter::group_by_overlapping_row_id;
use crate::table::Table;
-use crate::table::{DataSplit, RowRange};
+use crate::table::{DataSplit, Plan, RowRange};
use crate::Result;
use arrow_array::{Array, ArrayRef, Int64Array, RecordBatch, StructArray};
use arrow_buffer::NullBuffer;
@@ -355,6 +355,7 @@ impl DataEvolutionWriter {
)?;
let file_matches = group_matched_rows_by_file(&self.matched_batches,
file_index)?;
self.validate_partition_values(file_index, &file_matches)?;
+ index.validate_live_rows(&file_matches).await?;
// 3. For each affected file: read original columns, apply updates,
write partial files
let mut writer = DataEvolutionPartialWriter::new(&self.table,
self.update_columns.clone())?;
@@ -598,7 +599,15 @@ impl DataEvolutionDeleteWriter {
}
#[must_use = "commit messages must be passed to TableCommit"]
- pub async fn prepare_commit(mut self) -> Result<Vec<CommitMessage>> {
+ pub async fn prepare_commit(self) -> Result<Vec<CommitMessage>> {
+ self.prepare_commit_inner(None).await
+ }
+
+ pub(super) async fn prepare_commit_with_plan(self, plan: &Plan) ->
Result<Vec<CommitMessage>> {
+ self.prepare_commit_inner(Some(plan)).await
+ }
+
+ async fn prepare_commit_inner(mut self, captured: Option<&Plan>) ->
Result<Vec<CommitMessage>> {
dedup_i64_in_place(&mut self.row_ids);
if self.row_ids.is_empty() {
self.table
@@ -610,9 +619,18 @@ impl DataEvolutionDeleteWriter {
}
// A row filter or mask cannot authorize a physical file mutation.
- let snapshot =
super::time_travel::resolve_snapshot(&self.table).await?;
- let scan_table =
self.table.copy_with_pinned_snapshot(snapshot.as_ref());
- let plan = scan_table.new_read_builder().new_scan().plan_all().await?;
+ let (snapshot, plan) = match captured {
+ Some(plan) => {
+ let (_, snapshot, plan) = authorize_row_id_plan(&self.table,
plan).await?;
+ (snapshot, plan)
+ }
+ None => {
+ let snapshot =
super::time_travel::resolve_snapshot(&self.table).await?;
+ let scan_table =
self.table.copy_with_pinned_snapshot(snapshot.as_ref());
+ let plan =
scan_table.new_read_builder().new_scan().plan_all().await?;
+ (snapshot, plan)
+ }
+ };
let snapshot = snapshot.ok_or_else(|| crate::Error::DataInvalid {
message: "No files with row tracking found in target table".into(),
source: None,
@@ -1249,6 +1267,11 @@ pub(super) struct RowIdFileIndex {
}
impl RowIdFileIndex {
+ pub(super) async fn load_plan(table: &Table, plan: &Plan) -> Result<Self> {
+ let (read_table, _, plan) = authorize_row_id_plan(table, plan).await?;
+ Self::from_splits(read_table, plan.splits())
+ }
+
pub(super) async fn load(table: &Table, snapshot_id: Option<i64>) ->
Result<Self> {
// Authorize before resolving an empty target. The original handle's
// session is retained when the read view pins the selected snapshot.
@@ -1362,11 +1385,136 @@ impl RowIdFileIndex {
.map(|&index| self.files[index].first_row_id)
.collect())
}
+
+ async fn validate_live_rows(&self, matches: &HashMap<usize,
Vec<MatchedRow>>) -> Result<()> {
+ for (&file_pos, rows) in matches {
+ let group = &self.files[file_pos];
+ let anchor = data_evolution_anchor_file(&group.files)?;
+ let Some(deletion_file) =
group.split.deletion_file_for_data_file(anchor) else {
+ continue;
+ };
+ let deletion_vector =
+ DeletionVectorFactory::read(self.read_table.file_io(),
deletion_file).await?;
+ let first = anchor.first_row_id.expect("validated row-ID anchor");
+ for row in rows {
+ let row_id = group.first_row_id + row.offset as i64;
+ if deletion_vector.is_deleted((row_id - first) as u64) {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Cannot update deleted _ROW_ID
{row_id}"),
+ source: None,
+ });
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+/// Validate transported planner metadata and mint a worker-local mutation
grant.
+/// The caller supplies complete planner groups; manifests are deliberately not
+/// re-read to rediscover metadata already captured by the coordinator.
+async fn authorize_row_id_plan(
+ table: &Table,
+ plan: &Plan,
+) -> Result<(Table, Option<Snapshot>, Plan)> {
+ let required = table.schema().core_options().query_auth_enabled()
+ || plan.splits().iter().any(DataSplit::query_auth_required);
+ let grant = table.authorize_read(required, None).await?;
+ if required && grant.is_none() {
+ return Err(super::query_auth::unsupported(
+ "transported row-ID plans require REST authorization",
+ ));
+ }
+ if grant
+ .as_ref()
+ .is_some_and(|grant| !grant.is_unrestricted_whole_table())
+ {
+ return Err(super::query_auth::unsupported(
+ "row-ID updates require an unrestricted whole-table grant",
+ ));
+ }
+ super::RowIdPlan::from_plan(plan.clone())?;
+ if plan.splits().iter().any(|split| {
+ split
+ .query_auth_response()
+ .is_some_and(|response| !response.is_unrestricted())
+ }) {
+ return Err(super::query_auth::unsupported(
+ "a filtered or masked read plan cannot authorize row-ID mutations",
+ ));
+ }
+ let snapshot = match plan.snapshot_id() {
+ None => None,
+ Some(id) => {
+ let snapshot = if let Some(snapshot) = table.travel_snapshot() {
+ snapshot.clone()
+ } else if table.rest_env().is_some() {
+ match table.snapshot_manager().get_latest_snapshot().await? {
+ Some(snapshot) if snapshot.id() == id => snapshot,
+ // A concurrent append does not invalidate the captured
+ // plan. Resolve its exact snapshot rather than replacing
+ // the worker's conflict baseline with the latest one.
+ _ => table.snapshot_manager().get_snapshot(id).await?,
+ }
+ } else {
+ table.snapshot_manager().get_snapshot(id).await?
+ };
+ if snapshot.id() != id || snapshot.schema_id() >
table.schema().id() {
+ return Err(crate::Error::DataInvalid {
+ message: "The row-ID plan does not match the table's
captured snapshot/schema"
+ .into(),
+ source: None,
+ });
+ }
+ Some(snapshot)
+ }
+ };
+ let options = table.schema().core_options();
+ let computer = if table.schema().partition_keys().is_empty() {
+ None
+ } else {
+ Some(PartitionComputer::new(
+ table.schema().partition_keys(),
+ table.schema().fields(),
+ options.partition_default_name(),
+ options.legacy_partition_name(),
+ )?)
+ };
+ for split in plan.splits() {
+ let expected = bucket_path(
+ &table.data_file_location(),
+ computer.as_ref(),
+ split.partition(),
+ split.bucket(),
+ )?;
+ if split.bucket_path() != expected {
+ return Err(crate::Error::DataInvalid {
+ message: "The row-ID split bucket path does not belong to the
target table".into(),
+ source: None,
+ });
+ }
+ for file in split.data_files() {
+ let (first, last) = file.row_id_range().expect("validated row-ID
range");
+ if first < 0
+ || snapshot.as_ref().is_none_or(|snapshot| {
+ file.schema_id > snapshot.schema_id()
+ || snapshot.next_row_id().is_some_and(|next| last >=
next)
+ })
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "The row-ID split contains file metadata outside
its snapshot".into(),
+ source: None,
+ });
+ }
+ }
+ }
+ let read_table = table.copy_with_pinned_snapshot(snapshot.as_ref());
+ Ok((read_table, snapshot, plan.clone().planned(grant)))
}
/// Dedicated column files can cross normal-file boundaries. They provide
/// values, but must not merge anchors or introduce otherwise absent row IDs.
-fn group_by_normal_row_id(files: Vec<DataFileMeta>) -> Vec<Vec<DataFileMeta>> {
+pub(super) fn group_by_normal_row_id(files: Vec<DataFileMeta>) ->
Vec<Vec<DataFileMeta>> {
let (normal, dedicated): (Vec<_>, Vec<_>) =
files.into_iter().partition(is_data_evolution_normal_file);
group_by_overlapping_row_id(normal)
diff --git a/crates/paimon/src/table/global_index_build_builder.rs
b/crates/paimon/src/table/global_index_build_builder.rs
index cb203619..ab46595c 100644
--- a/crates/paimon/src/table/global_index_build_builder.rs
+++ b/crates/paimon/src/table/global_index_build_builder.rs
@@ -21,6 +21,7 @@ use std::time::Instant;
#[cfg(feature = "fulltext")]
use super::full_text_index_build_builder::FullTextIndexBuildBuilder;
use super::global_index_build_common::{resolve_index_fields, IndexColumns};
+use super::global_index_build_plan::{GlobalIndexBuildPlan,
GlobalIndexBuildShard};
use super::global_index_types::{
normalize_global_index_type, normalize_queryable_global_index_type,
BTREE_GLOBAL_INDEX_TYPE,
FULL_TEXT_GLOBAL_INDEX_TYPE,
@@ -125,6 +126,47 @@ impl<'a> GlobalIndexBuildBuilder<'a> {
Ok(self.prepare().await?.messages)
}
+ /// Capture independently executable vector-index shards without writing
files.
+ ///
+ /// The plan owns its snapshot, schema and build options. Workers execute a
+ /// shard rather than replanning the table or publishing its commit
messages.
+ pub async fn plan(&self) -> Result<GlobalIndexBuildPlan> {
+
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+ self.table.ensure_not_branch_reference_for_write()?;
+ let index_type =
normalize_global_index_type(self.index_type.trim()).ok_or_else(|| {
+ Error::Unsupported {
+ message: format!("Unsupported global index type: {}",
self.index_type),
+ }
+ })?;
+ if !crate::vindex::is_vindex_index_type(index_type) {
+ return Err(Error::Unsupported {
+ message: "Global index build plans currently support only
vindex indexes".into(),
+ });
+ }
+ let columns = self.columns.as_ref().ok_or_else(|| Error::DataInvalid {
+ message: "Global index column is required".into(),
+ source: None,
+ })?;
+ let fields = resolve_index_fields(self.table, columns, index_type)?;
+ let mut builder = VindexIndexBuildBuilder::new(self.table, index_type);
+ builder
+ .with_index_column(fields[0].name())
+ .with_options(self.options.clone());
+ builder.partition_filter = self.partition_filter.clone();
+ builder.plan().await
+ }
+
+ /// Build only the files and physical row range captured by this shard.
+ /// Builder settings do not replace the plan's schema or index options.
+ pub async fn build_shard(&self, shard: &GlobalIndexBuildShard) ->
Result<Vec<CommitMessage>> {
+ Ok(shard
+ .build(self.table)
+ .await?
+ .into_iter()
+ .map(|(message, _)| message)
+ .collect())
+ }
+
/// Prepare and commit, rejecting changes to the snapshot used for the
build.
pub async fn execute(&self) -> Result<usize> {
let prepared = self.prepare().await?;
diff --git a/crates/paimon/src/table/global_index_build_common/vector.rs
b/crates/paimon/src/table/global_index_build_common/vector.rs
index a226597d..97ed5b71 100644
--- a/crates/paimon/src/table/global_index_build_common/vector.rs
+++ b/crates/paimon/src/table/global_index_build_common/vector.rs
@@ -26,7 +26,7 @@ use crate::table::{RowRange, Table};
use crate::{Error, Result};
use std::collections::HashMap;
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct VectorIndexShard {
pub(crate) partition: BinaryRow,
pub(crate) partition_bytes: Vec<u8>,
diff --git a/crates/paimon/src/table/global_index_build_plan.rs
b/crates/paimon/src/table/global_index_build_plan.rs
new file mode 100644
index 00000000..9f8536d2
--- /dev/null
+++ b/crates/paimon/src/table/global_index_build_plan.rs
@@ -0,0 +1,151 @@
+// 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.
+
+//! Snapshot-bound index tasks, independent of their scheduling framework.
+
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::global_index_build_common::vector::VectorIndexShard;
+use super::vindex_index_build_builder::timing::VectorIndexBuildTiming;
+use super::vindex_index_build_builder::VindexIndexBuildBuilder;
+use super::{CommitMessage, RowRange, Table};
+use crate::spec::{Snapshot, TableSchema};
+use crate::{Error, Result};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct BuildContext {
+ location: String,
+ identifier: String,
+ branch: String,
+ schema: TableSchema,
+ snapshot: Option<Snapshot>,
+ column: String,
+ index_type: String,
+ options: HashMap<String, String>,
+}
+
+/// An immutable global-index build over one captured table snapshot.
+/// Contains metadata only: clients, FileIO and runtime handles stay in Table.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GlobalIndexBuildPlan {
+ context: BuildContext,
+ shards: Vec<VectorIndexShard>,
+}
+
+impl GlobalIndexBuildPlan {
+ pub(crate) fn new(
+ table: &Table,
+ snapshot: Option<Snapshot>,
+ column: String,
+ index_type: String,
+ options: HashMap<String, String>,
+ shards: Vec<VectorIndexShard>,
+ ) -> Self {
+ Self {
+ context: BuildContext {
+ location: table.location().to_string(),
+ identifier: table.identifier().full_name(),
+ branch: table.branch().to_string(),
+ schema: table.schema().clone(),
+ snapshot,
+ column,
+ index_type,
+ options,
+ },
+ shards,
+ }
+ }
+
+ pub fn snapshot_id(&self) -> Option<i64> {
+ self.context.snapshot.as_ref().map(Snapshot::id)
+ }
+
+ /// Each task carries the same captured schema, options and snapshot.
+ pub fn shards(&self) -> Vec<GlobalIndexBuildShard> {
+ self.shards
+ .iter()
+ .cloned()
+ .map(|shard| GlobalIndexBuildShard {
+ context: self.context.clone(),
+ shard,
+ })
+ .collect()
+ }
+}
+
+/// A complete physical row range and its files, executable on another worker.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GlobalIndexBuildShard {
+ context: BuildContext,
+ shard: VectorIndexShard,
+}
+
+impl GlobalIndexBuildShard {
+ pub fn snapshot_id(&self) -> i64 {
+ self.shard.snapshot_id
+ }
+
+ pub fn row_range(&self) -> RowRange {
+ RowRange::new(self.shard.row_range_start, self.shard.row_range_end)
+ }
+
+ pub(in crate::table) async fn build(
+ &self,
+ table: &Table,
+ ) -> Result<Option<(CommitMessage, Option<VectorIndexBuildTiming>)>> {
+ // A serialized task grants no authorization. Check the worker's table
+ // before replacing its schema with the captured read view.
+ table.schema().core_options().ensure_read_authorized()?;
+ table.ensure_not_branch_reference_for_write()?;
+ if table.location() != self.context.location
+ || table.identifier().full_name() != self.context.identifier
+ || table.branch() != self.context.branch
+ {
+ return Err(Error::DataInvalid {
+ message: "Global index build shard belongs to a different
table or branch".into(),
+ source: None,
+ });
+ }
+ let snapshot = self
+ .context
+ .snapshot
+ .as_ref()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Global index build shard requires a captured
snapshot".into(),
+ source: None,
+ })?;
+ if snapshot.id() != self.shard.snapshot_id
+ || self.shard.row_range_start < 0
+ || self.shard.row_range_end < self.shard.row_range_start
+ {
+ return Err(Error::DataInvalid {
+ message: "Invalid global index build shard snapshot or row
range".into(),
+ source: None,
+ });
+ }
+ let table = table
+ .copy_with_resolved_schema(self.context.schema.clone(),
&self.context.branch)?
+ .copy_with_pinned_snapshot(Some(snapshot));
+ let mut builder = VindexIndexBuildBuilder::new(&table,
&self.context.index_type);
+ builder
+ .with_index_column(&self.context.column)
+ .with_options(self.context.options.clone());
+ builder.build_planned_shard(&self.shard).await
+ }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 87e89f56..1f95c5af 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -79,6 +79,7 @@ mod full_text_index_build_builder;
mod full_text_search_builder;
mod global_index_build_builder;
pub(crate) mod global_index_build_common;
+mod global_index_build_plan;
mod global_index_drop_builder;
mod global_index_freshness;
mod global_index_result;
@@ -222,6 +223,7 @@ pub use full_text_search_builder::{
};
use futures::stream::BoxStream;
pub use global_index_build_builder::GlobalIndexBuildBuilder;
+pub use global_index_build_plan::{GlobalIndexBuildPlan, GlobalIndexBuildShard};
pub use global_index_drop_builder::GlobalIndexDropBuilder;
pub use global_index_result::{
GlobalIndexResult, GlobalIndexResultReadBuilder, PrimaryKeySearchPosition,
@@ -263,7 +265,7 @@ pub use table_merge_into::{
pub use table_read::{AuditLogRead, TableRead};
pub use table_scan::{AuditLogScan, FileScanPlan, TableScan};
pub use table_update::TableUpdate;
-pub use table_update_by_row_id::TableUpdateByRowId;
+pub use table_update_by_row_id::{RowIdPlan, TableUpdateByRowId};
pub use table_write::TableWrite;
pub use tag_manager::TagManager;
pub use update_assignment::UpdateAssignment;
diff --git a/crates/paimon/src/table/source.rs
b/crates/paimon/src/table/source.rs
index 2becb42f..01b292aa 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -1672,7 +1672,7 @@ impl Default for DataSplitBuilder {
/// Read plan: splits and the snapshot they were planned from.
///
/// Reference:
[org.apache.paimon.table.source.PlanImpl](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/PlanImpl.java)
-#[derive(Debug)]
+#[derive(Debug, Clone)]
pub struct Plan {
splits: Vec<DataSplit>,
snapshot_id: Option<i64>,
@@ -1680,6 +1680,16 @@ pub struct Plan {
}
impl Plan {
+ /// Restore a captured plan from its snapshot and typed splits.
+ ///
+ /// This transports planning metadata; it does not authorize access to the
+ /// referenced files. Readers and mutation workers authorize independently.
+ pub fn from_splits(snapshot_id: Option<i64>, splits: Vec<DataSplit>) ->
Self {
+ let mut plan = Self::new(splits);
+ plan.snapshot_id = snapshot_id;
+ plan
+ }
+
pub fn new(splits: Vec<DataSplit>) -> Self {
let mut plan = Self {
splits,
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 87879367..72e7ec2b 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -289,6 +289,70 @@ impl TableCommit {
self
}
+ /// Check logical row-ID/column conflicts before rebasing staged updates.
+ ///
+ /// Both snapshot IDs are resolved through this table's snapshot manager.
+ /// The captured interval must not cross a schema change, and this
+ /// committer's resolved schema must still be current. A newer schema may
+ /// exist before the next data snapshot is published.
+ /// Like Java's `checkRowIdFromSnapshot(..., false)`, COMPACT snapshots are
+ /// excluded: the caller may rewrite physical layouts only after this check
+ /// has rejected concurrent logical writes. This method publishes nothing
+ /// and never deletes prepared files, including on an uncertain outcome.
+ pub async fn check_row_id_conflicts(
+ &self,
+ messages: &[CommitMessage],
+ latest_snapshot_id: i64,
+ check_from_snapshot: i64,
+ ) -> Result<()> {
+ if check_from_snapshot <= 0 || check_from_snapshot >
latest_snapshot_id {
+ return Err(crate::Error::DataInvalid {
+ message:
+ "Row-ID conflict checks require 0 < check-from snapshot <=
latest snapshot"
+ .into(),
+ source: None,
+ });
+ }
+ if Self::check_from_snapshot(messages)? != Some(check_from_snapshot) {
+ return Err(crate::Error::DataInvalid {
+ message: "Row-ID conflict check baseline must match the commit
messages".into(),
+ source: None,
+ });
+ }
+ super::write_builder::ensure_table_write_authorized(&self.table)?;
+ if !self.data_evolution_enabled {
+ return Err(crate::Error::Unsupported {
+ message: "Row-ID conflict checks require a data-evolution
table".into(),
+ });
+ }
+ reject_compact_increment(messages)?;
+ let latest = self
+ .snapshot_manager
+ .get_snapshot(latest_snapshot_id)
+ .await?;
+ let baseline = if check_from_snapshot == latest_snapshot_id {
+ latest.clone()
+ } else {
+ self.snapshot_manager
+ .get_snapshot(check_from_snapshot)
+ .await?
+ };
+ if baseline.schema_id() != latest.schema_id()
+ || self.latest_schema_id().await? != self.table.schema().id()
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "Row-ID conflict checks require an unchanged, current
table schema".into(),
+ source: None,
+ });
+ }
+ self.check_row_id_from_snapshot(
+ Some(&latest),
+ &self.messages_to_entries(messages),
+ Some(check_from_snapshot),
+ )
+ .await
+ }
+
/// Attach caller properties to snapshots published by this committer.
///
/// Like Java's ManifestCommittable properties, these describe the current
@@ -2756,6 +2820,32 @@ impl TableCommit {
delta_entries: &[ManifestEntry],
next_row_id: Option<i64>,
) -> Result<()> {
+ // File identity deliberately excludes row IDs. Check DELETE metadata
+ // before identifier-based merging can cancel a reassigned current ADD.
+ let deleted = delta_entries
+ .iter()
+ .filter(|entry| *entry.kind() == FileKind::Delete)
+ .map(|entry| (commit_file_identifier(entry), entry))
+ .collect::<HashMap<_, _>>();
+ for current in base_entries
+ .iter()
+ .filter(|entry| *entry.kind() == FileKind::Add)
+ {
+ if let Some(deleted) =
deleted.get(&commit_file_identifier(current)) {
+ if current.file().first_row_id != deleted.file().first_row_id
+ || current.file().row_count != deleted.file().row_count
+ {
+ return Err(crate::Error::RowIdExistenceConflict {
+ file_name: deleted.file().file_name.clone(),
+ first_row_id: deleted.file().first_row_id,
+ row_count: deleted.file().row_count,
+ bucket: deleted.bucket(),
+ current_first_row_id: current.file().first_row_id,
+ current_row_count: Some(current.file().row_count),
+ });
+ }
+ }
+ }
let Some(next_row_id) = next_row_id else {
return Ok(());
};
@@ -2818,15 +2908,13 @@ impl TableCommit {
entry.file().row_count,
);
if !existing_index.contains(&key) {
- return Err(crate::Error::DataInvalid {
- message: format!(
- "Row ID existence conflict: file '{}' references
first_row_id={}, row_count={} in bucket {}, but no matching file exists in the
current snapshot.",
- entry.file().file_name,
- first_row_id,
- entry.file().row_count,
- entry.bucket(),
- ),
- source: None,
+ return Err(crate::Error::RowIdExistenceConflict {
+ file_name: entry.file().file_name.clone(),
+ first_row_id: Some(first_row_id),
+ row_count: entry.file().row_count,
+ bucket: entry.bucket(),
+ current_first_row_id: None,
+ current_row_count: None,
});
}
}
@@ -3711,6 +3799,7 @@ mod tests {
include!("table_commit/property_tests.rs");
include!("table_commit/callback_tests.rs");
include!("table_commit/index_delete_tests.rs");
+ include!("table_commit/row_id_conflict_tests.rs");
}
#[tokio::test]
diff --git a/crates/paimon/src/table/table_commit/row_id_conflict_tests.rs
b/crates/paimon/src/table/table_commit/row_id_conflict_tests.rs
new file mode 100644
index 00000000..99e90a4f
--- /dev/null
+++ b/crates/paimon/src/table/table_commit/row_id_conflict_tests.rs
@@ -0,0 +1,222 @@
+// 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.
+
+#[test]
+fn row_id_delete_must_match_the_current_file_layout() {
+ let commit = setup_data_evolution_commit(&test_file_io(),
"memory:/row_id_delete_layout");
+ let mut file = test_data_file("data-reassigned.parquet", 10);
+ file.first_row_id = Some(10);
+ let current = ManifestEntry::new(FileKind::Add, vec![], 0, -1,
file.clone(), 2);
+ file.first_row_id = Some(0);
+ let stale_delete = ManifestEntry::new(FileKind::Delete, vec![], 0, -1,
file, 2);
+ let error = commit
+ .check_row_id_existence(&[current], &[stale_delete], Some(20))
+ .expect_err("a DELETE must not cancel the current file after row-ID
reassignment");
+ assert!(error.to_string().contains("Row ID existence conflict"));
+ assert!(matches!(
+ error,
+ crate::Error::RowIdExistenceConflict {
+ first_row_id: Some(0),
+ row_count: 10,
+ bucket: 0,
+ current_first_row_id: Some(10),
+ current_row_count: Some(10),
+ ..
+ }
+ ));
+}
+
+#[test]
+fn row_id_existence_preserves_missing_range_details() {
+ let commit = setup_data_evolution_commit(&test_file_io(),
"memory:/missing_row_id_range");
+ let mut file = test_data_file("opaque '(4): file.parquet", 4);
+ file.first_row_id = Some(7);
+ let entry = ManifestEntry::new(FileKind::Add, vec![], 3, -1, file, 2);
+ let error = commit
+ .check_row_id_existence(&[], &[entry], Some(20))
+ .unwrap_err();
+ assert!(matches!(error, crate::Error::RowIdExistenceConflict {
+ ref file_name, first_row_id: Some(7), row_count: 4, bucket: 3,
+ current_first_row_id: None, current_row_count: None
+ } if file_name == "opaque '(4): file.parquet"));
+}
+
+#[test]
+fn row_id_delete_metadata_check_does_not_require_next_row_id() {
+ let commit = setup_data_evolution_commit(&test_file_io(),
"memory:/delete_without_counter");
+ for (current_id, current_count) in [(None, 10), (Some(0), 11)] {
+ let mut file = test_data_file("same.parquet", current_count);
+ file.first_row_id = current_id;
+ let current = ManifestEntry::new(FileKind::Add, vec![], 0, -1, file,
2);
+ let mut old = test_data_file("same.parquet", 10);
+ old.first_row_id = Some(0);
+ let old = ManifestEntry::new(FileKind::Delete, vec![], 0, -1, old, 2);
+ assert!(
+ matches!(commit.check_row_id_existence(&[current], &[old], None),
+ Err(crate::Error::RowIdExistenceConflict { current_first_row_id,
current_row_count: Some(count), .. })
+ if current_first_row_id == current_id && count == current_count)
+ );
+ }
+ let file = test_data_file("ordinary.parquet", 10);
+ let current = ManifestEntry::new(FileKind::Add, vec![], 0, -1,
file.clone(), 2);
+ let delete = ManifestEntry::new(FileKind::Delete, vec![], 0, -1, file, 2);
+ commit
+ .check_row_id_existence(&[current], &[delete], None)
+ .unwrap();
+ commit.check_row_id_existence(&[], &[], None).unwrap();
+}
+
+fn row_id_check_message(name: &str, column: &str) -> CommitMessage {
+ let mut file = test_data_file(name, 10);
+ file.first_row_id = Some(0);
+ file.file_source = Some(0);
+ file.write_cols = Some(vec![column.into()]);
+ let mut message = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![file]);
+ message.check_from_snapshot = Some(1);
+ message
+}
+
+async fn row_id_check_history(path: &str) -> TableCommit {
+ let io = test_file_io();
+ setup_dirs(&io, path).await;
+ let commit = setup_data_evolution_commit(&io, path);
+ let mut file = test_data_file("base.parquet", 10);
+ file.file_source = Some(0);
+ commit
+ .commit_with_identifier(
+ vec![CommitMessage::new(
+ EMPTY_SERIALIZED_ROW.clone(),
+ 0,
+ vec![file],
+ )],
+ 1,
+ )
+ .await
+ .unwrap();
+ commit
+
.commit_with_identifier(vec![row_id_check_message("concurrent.parquet",
"name")], 2)
+ .await
+ .unwrap();
+ commit
+}
+
+#[tokio::test]
+async fn
standalone_row_id_check_rejects_logical_changes_without_deleting_staged_files()
{
+ let commit = row_id_check_history("memory:/standalone_row_id_check").await;
+ let io = commit.table.file_io();
+ let path = "memory:/standalone_row_id_check/bucket-0/staged.parquet";
+ io.new_output(path)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"prepared"))
+ .await
+ .unwrap();
+ let error = commit
+ .check_row_id_conflicts(&[row_id_check_message("staged.parquet",
"name")], 2, 1)
+ .await
+ .unwrap_err();
+ assert!(matches!(error, crate::Error::DataInvalid { .. }));
+ assert!(error.to_string().contains("multiple MERGE INTO"));
+ assert!(
+ io.exists(path).await.unwrap(),
+ "a failed check must retain prepared files"
+ );
+ assert_eq!(
+ commit
+ .snapshot_manager
+ .get_latest_snapshot_id()
+ .await
+ .unwrap(),
+ Some(2)
+ );
+ commit
+ .check_row_id_conflicts(&[row_id_check_message("id-only.parquet",
"id")], 2, 1)
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn standalone_row_id_check_skips_compaction_history() {
+ let commit =
row_id_check_history("memory:/standalone_compact_history").await;
+ // Replace only the fixture's commit kind. The same overlapping delta must
+ // be ignored when it describes physical compaction rather than a DML
write.
+ let snapshot = commit.snapshot_manager.get_snapshot(2).await.unwrap();
+ let mut json = serde_json::to_value(snapshot).unwrap();
+ json["commitKind"] = "COMPACT".into();
+ let path = format!("{}/snapshot-2",
commit.snapshot_manager.snapshot_dir());
+ commit
+ .table
+ .file_io()
+ .new_output(&path)
+ .unwrap()
+ .write(serde_json::to_vec(&json).unwrap().into())
+ .await
+ .unwrap();
+ commit
+ .check_row_id_conflicts(&[row_id_check_message("staged.parquet",
"name")], 2, 1)
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn
standalone_row_id_check_validates_snapshot_baselines_and_current_schema() {
+ let commit =
row_id_check_history("memory:/standalone_check_validation").await;
+ let message = row_id_check_message("staged.parquet", "name");
+ for (latest, baseline) in [(2, 0), (1, 2), (0, 1), (2, -1), (2, 2)] {
+ assert!(matches!(
+ commit
+ .check_row_id_conflicts(std::slice::from_ref(&message),
latest, baseline)
+ .await,
+ Err(crate::Error::DataInvalid { .. })
+ ));
+ }
+ assert!(matches!(
+ commit.check_row_id_conflicts(&[], 2, 1).await,
+ Err(crate::Error::DataInvalid { .. })
+ ));
+ assert!(commit
+ .check_row_id_conflicts(std::slice::from_ref(&message), 3, 1)
+ .await
+ .is_err());
+ save_schema(
+ commit.table.file_io(),
+ "memory:/standalone_check_validation",
+ 1,
+ )
+ .await;
+ let error = commit
+ .check_row_id_conflicts(&[message], 2, 1)
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("current table schema"));
+}
+
+#[tokio::test]
+async fn
standalone_row_id_check_fails_closed_for_missing_baseline_and_assembled_authorization()
{
+ let commit =
row_id_check_history("memory:/standalone_missing_baseline").await;
+ commit.snapshot_manager.delete_snapshot(1).await.unwrap();
+ assert!(commit
+ .check_row_id_conflicts(&[row_id_check_message("staged.parquet",
"name")], 2, 1)
+ .await
+ .is_err());
+ let guarded = TableCommit::new(crate::table::query_auth_table(),
"guarded".into());
+ let error = guarded
+ .check_row_id_conflicts(&[row_id_check_message("staged.parquet",
"name")], 2, 1)
+ .await
+ .unwrap_err();
+ assert!(matches!(error, crate::Error::Unsupported { .. }));
+ assert!(error.to_string().contains("query-auth.enabled"));
+}
diff --git a/crates/paimon/src/table/table_update.rs
b/crates/paimon/src/table/table_update.rs
index 1eac4063..d9257a48 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -56,6 +56,7 @@ pub struct TableUpdate {
table: Table,
commit_user: String,
update_cols: Option<Vec<String>>,
+ plan: Option<super::Plan>,
blob_uri_reader_factory: Option<std::sync::Arc<dyn
crate::io::UriReaderFactory>>,
}
@@ -65,10 +66,39 @@ impl TableUpdate {
table: table.clone(),
commit_user,
update_cols: None,
+ plan: None,
blob_uri_reader_factory: None,
}
}
+ /// Reuse a captured `plan_all` result for row-ID updates and deletes.
+ ///
+ /// Each split must contain complete logical file groups, including their
+ /// partial-column files and deletion vectors. Splits may be distributed
+ /// among workers; the supplied row IDs must belong to that worker's
groups.
+ /// Arbitrarily trimmed or incremental splits are not mutation plans.
+ /// Workers authorize the whole table again and retain the captured
snapshot
+ /// as the conflict baseline without replanning its data manifests. Deletes
+ /// still read the captured index manifest to preserve all deletion vectors
+ /// when replacing a bucket's shared index file. Assign all deletes for one
+ /// partition/bucket to the same worker.
+ /// Predicate, key-based, MERGE and shard operations reject this setting;
+ /// use a separately configured updater for those operations.
+ pub fn with_plan(&mut self, plan: super::Plan) -> crate::Result<&mut Self>
{
+ super::RowIdPlan::from_plan(plan.clone())?;
+ self.plan = Some(plan);
+ Ok(self)
+ }
+
+ fn ensure_unplanned(&self) -> crate::Result<()> {
+ if self.plan.is_some() {
+ return Err(crate::Error::Unsupported {
+ message: "with_plan supports only row-ID update and delete
operations".into(),
+ });
+ }
+ Ok(())
+ }
+
/// Configure readers for Blob input references. The same factory supplies
/// matched updates and appended rows in upsert and MERGE operations.
pub fn with_blob_uri_reader_factory(
@@ -120,6 +150,7 @@ impl TableUpdate {
shard_count: usize,
read_type: Option<Vec<DataField>>,
) -> crate::Result<super::ShardTableUpdater> {
+ self.ensure_unplanned()?;
let mut updater = super::ShardTableUpdater::new(
&self.table,
read_type,
@@ -170,7 +201,10 @@ impl TableUpdate {
/// Create a row-ID updater sharing one snapshot across per-call columns.
pub async fn new_update_by_row_id(&self) ->
crate::Result<TableUpdateByRowId> {
- let mut writer = TableUpdateByRowId::new(&self.table).await?;
+ let mut writer = match &self.plan {
+ Some(plan) => TableUpdateByRowId::with_plan(&self.table,
plan).await?,
+ None => TableUpdateByRowId::new(&self.table).await?,
+ };
writer.with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone());
Ok(writer)
}
@@ -233,6 +267,7 @@ impl TableUpdate {
assignments: Vec<(String, super::UpdateAssignment)>,
read_columns: Vec<String>,
) -> crate::Result<Vec<CommitMessage>> {
+ self.ensure_unplanned()?;
super::table_update_predicate::update(
&self.table,
predicate,
@@ -261,6 +296,7 @@ impl TableUpdate {
batches: Vec<RecordBatch>,
upsert_keys: Vec<String>,
) -> crate::Result<Vec<CommitMessage>> {
+ self.ensure_unplanned()?;
let mut writer = super::table_upsert::TableUpsert::new(
&self.table,
self.commit_user.clone(),
@@ -286,6 +322,7 @@ impl TableUpdate {
when_matched: Vec<super::WhenMatched>,
when_not_matched: Vec<super::WhenNotMatched>,
) -> crate::Result<Vec<CommitMessage>> {
+ self.ensure_unplanned()?;
super::table_merge_into::merge_into(
&self.table,
&self.commit_user,
@@ -311,6 +348,7 @@ impl TableUpdate {
&self,
predicate: Option<crate::spec::Predicate>,
) -> crate::Result<Vec<CommitMessage>> {
+ self.ensure_unplanned()?;
super::table_update_delete::delete(&self.table, predicate).await
}
@@ -323,10 +361,17 @@ impl TableUpdate {
// ordinary write builder continues rejecting historical handles.
let mut writer = super::DataEvolutionDeleteWriter::new(&self.table)?;
writer.add_row_ids(row_ids)?;
- writer.prepare_commit().await
+ match &self.plan {
+ Some(plan) => writer.prepare_commit_with_plan(plan).await,
+ None => writer.prepare_commit().await,
+ }
}
}
fn same_columns(left: &[String], right: &[String]) -> bool {
left.len() == right.len() && left.iter().all(|column|
right.contains(column))
}
+
+#[cfg(test)]
+#[path = "table_update_plan_tests.rs"]
+mod plan_tests;
diff --git a/crates/paimon/src/table/table_update_by_row_id.rs
b/crates/paimon/src/table/table_update_by_row_id.rs
index afec5f00..acf5eba5 100644
--- a/crates/paimon/src/table/table_update_by_row_id.rs
+++ b/crates/paimon/src/table/table_update_by_row_id.rs
@@ -17,8 +17,8 @@
//! Row-ID updates sharing one target snapshot and file index.
-use super::data_evolution_writer::RowIdFileIndex;
-use crate::table::{CommitMessage, DataEvolutionWriter, Table};
+use super::data_evolution_writer::{group_by_normal_row_id, RowIdFileIndex};
+use crate::table::{CommitMessage, DataEvolutionWriter, DataSplit, Plan, Table};
use arrow_array::RecordBatch;
use std::collections::{HashMap, HashSet};
@@ -29,6 +29,99 @@ fn invalid(message: impl Into<String>) -> crate::Error {
}
}
+/// Routing metadata for complete row-tracked file groups in a captured plan.
+///
+/// Constructing this object performs no I/O and grants no file access. The
+/// reader or mutation executing the retained plan must authorize
independently.
+#[derive(Debug, Clone)]
+pub struct RowIdPlan {
+ plan: Plan,
+ groups: Vec<(i64, i64, DataSplit)>,
+}
+
+impl RowIdPlan {
+ pub fn from_plan(plan: Plan) -> crate::Result<Self> {
+ if plan.snapshot_id().is_none() && !plan.splits().is_empty() {
+ return Err(invalid("A row-ID plan requires its captured snapshot
ID"));
+ }
+ let mut groups = Vec::new();
+ for split in plan.splits() {
+ if Some(split.snapshot_id()) != plan.snapshot_id()
+ || split.is_streaming()
+ || split.row_ranges().is_some()
+ || split.scores().is_some()
+ {
+ return Err(invalid(
+ "A row-ID plan requires full snapshot file groups without
row selection",
+ ));
+ }
+ if split.data_files().is_empty() {
+ return Err(invalid("A row-ID split must contain data files"));
+ }
+ if split
+ .data_deletion_files()
+ .is_some_and(|files| files.len() != split.data_files().len())
+ {
+ return Err(invalid(
+ "Row-ID deletion metadata must match the captured data
files",
+ ));
+ }
+ let mut names = HashSet::new();
+ for file in split.data_files() {
+ if file.row_id_range().is_none() ||
!names.insert(&file.file_name) {
+ return Err(invalid(
+ "A row-ID split contains invalid or duplicate file
metadata",
+ ));
+ }
+ }
+ let file_groups =
group_by_normal_row_id(split.data_files().to_vec());
+ if file_groups.is_empty() {
+ return Err(invalid("A row-ID split requires a normal anchor
file"));
+ }
+ let mut grouped = HashSet::new();
+ for files in file_groups {
+ let anchor =
super::source::data_evolution_anchor_file(&files)?;
+ let (first, last) = anchor
+ .row_id_range()
+ .ok_or_else(|| invalid("Invalid row-ID anchor range"))?;
+ for file in &files {
+ grouped.insert(file.file_name.clone());
+ if super::source::is_data_evolution_normal_file(file)
+ && file.row_id_range() != Some((first, last))
+ {
+ return Err(invalid(
+ "Overlapping row-ID anchors must have identical
ranges",
+ ));
+ }
+ }
+ groups.push((first, last,
split.for_data_evolution_files(files)));
+ }
+ if grouped.len() != names.len() {
+ return Err(invalid(
+ "A row-ID split contains a dedicated file without its
normal anchor",
+ ));
+ }
+ }
+ groups.sort_by_key(|(first, _, _)| *first);
+ if groups.windows(2).any(|pair| pair[0].1 >= pair[1].0) {
+ return Err(invalid("A row-ID plan contains overlapping file
groups"));
+ }
+ Ok(Self { plan, groups })
+ }
+
+ pub fn snapshot_id(&self) -> Option<i64> {
+ self.plan.snapshot_id()
+ }
+
+ pub fn groups(&self) -> &[(i64, i64, DataSplit)] {
+ &self.groups
+ }
+
+ pub fn plan(&self) -> &Plan {
+ &self.plan
+ }
+}
+
/// Update columns selected per input table, as in PyPaimon.
///
/// Each call to `update_columns` stages its files immediately and returns all
@@ -47,6 +140,10 @@ impl TableUpdateByRowId {
Self::with_index(table, RowIdFileIndex::load(table, None).await?)
}
+ pub(super) async fn with_plan(table: &Table, plan: &Plan) ->
crate::Result<Self> {
+ Self::with_index(table, RowIdFileIndex::load_plan(table, plan).await?)
+ }
+
pub(super) fn with_index(table: &Table, index: RowIdFileIndex) ->
crate::Result<Self> {
// Validate table layout without fixing any update columns.
let _ = DataEvolutionWriter::new(table, Vec::new())?;
diff --git a/crates/paimon/src/table/table_update_plan_tests.rs
b/crates/paimon/src/table/table_update_plan_tests.rs
new file mode 100644
index 00000000..b4fb6ddd
--- /dev/null
+++ b/crates/paimon/src/table/table_update_plan_tests.rs
@@ -0,0 +1,418 @@
+// 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, IntType, Schema, TableSchema};
+use crate::table::{DataSplitBuilder, Plan, RowRange};
+use arrow_array::{ArrayRef, Int32Array, Int64Array};
+use std::sync::Arc;
+
+async fn table(name: &str) -> Table {
+ let schema = Schema::builder()
+ .column("value", DataType::Int(IntType::new()))
+ .option("bucket", "-1")
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .option("deletion-vectors.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", name),
+ format!("memory:/{name}"),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ for value in [10, 20] {
+ append(&table, value).await;
+ }
+ table
+}
+
+async fn append(table: &Table, value: i32) {
+ let batch = RecordBatch::try_from_iter(vec![(
+ "value",
+ Arc::new(Int32Array::from(vec![value])) as ArrayRef,
+ )])
+ .unwrap();
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+}
+
+fn update_batch(row_id: i64) -> RecordBatch {
+ RecordBatch::try_from_iter(vec![
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![row_id])) as ArrayRef,
+ ),
+ ("value", Arc::new(Int32Array::from(vec![99])) as ArrayRef),
+ ])
+ .unwrap()
+}
+
+#[tokio::test]
+async fn
planned_updates_and_deletes_keep_the_captured_baseline_without_manifest_rescan()
{
+ let table = table("planned_mutation_baseline").await;
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ assert_eq!(plan.snapshot_id(), Some(2));
+ append(&table, 30).await;
+ table
+ .file_io()
+ .delete_dir(&format!("{}/manifest", table.location()))
+ .await
+ .unwrap();
+
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_plan(plan).unwrap();
+ let messages = update
+ .update_by_arrow_with_row_id(vec![update_batch(0)])
+ .await
+ .unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].check_from_snapshot, Some(2));
+ let deletes = update.delete_by_row_id(vec![1]).await.unwrap();
+ assert_eq!(deletes.len(), 1);
+ assert_eq!(deletes[0].check_from_snapshot, Some(2));
+ let error = update
+ .update_by_arrow_with_row_id(vec![update_batch(2)])
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("No file found for _ROW_ID 2"));
+}
+
+#[tokio::test]
+async fn
planned_mutations_reject_foreign_paths_and_selected_splits_before_writing() {
+ let table = table("planned_mutation_validation").await;
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ let split = &plan.splits()[0];
+ for (path, snapshot_id, ranges, streaming) in [
+ ("memory:/another/bucket-0", 2, None, false),
+ (split.bucket_path(), 1, None, false),
+ (
+ split.bucket_path(),
+ 2,
+ Some(vec![RowRange::new(0, 0)]),
+ false,
+ ),
+ (split.bucket_path(), 2, None, true),
+ ] {
+ let mut builder = DataSplitBuilder::new()
+ .with_snapshot(snapshot_id)
+ .with_partition(split.partition().clone())
+ .with_bucket(split.bucket())
+ .with_bucket_path(path.to_string())
+ .with_data_files(split.data_files().to_vec())
+ .with_streaming(streaming);
+ if let Some(ranges) = ranges {
+ builder = builder.with_row_ranges(ranges);
+ }
+ let split = builder.build().unwrap();
+ let mut update = table.new_write_builder().new_update().unwrap();
+ if update
+ .with_plan(Plan::from_splits(Some(2), vec![split]))
+ .is_err()
+ {
+ continue;
+ }
+ assert!(update.new_update_by_row_id().await.is_err());
+ assert!(update.delete_by_row_id(vec![0]).await.is_err());
+ }
+}
+
+#[tokio::test]
+async fn planned_update_rejects_captured_deleted_rows_but_allows_live_rows() {
+ let table = table("planned_update_deletion_vector").await;
+ let update = table.new_write_builder().new_update().unwrap();
+ let deletes = update.delete_by_row_id(vec![0]).await.unwrap();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(deletes)
+ .await
+ .unwrap();
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ assert!(plan
+ .splits()
+ .iter()
+ .any(|split| split.data_deletion_files().is_some()));
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_plan(plan).unwrap();
+ let mut writer = update.new_update_by_row_id().await.unwrap();
+ let error = writer
+ .update_columns(vec![update_batch(0)], vec!["value".into()])
+ .await
+ .unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("Cannot update deleted _ROW_ID 0"));
+ assert!(writer.commit_messages().is_empty());
+ let messages = writer
+ .update_columns(vec![update_batch(1)], vec!["value".into()])
+ .await
+ .unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].check_from_snapshot, Some(3));
+}
+
+#[tokio::test]
+async fn planned_delete_preserves_unselected_bucket_deletion_vectors() {
+ use futures::TryStreamExt;
+
+ let table = table("planned_delete_bucket_deletion_vectors").await;
+ let deletes = table
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .delete_by_row_id(vec![0])
+ .await
+ .unwrap();
+ let previous_index = deletes[0].new_index_files[0].clone();
+ let previous_anchor = previous_index
+ .deletion_vectors_ranges
+ .as_ref()
+ .unwrap()
+ .keys()
+ .next()
+ .unwrap()
+ .clone();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(deletes)
+ .await
+ .unwrap();
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ let routing = crate::table::RowIdPlan::from_plan(plan.clone()).unwrap();
+ // Route only the second anchor. The first anchor's existing deletion
+ // vector must survive replacement of the bucket's shared index file.
+ let selected = routing
+ .groups()
+ .iter()
+ .find(|(first, _, _)| *first == 1)
+ .unwrap()
+ .2
+ .clone();
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update
+ .with_plan(Plan::from_splits(plan.snapshot_id(), vec![selected]))
+ .unwrap();
+ let messages = update.delete_by_row_id(vec![1]).await.unwrap();
+ assert_eq!(messages[0].check_from_snapshot, Some(3));
+ assert_eq!(messages[0].deleted_index_files, vec![previous_index]);
+ let ranges = messages[0].new_index_files[0]
+ .deletion_vectors_ranges
+ .as_ref()
+ .unwrap();
+ assert_eq!(ranges.len(), 2);
+ assert_eq!(ranges[&previous_anchor].cardinality, Some(1));
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
+}
+
+#[tokio::test]
+async fn row_id_plan_routes_complete_groups_and_rejects_ambiguous_ranges() {
+ let table = table("planned_row_id_routing").await;
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ let routing = crate::table::RowIdPlan::from_plan(plan.clone()).unwrap();
+ assert_eq!(routing.snapshot_id(), Some(2));
+ assert_eq!(
+ routing
+ .groups()
+ .iter()
+ .map(|(first, last, _)| (*first, *last))
+ .collect::<Vec<_>>(),
+ vec![(0, 0), (1, 1)]
+ );
+ let selected = routing.groups()[0].2.clone();
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update
+ .with_plan(Plan::from_splits(Some(2), vec![selected.clone()]))
+ .unwrap();
+ assert!(update
+ .delete_by_row_id(vec![1])
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("No file found for _ROW_ID 1"));
+ assert!(crate::table::RowIdPlan::from_plan(Plan::from_splits(
+ Some(2),
+ vec![selected.clone(), selected]
+ ))
+ .unwrap_err()
+ .to_string()
+ .contains("overlapping file groups"));
+ assert_eq!(routing.plan().splits(), plan.splits());
+}
+
+#[tokio::test]
+async fn row_id_plan_keeps_full_partial_columns_and_dedicated_subranges() {
+ let table = table("planned_row_id_partial_ranges").await;
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ let split = &plan.splits()[0];
+ let mut base = split.data_files()[0].clone();
+ base.first_row_id = Some(0);
+ base.row_count = 6;
+ base.max_sequence_number = 0;
+ let mut partial = base.clone();
+ partial.file_name = "partial-column.parquet".into();
+ partial.max_sequence_number = 1;
+ partial.write_cols = Some(vec!["value".into()]);
+ let mut left = partial.clone();
+ left.file_name = "left.blob".into();
+ left.row_count = 2;
+ left.max_sequence_number = 1;
+ left.write_cols = Some(vec!["value".into()]);
+ let mut right = left.clone();
+ right.file_name = "right.vector.store".into();
+ right.first_row_id = Some(2);
+ right.row_count = 4;
+ let files = vec![right, base.clone(), left.clone(), partial];
+ let split = split.for_data_evolution_files(files.clone());
+ let routing =
+ crate::table::RowIdPlan::from_plan(Plan::from_splits(Some(2),
vec![split.clone()]))
+ .unwrap();
+ assert_eq!(routing.groups().len(), 1);
+ assert_eq!((routing.groups()[0].0, routing.groups()[0].1), (0, 5));
+ assert_eq!(routing.groups()[0].2.data_files().len(), 4);
+ assert!(files
+ .iter()
+ .all(|file| routing.groups()[0].2.data_files().contains(file)));
+ left.file_name = "invalid-short-column.parquet".into();
+ let invalid = split.for_data_evolution_files(vec![base, left]);
+ assert!(
+ crate::table::RowIdPlan::from_plan(Plan::from_splits(Some(2),
vec![invalid]))
+ .unwrap_err()
+ .to_string()
+ .contains("identical ranges")
+ );
+}
+
+#[tokio::test]
+async fn
captured_plan_rejects_other_operation_families_before_io_or_callbacks() {
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ let table = table("planned_operation_family").await;
+ let plan = table
+ .new_read_builder()
+ .new_scan()
+ .plan_all()
+ .await
+ .unwrap();
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_plan(plan).unwrap();
+ for directory in ["manifest", "snapshot"] {
+ table
+ .file_io()
+ .delete_dir(&format!("{}/{directory}", table.location()))
+ .await
+ .unwrap();
+ }
+ let calls = Arc::new(AtomicUsize::new(0));
+ let count = calls.clone();
+ let callback = crate::table::UpdateAssignment::Function(Arc::new(move |_| {
+ count.fetch_add(1, Ordering::SeqCst);
+ Ok(Vec::new())
+ }));
+ let errors = vec![
+ update
+ .new_shard_updater(0, 1, None)
+ .await
+ .err()
+ .unwrap()
+ .to_string(),
+ update
+ .update_by_predicate(None, vec![("value".into(), callback)],
vec![])
+ .await
+ .unwrap_err()
+ .to_string(),
+ update
+ .upsert_by_arrow_with_key(vec![], vec!["value".into()])
+ .await
+ .unwrap_err()
+ .to_string(),
+ update
+ .merge_into(crate::table::MergeSource::SelfTable, vec![], vec![],
vec![])
+ .await
+ .unwrap_err()
+ .to_string(),
+ update
+ .delete_by_predicate(None)
+ .await
+ .unwrap_err()
+ .to_string(),
+ ];
+ assert!(
+ errors
+ .iter()
+ .all(|error| error.contains("with_plan supports only row-ID")),
+ "{errors:?}"
+ );
+ assert_eq!(calls.load(Ordering::SeqCst), 0);
+}
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs
b/crates/paimon/src/table/vindex_index_build_builder.rs
index bfc1ecf6..b85a2539 100644
--- a/crates/paimon/src/table/vindex_index_build_builder.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -26,6 +26,8 @@ use planning::plan_vindex_shards;
use validation::{checked_i32, find_index_field, validate_vector_field};
use crate::spec::{CoreOptions, Predicate};
+use crate::table::global_index_build_common::vector::VectorIndexShard;
+use crate::table::global_index_build_plan::GlobalIndexBuildPlan;
use crate::table::{CommitMessage, RowRange, Table};
use crate::vindex::{is_vindex_index_type, VindexVectorIndexOptions};
use crate::{Error, Result};
@@ -60,13 +62,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
self
}
- pub(super) async fn prepare(
- &self,
- ) -> Result<(
- Option<i64>,
- Vec<CommitMessage>,
- Vec<timing::VectorIndexBuildTiming>,
- )> {
+ pub(super) async fn plan(&self) -> Result<GlobalIndexBuildPlan> {
// Building the index scans the table's rows.
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
@@ -92,29 +88,30 @@ impl<'a> VindexIndexBuildBuilder<'a> {
let core_options = CoreOptions::new(&merged_options);
super::global_index_build_common::validate_build_table(self.table)?;
let rows_per_shard = core_options.global_index_row_count_per_shard()?;
- let parallelism = core_options.global_index_build_parallelism()?;
+ core_options.global_index_build_parallelism()?;
let index_field = find_index_field(self.table, index_column)?;
validate_vector_field(index_field)?;
- let vindex_options = VindexVectorIndexOptions::new(
+ let options = VindexVectorIndexOptions::new(
self.table.schema().options(),
&self.options,
&self.index_type,
index_field,
)?;
- let dimension = checked_i32(
- vindex_options.dimension() as u64,
+ checked_i32(
+ options.dimension() as u64,
"vindex dimension is too large for Rust builder",
)?;
- let index_meta =
- serde_json::to_vec(&vindex_options.native_options).map_err(|e|
Error::DataInvalid {
- message: format!("Failed to serialize vindex options metadata:
{e}"),
- source: Some(Box::new(e)),
- })?;
-
let snapshot_manager = self.table.snapshot_manager();
let Some(snapshot) = snapshot_manager.get_latest_snapshot().await?
else {
- return Ok((None, vec![], vec![]));
+ return Ok(GlobalIndexBuildPlan::new(
+ self.table,
+ None,
+ index_column.into(),
+ self.index_type.clone(),
+ self.options.clone(),
+ vec![],
+ ));
};
let mut read_builder = self.table.new_read_builder();
@@ -144,48 +141,93 @@ impl<'a> VindexIndexBuildBuilder<'a> {
rows_per_shard,
&indexed,
)?;
- if shards.is_empty() {
- return Ok((Some(snapshot.id()), vec![], vec![]));
+
+ if !shards.is_empty() {
+
crate::table::global_index_build_common::validate_existing_index_overlap(
+ self.table,
+ snapshot.index_manifest(),
+ &self.index_type,
+ index_field.id(),
+ None,
+ &shards
+ .iter()
+ .map(|shard| RowRange::new(shard.row_range_start,
shard.row_range_end))
+ .collect::<Vec<_>>(),
+ )
+ .await?;
}
-
crate::table::global_index_build_common::validate_existing_index_overlap(
+ Ok(GlobalIndexBuildPlan::new(
self.table,
- snapshot.index_manifest(),
+ Some(snapshot),
+ index_column.into(),
+ self.index_type.clone(),
+ self.options.clone(),
+ shards,
+ ))
+ }
+
+ pub(super) async fn build_planned_shard(
+ &self,
+ shard: &VectorIndexShard,
+ ) -> Result<Option<(CommitMessage,
Option<timing::VectorIndexBuildTiming>)>> {
+ let index_column = self
+ .index_column
+ .as_deref()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "vindex index column is required".into(),
+ source: None,
+ })?;
+ let index_field = find_index_field(self.table, index_column)?;
+ validate_vector_field(index_field)?;
+ let options = VindexVectorIndexOptions::new(
+ self.table.schema().options(),
+ &self.options,
&self.index_type,
- index_field.id(),
- None,
- &shards
- .iter()
- .map(|shard| RowRange::new(shard.row_range_start,
shard.row_range_end))
- .collect::<Vec<_>>(),
- )
- .await?;
+ index_field,
+ )?;
+ let dimension = checked_i32(
+ options.dimension() as u64,
+ "vindex dimension is too large for Rust builder",
+ )?;
+ let index_meta =
+ serde_json::to_vec(&options.native_options).map_err(|error|
Error::DataInvalid {
+ message: format!("Failed to serialize vindex options metadata:
{error}"),
+ source: Some(Box::new(error)),
+ })?;
+ let built = self
+ .build_index_file(
+ shard,
+ index_column,
+ dimension,
+ index_field.id(),
+ &options,
+ index_meta,
+ )
+ .await?;
+ Ok(built.map(|built| {
+ let mut message =
CommitMessage::new(shard.partition_bytes.clone(), 0, vec![]);
+ message.new_index_files = vec![built.meta];
+ (message, built.timing)
+ }))
+ }
+ pub(super) async fn prepare(
+ &self,
+ ) -> Result<(
+ Option<i64>,
+ Vec<CommitMessage>,
+ Vec<timing::VectorIndexBuildTiming>,
+ )> {
+ let plan = self.plan().await?;
+ let mut options = self.table.schema().options().clone();
+ options.extend(self.options.clone());
+ let parallelism =
CoreOptions::new(&options).global_index_build_parallelism()?;
let prepared =
super::global_index_build_common::preparation::prepare_shards(
self.table,
- shards,
+ plan.shards(),
parallelism,
- |shard| {
- let vindex_options = &vindex_options;
- let index_meta = index_meta.clone();
- async move {
- let built = self
- .build_index_file(
- &shard,
- index_column,
- dimension,
- index_field.id(),
- vindex_options,
- index_meta,
- )
- .await?;
- Ok(built.map(|built| {
- let mut message =
CommitMessage::new(shard.partition_bytes, 0, vec![]);
- message.new_index_files = vec![built.meta];
- (message, built.timing)
- }))
- }
- },
+ |shard| async move { shard.build(self.table).await },
)
.await?;
let mut messages = Vec::with_capacity(prepared.len());
@@ -196,8 +238,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
timings.push(timing);
}
}
-
- Ok((Some(snapshot.id()), messages, timings))
+ Ok((plan.snapshot_id(), messages, timings))
}
}
diff --git a/crates/paimon/src/table/vindex_index_build_builder/tests.rs
b/crates/paimon/src/table/vindex_index_build_builder/tests.rs
index 617a8dbc..cba9244d 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/tests.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/tests.rs
@@ -481,6 +481,157 @@ async fn write_vectors(table: &Table, ids: Vec<i32>,
vectors: Vec<Vec<f32>>) {
.unwrap();
}
+#[tokio::test]
+async fn planned_global_index_validates_dimension_before_empty_snapshot() {
+ let path = "memory:/planned_global_index_empty_dimension";
+ let table = vindex_e2e_table(path, "2");
+ setup_dirs(table.file_io(), path).await;
+ let mut builder = table.new_global_index_build_builder();
+ builder
+ .with_index_type(IVF_FLAT_IDENTIFIER)
+ .with_index_column("embedding")
+ .with_options(HashMap::from([(
+ "ivf-flat.dimension".into(),
+ (i32::MAX as u64 + 1).to_string(),
+ )]));
+ for error in [
+ builder.plan().await.unwrap_err(),
+ builder.build().await.unwrap_err(),
+ ] {
+ assert!(error.to_string().contains("dimension is too large"));
+ }
+}
+
+#[tokio::test]
+async fn
planned_global_index_shards_keep_snapshot_schema_and_physical_coverage() {
+ let path = "memory:/planned_global_index_shards";
+ let mut options = vindex_e2e_options("2");
+ options.insert("ivf-flat.nlist".into(), "1".into());
+ options.insert("deletion-vectors.enabled".into(), "true".into());
+ let table = test_table_with_io(
+ FileIOBuilder::new("memory").build().unwrap(),
+ path,
+ vindex_schema_builder(options).build().unwrap(),
+ );
+ setup_dirs(table.file_io(), path).await;
+ write_vectors(&table, vec![1, 2, 3, 4], vec![vec![1., 0.]; 4]).await;
+ let write = table.new_write_builder();
+ write
+ .new_commit()
+ .commit(
+ write
+ .new_update()
+ .unwrap()
+ .delete_by_row_id(vec![1])
+ .await
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let mut builder = table.new_global_index_build_builder();
+ builder
+ .with_index_type(IVF_FLAT_IDENTIFIER)
+ .with_index_column("embedding");
+ let plan = builder.plan().await.unwrap();
+ let snapshot_id = plan.snapshot_id().unwrap();
+ let plan: crate::table::GlobalIndexBuildPlan =
+ serde_json::from_slice(&serde_json::to_vec(&plan).unwrap()).unwrap();
+ let shards = plan.shards();
+ assert_eq!(
+ shards.iter().map(|s| s.row_range()).collect::<Vec<_>>(),
+ vec![RowRange::new(0, 1), RowRange::new(2, 3)]
+ );
+ assert!(shards
+ .iter()
+ .all(|shard| shard.snapshot_id() == snapshot_id));
+ write_vectors(&table, vec![5, 6], vec![vec![0., 1.]; 2]).await;
+ // Changing the builder after planning cannot change a worker's source or
field.
+ builder
+ .with_index_column("id")
+ .with_options(HashMap::from([("ivf-flat.dimension".into(),
"999".into())]));
+ let messages = builder.build_shard(&shards[0]).await.unwrap();
+ assert_eq!(messages.len(), 1);
+ let file = &messages[0].new_index_files[0];
+ assert_eq!(
+ file.row_count, 2,
+ "deleted vectors remain in physical build coverage"
+ );
+ let meta = file.global_index_meta.as_ref().unwrap();
+ assert_eq!(
+ (
+ meta.row_range_start,
+ meta.row_range_end,
+ meta.index_field_id
+ ),
+ (0, 1, 1)
+ );
+ assert_eq!(
+ latest_vindex_index_files(&table).await.len(),
+ 0,
+ "workers do not commit"
+ );
+ assert_eq!(
+ table
+ .file_io()
+ .list_status(&format!("{path}/index/"))
+ .await
+ .unwrap()
+ .iter()
+ .filter(|status|
status.path.contains("vector-ivf-flat-global-index-"))
+ .count(),
+ 1,
+ "one task must not build another shard or later append"
+ );
+ write.new_commit().commit(messages).await.unwrap();
+ let plan = table
+ .new_global_index_build_builder()
+ .with_index_type(IVF_FLAT_IDENTIFIER)
+ .with_index_column("embedding")
+ .plan()
+ .await
+ .unwrap();
+ assert_eq!(
+ plan.shards()
+ .iter()
+ .map(|s| s.row_range())
+ .collect::<Vec<_>>(),
+ vec![RowRange::new(2, 3), RowRange::new(4, 5)]
+ );
+}
+
+#[tokio::test]
+async fn
planned_global_index_shards_recheck_worker_authorization_and_table_identity() {
+ let path = "memory:/planned_global_index_authorization";
+ let table = vindex_e2e_table(path, "2");
+ setup_dirs(table.file_io(), path).await;
+ write_vectors(&table, vec![1, 2], vec![vec![1., 0.]; 2]).await;
+ let plan = table
+ .new_global_index_build_builder()
+ .with_index_type(IVF_FLAT_IDENTIFIER)
+ .with_index_column("embedding")
+ .plan()
+ .await
+ .unwrap();
+ let shard = &plan.shards()[0];
+ let denied = table.copy_with_options(HashMap::from([(
+ "query-auth.enabled".into(),
+ "true".into(),
+ )]));
+ let error = denied
+ .new_global_index_build_builder()
+ .build_shard(shard)
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("query-auth"));
+ let other = vindex_e2e_table("memory:/different_table", "2");
+ let error = other
+ .new_global_index_build_builder()
+ .build_shard(shard)
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("different table"));
+}
+
#[tokio::test]
async fn vector_build_with_deletion_vectors_preserves_physical_coverage() {
for granule in [false, true] {