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

danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new d43fa7fb6319 fix(debezium): backfill Postgres TOAST columns on a copy 
so the merge result survives (#19749)
d43fa7fb6319 is described below

commit d43fa7fb63199a11bc0f88707b70694b5021b7a2
Author: Lokesh Jain <[email protected]>
AuthorDate: Thu Aug 27 15:30:42 2026 +0530

    fix(debezium): backfill Postgres TOAST columns on a copy so the merge 
result survives (#19749)
    
    On a table-version-6 MOR table using PostgresDebeziumAvroPayload, an 
unchanged Postgres
    TOAST column (emitted as __debezium_unavailable_value) leaked to readers 
instead of being
    backfilled from the prior value.
    
    The payload backfilled the sentinel by mutating the incoming Avro record in 
place and
    returning the same reference. HoodieAvroRecordMerger.merge has an identity 
shortcut
    (updatedRecord == newerAvroRecord returns the engine-native newer record), 
so the merge
    result was skipped and the backfill dropped.
    
    Backfill onto a copy instead: mergeToastedValuesIfPresent returns a new 
record when a
    TOASTed column is filled, and the same reference otherwise. The copy is 
allocated only
    when a sentinel is found, so records without one keep the merger fast-path. 
The merger is
    left unchanged, so other payloads and the HoodieOperation marker it 
preserves are
    unaffected.
    
    Add TestPostgresDebeziumToastV6ReadMerge, red without the fix and green 
with it.
---
 .../debezium/PostgresDebeziumAvroPayload.java      |  33 ++++--
 .../TestPostgresDebeziumToastV6ReadMerge.scala     | 124 +++++++++++++++++++++
 2 files changed, 150 insertions(+), 7 deletions(-)

diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/model/debezium/PostgresDebeziumAvroPayload.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/model/debezium/PostgresDebeziumAvroPayload.java
index 9797f50d87d1..cdfc737391b7 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/model/debezium/PostgresDebeziumAvroPayload.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/model/debezium/PostgresDebeziumAvroPayload.java
@@ -83,7 +83,7 @@ public class PostgresDebeziumAvroPayload extends 
AbstractDebeziumAvroPayload {
     Option<IndexedRecord> insertOrDeleteRecord = 
super.combineAndGetUpdateValue(currentValue, schema, properties);
 
     if (insertOrDeleteRecord.isPresent()) {
-      mergeToastedValuesIfPresent(insertOrDeleteRecord.get(), currentValue);
+      return Option.of(mergeToastedValuesIfPresent(insertOrDeleteRecord.get(), 
currentValue));
     }
     return insertOrDeleteRecord;
   }
@@ -96,22 +96,41 @@ public class PostgresDebeziumAvroPayload extends 
AbstractDebeziumAvroPayload {
     Option<IndexedRecord> insertOrDeleteRecord = 
super.combineAndGetUpdateValue(currentValue, schema);
 
     if (insertOrDeleteRecord.isPresent()) {
-      mergeToastedValuesIfPresent(insertOrDeleteRecord.get(), currentValue);
+      return Option.of(mergeToastedValuesIfPresent(insertOrDeleteRecord.get(), 
currentValue));
     }
     return insertOrDeleteRecord;
   }
 
-  private void mergeToastedValuesIfPresent(IndexedRecord incomingRecord, 
IndexedRecord currentRecord) {
+  /**
+   * Returns the incoming record with any TOASTed column backfilled from 
{@code currentRecord}, or
+   * {@code incomingRecord} itself when there is nothing to backfill.
+   *
+   * <p>The backfill is applied to a copy rather than in place: record mergers 
treat "the payload
+   * handed back the same reference" as "the payload changed nothing" and skip 
rebuilding the
+   * engine-native record from the Avro result, which would silently drop the 
backfill. The copy is
+   * only allocated once a TOASTed column is actually found, so records 
without a sentinel are
+   * unaffected.
+   */
+  private IndexedRecord mergeToastedValuesIfPresent(IndexedRecord 
incomingRecord, IndexedRecord currentRecord) {
     List<Schema.Field> fields = incomingRecord.getSchema().getFields();
+    GenericRecord incoming = (GenericRecord) incomingRecord;
+    GenericRecord merged = null;
 
-    fields.forEach(field -> {
+    for (Schema.Field field : fields) {
       // There are only four avro data types that have unconstrained sizes, 
which are
       // NON-NULLABLE STRING, NULLABLE STRING, NON-NULLABLE BYTES, NULLABLE 
BYTES
-      if (((GenericRecord) incomingRecord).get(field.name()) != null
+      if (incoming.get(field.name()) != null
           && (containsStringToastedValues(incomingRecord, field) || 
containsBytesToastedValues(incomingRecord, field))) {
-        ((GenericRecord) incomingRecord).put(field.name(), 
((GenericData.Record) currentRecord).get(field.name()));
+        if (merged == null) {
+          merged = new GenericData.Record(incomingRecord.getSchema());
+          for (Schema.Field f : fields) {
+            merged.put(f.pos(), incoming.get(f.pos()));
+          }
+        }
+        merged.put(field.name(), ((GenericRecord) 
currentRecord).get(field.name()));
       }
-    });
+    }
+    return merged == null ? incomingRecord : merged;
   }
 
   /**
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestPostgresDebeziumToastV6ReadMerge.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestPostgresDebeziumToastV6ReadMerge.scala
new file mode 100644
index 000000000000..d877ccb5ac62
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestPostgresDebeziumToastV6ReadMerge.scala
@@ -0,0 +1,124 @@
+/*
+ * 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.
+ */
+
+package org.apache.hudi.functional
+
+import org.apache.hudi.common.model.debezium.PostgresDebeziumAvroPayload
+
+import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
+
+/**
+ * Regression test: a Postgres CDC TOAST sentinel must be backfilled at read 
time on a
+ * table-version-6 MOR table using [[PostgresDebeziumAvroPayload]].
+ *
+ * A Postgres TOAST column that did not change is emitted by Debezium as the 
sentinel
+ * `__debezium_unavailable_value`; the payload merge is supposed to restore 
the prior column value.
+ * On a v6 table the 1.x read path uses the CUSTOM payload merger 
([[org.apache.hudi.common.model.HoodieAvroRecordMerger]]).
+ * That payload backfills by mutating the incoming Avro record in place and 
returning the same
+ * reference; the merger previously short-circuited `if (updatedRecord == 
newerAvroRecord) return newer`
+ * and returned the engine-native record, which never received the in-place 
backfill — so the raw
+ * sentinel leaked to readers. The fix removes that shortcut and rebuilds the 
result from the merged
+ * Avro record.
+ *
+ * Scenario (single id, MOR base + log slice; `_event_lsn` is the ordering 
field):
+ *   base   : _event_lsn=100, description='Original description'
+ *   update : _event_lsn=200, description='Updated description'
+ *   update : _event_lsn=300, description=`__debezium_unavailable_value` 
(TOAST — column unchanged)
+ *
+ * The winning record by ordering is _event_lsn=300; its TOAST sentinel must 
be backfilled from the
+ * base record, so the read must return 'Original description' (v6 / payload 
semantics). Before the
+ * fix this returned the raw sentinel.
+ */
+class TestPostgresDebeziumToastV6ReadMerge extends HoodieSparkSqlTestBase {
+
+  test("v6 MOR PostgresDebeziumAvroPayload backfills TOAST sentinel at read") {
+    withTempDir { tmp =>
+      withTable(generateTableName) { tableName =>
+        val tableLocation = s"${tmp.getCanonicalPath}/$tableName"
+
+        // Table-version-6 MOR table with the Postgres Debezium payload. 
`hoodie.write.table.version`
+        // = '6' forces the same table version production tables created 
before the Hudi 1.x upgrade
+        // carry, which is the combination that exercises the CUSTOM payload 
read-merge path.
+        spark.sql(
+          s"""
+             |CREATE TABLE $tableName (
+             |  id INT,
+             |  name STRING,
+             |  description STRING,
+             |  _change_operation_type STRING,
+             |  _event_lsn BIGINT
+             |) USING hudi
+             |LOCATION '$tableLocation'
+             |TBLPROPERTIES (
+             |  type = 'mor',
+             |  primaryKey = 'id',
+             |  preCombineField = '_event_lsn',
+             |  payloadClass = 
'${classOf[PostgresDebeziumAvroPayload].getName}',
+             |  'hoodie.spark.sql.insert.into.operation' = 'upsert',
+             |  'hoodie.write.table.version' = '6'
+             |)
+             |""".stripMargin)
+
+        // Batch 1: base snapshot (lsn=100, description='Original 
description') -> base file.
+        spark.sql(
+          s"""
+             |INSERT INTO $tableName
+             |SELECT id, data.* FROM (
+             |  SELECT 1 AS id, named_struct(
+             |    'name', 'John',
+             |    'description', 'Original description',
+             |    '_change_operation_type', 'r',
+             |    '_event_lsn', CAST(100 AS BIGINT)
+             |  ) AS data
+             |) t
+             |""".stripMargin)
+
+        // Batch 2: two CDC updates for id=1 in one upsert -> MOR log file.
+        //   lsn=200: description='Updated description'
+        //   lsn=300: TOAST sentinel (description unchanged in Postgres, so 
Debezium omits it).
+        spark.sql(
+          s"""
+             |INSERT INTO $tableName
+             |SELECT id, data.* FROM (
+             |  SELECT 1 AS id, named_struct(
+             |    'name', 'John Updated',
+             |    'description', 'Updated description',
+             |    '_change_operation_type', 'u',
+             |    '_event_lsn', CAST(200 AS BIGINT)
+             |  ) AS data
+             |  UNION ALL
+             |  SELECT 1, named_struct(
+             |    'name', 'John Updated Again',
+             |    'description', 
'${PostgresDebeziumAvroPayload.DEBEZIUM_TOASTED_VALUE}',
+             |    '_change_operation_type', 'u',
+             |    '_event_lsn', CAST(300 AS BIGINT)
+             |  )
+             |) t
+             |""".stripMargin)
+
+        // The base+log read merge must backfill the TOAST sentinel from the 
base record. The winning
+        // record is lsn=300 (name='John Updated Again'), and its description 
must be restored to the
+        // prior value 'Original description' rather than the raw sentinel.
+        checkAnswer(s"SELECT id, name, description, _event_lsn FROM $tableName 
WHERE id = 1")(
+          Seq(1, "John Updated Again", "Original description", 300L)
+        )
+      }
+    }
+  }
+}

Reply via email to