This is an automated email from the ASF dual-hosted git repository.
fresh-borzoni pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 9f3e8778 [rust] Support paimon key encoder. (#579)
9f3e8778 is described below
commit 9f3e87781a4ddad70954e1abb882ae9fc6d40ad8
Author: Hongshun Wang <[email protected]>
AuthorDate: Tue Jun 2 20:37:45 2026 +0800
[rust] Support paimon key encoder. (#579)
---
crates/fluss/src/client/table/lookup.rs | 69 ++-
crates/fluss/src/client/table/upsert.rs | 15 +-
crates/fluss/src/metadata/table.rs | 13 +
crates/fluss/src/row/encode/mod.rs | 65 ++-
crates/fluss/src/row/encode/paimon_key_encoder.rs | 162 ++++++
crates/fluss/src/row/mod.rs | 1 +
crates/fluss/src/row/paimon/mod.rs | 20 +
.../src/row/paimon/paimon_binary_row_writer.rs | 552 +++++++++++++++++++++
crates/fluss/src/rpc/api_key.rs | 12 +-
crates/fluss/src/rpc/server_connection.rs | 4 +-
crates/fluss/tests/integration/kv_table.rs | 331 +++++++++++-
11 files changed, 1191 insertions(+), 53 deletions(-)
diff --git a/crates/fluss/src/client/table/lookup.rs
b/crates/fluss/src/client/table/lookup.rs
index 8bf2d0bb..a1bc1f72 100644
--- a/crates/fluss/src/client/table/lookup.rs
+++ b/crates/fluss/src/client/table/lookup.rs
@@ -303,14 +303,21 @@ impl TableLookup {
let lookup_row_type = row_type.project_with_field_names(primary_keys)?;
let physical_primary_keys =
self.table_info.get_physical_primary_keys().to_vec();
- let primary_key_encoder =
- KeyEncoderFactory::of(&lookup_row_type, &physical_primary_keys,
&data_lake_format)?;
-
- let bucket_key_encoder = if self.table_info.is_default_bucket_key() {
+ let kv_format_version =
self.table_info.get_table_config().get_kv_format_version()?;
+ let is_default_bucket_key = self.table_info.is_default_bucket_key();
+ let primary_key_encoder = KeyEncoderFactory::of_primary_key_encoder(
+ &lookup_row_type,
+ &physical_primary_keys,
+ &data_lake_format,
+ kv_format_version,
+ is_default_bucket_key,
+ )?;
+
+ let bucket_key_encoder = if is_default_bucket_key {
None
} else {
let bucket_keys = self.table_info.get_bucket_keys().to_vec();
- Some(KeyEncoderFactory::of(
+ Some(KeyEncoderFactory::of_bucket_key_encoder(
&lookup_row_type,
&bucket_keys,
&data_lake_format,
@@ -451,8 +458,37 @@ impl TablePrefixLookup {
let lookup_row_type =
row_type.project_with_field_names(&self.lookup_column_names)?;
let bucket_keys = self.table_info.get_bucket_keys().to_vec();
- let prefix_key_encoder =
- KeyEncoderFactory::of(&lookup_row_type, &bucket_keys,
&data_lake_format)?;
+ let kv_format_version =
self.table_info.get_table_config().get_kv_format_version()?;
+ let is_default_bucket_key = self.table_info.is_default_bucket_key();
+
+ // The bytes produced here are sent to the server to perform a
+ // lexicographic byte-prefix match against the stored primary keys, so
+ // the encoder must follow the primary key encoder rules (row-level
+ // byte-comparable). The encoded fields are the bucket key columns,
+ // which `validate_prefix_lookup` guarantees to be a strict prefix of
+ // the physical primary keys.
+ let prefix_lookup_key_encoder =
KeyEncoderFactory::of_primary_key_encoder(
+ &lookup_row_type,
+ &bucket_keys,
+ &data_lake_format,
+ kv_format_version,
+ is_default_bucket_key,
+ )?;
+
+ // Bucket id must stay consistent with the bucket id computed by the
+ // downstream lake, so when the bucket key differs from the primary
+ // key we need a separate lake-aligned encoder. When they are the
+ // same, the prefix-lookup key encoder already produces the right
+ // bytes and we fall back to it (None).
+ let bucket_key_encoder = if is_default_bucket_key ||
data_lake_format.is_none() {
+ None
+ } else {
+ Some(KeyEncoderFactory::of_bucket_key_encoder(
+ &lookup_row_type,
+ &bucket_keys,
+ &data_lake_format,
+ )?)
+ };
let partition_getter = if self.table_info.is_partitioned() {
Some(PartitionGetter::new(
@@ -471,7 +507,8 @@ impl TablePrefixLookup {
metadata: self.metadata,
lookup_client: self.lookup_client,
bucketing_function,
- prefix_key_encoder,
+ prefix_lookup_key_encoder,
+ bucket_key_encoder,
partition_getter,
num_buckets,
schema_ctx,
@@ -574,7 +611,13 @@ pub struct PrefixKeyLookuper {
metadata: Arc<Metadata>,
lookup_client: Arc<LookupClient>,
bucketing_function: Box<dyn BucketingFunction>,
- prefix_key_encoder: Box<dyn KeyEncoder>,
+ /// Encodes the lookup row into the prefix bytes sent to the server for
+ /// byte-prefix matching against stored primary keys.
+ prefix_lookup_key_encoder: Box<dyn KeyEncoder>,
+ /// Optional lake-aligned encoder used solely to compute the bucket id.
+ /// `None` when the bucket key equals the primary key, in which case the
+ /// prefix lookup key bytes are reused for bucketing.
+ bucket_key_encoder: Option<Box<dyn KeyEncoder>>,
partition_getter: Option<PartitionGetter>,
num_buckets: i32,
schema_ctx: LookupSchemaCtx,
@@ -582,7 +625,11 @@ pub struct PrefixKeyLookuper {
impl PrefixKeyLookuper {
pub async fn lookup(&mut self, row: &dyn InternalRow) ->
Result<LookupResult> {
- let prefix_bytes = self.prefix_key_encoder.encode_key(row)?;
+ let prefix_bytes = self.prefix_lookup_key_encoder.encode_key(row)?;
+ let bk_bytes = match &mut self.bucket_key_encoder {
+ Some(encoder) => encoder.encode_key(row)?,
+ None => prefix_bytes.clone(),
+ };
let partition_id = if let Some(ref partition_getter) =
self.partition_getter {
let partition_name = partition_getter.get_partition(row)?;
@@ -604,7 +651,7 @@ impl PrefixKeyLookuper {
let bucket_id = self
.bucketing_function
- .bucketing(&prefix_bytes, self.num_buckets)?;
+ .bucketing(&bk_bytes, self.num_buckets)?;
let table_id = self.table_info.get_table_id();
let table_bucket = TableBucket::new_with_partition(table_id,
partition_id, bucket_id);
diff --git a/crates/fluss/src/client/table/upsert.rs
b/crates/fluss/src/client/table/upsert.rs
index 52ec37b3..8a5547a7 100644
--- a/crates/fluss/src/client/table/upsert.rs
+++ b/crates/fluss/src/client/table/upsert.rs
@@ -143,9 +143,18 @@ impl UpsertWriterFactory {
&partial_update_columns,
)?;
- let primary_key_encoder = KeyEncoderFactory::of(row_type,
physical_pks, data_lake_format)?;
- let bucket_key_encoder = if !table_info.is_default_bucket_key() {
- Some(KeyEncoderFactory::of(
+ let kv_format_version =
table_info.get_table_config().get_kv_format_version()?;
+ let is_default_bucket_key = table_info.is_default_bucket_key();
+
+ let primary_key_encoder = KeyEncoderFactory::of_primary_key_encoder(
+ row_type,
+ physical_pks,
+ data_lake_format,
+ kv_format_version,
+ is_default_bucket_key,
+ )?;
+ let bucket_key_encoder = if !is_default_bucket_key {
+ Some(KeyEncoderFactory::of_bucket_key_encoder(
row_type,
table_info.get_bucket_keys(),
data_lake_format,
diff --git a/crates/fluss/src/metadata/table.rs
b/crates/fluss/src/metadata/table.rs
index 390bdbfc..35d251d7 100644
--- a/crates/fluss/src/metadata/table.rs
+++ b/crates/fluss/src/metadata/table.rs
@@ -1172,6 +1172,19 @@ impl TableConfig {
kv_format.parse().map_err(Into::into)
}
+ /// Returns the KV-format version of the table, defaulting to `1` when the
+ /// `table.kv.format-version` property is absent. Matches Java's
+ /// `TableConfig.getKvFormatVersion().orElse(1)` semantics.
+ pub fn get_kv_format_version(&self) -> Result<i32> {
+ const DEFAULT: i32 = 1;
+ match self.properties.get("table.kv.format-version") {
+ Some(v) => v.parse::<i32>().map_err(|e| {
+ Error::invalid_table(format!("Invalid table.kv.format-version
{v:?}: {e}"))
+ }),
+ None => Ok(DEFAULT),
+ }
+ }
+
pub fn get_log_format(&self) -> Result<LogFormat> {
// TODO: Consolidate configurations logic, constants, defaults in a
single place
const DEFAULT_LOG_FORMAT: &str = "ARROW";
diff --git a/crates/fluss/src/row/encode/mod.rs
b/crates/fluss/src/row/encode/mod.rs
index 16a540eb..5f4c67ca 100644
--- a/crates/fluss/src/row/encode/mod.rs
+++ b/crates/fluss/src/row/encode/mod.rs
@@ -17,11 +17,13 @@
mod compacted_key_encoder;
mod compacted_row_encoder;
+mod paimon_key_encoder;
use crate::error::{Error, Result};
use crate::metadata::{DataLakeFormat, KvFormat, RowType};
use crate::row::encode::compacted_key_encoder::CompactedKeyEncoder;
use crate::row::encode::compacted_row_encoder::CompactedRowEncoder;
+use crate::row::encode::paimon_key_encoder::PaimonKeyEncoder;
use crate::row::{Datum, InternalRow};
use bytes::Bytes;
@@ -34,6 +36,61 @@ pub trait KeyEncoder: Send + Sync {
pub struct KeyEncoderFactory;
impl KeyEncoderFactory {
+ /// Create a key encoder for the primary key, mirroring Java's
+ /// `KeyEncoder.ofPrimaryKeyEncoder`.
+ ///
+ /// Use this encoder when encoding the primary key for KV writes/lookups,
+ /// or when encoding a prefix-lookup key (which is a strict prefix of the
+ /// primary key and must share its byte layout to support lexicographic
+ /// prefix matching).
+ ///
+ /// - `kv_format_version == 2` with `is_default_bucket_key == false`
+ /// (i.e. the primary key differs from the bucket key) →
+ /// `CompactedKeyEncoder::create_key_encoder` regardless of lake format
+ /// (primary key must be decodable by the server without lake-specific
+ /// format awareness).
+ /// - Otherwise (`kv_format_version == 1`, or `kv_format_version == 2`
+ /// with `is_default_bucket_key == true` so the primary key matches the
+ /// bucket key) → align with the lake format via `Self::of`, producing
+ /// the same physical layout as the bucket key.
+ /// - Any other `kv_format_version` → [`Error::UnsupportedOperation`].
+ pub fn of_primary_key_encoder(
+ row_type: &RowType,
+ key_fields: &[String],
+ data_lake_format: &Option<DataLakeFormat>,
+ kv_format_version: i32,
+ is_default_bucket_key: bool,
+ ) -> Result<Box<dyn KeyEncoder>> {
+ match kv_format_version {
+ 1 => Self::of(row_type, key_fields, data_lake_format),
+ 2 if is_default_bucket_key => Self::of(row_type, key_fields,
data_lake_format),
+ 2 => Ok(Box::new(CompactedKeyEncoder::create_key_encoder(
+ row_type, key_fields,
+ )?)),
+ v => Err(Error::UnsupportedOperation {
+ message: format!("Unsupported kv format version: {v}"),
+ }),
+ }
+ }
+
+ /// Create a key encoder for the bucket key, mirroring Java's
+ /// `KeyEncoder.ofBucketKeyEncoder`.
+ ///
+ /// Use this encoder when computing the bucket id for a row. The bucket
+ /// key encoding is bound to the configured data lake format (delegating
+ /// to `Self::of`) because the resulting bucket id must stay consistent
+ /// with the bucket id computed by the downstream lake (e.g. Paimon /
+ /// Lance / Iceberg); otherwise the same row would land in different
+ /// buckets on the Fluss side and on the lake side, breaking lake-tiering
+ /// semantics.
+ pub fn of_bucket_key_encoder(
+ row_type: &RowType,
+ key_fields: &[String],
+ data_lake_format: &Option<DataLakeFormat>,
+ ) -> Result<Box<dyn KeyEncoder>> {
+ Self::of(row_type, key_fields, data_lake_format)
+ }
+
/// Create a key encoder to encode the key bytes of the input row.
/// # Arguments
/// * `row_type` - the row type of the input row
@@ -42,15 +99,15 @@ impl KeyEncoderFactory {
///
/// # Returns
/// key encoder
- pub fn of(
+ fn of(
row_type: &RowType,
key_fields: &[String],
data_lake_format: &Option<DataLakeFormat>,
) -> Result<Box<dyn KeyEncoder>> {
match data_lake_format {
- Some(DataLakeFormat::Paimon) => Err(Error::UnsupportedOperation {
- message: "KeyEncoder for Paimon format is not yet
implemented".to_string(),
- }),
+ Some(DataLakeFormat::Paimon) => {
+ Ok(Box::new(PaimonKeyEncoder::new(row_type, key_fields)?))
+ }
Some(DataLakeFormat::Lance) =>
Ok(Box::new(CompactedKeyEncoder::create_key_encoder(
row_type, key_fields,
)?)),
diff --git a/crates/fluss/src/row/encode/paimon_key_encoder.rs
b/crates/fluss/src/row/encode/paimon_key_encoder.rs
new file mode 100644
index 00000000..5eeb569d
--- /dev/null
+++ b/crates/fluss/src/row/encode/paimon_key_encoder.rs
@@ -0,0 +1,162 @@
+// 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 bytes::Bytes;
+
+use crate::error::Error::IllegalArgument;
+use crate::error::Result;
+use crate::metadata::RowType;
+use crate::row::binary::ValueWriter;
+use crate::row::encode::KeyEncoder;
+use crate::row::field_getter::FieldGetter;
+use crate::row::paimon::PaimonBinaryRowWriter;
+use crate::row::{Datum, InternalRow};
+
+/// Rust port of Java's `org.apache.fluss.row.encode.paimon.PaimonKeyEncoder`.
+///
+/// Encodes a set of key columns of a Fluss `InternalRow` into Paimon's
+/// BinaryRow layout via [`PaimonBinaryRowWriter`]. The key's row type is a
+/// projection of the original `row_type` to just the `keys` (same column order
+/// as `keys`), and each key column is written into its projection position.
+pub struct PaimonKeyEncoder {
+ field_getters: Vec<FieldGetter>,
+ field_encoders: Vec<ValueWriter>,
+ writer: PaimonBinaryRowWriter,
+}
+
+impl PaimonKeyEncoder {
+ /// Construct a Paimon key encoder for `keys` drawn from `row_type`.
+ pub fn new(row_type: &RowType, keys: &[String]) -> Result<Self> {
+ let mut field_getters: Vec<FieldGetter> =
Vec::with_capacity(keys.len());
+ let mut field_encoders: Vec<ValueWriter> =
Vec::with_capacity(keys.len());
+
+ for key in keys {
+ let idx = row_type
+ .get_field_index(key)
+ .ok_or_else(|| IllegalArgument {
+ message: format!("Field {key:?} not found in input row
type {row_type:?}"),
+ })?;
+ let data_type = row_type.fields()[idx].data_type();
+ // Validate Paimon-supported field type (rejects ARRAY/MAP/ROW).
+ let value_writer =
PaimonBinaryRowWriter::create_value_writer(data_type)?;
+ field_getters.push(FieldGetter::create(data_type, idx));
+ field_encoders.push(value_writer);
+ }
+
+ Ok(Self {
+ writer: PaimonBinaryRowWriter::new(keys.len()),
+ field_getters,
+ field_encoders,
+ })
+ }
+}
+
+impl KeyEncoder for PaimonKeyEncoder {
+ fn encode_key(&mut self, row: &dyn InternalRow) -> Result<Bytes> {
+ use crate::row::binary::BinaryWriter;
+
+ self.writer.reset();
+ self.writer.write_change_type_insert();
+
+ for (proj_pos, (getter, encoder)) in self
+ .field_getters
+ .iter()
+ .zip(self.field_encoders.iter())
+ .enumerate()
+ {
+ let datum: Datum = getter.get_field(row)?;
+ encoder.write_value(&mut self.writer, proj_pos, &datum)?;
+ }
+
+ Ok(self.writer.to_bytes())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::metadata::DataTypes;
+ use crate::row::{Datum, GenericRow};
+
+ #[test]
+ fn encode_single_int_key_has_insert_header() {
+ let row_type = RowType::with_data_types_and_field_names(
+ vec![DataTypes::int(), DataTypes::string()],
+ vec!["pk", "other"],
+ );
+ let mut encoder =
+ PaimonKeyEncoder::new(&row_type,
&["pk".to_string()]).expect("construct encoder");
+
+ let row = GenericRow::from_data(vec![Datum::from(42i32),
Datum::from("hi")]);
+ let bytes = encoder.encode_key(&row).unwrap();
+
+ // 8 bytes null-bits + 8 bytes field slot
+ assert_eq!(bytes.len(), 16);
+ // change-type INSERT = 0 at byte 0
+ assert_eq!(bytes[0], 0);
+ // int 42 in little-endian in the field slot
+ assert_eq!(&bytes[8..12], &42_i32.to_le_bytes());
+ }
+
+ #[test]
+ fn encode_multi_field_key() {
+ let row_type = RowType::with_data_types_and_field_names(
+ vec![DataTypes::string(), DataTypes::int(), DataTypes::string()],
+ vec!["other", "pk1", "pk2"],
+ );
+ let mut encoder = PaimonKeyEncoder::new(&row_type,
&["pk1".to_string(), "pk2".to_string()])
+ .expect("construct encoder");
+
+ let row = GenericRow::from_data(vec![
+ Datum::from("ignored"),
+ Datum::from(7i32),
+ Datum::from("hi"),
+ ]);
+ let bytes = encoder.encode_key(&row).unwrap();
+
+ // 8 bytes null-bits + 2 * 8 bytes field slots = 24
+ assert_eq!(bytes.len(), 24);
+ // slot 0 -> int 7
+ assert_eq!(&bytes[8..12], &7_i32.to_le_bytes());
+ // slot 1 -> "hi" inlined; bytes[16..18] = "hi", bytes[23] = 0x82
+ assert_eq!(&bytes[16..18], b"hi");
+ assert_eq!(bytes[23], 0x82);
+ }
+
+ #[test]
+ fn encode_reuses_buffer_across_rows() {
+ let row_type =
RowType::with_data_types_and_field_names(vec![DataTypes::int()], vec!["pk"]);
+ let mut encoder = PaimonKeyEncoder::new(&row_type,
&["pk".to_string()]).unwrap();
+
+ let row_a = GenericRow::from_data(vec![Datum::from(1i32)]);
+ let row_b = GenericRow::from_data(vec![Datum::from(1i32)]);
+ let a = encoder.encode_key(&row_a).unwrap().to_vec();
+ let b = encoder.encode_key(&row_b).unwrap().to_vec();
+ assert_eq!(a, b);
+ }
+
+ #[test]
+ fn encode_missing_key_field_errors() {
+ let row_type =
RowType::with_data_types_and_field_names(vec![DataTypes::int()], vec!["pk"]);
+ let res = PaimonKeyEncoder::new(&row_type, &["missing".to_string()]);
+ let err = match res {
+ Ok(_) => panic!("expected IllegalArgument"),
+ Err(e) => e,
+ };
+ assert!(err.to_string().contains("not found in input row type"));
+ }
+}
diff --git a/crates/fluss/src/row/mod.rs b/crates/fluss/src/row/mod.rs
index 5afd624d..7b483b4e 100644
--- a/crates/fluss/src/row/mod.rs
+++ b/crates/fluss/src/row/mod.rs
@@ -32,6 +32,7 @@ pub mod encode;
pub mod field_getter;
mod fixed_schema_decoder;
mod lookup_row;
+pub mod paimon;
mod projected_row;
mod row_decoder;
diff --git a/crates/fluss/src/row/paimon/mod.rs
b/crates/fluss/src/row/paimon/mod.rs
new file mode 100644
index 00000000..99981df2
--- /dev/null
+++ b/crates/fluss/src/row/paimon/mod.rs
@@ -0,0 +1,20 @@
+// 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 paimon_binary_row_writer;
+
+pub use paimon_binary_row_writer::PaimonBinaryRowWriter;
diff --git a/crates/fluss/src/row/paimon/paimon_binary_row_writer.rs
b/crates/fluss/src/row/paimon/paimon_binary_row_writer.rs
new file mode 100644
index 00000000..a9a89515
--- /dev/null
+++ b/crates/fluss/src/row/paimon/paimon_binary_row_writer.rs
@@ -0,0 +1,552 @@
+// 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 bytes::Bytes;
+
+use crate::error::{Error, Result};
+use crate::metadata::DataType;
+use crate::row::binary::{BinaryWriter, ValueWriter};
+use crate::row::datum::{TimestampLtz, TimestampNtz};
+use crate::row::{Decimal, FlussArray, FlussMap};
+
+/// Header size in bits (for the ChangeType byte at the front of the null
bitset).
+const HEADER_SIZE_IN_BITS: usize = 8;
+/// Maximum number of bytes that can be packed inline into a fixed 8-byte
field slot
+/// (Paimon's variable-length inline-encoding optimisation).
+const MAX_FIX_PART_DATA_SIZE: usize = 7;
+
+/// A Rust port of Java's
+/// `org.apache.fluss.row.encode.paimon.PaimonBinaryRowWriter`, encoding a
Fluss
+/// `InternalRow` using Paimon's BinaryRow layout.
+///
+/// Layout:
+/// - 1-byte ChangeType header at offset 0 (always `INSERT = 0` for key
encoding).
+/// - Null bitset (`nullBitsSizeInBytes` bytes), where bit `pos + 8` indicates
+/// field `pos` is null.
+/// - Fixed-length region: 8 bytes per field after the null bitset.
+/// - Variable-length tail growing on demand.
+///
+/// This writer implements the [`BinaryWriter`] trait so it can plug into
+/// [`ValueWriter::write_value`]. Because the trait API does not pass the
+/// position to the type-specific write methods, the writer keeps an internal
+/// `current_pos` cursor that is advanced after every field write (including
+/// `set_null_at`). The encoder is required to write fields in field order,
+/// matching the iteration order used by [`crate::row::encode`].
+pub struct PaimonBinaryRowWriter {
+ null_bits_size_in_bytes: usize,
+ fixed_size: usize,
+ buffer: Vec<u8>,
+ cursor: usize,
+ current_pos: usize,
+}
+
+impl PaimonBinaryRowWriter {
+ pub fn new(arity: usize) -> Self {
+ let null_bits_size_in_bytes = calculate_bit_set_width_in_bytes(arity);
+ let fixed_size = get_fixed_length_part_size(null_bits_size_in_bytes,
arity);
+ Self {
+ null_bits_size_in_bytes,
+ fixed_size,
+ buffer: vec![0u8; fixed_size],
+ cursor: fixed_size,
+ current_pos: 0,
+ }
+ }
+
+ /// Mirrors Java's `PaimonBinaryRowWriter.createFieldWriter`, returning the
+ /// Fluss [`ValueWriter`] for a Paimon-supported scalar field type.
+ /// ARRAY/MAP/ROW are explicitly rejected (Java's `default` branch throws).
+ pub fn create_value_writer(field_type: &DataType) -> Result<ValueWriter> {
+ match field_type {
+ DataType::Char(_)
+ | DataType::String(_)
+ | DataType::Boolean(_)
+ | DataType::Binary(_)
+ | DataType::Bytes(_)
+ | DataType::Decimal(_)
+ | DataType::TinyInt(_)
+ | DataType::SmallInt(_)
+ | DataType::Int(_)
+ | DataType::Date(_)
+ | DataType::Time(_)
+ | DataType::BigInt(_)
+ | DataType::Float(_)
+ | DataType::Double(_)
+ | DataType::Timestamp(_)
+ | DataType::TimestampLTz(_) =>
ValueWriter::create_value_writer(field_type, None),
+ _ => Err(Error::UnsupportedOperation {
+ message: format!("Unsupported type for Paimon BinaryRow
writer: {field_type:?}"),
+ }),
+ }
+ }
+
+ /// Writes the Paimon ChangeType byte at offset 0 (always `INSERT = 0`
+ /// for key encoding). Must be called immediately after [`Self::reset`].
+ pub fn write_change_type_insert(&mut self) {
+ self.buffer[0] = 0;
+ }
+
+ pub fn to_bytes(&self) -> Bytes {
+ Bytes::copy_from_slice(&self.buffer[..self.cursor])
+ }
+
+ #[allow(dead_code)]
+ pub fn buffer(&self) -> &[u8] {
+ &self.buffer[..self.cursor]
+ }
+
+ #[allow(dead_code)]
+ pub fn cursor(&self) -> usize {
+ self.cursor
+ }
+
+ fn field_offset(&self, pos: usize) -> usize {
+ self.null_bits_size_in_bytes + 8 * pos
+ }
+
+ fn set_null_bit(&mut self, pos: usize) {
+ let bit = pos + HEADER_SIZE_IN_BITS;
+ let byte_index = bit / 8;
+ let bit_in_byte = bit % 8;
+ self.buffer[byte_index] |= 1u8 << bit_in_byte;
+ }
+
+ fn put_long_le(&mut self, offset: usize, value: i64) {
+ self.buffer[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn put_int_le(&mut self, offset: usize, value: i32) {
+ self.buffer[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn put_short_le(&mut self, offset: usize, value: i16) {
+ self.buffer[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
+ }
+
+ /// Set `(offset << 32) | size` as a little-endian i64 at the field slot.
+ fn set_offset_and_size(&mut self, pos: usize, offset: usize, size: u64) {
+ let packed = ((offset as i64) << 32) | (size as i64);
+ let field_offset = self.field_offset(pos);
+ self.put_long_le(field_offset, packed);
+ }
+
+ /// Inline ≤ 7-byte payload into the 8-byte fixed slot using Paimon's
layout
+ /// (`firstByte = len | 0x80` in the high byte, data bytes packed
+ /// little-endian into the low bytes).
+ fn write_bytes_to_fix_len_part(&mut self, pos: usize, bytes: &[u8]) {
+ let len = bytes.len();
+ debug_assert!(len <= MAX_FIX_PART_DATA_SIZE);
+ let field_offset = self.field_offset(pos);
+ // Zero the slot first (in case we're reusing buffer positions on
reset).
+ for b in &mut self.buffer[field_offset..field_offset + 8] {
+ *b = 0;
+ }
+ // Data bytes occupy the low-order positions; first byte (len|0x80)
+ // sits at the high-order byte (index 7) thanks to little-endian
layout.
+ self.buffer[field_offset..field_offset + len].copy_from_slice(bytes);
+ self.buffer[field_offset + 7] = (len as u8) | 0x80;
+ }
+
+ fn ensure_capacity(&mut self, needed_size: usize) {
+ let length = self.cursor + needed_size;
+ if self.buffer.len() < length {
+ self.grow(length);
+ }
+ }
+
+ fn grow(&mut self, min_capacity: usize) {
+ let old_capacity = self.buffer.len();
+ let mut new_capacity = old_capacity + (old_capacity >> 1);
+ if new_capacity < min_capacity {
+ new_capacity = min_capacity;
+ }
+ self.buffer.resize(new_capacity, 0);
+ }
+
+ /// Zero out the padding region between `numBytes` and the next 8-byte
+ /// boundary at the current cursor (matches Java's `zeroOutPaddingBytes`).
+ fn zero_out_padding_bytes(&mut self, num_bytes: usize) {
+ if (num_bytes & 0x07) > 0 {
+ let aligned = (num_bytes >> 3) << 3;
+ // 8 bytes starting at cursor + aligned.
+ let off = self.cursor + aligned;
+ for b in &mut self.buffer[off..off + 8] {
+ *b = 0;
+ }
+ }
+ }
+
+ fn write_bytes_to_var_len_part(&mut self, pos: usize, bytes: &[u8]) {
+ let len = bytes.len();
+ let rounded_size = round_number_of_bytes_to_nearest_word(len);
+
+ self.ensure_capacity(rounded_size);
+ self.zero_out_padding_bytes(len);
+
+ self.buffer[self.cursor..self.cursor + len].copy_from_slice(bytes);
+
+ self.set_offset_and_size(pos, self.cursor, len as u64);
+ self.cursor += rounded_size;
+ }
+
+ fn write_bytes_internal(&mut self, pos: usize, bytes: &[u8]) {
+ if bytes.len() <= MAX_FIX_PART_DATA_SIZE {
+ self.write_bytes_to_fix_len_part(pos, bytes);
+ } else {
+ self.write_bytes_to_var_len_part(pos, bytes);
+ }
+ }
+}
+
+/// Number of bytes occupied by Paimon's null bitset for the given arity,
+/// including the 1-byte (8-bit) ChangeType header.
+fn calculate_bit_set_width_in_bytes(arity: usize) -> usize {
+ ((arity + 63 + HEADER_SIZE_IN_BITS) / 64) * 8
+}
+
+fn get_fixed_length_part_size(null_bits_size_in_bytes: usize, arity: usize) ->
usize {
+ null_bits_size_in_bytes + 8 * arity
+}
+
+fn round_number_of_bytes_to_nearest_word(num_bytes: usize) -> usize {
+ let remainder = num_bytes & 0x07;
+ if remainder == 0 {
+ num_bytes
+ } else {
+ num_bytes + (8 - remainder)
+ }
+}
+
+impl BinaryWriter for PaimonBinaryRowWriter {
+ fn reset(&mut self) {
+ self.cursor = self.fixed_size;
+ self.current_pos = 0;
+ // Zero the null-bits region only (Java semantics: field slots are not
+ // wiped because every field is overwritten in the next encode pass).
+ for b in &mut self.buffer[..self.null_bits_size_in_bytes] {
+ *b = 0;
+ }
+ }
+
+ fn set_null_at(&mut self, pos: usize) {
+ debug_assert_eq!(
+ pos, self.current_pos,
+ "Paimon writer expects in-order writes"
+ );
+ self.set_null_bit(pos);
+ let field_offset = self.field_offset(pos);
+ self.put_long_le(field_offset, 0);
+ self.current_pos = pos + 1;
+ }
+
+ fn write_boolean(&mut self, value: bool) {
+ let pos = self.current_pos;
+ let off = self.field_offset(pos);
+ self.put_long_le(off, 0);
+ self.buffer[off] = if value { 1 } else { 0 };
+ self.current_pos = pos + 1;
+ }
+
+ fn write_byte(&mut self, value: u8) {
+ let pos = self.current_pos;
+ let off = self.field_offset(pos);
+ self.put_long_le(off, 0);
+ self.buffer[off] = value;
+ self.current_pos = pos + 1;
+ }
+
+ fn write_bytes(&mut self, value: &[u8]) {
+ let pos = self.current_pos;
+ self.write_bytes_internal(pos, value);
+ self.current_pos = pos + 1;
+ }
+
+ fn write_char(&mut self, value: &str, _length: usize) {
+ // Paimon treats CHAR identically to STRING (BinaryString in Java).
+ self.write_string(value);
+ }
+
+ fn write_string(&mut self, value: &str) {
+ let pos = self.current_pos;
+ self.write_bytes_internal(pos, value.as_bytes());
+ self.current_pos = pos + 1;
+ }
+
+ fn write_short(&mut self, value: i16) {
+ let pos = self.current_pos;
+ let off = self.field_offset(pos);
+ // Zero the unused high bytes to keep the slot deterministic.
+ self.put_long_le(off, 0);
+ self.put_short_le(off, value);
+ self.current_pos = pos + 1;
+ }
+
+ fn write_int(&mut self, value: i32) {
+ let pos = self.current_pos;
+ let off = self.field_offset(pos);
+ self.put_long_le(off, 0);
+ self.put_int_le(off, value);
+ self.current_pos = pos + 1;
+ }
+
+ fn write_long(&mut self, value: i64) {
+ let pos = self.current_pos;
+ let off = self.field_offset(pos);
+ self.put_long_le(off, value);
+ self.current_pos = pos + 1;
+ }
+
+ fn write_float(&mut self, value: f32) {
+ let pos = self.current_pos;
+ let off = self.field_offset(pos);
+ self.put_long_le(off, 0);
+ self.buffer[off..off + 4].copy_from_slice(&value.to_le_bytes());
+ self.current_pos = pos + 1;
+ }
+
+ fn write_double(&mut self, value: f64) {
+ let pos = self.current_pos;
+ let off = self.field_offset(pos);
+ self.buffer[off..off + 8].copy_from_slice(&value.to_le_bytes());
+ self.current_pos = pos + 1;
+ }
+
+ fn write_binary(&mut self, bytes: &[u8], length: usize) {
+ let pos = self.current_pos;
+ let slice = &bytes[..length.min(bytes.len())];
+ self.write_bytes_internal(pos, slice);
+ self.current_pos = pos + 1;
+ }
+
+ fn write_decimal(&mut self, value: &Decimal, precision: u32) {
+ let pos = self.current_pos;
+ if Decimal::is_compact_precision(precision) {
+ // Compact: store unscaled long in the field slot (use write_long
+ // semantics but consume `current_pos` exactly once).
+ let unscaled = value.to_unscaled_long().unwrap_or(0);
+ let off = self.field_offset(pos);
+ self.put_long_le(off, unscaled);
+ } else {
+ // Non-compact: 16 bytes in variable region, set offset+size in
slot.
+ self.ensure_capacity(16);
+ // Zero the 16 bytes.
+ for b in &mut self.buffer[self.cursor..self.cursor + 16] {
+ *b = 0;
+ }
+ let bytes = value.to_unscaled_bytes();
+ debug_assert!(bytes.len() <= 16, "decimal unscaled bytes exceed
16");
+ self.buffer[self.cursor..self.cursor +
bytes.len()].copy_from_slice(&bytes);
+ self.set_offset_and_size(pos, self.cursor, bytes.len() as u64);
+ self.cursor += 16;
+ }
+ self.current_pos = pos + 1;
+ }
+
+ fn write_time(&mut self, value: i32, _precision: u32) {
+ // Java's Paimon writer uses writeInt for TIME.
+ self.write_int(value);
+ }
+
+ fn write_timestamp_ntz(&mut self, value: &TimestampNtz, precision: u32) {
+ let pos = self.current_pos;
+ if TimestampNtz::is_compact(precision) {
+ let off = self.field_offset(pos);
+ self.put_long_le(off, value.get_millisecond());
+ } else {
+ self.ensure_capacity(8);
+ self.put_long_le(self.cursor, value.get_millisecond());
+ self.set_offset_and_size(pos, self.cursor,
value.get_nano_of_millisecond() as u64);
+ self.cursor += 8;
+ }
+ self.current_pos = pos + 1;
+ }
+
+ fn write_timestamp_ltz(&mut self, value: &TimestampLtz, precision: u32) {
+ let pos = self.current_pos;
+ if TimestampLtz::is_compact(precision) {
+ let off = self.field_offset(pos);
+ self.put_long_le(off, value.get_epoch_millisecond());
+ } else {
+ self.ensure_capacity(8);
+ self.put_long_le(self.cursor, value.get_epoch_millisecond());
+ self.set_offset_and_size(pos, self.cursor,
value.get_nano_of_millisecond() as u64);
+ self.cursor += 8;
+ }
+ self.current_pos = pos + 1;
+ }
+
+ fn write_array(&mut self, _value: &FlussArray) {
+ // Java's PaimonBinaryRowWriter rejects ARRAY in its switch default.
+ // This should be unreachable because `create_value_writer` rejects
+ // ARRAY at encoder-construction time.
+ panic!("Paimon BinaryRow writer does not support ARRAY field types");
+ }
+
+ fn write_map(&mut self, _value: &FlussMap) {
+ panic!("Paimon BinaryRow writer does not support MAP field types");
+ }
+
+ fn complete(&mut self) {
+ // No-op: `to_bytes` already returns the trimmed-to-cursor buffer.
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::metadata::DataTypes;
+
+ fn writer_for_arity(arity: usize) -> PaimonBinaryRowWriter {
+ let mut w = PaimonBinaryRowWriter::new(arity);
+ w.write_change_type_insert();
+ w
+ }
+
+ #[test]
+ fn fixed_layout_sizes() {
+ // arity=4 -> nullBitsSizeInBytes = ceil((4 + 63 + 8)/64)*8 = 8
+ // fixedSize = 8 + 8*4 = 40
+ let w = PaimonBinaryRowWriter::new(4);
+ assert_eq!(w.null_bits_size_in_bytes, 8);
+ assert_eq!(w.fixed_size, 40);
+ assert_eq!(w.cursor, 40);
+ }
+
+ #[test]
+ fn write_int_in_slot_le() {
+ let mut w = writer_for_arity(1);
+ w.write_int(0x01020304);
+ let bytes = w.to_bytes();
+ // header byte 0 (INSERT) + null bits (zeros) total 8, then slot 8
bytes.
+ assert_eq!(bytes.len(), 16);
+ assert_eq!(bytes[0], 0x00);
+ // INT is written little-endian into bytes 8..12.
+ assert_eq!(&bytes[8..12], &0x01020304_i32.to_le_bytes());
+ // Remaining 4 bytes of the slot are zero.
+ assert_eq!(&bytes[12..16], &[0u8; 4]);
+ }
+
+ #[test]
+ fn write_short_string_inlined() {
+ let mut w = writer_for_arity(1);
+ w.write_string("hi"); // 2 bytes -> inline
+ let bytes = w.to_bytes();
+ assert_eq!(bytes.len(), 16);
+ // bytes[8..10] = "hi", bytes[15] = 2 | 0x80
+ assert_eq!(&bytes[8..10], b"hi");
+ assert_eq!(bytes[15], 0x82);
+ }
+
+ #[test]
+ fn write_long_string_in_var_part() {
+ let mut w = writer_for_arity(1);
+ let s = "this_is_a_long_string"; // 21 bytes -> var part, rounded to 24
+ w.write_string(s);
+ let bytes = w.to_bytes();
+ assert_eq!(bytes.len(), 16 + 24);
+ // Field slot at 8..16 is (offset=16 << 32) | 21
+ let packed = i64::from_le_bytes(bytes[8..16].try_into().unwrap());
+ let offset = (packed >> 32) as usize;
+ let size = (packed & 0xFFFFFFFF) as usize;
+ assert_eq!(offset, 16);
+ assert_eq!(size, 21);
+ assert_eq!(&bytes[offset..offset + size], s.as_bytes());
+ }
+
+ #[test]
+ fn set_null_at_marks_bit_and_zeroes_slot() {
+ let mut w = writer_for_arity(2);
+ w.set_null_at(0);
+ w.write_int(7);
+ let bytes = w.to_bytes();
+ // bit 0+8 = 8 -> null bitset byte index 1, bit 0 -> 0x01
+ assert_eq!(bytes[1], 0x01);
+ assert_eq!(&bytes[8..16], &[0u8; 8]);
+ assert_eq!(&bytes[16..20], &7_i32.to_le_bytes());
+ }
+
+ #[test]
+ fn reset_clears_null_bits_and_reuses_buffer() {
+ let mut w = PaimonBinaryRowWriter::new(2);
+ w.write_change_type_insert();
+ w.set_null_at(0);
+ w.write_int(7);
+ let first = w.to_bytes().to_vec();
+
+ // Re-encode the same row after reset; bytes should match.
+ w.reset();
+ w.write_change_type_insert();
+ w.set_null_at(0);
+ w.write_int(7);
+ let second = w.to_bytes().to_vec();
+
+ assert_eq!(first, second);
+ }
+
+ #[test]
+ fn buffer_grows_for_large_var_len() {
+ let mut w = PaimonBinaryRowWriter::new(1);
+ w.write_change_type_insert();
+ let big: Vec<u8> = (0..200u8).collect();
+ w.write_bytes(&big);
+ let bytes = w.to_bytes();
+ // var part rounded up: 200 -> 200 (already multiple of 8)
+ assert_eq!(bytes.len(), 16 + 200);
+ let packed = i64::from_le_bytes(bytes[8..16].try_into().unwrap());
+ let off = (packed >> 32) as usize;
+ let size = (packed & 0xFFFFFFFF) as usize;
+ assert_eq!(off, 16);
+ assert_eq!(size, 200);
+ assert_eq!(&bytes[off..off + size], big.as_slice());
+ }
+
+ #[test]
+ fn create_value_writer_rejects_array() {
+ let dt = DataTypes::array(DataTypes::int());
+ let res = PaimonBinaryRowWriter::create_value_writer(&dt);
+ match res {
+ Err(Error::UnsupportedOperation { message }) => {
+ assert!(message.contains("Unsupported type for Paimon
BinaryRow writer"));
+ }
+ Err(other) => panic!("expected UnsupportedOperation, got
{other:?}"),
+ Ok(_) => panic!("expected error, got Ok"),
+ }
+ }
+
+ #[test]
+ fn create_value_writer_accepts_scalars() {
+ let cases = [
+ DataTypes::int(),
+ DataTypes::bigint(),
+ DataTypes::string(),
+ DataTypes::char(8),
+ DataTypes::boolean(),
+ DataTypes::float(),
+ DataTypes::double(),
+ DataTypes::binary(8),
+ DataTypes::bytes(),
+ DataTypes::date(),
+ DataTypes::time(),
+ DataTypes::decimal(10, 2),
+ DataTypes::timestamp(),
+ ];
+ for dt in cases {
+ PaimonBinaryRowWriter::create_value_writer(&dt)
+ .unwrap_or_else(|e| panic!("expected scalar {dt:?} accepted,
got {e}"));
+ }
+ }
+}
diff --git a/crates/fluss/src/rpc/api_key.rs b/crates/fluss/src/rpc/api_key.rs
index 977b69d1..aaeca70d 100644
--- a/crates/fluss/src/rpc/api_key.rs
+++ b/crates/fluss/src/rpc/api_key.rs
@@ -78,12 +78,12 @@ impl ApiKey {
| ApiKey::GetDatabaseInfo
| ApiKey::CreatePartition
| ApiKey::DropPartition
- | ApiKey::Authenticate
- // TODO(key-encoding-v1): The Java server supports v0..v1 for these
- // APIs, but the Rust client has not yet implemented the v1 key
- // encoding format. Pinned to v0 until that is done.
- | ApiKey::PutKv | ApiKey::Lookup | ApiKey::PrefixLookup => {
- Some(ApiVersionRange::new(ApiVersion(0), ApiVersion(0)))
+ | ApiKey::Authenticate => Some(ApiVersionRange::new(ApiVersion(0),
ApiVersion(0))),
+ // PutKv / Lookup / PrefixLookup support v0 (legacy key encoding)
+ // and v1 (Paimon BinaryRow key encoding for kv_format_version=2
+ // non-default bucket keys). The Rust client encodes both.
+ ApiKey::PutKv | ApiKey::Lookup | ApiKey::PrefixLookup => {
+ Some(ApiVersionRange::new(ApiVersion(0), ApiVersion(1)))
}
Unknown(_) => None,
}
diff --git a/crates/fluss/src/rpc/server_connection.rs
b/crates/fluss/src/rpc/server_connection.rs
index e1148f9d..762c7ea9 100644
--- a/crates/fluss/src/rpc/server_connection.rs
+++ b/crates/fluss/src/rpc/server_connection.rs
@@ -1181,7 +1181,7 @@ mod tests {
assert_eq!(
resolve_api_version_for(None, ApiKey::PutKv).unwrap(),
- ApiVersion(0)
+ ApiVersion(1)
);
let server_versions = vec![
@@ -1215,7 +1215,7 @@ mod tests {
// Successful negotiation cases
assert_eq!(
negotiated.highest_available_version(ApiKey::PutKv).unwrap(),
- ApiVersion(0)
+ ApiVersion(1)
);
assert_eq!(
negotiated
diff --git a/crates/fluss/tests/integration/kv_table.rs
b/crates/fluss/tests/integration/kv_table.rs
index 4c30c978..616ba06a 100644
--- a/crates/fluss/tests/integration/kv_table.rs
+++ b/crates/fluss/tests/integration/kv_table.rs
@@ -1182,15 +1182,8 @@ mod kv_table_test {
.expect("Failed to drop table");
}
- /// Test that KV format v2 tables with non-default bucket key reject v0
clients.
- /// The Rust client currently only supports API version 0 for
PutKv/Lookup/PrefixLookup.
- /// When the server creates a table with kv_format_version=2 and a
non-default bucket key,
- /// it rejects v0 clients because CompactedKeyEncoder (v1) is required.
- // TODO(key-encoding-v1): Once v1 key encoding is implemented and the
client advertises
- // PutKv/Lookup/PrefixLookup v1, this test should be updated to verify
that v1 clients
- // can successfully write to and read from kv_format_v2 tables with
non-default bucket keys.
#[tokio::test]
- async fn kv_format_v2_table_rejects_v0_client() {
+ async fn upsert_and_prefix_lookup_with_paimon_format_v1() {
let cluster = get_shared_cluster();
let connection = cluster.get_fluss_connection().await;
@@ -1199,7 +1192,7 @@ mod kv_table_test {
let table_path = TablePath::new("fluss",
"test_kv_format_v2_reject_v0");
// Create a KV table with:
- // 1. kv_format_version = 2
+ // 1. kv_format_version = 1
// 2. non-default bucket key ("a" is a subset of pk ("a", "b"))
// 3. datalake format is exist.
let table_descriptor = TableDescriptor::builder()
@@ -1213,40 +1206,324 @@ mod kv_table_test {
.expect("Failed to build schema"),
)
.distributed_by(Some(2), vec!["a".to_string()])
- .property("table.kv.format-version", "2")
- .property("table.datalake.format", "lance")
+ .property("table.kv.format-version", "1")
+ .property("table.datalake.format", "paimon")
.build()
.expect("Failed to build table");
create_table(&admin, &table_path, &table_descriptor).await;
let table = connection.get_table(&table_path).await.unwrap();
+ assert_eq!(
+ table
+ .get_table_info()
+ .get_properties()
+ .get("table.kv.format-version")
+ .unwrap(),
+ "1"
+ );
- // Test PutKv with v0 client - should fail with UNSUPPORTED_VERSION
let table_upsert = table.new_upsert().expect("Failed to create
upsert");
let upsert_writer = table_upsert
.create_writer()
.expect("Failed to create writer");
- let mut row = GenericRow::new(3);
- row.set_field(0, 1);
- row.set_field(1, "a");
- row.set_field(2, "value1");
- let upsert_result = upsert_writer
- .upsert(&row)
- .expect("Failed to upsert row")
- .await;
- assert!(
- upsert_result.is_err(),
- "PutKv with v0 client should be rejected for kv_format_v2 table
with non-default bucket key"
+ let mut upsert_row = GenericRow::new(3);
+ upsert_row.set_field(0, 1);
+ upsert_row.set_field(1, "a");
+ upsert_row.set_field(2, "value1");
+ upsert_writer
+ .upsert(&upsert_row)
+ .unwrap()
+ .await
+ .expect("Failed to upsert row");
+
+ // prefix lookup
+ let mut lookup_row = GenericRow::new(1);
+ lookup_row.set_field(0, 1);
+ let mut prefix_lookup = table
+ .new_lookup()
+ .expect("Failed to create lookup")
+ .lookup_by(vec!["a".to_string()])
+ .create_lookuper()
+ .expect("Failed to create prefix lookuper");
+ let lookup_result = prefix_lookup
+ .lookup(&lookup_row)
+ .await
+ .expect("fail to lookup");
+ let row = lookup_result
+ .get_single_row()
+ .unwrap()
+ .expect("Row should exist");
+ assert_eq!(row.get_string(2).unwrap(), "value1");
+ }
+
+ /// Verifies upsert/lookup/update/delete on a KV table whose data lake
format
+ /// is `paimon`, exercising every scalar `DataType` supported by Paimon's
+ /// BinaryRow encoder. With the default `kv_format_version=1`, the client
+ /// uses `PaimonKeyEncoder` for both the bucket key and the primary key,
+ /// so this also exercises the end-to-end Paimon key encoding path.
+ /// ARRAY/MAP/ROW are intentionally excluded because Paimon's BinaryRow
+ /// writer rejects them.
+ #[tokio::test]
+ async fn upsert_and_lookup_with_paimon_format_v2() {
+ let cluster = get_shared_cluster();
+ let connection = cluster.get_fluss_connection().await;
+
+ let admin = connection.get_admin().unwrap();
+
+ let table_path = TablePath::new("fluss", "test_kv_paimon_format");
+
+ // Schema covering every Paimon-supported scalar type.
+ // INT primary key keeps the bucket-key/primary-key encoding focused
+ // while the value columns sweep all remaining scalar types.
+ let table_descriptor = TableDescriptor::builder()
+ .schema(
+ Schema::builder()
+ .column("id", DataTypes::int())
+ .column("col_tinyint", DataTypes::tinyint())
+ .column("col_smallint", DataTypes::smallint())
+ .column("col_bigint", DataTypes::bigint())
+ .column("col_float", DataTypes::float())
+ .column("col_double", DataTypes::double())
+ .column("col_boolean", DataTypes::boolean())
+ .column("col_char", DataTypes::char(10))
+ .column("col_string", DataTypes::string())
+ .column("col_decimal", DataTypes::decimal(10, 2))
+ .column("col_date", DataTypes::date())
+ .column("col_time_s", DataTypes::time_with_precision(0))
+ .column("col_time_ms", DataTypes::time_with_precision(3))
+ .column("col_time_us", DataTypes::time_with_precision(6))
+ .column("col_time_ns", DataTypes::time_with_precision(9))
+ .column("col_timestamp",
DataTypes::timestamp_with_precision(0))
+ .column(
+ "col_timestamp_ltz",
+ DataTypes::timestamp_ltz_with_precision(6),
+ )
+ .column("col_bytes", DataTypes::bytes())
+ .column("col_binary", DataTypes::binary(4))
+ .primary_key(vec!["id"])
+ .build()
+ .expect("Failed to build schema"),
+ )
+ .property("table.datalake.format", "paimon")
+ .build()
+ .expect("Failed to build table");
+
+ create_table(&admin, &table_path, &table_descriptor).await;
+
+ let table = connection.get_table(&table_path).await.unwrap();
+ assert_eq!(
+ table
+ .get_table_info()
+ .get_properties()
+ .get("table.kv.format-version")
+ .unwrap(),
+ "2"
+ );
+
+ let field_count = table.get_table_info().schema.columns().len();
+
+ let table_upsert = table.new_upsert().expect("Failed to create
upsert");
+ let upsert_writer = table_upsert
+ .create_writer()
+ .expect("Failed to create writer");
+
+ // Test data covering every Paimon scalar type.
+ let id: i32 = 1;
+ let col_tinyint: i8 = 127;
+ let col_smallint: i16 = 32767;
+ let col_bigint: i64 = 9_223_372_036_854_775_807;
+ let col_float: f32 = std::f32::consts::PI;
+ let col_double: f64 = std::f64::consts::E;
+ let col_boolean: bool = true;
+ let col_char: &str = "hello";
+ let col_string: &str = "world of fluss rust client (paimon format)";
+ let col_decimal = Decimal::from_unscaled_long(12345, 10, 2).unwrap();
// 123.45
+ let col_date = Date::new(20476); // 2026-01-23
+ let col_time_s = Time::new(36_827_000); // 10:13:47
+ let col_time_ms = Time::new(36_827_123);
+ let col_time_us = Time::new(86_399_999);
+ let col_time_ns = Time::new(1);
+ // col_timestamp uses precision 0 (second granularity);
col_timestamp_ltz
+ // uses precision 6 (microsecond granularity) to also exercise the
+ // sub-millisecond nanos path.
+ let col_timestamp = TimestampNtz::new(1_769_163_227_000);
+ let col_timestamp_ltz =
+ TimestampLtz::from_millis_nanos(1_769_163_227_123,
456_000).unwrap();
+ let col_bytes: Vec<u8> = b"binary data".to_vec();
+ let col_binary: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF];
+
+ let build_full_row = |id_val: i32| {
+ let mut row = GenericRow::new(field_count);
+ row.set_field(0, id_val);
+ row.set_field(1, col_tinyint);
+ row.set_field(2, col_smallint);
+ row.set_field(3, col_bigint);
+ row.set_field(4, col_float);
+ row.set_field(5, col_double);
+ row.set_field(6, col_boolean);
+ row.set_field(7, col_char);
+ row.set_field(8, col_string);
+ row.set_field(9, col_decimal.clone());
+ row.set_field(10, col_date);
+ row.set_field(11, col_time_s);
+ row.set_field(12, col_time_ms);
+ row.set_field(13, col_time_us);
+ row.set_field(14, col_time_ns);
+ row.set_field(15, col_timestamp);
+ row.set_field(16, col_timestamp_ltz);
+ row.set_field(17, col_bytes.as_slice());
+ row.set_field(18, col_binary.as_slice());
+ row
+ };
+
+ // Upsert two rows so we can also exercise delete on one and keep the
+ // other intact afterwards.
+ upsert_writer
+ .upsert(&build_full_row(id))
+ .expect("Failed to upsert paimon row");
+ upsert_writer
+ .upsert(&build_full_row(id + 1))
+ .expect("Failed to upsert second paimon row");
+ upsert_writer.flush().await.expect("Failed to flush");
+
+ let mut lookuper = table
+ .new_lookup()
+ .expect("Failed to create lookup")
+ .create_lookuper()
+ .expect("Failed to create lookuper");
+
+ // Verify every type round-trips through paimon-format upsert/lookup.
+ let assert_full_row = |row: &dyn DataGetters, expected_id: i32| {
+ assert_eq!(row.get_int(0).unwrap(), expected_id, "id mismatch");
+ assert_eq!(row.get_byte(1).unwrap(), col_tinyint, "tinyint
mismatch");
+ assert_eq!(row.get_short(2).unwrap(), col_smallint, "smallint
mismatch");
+ assert_eq!(row.get_long(3).unwrap(), col_bigint, "bigint
mismatch");
+ assert!(
+ (row.get_float(4).unwrap() - col_float).abs() < f32::EPSILON,
+ "float mismatch",
+ );
+ assert!(
+ (row.get_double(5).unwrap() - col_double).abs() < f64::EPSILON,
+ "double mismatch",
+ );
+ assert_eq!(row.get_boolean(6).unwrap(), col_boolean, "boolean
mismatch");
+ assert_eq!(row.get_char(7, 10).unwrap(), col_char, "char
mismatch");
+ assert_eq!(row.get_string(8).unwrap(), col_string, "string
mismatch");
+ assert_eq!(
+ row.get_decimal(9, 10, 2).unwrap(),
+ col_decimal,
+ "decimal mismatch",
+ );
+ assert_eq!(
+ row.get_date(10).unwrap().get_inner(),
+ col_date.get_inner(),
+ "date mismatch",
+ );
+ assert_eq!(
+ row.get_time(11).unwrap().get_inner(),
+ col_time_s.get_inner(),
+ "time(0) mismatch",
+ );
+ assert_eq!(
+ row.get_time(12).unwrap().get_inner(),
+ col_time_ms.get_inner(),
+ "time(3) mismatch",
+ );
+ assert_eq!(
+ row.get_time(13).unwrap().get_inner(),
+ col_time_us.get_inner(),
+ "time(6) mismatch",
+ );
+ assert_eq!(
+ row.get_time(14).unwrap().get_inner(),
+ col_time_ns.get_inner(),
+ "time(9) mismatch",
+ );
+ assert_eq!(
+ row.get_timestamp_ntz(15, 0).unwrap().get_millisecond(),
+ col_timestamp.get_millisecond(),
+ "timestamp(0) mismatch",
+ );
+ let ts_ltz = row.get_timestamp_ltz(16, 6).unwrap();
+ assert_eq!(
+ ts_ltz.get_epoch_millisecond(),
+ col_timestamp_ltz.get_epoch_millisecond(),
+ "timestamp_ltz(6) millis mismatch",
+ );
+ assert_eq!(
+ ts_ltz.get_nano_of_millisecond(),
+ col_timestamp_ltz.get_nano_of_millisecond(),
+ "timestamp_ltz(6) nanos mismatch",
+ );
+ assert_eq!(row.get_bytes(17).unwrap(), col_bytes, "bytes
mismatch");
+ assert_eq!(
+ row.get_binary(18, 4).unwrap(),
+ col_binary,
+ "binary mismatch",
+ );
+ };
+
+ for expected_id in [id, id + 1] {
+ let result = lookuper
+ .lookup(&make_key_with_field_count(expected_id, 1))
+ .await
+ .expect("Failed to lookup");
+ let row = result.get_single_row().unwrap().expect("Row should
exist");
+ assert_full_row(&row, expected_id);
+ }
+
+ // Update one row by upserting again with a different decimal value to
+ // verify update path under paimon format.
+ let updated_decimal = Decimal::from_unscaled_long(98765, 10,
2).unwrap(); // 987.65
+ let mut updated_row = build_full_row(id);
+ updated_row.set_field(9, updated_decimal.clone());
+ upsert_writer
+ .upsert(&updated_row)
+ .expect("Failed to upsert updated row")
+ .await
+ .expect("Failed to wait for upsert acknowledgment");
+
+ let result = lookuper
+ .lookup(&make_key_with_field_count(id, 1))
+ .await
+ .expect("Failed to lookup after update");
+ let found_row = result.get_single_row().unwrap().expect("Row should
exist");
+ assert_eq!(
+ found_row.get_decimal(9, 10, 2).unwrap(),
+ updated_decimal,
+ "Decimal should be updated under paimon format",
);
- let err_msg = upsert_result.unwrap_err().to_string();
+ // Other columns remain unchanged.
+ assert_eq!(found_row.get_string(8).unwrap(), col_string);
+
+ // Delete the updated record.
+ let mut delete_row = GenericRow::new(field_count);
+ delete_row.set_field(0, id);
+ upsert_writer
+ .delete(&delete_row)
+ .expect("Failed to delete")
+ .await
+ .expect("Failed to wait for delete acknowledgment");
+
+ let result = lookuper
+ .lookup(&make_key_with_field_count(id, 1))
+ .await
+ .expect("Failed to lookup deleted record");
assert!(
- err_msg.contains("Client API version 0 is not supported"),
- "Expected 'Client API version 0 is not supported' error, got: {}",
- err_msg
+ result.get_single_row().unwrap().is_none(),
+ "Record {id} should not exist after delete under paimon format",
);
+ // The other record remains intact and still round-trips correctly.
+ let result = lookuper
+ .lookup(&make_key_with_field_count(id + 1, 1))
+ .await
+ .expect("Failed to lookup remaining record");
+ let row = result.get_single_row().unwrap().expect("Row should exist");
+ assert_full_row(&row, id + 1);
+
admin
.drop_table(&table_path, false)
.await