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 3ae85d3f [core] Align write validation and file metadata with Java
(#1172)
3ae85d3f is described below
commit 3ae85d3fc4e56125dda33418ff86761a6e28c5a9
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 22:09:13 2026 +0800
[core] Align write validation and file metadata with Java (#1172)
---
bindings/python/src/arrow_input.rs | 170 +++++++++
bindings/python/src/write.rs | 81 ++++-
crates/paimon/src/catalog/partition_listing.rs | 7 +-
crates/paimon/src/common/date_time.rs | 18 +
crates/paimon/src/spec/data_file.rs | 76 ++++
crates/paimon/src/spec/partition_utils.rs | 134 ++++---
crates/paimon/src/table/data_file_writer.rs | 5 +-
crates/paimon/src/table/kv_file_writer.rs | 5 +-
crates/paimon/src/table/postpone_file_writer.rs | 80 +---
crates/paimon/src/table/row_kind_generator.rs | 16 +-
crates/paimon/src/table/table_commit.rs | 74 +++-
crates/paimon/src/table/table_write.rs | 107 ++++--
crates/paimon/tests/creation_time_write_test.rs | 405 +++++++++++++++++++++
.../paimon/tests/filtered_write_validation_test.rs | 337 +++++++++++++++++
14 files changed, 1320 insertions(+), 195 deletions(-)
diff --git a/bindings/python/src/arrow_input.rs
b/bindings/python/src/arrow_input.rs
index 18cb5427..c2bd7a4f 100644
--- a/bindings/python/src/arrow_input.rs
+++ b/bindings/python/src/arrow_input.rs
@@ -79,6 +79,44 @@ pub(crate) fn batch_from_pyarrow(value: &Bound<'_, PyAny>)
-> PyResult<RecordBat
.map_err(|e| PyValueError::new_err(e.to_string()))
}
+pub(crate) fn write_batch_from_pyarrow(value: &Bound<'_, PyAny>) ->
PyResult<RecordBatch> {
+ let schema = Schema::from_pyarrow_bound(&value.getattr("schema")?)?;
+ let mut fields = schema.fields().to_vec();
+ let mut nullable_transport = false;
+ for (index, field) in schema.fields().iter().enumerate() {
+ if !field.is_nullable()
+ && value
+ .call_method1("column", (index,))?
+ .getattr("null_count")?
+ .extract::<usize>()?
+ > 0
+ {
+ fields[index] =
Arc::new(field.as_ref().clone().with_nullable(true));
+ nullable_transport = true;
+ }
+ }
+ if !nullable_transport {
+ return batch_from_pyarrow(value);
+ }
+
+ // PyArrow permits NULLs under a top-level NOT NULL transport field. A
+ // DELETE may legitimately omit non-key values, so retain those NULLs for
+ // core's declared-schema and per-event checks instead of rejecting them
+ // in Arrow's RecordBatch constructor. Nested fields stay unchanged.
+ let columns = value
+ .getattr("columns")?
+ .try_iter()?
+ .map(|column| array_from_pyarrow(&column?))
+ .collect::<PyResult<Vec<_>>>()?;
+ let rows = value.getattr("num_rows")?.extract::<usize>()?;
+ RecordBatch::try_new_with_options(
+ Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone())),
+ columns,
+ &RecordBatchOptions::default().with_row_count(Some(rows)),
+ )
+ .map_err(|e| PyValueError::new_err(e.to_string()))
+}
+
fn normalize_sparse_unions(data: ArrayData) -> Result<ArrayData, ArrowError> {
if !contains_sparse_union(data.data_type()) {
return Ok(data);
@@ -150,6 +188,138 @@ mod tests {
};
use arrow::buffer::{NullBuffer, ScalarBuffer};
use arrow::datatypes::{Field, UnionFields};
+ use pyo3::{ffi::c_str, types::PyDict};
+
+ #[test]
+ fn write_batch_import_preserves_nullable_transport_for_core() {
+ Python::attach(|py| {
+ let locals = PyDict::new(py);
+ py.run(
+ c_str!(
+ r#"
+import pyarrow as pa
+schema = pa.schema([
+ pa.field('id', pa.int32(), nullable=False),
+ pa.field('payload', pa.int32(), nullable=False, metadata={'field':
'value'}),
+ pa.field('nested', pa.struct([pa.field('child', pa.int32(),
nullable=False)])),
+], metadata={'scope': 'write'})
+batch = pa.RecordBatch.from_pylist([
+ {'id': 0, 'payload': 9, 'nested': {'child': 9}},
+ {'id': 1, 'payload': None, 'nested': {'child': 10}},
+ {'id': 2, 'payload': 42, 'nested': {'child': 11}},
+], schema=schema).slice(1, 2)
+"#
+ ),
+ None,
+ Some(&locals),
+ )
+ .unwrap();
+ let source = locals.get_item("batch").unwrap().unwrap();
+ let imported = write_batch_from_pyarrow(&source).unwrap();
+ assert_eq!(imported.num_rows(), 2);
+ let schema = imported.schema();
+ assert!(!schema.field(0).is_nullable());
+ assert!(schema.field(1).is_nullable());
+ assert_eq!(schema.metadata().get("scope").unwrap(), "write");
+ assert_eq!(schema.field(1).metadata().get("field").unwrap(),
"value");
+ let values = imported
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ assert!(values.is_null(0));
+ assert_eq!(values.value(1), 42);
+ let nested = imported
+ .column(2)
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap();
+ assert!(!nested.fields()[0].is_nullable());
+ let children = nested
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ assert_eq!(children.values().as_ref(), &[10, 11]);
+ // The input's declared schema stays untouched; the table
declaration
+ // remains the authority for the core's per-event nullability
checks.
+ let original =
Schema::from_pyarrow_bound(&source.getattr("schema").unwrap()).unwrap();
+ assert!(!original.field(1).is_nullable());
+ });
+ }
+
+ #[test]
+ fn write_batch_import_leaves_nested_value_validation_to_core() {
+ use arrow::array::{ListArray, MapArray};
+
+ Python::attach(|py| {
+ let locals = PyDict::new(py);
+ py.run(
+ c_str!(
+ r#"
+import pyarrow as pa
+child = pa.field('value', pa.int32(), nullable=False)
+schema = pa.schema([
+ pa.field('id', pa.int32(), nullable=False),
+ pa.field('items', pa.list_(child)),
+ pa.field('row', pa.struct([child])),
+ pa.field('map', pa.map_(pa.string(), child)),
+])
+batch = pa.RecordBatch.from_pylist([
+ {'id': 0, 'items': [0], 'row': {'value': 0}, 'map': [('key', 0)]},
+ {'id': 1, 'items': [None], 'row': {'value': None}, 'map': [('key', None)]},
+ {'id': 2, 'items': [42], 'row': {'value': 42}, 'map': [('key', 42)]},
+], schema=schema).slice(1, 2)
+"#
+ ),
+ None,
+ Some(&locals),
+ )
+ .unwrap();
+ let source = locals.get_item("batch").unwrap().unwrap();
+ let imported = write_batch_from_pyarrow(&source).unwrap();
+ let original =
Schema::from_pyarrow_bound(&source.getattr("schema").unwrap()).unwrap();
+ assert_eq!(imported.schema().as_ref(), &original);
+ assert_eq!(imported.num_rows(), 2);
+ let items = imported
+ .column(1)
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .unwrap();
+ assert!(!items.value_type().is_nested());
+ assert!(items.value(0).is_null(0));
+ assert_eq!(
+ items
+ .value(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 42
+ );
+ let row = imported
+ .column(2)
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap();
+ assert!(row.column(0).is_null(0));
+ let map = imported
+ .column(3)
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .unwrap();
+ assert!(map.value(0).column(1).is_null(0));
+ assert_eq!(
+ map.value(1)
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 42
+ );
+ });
+ }
fn input() -> ArrayData {
UnionArray::try_new(
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 0ad668c5..3e68e80e 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -863,7 +863,7 @@ impl WriteState {
batch: &Bound<'_, PyAny>,
bucket: Option<i32>,
) -> PyResult<()> {
- let batch = RecordBatch::from_pyarrow_bound(batch)?;
+ let batch = crate::arrow_input::write_batch_from_pyarrow(batch)?;
let inner = self
.inner
.as_mut()
@@ -1651,6 +1651,85 @@ impl PyCommitMessage {
}
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use paimon::catalog::Identifier;
+ use paimon::io::FileIOBuilder;
+ use paimon::spec::{IntType, Schema, TableSchema, VarCharType};
+ use pyo3::ffi::c_str;
+
+ #[test]
+ fn ignored_delete_null_payload_reaches_core_nullability_checks() {
+ Python::attach(|py| {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("op", DataType::VarChar(VarCharType::string_type()))
+ .column("payload",
DataType::Int(IntType::with_nullable(false)))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("rowkind.field", "op")
+ .option("ignore-delete", "true")
+ .build()
+ .unwrap();
+ let table = Arc::new(Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "nullable_transport"),
+ "memory:/nullable-transport".into(),
+ TableSchema::new(0, &schema),
+ None,
+ ));
+ let locals = PyDict::new(py);
+ for large_string in [false, true] {
+ locals.set_item("large_string", large_string).unwrap();
+ for (kind, id, payload, error_column) in [
+ ("-D", Some(1), None, None),
+ ("-U", Some(1), None, Some("payload")),
+ ("+I", Some(1), None, Some("payload")),
+ ("-D", None, Some(42), Some("id")),
+ ] {
+ locals.set_item("kind", kind).unwrap();
+ locals.set_item("row_id", id).unwrap();
+ locals.set_item("payload", payload).unwrap();
+ py.run(
+ c_str!(
+ r#"
+import pyarrow as pa
+schema = pa.schema([
+ pa.field('id', pa.int32(), nullable=False),
+ pa.field('op', pa.large_string() if large_string else pa.string()),
+ pa.field('payload', pa.int32(), nullable=False),
+])
+batch = pa.RecordBatch.from_pylist([
+ {'id': row_id, 'op': kind, 'payload': payload},
+], schema=schema)
+"#
+ ),
+ None,
+ Some(&locals),
+ )
+ .unwrap();
+ let batch = locals.get_item("batch").unwrap().unwrap();
+ let mut state =
WriteContext::new(table.clone()).new_write(false).unwrap();
+ let result = state.write_arrow(py, &batch, None);
+ if let Some(column) = error_column {
+ let error = result.unwrap_err().to_string();
+ assert!(
+ error.contains(&format!(
+ "Cannot write null to non-null
column({column})"
+ )),
+ "{error}"
+ );
+ } else {
+ result.unwrap();
+ }
+ assert!(state.prepare_commit(py).unwrap().is_empty());
+ }
+ }
+ });
+ }
+}
+
#[pymethods]
impl PyCommitMessage {
/// Export the Java `CommitMessageSerializer` v14 body (no version header).
diff --git a/crates/paimon/src/catalog/partition_listing.rs
b/crates/paimon/src/catalog/partition_listing.rs
index 5557cf3b..1fcdbfde 100644
--- a/crates/paimon/src/catalog/partition_listing.rs
+++ b/crates/paimon/src/catalog/partition_listing.rs
@@ -129,12 +129,7 @@ fn aggregate_partition_entries_in_zone(
agg.file_size_in_bytes =
agg.file_size_in_bytes.wrapping_add(file.file_size);
agg.file_count += 1;
agg.total_buckets = entry.total_buckets();
- if let Some(time) = file.creation_time {
- let millis = time.timestamp_millis();
- let time =
-
crate::common::date_time::local_to_utc_seconds(millis.div_euclid(1000), zone)?
- * 1000
- + millis.rem_euclid(1000);
+ if let Some(time) = file.creation_time_epoch_millis_in_zone(zone)? {
agg.last_file_creation_time = Some(
agg.last_file_creation_time
.map_or(time, |old| old.max(time)),
diff --git a/crates/paimon/src/common/date_time.rs
b/crates/paimon/src/common/date_time.rs
index e80b1dfc..44fdcbe5 100644
--- a/crates/paimon/src/common/date_time.rs
+++ b/crates/paimon/src/common/date_time.rs
@@ -19,6 +19,24 @@
use chrono::{Datelike, NaiveDateTime, Timelike};
+/// Java Timestamp.now stores system-local wall fields, without a time zone.
+/// The UTC label is only the carrier used by manifest timestamp-millis fields.
+pub(crate) fn current_local_timestamp() -> chrono::DateTime<chrono::Utc> {
+ // DataFileMeta persists TIMESTAMP(3), as Java now().toMillisTimestamp().
+ let now = chrono::Utc::now();
+ let now = now
+ .with_nanosecond(now.timestamp_subsec_millis() * 1_000_000)
+ .unwrap();
+ local_timestamp(now, &jiff::tz::TimeZone::system())
+}
+
+pub(crate) fn local_timestamp(
+ instant: chrono::DateTime<chrono::Utc>,
+ zone: &jiff::tz::TimeZone,
+) -> chrono::DateTime<chrono::Utc> {
+ instant +
chrono::Duration::seconds(i64::from(local_offset(instant.timestamp(), zone)))
+}
+
/// Format millis-since-midnight to `HH:mm:ss[.f...]` using Java
`DateTimeUtils` semantics.
///
/// Matches Java `DateTimeUtils.formatTimestampMillis(time, precision)`:
diff --git a/crates/paimon/src/spec/data_file.rs
b/crates/paimon/src/spec/data_file.rs
index 97e2d2d0..842daae6 100644
--- a/crates/paimon/src/spec/data_file.rs
+++ b/crates/paimon/src/spec/data_file.rs
@@ -76,6 +76,8 @@ pub struct DataFileMeta {
deserialize_with = "from_millis_opt",
default
)]
+ /// Local wall-clock TIMESTAMP(3), matching Java Timestamp.now().
+ /// The UTC label carries the serialized fields; it does not identify an
instant.
pub creation_time: Option<DateTime<Utc>>,
#[serde(rename = "_DELETE_ROW_COUNT")]
// rowCount = add_row_count + delete_row_count.
@@ -250,6 +252,25 @@ fn read_compact_millis_as_utc(
}
impl DataFileMeta {
+ /// Resolve the stored local creation timestamp in the system time zone,
+ /// matching Java DataFileMeta.creationTimeEpochMillis().
+ pub fn creation_time_epoch_millis(&self) -> crate::Result<Option<i64>> {
+ self.creation_time_epoch_millis_in_zone(&jiff::tz::TimeZone::system())
+ }
+
+ pub(crate) fn creation_time_epoch_millis_in_zone(
+ &self,
+ zone: &jiff::tz::TimeZone,
+ ) -> crate::Result<Option<i64>> {
+ self.creation_time
+ .map(|time| {
+ let millis = time.timestamp_millis();
+
crate::common::date_time::local_to_utc_seconds(millis.div_euclid(1000), zone)
+ .map(|seconds| seconds * 1000 + millis.rem_euclid(1000))
+ })
+ .transpose()
+ }
+
/// Placeholder for `row_count` when the producer cannot know how many rows
/// a file holds.
///
@@ -682,6 +703,61 @@ mod tests {
}
}
+ #[test]
+ fn creation_time_is_local_wall_time_in_every_wire_format() {
+ use crate::common::date_time::local_timestamp;
+ for (zone, instant, expected_wall) in [
+ (
+ "UTC",
+ "1969-12-31T23:59:59.999Z",
+ "1969-12-31T23:59:59.999Z",
+ ),
+ (
+ "Asia/Shanghai",
+ "2024-01-02T04:34:56.789Z",
+ "2024-01-02T12:34:56.789Z",
+ ),
+ (
+ "America/New_York",
+ "2024-01-02T17:34:56.789Z",
+ "2024-01-02T12:34:56.789Z",
+ ),
+ (
+ "America/New_York",
+ "2024-11-03T05:30:00.456Z",
+ "2024-11-03T01:30:00.456Z",
+ ),
+ ] {
+ let zone = jiff::tz::TimeZone::get(zone).unwrap();
+ let instant = instant.parse::<DateTime<Utc>>().unwrap();
+ let wall = expected_wall.parse::<DateTime<Utc>>().unwrap();
+ let mut metadata = sample_full_data_file_meta();
+ metadata.creation_time = Some(local_timestamp(instant, &zone));
+ assert_eq!(metadata.creation_time, Some(wall));
+ for decoded in [
+
serde_json::from_slice::<DataFileMeta>(&serde_json::to_vec(&metadata).unwrap())
+ .unwrap(),
+ DataFileMeta::from_serialized_row_data(
+ &metadata.to_serialized_row_data().unwrap(),
+ DataFileMetaRowLayout::CURRENT,
+ )
+ .unwrap(),
+ ] {
+ assert_eq!(decoded.creation_time, Some(wall));
+ assert_eq!(
+ decoded.creation_time_epoch_millis_in_zone(&zone).unwrap(),
+ Some(instant.timestamp_millis())
+ );
+ }
+ }
+ assert_eq!(
+ sample_minimal_data_file_meta()
+ .creation_time_epoch_millis()
+ .unwrap(),
+ None
+ );
+ }
+
#[test]
fn data_file_meta_row_data_round_trips() {
for meta in [
diff --git a/crates/paimon/src/spec/partition_utils.rs
b/crates/paimon/src/spec/partition_utils.rs
index 85c1bf3a..f9da9b8b 100644
--- a/crates/paimon/src/spec/partition_utils.rs
+++ b/crates/paimon/src/spec/partition_utils.rs
@@ -408,9 +408,10 @@ fn format_partition_value(
s.to_string()
}
- DataType::Binary(_) | DataType::VarBinary(_) if !legacy => {
- // Java's BinaryToStringCastRule wraps the raw bytes in a
- // BinaryString, whose toString decodes them as UTF-8.
+ DataType::Binary(_) | DataType::VarBinary(_) => {
+ // Java uses BinaryToStringCastRule even for legacy names: a raw
+ // byte[].toString() contains a new allocation identity on each
read.
+ // BinaryString.toString decodes the bytes as UTF-8.
let value = decode_java_utf8(row.get_binary(pos)?);
if is_java_whitespace_only(&value) {
return Ok(default_partition_name.to_string());
@@ -467,9 +468,7 @@ fn format_partition_value(
}
}
- DataType::Binary(_)
- | DataType::VarBinary(_)
- | DataType::Variant(_)
+ DataType::Variant(_)
| DataType::Blob(_)
| DataType::Array(_)
| DataType::Map(_)
@@ -1435,14 +1434,6 @@ mod tests {
#[test]
fn test_unsupported_types() {
- // Legacy binary uses byte[].toString() in Java, which contains an
- // allocation-specific identity hash and cannot name a stable path.
- assert_single_partition_err(
- "data",
- DataType::Binary(BinaryType::new(10).unwrap()),
- |b| b.write_int(0, 0),
- true,
- );
// Array
assert_single_partition_err(
"arr",
@@ -1505,70 +1496,75 @@ mod tests {
#[test]
fn test_binary_partition_uses_utf8_cast_and_path_escaping() {
- assert_single_partition(
- "bin",
- DataType::Binary(BinaryType::new(16).unwrap()),
- |b| b.write_string(0, "a/b=c"),
- "bin=a%2Fb%3Dc/",
- false,
- );
- assert_single_partition(
- "bin",
- DataType::VarBinary(VarBinaryType::new(16).unwrap()),
- |b| b.write_string(0, "汉字"),
- "bin=汉字/",
- false,
- );
- assert_single_partition(
- "bin",
- DataType::VarBinary(VarBinaryType::new(16).unwrap()),
- |b| b.write_string(0, " "),
- "bin=__DEFAULT_PARTITION__/",
- false,
- );
- }
-
- #[test]
- fn test_binary_partition_matches_java_whitespace() {
- // Modern JVMs (Java 11/17) treat U+001C as whitespace but U+180E as a
- // format character, and never fold U+00A0 or U+2007. Rust str::trim
- // differs for controls and NBSP, so we match Java explicitly.
- for (value, expected) in [
- (b"\x1c".as_slice(), "bin=__DEFAULT_PARTITION__/"),
- // U+180E is whitespace only on legacy JDK 8; a modern JVM keeps
it.
- ("\u{180E}".as_bytes(), "bin=\u{180E}/"),
- ("\u{00A0}".as_bytes(), "bin=\u{00A0}/"),
- ("\u{2007}".as_bytes(), "bin=\u{2007}/"),
- ] {
+ for legacy in [false, true] {
+ assert_single_partition(
+ "bin",
+ DataType::Binary(BinaryType::new(16).unwrap()),
+ |b| b.write_string(0, "a/b=c"),
+ "bin=a%2Fb%3Dc/",
+ legacy,
+ );
assert_single_partition(
"bin",
DataType::VarBinary(VarBinaryType::new(16).unwrap()),
- |b| b.write_bytes(0, value),
- expected,
- false,
+ |b| b.write_string(0, "汉字"),
+ "bin=汉字/",
+ legacy,
);
+ assert_single_partition(
+ "bin",
+ DataType::VarBinary(VarBinaryType::new(16).unwrap()),
+ |b| b.write_string(0, " "),
+ "bin=__DEFAULT_PARTITION__/",
+ legacy,
+ );
+ }
+ }
+
+ #[test]
+ fn test_binary_partition_matches_java_whitespace() {
+ // Modern JVMs treat U+001C as whitespace but U+180E as a format
+ // character, and never fold U+00A0 or U+2007. Rust str::trim differs
+ // for controls and NBSP, so match Java for both naming modes.
+ for legacy in [false, true] {
+ for (value, expected) in [
+ (b"\x1c".as_slice(), "bin=__DEFAULT_PARTITION__/"),
+ ("\u{180E}".as_bytes(), "bin=\u{180E}/"),
+ ("\u{00A0}".as_bytes(), "bin=\u{00A0}/"),
+ ("\u{2007}".as_bytes(), "bin=\u{2007}/"),
+ ] {
+ assert_single_partition(
+ "bin",
+ DataType::VarBinary(VarBinaryType::new(16).unwrap()),
+ |b| b.write_bytes(0, value),
+ expected,
+ legacy,
+ );
+ }
}
}
#[test]
fn test_binary_partition_matches_java_malformed_utf8() {
- // Expected replacements were checked against JDK 8 new String(bytes,
UTF_8).
- for (value, expected) in [
- (b"\xED\xA0\x80".as_slice(), "bin=\u{FFFD}/"),
- (b"\xED\xA0".as_slice(), "bin=\u{FFFD}/"),
- (b"\xED\xA0A".as_slice(), "bin=\u{FFFD}A/"),
- (b"\xED\xA0\xFF".as_slice(), "bin=\u{FFFD}\u{FFFD}/"),
- (b"\xE0\x80\x80".as_slice(), "bin=\u{FFFD}\u{FFFD}\u{FFFD}/"),
- (b"\xE2\x82".as_slice(), "bin=\u{FFFD}/"),
- (b"\xE2(\xA1".as_slice(), "bin=\u{FFFD}(\u{FFFD}/"),
- ] {
- assert_single_partition(
- "bin",
- DataType::VarBinary(VarBinaryType::new(16).unwrap()),
- |b| b.write_bytes(0, value),
- expected,
- false,
- );
+ // Expected replacements were checked against new String(bytes, UTF_8).
+ for legacy in [false, true] {
+ for (value, expected) in [
+ (b"\xED\xA0\x80".as_slice(), "bin=\u{FFFD}/"),
+ (b"\xED\xA0".as_slice(), "bin=\u{FFFD}/"),
+ (b"\xED\xA0A".as_slice(), "bin=\u{FFFD}A/"),
+ (b"\xED\xA0\xFF".as_slice(), "bin=\u{FFFD}\u{FFFD}/"),
+ (b"\xE0\x80\x80".as_slice(), "bin=\u{FFFD}\u{FFFD}\u{FFFD}/"),
+ (b"\xE2\x82".as_slice(), "bin=\u{FFFD}/"),
+ (b"\xE2(\xA1".as_slice(), "bin=\u{FFFD}(\u{FFFD}/"),
+ ] {
+ assert_single_partition(
+ "bin",
+ DataType::VarBinary(VarBinaryType::new(16).unwrap()),
+ |b| b.write_bytes(0, value),
+ expected,
+ legacy,
+ );
+ }
}
}
diff --git a/crates/paimon/src/table/data_file_writer.rs
b/crates/paimon/src/table/data_file_writer.rs
index 099b9dea..bb7bea28 100644
--- a/crates/paimon/src/table/data_file_writer.rs
+++ b/crates/paimon/src/table/data_file_writer.rs
@@ -36,7 +36,6 @@ use crate::spec::stats::BinaryTableStats;
use crate::spec::{CoreOptions, DataField, DataFileMeta, EMPTY_SERIALIZED_ROW};
use crate::Result;
use arrow_array::RecordBatch;
-use chrono::Utc;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::task::JoinSet;
@@ -452,6 +451,8 @@ impl DataFileWriter {
if let Some(name) = sidecar_name {
meta.extra_files.push(name);
}
+ // Java constructs the result after all auxiliary outputs finish.
+ meta.creation_time =
Some(crate::common::date_time::current_local_timestamp());
Ok(meta)
})
}
@@ -600,7 +601,7 @@ impl DataFileWriter {
schema_id,
level: 0,
extra_files: vec![],
- creation_time: Some(Utc::now()),
+ creation_time: None,
delete_row_count: Some(0),
embedded_index: None,
file_source,
diff --git a/crates/paimon/src/table/kv_file_writer.rs
b/crates/paimon/src/table/kv_file_writer.rs
index cdd8b33c..02d35d64 100644
--- a/crates/paimon/src/table/kv_file_writer.rs
+++ b/crates/paimon/src/table/kv_file_writer.rs
@@ -52,7 +52,6 @@ use arrow_array::{Array, BooleanArray, Int64Array, Int8Array,
RecordBatch, UInt3
use arrow_ord::sort::{lexsort_to_indices, SortColumn, SortOptions};
use arrow_row::{RowConverter, SortField};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as
ArrowSchema};
-use chrono::Utc;
use std::collections::HashMap;
use std::sync::Arc;
@@ -774,7 +773,7 @@ impl KeyValueFileWriter {
schema_id: self.config.schema_id,
level: 0,
extra_files: vec![],
- creation_time: Some(Utc::now()),
+ creation_time: None,
delete_row_count: Some(write.delete_row_count),
embedded_index: None,
file_source: Some(0), // FileSource.APPEND
@@ -829,6 +828,8 @@ impl KeyValueFileWriter {
)
.await?;
+ // Java creates file metadata after the index and Blob references
finish.
+ meta.creation_time =
Some(crate::common::date_time::current_local_timestamp());
Ok(meta)
}
diff --git a/crates/paimon/src/table/postpone_file_writer.rs
b/crates/paimon/src/table/postpone_file_writer.rs
index 449d4471..952e6ca8 100644
--- a/crates/paimon/src/table/postpone_file_writer.rs
+++ b/crates/paimon/src/table/postpone_file_writer.rs
@@ -40,9 +40,7 @@ use
crate::table::data_file_index_writer::{DataFileIndexWriter, FileIndexOptions
use crate::table::kv_file_writer::build_physical_schema;
use crate::Result;
use arrow_array::{Int64Array, Int8Array, RecordBatch};
-use chrono::{DateTime, Utc};
use std::sync::Arc;
-use tokio::task::JoinSet;
/// Configuration for [`PostponeFileWriter`].
pub(crate) struct PostponeWriteConfig {
@@ -84,12 +82,8 @@ pub(crate) struct PostponeFileWriter {
current_file_name: Option<String>,
current_file_path: Option<DataFilePath>,
current_stats: PostponeFileStats,
- /// Timestamp captured when the current file was opened (used for
deterministic replay order).
- current_file_creation_time: DateTime<Utc>,
written_files: Vec<DataFileMeta>,
created_paths: Vec<String>,
- /// Background file close tasks spawned during rolling.
- in_flight_closes: JoinSet<Result<DataFileMeta>>,
resources: Option<ResourceContext>,
}
@@ -130,10 +124,8 @@ impl PostponeFileWriter {
current_file_name: None,
current_file_path: None,
current_stats: PostponeFileStats::default(),
- current_file_creation_time: Utc::now(),
written_files: Vec::new(),
created_paths: Vec::new(),
- in_flight_closes: JoinSet::new(),
resources: None,
})
}
@@ -220,11 +212,13 @@ impl PostponeFileWriter {
self.current_stats
.add_batch(&batch, &self.config, start_seq, end_seq)?;
- // Roll to a new file if target size is reached — close in background
+ // Java creates metadata at result/close time and replays pending files
+ // by that time. Finish closes in arrival order, including equal-millis
+ // ties, so asynchronous completion cannot reorder one writer's rows.
if self.current_writer.as_ref().unwrap().num_bytes() as i64 >=
self.config.target_file_size
|| self.current_stats.row_count >= self.config.target_file_row_num
{
- self.roll_file();
+ self.close_current_file().await?;
}
// Flush row group if in-progress buffer exceeds write_buffer_size
@@ -245,7 +239,6 @@ impl PostponeFileWriter {
self.current_blob_references = None;
self.current_file_name = None;
self.current_file_path = None;
- while self.in_flight_closes.join_next().await.is_some() {}
for path in self.created_paths.drain(..) {
let _ = self.file_io.delete_file(&path).await;
}
@@ -263,62 +256,11 @@ impl PostponeFileWriter {
async fn finish(&mut self) -> Result<Vec<DataFileMeta>> {
self.close_current_file().await?;
- while let Some(result) = self.in_flight_closes.join_next().await {
- let meta = result.map_err(|e| crate::Error::DataInvalid {
- message: format!("Background file close task panicked: {e}"),
- source: None,
- })??;
- self.written_files.push(meta);
- }
- // Java replays postpone files by millisecond creation time, retaining
- // manifest order for ties. Background closes must not reorder
arrivals.
- self.written_files
- .sort_by_key(|file| file.min_sequence_number);
self.managed_blob_writer.prepare_commit().await?;
self.created_paths.clear();
Ok(std::mem::take(&mut self.written_files))
}
- /// Spawn the current writer's close in the background for non-blocking
rolling.
- fn roll_file(&mut self) {
- let writer = match self.current_writer.take() {
- Some(w) => w,
- None => return,
- };
- let file_name = self.current_file_name.take().unwrap();
- let index = self.current_index.take();
- let blob_references = self.current_blob_references.take();
- let file_io = self.file_io.clone();
- let location = self.current_file_path.take().unwrap();
- let bucket_dir = location.parent().to_string();
- let threshold = self
- .config
- .file_index_options
- .as_ref()
- .map(|options| options.in_manifest_threshold);
- let stats = std::mem::take(&mut self.current_stats);
- let schema_id = self.config.schema_id;
- // Capture creation_time from when the file was opened, not when the
async close finishes.
- // Java's postpone compaction sorts by creationTime for replay order.
- let creation_time = self.current_file_creation_time;
-
- self.in_flight_closes.spawn(async move {
- let file_size = writer.close().await?.file_size as i64;
- let mut meta = build_meta(file_name, file_size, stats, schema_id,
creation_time);
- ManagedBlobReferences::finish(
- blob_references,
- &file_io,
- &location.path,
- &bucket_dir,
- &mut meta,
- )
- .await?;
- meta.external_path = location.external_path;
- write_index(index, threshold, &file_io, &bucket_dir, &mut
meta).await?;
- Ok(meta)
- });
- }
-
async fn open_new_file(&mut self, user_schema: arrow_schema::SchemaRef) ->
Result<()> {
let index = self
.config
@@ -373,7 +315,6 @@ impl PostponeFileWriter {
self.current_file_name = Some(file_name);
self.current_file_path = Some(location);
self.current_stats = PostponeFileStats::default();
- self.current_file_creation_time = Utc::now();
Ok(())
}
@@ -388,13 +329,7 @@ impl PostponeFileWriter {
let stats = std::mem::take(&mut self.current_stats);
let file_size = writer.close().await?.file_size as i64;
- let mut meta = build_meta(
- file_name,
- file_size,
- stats,
- self.config.schema_id,
- self.current_file_creation_time,
- );
+ let mut meta = build_meta(file_name, file_size, stats,
self.config.schema_id);
let location = self.current_file_path.take().unwrap();
let bucket_dir = location.parent().to_string();
let threshold = self
@@ -412,6 +347,8 @@ impl PostponeFileWriter {
.await?;
meta.external_path = location.external_path;
write_index(index, threshold, &self.file_io, &bucket_dir, &mut
meta).await?;
+ // Java constructs the result after auxiliary files have also finished.
+ meta.creation_time =
Some(crate::common::date_time::current_local_timestamp());
self.written_files.push(meta);
Ok(())
}
@@ -498,7 +435,6 @@ fn build_meta(
file_size: i64,
stats: PostponeFileStats,
schema_id: i64,
- creation_time: DateTime<Utc>,
) -> DataFileMeta {
DataFileMeta {
file_name,
@@ -521,7 +457,7 @@ fn build_meta(
schema_id,
level: 0,
extra_files: vec![],
- creation_time: Some(creation_time),
+ creation_time: None,
delete_row_count: Some(stats.delete_row_count),
embedded_index: None,
file_source: Some(0), // FileSource.APPEND
diff --git a/crates/paimon/src/table/row_kind_generator.rs
b/crates/paimon/src/table/row_kind_generator.rs
index 07e89858..f037dabe 100644
--- a/crates/paimon/src/table/row_kind_generator.rs
+++ b/crates/paimon/src/table/row_kind_generator.rs
@@ -19,7 +19,7 @@
//!
//! Reference: Java `org.apache.paimon.table.sink.RowKindGenerator`.
-use arrow_array::{Array, RecordBatch, StringArray};
+use arrow_array::{Array, LargeStringArray, RecordBatch, StringArray};
use crate::spec::{RowKind, TableSchema};
@@ -51,13 +51,17 @@ impl RowKindGenerator {
source: None,
});
}
- let strings = col.as_any().downcast_ref::<StringArray>().ok_or_else(||
{
- crate::Error::DataInvalid {
+ let value = if let Some(strings) =
col.as_any().downcast_ref::<StringArray>() {
+ strings.value(row)
+ } else if let Some(strings) =
col.as_any().downcast_ref::<LargeStringArray>() {
+ strings.value(row)
+ } else {
+ return Err(crate::Error::DataInvalid {
message: "rowkind.field column must be
Utf8/String".to_string(),
source: None,
- }
- })?;
- RowKind::from_short_string(strings.value(row))
+ });
+ };
+ RowKind::from_short_string(value)
}
}
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index ff20f128..df9ec40a 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -3268,8 +3268,7 @@ impl TableCommit {
let file = entry.file();
let file_creation_time = file
- .creation_time
- .map(|t| t.timestamp_millis())
+ .creation_time_epoch_millis()?
.unwrap_or_else(|| current_time_millis() as i64);
let stats = match stats_map.entry(partition_bytes) {
@@ -3288,7 +3287,9 @@ impl TableCommit {
record_count: 0,
file_size_in_bytes: 0,
file_count: 0,
- last_file_creation_time: 0,
+ // Java starts with the first PartitionEntry, so
+ // pre-epoch creation times must remain negative.
+ last_file_creation_time: file_creation_time,
total_buckets: entry.total_buckets(),
})
}
@@ -4032,6 +4033,73 @@ mod tests {
TableCommit::new(table, "test-user".to_string())
}
+ #[test]
+ fn partition_creation_time_uses_java_local_zone_and_keeps_millis() {
+ const CHILD: &str = "PAIMON_CREATION_TIME_ZONE_CHILD";
+ if let Ok(zone) = std::env::var(CHILD) {
+ let commit = TableCommit::new(
+ test_table(&test_file_io(), "memory:/creation-time"),
+ "user".into(),
+ );
+ let cases: &[(&str, &str)] = match zone.as_str() {
+ "UTC" => &[
+ ("2024-01-02T12:34:56.789Z", "2024-01-02T12:34:56.789Z"),
+ ("1970-01-01T00:00:00.000Z", "1970-01-01T00:00:00.000Z"),
+ ("1969-12-31T23:59:59.999Z", "1969-12-31T23:59:59.999Z"),
+ ],
+ "Asia/Shanghai" => &[
+ ("2024-01-02T12:34:56.789Z", "2024-01-02T04:34:56.789Z"),
+ ("1970-01-01T07:59:59.999Z", "1969-12-31T23:59:59.999Z"),
+ ],
+ "America/New_York" => &[
+ ("2024-01-02T12:34:56.789Z", "2024-01-02T17:34:56.789Z"),
+ ("2024-03-10T02:30:00.123Z", "2024-03-10T07:30:00.123Z"),
+ ("2024-11-03T01:30:00.456Z", "2024-11-03T05:30:00.456Z"),
+ ],
+ _ => unreachable!(),
+ };
+ for (wall, expected) in cases {
+ let mut file = test_data_file("data.parquet", 1);
+ file.creation_time =
Some(wall.parse::<DateTime<Utc>>().unwrap());
+ let mut earlier = file.clone();
+ earlier.file_name = "earlier.parquet".into();
+ earlier.creation_time = file
+ .creation_time
+ .map(|wall| wall - chrono::Duration::milliseconds(1));
+ let mut entries = [
+ ManifestEntry::new(FileKind::Add, vec![], 0, 1, file, 2),
+ ManifestEntry::new(FileKind::Add, vec![], 0, 1, earlier,
2),
+ ];
+ for _ in 0..2 {
+ let stats =
commit.generate_partition_statistics(&entries).unwrap();
+ assert_eq!(stats[0].file_count, 2);
+ assert_eq!(stats[0].record_count, 2);
+ assert_eq!(
+ stats[0].last_file_creation_time,
+ expected
+ .parse::<DateTime<Utc>>()
+ .unwrap()
+ .timestamp_millis(),
+ "{zone} {wall}"
+ );
+ entries.reverse();
+ }
+ }
+ return;
+ }
+ for zone in ["UTC", "Asia/Shanghai", "America/New_York"] {
+ let output =
std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact",
"table::table_commit::tests::partition_creation_time_uses_java_local_zone_and_keeps_millis",
"--nocapture"])
+ .env(CHILD, zone).env("TZ", zone).output().unwrap();
+ assert!(
+ output.status.success(),
+ "{zone}: {}\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+ }
+
#[test]
fn partition_statistics_use_java_partition_values() {
use crate::spec::{DateType, DecimalType, Schema, VarBinaryType,
VarCharType};
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 8513c2be..7a5abef7 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -806,15 +806,11 @@ impl TableWrite {
}
pub(super) fn normalize_write_batch(&self, batch: &RecordBatch) ->
Result<Option<RecordBatch>> {
- let batch = self.validate_write_batch_schema(batch)?;
- // Java filters row kinds before extracting Blob values. Ignored rows
- // need neither valid descriptor bytes nor physical files.
+ let filtered = self.filter_write_input(batch)?;
+ let batch =
self.validate_write_batch_schema(filtered.as_ref().unwrap_or(batch))?;
+ // Filtering already precedes value normalization. Generate the system
+ // column now so every surviving PK batch has the same buffer schema.
let batch = self.generate_rowkind_batch(&batch)?;
- let selection = self.rowkind_selection(&batch)?;
- let batch = match &selection {
- Some(rows) => take_rows(&batch, rows)?,
- None => batch,
- };
if batch.num_rows() == 0 {
return Ok(None);
}
@@ -870,6 +866,75 @@ impl TableWrite {
Ok(Some(batch))
}
+ /// Java resolves the event and checks top-level nullability before its
+ /// RowKindFilter. Ignored rows never reach value extraction or nested
+ /// writers, so their child values must not be normalized or validated.
+ fn filter_write_input(&self, batch: &RecordBatch) ->
Result<Option<RecordBatch>> {
+ let Some(filter) = &self.row_kind_filter else {
+ return Ok(None);
+ };
+ if self.primary_key_indices.is_empty() {
+ return Ok(None);
+ }
+ // Reject malformed schemas even when every event would be ignored.
+ // Empty arrays check equivalent layouts without examining row values.
+
self.validate_write_batch_schema(&RecordBatch::new_empty(batch.schema()))?;
+ let kinds = if let Some(generator) = &self.row_kind_generator {
+ (0..batch.num_rows())
+ .map(|row| generator.generate(batch, row))
+ .collect::<Result<Vec<_>>>()?
+ } else if let Some(column) =
batch.column_by_name(VALUE_KIND_FIELD_NAME) {
+ column
+ .as_any()
+ .downcast_ref::<Int8Array>()
+ .unwrap()
+ .iter()
+ .map(|kind|
RowKind::from_value(kind.unwrap_or(RowKind::Insert.to_value())))
+ .collect::<Result<Vec<_>>>()?
+ } else {
+ vec![RowKind::Insert; batch.num_rows()]
+ };
+
+ let schema = self.table.schema();
+ let options = CoreOptions::new(schema.options());
+ // Java's deduplicate DELETE can omit non-key values. Sequence fields
+ // still identify the event; other engines and partition migrations
+ // require every declared top-level NOT NULL field.
+ let partial_delete = self.merge_engine == MergeEngine::Deduplicate
+ && schema
+ .partition_keys()
+ .iter()
+ .all(|field| schema.primary_keys().contains(field));
+ let sequence_fields = options.sequence_fields();
+ for (index, field) in self.write_schema.fields().iter().enumerate() {
+ if field.is_nullable() || batch.column(index).null_count() == 0 {
+ continue;
+ }
+ let delete_required = !partial_delete
+ || schema.primary_keys().contains(field.name())
+ || sequence_fields.contains(&field.name().as_str());
+ for (row, kind) in kinds.iter().enumerate() {
+ if batch.column(index).is_null(row) && (*kind !=
RowKind::Delete || delete_required)
+ {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Cannot write null to non-null
column({})", field.name()),
+ source: None,
+ });
+ }
+ }
+ }
+ let rows = kinds
+ .into_iter()
+ .enumerate()
+ .filter_map(|(row, kind)| filter.test(kind).then_some(row))
+ .collect::<Vec<_>>();
+ if rows.len() == batch.num_rows() {
+ Ok(None)
+ } else {
+ take_rows(batch, &rows).map(Some)
+ }
+ }
+
pub(super) async fn write_partition_bucket_batch(
&mut self,
partition: Vec<u8>,
@@ -1197,32 +1262,6 @@ impl TableWrite {
Self::add_per_row_value_kind_column(batch, kinds)
}
- /// Both generated row kinds and explicit caller-provided kinds use the
- /// same Java RowKindFilter before bucket assignment and changelog writing.
- fn rowkind_selection(&self, batch: &RecordBatch) ->
Result<Option<Vec<usize>>> {
- let Some(filter) = &self.row_kind_filter else {
- return Ok(None);
- };
- let Some(column) = batch.column_by_name(VALUE_KIND_FIELD_NAME) else {
- return Ok(None);
- };
- let kinds = column
- .as_any()
- .downcast_ref::<arrow_array::Int8Array>()
- .ok_or_else(|| crate::Error::DataInvalid {
- message: "_VALUE_KIND column must be Int8".to_string(),
- source: None,
- })?;
- let mut keep_rows = Vec::new();
- for (row, kind) in kinds.iter().enumerate() {
- let kind = RowKind::from_value(kind.unwrap_or(RowKind::Insert as
i8))?;
- if filter.test(kind) {
- keep_rows.push(row);
- }
- }
- Ok((keep_rows.len() != batch.num_rows()).then_some(keep_rows))
- }
-
fn add_per_row_value_kind_column(
batch: &RecordBatch,
value_kinds: Vec<i8>,
diff --git a/crates/paimon/tests/creation_time_write_test.rs
b/crates/paimon/tests/creation_time_write_test.rs
new file mode 100644
index 00000000..ce038cea
--- /dev/null
+++ b/crates/paimon/tests/creation_time_write_test.rs
@@ -0,0 +1,405 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::{ArrayRef, Int32Array, LargeBinaryArray, RecordBatch};
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs};
+use futures::TryStreamExt;
+use opendal::Operator;
+use paimon::spec::{BlobType, DataType, IntType, Manifest, ManifestList,
Schema, TableSchema};
+use paimon::{
+ catalog::Identifier,
+ io::{FileIOBuilder, FileIOProvider},
+ table::Table,
+};
+use std::sync::{Arc, Mutex};
+
+fn local_wall_millis() -> i64 {
+ // Chrono Local ignores TZ on Windows. Resolve the explicit test zone
+ // independently, so the same wall-time expectations cover every platform.
+ let zone = std::env::var("TZ")
+ .ok()
+ .and_then(|name| jiff::tz::TimeZone::get(&name).ok())
+ .unwrap_or_else(jiff::tz::TimeZone::system);
+ jiff::Timestamp::now()
+ .to_zoned(zone)
+ .datetime()
+ .to_zoned(jiff::tz::TimeZone::UTC)
+ .unwrap()
+ .timestamp()
+ .as_millisecond()
+}
+
+#[derive(Debug)]
+struct IndexOutputProvider {
+ operator: Operator,
+ index_output_times: Mutex<Vec<i64>>,
+}
+
+#[async_trait::async_trait]
+impl FileIOProvider for IndexOutputProvider {
+ async fn create(&self, path: &str) -> paimon::Result<(Operator, String)> {
+ let relative = path.strip_prefix("s3://bucket/").unwrap().to_string();
+ if relative.ends_with(".index") {
+ // Force a distinct millisecond at the actual auxiliary output,
+ // without relying on a storage delay or scheduling a sleep.
+ let before = local_wall_millis();
+ let deadline = std::time::Instant::now() +
std::time::Duration::from_secs(10);
+ let completed = loop {
+ let now = local_wall_millis();
+ if now > before {
+ break now;
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "wall clock did not advance"
+ );
+ tokio::task::yield_now().await;
+ };
+ self.index_output_times.lock().unwrap().push(completed);
+ }
+ Ok((self.operator.clone(), relative))
+ }
+}
+
+async fn check_creation_time_after_index_output(primary_key: bool) {
+ let provider = Arc::new(IndexOutputProvider {
+ operator:
Operator::from_config(opendal::services::MemoryConfig::default()).unwrap(),
+ index_output_times: Mutex::new(Vec::new()),
+ });
+ let io = FileIOBuilder::new("unused")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .option("file-index.bitmap.columns", "id")
+ .option("file-index.in-manifest-threshold", "0b");
+ if primary_key {
+ schema = schema.primary_key(["id"]).option("bucket", "1");
+ }
+ let path = "s3://bucket/creation-time-index";
+ let table = Table::new(
+ io.clone(),
+ Identifier::new("default", "creation_time_index"),
+ path.into(),
+ TableSchema::new(0, &schema.build().unwrap()),
+ None,
+ );
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let batch =
+ RecordBatch::try_from_iter([("id", Arc::new(Int32Array::from(vec![1,
2])) as ArrayRef)])
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let file = &messages[0].new_files[0];
+ let index_output_time = {
+ let index_times = provider.index_output_times.lock().unwrap();
+ assert_eq!(index_times.len(), 1);
+ index_times[0]
+ };
+ assert!(
+ file.creation_time.unwrap().timestamp_millis() >= index_output_time,
+ "creation time must be assigned after the auxiliary index output: {:?}
vs {}",
+ file.creation_time,
+ index_output_time,
+ );
+ assert_eq!(file.extra_files, vec![format!("{}.index", file.file_name)]);
+ let index = format!("{path}/bucket-0/{}", file.extra_files[0]);
+ assert!(io.new_input(&index).unwrap().metadata().await.unwrap().size > 0);
+}
+
+#[tokio::test]
+async fn append_creation_time_is_assigned_after_index_output() {
+ check_creation_time_after_index_output(false).await;
+}
+
+#[tokio::test]
+async fn pk_creation_time_is_assigned_after_index_output() {
+ check_creation_time_after_index_output(true).await;
+}
+
+#[test]
+fn writers_persist_local_creation_times_in_all_data_and_changelog_files() {
+ const CHILD: &str = "PAIMON_WRITER_CREATION_TIME_ZONE_CHILD";
+ if std::env::var_os(CHILD).is_some() {
+ tokio::runtime::Runtime::new()
+ .unwrap()
+ .block_on(check_writers());
+ return;
+ }
+ for zone in ["Asia/Shanghai", "America/New_York"] {
+ let output =
std::process::Command::new(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+
"writers_persist_local_creation_times_in_all_data_and_changelog_files",
+ "--nocapture",
+ ])
+ .env(CHILD, "1")
+ .env("TZ", zone)
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "{zone}: {}\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+}
+
+async fn check_writers() {
+ for mode in [
+ "append",
+ "pk",
+ "input-changelog",
+ "postpone",
+ "dedicated-blob",
+ ] {
+ let mut schema = Schema::builder().column("id",
DataType::Int(IntType::new()));
+ let payload_type = if mode == "dedicated-blob" {
+ DataType::Blob(BlobType::new())
+ } else {
+ DataType::Int(IntType::new())
+ };
+ schema = schema
+ .column("payload", payload_type)
+ .option("file.format", "parquet");
+ if matches!(mode, "pk" | "input-changelog" | "postpone") {
+ schema = schema
+ .primary_key(["id"])
+ .option("bucket", if mode == "postpone" { "-2" } else { "1" });
+ }
+ if mode == "input-changelog" {
+ schema = schema
+ .option("changelog-producer", "input")
+ .option("changelog-file.format", "parquet");
+ }
+ if mode == "dedicated-blob" {
+ schema = schema
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .option("blob-field", "payload");
+ }
+ let path = format!("memory:/creation_time_{mode}");
+ let (io, table) = memory_table(&path, TableSchema::new(0,
&schema.build().unwrap()));
+ setup_dirs(&io, &path).await;
+ persist_table_schema(&io, &path, table.schema()).await;
+ let payload: ArrayRef = if mode == "dedicated-blob" {
+ Arc::new(LargeBinaryArray::from(vec![Some(b"blob".as_slice())]))
+ } else {
+ Arc::new(Int32Array::from(vec![10]))
+ };
+ let batch = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+ ("payload", payload),
+ ])
+ .unwrap();
+ let before = local_wall_millis();
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let after = local_wall_millis();
+ let files = messages
+ .iter()
+ .flat_map(|message|
message.new_files.iter().chain(&message.new_changelog_files))
+ .collect::<Vec<_>>();
+ assert!(!files.is_empty(), "{mode}");
+ for file in &files {
+ let wall = file.creation_time.unwrap();
+ assert!(
+ (before..=after).contains(&wall.timestamp_millis()),
+ "{mode}: {} outside local wall range {before}..={after}",
+ wall.timestamp_millis()
+ );
+ assert_eq!(
+ wall.timestamp_subsec_nanos() % 1_000_000,
+ 0,
+ "{mode}: creation time must have TIMESTAMP(3) precision"
+ );
+ assert!(file.creation_time_epoch_millis().unwrap().is_some());
+ }
+ if mode == "input-changelog" {
+ assert!(messages
+ .iter()
+ .any(|message| !message.new_changelog_files.is_empty()));
+ }
+ if mode == "dedicated-blob" {
+ assert!(files.iter().any(|file|
file.file_name.ends_with(".blob")));
+ }
+ let mut expected_times = messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .map(|file| file.creation_time)
+ .collect::<Vec<_>>();
+ expected_times.sort();
+ builder.new_commit().commit(messages).await.unwrap();
+ writer.close().await;
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ let manifest_dir = format!("{}/manifest", table.location());
+ let manifests = ManifestList::read(
+ &io,
+ &format!("{manifest_dir}/{}", snapshot.delta_manifest_list()),
+ )
+ .await
+ .unwrap();
+ let mut persisted_times = Vec::new();
+ for manifest in manifests {
+ persisted_times.extend(
+ Manifest::read(&io, &format!("{manifest_dir}/{}",
manifest.file_name()))
+ .await
+ .unwrap()
+ .iter()
+ .map(|entry| entry.file().creation_time),
+ );
+ }
+ persisted_times.sort();
+ assert_eq!(
+ persisted_times, expected_times,
+ "{mode}: commit must retain local wall time"
+ );
+ if mode == "postpone" {
+ // Pending files enter normal reads only after a fixed-bucket
rewrite.
+ // Their committed metadata must still have the same Java
timestamp.
+ continue;
+ }
+ let mut read = table.new_read_builder();
+ read.with_projection(&["id"]).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,
+ "{mode}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn postpone_creation_time_is_assigned_when_file_result_is_finished() {
+ use std::time::{Duration, Instant};
+
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "-2")
+ .build()
+ .unwrap();
+ let path = "memory:/postpone_creation_at_result";
+ let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+ setup_dirs(&io, path).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let batch = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+ ("payload", Arc::new(Int32Array::from(vec![10])) as ArrayRef),
+ ])
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ let opened = local_wall_millis();
+ // Advance one wall-clock millisecond after opening the file, without a
+ // sleep or any process-global time-zone change.
+ let started = Instant::now();
+ let before_finish = loop {
+ let current = local_wall_millis();
+ if current > opened {
+ break current;
+ }
+ assert!(
+ started.elapsed() < Duration::from_secs(2),
+ "wall clock did not advance"
+ );
+ tokio::task::yield_now().await;
+ };
+ let messages = writer.prepare_commit().await.unwrap();
+ let finished = local_wall_millis();
+ let file = &messages[0].new_files[0];
+ assert!(
+
(before_finish..=finished).contains(&file.creation_time.unwrap().timestamp_millis()),
+ "Postpone metadata must use result time, not the earlier file-open
time"
+ );
+ writer.close().await;
+}
+
+#[tokio::test]
+async fn postpone_rolling_results_keep_arrival_order_and_creation_times() {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "-2")
+ .option("target-file-row-num", "1")
+ .build()
+ .unwrap();
+ let path = "memory:/postpone_creation_roll_order";
+ let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+ setup_dirs(&io, path).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let mut windows = Vec::new();
+ for value in [30, 10, 20] {
+ let batch = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(Int32Array::from(vec![value])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let before = local_wall_millis();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ let after = local_wall_millis();
+ windows.push(before..=after);
+ }
+ let messages = writer.prepare_commit().await.unwrap();
+ let files = &messages[0].new_files;
+ assert_eq!(files.len(), 3);
+ let mut previous_time = i64::MIN;
+ for (index, (file, window)) in files.iter().zip(windows).enumerate() {
+ assert_eq!(file.min_sequence_number, index as i64);
+ assert_eq!(file.max_sequence_number, index as i64);
+ let time = file.creation_time.unwrap().timestamp_millis();
+ assert!(
+ window.contains(&time),
+ "Rolled file must get its timestamp before the next input batch"
+ );
+ assert!(
+ time >= previous_time,
+ "Java creation-time replay must preserve this writer's arrival
order"
+ );
+ previous_time = time;
+ }
+ writer.close().await;
+}
diff --git a/crates/paimon/tests/filtered_write_validation_test.rs
b/crates/paimon/tests/filtered_write_validation_test.rs
new file mode 100644
index 00000000..0a78cd0b
--- /dev/null
+++ b/crates/paimon/tests/filtered_write_validation_test.rs
@@ -0,0 +1,337 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::{
+ make_array, types::Int32Type, Array, ArrayRef, Int32Array, Int8Array,
LargeStringArray,
+ ListArray, MapArray, RecordBatch, StringArray, StructArray,
+};
+use arrow_schema::{DataType as ArrowType, Field, Schema as ArrowSchema};
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs};
+use futures::TryStreamExt;
+use paimon::spec::{
+ ArrayType, DataField, DataType, IntType, MapType, RowType, Schema,
TableSchema, VarCharType,
+};
+use std::sync::Arc;
+
+fn payload(mode: &str, valid: bool) -> (DataType, ArrayRef) {
+ let values = if valid {
+ vec![Some(10), Some(42)]
+ } else {
+ vec![None, Some(42)]
+ };
+ let (logical, actual): (_, ArrayRef) = if mode == "array" {
+ (
+
DataType::Array(ArrayType::new(DataType::Int(IntType::with_nullable(false)))),
+ Arc::new(ListArray::from_iter_primitive::<Int32Type, _, _>(
+ values.into_iter().map(|value| Some(vec![value])),
+ )),
+ )
+ } else if mode == "row" {
+ (
+ DataType::Row(RowType::new(vec![DataField::new(
+ 3,
+ "value".into(),
+ DataType::Int(IntType::with_nullable(false)),
+ )])),
+ Arc::new(StructArray::from(vec![(
+ Arc::new(Field::new("value", ArrowType::Int32, true)),
+ Arc::new(Int32Array::from(values)) as ArrayRef,
+ )])),
+ )
+ } else {
+ let entries = StructArray::from(vec![
+ (
+ Arc::new(Field::new("key", ArrowType::Utf8, false)),
+ Arc::new(StringArray::from(vec!["a", "b"])) as ArrayRef,
+ ),
+ (
+ Arc::new(Field::new("value", ArrowType::Int32, true)),
+ Arc::new(Int32Array::from(values)) as ArrayRef,
+ ),
+ ]);
+ (
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::Int(IntType::with_nullable(false)),
+ )),
+ Arc::new(
+ MapArray::try_new(
+ Arc::new(Field::new("entries",
entries.data_type().clone(), false)),
+ arrow_buffer::OffsetBuffer::new(vec![0, 1, 2].into()),
+ entries,
+ None,
+ false,
+ )
+ .unwrap(),
+ ),
+ )
+ };
+ // The Arrow C Data interface can carry declared NOT NULL children whose
+ // values do not satisfy that declaration, as PyArrow permits constructing.
+ let expected = paimon::arrow::paimon_type_to_arrow(&logical).unwrap();
+ let mut data = actual.to_data();
+ if let ArrowType::Map(entries, _) = &expected {
+ let entry_data = unsafe {
+ data.child_data()[0]
+ .clone()
+ .into_builder()
+ .data_type(entries.data_type().clone())
+ .build_unchecked()
+ };
+ data = unsafe {
+ data.into_builder()
+ .child_data(vec![entry_data])
+ .build_unchecked()
+ };
+ }
+ let data = unsafe {
data.into_builder().data_type(expected).build_unchecked() };
+ (logical, make_array(data))
+}
+
+async fn check_ignored_children(
+ mode: &str,
+ generated: bool,
+ large_string: bool,
+ bucket: Option<i32>,
+) {
+ let (payload_type, payload) = payload(mode, false);
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("op", DataType::VarChar(VarCharType::string_type()))
+ .column("payload", payload_type)
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("ignore-delete", "true");
+ if generated {
+ schema = schema.option("rowkind.field", "op");
+ } else {
+ schema = schema.option("changelog-producer", "input");
+ }
+ let path = "memory:/filtered-children";
+ let (io, table) = memory_table(path, TableSchema::new(0,
&schema.build().unwrap()));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ let op: ArrayRef = if large_string {
+ Arc::new(LargeStringArray::from(vec!["-D", "+I"]))
+ } else {
+ Arc::new(StringArray::from(vec!["-D", "+I"]))
+ };
+ let mut batch = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef),
+ ("op", op),
+ ("payload", payload),
+ ])
+ .unwrap();
+ if !generated {
+ let mut fields = batch.schema().fields().to_vec();
+ fields.push(Arc::new(Field::new("_VALUE_KIND", ArrowType::Int8,
false)));
+ let mut columns = batch.columns().to_vec();
+ columns.push(Arc::new(Int8Array::from(vec![3, 0])));
+ batch = RecordBatch::try_new(Arc::new(ArrowSchema::new(fields)),
columns).unwrap();
+ }
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow(&[batch], bucket).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(
+ messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .map(|file| file.row_count)
+ .sum::<i64>(),
+ 1
+ );
+ let files = messages
+ .iter()
+ .flat_map(|message|
message.new_files.iter().chain(&message.new_changelog_files))
+ .collect::<Vec<_>>();
+ assert_eq!(files.len(), if generated { 1 } else { 2 });
+ assert!(files.iter().all(|file| file.row_count == 1
+ && file.min_sequence_number == 0
+ && file.max_sequence_number == 0));
+ builder.new_commit().commit(messages).await.unwrap();
+ let read_builder = table.new_read_builder();
+ let plan = read_builder.new_scan().plan().await.unwrap();
+ let batches: Vec<_> = read_builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+ let ids = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ assert_eq!(ids.value(0), 2);
+}
+
+#[tokio::test]
+async fn ignored_nested_nulls_do_not_fail_writes() {
+ for mode in ["array", "row", "map"] {
+ for generated in [false, true] {
+ for large_string in [false, true] {
+ for bucket in [None, Some(0)] {
+ check_ignored_children(mode, generated, large_string,
bucket).await;
+ }
+ }
+ }
+ }
+}
+
+#[tokio::test]
+async fn surviving_nested_nulls_still_fail_writes() {
+ for mode in ["array", "row", "map"] {
+ let (logical, payload) = payload(mode, false);
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("op", DataType::VarChar(VarCharType::string_type()))
+ .column("payload", logical)
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("rowkind.field", "op")
+ .option("ignore-delete", "true")
+ .build()
+ .unwrap();
+ let (_, table) = memory_table("memory:/retained-children",
TableSchema::new(0, &schema));
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ let batch = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef),
+ (
+ "op",
+ Arc::new(StringArray::from(vec!["+I", "+I"])) as ArrayRef,
+ ),
+ ("payload", payload),
+ ])
+ .unwrap();
+ assert!(writer.write_arrow_batch(&batch).await.is_err(), "{mode}");
+ assert!(writer.prepare_commit().await.unwrap().is_empty());
+ }
+}
+
+#[tokio::test]
+async fn ignored_rows_keep_java_top_level_nullability_and_schema_checks() {
+ for engine in ["deduplicate", "partial-update", "aggregation"] {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("op", DataType::VarChar(VarCharType::string_type()))
+ .column("seq", DataType::Int(IntType::with_nullable(false)))
+ .column("payload", DataType::Int(IntType::with_nullable(false)))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("merge-engine", engine)
+ .option("sequence.field", "seq")
+ .option("rowkind.field", "op")
+ .option("ignore-delete", "true")
+ .build()
+ .unwrap();
+ let (_, table) = memory_table("memory:/filtered-nullability",
TableSchema::new(0, &schema));
+ for (kind, id, seq, payload, valid) in [
+ ("-D", Some(1), Some(1), None, engine == "deduplicate"),
+ ("-U", Some(1), Some(1), None, false),
+ ("-D", None, Some(1), Some(42), false),
+ ("-D", Some(1), None, Some(42), false),
+ ] {
+ let batch = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![id])) as ArrayRef),
+ ("op", Arc::new(StringArray::from(vec![kind])) as ArrayRef),
+ ("seq", Arc::new(Int32Array::from(vec![seq])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(Int32Array::from(vec![payload])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ let result = writer.write_arrow_batch(&batch).await;
+ assert_eq!(
+ result.is_ok(),
+ valid,
+ "{engine} {kind} {id:?} {seq:?}: {result:?}"
+ );
+ assert!(writer.prepare_commit().await.unwrap().is_empty());
+ }
+ let wrong = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+ ("op", Arc::new(StringArray::from(vec!["-D"])) as ArrayRef),
+ ("seq", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(StringArray::from(vec!["wrong type"])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ assert!(writer.write_arrow_batch(&wrong).await.is_err());
+ assert!(writer.prepare_commit().await.unwrap().is_empty());
+ }
+}
+
+#[tokio::test]
+async fn empty_and_filtered_rowkind_inputs_preserve_transport_validation() {
+ for generated in [false, true] {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("op", DataType::VarChar(VarCharType::string_type()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("ignore-delete", "true");
+ if generated {
+ schema = schema.option("rowkind.field", "op");
+ }
+ let (_, table) = memory_table(
+ "memory:/empty-filtered",
+ TableSchema::new(0, &schema.build().unwrap()),
+ );
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ let input = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+ (
+ "op",
+ Arc::new(LargeStringArray::from(vec!["-D"])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ if generated {
+ writer
+ .write_arrow_batch(&RecordBatch::new_empty(input.schema()))
+ .await
+ .unwrap();
+ writer.write_arrow_batch(&input).await.unwrap();
+ }
+ for misplaced in [false, true] {
+ let kind: ArrayRef = if misplaced {
+ Arc::new(Int8Array::from(vec![3]))
+ } else {
+ Arc::new(Int32Array::from(vec![3]))
+ };
+ let mut columns = vec![
+ ("id", input.column(0).clone()),
+ ("op", input.column(1).clone()),
+ ];
+ columns.insert(if misplaced { 0 } else { 2 }, ("_VALUE_KIND",
kind));
+ let wrong = RecordBatch::try_from_iter(columns).unwrap();
+ assert!(writer.write_arrow_batch(&wrong).await.is_err());
+ }
+ assert!(writer.prepare_commit().await.unwrap().is_empty());
+ }
+}