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 77b1d49a [core] Preserve append writer configuration per bucket across 
prepares (#1188)
77b1d49a is described below

commit 77b1d49a7c414dac0c79af5eb24da3732777d122
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 22 13:16:14 2026 +0800

    [core] Preserve append writer configuration per bucket across prepares 
(#1188)
---
 bindings/python/src/write.rs                       | 339 +++++++++++++++++++-
 bindings/python/tests/test_blob_consumer.py        |  39 ++-
 bindings/python/tests/test_write.py                |  62 +++-
 crates/paimon/src/table/blob_consumer_tests.rs     |   4 +-
 crates/paimon/src/table/internal.rs                |  18 +-
 crates/paimon/src/table/table_write.rs             | 202 ++++++++++--
 crates/paimon/src/table/table_write_input.rs       |  60 +++-
 .../src/table/table_write_lifecycle_tests.rs       | 354 +++++++++++++++++++++
 .../paimon/tests/filtered_write_validation_test.rs |   7 +-
 crates/paimon/tests/table_partial_write_test.rs    | 264 +++++++++++++++
 10 files changed, 1273 insertions(+), 76 deletions(-)

diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index e0dce175..1ac21b05 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -88,6 +88,7 @@ impl WriteContext {
             ))),
             table_location: self.table.location().to_string(),
             commit_user: self.commit_user.clone(),
+            blob_consumers: Vec::new(),
         })
     }
 }
@@ -366,9 +367,15 @@ impl WriteTarget {
         }
     }
 
-    async fn prepare_commit(&mut self) -> paimon::Result<Vec<CommitMessage>> {
+    async fn prepare_commit(
+        &mut self,
+        commit_identifier: i64,
+    ) -> paimon::Result<Vec<CommitMessage>> {
         match self {
-            Self::Table(writer) => writer.prepare_commit().await,
+            Self::Table(writer) => {
+                
paimon::table::internal::prepare_commit_with_identifier(writer, 
commit_identifier)
+                    .await
+            }
             Self::PostponeFixed(writer) => writer.prepare_commit().await,
         }
     }
@@ -464,6 +471,7 @@ impl PyPostponeFixedBucketWriteBuilder {
                 ))),
                 table_location: context.table.location().to_string(),
                 commit_user: context.commit_user.clone(),
+                blob_consumers: Vec::new(),
             },
             prepared: false,
         })
