This is an automated email from the ASF dual-hosted git repository.

yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new aba23877 feat(core): write composite and non-string record keys (#871)
aba23877 is described below

commit aba238779ff1af7ecfb2fbd7053046d46981393c
Author: Y Ethan Guo <[email protected]>
AuthorDate: Fri Oct 30 00:04:33 2026 -0700

    feat(core): write composite and non-string record keys (#871)
    
    The native write path accepted only a single `Utf8` record key field, so 
tables keyed on several fields or on an integer could not be written. Record 
keys are now built the way Java's `KeyGenerator.constructRecordKey` builds them 
(`field:value` pairs joined by commas, `__null__` and `__empty__` placeholders, 
the same all-null error) for string, boolean and integer key fields. The 
writer, `SimpleIndex` and the virtual-key reader share one encoder and one key 
generator resolver in `keyg [...]
    
    Date, decimal, timestamp and floating-point key fields stay unsupported: 
Spark and Flink store them differently (a date as an ISO `LocalDate` or as the 
epoch day, a decimal through `toString` or `toPlainString`), the table does not 
record which engine wrote it, and an encoding that does not match the table's 
would duplicate rows. A delete or update filter on one field of a composite key 
scans instead of using the index, and an `IN` list on `_hoodie_record_key` is 
refused for composite [...]
---
 .../src/file_group/reader_v2/record_context.rs     | 202 +++----------
 crates/core/src/index/simple.rs                    |  50 ++--
 crates/core/src/keygen/mod.rs                      | 218 ++++++++++++--
 crates/core/src/keygen/record_key.rs               | 296 +++++++++++++++++++
 crates/core/src/table/mod.rs                       |  22 ++
 crates/core/src/write/keygen.rs                    | 261 ++++++----------
 crates/core/src/write/rewrite/mod.rs               |  21 +-
 crates/core/src/write/rewrite/mor.rs               |   4 +-
 crates/core/src/write/rewrite/rows.rs              | 105 ++++---
 crates/core/src/write/rewrite/validate.rs          |   9 +-
 .../core/tests/table_write_composite_keys_tests.rs | 328 +++++++++++++++++++++
 11 files changed, 1051 insertions(+), 465 deletions(-)

diff --git a/crates/core/src/file_group/reader_v2/record_context.rs 
b/crates/core/src/file_group/reader_v2/record_context.rs
index 283ab160..a33fb5f1 100644
--- a/crates/core/src/file_group/reader_v2/record_context.rs
+++ b/crates/core/src/file_group/reader_v2/record_context.rs
@@ -30,12 +30,14 @@
 //! struct.
 
 use crate::Result;
+use crate::config::HudiConfigs;
 use crate::config::table::HudiTableConfig;
 use crate::error::CoreError;
 use crate::file_group::reader_v2::buffered_record::{BufferedRecord, 
OrderingValue};
 use crate::file_group::reader_v2::delete_context::DeleteContext;
+use crate::keygen::record_key::{construct_record_key, render_key_field};
 use crate::metadata::meta_field::MetaField;
-use arrow_array::{Array, ArrayRef, ListArray, RecordBatch, StringArray, 
StructArray, UnionArray};
+use arrow_array::{Array, ListArray, RecordBatch, StringArray, StructArray, 
UnionArray};
 use arrow_schema::{DataType, SchemaRef};
 use std::collections::HashMap;
 use std::sync::Arc;
@@ -320,16 +322,6 @@ impl Default for RecordContext {
 }
 
 impl RecordContext {
-    /// Composite-record-key separators + placeholders, matching Java 
`KeyGenerator`
-    /// (`DEFAULT_COLUMN_VALUE_SEPARATOR` `:`, 
`DEFAULT_RECORD_KEY_PARTS_SEPARATOR` `,`,
-    /// `NULL_RECORDKEY_PLACEHOLDER`, `EMPTY_RECORDKEY_PLACEHOLDER`). These 
MUST stay in sync
-    /// with the writer's key generator so a key reconstructed at read time 
equals the one
-    /// the writer stored.
-    const COMPOSITE_KEY_COLUMN_VALUE_SEPARATOR: char = ':';
-    const COMPOSITE_KEY_PARTS_SEPARATOR: char = ',';
-    const NULL_RECORDKEY_PLACEHOLDER: &'static str = "__null__";
-    const EMPTY_RECORDKEY_PLACEHOLDER: &'static str = "__empty__";
-
     /// Canonical parse of the table's record-key field list — the single 
source of truth
     /// shared with [`ReaderContext::record_key_fields_from`] (which delegates 
here) so the
     /// reader can't disagree with the record context on what the key fields 
are.
@@ -422,25 +414,12 @@ impl RecordContext {
         }
     }
 
-    /// Table version at or above which a `ComplexKeyGenerator` ALWAYS encodes 
the
-    /// single record-key field name (Java `HoodieTableVersion.NINE`).
-    const COMPLEX_KEYGEN_ENCODE_MIN_TABLE_VERSION: i32 = 9;
-    /// Write config controlling single-field complex-keygen encoding for table
-    /// versions below NINE. Java 
`HoodieWriteConfig.COMPLEX_KEYGEN_NEW_ENCODING`
-    /// (default `false`). When absent/false, the field name IS encoded.
-    const COMPLEX_KEYGEN_NEW_ENCODING_KEY: &'static str =
-        "hoodie.write.complex.keygen.new.encoding";
-
     /// Whether a single-field VIRTUAL key is stored as `<field>:<value>` by 
the
     /// writer's key generator, so the reader must reconstruct the same shape.
-    ///
-    /// Mirrors Java: a `ComplexKeyGenerator`/`ComplexAvroKeyGenerator` over a 
SINGLE
-    /// record-key field prefixes the field name when
-    /// `KeyGenUtils.encodeSingleKeyFieldNameForComplexKeyGen` is true —
-    /// `tableVersion >= NINE || !COMPLEX_KEYGEN_NEW_ENCODING` (the config 
defaults to
-    /// `false`, so v9 always encodes and v6/v8 encode by default). Only 
applies to
-    /// virtual-key tables; meta-field tables read the already-encoded key from
-    /// `_hoodie_record_key`.
+    /// Resolved by the writer's own rule (key generator class, then type, then
+    /// inference, plus 
`KeyGenUtils.encodeSingleKeyFieldNameForComplexKeyGen`).
+    /// Only applies to virtual-key tables; meta-field tables read the
+    /// already-encoded key from `_hoodie_record_key`.
     fn should_encode_single_complex_key_field(
         table_config: &HashMap<String, String>,
         populate_meta_fields: bool,
@@ -449,25 +428,10 @@ impl RecordContext {
         if populate_meta_fields || num_record_key_fields != 1 {
             return false;
         }
-        let is_complex_keygen = table_config
-            .get(HudiTableConfig::KeyGeneratorClass.as_ref())
-            .map(|c| c.ends_with("ComplexKeyGenerator") || 
c.ends_with("ComplexAvroKeyGenerator"))
-            .unwrap_or(false);
-        if !is_complex_keygen {
-            return false;
-        }
-        // `tableVersion >= NINE || !COMPLEX_KEYGEN_NEW_ENCODING`. An 
unparseable /
-        // absent version falls back to the new-encoding config alone (which 
defaults
-        // to `false` → encode), matching the common default.
-        let table_version = table_config
-            .get(HudiTableConfig::TableVersion.as_ref())
-            .and_then(|v| v.trim().parse::<i32>().ok());
-        let new_encoding = table_config
-            .get(Self::COMPLEX_KEYGEN_NEW_ENCODING_KEY)
-            .map(|v| v.eq_ignore_ascii_case("true"))
-            .unwrap_or(false);
-        table_version.is_some_and(|v| v >= 
Self::COMPLEX_KEYGEN_ENCODE_MIN_TABLE_VERSION)
-            || !new_encoding
+        let configs = HudiConfigs::new(table_config.iter().map(|(k, v)| 
(k.as_str(), v.clone())));
+        // The generators the writer does not reproduce (timestamp, custom) 
store a
+        // single-field key as the bare value.
+        crate::keygen::encodes_single_key_field_name(&configs).unwrap_or(false)
     }
 
     // 
=========================================================================
@@ -494,10 +458,9 @@ impl RecordContext {
     /// allocation, preserving the vectorized base-merge kernel's hot path). 
With
     /// VIRTUAL KEYS (`hoodie.populate.meta.fields=false`) the key field is a 
real
     /// data column whose Arrow type is NOT necessarily `Utf8` — e.g. an
-    /// `id INT` primary key. Such a column is stringified via
-    /// [`Self::stringify_record_key_column`], mirroring Hudi's
-    /// `SimpleKeyGenerator`, which derives the record key from the field's 
string
-    /// value. Both the base-file side (here) and the log side
+    /// `id INT` primary key. Such a column is stringified the way the Java key
+    /// generator renders the field value (shared with the writer through
+    /// [`render_key_field`]). Both the base-file side (here) and the log side
     /// ([`Self::get_record_keys`] via `batch_to_buffered_records`) go through 
this
     /// method, so their merge keys are stringified identically and match by
     /// construction.
@@ -505,8 +468,7 @@ impl RecordContext {
     /// # Errors
     /// Returns [`CoreError::ReadFileSliceError`] if the key field is absent 
from
     /// `batch`, and [`CoreError::Unsupported`] if a virtual-key column has a 
type
-    /// hudi-rs does not derive record keys from (see
-    /// [`Self::stringify_record_key_column`]).
+    /// hudi-rs does not derive record keys from (see [`render_key_field`]).
     pub fn record_key_array(&self, batch: &RecordBatch) -> Result<StringArray> 
{
         // Composite virtual key (>1 record-key field): reconstruct 
`field:val,field:val`
         // per row, mirroring Java `KeyGenerator.constructRecordKey`. Both the 
base-file side
@@ -531,41 +493,15 @@ impl RecordContext {
                 ))
             })?;
 
-        Self::stringify_key_field_column(batch.column(col_idx), 
&self.record_key_field)
-    }
-
-    /// Stringify one record-key field's column into a [`StringArray`], the 
shared building
-    /// block for both the single-field key and every field of a composite 
key. A `Utf8`
-    /// column is cloned (a cheap Arc-buffer bump, not a data copy); any other 
type goes
-    /// through [`Self::stringify_record_key_column`] (integral types 
supported, others
-    /// rejected loudly). Using one helper guarantees the single-field, 
composite, base, and
-    /// log sides all derive keys identically.
-    fn stringify_key_field_column(column: &ArrayRef, field: &str) -> 
Result<StringArray> {
-        match column.data_type() {
-            DataType::Utf8 => Ok(column
-                .as_any()
-                .downcast_ref::<StringArray>()
-                .expect("Utf8 column downcasts to StringArray")
-                .clone()),
-            _ => Self::stringify_record_key_column(column.as_ref(), field),
-        }
+        render_key_field(batch.column(col_idx).as_ref(), 
&self.record_key_field)
     }
 
-    /// Build the merge key for a COMPOSITE virtual key (>1 record-key field), 
mirroring Java
-    /// `KeyGenerator.constructRecordKey`: for each field append 
`<field>:<value>`, join the
-    /// parts with `,`, substitute `__null__` / `__empty__` for null / empty 
values, and error
-    /// if every field is null/empty (matching Java's `HoodieKeyException`). 
Each field column
-    /// is stringified through the same path as the single-field key (`Utf8` 
clone or
-    /// [`Self::stringify_record_key_column`]), so a key over e.g. `id INT, 
name STRING` is
-    /// keyed identically on the base and log sides. All record-key fields are 
present in the
-    /// batch because the schema handler adds every one to the required schema.
-    ///
-    /// Note: `,` / `:` occurring INSIDE field values are NOT escaped, 
matching Java's
-    /// `KeyGenerator.constructRecordKey` bug-for-bug (e.g. `name = "a,b:c"` 
yields an
-    /// ambiguous key). That ambiguity is inherited by design: both the base 
and log sides
-    /// construct the key identically through this method, so base/log pairing 
is unaffected.
+    /// Build the merge key for a COMPOSITE virtual key (>1 record-key field), 
or a single
+    /// field the complex key generator encodes, through Java
+    /// `KeyGenerator.constructRecordKey` (shared with the writer). All 
record-key fields are
+    /// present in the batch because the schema handler adds every one to the 
required
+    /// schema.
     fn build_composite_record_key_array(&self, batch: &RecordBatch) -> 
Result<StringArray> {
-        // Stringify each key field's column once, preserving nulls.
         let mut per_field: Vec<StringArray> = 
Vec::with_capacity(self.record_key_fields.len());
         for field in &self.record_key_fields {
             let col_idx = batch.schema().index_of(field).map_err(|e| {
@@ -573,88 +509,17 @@ impl RecordContext {
                     "Composite key field '{field}' not found in schema: {e}"
                 ))
             })?;
-            per_field.push(Self::stringify_key_field_column(
-                batch.column(col_idx),
-                field,
-            )?);
-        }
-
-        let num_rows = batch.num_rows();
-        let mut keys: Vec<String> = Vec::with_capacity(num_rows);
-        for row in 0..num_rows {
-            let mut key = String::new();
-            let mut all_null_or_empty = true;
-            for (i, field) in self.record_key_fields.iter().enumerate() {
-                if i > 0 {
-                    key.push(Self::COMPOSITE_KEY_PARTS_SEPARATOR);
-                }
-                key.push_str(field);
-                key.push(Self::COMPOSITE_KEY_COLUMN_VALUE_SEPARATOR);
-                let col = &per_field[i];
-                if col.is_null(row) {
-                    key.push_str(Self::NULL_RECORDKEY_PLACEHOLDER);
-                } else {
-                    let value = col.value(row);
-                    if value.is_empty() {
-                        key.push_str(Self::EMPTY_RECORDKEY_PLACEHOLDER);
-                    } else {
-                        key.push_str(value);
-                        all_null_or_empty = false;
-                    }
-                }
-            }
-            if all_null_or_empty {
-                return Err(CoreError::ReadFileSliceError(format!(
-                    "composite record key is entirely null/empty for fields 
{:?}; a record \
-                     key cannot be null or empty (mirrors Java 
HoodieKeyException)",
-                    self.record_key_fields
-                )));
-            }
-            keys.push(key);
+            per_field.push(render_key_field(batch.column(col_idx).as_ref(), 
field)?);
         }
+        let keys = (0..batch.num_rows())
+            .map(|row| {
+                construct_record_key(&self.record_key_fields, &per_field, row)
+                    .map_err(CoreError::ReadFileSliceError)
+            })
+            .collect::<Result<Vec<String>>>()?;
         Ok(StringArray::from(keys))
     }
 
-    /// Convert a non-`Utf8` virtual record-key column into a [`StringArray`],
-    /// mirroring Hudi's `SimpleKeyGenerator` (the key is the field value's 
string
-    /// form). Supports string and integral key fields — the types Hudi allows 
as
-    /// a simple record key in practice. Any other type is rejected LOUDLY with
-    /// [`CoreError::Unsupported`] rather than guessing a string form that 
might
-    /// not match the writer's key generator (which would silently mis-key the
-    /// merge). For a COMPOSITE virtual key, 
[`Self::build_composite_record_key_array`]
-    /// calls this per field, so the same per-column type handling is shared.
-    fn stringify_record_key_column(column: &dyn Array, field: &str) -> 
Result<StringArray> {
-        match column.data_type() {
-            DataType::LargeUtf8
-            | DataType::Int8
-            | DataType::Int16
-            | DataType::Int32
-            | DataType::Int64
-            | DataType::UInt8
-            | DataType::UInt16
-            | DataType::UInt32
-            | DataType::UInt64 => {
-                let string_column = arrow_cast::cast(column, 
&DataType::Utf8).map_err(|e| {
-                    CoreError::ReadFileSliceError(format!(
-                        "failed to stringify virtual record-key column 
'{field}' of type {:?}: {e}",
-                        column.data_type()
-                    ))
-                })?;
-                Ok(string_column
-                    .as_any()
-                    .downcast_ref::<StringArray>()
-                    .expect("cast to Utf8 yields a StringArray")
-                    .clone())
-            }
-            other => Err(CoreError::Unsupported(format!(
-                "virtual record-key column '{field}' has type {other:?}; 
hudi-rs \
-                 derives record keys only from string and integral key fields 
(matching \
-                 SimpleKeyGenerator). Refusing to guess a string form that may 
not match \
-                 the writer's key generator."
-            ))),
-        }
-    }
-
     /// Extract ordering values from the first ordering field column.
     ///
     /// Mirrors Java's `RecordContext.getOrderingValue(T, Schema, 
List<String>)`,
@@ -1334,7 +1199,7 @@ impl RecordContext {
 mod tests {
     use super::*;
     use crate::file_group::reader_v2::delete_context::DeleteContext;
-    use arrow_array::{BooleanArray, Int32Array, Int64Array, StringArray};
+    use arrow_array::{ArrayRef, BooleanArray, Int32Array, Int64Array, 
StringArray};
     use arrow_schema::{DataType, Field, Schema};
     use std::sync::Arc;
 
@@ -1562,6 +1427,15 @@ mod tests {
             vec!["id:1".to_string(), "id:42".to_string()]
         );
 
+        // Only the type recorded, as hudi-rs and Java 1.x write tables.
+        let complex_type = 
make_ctx(&[(HudiTableConfig::KeyGeneratorType.as_ref(), "COMPLEX")]);
+        assert_eq!(
+            keys(&complex_type),
+            vec!["id:1".to_string(), "id:42".to_string()]
+        );
+        let simple_type = 
make_ctx(&[(HudiTableConfig::KeyGeneratorType.as_ref(), "SIMPLE")]);
+        assert_eq!(keys(&simple_type), vec!["1".to_string(), 
"42".to_string()]);
+
         // SimpleKeyGenerator → bare value (no field-name prefix).
         let simple = make_ctx(&[(
             HudiTableConfig::KeyGeneratorClass.as_ref(),
diff --git a/crates/core/src/index/simple.rs b/crates/core/src/index/simple.rs
index 0f848691..2d5b3609 100644
--- a/crates/core/src/index/simple.rs
+++ b/crates/core/src/index/simple.rs
@@ -22,9 +22,9 @@ use std::collections::{HashMap, HashSet};
 use arrow::array::StringArray;
 
 use crate::Result;
-use crate::config::table::HudiTableConfig::RecordKeyFields;
 use crate::error::CoreError;
 use crate::index::{HoodieIndex, HoodieKey, RecordLocation};
+use crate::keygen::record_key::record_keys_for_batch;
 use crate::metadata::meta_field::MetaField;
 use crate::table::{ReadOptions, Table};
 
@@ -50,50 +50,38 @@ impl HoodieIndex for SimpleIndex {
         if requested.is_empty() {
             return Ok(locations);
         }
-        let record_key_fields: Vec<String> = table
-            .hudi_configs
-            .try_get(RecordKeyFields)?
-            .map(Into::into)
-            .unwrap_or_default();
-        if record_key_fields.len() != 1 {
-            return Err(CoreError::Unsupported(
-                "SimpleIndex currently requires exactly one record key 
field".to_string(),
-            ));
-        }
-        let fallback_key = &record_key_fields[0];
         let reader = table
             .create_file_group_reader_with_options(None, 
std::iter::empty::<(&str, &str)>())
             .await?;
         let mut by_record_key: HashMap<String, RecordLocation> = 
HashMap::new();
         for slice in table.get_file_slices(&ReadOptions::new()).await? {
             let batch = reader.read_file_slice(&slice, 
&ReadOptions::new()).await?;
-            let key_name = if batch
-                .column_by_name(MetaField::RecordKey.as_ref())
-                .is_some()
-            {
-                MetaField::RecordKey.as_ref()
-            } else {
-                fallback_key
-            };
-            let key_array = batch
-                .column_by_name(key_name)
-                .ok_or_else(|| {
-                    CoreError::Schema(format!("record key field '{key_name}' 
is missing"))
-                })?
-                .as_any()
-                .downcast_ref::<StringArray>()
-                .ok_or_else(|| {
-                    CoreError::Unsupported(format!("record key field 
'{key_name}' must be Utf8"))
-                })?;
             let location = RecordLocation {
                 file_id: slice.file_id().to_string(),
                 instant_time: table.timeline.get_latest_commit_timestamp()?,
                 partition_path: slice.partition_path.clone(),
             };
-            for key in key_array.iter().flatten() {
+            let mut tag = |key: &str| {
                 if requested.contains(key) {
                     by_record_key.insert(key.to_string(), location.clone());
                 }
+            };
+            match batch.column_by_name(MetaField::RecordKey.as_ref()) {
+                Some(column) => column
+                    .as_any()
+                    .downcast_ref::<StringArray>()
+                    .ok_or_else(|| {
+                        CoreError::Unsupported(format!(
+                            "record key field '{}' must be Utf8",
+                            MetaField::RecordKey.as_ref()
+                        ))
+                    })?
+                    .iter()
+                    .flatten()
+                    .for_each(tag),
+                None => record_keys_for_batch(&table.hudi_configs, &batch)?
+                    .iter()
+                    .for_each(|key| tag(key)),
             }
         }
         for (hoodie_key, slot) in locations.iter_mut() {
diff --git a/crates/core/src/keygen/mod.rs b/crates/core/src/keygen/mod.rs
index 7cb466f3..3d2c5568 100644
--- a/crates/core/src/keygen/mod.rs
+++ b/crates/core/src/keygen/mod.rs
@@ -17,39 +17,157 @@
  * under the License.
  */
 
-//! Key generator implementations for transforming user filters to partition 
filters.
+//! Key generator implementations: record key encoding, and transforms of user
+//! filters to partition filters.
 
+pub(crate) mod record_key;
 pub mod timestamp_based;
 
 use crate::Result;
 use crate::config::HudiConfigs;
-use crate::config::table::HudiTableConfig::{KeyGeneratorClass, 
KeyGeneratorType};
+use crate::config::table::HudiTableConfig;
+use crate::config::table::HudiTableConfig::{
+    KeyGeneratorClass, KeyGeneratorType, PartitionFields, TableVersion,
+};
+use crate::error::CoreError;
 use crate::expr::filter::Filter;
+use crate::keygen::record_key::configured_record_key_fields;
 
-/// Returns true if the table uses a timestamp-based key generator,
-/// checking both `hoodie.table.keygenerator.class` and
-/// `hoodie.table.keygenerator.type`.
-pub fn is_timestamp_based_keygen(hudi_configs: &HudiConfigs) -> Result<bool> {
-    let by_class = hudi_configs
-        .try_get(KeyGeneratorClass)?
-        .map(|v| {
-            let s: String = v.into();
-            s.contains("TimestampBasedKeyGenerator")
-        })
-        .unwrap_or(false);
-
-    if by_class {
-        return Ok(true);
+/// Java `HoodieWriteConfig.COMPLEX_KEYGEN_NEW_ENCODING`.
+const COMPLEX_KEYGEN_NEW_ENCODING: &str = 
"hoodie.write.complex.keygen.new.encoding";
+
+/// The key generators hudi-rs tells apart, as Java `KeyGeneratorType` names 
them.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum KeyGeneratorKind {
+    Simple,
+    NonPartitioned,
+    Complex,
+    /// Spark SQL's `SqlKeyGenerator`, which delegates to `ComplexKeyGenerator`
+    /// when the table config names the wrapper itself rather than an origin 
class.
+    SparkSql,
+    TimestampBased,
+    /// Custom, global-delete and user-provided generators.
+    Other,
+}
+
+impl KeyGeneratorKind {
+    fn from_class(class: &str) -> Self {
+        match class {
+            "org.apache.hudi.keygen.SimpleKeyGenerator"
+            | "org.apache.hudi.keygen.SimpleAvroKeyGenerator" => Self::Simple,
+            "org.apache.hudi.keygen.NonpartitionedKeyGenerator"
+            | "org.apache.hudi.keygen.NonpartitionedAvroKeyGenerator" => 
Self::NonPartitioned,
+            "org.apache.hudi.keygen.ComplexKeyGenerator"
+            | "org.apache.hudi.keygen.ComplexAvroKeyGenerator" => 
Self::Complex,
+            "org.apache.spark.sql.hudi.command.SqlKeyGenerator" => 
Self::SparkSql,
+            "org.apache.hudi.keygen.TimestampBasedKeyGenerator"
+            | "org.apache.hudi.keygen.TimestampBasedAvroKeyGenerator" => 
Self::TimestampBased,
+            _ => Self::Other,
+        }
     }
 
-    Ok(hudi_configs
-        .try_get(KeyGeneratorType)?
-        .map(|v| {
-            let s: String = v.into();
-            let upper = s.to_uppercase();
-            upper == "TIMESTAMP" || upper == "TIMESTAMP_AVRO"
-        })
-        .unwrap_or(false))
+    fn from_type(key_generator_type: &str) -> Self {
+        match key_generator_type.to_ascii_uppercase().as_str() {
+            "SIMPLE" | "SIMPLE_AVRO" => Self::Simple,
+            "NON_PARTITION" | "NON_PARTITION_AVRO" => Self::NonPartitioned,
+            "COMPLEX" | "COMPLEX_AVRO" => Self::Complex,
+            "SPARK_SQL" => Self::SparkSql,
+            "TIMESTAMP" | "TIMESTAMP_AVRO" => Self::TimestampBased,
+            _ => Self::Other,
+        }
+    }
+}
+
+fn declared<'a>(hudi_configs: &'a HudiConfigs, config: &HudiTableConfig) -> 
Option<&'a str> {
+    hudi_configs
+        .get_raw(config.as_ref())
+        .map(str::trim)
+        .filter(|v| !v.is_empty())
+}
+
+/// The generator the table config names. The class wins over the type, as in
+/// Java `KeyGeneratorType.getKeyGeneratorClassName`.
+fn declared_key_generator(hudi_configs: &HudiConfigs) -> 
Option<KeyGeneratorKind> {
+    declared(hudi_configs, &KeyGeneratorClass)
+        .map(KeyGeneratorKind::from_class)
+        .or_else(|| declared(hudi_configs, 
&KeyGeneratorType).map(KeyGeneratorKind::from_type))
+}
+
+/// The table's key generator: the declared one, and with none it is inferred
+/// from the field lists (`KeyGenUtils.inferKeyGeneratorType`). Generators 
whose
+/// keys or partition paths hudi-rs does not reproduce are rejected, since
+/// writing with a different encoding would split a key across two index 
entries.
+pub(crate) fn key_generator_kind(hudi_configs: &HudiConfigs) -> 
Result<KeyGeneratorKind> {
+    let kind = match declared_key_generator(hudi_configs) {
+        Some(kind) => kind,
+        None => {
+            let partition_fields: Vec<String> = 
hudi_configs.get_or_default(PartitionFields).into();
+            if partition_fields.first().is_some_and(|f| f.contains(':')) {
+                // A `field:type` partition spec is the custom key generator.
+                KeyGeneratorKind::Other
+            } else {
+                match partition_fields.len() {
+                    0 => KeyGeneratorKind::NonPartitioned,
+                    1 if configured_record_key_fields(hudi_configs)?.len() > 1 
=> {
+                        KeyGeneratorKind::Complex
+                    }
+                    1 => KeyGeneratorKind::Simple,
+                    _ => KeyGeneratorKind::Complex,
+                }
+            }
+        }
+    };
+    if matches!(
+        kind,
+        KeyGeneratorKind::TimestampBased | KeyGeneratorKind::Other
+    ) {
+        let setting = [KeyGeneratorClass, KeyGeneratorType, PartitionFields]
+            .iter()
+            .find_map(|config| {
+                declared(hudi_configs, config).map(|value| 
format!("{}={value}", config.as_ref()))
+            })
+            .unwrap_or_default();
+        return Err(CoreError::Unsupported(format!(
+            "writes do not support {setting}; supported key generators are 
SIMPLE, COMPLEX, \
+             NON_PARTITION, and SPARK_SQL"
+        )));
+    }
+    Ok(kind)
+}
+
+/// Whether the table's key generator stores a single-field record key as
+/// `field:value` (Java `ComplexKeyGenerator`) rather than the bare value
+/// (`SimpleKeyGenerator`, `NonpartitionedKeyGenerator`).
+pub(crate) fn encodes_single_key_field_name(hudi_configs: &HudiConfigs) -> 
Result<bool> {
+    if !matches!(
+        key_generator_kind(hudi_configs)?,
+        KeyGeneratorKind::Complex | KeyGeneratorKind::SparkSql
+    ) {
+        return Ok(false);
+    }
+    // Java `KeyGenUtils.encodeSingleKeyFieldNameForComplexKeyGen`.
+    let table_version: Option<isize> = 
hudi_configs.try_get(TableVersion)?.map(Into::into);
+    let new_encoding = hudi_configs
+        .get_raw(COMPLEX_KEYGEN_NEW_ENCODING)
+        .is_some_and(|v| v.trim().eq_ignore_ascii_case("true"));
+    Ok(table_version.is_some_and(|v| v >= 9) || !new_encoding)
+}
+
+/// Java `KeyGeneratorType.isComplexKeyGenerator`: only a declared type or
+/// class counts, never one inferred from the field lists, and the type is
+/// checked before the class.
+pub(crate) fn declares_complex_key_generator(hudi_configs: &HudiConfigs) -> 
bool {
+    let kind = match hudi_configs.get_raw(KeyGeneratorType.as_ref()) {
+        Some(key_generator_type) => 
KeyGeneratorKind::from_type(key_generator_type.trim()),
+        None => declared(hudi_configs, &KeyGeneratorClass)
+            .map_or(KeyGeneratorKind::Other, KeyGeneratorKind::from_class),
+    };
+    kind == KeyGeneratorKind::Complex
+}
+
+/// Returns true if the table uses a timestamp-based key generator.
+pub fn is_timestamp_based_keygen(hudi_configs: &HudiConfigs) -> Result<bool> {
+    Ok(declared_key_generator(hudi_configs) == 
Some(KeyGeneratorKind::TimestampBased))
 }
 
 /// Trait for key generators that can transform user filters on data columns
@@ -61,3 +179,55 @@ pub trait KeyGeneratorFilterTransformer {
     /// Transforms a filter on the source field to one or more filters on 
partition fields.
     fn transform_filter(&self, filter: &Filter) -> Result<Vec<Filter>>;
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn configs(pairs: &[(&str, &str)]) -> HudiConfigs {
+        HudiConfigs::new(pairs.iter().map(|(k, v)| (*k, v.to_string())))
+    }
+
+    #[test]
+    fn test_declared_key_generator_kinds() {
+        let timestamp = configs(&[(
+            "hoodie.table.keygenerator.class",
+            "org.apache.hudi.keygen.TimestampBasedAvroKeyGenerator",
+        )]);
+        assert!(is_timestamp_based_keygen(&timestamp).unwrap());
+        assert!(
+            
is_timestamp_based_keygen(&configs(&[("hoodie.table.keygenerator.type", 
"timestamp")]))
+                .unwrap()
+        );
+        assert!(!is_timestamp_based_keygen(&configs(&[])).unwrap());
+        // The class wins over the type.
+        assert!(
+            !is_timestamp_based_keygen(&configs(&[
+                ("hoodie.table.keygenerator.type", "TIMESTAMP"),
+                (
+                    "hoodie.table.keygenerator.class",
+                    "org.apache.hudi.keygen.SimpleKeyGenerator",
+                ),
+            ]))
+            .unwrap()
+        );
+
+        // Java checks the type before the class, and Spark SQL's wrapper is 
not complex.
+        let complex_type = configs(&[
+            ("hoodie.table.keygenerator.type", "COMPLEX"),
+            (
+                "hoodie.table.keygenerator.class",
+                "org.apache.hudi.keygen.SimpleKeyGenerator",
+            ),
+        ]);
+        assert!(declares_complex_key_generator(&complex_type));
+        assert!(!declares_complex_key_generator(&configs(&[(
+            "hoodie.table.keygenerator.type",
+            "SPARK_SQL"
+        )])));
+        assert!(!declares_complex_key_generator(&configs(&[(
+            "hoodie.table.partition.fields",
+            "p,q"
+        )])));
+    }
+}
diff --git a/crates/core/src/keygen/record_key.rs 
b/crates/core/src/keygen/record_key.rs
new file mode 100644
index 00000000..0a8182cf
--- /dev/null
+++ b/crates/core/src/keygen/record_key.rs
@@ -0,0 +1,296 @@
+/*
+ * 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.
+ */
+//! Record key encoding shared by the writer, the index and the virtual-key
+//! reader, so a key derived at read time equals the one a Java writer stored.
+
+use arrow::array::{
+    Array, BooleanArray, Int8Array, Int16Array, Int32Array, Int64Array, 
LargeStringArray,
+    StringArray, StringViewArray, UInt8Array, UInt16Array, UInt32Array, 
UInt64Array,
+};
+use arrow::datatypes::DataType;
+use arrow::record_batch::RecordBatch;
+
+use crate::Result;
+use crate::config::HudiConfigs;
+use crate::config::table::HudiTableConfig::RecordKeyFields;
+use crate::error::CoreError;
+use crate::keygen::{KeyGeneratorKind, encodes_single_key_field_name, 
key_generator_kind};
+
+/// Java `KeyGenerator.NULL_RECORDKEY_PLACEHOLDER`.
+pub(crate) const NULL_RECORDKEY_PLACEHOLDER: &str = "__null__";
+/// Java `KeyGenerator.EMPTY_RECORDKEY_PLACEHOLDER`.
+pub(crate) const EMPTY_RECORDKEY_PLACEHOLDER: &str = "__empty__";
+/// Java `KeyGenerator.DEFAULT_COLUMN_VALUE_SEPARATOR`.
+const COLUMN_VALUE_SEPARATOR: char = ':';
+/// Java `KeyGenerator.DEFAULT_RECORD_KEY_PARTS_SEPARATOR`.
+const RECORD_KEY_PARTS_SEPARATOR: char = ',';
+
+/// Render a string, boolean or integer record key field column as the Java
+/// key generators do: `toString` of the value. Nulls stay null.
+///
+/// Every other type is [`CoreError::Unsupported`]: Java writers store it in
+/// more than one form that the table does not record, and an encoding that
+/// does not match the table's would duplicate rows. Spark renders a date as
+/// its ISO `LocalDate` and Flink as the epoch day; decimals differ between
+/// `toString` and `toPlainString`; timestamps are stored as a raw epoch count,
+/// a JVM-zone `java.sql.Timestamp` or epoch millis; and float keys depend on
+/// the JDK's `Double.toString` digits.
+pub(crate) fn render_key_field(column: &dyn Array, field: &str) -> 
Result<StringArray> {
+    macro_rules! render {
+        ($array:ty, $render:expr) => {
+            downcast::<$array>(column, field)?
+                .iter()
+                .map(|value| value.map($render))
+                .collect::<Vec<Option<String>>>()
+        };
+    }
+    let rendered = match column.data_type() {
+        DataType::Utf8 => return Ok(downcast::<StringArray>(column, 
field)?.clone()),
+        DataType::LargeUtf8 => render!(LargeStringArray, str::to_string),
+        DataType::Utf8View => render!(StringViewArray, str::to_string),
+        DataType::Boolean => render!(BooleanArray, |v: bool| v.to_string()),
+        DataType::Int8 => render!(Int8Array, |v: i8| v.to_string()),
+        DataType::Int16 => render!(Int16Array, |v: i16| v.to_string()),
+        DataType::Int32 => render!(Int32Array, |v: i32| v.to_string()),
+        DataType::Int64 => render!(Int64Array, |v: i64| v.to_string()),
+        DataType::UInt8 => render!(UInt8Array, |v: u8| v.to_string()),
+        DataType::UInt16 => render!(UInt16Array, |v: u16| v.to_string()),
+        DataType::UInt32 => render!(UInt32Array, |v: u32| v.to_string()),
+        DataType::UInt64 => render!(UInt64Array, |v: u64| v.to_string()),
+        other => {
+            return Err(CoreError::Unsupported(format!(
+                "record key field '{field}' has type {other}, whose Java key 
rendering is not \
+                 reproduced; supported record key types are strings, booleans, 
and integers"
+            )));
+        }
+    };
+    Ok(StringArray::from(rendered))
+}
+
+/// Java `KeyGenerator.constructRecordKey`: `field:value` parts in field order,
+/// joined by `,`, with null and empty values replaced by their placeholders.
+/// Values containing `,` or `:` are not escaped, as in Java.
+///
+/// Returns Java's `HoodieKeyException` message as the error when every part is
+/// null or empty, so callers can raise it in their own error variant.
+pub(crate) fn construct_record_key(
+    fields: &[String],
+    values: &[StringArray],
+    row: usize,
+) -> std::result::Result<String, String> {
+    let mut key = String::new();
+    let mut entirely_null_or_empty = true;
+    for (i, (field, column)) in fields.iter().zip(values).enumerate() {
+        if i > 0 {
+            key.push(RECORD_KEY_PARTS_SEPARATOR);
+        }
+        key.push_str(field);
+        key.push(COLUMN_VALUE_SEPARATOR);
+        if column.is_null(row) {
+            key.push_str(NULL_RECORDKEY_PLACEHOLDER);
+        } else if column.value(row).is_empty() {
+            key.push_str(EMPTY_RECORDKEY_PLACEHOLDER);
+        } else {
+            key.push_str(column.value(row));
+            entirely_null_or_empty = false;
+        }
+    }
+    if entirely_null_or_empty {
+        return Err(format!(
+            "recordKey values: \"{key}\" for fields: [{}] cannot be entirely 
null or empty.",
+            fields.join(", ")
+        ));
+    }
+    Ok(key)
+}
+
+/// Java `KeyGenUtils.getRecordKeyFields`: the configured record key fields,
+/// trimmed, with empty entries dropped. Empty means auto-generated keys.
+pub(crate) fn configured_record_key_fields(hudi_configs: &HudiConfigs) -> 
Result<Vec<String>> {
+    let fields: Vec<String> = hudi_configs
+        .try_get(RecordKeyFields)?
+        .map(Into::into)
+        .unwrap_or_default();
+    Ok(fields
+        .iter()
+        .map(|f| f.trim())
+        .filter(|f| !f.is_empty())
+        .map(str::to_string)
+        .collect())
+}
+
+/// Record keys of `batch` derived from the configured record key fields, as
+/// the table's key generator encodes them (Java `KeyGenerator.getRecordKey`).
+/// Fails on a table with auto-generated keys, which no row value determines.
+pub(crate) fn record_keys_for_batch(
+    hudi_configs: &HudiConfigs,
+    batch: &RecordBatch,
+) -> Result<Vec<String>> {
+    let record_key_fields = configured_record_key_fields(hudi_configs)?;
+    if record_key_fields.is_empty() {
+        return Err(CoreError::Unsupported(
+            "auto-generated record keys cannot be derived from row 
values".to_string(),
+        ));
+    }
+    encode_record_keys(hudi_configs, &record_key_fields, batch)
+}
+
+/// Several fields, or one field under the complex key generator, encode as
+/// `field:value` parts (Java `KeyGenUtils.getRecordKey(record, fields, ..)`);
+/// one field otherwise is the bare value, which may not be null or empty.
+pub(crate) fn encode_record_keys(
+    hudi_configs: &HudiConfigs,
+    fields: &[String],
+    batch: &RecordBatch,
+) -> Result<Vec<String>> {
+    if fields.len() > 1 && key_generator_kind(hudi_configs)? == 
KeyGeneratorKind::Simple {
+        return Err(CoreError::Unsupported(format!(
+            "the simple key generator expects a single record key field; got 
'{}'",
+            fields.join(",")
+        )));
+    }
+    let mut values = Vec::with_capacity(fields.len());
+    for field in fields {
+        let column = batch.column_by_name(field).ok_or_else(|| {
+            CoreError::Schema(format!(
+                "record key field '{field}' is missing from write batch"
+            ))
+        })?;
+        values.push(render_key_field(column.as_ref(), field)?);
+    }
+    if let ([field], [column]) = (fields, values.as_slice())
+        && !encodes_single_key_field_name(hudi_configs)?
+    {
+        return (0..batch.num_rows())
+            .map(|row| {
+                if column.is_null(row) || column.value(row).is_empty() {
+                    return Err(CoreError::Write(format!(
+                        "record key field '{field}' must be non-null and 
non-empty"
+                    )));
+                }
+                Ok(column.value(row).to_string())
+            })
+            .collect();
+    }
+    (0..batch.num_rows())
+        .map(|row| construct_record_key(fields, &values, 
row).map_err(CoreError::Write))
+        .collect()
+}
+
+fn downcast<'a, A: 'static>(column: &'a dyn Array, field: &str) -> Result<&'a 
A> {
+    column.as_any().downcast_ref::<A>().ok_or_else(|| {
+        CoreError::Schema(format!(
+            "record key field '{field}' does not match its declared type {}",
+            column.data_type()
+        ))
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use arrow::array::{
+        ArrayRef, BinaryArray, Date32Array, Decimal128Array, Float32Array, 
Float64Array,
+        TimestampMicrosecondArray, TimestampMillisecondArray,
+    };
+    use std::sync::Arc;
+
+    fn rendered(column: ArrayRef) -> Vec<Option<String>> {
+        render_key_field(column.as_ref(), "k")
+            .unwrap()
+            .iter()
+            .map(|v| v.map(str::to_string))
+            .collect()
+    }
+
+    fn some(values: &[&str]) -> Vec<Option<String>> {
+        values.iter().map(|v| Some(v.to_string())).collect()
+    }
+
+    #[test]
+    fn test_render_key_field_scalar_types() {
+        assert_eq!(
+            rendered(Arc::new(LargeStringArray::from(vec![Some("x"), None]))),
+            vec![Some("x".to_string()), None]
+        );
+        assert_eq!(
+            rendered(Arc::new(BooleanArray::from(vec![true, false]))),
+            some(&["true", "false"])
+        );
+        assert_eq!(
+            rendered(Arc::new(Int64Array::from(vec![i64::MIN]))),
+            some(&["-9223372036854775808"])
+        );
+        assert_eq!(
+            rendered(Arc::new(UInt64Array::from(vec![u64::MAX]))),
+            some(&["18446744073709551615"])
+        );
+    }
+
+    #[test]
+    fn test_render_key_field_rejects_unrendered_types() {
+        let columns: Vec<ArrayRef> = vec![
+            Arc::new(Float32Array::from(vec![1.0f32])),
+            Arc::new(Float64Array::from(vec![1.0])),
+            Arc::new(TimestampMicrosecondArray::from(vec![1])),
+            
Arc::new(TimestampMillisecondArray::from(vec![1]).with_timezone("UTC")),
+            Arc::new(BinaryArray::from_vec(vec![b"x"])),
+            Arc::new(Date32Array::from(vec![0])),
+            Arc::new(
+                Decimal128Array::from(vec![1])
+                    .with_precision_and_scale(10, 2)
+                    .unwrap(),
+            ),
+        ];
+        for column in columns {
+            let err = render_key_field(column.as_ref(), "k").unwrap_err();
+            assert!(matches!(err, CoreError::Unsupported(_)), "{err:?}");
+            let message = err.to_string();
+            assert!(
+                message.contains(&format!(
+                    "record key field 'k' has type {}",
+                    column.data_type()
+                )),
+                "{message}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_construct_record_key() {
+        let fields = vec!["a".to_string(), "b".to_string()];
+        let values = vec![
+            StringArray::from(vec![Some("1"), None, Some("")]),
+            StringArray::from(vec![Some("x,y:z"), Some("2"), None]),
+        ];
+        assert_eq!(
+            construct_record_key(&fields, &values, 0).unwrap(),
+            "a:1,b:x,y:z"
+        );
+        assert_eq!(
+            construct_record_key(&fields, &values, 1).unwrap(),
+            "a:__null__,b:2"
+        );
+        assert_eq!(
+            construct_record_key(&fields, &values, 2).unwrap_err(),
+            "recordKey values: \"a:__empty__,b:__null__\" for fields: [a, b] 
cannot be \
+             entirely null or empty."
+        );
+    }
+}
diff --git a/crates/core/src/table/mod.rs b/crates/core/src/table/mod.rs
index c96ffb59..57d20cae 100644
--- a/crates/core/src/table/mod.rs
+++ b/crates/core/src/table/mod.rs
@@ -114,6 +114,7 @@ use crate::file_group::file_slice::FileSlice;
 use crate::file_group::reader::FileGroupReader;
 use crate::file_group::reader_v2::reader_context::CompletionGateInputs;
 use crate::keygen::is_timestamp_based_keygen;
+use crate::keygen::record_key::configured_record_key_fields;
 use crate::metadata::METADATA_TABLE_PARTITION_FIELD;
 use crate::metadata::commit::HoodieCommitMetadata;
 use crate::metadata::meta_field::MetaField;
@@ -419,6 +420,7 @@ impl Table {
     /// A filter that matches no rows succeeds without creating a commit and 
reports zero deletes.
     pub async fn delete(&mut self, filter: &str) -> Result<WriteResult> {
         let filter = parse_write_filter(filter)?;
+        ensure_record_key_list_parsable(&self.hudi_configs, &filter)?;
         crate::write::delete_filter(self, filter).await
     }
 
@@ -442,6 +444,7 @@ impl Table {
     /// Predicates may target any column. Zero matches succeed without a 
commit.
     pub async fn update(&mut self, filter: &str, updates: RecordBatch) -> 
Result<WriteResult> {
         let filter = parse_write_filter(filter)?;
+        ensure_record_key_list_parsable(&self.hudi_configs, &filter)?;
         crate::write::update_filter(self, filter, updates).await
     }
 
@@ -1519,6 +1522,25 @@ fn parse_write_filter(filter: &str) -> Result<Filter> {
     )))
 }
 
+/// [`parse_write_filter`] splits an `IN` list on every `,`, and every
+/// composite record key contains one, so the list would name key fragments.
+fn ensure_record_key_list_parsable(hudi_configs: &HudiConfigs, filter: 
&Filter) -> Result<()> {
+    if filter.field == MetaField::RecordKey.as_ref()
+        && matches!(
+            filter.operator,
+            crate::expr::ExprOperator::In | crate::expr::ExprOperator::NotIn
+        )
+        && configured_record_key_fields(hudi_configs)?.len() > 1
+    {
+        return Err(CoreError::Unsupported(format!(
+            "an IN list on {} cannot hold composite record keys, which contain 
','; \
+             use one `=` predicate per key or a predicate on a non-key column",
+            filter.field
+        )));
+    }
+    Ok(())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/crates/core/src/write/keygen.rs b/crates/core/src/write/keygen.rs
index 20147228..88fed5c0 100644
--- a/crates/core/src/write/keygen.rs
+++ b/crates/core/src/write/keygen.rs
@@ -24,11 +24,12 @@ use arrow::record_batch::RecordBatch;
 use crate::Result;
 use crate::config::HudiConfigs;
 use crate::config::table::HudiTableConfig::{
-    IsHiveStylePartitioning, IsPartitionPathUrlencoded, KeyGeneratorClass, 
KeyGeneratorType,
-    PartitionFields, RecordKeyFields, TableVersion,
+    IsHiveStylePartitioning, IsPartitionPathUrlencoded, PartitionFields, 
TableVersion,
 };
 use crate::error::CoreError;
 use crate::index::HoodieKey;
+use crate::keygen::record_key::{configured_record_key_fields, 
encode_record_keys};
+use crate::keygen::{declares_complex_key_generator, 
encodes_single_key_field_name};
 
 /// Build per-row [`HoodieKey`]s from a write batch.
 ///
@@ -54,74 +55,9 @@ pub(crate) const VALIDATION_INSTANT: &str = 
"00000000000000000";
 /// Java `PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH`.
 const DEFAULT_PARTITION_PATH: &str = "__HIVE_DEFAULT_PARTITION__";
 
-/// Java `HoodieWriteConfig.COMPLEX_KEYGEN_NEW_ENCODING`.
-const COMPLEX_KEYGEN_NEW_ENCODING: &str = 
"hoodie.write.complex.keygen.new.encoding";
-
 /// Java `HoodieWriteConfig.ENABLE_COMPLEX_KEYGEN_VALIDATION`.
 pub(crate) const COMPLEX_KEYGEN_VALIDATION: &str = 
"hoodie.write.complex.keygen.validation.enable";
 
-/// Whether the table's key generator stores a single-field record key as
-/// `field:value` (Java `ComplexKeyGenerator`) rather than the bare value
-/// (`SimpleKeyGenerator`, `NonpartitionedKeyGenerator`).
-///
-/// The generator resolves like Java 
`KeyGeneratorType.getKeyGeneratorClassName`:
-/// the class wins over the type, and with neither it is inferred from the 
field
-/// lists (`KeyGenUtils.inferKeyGeneratorType`). Generators whose keys or 
partition
-/// paths the writer does not reproduce are rejected, since writing with a
-/// different encoding would split a key across two index entries.
-pub(crate) fn encodes_single_key_field_name(hudi_configs: &HudiConfigs) -> 
Result<bool> {
-    let complex = if let Some(class) = hudi_configs
-        .get_raw(KeyGeneratorClass.as_ref())
-        .map(str::trim)
-        .filter(|c| !c.is_empty())
-    {
-        match class {
-            "org.apache.hudi.keygen.SimpleKeyGenerator"
-            | "org.apache.hudi.keygen.SimpleAvroKeyGenerator"
-            | "org.apache.hudi.keygen.NonpartitionedKeyGenerator"
-            | "org.apache.hudi.keygen.NonpartitionedAvroKeyGenerator" => false,
-            // Spark SQL's wrapper delegates to ComplexKeyGenerator when the 
table
-            // config names the wrapper itself rather than an origin class.
-            "org.apache.hudi.keygen.ComplexKeyGenerator"
-            | "org.apache.hudi.keygen.ComplexAvroKeyGenerator"
-            | "org.apache.spark.sql.hudi.command.SqlKeyGenerator" => true,
-            other => {
-                return 
Err(unsupported_key_generator(KeyGeneratorClass.as_ref(), other));
-            }
-        }
-    } else if let Some(key_generator_type) = hudi_configs
-        .get_raw(KeyGeneratorType.as_ref())
-        .map(str::trim)
-        .filter(|t| !t.is_empty())
-    {
-        match key_generator_type {
-            "SIMPLE" | "SIMPLE_AVRO" | "NON_PARTITION" | "NON_PARTITION_AVRO" 
=> false,
-            "COMPLEX" | "COMPLEX_AVRO" | "SPARK_SQL" => true,
-            other => {
-                return 
Err(unsupported_key_generator(KeyGeneratorType.as_ref(), other));
-            }
-        }
-    } else {
-        let partition_fields: Vec<String> = 
hudi_configs.get_or_default(PartitionFields).into();
-        if partition_fields.first().is_some_and(|f| f.contains(':')) {
-            return Err(unsupported_key_generator(
-                PartitionFields.as_ref(),
-                &partition_fields.join(","),
-            ));
-        }
-        partition_fields.len() > 1
-    };
-    if !complex {
-        return Ok(false);
-    }
-    // Java `KeyGenUtils.encodeSingleKeyFieldNameForComplexKeyGen`.
-    let table_version: Option<isize> = 
hudi_configs.try_get(TableVersion)?.map(Into::into);
-    let new_encoding = hudi_configs
-        .get_raw(COMPLEX_KEYGEN_NEW_ENCODING)
-        .is_some_and(|v| v.trim().eq_ignore_ascii_case("true"));
-    Ok(table_version.is_some_and(|v| v >= 9) || !new_encoding)
-}
-
 /// Fail a write on a table whose key generator the writer cannot reproduce.
 pub(crate) fn ensure_supported_key_generator(hudi_configs: &HudiConfigs) -> 
Result<()> {
     encodes_single_key_field_name(hudi_configs).map(|_| ())
@@ -134,10 +70,7 @@ pub(crate) fn ensure_supported_key_generator(hudi_configs: 
&HudiConfigs) -> Resu
 /// refused until the validation is turned off.
 pub(crate) fn ensure_complex_keygen_validated(hudi_configs: &HudiConfigs) -> 
Result<()> {
     let table_version: Option<isize> = 
hudi_configs.try_get(TableVersion)?.map(Into::into);
-    let record_key_fields: Vec<String> = hudi_configs
-        .try_get(RecordKeyFields)?
-        .map(Into::into)
-        .unwrap_or_default();
+    let record_key_fields = configured_record_key_fields(hudi_configs)?;
     if table_version.is_none_or(|v| v >= 9)
         || record_key_fields.len() != 1
         || !complex_keygen_validation_enabled(|key| hudi_configs.get_raw(key))
@@ -162,30 +95,6 @@ pub(crate) fn complex_keygen_validation_enabled<'a>(
     !get_raw(COMPLEX_KEYGEN_VALIDATION).is_some_and(|v| 
v.trim().eq_ignore_ascii_case("false"))
 }
 
-/// Java `KeyGeneratorType.isComplexKeyGenerator`: only a declared type or
-/// class counts, never one inferred from the field lists.
-fn declares_complex_key_generator(hudi_configs: &HudiConfigs) -> bool {
-    if let Some(key_generator_type) = 
hudi_configs.get_raw(KeyGeneratorType.as_ref()) {
-        return matches!(key_generator_type.trim(), "COMPLEX" | "COMPLEX_AVRO");
-    }
-    matches!(
-        hudi_configs
-            .get_raw(KeyGeneratorClass.as_ref())
-            .map(str::trim),
-        Some(
-            "org.apache.hudi.keygen.ComplexKeyGenerator"
-                | "org.apache.hudi.keygen.ComplexAvroKeyGenerator"
-        )
-    )
-}
-
-fn unsupported_key_generator(config: &str, value: &str) -> CoreError {
-    CoreError::Unsupported(format!(
-        "writes do not support {config}={value}; supported key generators are 
SIMPLE, \
-         COMPLEX, NON_PARTITION, and SPARK_SQL"
-    ))
-}
-
 /// Run key/partition derivation for its error side only — call before fencing
 /// an instant so invalid input leaves no timeline trace.
 pub(crate) fn validate_keygen_inputs(
@@ -214,90 +123,43 @@ pub fn hoodie_keys_for_batch_with_offset(
     row_id_offset: usize,
     partition_id: u32,
 ) -> Result<Vec<HoodieKey>> {
-    let record_key_fields: Option<Vec<String>> =
-        hudi_configs.try_get(RecordKeyFields)?.map(|v| v.into());
-    let auto_keys = record_key_fields
-        .as_ref()
-        .map(|f| f.is_empty() || (f.len() == 1 && f[0].is_empty()))
-        .unwrap_or(true);
-
-    let encode_field_name = encodes_single_key_field_name(hudi_configs)?;
+    ensure_supported_key_generator(hudi_configs)?;
+    let record_key_fields = configured_record_key_fields(hudi_configs)?;
     let partition_fields: Vec<String> = 
hudi_configs.get_or_default(PartitionFields).into();
     let hive_style: bool = 
hudi_configs.get_or_default(IsHiveStylePartitioning).into();
     let url_encode: bool = hudi_configs
         .get_or_default(IsPartitionPathUrlencoded)
         .into();
 
-    if auto_keys {
+    let record_keys = if record_key_fields.is_empty() {
         let instant = instant.ok_or_else(|| {
             CoreError::Write(
                 "auto record-key generation requires a commit instant 
timestamp".to_string(),
             )
         })?;
-        let mut keys = Vec::with_capacity(batch.num_rows());
-        for row in 0..batch.num_rows() {
-            // Match Java HoodieRecord.generateSequenceId(instant, 
partitionId, rowId).
-            let record_key = format!("{instant}_{partition_id}_{}", 
row_id_offset + row);
-            let partition_path =
-                partition_path_for_row(batch, row, &partition_fields, 
hive_style, url_encode)?;
-            keys.push(HoodieKey {
-                record_key,
-                partition_path,
-            });
-        }
-        return Ok(keys);
-    }
-
-    let Some(fields) = record_key_fields else {
-        // Unreachable: auto_keys above covers None, but the no-panic rule
-        // wants an error, not an unwrap.
-        return Err(CoreError::Config(
-            crate::config::error::ConfigError::NotFound("record key 
fields".to_string()),
-        ));
+        // Match Java HoodieRecord.generateSequenceId(instant, partitionId, 
rowId).
+        (0..batch.num_rows())
+            .map(|row| format!("{instant}_{partition_id}_{}", row_id_offset + 
row))
+            .collect()
+    } else {
+        encode_record_keys(hudi_configs, &record_key_fields, batch)?
     };
-    if fields.len() != 1 {
-        return Err(CoreError::Unsupported(
-            "writes currently require exactly one record key field (or none 
for auto keys)"
-                .to_string(),
-        ));
-    }
-    let key_name = &fields[0];
-    let key_array = batch
-        .column_by_name(key_name)
-        .ok_or_else(|| {
-            CoreError::Schema(format!(
-                "record key field '{key_name}' is missing from write batch"
-            ))
-        })?
-        .as_any()
-        .downcast_ref::<StringArray>()
-        .ok_or_else(|| {
-            CoreError::Unsupported(format!(
-                "record key field '{key_name}' must be Utf8 for writes"
-            ))
-        })?;
-
-    let mut keys = Vec::with_capacity(batch.num_rows());
-    for row in 0..batch.num_rows() {
-        if key_array.is_null(row) || key_array.value(row).is_empty() {
-            return Err(CoreError::Write(format!(
-                "record key field '{key_name}' must be non-null and non-empty"
-            )));
-        }
-        let value = key_array.value(row);
-        let record_key = if encode_field_name {
-            format!("{key_name}:{value}")
-        } else {
-            value.to_string()
-        };
-        let partition_path =
-            partition_path_for_row(batch, row, &partition_fields, hive_style, 
url_encode)?;
-        keys.push(HoodieKey {
-            record_key,
-            partition_path,
-        });
-    }
-    Ok(keys)
+    record_keys
+        .into_iter()
+        .enumerate()
+        .map(|(row, record_key)| {
+            Ok(HoodieKey {
+                record_key,
+                partition_path: partition_path_for_row(
+                    batch,
+                    row,
+                    &partition_fields,
+                    hive_style,
+                    url_encode,
+                )?,
+            })
+        })
+        .collect()
 }
 
 fn partition_path_for_row(
@@ -431,24 +293,71 @@ mod tests {
     }
 
     #[test]
-    fn test_non_string_record_key_errors() {
+    fn test_non_string_record_key_renders_like_java() {
         let cfg = configs(&[("hoodie.table.recordkey.fields", "id")]);
         let b = batch(
             vec![Field::new("id", DataType::Int64, false)],
-            vec![Arc::new(Int64Array::from(vec![7]))],
+            vec![Arc::new(Int64Array::from(vec![7, -3]))],
         );
-        assert!(hoodie_keys_for_batch(&cfg, &b, None).is_err());
+        let keys = hoodie_keys_for_batch(&cfg, &b, None).unwrap();
+        assert_eq!(keys[0].record_key, "7");
+        assert_eq!(keys[1].record_key, "-3");
+
+        let b = batch(
+            vec![Field::new("id", DataType::Binary, false)],
+            vec![Arc::new(arrow::array::BinaryArray::from_vec(vec![b"x"]))],
+        );
+        let err = hoodie_keys_for_batch(&cfg, &b, None).unwrap_err();
+        assert!(matches!(err, CoreError::Unsupported(_)), "{err:?}");
     }
 
     #[test]
-    fn test_multi_field_record_key_unsupported() {
-        let cfg = configs(&[("hoodie.table.recordkey.fields", "a,b")]);
-        let b = batch(
-            vec![Field::new("a", DataType::Utf8, false)],
-            vec![Arc::new(StringArray::from(vec!["x"]))],
+    fn test_multi_field_record_key_encodes_field_names() {
+        let fields = vec![
+            Field::new("a", DataType::Int32, true),
+            Field::new("b", DataType::Utf8, true),
+        ];
+        let columns: Vec<arrow::array::ArrayRef> = vec![
+            Arc::new(Int32Array::from(vec![Some(1), None, Some(3), None])),
+            Arc::new(StringArray::from(vec![
+                Some("x"),
+                Some("y"),
+                Some(""),
+                Some(""),
+            ])),
+        ];
+        // Java trims the configured field list and drops empty entries.
+        let cfg = configs(&[("hoodie.table.recordkey.fields", " a , b ,")]);
+        let b = batch(fields.clone(), columns.clone());
+        let keys = hoodie_keys_for_batch(&cfg, &b.slice(0, 3), None).unwrap();
+        let keys: Vec<&str> = keys.iter().map(|k| 
k.record_key.as_str()).collect();
+        assert_eq!(keys, vec!["a:1,b:x", "a:__null__,b:y", "a:3,b:__empty__"]);
+        let err = hoodie_keys_for_batch(&cfg, &b, None).unwrap_err();
+        assert!(
+            err.to_string().contains(
+                "recordKey values: \"a:__null__,b:__empty__\" for fields: [a, 
b] cannot be \
+                 entirely null or empty"
+            ),
+            "{err}"
+        );
+
+        // One partition field with several key fields infers the complex 
generator.
+        let cfg = configs(&[
+            ("hoodie.table.recordkey.fields", "a,b"),
+            ("hoodie.table.partition.fields", "b"),
+        ]);
+        assert_eq!(
+            hoodie_keys_for_batch(&cfg, &b.slice(0, 1), 
None).unwrap()[0].record_key,
+            "a:1,b:x"
         );
+
+        // Java's simple key generator rejects several key fields.
+        let cfg = configs(&[
+            ("hoodie.table.recordkey.fields", "a,b"),
+            ("hoodie.table.keygenerator.type", "SIMPLE"),
+        ]);
         let err = hoodie_keys_for_batch(&cfg, &b, None).unwrap_err();
-        assert!(err.to_string().contains("exactly one record key field"));
+        assert!(err.to_string().contains("single record key field"), "{err}");
     }
 
     #[test]
diff --git a/crates/core/src/write/rewrite/mod.rs 
b/crates/core/src/write/rewrite/mod.rs
index 28f9ac48..630f0c2d 100644
--- a/crates/core/src/write/rewrite/mod.rs
+++ b/crates/core/src/write/rewrite/mod.rs
@@ -22,17 +22,22 @@ use std::str::FromStr;
 
 use arrow::array::{Array, ArrayRef, BooleanArray, StringArray, UInt32Array};
 use arrow::compute::concat_batches;
+use arrow::datatypes::DataType;
 use arrow::record_batch::RecordBatch;
 use arrow_select::take::take;
 use arrow_select::zip::zip;
 
 use crate::Result;
-use crate::config::table::HudiTableConfig::{OrderingFields, RecordKeyFields, 
RecordMergeStrategy};
+use crate::config::table::HudiTableConfig::{OrderingFields, 
RecordMergeStrategy};
 use crate::error::CoreError;
 use crate::expr::ExprOperator;
 use crate::expr::filter::{Filter, filters_to_row_mask, 
validate_fields_against_schemas};
 use crate::file_group::record_batches::RecordBatches;
 use crate::index::{HoodieIndex, HoodieKey, is_record_index_enabled};
+use crate::keygen::encodes_single_key_field_name;
+use crate::keygen::record_key::{
+    configured_record_key_fields, record_keys_for_batch, render_key_field,
+};
 use crate::merge::RecordMergeStrategyValue;
 use crate::merge::record_merger::RecordMerger;
 use crate::metadata::commit::HoodieWriteStat;
@@ -186,8 +191,8 @@ pub async fn upsert_batches(
     let instant = request_rewrite_instant(table, "UPSERT", 
RewriteKind::Commit).await?;
     let incoming = prepare_batches_for_write(&table.hudi_configs, batches, 
&instant, "pending")?;
     let incoming = concat(&incoming)?;
-    let key_name = record_key_name(table, &incoming)?;
-    let incoming = deduplicate_last_by_key(&incoming, &key_name, 
&table.hudi_configs)?;
+    let incoming_keys = batch_record_keys(table, &incoming)?;
+    let incoming = deduplicate_last_by_key(&incoming, &incoming_keys, 
&table.hudi_configs)?;
     let incoming_hoodie_keys = hoodie_keys_for_batch(&table.hudi_configs, 
&incoming, None)?;
     let delete_markers = delete_marker_rows(&incoming);
     let locations = crate::index::for_table_checked(table)
@@ -490,7 +495,7 @@ pub async fn upsert_batches(
 pub async fn delete_filter(table: &mut Table, filter: Filter) -> 
Result<WriteResult> {
     table.reload_timeline_for_write().await?;
     // Key equality / IN on the record key (or meta record key) → 
RLI/SimpleIndex path.
-    if let Some(keys) = keys_from_record_key_filter(table, &filter)?
+    if let Some(keys) = keys_from_record_key_filter(table, &filter).await?
         && !keys.is_empty()
     {
         return delete_keys(table, &keys).await;
@@ -502,13 +507,7 @@ pub async fn delete_filter(table: &mut Table, filter: 
Filter) -> Result<WriteRes
             return Ok(WriteResult::default());
         };
         let mask = filters_to_row_mask(&[filter], &old)?;
-        let key_name = record_key_name(table, &old).map_err(|_| {
-            CoreError::Unsupported(
-                "MOR expression delete requires record keys (or 
_hoodie_record_key) in the data"
-                    .to_string(),
-            )
-        })?;
-        let record_keys = keys(&old, &key_name)?;
+        let record_keys = batch_record_keys(table, &old)?;
         let partitions = partition_paths_for_batch(&old);
         let delete_key_list = mask
             .iter()
diff --git a/crates/core/src/write/rewrite/mor.rs 
b/crates/core/src/write/rewrite/mor.rs
index 1febfca2..612d43a5 100644
--- a/crates/core/src/write/rewrite/mor.rs
+++ b/crates/core/src/write/rewrite/mor.rs
@@ -116,8 +116,8 @@ pub(super) async fn mor_upsert_batches(
             ));
         }
     }
-    let key_name = record_key_name(table, &incoming)?;
-    let incoming = deduplicate_last_by_key(&incoming, &key_name, 
&table.hudi_configs)?;
+    let incoming_keys = batch_record_keys(table, &incoming)?;
+    let incoming = deduplicate_last_by_key(&incoming, &incoming_keys, 
&table.hudi_configs)?;
     crate::write::keygen::validate_keygen_inputs(
         &table.hudi_configs,
         std::slice::from_ref(&incoming),
diff --git a/crates/core/src/write/rewrite/rows.rs 
b/crates/core/src/write/rewrite/rows.rs
index e6d628be..b67a862c 100644
--- a/crates/core/src/write/rewrite/rows.rs
+++ b/crates/core/src/write/rewrite/rows.rs
@@ -150,77 +150,90 @@ pub(super) fn concat(batches: &[RecordBatch]) -> 
Result<RecordBatch> {
     concat_batches(&first.schema(), batches).map_err(Into::into)
 }
 
-pub(super) fn record_key_name(table: &Table, batch: &RecordBatch) -> 
Result<String> {
+/// Record keys of `batch`: `_hoodie_record_key` when the batch carries it,
+/// otherwise derived from the configured record key fields.
+pub(super) fn batch_record_keys(table: &Table, batch: &RecordBatch) -> 
Result<Vec<String>> {
     if batch
         .column_by_name(MetaField::RecordKey.as_ref())
         .is_some()
     {
-        return Ok(MetaField::RecordKey.as_ref().to_string());
+        return keys(batch, MetaField::RecordKey.as_ref());
     }
-    let fields: Vec<String> = table
-        .hudi_configs
-        .try_get(RecordKeyFields)?
-        .map(Into::into)
-        .unwrap_or_default();
-    if fields.len() != 1 || fields[0].is_empty() {
+    if configured_record_key_fields(&table.hudi_configs)?.is_empty() {
         return Err(CoreError::Unsupported(
             "upsert/delete require a record key field (or _hoodie_record_key 
in the batch); auto-generated keys are insert-only"
                 .to_string(),
         ));
     }
-    Ok(fields[0].clone())
+    record_keys_for_batch(&table.hudi_configs, batch)
 }
 
-pub(super) fn configured_record_key_field(table: &Table) -> 
Result<Option<String>> {
-    let fields: Vec<String> = table
-        .hudi_configs
-        .try_get(RecordKeyFields)?
-        .map(Into::into)
-        .unwrap_or_default();
-    if fields.len() == 1 && !fields[0].is_empty() {
-        Ok(Some(fields[0].clone()))
-    } else {
-        Ok(None)
-    }
-}
-
-/// If `filter` is `=` / `IN` on the configured record key or 
`_hoodie_record_key`,
-/// return keys for the RLI/SimpleIndex delete path.
-pub(super) fn keys_from_record_key_filter(
+/// If `filter` is `=` / `IN` on `_hoodie_record_key`, or on the record key
+/// field of a single-field key, return keys for the RLI/SimpleIndex delete
+/// path. A filter on one field of a composite key does not name whole keys.
+pub(super) async fn keys_from_record_key_filter(
     table: &Table,
     filter: &Filter,
 ) -> Result<Option<Vec<HoodieKey>>> {
-    let configured = configured_record_key_field(table)?;
-    let is_key_field = filter.field == MetaField::RecordKey.as_ref()
-        || configured.as_deref() == Some(filter.field.as_str());
-    if !is_key_field {
+    if !matches!(filter.operator, ExprOperator::Eq | ExprOperator::In) {
         return Ok(None);
     }
-    if !matches!(filter.operator, ExprOperator::Eq | ExprOperator::In) {
+    let to_keys = |record_keys: Vec<String>| {
+        record_keys
+            .into_iter()
+            .map(|record_key| HoodieKey {
+                record_key,
+                partition_path: String::new(),
+            })
+            .collect()
+    };
+    if filter.field == MetaField::RecordKey.as_ref() {
+        return Ok(Some(to_keys(filter.values.clone())));
+    }
+    if configured_record_key_fields(&table.hudi_configs)? != 
[filter.field.as_str()] {
+        return Ok(None);
+    }
+    let key_type = match table.get_schema().await {
+        Ok(schema) => schema
+            .field_with_name(&filter.field)
+            .map(|field| field.data_type().clone())
+            .unwrap_or(DataType::Utf8),
+        Err(_) => DataType::Utf8,
+    };
+    // The index matches stored key text, so a value names a key only when it
+    // renders back to itself: `id = 07` matches row 7, whose key is `7`.
+    let Ok(values) = 
arrow_cast::cast(&StringArray::from(filter.values.clone()), &key_type) else {
+        return Ok(None);
+    };
+    let renders_as_given = render_key_field(values.as_ref(), 
&filter.field).is_ok_and(|rendered| {
+        rendered
+            .iter()
+            .zip(&filter.values)
+            .all(|(rendered, given)| rendered == Some(given.as_str()))
+    });
+    // An empty value names no key, as the writer refuses to store one.
+    if !renders_as_given || filter.values.iter().any(String::is_empty) {
         return Ok(None);
     }
     // A filter on the key column holds column values, which the complex key
     // generator stores as `field:value`. A generator the writer cannot
     // reproduce falls back to the scan path.
-    let encode_field_name = filter.field != MetaField::RecordKey.as_ref()
-        && match 
crate::write::keygen::encodes_single_key_field_name(&table.hudi_configs) {
-            Ok(encodes) => encodes,
-            Err(_) => return Ok(None),
-        };
-    Ok(Some(
+    let Ok(encode_field_name) = 
encodes_single_key_field_name(&table.hudi_configs) else {
+        return Ok(None);
+    };
+    Ok(Some(to_keys(
         filter
             .values
             .iter()
-            .map(|value| HoodieKey {
-                record_key: if encode_field_name {
+            .map(|value| {
+                if encode_field_name {
                     format!("{}:{value}", filter.field)
                 } else {
                     value.clone()
-                },
-                partition_path: String::new(),
+                }
             })
             .collect(),
-    ))
+    )))
 }
 
 pub(super) fn partition_paths_for_batch(batch: &RecordBatch) -> Vec<String> {
@@ -350,12 +363,7 @@ pub(super) fn apply_set_updates(
 
 /// Upsert/delete need a configured `hoodie.table.recordkey.fields` (Java auto 
keys are insert-only).
 pub(super) fn ensure_configured_record_key(table: &Table) -> Result<()> {
-    let fields: Vec<String> = table
-        .hudi_configs
-        .try_get(RecordKeyFields)?
-        .map(Into::into)
-        .unwrap_or_default();
-    if fields.len() != 1 || fields[0].is_empty() {
+    if configured_record_key_fields(&table.hudi_configs)?.is_empty() {
         return Err(CoreError::Unsupported(
             "upsert/delete require hoodie.table.recordkey.fields; 
auto-generated keys are insert-only"
                 .to_string(),
@@ -377,7 +385,7 @@ pub(super) fn keys(batch: &RecordBatch, field: &str) -> 
Result<Vec<String>> {
 /// arbitrary by design when no ordering field is configured.
 pub(super) fn deduplicate_last_by_key(
     batch: &RecordBatch,
-    field: &str,
+    keys: &[String],
     hudi_configs: &crate::config::HudiConfigs,
 ) -> Result<RecordBatch> {
     // Java routes in-batch dedupe through the merge-mode-derived merger
@@ -405,7 +413,6 @@ pub(super) fn deduplicate_last_by_key(
         })
         .collect::<std::result::Result<Vec<_>, _>>()?;
     let mut winners: HashMap<String, u32> = 
HashMap::with_capacity(batch.num_rows());
-    let keys = keys(batch, field)?;
     for (index, key) in keys.iter().enumerate() {
         let index = index as u32;
         match winners.get(key) {
diff --git a/crates/core/src/write/rewrite/validate.rs 
b/crates/core/src/write/rewrite/validate.rs
index a6bade40..1cedacb3 100644
--- a/crates/core/src/write/rewrite/validate.rs
+++ b/crates/core/src/write/rewrite/validate.rs
@@ -47,14 +47,7 @@ pub(crate) fn ensure_writable_table_version(table: &Table) 
-> Result<()> {
 /// identity would strand its index entry and diverge the partition value from
 /// the path it physically lives in.
 pub(super) fn ensure_set_columns_updatable(table: &Table, updates: 
&RecordBatch) -> Result<()> {
-    let mut identity: Vec<String> = Vec::new();
-    if let Some(fields) = table
-        .hudi_configs
-        .try_get(RecordKeyFields)?
-        .map(|v| -> Vec<String> { v.into() })
-    {
-        identity.extend(fields);
-    }
+    let mut identity = configured_record_key_fields(&table.hudi_configs)?;
     let partition_fields: Vec<String> = table
         .hudi_configs
         .get_or_default(crate::config::table::HudiTableConfig::PartitionFields)
diff --git a/crates/core/tests/table_write_composite_keys_tests.rs 
b/crates/core/tests/table_write_composite_keys_tests.rs
new file mode 100644
index 00000000..b3bc02d2
--- /dev/null
+++ b/crates/core/tests/table_write_composite_keys_tests.rs
@@ -0,0 +1,328 @@
+/*
+ * 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.
+ */
+
+//! Writes to tables keyed on several record key fields, or on a non-string
+//! field, with `_hoodie_record_key` encoded like Java's key generators.
+
+use std::sync::Arc;
+
+use arrow::array::{ArrayRef, Int64Array, StringArray};
+use arrow::datatypes::{DataType, Field, Schema};
+use arrow::record_batch::RecordBatch;
+use hudi_core::config::table::TableTypeValue;
+use hudi_core::index::{HoodieIndex, HoodieKey, SimpleIndex};
+use hudi_core::table::{ReadOptions, Table};
+use tempfile::tempdir;
+
+const KEY_FIELDS: [&str; 2] = ["wr_order_number", "wr_item_sk"];
+
+type OrderRow<'a> = (i64, &'a str, i64);
+
+fn order_batch(rows: &[OrderRow]) -> RecordBatch {
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("wr_order_number", DataType::Int64, true),
+        Field::new("wr_item_sk", DataType::Utf8, true),
+        Field::new("wr_quantity", DataType::Int64, false),
+    ]));
+    RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(Int64Array::from(
+                rows.iter().map(|(order, _, _)| *order).collect::<Vec<_>>(),
+            )),
+            Arc::new(StringArray::from(
+                rows.iter().map(|(_, item, _)| *item).collect::<Vec<_>>(),
+            )),
+            Arc::new(Int64Array::from(
+                rows.iter().map(|(_, _, qty)| *qty).collect::<Vec<_>>(),
+            )),
+        ],
+    )
+    .unwrap()
+}
+
+fn strings(batch: &RecordBatch, name: &str) -> Vec<String> {
+    let column = batch
+        .column_by_name(name)
+        .unwrap()
+        .as_any()
+        .downcast_ref::<StringArray>()
+        .unwrap();
+    (0..batch.num_rows())
+        .map(|i| column.value(i).to_string())
+        .collect()
+}
+
+fn longs(batch: &RecordBatch, name: &str) -> Vec<i64> {
+    let column = batch
+        .column_by_name(name)
+        .unwrap()
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap();
+    (0..batch.num_rows()).map(|i| column.value(i)).collect()
+}
+
+/// `(_hoodie_record_key, wr_order_number, wr_item_sk, wr_quantity)`, sorted.
+async fn order_rows(table: &Table) -> Vec<(String, i64, String, i64)> {
+    let mut rows = Vec::new();
+    for batch in table.read(&ReadOptions::new()).await.unwrap() {
+        let keys = strings(&batch, "_hoodie_record_key");
+        let orders = longs(&batch, "wr_order_number");
+        let items = strings(&batch, "wr_item_sk");
+        let quantities = longs(&batch, "wr_quantity");
+        for i in 0..batch.num_rows() {
+            rows.push((keys[i].clone(), orders[i], items[i].clone(), 
quantities[i]));
+        }
+    }
+    rows.sort();
+    rows
+}
+
+fn order_row(order: i64, item: &str, quantity: i64) -> (String, i64, String, 
i64) {
+    (
+        format!("wr_order_number:{order},wr_item_sk:{item}"),
+        order,
+        item.to_string(),
+        quantity,
+    )
+}
+
+fn key(record_key: &str) -> HoodieKey {
+    HoodieKey {
+        record_key: record_key.to_string(),
+        partition_path: String::new(),
+    }
+}
+
+/// Append, upsert, delete by key, delete by filter, and the SET guard on a
+/// table keyed on `(wr_order_number BIGINT, wr_item_sk STRING)`.
+async fn run_composite_key_lifecycle(table_type: TableTypeValue, record_index: 
bool) {
+    let dir = tempdir().unwrap();
+    let mut table = Table::create(dir.path().to_str().unwrap())
+        .with_table_name("web_returns")
+        .with_table_type(table_type)
+        .with_record_key_fields(KEY_FIELDS)
+        .with_record_index(record_index)
+        .create()
+        .await
+        .unwrap();
+    let props = 
std::fs::read_to_string(dir.path().join(".hoodie/hoodie.properties")).unwrap();
+    
assert!(props.contains("hoodie.table.recordkey.fields=wr_order_number,wr_item_sk"));
+    assert!(props.contains("hoodie.table.keygenerator.type=NON_PARTITION"));
+
+    table
+        .append([order_batch(&[(1, "a", 10), (1, "b", 20), (2, "a", 30)])])
+        .await
+        .unwrap();
+    assert_eq!(
+        order_rows(&table).await,
+        vec![
+            order_row(1, "a", 10),
+            order_row(1, "b", 20),
+            order_row(2, "a", 30)
+        ]
+    );
+
+    let upsert = table
+        .upsert([order_batch(&[(1, "b", 21), (3, "c", 40), (4, "a", 50)])])
+        .await
+        .unwrap();
+    assert_eq!((upsert.num_updates, upsert.num_inserts), (1, 2));
+    assert_eq!(
+        order_rows(&table).await,
+        vec![
+            order_row(1, "a", 10),
+            order_row(1, "b", 21),
+            order_row(2, "a", 30),
+            order_row(3, "c", 40),
+            order_row(4, "a", 50),
+        ]
+    );
+
+    let deleted = table
+        .delete_keys([key("wr_order_number:2,wr_item_sk:a")])
+        .await
+        .unwrap();
+    assert_eq!(deleted.num_deletes, 1);
+
+    let deleted = table
+        .delete("_hoodie_record_key = 'wr_order_number:3,wr_item_sk:c'")
+        .await
+        .unwrap();
+    assert_eq!(deleted.num_deletes, 1);
+    let remaining = vec![
+        order_row(1, "a", 10),
+        order_row(1, "b", 21),
+        order_row(4, "a", 50),
+    ];
+    assert_eq!(order_rows(&table).await, remaining);
+
+    // Write filters split an IN list on every ',', which each composite key 
holds.
+    let in_list = "_hoodie_record_key IN ('wr_order_number:1,wr_item_sk:a', \
+                   'wr_order_number:4,wr_item_sk:a')";
+    let set = order_batch(&[(1, "a", 0)]);
+    let errors = [
+        table.delete(in_list).await.unwrap_err(),
+        table.update(in_list, set).await.unwrap_err(),
+    ];
+    for err in errors {
+        assert!(
+            err.to_string()
+                .contains("cannot hold composite record keys"),
+            "{err}"
+        );
+    }
+    assert_eq!(order_rows(&table).await, remaining);
+
+    // One key field is not a whole key: every row with that item goes.
+    let deleted = table.delete("wr_item_sk = 'a'").await.unwrap();
+    assert_eq!(deleted.num_deletes, 2);
+    assert_eq!(order_rows(&table).await, vec![order_row(1, "b", 21)]);
+
+    // The guard runs before the SET type check, so one Int64 column probes 
both fields.
+    for field in KEY_FIELDS {
+        let set =
+            RecordBatch::try_from_iter([(field, 
Arc::new(Int64Array::from(vec![9])) as ArrayRef)])
+                .unwrap();
+        let err = table.update("wr_quantity = 21", set).await.unwrap_err();
+        assert!(
+            err.to_string().contains(&format!(
+                "cannot SET record-key or partition column '{field}'"
+            )),
+            "{err}"
+        );
+    }
+}
+
+#[tokio::test]
+async fn test_cow_composite_key_lifecycle_with_record_index() {
+    run_composite_key_lifecycle(TableTypeValue::CopyOnWrite, true).await;
+}
+
+#[tokio::test]
+async fn test_cow_composite_key_lifecycle_with_simple_index() {
+    run_composite_key_lifecycle(TableTypeValue::CopyOnWrite, false).await;
+}
+
+#[tokio::test]
+async fn test_mor_composite_key_lifecycle_with_record_index() {
+    run_composite_key_lifecycle(TableTypeValue::MergeOnRead, true).await;
+}
+
+#[tokio::test]
+async fn test_mor_composite_key_lifecycle_with_simple_index() {
+    run_composite_key_lifecycle(TableTypeValue::MergeOnRead, false).await;
+}
+
+/// Without meta fields, SimpleIndex derives the composite keys from the data 
columns.
+#[tokio::test]
+async fn test_simple_index_tags_virtual_composite_keys() {
+    let dir = tempdir().unwrap();
+    let mut table = Table::create(dir.path().to_str().unwrap())
+        .with_table_name("web_returns")
+        .with_record_key_fields(KEY_FIELDS)
+        .with_populates_meta_fields(false)
+        .with_record_index(false)
+        .create()
+        .await
+        .unwrap();
+    let append = table
+        .append([order_batch(&[(1, "a", 10), (1, "b", 20)])])
+        .await
+        .unwrap();
+    let stored = key("wr_order_number:1,wr_item_sk:b");
+    let absent = key("wr_order_number:2,wr_item_sk:b");
+    let locations = SimpleIndex
+        .tag_location(&table, &[stored.clone(), absent.clone()])
+        .await
+        .unwrap();
+    let location = locations[&stored].as_ref().expect("stored key is tagged");
+    assert!(append.base_file_path.contains(&location.file_id));
+    assert!(locations[&absent].is_none());
+}
+
+#[tokio::test]
+async fn test_composite_key_partitioned_table_uses_complex_key_generator() {
+    let dir = tempdir().unwrap();
+    let mut table = Table::create(dir.path().to_str().unwrap())
+        .with_table_name("web_returns")
+        .with_record_key_fields(KEY_FIELDS)
+        .with_partition_fields(["wr_item_sk"])
+        .create()
+        .await
+        .unwrap();
+    let props = 
std::fs::read_to_string(dir.path().join(".hoodie/hoodie.properties")).unwrap();
+    assert!(props.contains("hoodie.table.keygenerator.type=COMPLEX"));
+
+    table.append([order_batch(&[(1, "a", 10)])]).await.unwrap();
+    let upsert = table
+        .upsert([order_batch(&[(1, "a", 11), (2, "a", 20)])])
+        .await
+        .unwrap();
+    assert_eq!((upsert.num_updates, upsert.num_inserts), (1, 1));
+    assert_eq!(
+        order_rows(&table).await,
+        vec![order_row(1, "a", 11), order_row(2, "a", 20)]
+    );
+}
+
+#[tokio::test]
+async fn test_single_int64_record_key_lifecycle() {
+    let dir = tempdir().unwrap();
+    let mut table = Table::create(dir.path().to_str().unwrap())
+        .with_table_name("web_returns")
+        .with_record_key_fields(["wr_order_number"])
+        .create()
+        .await
+        .unwrap();
+    table
+        .append([order_batch(&[(1, "a", 10), (2, "a", 20)])])
+        .await
+        .unwrap();
+    let upsert = table
+        .upsert([order_batch(&[(2, "a", 21), (3, "a", 30)])])
+        .await
+        .unwrap();
+    assert_eq!((upsert.num_updates, upsert.num_inserts), (1, 1));
+    let key_and_quantity = |rows: Vec<(String, i64, String, i64)>| -> 
Vec<(String, i64)> {
+        rows.into_iter()
+            .map(|(key, _, _, qty)| (key, qty))
+            .collect()
+    };
+    assert_eq!(
+        key_and_quantity(order_rows(&table).await),
+        [
+            ("1".to_string(), 10),
+            ("2".to_string(), 21),
+            ("3".to_string(), 30)
+        ]
+    );
+
+    assert_eq!(table.delete_keys([key("1")]).await.unwrap().num_deletes, 1);
+    // `02` is not the stored key text, but it still names row 2.
+    for filter in ["wr_order_number = 3", "wr_order_number = 02"] {
+        assert_eq!(
+            table.delete(filter).await.unwrap().num_deletes,
+            1,
+            "{filter}"
+        );
+    }
+    assert!(order_rows(&table).await.is_empty());
+}

Reply via email to