@@ -478,12 +486,15 @@ struct WriteState {
     inner: Option<WriteTarget>,
     table_location: String,
     commit_user: String,
+    // Core keeps prior consumers in captured partition/bucket settings. Expose
+    // every owned Python reference to GC, including replaced consumers.
+    blob_consumers: Vec<Arc<PythonBlobConsumer>>,
 }
 
 /// Descriptor serialization and exception transport only; record boundaries,
 /// callback timing and requested flushes belong to Rust core.
 struct PythonBlobConsumer {
-    callback: Py<PyAny>,
+    callback: Mutex<Option<Py<PyAny>>>,
 }
 
 impl BlobConsumer for PythonBlobConsumer {
@@ -496,10 +507,14 @@ impl BlobConsumer for PythonBlobConsumer {
             Python::attach(|py| {
                 let encoded =
                     descriptor.map(|descriptor| PyBytes::new(py, 
&descriptor.serialize()));
-                self.callback
-                    .bind(py)
-                    .call1((field_name, encoded))?
-                    .is_truthy()
+                let callback = self
+                    .callback
+                    .lock()
+                    .unwrap()
+                    .as_ref()
+                    .map(|callback| callback.clone_ref(py))
+                    .ok_or_else(|| PyRuntimeError::new_err("Blob consumer has 
been cleared"))?;
+                callback.bind(py).call1((field_name, encoded))?.is_truthy()
             })
         });
         result.map_err(callback_error)
@@ -858,7 +873,9 @@ impl WriteState {
                 if !callback.bind(py).is_callable() {
                     return Err(PyTypeError::new_err("blob_consumer must be 
callable"));
                 }
-                Ok(Arc::new(PythonBlobConsumer { callback }))
+                Ok(Arc::new(PythonBlobConsumer {
+                    callback: Mutex::new(Some(callback)),
+                }))
             })
             .transpose()?;
         self.inner
@@ -870,9 +887,42 @@ impl WriteState {
                     .map(|consumer| consumer as Arc<dyn BlobConsumer>),
             )
             .map_err(to_py_err)?;
+        // Release replaced callbacks which no bucket has captured. A snapshot
+        // retained by core contributes another Arc, without another Py ref.
+        self.prune_blob_consumers();
+        if let Some(consumer) = consumer {
+            self.blob_consumers.push(consumer);
+        }
         Ok(())
     }
 
+    fn prune_blob_consumers(&mut self) {
+        let (retained, retired): (Vec<_>, Vec<_>) = std::mem::take(&mut 
self.blob_consumers)
+            .into_iter()
+            .partition(|consumer| Arc::strong_count(consumer) > 1);
+        self.blob_consumers = retained;
+        // Python destructors can re-enter Native. No callback mutex is held,
+        // and the retained references are restored before any destructor runs.
+        drop(retired);
+    }
+
+    fn traverse_callbacks(&self, visit: PyVisit<'_>) -> Result<(), 
PyTraverseError> {
+        for consumer in &self.blob_consumers {
+            visit.call(&*consumer.callback.lock().unwrap())?;
+        }
+        Ok(())
+    }
+
+    fn clear_callbacks(&self) {
+        let references = self
+            .blob_consumers
+            .iter()
+            .map(|consumer| consumer.callback.lock().unwrap().take())
+            .collect::<Vec<_>>();
+        // Destructors may re-enter Native. Drop Python references outside 
locks.
+        drop(references);
+    }
+
     fn write_result(&self, result: paimon::Result<()>) -> PyResult<()> {
         result.map_err(to_callback_error)
     }
@@ -900,14 +950,18 @@ impl WriteState {
         self.write_result(result)
     }
 
-    fn prepare_commit(&mut self, py: Python<'_>) -> 
PyResult<Vec<PyCommitMessage>> {
+    fn prepare_commit(
+        &mut self,
+        py: Python<'_>,
+        commit_identifier: i64,
+    ) -> PyResult<Vec<PyCommitMessage>> {
         let inner = self
             .inner
             .as_mut()
             .ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?;
-        let messages = py
-            .detach(|| runtime().block_on(inner.prepare_commit()))
-            .map_err(to_callback_error)?;
+        let result = py.detach(|| 
runtime().block_on(inner.prepare_commit(commit_identifier)));
+        self.prune_blob_consumers();
+        let messages = result.map_err(to_callback_error)?;
         Ok(wrap_messages(
             messages,
             &self.table_location,
@@ -1213,6 +1267,14 @@ impl PyBatchTableUpdate {
 
 #[pymethods]
 impl PyBatchTableWrite {
+    fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
+        self.state.traverse_callbacks(visit)
+    }
+
+    fn __clear__(&mut self) {
+        self.state.clear_callbacks();
+    }
+
     #[pyo3(signature = (factory))]
     fn with_blob_uri_reader_factory(
         mut slf: PyRefMut<'_, Self>,
@@ -1245,6 +1307,7 @@ impl PyBatchTableWrite {
         if let Some(mut writer) = self.state.inner.take() {
             py.detach(|| runtime().block_on(writer.close()));
         }
+        self.state.clear_callbacks();
     }
 
     #[pyo3(signature = (batch, bucket=None))]
@@ -1264,7 +1327,7 @@ impl PyBatchTableWrite {
             ));
         }
         self.prepared = true;
-        self.state.prepare_commit(py)
+        self.state.prepare_commit(py, i64::MAX)
     }
 }
 
@@ -1279,6 +1342,14 @@ pub struct PyStreamTableWrite {
 
 #[pymethods]
 impl PyStreamTableWrite {
+    fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
+        self.state.traverse_callbacks(visit)
+    }
+
+    fn __clear__(&mut self) {
+        self.state.clear_callbacks();
+    }
+
     #[pyo3(signature = (factory))]
     fn with_blob_uri_reader_factory(
         mut slf: PyRefMut<'_, Self>,
@@ -1311,6 +1382,7 @@ impl PyStreamTableWrite {
         if let Some(mut writer) = self.state.inner.take() {
             py.detach(|| runtime().block_on(writer.close()));
         }
+        self.state.clear_callbacks();
     }
 
     #[pyo3(signature = (batch, bucket=None))]
@@ -1324,15 +1396,15 @@ impl PyStreamTableWrite {
     }
 
     /// Rust currently flushes synchronously and has no background compaction.
-    /// The identifier accompanies the returned messages in 
StreamTableCommit.commit.
+    /// The identifier also bounds retirement of captured bucket configuration.
     fn prepare_commit(
         &mut self,
         py: Python<'_>,
         wait_compaction: bool,
         commit_identifier: i64,
     ) -> PyResult<Vec<PyCommitMessage>> {
-        let _ = (wait_compaction, commit_identifier);
-        self.state.prepare_commit(py)
+        let _ = wait_compaction;
+        self.state.prepare_commit(py, commit_identifier)
     }
 }
 
@@ -1687,6 +1759,239 @@ mod tests {
     use paimon::spec::{BigIntType, IntType, Schema, TableSchema, VarCharType};
     use pyo3::ffi::c_str;
 
+    #[test]
+    fn replaced_blob_consumers_are_visible_to_writer_cycle_collection() {
+        Python::attach(|py| {
+            for batch in [false, true] {
+                let schema = Schema::builder()
+                    .column("id", DataType::Int(IntType::new()))
+                    .build()
+                    .unwrap();
+                let table = Arc::new(Table::new(
+                    FileIOBuilder::new("memory").build().unwrap(),
+                    Identifier::new("default", "blob_callback_gc"),
+                    "memory:/blob_callback_gc".into(),
+                    TableSchema::new(0, &schema),
+                    None,
+                ));
+                let context = WriteContext::new(table);
+                let writer = if batch {
+                    Py::new(
+                        py,
+                        PyBatchTableWrite {
+                            state: context.new_write(false).unwrap(),
+                            prepared: false,
+                        },
+                    )
+                    .unwrap()
+                    .into_any()
+                } else {
+                    Py::new(
+                        py,
+                        PyStreamTableWrite {
+                            state: context.new_write(false).unwrap(),
+                        },
+                    )
+                    .unwrap()
+                    .into_any()
+                };
+                let locals = PyDict::new(py);
+                locals.set_item("writer", writer).unwrap();
+                py.run(
+                    c_str!(
+                        r#"
+import gc
+finalized = []
+class Callback:
+    def __init__(self, writer, label):
+        self.writer = writer
+        self.label = label
+    def __call__(self, field, descriptor):
+        return False
+    def __del__(self):
+        finalized.append(self.label)
+writer.with_blob_consumer(Callback(writer, 'first'))
+"#
+                    ),
+                    Some(&locals),
+                    Some(&locals),
+                )
+                .unwrap();
+                // Capture the first callback in core without importing PyArrow
+                // or building/installing the extension for a Python test.
+                let input = RecordBatch::try_from_iter([(
+                    "id",
+                    Arc::new(arrow::array::Int32Array::from(vec![1])) as 
arrow::array::ArrayRef,
+                )])
+                .unwrap();
+                let object = locals.get_item("writer").unwrap().unwrap();
+                if batch {
+                    let mut writer = object.extract::<PyRefMut<'_, 
PyBatchTableWrite>>().unwrap();
+                    let inner = writer.state.inner.as_mut().unwrap();
+                    py.detach(|| runtime().block_on(inner.write_arrow(&input, 
None)))
+                        .unwrap();
+                } else {
+                    let mut writer = object
+                        .extract::<PyRefMut<'_, PyStreamTableWrite>>()
+                        .unwrap();
+                    let inner = writer.state.inner.as_mut().unwrap();
+                    py.detach(|| runtime().block_on(inner.write_arrow(&input, 
None)))
+                        .unwrap();
+                }
+                drop(object);
+                py.run(
+                    c_str!(
+                        r#"
+writer.with_blob_consumer(Callback(writer, 'second'))
+assert finalized == []
+del writer
+gc.collect()
+assert sorted(finalized) == ['first', 'second'], finalized
+"#
+                    ),
+                    Some(&locals),
+                    Some(&locals),
+                )
+                .unwrap();
+            }
+        });
+    }
+
+    #[test]
+    fn idle_bucket_prepare_releases_only_retired_python_blob_consumers() {
+        Python::attach(|py| {
+            let schema = Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("pt", DataType::Int(IntType::new()))
+                .partition_keys(["pt"])
+                .option("bucket", "-1")
+                .build()
+                .unwrap();
+            let table = Arc::new(Table::new(
+                FileIOBuilder::new("memory").build().unwrap(),
+                Identifier::new("default", "retired_callbacks"),
+                "memory:/retired_callbacks".into(),
+                TableSchema::new(0, &schema),
+                None,
+            ));
+            let writer = Py::new(
+                py,
+                PyStreamTableWrite {
+                    state: 
WriteContext::new(table.clone()).new_write(false).unwrap(),
+                },
+            )
+            .unwrap();
+            let locals = PyDict::new(py);
+            locals.set_item("writer", &writer).unwrap();
+            py.run(
+                c_str!(
+                    r#"
+import gc
+import weakref
+finalized = []
+class Callback:
+    def __init__(self, label):
+        self.label = label
+    def __call__(self, field, descriptor):
+        return False
+    def __del__(self):
+        finalized.append(self.label)
+def install(label):
+    callback = Callback(label)
+    writer.with_blob_consumer(callback)
+    return weakref.ref(callback)
+first = install('first')
+"#
+                ),
+                Some(&locals),
+                Some(&locals),
+            )
+            .unwrap();
+            let write_partition = |partition| {
+                let input = RecordBatch::try_from_iter([
+                    (
+                        "id",
+                        Arc::new(arrow::array::Int32Array::from(vec![1])) as 
arrow::array::ArrayRef,
+                    ),
+                    (
+                        "pt",
+                        
Arc::new(arrow::array::Int32Array::from(vec![partition]))
+                            as arrow::array::ArrayRef,
+                    ),
+                ])
+                .unwrap();
+                let mut borrowed = writer.borrow_mut(py);
+                let inner = borrowed.state.inner.as_mut().unwrap();
+                py.detach(|| runtime().block_on(inner.write_arrow(&input, 
None)))
+                    .unwrap();
+            };
+            write_partition(1);
+            py.run(
+                c_str!("second = install('second')"),
+                Some(&locals),
+                Some(&locals),
+            )
+            .unwrap();
+            write_partition(2);
+            py.run(
+                c_str!("current = install('current')"),
+                Some(&locals),
+                Some(&locals),
+            )
+            .unwrap();
+            assert_eq!(
+                writer
+                    .borrow_mut(py)
+                    .state
+                    .prepare_commit(py, 1)
+                    .unwrap()
+                    .len(),
+                2
+            );
+            let prepared_files = py
+                .detach(|| {
+                    
runtime().block_on(table.file_io().list_status_recursive(table.location()))
+                })
+                .unwrap();
+            assert!(!prepared_files.is_empty());
+            py.run(
+                c_str!("gc.collect(); assert first() is not None and second() 
is not None and current() is not None"),
+                Some(&locals), Some(&locals),
+            ).unwrap();
+            write_partition(2);
+            assert_eq!(
+                writer
+                    .borrow_mut(py)
+                    .state
+                    .prepare_commit(py, 2)
+                    .unwrap()
+                    .len(),
+                1
+            );
+            py.run(
+                c_str!("gc.collect(); assert first() is None; assert second() 
is not None and current() is not None; assert finalized == ['first']"),
+                Some(&locals), Some(&locals),
+            ).unwrap();
+            for file in prepared_files.iter().filter(|file| !file.is_dir) {
+                assert!(py
+                    .detach(|| 
runtime().block_on(table.file_io().exists(&file.path)))
+                    .unwrap());
+            }
+            writer.borrow_mut(py).close(py);
+            py.run(
+                c_str!("gc.collect(); assert second() is None and current() is 
None"),
+                Some(&locals),
+                Some(&locals),
+            )
+            .unwrap();
+            for file in prepared_files.iter().filter(|file| !file.is_dir) {
+                assert!(py
+                    .detach(|| 
runtime().block_on(table.file_io().exists(&file.path)))
+                    .unwrap());
+            }
+        });
+    }
+
     #[test]
     fn merge_table_source_conditions_resolve_read_metadata() {
         Python::attach(|py| {
@@ -2208,7 +2513,7 @@ batch = pa.RecordBatch.from_pylist([
                     } else {
                         result.unwrap();
                     }
-                    assert!(state.prepare_commit(py).unwrap().is_empty());
+                    assert!(state.prepare_commit(py, 
i64::MAX).unwrap().is_empty());
                 }
             }
         });
diff --git a/bindings/python/tests/test_blob_consumer.py 
b/bindings/python/tests/test_blob_consumer.py
index d2e8f068..d86a2ee8 100644
--- a/bindings/python/tests/test_blob_consumer.py
+++ b/bindings/python/tests/test_blob_consumer.py
@@ -24,20 +24,25 @@ import pytest
 from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
 
 
-def _table(tmp_path):
+def _table(tmp_path, partitioned=False):
     context = SQLContext()
     context.register_catalog('paimon', {'warehouse': str(tmp_path)})
     context.sql('CREATE SCHEMA paimon.consumer')
-    context.sql("""CREATE TABLE paimon.consumer.t (id INT, payload BLOB) WITH (
+    partition = ', pt INT) PARTITIONED BY (pt)' if partitioned else ')'
+    context.sql("""CREATE TABLE paimon.consumer.t (id INT, payload BLOB{} WITH 
(
         'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true',
-        'blob.copy-buffer-size' = '2 B')""")
+        'blob.copy-buffer-size' = '2 B')""".format(partition))
     return context, PaimonCatalog({'warehouse': 
str(tmp_path)}).get_table('consumer.t')
 
 
-def _batch():
-    return pa.record_batch([pa.array([1, 2, 3], type=pa.int32()),
-                            pa.array([b'payload', None, b''], 
type=pa.large_binary())],
-                           names=['id', 'payload'])
+def _batch(partition=None, first_id=1):
+    columns = [pa.array(range(first_id, first_id + 3), type=pa.int32()),
+               pa.array([b'payload', None, b''], type=pa.large_binary())]
+    names = ['id', 'payload']
+    if partition is not None:
+        columns.append(pa.array([partition] * 3, type=pa.int32()))
+        names.append('pt')
+    return pa.record_batch(columns, names=names)
 
 
 def _descriptor_payload(encoded):
@@ -54,7 +59,7 @@ def _descriptor_payload(encoded):
 
 @pytest.mark.parametrize('stream', [False, True])
 def test_blob_consumer_descriptor_bytes_and_nulls(tmp_path, stream):
-    context, table = _table(tmp_path)
+    context, table = _table(tmp_path, partitioned=True)
     builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
     writer = builder.new_write()
     received = []
@@ -65,18 +70,22 @@ def test_blob_consumer_descriptor_bytes_and_nulls(tmp_path, 
stream):
 
     try:
         assert writer.with_blob_consumer(callback) is writer
-        writer.write_arrow(_batch())
-        with pytest.raises(ValueError, match='before any write'):
-            writer.with_blob_consumer(None)
+        writer.write_arrow(_batch(1))
+        assert writer.with_blob_consumer(None) is writer
+        # The first partition retains its callback, while the new partition
+        # captures the cleared factory setting.
+        writer.write_arrow(_batch(1, 4))
+        writer.write_arrow(_batch(2, 7))
         messages = writer.prepare_commit(True, 1) if stream else 
writer.prepare_commit()
         commit = builder.new_commit()
         commit.commit(1, messages) if stream else commit.commit(messages)
-        assert [name for name, _ in received] == ['payload'] * 3
+        assert [name for name, _ in received] == ['payload'] * 6
         assert [_descriptor_payload(encoded) if encoded is not None else None
-                for _, encoded in received] == [b'payload', None, b'']
+                for _, encoded in received] == [b'payload', None, b''] * 2
         assert pa.Table.from_batches(context.sql(
-            'SELECT id, payload FROM paimon.consumer.t ORDER BY 
id')).to_pydict() == {
-            'id': [1, 2, 3], 'payload': [b'payload', None, b'']}
+            'SELECT id, payload, pt FROM paimon.consumer.t ORDER BY 
id')).to_pydict() == {
+            'id': list(range(1, 10)), 'payload': [b'payload', None, b''] * 3,
+            'pt': [1] * 6 + [2] * 3}
     finally:
         writer.close()
     with pytest.raises(RuntimeError, match='closed'):
diff --git a/bindings/python/tests/test_write.py 
b/bindings/python/tests/test_write.py
index a915f314..14284b3f 100644
--- a/bindings/python/tests/test_write.py
+++ b/bindings/python/tests/test_write.py
@@ -66,27 +66,71 @@ def test_write_commit_read_roundtrip():
 
 @pytest.mark.parametrize("streaming", [False, True])
 def test_partial_write_type_roundtrip_and_lifecycle(tmp_path, streaming):
-    ctx = _make_empty_table(str(tmp_path))
+    ctx = SQLContext()
+    ctx.register_catalog("paimon", {"warehouse": str(tmp_path)})
+    ctx.sql("CREATE SCHEMA paimon.wdb")
+    ctx.sql("CREATE TABLE paimon.wdb.t (id INT, name STRING, pt STRING) 
PARTITIONED BY (pt)")
     table = _get_table(str(tmp_path))
     builder = table.new_stream_write_builder() if streaming else 
table.new_batch_write_builder()
     writer = builder.new_write()
-    assert writer.with_write_type(["id"]) is writer
-    writer.write_arrow(pa.record_batch([pa.array([], type=pa.int32())], 
names=["id"]))
-    assert writer.with_write_type(["id"]) is writer
+    assert writer.with_write_type(["id", "pt"]) is writer
+    empty = pa.record_batch(
+        [pa.array([], type=pa.int32()), pa.array([], type=pa.string())], 
names=["id", "pt"])
+    writer.write_arrow(empty)
+    assert writer.with_write_type(["name", "pt"]) is writer
+    assert writer.with_write_type(["id", "pt"]) is writer
     for columns in [[], ["missing"], ["id", "id"]]:
         with pytest.raises(ValueError):
             writer.with_write_type(columns)
-    writer.write_arrow(pa.record_batch([pa.array([1, 2], type=pa.int32())], 
names=["id"]))
-    with pytest.raises(ValueError, match="before any data"):
-        writer.with_write_type(["id", "name"])
+    schema = pa.schema([("id", pa.int32()), ("name", pa.string()), ("pt", 
pa.string())])
+    writer.write_arrow(pa.record_batch([[1, 2], ["ignored", "ignored"], 
["old", "old"]], schema=schema))
+    assert writer.with_write_type(["id", "pt"]) is writer
+    for columns in [["name", "pt"], ["pt", "id"]]:
+        with pytest.raises(ValueError, match="before any write"):
+            writer.with_write_type(columns)
+    writer.write_arrow(pa.record_batch(
+        [pa.array([3, 4], type=pa.int32()), pa.array(["old", "new"])], 
names=["id", "pt"]))
     messages = writer.prepare_commit(True, 7) if streaming else 
writer.prepare_commit()
     commit = builder.new_commit()
     commit.commit(7, messages) if streaming else commit.commit(messages)
+    if streaming:
+        assert writer.prepare_commit(True, 8) == []
+    assert writer.with_write_type(["id", "pt"]) is writer
+    with pytest.raises(ValueError, match="before any write"):
+        writer.with_write_type(["name", "pt"])
     writer.close()
     with pytest.raises(RuntimeError, match="closed"):
         writer.with_write_type(["id"])
-    assert pa.Table.from_batches(ctx.sql("SELECT id, name FROM paimon.wdb.t 
ORDER BY id")).to_pydict() == {
-        "id": [1, 2], "name": [None, None]}
+    assert pa.Table.from_batches(ctx.sql("SELECT id, name, pt FROM 
paimon.wdb.t ORDER BY pt, id")).to_pydict() == {
+        "id": [4, 1, 2, 3], "name": [None] * 4, "pt": ["new", "old", "old", 
"old"]}
+
+
[email protected]("streaming", [False, True])
+def test_ignored_rows_do_not_lock_write_type(tmp_path, streaming):
+    ctx = SQLContext()
+    ctx.register_catalog("paimon", {"warehouse": str(tmp_path)})
+    ctx.sql("CREATE SCHEMA paimon.wdb")
+    ctx.sql("CREATE TABLE paimon.wdb.t (id INT, name STRING, op STRING) "
+            "WITH ('rowkind.field' = 'op', 'ignore-delete' = 'true')")
+    table = _get_table(str(tmp_path))
+    builder = table.new_stream_write_builder() if streaming else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    schema = pa.schema([("id", pa.int32()), ("name", pa.string()), ("op", 
pa.string())])
+    try:
+        writer.with_write_type(["id", "op"])
+        writer.write_arrow(pa.record_batch([[1], ["ignored"], ["-D"]], 
schema=schema))
+        assert writer.with_write_type(["name", "op"]) is writer
+        writer.write_arrow(pa.record_batch([[2], ["accepted"], ["+I"]], 
schema=schema))
+        assert writer.with_write_type(["name", "op"]) is writer
+        with pytest.raises(ValueError, match="before any write"):
+            writer.with_write_type(["id", "op"])
+        messages = writer.prepare_commit(True, 1) if streaming else 
writer.prepare_commit()
+        commit = builder.new_commit()
+        commit.commit(1, messages) if streaming else commit.commit(messages)
+    finally:
+        writer.close()
+    assert pa.Table.from_batches(ctx.sql("SELECT id, name FROM 
paimon.wdb.t")).to_pydict() == {
+        "id": [None], "name": ["accepted"]}
 
 
 @pytest.mark.parametrize("streaming", [False, True])
diff --git a/crates/paimon/src/table/blob_consumer_tests.rs 
b/crates/paimon/src/table/blob_consumer_tests.rs
index 9c3ef0a5..d11b28ee 100644
--- a/crates/paimon/src/table/blob_consumer_tests.rs
+++ b/crates/paimon/src/table/blob_consumer_tests.rs
@@ -94,7 +94,9 @@ async fn 
append_consumer_survives_file_rolls_and_checkpoint_recreation() {
                 .sum::<i64>(),
             2
         );
-        assert!(writer.with_blob_consumer(None).is_err());
+        // Java changes the factory setting while an existing bucket keeps the
+        // consumer it captured, including across consecutive prepares.
+        writer.with_blob_consumer(None).unwrap();
     }
     writer.close().await;
     let events = events.lock().unwrap().clone();
diff --git a/crates/paimon/src/table/internal.rs 
b/crates/paimon/src/table/internal.rs
index 06b6a80c..800f5b83 100644
--- a/crates/paimon/src/table/internal.rs
+++ b/crates/paimon/src/table/internal.rs
@@ -15,15 +15,17 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Unstable scheduling bridge for the repository's language bindings.
+//! Unstable execution bridge for the repository's language bindings.
 //!
 //! Source plans are the public input to search readers. Candidate and scoring
 //! requests below are internal execution stages: Core owns their ordering and
-//! global result coordination. This module is available only with 
`internal-api`.
+//! global result coordination. Writer adapters forward their existing commit
+//! identifiers through the same bridge. This module is available only with 
`internal-api`.
 
 pub use super::search_task::{SearchTask, SearchTaskExecutor, SearchTaskResult};
 use super::{
-    BatchVectorRead, GlobalIndexResult, HybridSearchBuilder, Table, 
VectorRead, VectorScanPlan,
+    BatchVectorRead, CommitMessage, GlobalIndexResult, HybridSearchBuilder, 
Table, TableWrite,
+    VectorRead, VectorScanPlan,
 };
 
 pub async fn execute_search_task(
@@ -65,3 +67,13 @@ pub async fn execute_hybrid_with_executor(
 ) -> crate::Result<GlobalIndexResult> {
     builder.execute_with_executor(executor).await
 }
+
+/// Forward an identifier already supplied by the batch or stream facade.
+pub async fn prepare_commit_with_identifier(
+    writer: &mut TableWrite,
+    commit_identifier: i64,
+) -> crate::Result<Vec<CommitMessage>> {
+    writer
+        .prepare_commit_with_identifier(commit_identifier)
+        .await
+}
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index 39842497..1e8c2217 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -54,9 +54,27 @@ use std::sync::Arc;
 
 #[path = "table_write_input.rs"]
 mod input;
+#[cfg(test)]
+#[path = "table_write_lifecycle_tests.rs"]
+mod lifecycle_tests;
 #[path = "table_write_precomputed.rs"]
 mod precomputed;
 
+#[derive(Clone, Copy)]
+enum AppendPrepareIdentifier {
+    Known(i64),
+    Unknown,
+}
+
+/// Java's append writer captures its factory settings per partition/bucket.
+/// Keep only these settings across prepares, not completed file resources.
+#[derive(Clone)]
+struct AppendWriteConfig {
+    blob_consumer: Option<Arc<dyn crate::spec::BlobConsumer>>,
+    blob_uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+    last_modified_prepare: Option<AppendPrepareIdentifier>,
+}
+
 /// Enum to hold either an append-only writer, a key-value writer, or a 
postpone writer.
 enum FileWriter {
     Append(DataFileWriter),
@@ -143,6 +161,7 @@ pub struct TableWrite {
     write_fields: Vec<DataField>,
     written: bool,
     partition_writers: HashMap<PartitionBucketKey, FileWriter>,
+    append_write_configs: HashMap<PartitionBucketKey, AppendWriteConfig>,
     partition_computer: PartitionComputer,
     partition_keys: Vec<String>,
     schema_id: i64,
@@ -213,6 +232,7 @@ impl TableWrite {
             write_fields: schema.fields().to_vec(),
             written: false,
             partition_writers: HashMap::new(),
+            append_write_configs: HashMap::new(),
             partition_computer: PartitionComputer::new(
                 schema.partition_keys(),
                 schema.fields(),
@@ -513,6 +533,7 @@ impl TableWrite {
             write_fields: fields.to_vec(),
             written: false,
             partition_writers: HashMap::new(),
+            append_write_configs: HashMap::new(),
             partition_computer,
             partition_keys,
             schema_id: schema.id(),
@@ -641,15 +662,11 @@ impl TableWrite {
     /// Full-table inputs may provide routing columns which are not written.
     /// Selected inputs must include partition columns and, unless a bucket is
     /// supplied through [`Self::write_arrow`], the columns used for hashing.
-    /// Configure this before writing any nonempty batch.
+    /// Configure this before the first effective write. Afterwards the same
+    /// ordered selection is a no-op and a different selection is rejected,
+    /// including after prepares. Empty or ignored input does not lock it.
     pub fn with_write_type(&mut self, write_columns: Vec<String>) -> 
Result<&mut Self> {
         self.ensure_active()?;
-        if self.written {
-            return Err(crate::Error::DataInvalid {
-                message: "with_write_type must be called before any data is 
written".into(),
-                source: None,
-            });
-        }
         if self.format_writer.is_some() || 
!self.table.schema().primary_keys().is_empty() {
             return Err(crate::Error::Unsupported {
                 message: "with_write_type requires a Paimon append 
table".into(),
@@ -683,6 +700,15 @@ impl TableWrite {
                     })
             })
             .collect::<Result<Vec<_>>>()?;
+        if fields == self.write_fields {
+            return Ok(self);
+        }
+        if self.written {
+            return Err(crate::Error::DataInvalid {
+                message: "with_write_type must be called before any write 
operation".into(),
+                source: None,
+            });
+        }
         let indices = |names: &[String]| {
             names
                 .iter()
@@ -728,21 +754,15 @@ impl TableWrite {
         Ok(self)
     }
 
-    /// Receive dedicated append BLOB payload descriptors. Configure before
-    /// writing, as in PyPaimon. Java's primary-key and inline BLOB writers do
-    /// not invoke this consumer. Automatic abort preserves consumer Blob 
files;
+    /// Receive dedicated append BLOB payload descriptors. Changes apply to
+    /// newly created partition/bucket writers, as in Java. Primary-key and 
inline
+    /// BLOB writers do not invoke it. Automatic abort preserves consumer Blob 
files;
     /// callers can explicitly discard prepared files with TableCommit::abort.
     pub fn with_blob_consumer(
         &mut self,
         consumer: Option<Arc<dyn crate::spec::BlobConsumer>>,
     ) -> Result<&mut Self> {
         self.ensure_active()?;
-        if self.written {
-            return Err(crate::Error::DataInvalid {
-                message: "with_blob_consumer must be called before any write 
operation".into(),
-                source: None,
-            });
-        }
         if self.format_writer.is_some() {
             return Err(crate::Error::Unsupported {
                 message: "with_blob_consumer requires a Paimon table".into(),
@@ -1457,19 +1477,70 @@ impl TableWrite {
         for (_, mut writer) in self.partition_writers.drain() {
             writer.abort().await;
         }
+        self.append_write_configs.clear();
+        self.blob_consumer = None;
+        self.blob_uri_reader_factory = None;
     }
 
     /// Flush all writers and collect CommitMessages for use with TableCommit.
     /// Append writers are closed; primary-key writers retain their sequence
     /// counters after handing off the prepared files, as Java does.
+    /// Captured append settings survive active prepares. Empty prepares retire
+    /// idle bucket-unaware writers. This generic operation has no checkpoint
+    /// identifier, so it conservatively retains fixed-bucket settings because
+    /// it cannot prove the previous output has committed.
     ///
     /// Invalidate the snapshot sequence cache used to seed previously unseen
     /// buckets. Already-open key-value writers keep their own advancing 
counter.
     pub async fn prepare_commit(&mut self) -> Result<Vec<CommitMessage>> {
+        self.prepare_commit_internal(None).await
+    }
+
+    #[cfg(any(test, feature = "internal-api"))]
+    pub(crate) async fn prepare_commit_with_identifier(
+        &mut self,
+        commit_identifier: i64,
+    ) -> Result<Vec<CommitMessage>> {
+        self.prepare_commit_internal(Some(commit_identifier)).await
+    }
+
+    async fn prepare_commit_internal(
+        &mut self,
+        commit_identifier: Option<i64>,
+    ) -> Result<Vec<CommitMessage>> {
         if let Some(writer) = self.format_writer.as_mut() {
             return writer.prepare_commit().await;
         }
         self.ensure_active()?;
+        // Java keeps newly created writers on the first prepare. Later empty
+        // increments may retire them, but active buckets keep their captured
+        // factory configuration across successful prepares.
+        let can_retire = self.append_write_configs.values().any(|config| {
+            matches!(
+                config.last_modified_prepare,
+                Some(AppendPrepareIdentifier::Unknown)
+            ) || matches!(
+                config.last_modified_prepare,
+                Some(AppendPrepareIdentifier::Known(id)) if id != i64::MIN
+            )
+        });
+        let fixed_bucket = 
CoreOptions::new(self.table.schema().options()).bucket() >= 0;
+        let latest_committed_identifier = if can_retire
+            && fixed_bucket
+            && commit_identifier.is_some()
+            && self.append_write_configs.iter().any(|(key, config)| {
+                !self.partition_writers.contains_key(key)
+                    && matches!(
+                        config.last_modified_prepare,
+                        Some(AppendPrepareIdentifier::Known(_))
+                    )
+            }) {
+            // Resolve before draining files: a metadata failure leaves pending
+            // output available for a retry, with no prepared files lost.
+            Self::latest_committed_append_identifier(&self.table, 
&self.commit_user).await?
+        } else {
+            None
+        };
         self.partition_seq_cache.clear();
         let writers: Vec<(PartitionBucketKey, FileWriter)> =
             self.partition_writers.drain().collect();
@@ -1531,16 +1602,81 @@ impl TableWrite {
                 messages.push(msg);
             }
         }
+        let modified = messages
+            .iter()
+            .map(|message| (message.partition.clone(), message.bucket))
+            .collect::<HashSet<_>>();
+        self.append_write_configs.retain(|key, config| {
+            if modified.contains(key) {
+                config.last_modified_prepare = Some(match commit_identifier {
+                    Some(id) => AppendPrepareIdentifier::Known(id),
+                    None => AppendPrepareIdentifier::Unknown,
+                });
+                return true;
+            }
+            if !can_retire {
+                return true;
+            }
+            if !fixed_bucket {
+                return false;
+            }
+            // The generic core prepare has no checkpoint identifier, so it
+            // cannot prove a fixed bucket's previous output has committed.
+            match (
+                commit_identifier,
+                config.last_modified_prepare,
+                latest_committed_identifier,
+            ) {
+                (Some(_), Some(AppendPrepareIdentifier::Known(last)), 
Some(latest)) => {
+                    last >= latest
+                }
+                _ => true,
+            }
+        });
         Ok(messages)
     }
 
+    async fn latest_committed_append_identifier(
+        table: &Table,
+        commit_user: &str,
+    ) -> Result<Option<i64>> {
+        // FileSystemWriteRestore deliberately reads filesystem snapshots, even
+        // for catalog-managed tables, and stops at the first expired snapshot.
+        let manager = table.snapshot_manager().with_rest_env(None);
+        let Some(mut id) = manager.get_latest_snapshot_id().await? else {
+            return Ok(None);
+        };
+        while id >= 1 {
+            let snapshot = match manager.get_snapshot(id).await {
+                Ok(snapshot) => snapshot,
+                Err(crate::Error::SnapshotNotExist { .. }) => break,
+                Err(crate::Error::IoUnexpected { source, .. })
+                    if source.kind() == opendal::ErrorKind::NotFound =>
+                {
+                    break;
+                }
+                Err(error) => return Err(error),
+            };
+            if snapshot.commit_user() == commit_user {
+                return Ok(Some(snapshot.commit_identifier()));
+            }
+            id -= 1;
+        }
+        Ok(None)
+    }
+
     async fn fail_prepare(&mut self, messages: &[CommitMessage]) {
+        let preserve_blob_files = self.primary_key_indices.is_empty()
+            && self
+                .append_write_configs
+                .values()
+                .any(|config| config.blob_consumer.is_some());
         self.failed = true;
         self.close().await;
         // Only this failed invocation's outputs: no message has been returned
         // or submitted. Earlier successful prepare calls belong to the caller.
         let mut cleanup = messages.to_vec();
-        if self.primary_key_indices.is_empty() && self.blob_consumer.is_some() 
{
+        if preserve_blob_files {
             // A successful partition may already have exposed its dedicated
             // Blob files to the consumer. Preserve Java's deletion policy in
             // this table-wide cleanup too. Explicit TableCommit.abort remains
@@ -1561,7 +1697,19 @@ impl TableWrite {
         let partition_path = self.resolve_partition_path(&partition_bytes)?;
 
         let writer = if self.primary_key_indices.is_empty() {
-            self.create_append_writer(partition_path, bucket)?
+            let key = (partition_bytes.clone(), bucket);
+            let config = self
+                .append_write_configs
+                .get(&key)
+                .cloned()
+                .unwrap_or_else(|| AppendWriteConfig {
+                    blob_consumer: self.blob_consumer.clone(),
+                    blob_uri_reader_factory: 
self.blob_uri_reader_factory.clone(),
+                    last_modified_prepare: None,
+                });
+            let writer = self.create_append_writer(partition_path, bucket, 
&config)?;
+            self.append_write_configs.insert(key, config);
+            writer
         } else if bucket == POSTPONE_BUCKET {
             self.create_postpone_writer(partition_path, bucket)?
         } else {
@@ -1584,7 +1732,12 @@ impl TableWrite {
     }
 
     /// Create an append-only writer for non-PK tables.
-    fn create_append_writer(&self, partition_path: String, bucket: i32) -> 
Result<FileWriter> {
+    fn create_append_writer(
+        &self,
+        partition_path: String,
+        bucket: i32,
+        config: &AppendWriteConfig,
+    ) -> Result<FileWriter> {
         if self.has_blob_fields
             || self.has_dedicated_vector_fields
             || !self.blob_view_fields.is_empty()
@@ -1613,8 +1766,8 @@ impl TableWrite {
                     &self.blob_inline_fields,
                 )?
                 .with_blob_writer_options(
-                    self.blob_consumer.clone(),
-                    self.blob_uri_reader_factory.clone(),
+                    config.blob_consumer.clone(),
+                    config.blob_uri_reader_factory.clone(),
                 )?
                 .with_resources(self.resources.clone()),
             )))
@@ -1800,7 +1953,7 @@ pub(in crate::table) mod tests {
         use arrow_array::{ArrayRef, LargeBinaryArray};
         use std::sync::Mutex;
 
-        for has_consumer in [false, true] {
+        for (has_consumer, replace_consumer) in [(false, false), (true, 
false), (true, true)] {
             let io = test_file_io();
             let schema = Schema::builder()
                 .column("pt", DataType::Int(IntType::new()))
@@ -1842,6 +1995,11 @@ pub(in crate::table) mod tests {
             ])
             .unwrap();
             writer.write_arrow_batch(&batch).await.unwrap();
+            if replace_consumer {
+                // Captured callbacks own these payloads even when the factory
+                // has subsequently disabled its consumer for future writers.
+                writer.with_blob_consumer(None).unwrap();
+            }
             assert_eq!(writer.partition_writers.len(), 2);
             let FileWriter::AppendDedicated(partition) =
                 writer.partition_writers.values_mut().next().unwrap()
diff --git a/crates/paimon/src/table/table_write_input.rs 
b/crates/paimon/src/table/table_write_input.rs
index b2e99b4c..a5987da4 100644
--- a/crates/paimon/src/table/table_write_input.rs
+++ b/crates/paimon/src/table/table_write_input.rs
@@ -26,7 +26,8 @@ pub(super) struct RoutingInput {
 }
 
 impl TableWrite {
-    /// Accept the declared table layout as well as the selected write layout.
+    /// Accept the declared table layout or the selected write layout with
+    /// additional routing/event columns. The selected columns keep their 
order.
     /// The empty schema check intentionally does not inspect unwritten values:
     /// an omitted Blob must never open a URI or invoke a Blob consumer.
     pub(super) fn project_full_write_input(
@@ -42,14 +43,59 @@ impl TableWrite {
                 .map(|field| field.name())
                 .eq(expected.iter().map(|field| field.name()))
         };
-        if !self.table.schema().primary_keys().is_empty()
-            || names_match(&self.write_fields)
-            || !names_match(fields)
-        {
+        if !self.table.schema().primary_keys().is_empty() || 
names_match(&self.write_fields) {
             return Ok(None);
         }
-        let full_schema = build_target_arrow_schema(fields)?;
-        self.normalize_batch_schema(&input.slice(0, 0), &full_schema)?;
+        let transport_fields = if names_match(fields) {
+            fields.to_vec()
+        } else {
+            let selected = self
+                .write_fields
+                .iter()
+                .map(|field| field.name())
+                .collect::<HashSet<_>>();
+            let mut routing = self
+                .partition_keys
+                .iter()
+                .map(String::as_str)
+                .collect::<HashSet<_>>();
+            let bucket_keys = self.table.schema().bucket_keys();
+            let options = CoreOptions::new(self.table.schema().options());
+            if options.bucket() > 1 {
+                routing.extend(bucket_keys.iter().map(String::as_str));
+            }
+            if let Some(field) = options.rowkind_field() {
+                routing.insert(field);
+            }
+            let schema = input.schema();
+            let mut names = HashSet::new();
+            if !schema.fields().iter().all(|field| {
+                names.insert(field.name().as_str())
+                    && (selected.contains(field.name().as_str())
+                        || routing.contains(field.name().as_str()))
+            }) || !schema
+                .fields()
+                .iter()
+                .filter(|field| selected.contains(field.name().as_str()))
+                .map(|field| field.name().as_str())
+                .eq(self.write_fields.iter().map(|field| field.name()))
+            {
+                return Ok(None);
+            }
+            schema
+                .fields()
+                .iter()
+                .map(|field| {
+                    fields
+                        .iter()
+                        .find(|candidate| candidate.name() == field.name())
+                        .unwrap()
+                        .clone()
+                })
+                .collect()
+        };
+        let transport_schema = build_target_arrow_schema(&transport_fields)?;
+        self.normalize_batch_schema(&input.slice(0, 0), &transport_schema)?;
         let indices = self
             .write_fields
             .iter()
diff --git a/crates/paimon/src/table/table_write_lifecycle_tests.rs 
b/crates/paimon/src/table/table_write_lifecycle_tests.rs
new file mode 100644
index 00000000..0389cd44
--- /dev/null
+++ b/crates/paimon/src/table/table_write_lifecycle_tests.rs
@@ -0,0 +1,354 @@
+// 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::{BlobDescriptor, BlobType, CommitKind, IntType, Schema, 
TableSchema};
+use arrow_array::{Int32Array, LargeBinaryArray};
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Mutex;
+
+async fn table(fixed: bool, blob: bool) -> Table {
+    let io = FileIOBuilder::new("memory").build().unwrap();
+    let mut schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column(
+            "value",
+            if blob {
+                DataType::Blob(BlobType::new())
+            } else {
+                DataType::Int(IntType::new())
+            },
+        )
+        .column("pt", DataType::Int(IntType::new()))
+        .partition_keys(["pt"]);
+    if fixed {
+        schema = schema.option("bucket", "2").option("bucket-key", "id");
+    } else {
+        schema = schema
+            .option("row-tracking.enabled", "true")
+            .option("data-evolution.enabled", "true");
+    }
+    let table = Table::new(
+        io,
+        Identifier::new("default", "append_lifecycle"),
+        "memory:/append_lifecycle".into(),
+        TableSchema::new(0, &schema.build().unwrap()),
+        None,
+    );
+    table
+        .file_io()
+        .new_output(&table.schema_manager().schema_path(0))
+        .unwrap()
+        .write(serde_json::to_vec(table.schema()).unwrap().into())
+        .await
+        .unwrap();
+    table
+}
+
+fn batch(partition: i32, blob: bool) -> RecordBatch {
+    let value: ArrayRef = if blob {
+        Arc::new(LargeBinaryArray::from(vec![b"payload".as_slice()]))
+    } else {
+        Arc::new(Int32Array::from(vec![20]))
+    };
+    RecordBatch::try_from_iter([
+        ("id", Arc::new(Int32Array::from(vec![10])) as ArrayRef),
+        ("value", value),
+        (
+            "pt",
+            Arc::new(Int32Array::from(vec![partition])) as ArrayRef,
+        ),
+    ])
+    .unwrap()
+}
+
+async fn write(writer: &mut TableWrite, fixed: bool, partition: i32) {
+    writer
+        .write_arrow(&[batch(partition, false)], fixed.then_some(0))
+        .await
+        .unwrap();
+}
+
+fn assert_columns(messages: &[CommitMessage], column: &str) {
+    assert_eq!(messages.len(), 1);
+    assert!(!messages[0].new_files.is_empty());
+    for file in &messages[0].new_files {
+        assert_eq!(file.write_cols, Some(vec!["pt".into(), column.into()]));
+        assert_eq!(file.row_count, 1);
+    }
+}
+
+async fn snapshot(table: &Table, id: i64, user: &str, identifier: i64) {
+    let snapshot = Snapshot::builder()
+        .version(3)
+        .id(id)
+        .schema_id(0)
+        .base_manifest_list(String::new())
+        .delta_manifest_list(String::new())
+        .commit_user(user.into())
+        .commit_identifier(identifier)
+        .commit_kind(CommitKind::APPEND)
+        .time_millis(0)
+        .build();
+    let manager = table.snapshot_manager();
+    table
+        .file_io()
+        .new_output(&manager.snapshot_path(id))
+        .unwrap()
+        .write(serde_json::to_vec(&snapshot).unwrap().into())
+        .await
+        .unwrap();
+    manager.write_latest_hint(id).await.unwrap();
+}
+
+#[tokio::test]
+async fn unaware_append_retirement_does_not_unlock_write_type() {
+    for idle_prepares in 0..=2 {
+        let table = table(false, false).await;
+        let mut writer = TableWrite::new(&table, "owner".into()).unwrap();
+        writer
+            .with_write_type(vec!["pt".into(), "id".into()])
+            .unwrap();
+        write(&mut writer, false, 1).await;
+        assert_columns(&writer.prepare_commit().await.unwrap(), "id");
+        for _ in 0..idle_prepares {
+            assert!(writer.prepare_commit().await.unwrap().is_empty());
+        }
+        assert!(writer
+            .with_write_type(vec!["pt".into(), "value".into()])
+            .is_err());
+        writer
+            .with_write_type(vec!["pt".into(), "id".into()])
+            .unwrap();
+        write(&mut writer, false, 1).await;
+        assert_columns(&writer.prepare_commit().await.unwrap(), "id");
+    }
+}
+
+#[tokio::test]
+async fn empty_prepares_do_not_capture_append_factory_settings() {
+    let table = table(false, false).await;
+    let mut writer = TableWrite::new(&table, "owner".into()).unwrap();
+    writer
+        .with_write_type(vec!["pt".into(), "id".into()])
+        .unwrap();
+    for _ in 0..2 {
+        assert!(writer.prepare_commit().await.unwrap().is_empty());
+    }
+    writer
+        .with_write_type(vec!["pt".into(), "value".into()])
+        .unwrap();
+    write(&mut writer, false, 1).await;
+    assert_columns(&writer.prepare_commit().await.unwrap(), "value");
+}
+
+#[tokio::test]
+async fn idle_append_retirement_recaptures_consumers_and_close_releases_them() 
{
+    for idle_prepares in 0..=2 {
+        let table = table(false, true).await;
+        let mut writer = TableWrite::new(&table, "owner".into()).unwrap();
+        let old_calls = Arc::new(AtomicUsize::new(0));
+        let new_calls = Arc::new(AtomicUsize::new(0));
+        let consumer = |calls: Arc<AtomicUsize>| {
+            Arc::new(move |_: &str, descriptor: Option<&BlobDescriptor>| {
+                if descriptor.is_some() {
+                    calls.fetch_add(1, Ordering::Relaxed);
+                }
+                Ok(false)
+            }) as Arc<dyn crate::spec::BlobConsumer>
+        };
+        let old = consumer(old_calls.clone());
+        let old_ref = Arc::downgrade(&old);
+        writer.with_blob_consumer(Some(old)).unwrap();
+        writer.write_arrow_batch(&batch(1, true)).await.unwrap();
+        writer.prepare_commit().await.unwrap();
+        let new = consumer(new_calls.clone());
+        let new_ref = Arc::downgrade(&new);
+        writer.with_blob_consumer(Some(new)).unwrap();
+        for _ in 0..idle_prepares {
+            assert!(writer.prepare_commit().await.unwrap().is_empty());
+        }
+        assert_eq!(old_ref.upgrade().is_some(), idle_prepares == 0);
+        writer.write_arrow_batch(&batch(1, true)).await.unwrap();
+        writer.prepare_commit().await.unwrap();
+        assert_eq!(
+            old_calls.load(Ordering::Relaxed),
+            if idle_prepares == 0 { 2 } else { 1 }
+        );
+        assert_eq!(
+            new_calls.load(Ordering::Relaxed),
+            usize::from(idle_prepares != 0)
+        );
+        writer.close().await;
+        assert!(old_ref.upgrade().is_none());
+        assert!(new_ref.upgrade().is_none());
+    }
+}
+
+#[tokio::test]
+async fn 
fixed_append_waits_for_a_strictly_newer_committed_identifier_of_its_user() {
+    let table = table(true, false).await;
+    let mut writer = TableWrite::new(&table, "owner".into()).unwrap();
+    writer
+        .with_write_type(vec!["pt".into(), "id".into()])
+        .unwrap();
+    write(&mut writer, true, 1).await;
+    assert_columns(
+        &writer.prepare_commit_with_identifier(1).await.unwrap(),
+        "id",
+    );
+    for (id, user, identifier) in [(1, "owner", 1), (2, "owner", 1), (3, 
"other", 99)] {
+        snapshot(&table, id, user, identifier).await;
+        assert!(writer
+            .prepare_commit_with_identifier(2)
+            .await
+            .unwrap()
+            .is_empty());
+        assert_eq!(writer.append_write_configs.len(), 1);
+    }
+    snapshot(&table, 4, "owner", 2).await;
+    assert!(writer
+        .prepare_commit_with_identifier(3)
+        .await
+        .unwrap()
+        .is_empty());
+    assert!(writer.append_write_configs.is_empty());
+    write(&mut writer, true, 1).await;
+    assert_columns(
+        &writer.prepare_commit_with_identifier(3).await.unwrap(),
+        "id",
+    );
+}
+
+#[tokio::test]
+async fn fixed_append_keeps_unknown_prepare_identifiers() {
+    for initial_identifier in [None, Some(1)] {
+        let table = table(true, false).await;
+        let mut writer = TableWrite::new(&table, "owner".into()).unwrap();
+        writer
+            .with_write_type(vec!["pt".into(), "id".into()])
+            .unwrap();
+        write(&mut writer, true, 1).await;
+        writer
+            .prepare_commit_internal(initial_identifier)
+            .await
+            .unwrap();
+        snapshot(&table, 1, "owner", 2).await;
+        assert!(writer.prepare_commit().await.unwrap().is_empty());
+        if initial_identifier.is_none() {
+            assert!(writer
+                .prepare_commit_with_identifier(3)
+                .await
+                .unwrap()
+                .is_empty());
+        }
+        write(&mut writer, true, 1).await;
+        assert_columns(&writer.prepare_commit().await.unwrap(), "id");
+    }
+}
+
+#[tokio::test]
+async fn fixed_append_stops_at_an_expired_snapshot_gap() {
+    let table = table(true, false).await;
+    let mut writer = TableWrite::new(&table, "owner".into()).unwrap();
+    writer
+        .with_write_type(vec!["pt".into(), "id".into()])
+        .unwrap();
+    write(&mut writer, true, 1).await;
+    writer.prepare_commit_with_identifier(1).await.unwrap();
+    snapshot(&table, 1, "owner", 2).await;
+    snapshot(&table, 3, "other", 99).await;
+    assert!(writer
+        .prepare_commit_with_identifier(3)
+        .await
+        .unwrap()
+        .is_empty());
+    write(&mut writer, true, 1).await;
+    assert_columns(
+        &writer.prepare_commit_with_identifier(3).await.unwrap(),
+        "id",
+    );
+}
+
+#[tokio::test]
+async fn fixed_append_metadata_errors_leave_pending_output_available() {
+    let table = table(true, false).await;
+    let mut writer = TableWrite::new(&table, "owner".into()).unwrap();
+    writer
+        .with_write_type(vec!["pt".into(), "id".into()])
+        .unwrap();
+    write(&mut writer, true, 1).await;
+    writer.prepare_commit_with_identifier(1).await.unwrap();
+    write(&mut writer, true, 2).await;
+    let manager = table.snapshot_manager();
+    table
+        .file_io()
+        .new_output(&manager.snapshot_path(1))
+        .unwrap()
+        .write(bytes::Bytes::from_static(b"invalid snapshot JSON"))
+        .await
+        .unwrap();
+    manager.write_latest_hint(1).await.unwrap();
+    assert!(matches!(
+        writer.prepare_commit_with_identifier(2).await,
+        Err(crate::Error::DataInvalid { .. })
+    ));
+    snapshot(&table, 1, "owner", 1).await;
+    assert_columns(
+        &writer.prepare_commit_with_identifier(2).await.unwrap(),
+        "id",
+    );
+}
+
+#[tokio::test]
+async fn 
failed_prepare_preserves_exposed_blob_files_after_releasing_contexts() {
+    let table = table(false, true).await;
+    let exposed = Arc::new(Mutex::new(Vec::new()));
+    let captured = exposed.clone();
+    let mut writer = TableWrite::new(&table, "owner".into()).unwrap();
+    writer
+        .with_blob_consumer(Some(Arc::new(
+            move |_: &str, descriptor: Option<&BlobDescriptor>| {
+                if let Some(descriptor) = descriptor {
+                    
captured.lock().unwrap().push(descriptor.uri().to_string());
+                }
+                Ok(false)
+            },
+        )))
+        .unwrap();
+    writer.write_arrow_batch(&batch(1, true)).await.unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    // Model the successful partition outputs collected before another
+    // partition's prepare failed. These files have not been handed off.
+    writer.fail_prepare(&messages).await;
+    let exposed = exposed.lock().unwrap().clone();
+    assert!(!exposed.is_empty());
+    for path in exposed {
+        assert!(table.file_io().exists(&path).await.unwrap());
+    }
+    assert!(writer.append_write_configs.is_empty());
+    assert!(writer.blob_consumer.is_none());
+    assert!(!table
+        .file_io()
+        .list_status_recursive(table.location())
+        .await
+        .unwrap()
+        .iter()
+        .any(|file| file.path.ends_with(".parquet")));
+}
diff --git a/crates/paimon/tests/filtered_write_validation_test.rs 
b/crates/paimon/tests/filtered_write_validation_test.rs
index 4d51a78e..97d0deee 100644
--- a/crates/paimon/tests/filtered_write_validation_test.rs
+++ b/crates/paimon/tests/filtered_write_validation_test.rs
@@ -364,8 +364,11 @@ async fn 
completely_filtered_batches_do_not_lock_writer_configuration() {
     writer.with_blob_consumer(None).unwrap();
     writer.with_blob_uri_reader_factory(None).unwrap();
     writer.write_arrow_batch(&batch("+I")).await.unwrap();
-    assert!(writer.with_write_type(vec!["id".into()]).is_err());
-    assert!(writer.with_blob_consumer(None).is_err());
+    writer.with_write_type(vec!["id".into()]).unwrap();
+    assert!(writer
+        .with_write_type(vec!["id".into(), "op".into()])
+        .is_err());
+    writer.with_blob_consumer(None).unwrap();
     assert!(writer.with_blob_uri_reader_factory(None).is_err());
     let messages = writer.prepare_commit().await.unwrap();
     assert_eq!(
diff --git a/crates/paimon/tests/table_partial_write_test.rs 
b/crates/paimon/tests/table_partial_write_test.rs
index fd0c87b3..2e305d27 100644
--- a/crates/paimon/tests/table_partial_write_test.rs
+++ b/crates/paimon/tests/table_partial_write_test.rs
@@ -172,6 +172,9 @@ async fn 
invalid_write_type_does_not_replace_previous_configuration() {
         .await
         .unwrap();
     let messages = writer.prepare_commit().await.unwrap();
+    writer
+        .with_write_type(vec!["pt".into(), "id".into()])
+        .unwrap();
     assert!(writer
         .with_write_type(vec!["id".into(), "omitted".into(), "pt".into()])
         .is_err());
@@ -179,6 +182,211 @@ async fn 
invalid_write_type_does_not_replace_previous_configuration() {
     assert_eq!(rows(&table).await, vec![vec![Some(1), None, Some(2)]]);
 }
 
+#[tokio::test]
+async fn append_write_type_stays_locked_across_partitions_and_prepares() {
+    for fixed in [false, true] {
+        let table = table(!fixed, fixed, false).await;
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        writer
+            .with_write_type(vec!["pt".into(), "id".into()])
+            .unwrap();
+        writer
+            .write_arrow(&[partial(vec![11], vec![Some(1)])], 
fixed.then_some(0))
+            .await
+            .unwrap();
+        for columns in [vec!["pt", "omitted"], vec!["id", "pt"]] {
+            let error = writer
+                
.with_write_type(columns.into_iter().map(str::to_string).collect())
+                .err()
+                .unwrap();
+            assert!(error.to_string().contains("before any write operation"));
+        }
+        writer
+            .with_write_type(vec!["pt".into(), "id".into()])
+            .unwrap();
+        let full = RecordBatch::try_from_iter([
+            ("id", Arc::new(Int32Array::from(vec![22, 33])) as ArrayRef),
+            (
+                "omitted",
+                Arc::new(Int32Array::from(vec![99, 99])) as ArrayRef,
+            ),
+            ("pt", Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef),
+        ])
+        .unwrap();
+        if fixed {
+            for row in 0..2 {
+                writer
+                    .write_arrow(&[full.slice(row, 1)], Some(0))
+                    .await
+                    .unwrap();
+            }
+        } else {
+            writer.write_arrow_batch(&full).await.unwrap();
+        }
+        let mut messages = writer.prepare_commit().await.unwrap();
+        assert!(writer.prepare_commit().await.unwrap().is_empty());
+        assert!(writer.with_write_type(vec!["omitted".into()]).is_err());
+        writer
+            .write_arrow(&[partial(vec![44], vec![Some(3)])], 
fixed.then_some(0))
+            .await
+            .unwrap();
+        messages.extend(writer.prepare_commit().await.unwrap());
+        builder.new_commit().commit(messages).await.unwrap();
+        assert_eq!(
+            rows(&table).await,
+            vec![
+                vec![Some(11), None, Some(1)],
+                vec![Some(22), None, Some(1)],
+                vec![Some(33), None, Some(2)],
+                vec![Some(44), None, Some(3)],
+            ]
+        );
+    }
+}
+
+#[tokio::test]
+async fn selected_input_accepts_only_current_columns_and_routing_fields() {
+    use paimon::spec::{BlobType, VarCharType};
+    for fixed in [false, true] {
+        let mut schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::with_nullable(false)))
+            .column(
+                "unused",
+                if fixed {
+                    DataType::Int(IntType::new())
+                } else {
+                    DataType::Blob(BlobType::new())
+                },
+            )
+            .column("pt", DataType::Int(IntType::with_nullable(false)))
+            .column("op", DataType::VarChar(VarCharType::string_type()))
+            .partition_keys(["pt"])
+            .option("rowkind.field", "op")
+            .option("ignore-delete", "true")
+            .option("data-evolution.enabled", (!fixed).to_string())
+            .option("row-tracking.enabled", (!fixed).to_string());
+        if fixed {
+            schema = schema.option("bucket", "4").option("bucket-key", "id");
+        }
+        let (io, table) = memory_table(
+            "memory:/selected_transport",
+            TableSchema::new(0, &schema.build().unwrap()),
+        );
+        setup_dirs(&io, table.location()).await;
+        persist_table_schema(&io, table.location(), table.schema()).await;
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        writer.with_write_type(vec!["value".into()]).unwrap();
+        let transport = |values: Vec<Option<i32>>, kinds: Vec<&str>, 
extra_payload| {
+            let count = values.len();
+            let mut columns = vec![
+                ("value", Arc::new(Int32Array::from(values)) as ArrayRef),
+                (
+                    "pt",
+                    Arc::new(Int32Array::from(vec![Some(1); count])) as 
ArrayRef,
+                ),
+                (
+                    "op",
+                    Arc::new(arrow_array::StringArray::from(kinds)) as 
ArrayRef,
+                ),
+            ];
+            if fixed {
+                columns.push((
+                    "id",
+                    Arc::new(Int32Array::from(vec![10; count])) as ArrayRef,
+                ));
+            }
+            if extra_payload {
+                let unused: ArrayRef = if fixed {
+                    Arc::new(Int32Array::from(vec![99; count]))
+                } else {
+                    Arc::new(arrow_array::LargeBinaryArray::from(vec![
+                        b"bad".as_slice();
+                        count
+                    ]))
+                };
+                columns.push(("unused", unused));
+            }
+            RecordBatch::try_from_iter(columns).unwrap()
+        };
+        // Ignored events do not validate nested or omitted payload values, but
+        // the current selected NOT NULL fields still have their logical rule.
+        assert!(writer
+            .write_arrow_batch(&transport(vec![None], vec!["-D"], false))
+            .await
+            .is_err());
+        writer
+            .write_arrow_batch(&transport(vec![Some(1)], vec!["-D"], false))
+            .await
+            .unwrap();
+        writer
+            .with_write_type(vec!["value".into(), "pt".into()])
+            .unwrap();
+        writer.with_write_type(vec!["value".into()]).unwrap();
+        writer
+            .write_arrow_batch(&transport(
+                vec![Some(22), Some(33)],
+                vec!["+I", "-D"],
+                false,
+            ))
+            .await
+            .unwrap();
+        assert!(writer
+            .write_arrow_batch(&transport(vec![Some(99)], vec!["+I"], true))
+            .await
+            .is_err());
+        assert!(writer.with_write_type(vec!["id".into()]).is_err());
+        let messages = writer.prepare_commit().await.unwrap();
+        assert_eq!(
+            messages
+                .iter()
+                .flat_map(|m| &m.new_files)
+                .map(|file| file.row_count)
+                .sum::<i64>(),
+            1
+        );
+        assert!(messages
+            .iter()
+            .flat_map(|m| &m.new_files)
+            .all(|file| !file.file_name.ends_with(".blob")));
+        builder.new_commit().commit(messages).await.unwrap();
+        let mut read = table.new_read_builder();
+        read.with_projection(&["value", "pt"]).unwrap();
+        let plan = read.new_scan().plan().await.unwrap();
+        let batches: Vec<RecordBatch> = read
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 
1);
+        assert_eq!(
+            batches[0]
+                .column_by_name("value")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .value(0),
+            22
+        );
+        assert_eq!(
+            batches[0]
+                .column_by_name("pt")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .value(0),
+            1
+        );
+    }
+}
+
 #[tokio::test]
 async fn omitted_managed_blob_does_not_create_dedicated_files() {
     let table = table(true, false, true).await;
@@ -336,3 +544,59 @@ async fn selected_columns_validate_input_before_staging() {
         1
     );
 }
+
+#[tokio::test]
+async fn append_blob_consumer_is_captured_per_partition_across_prepares() {
+    use paimon::spec::BlobDescriptor;
+    use std::sync::Mutex;
+    let table = table(true, false, true).await;
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    let calls = Arc::new(Mutex::new(Vec::new()));
+    let consumer = |name: &'static str| {
+        let calls = calls.clone();
+        Arc::new(move |_: &str, descriptor: Option<&BlobDescriptor>| {
+            if descriptor.is_some() {
+                calls.lock().unwrap().push(name);
+            }
+            Ok(false)
+        }) as Arc<dyn paimon::spec::BlobConsumer>
+    };
+    let blob_batch = |partition| {
+        RecordBatch::try_from_iter([
+            ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+            (
+                "omitted",
+                Arc::new(arrow_array::LargeBinaryArray::from(vec![
+                    b"payload".as_slice()
+                ])) as ArrayRef,
+            ),
+            (
+                "pt",
+                Arc::new(Int32Array::from(vec![partition])) as ArrayRef,
+            ),
+        ])
+        .unwrap()
+    };
+    writer.with_blob_consumer(Some(consumer("first"))).unwrap();
+    writer.write_arrow_batch(&blob_batch(1)).await.unwrap();
+    writer.with_blob_consumer(Some(consumer("second"))).unwrap();
+    writer.write_arrow_batch(&blob_batch(1)).await.unwrap();
+    writer.write_arrow_batch(&blob_batch(2)).await.unwrap();
+    let first = writer.prepare_commit().await.unwrap();
+    builder.new_commit().commit(first).await.unwrap();
+    writer.with_blob_consumer(None).unwrap();
+    writer.write_arrow_batch(&blob_batch(1)).await.unwrap();
+    writer.write_arrow_batch(&blob_batch(2)).await.unwrap();
+    writer.write_arrow_batch(&blob_batch(3)).await.unwrap();
+    let second = writer.prepare_commit().await.unwrap();
+    writer.close().await;
+    // Closing the writer cannot remove any successfully prepared payload.
+    builder.new_commit().commit(second).await.unwrap();
+    let mut received = calls.lock().unwrap().clone();
+    received.sort();
+    assert_eq!(
+        received,
+        vec!["first", "first", "first", "second", "second"]
+    );
+}

Reply via email to