Skip to content

fix(debezium): backfill Postgres TOAST columns on a copy so the merge result survives - #19749

Merged
danny0405 merged 1 commit into
apache:masterfrom
lokeshj1703:oss-avro-merger-toast-fix
Aug 27, 2026
Merged

fix(debezium): backfill Postgres TOAST columns on a copy so the merge result survives#19749
danny0405 merged 1 commit into
apache:masterfrom
lokeshj1703:oss-avro-merger-toast-fix

Conversation

@lokeshj1703

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

closes #19748

Summary and Changelog

On a table-version-6 MOR table using PostgresDebeziumAvroPayload, an unchanged Postgres TOAST column (emitted as the sentinel __debezium_unavailable_value) leaked to readers instead of being backfilled from the prior value.

The payload backfills by mutating the incoming Avro record in place and returning the same reference. HoodieAvroRecordMerger.merge had an identity shortcut if (updatedRecord == newerAvroRecord) return newer; that returned the engine-native newer record, which never received the in-place mutation, so the merge result was discarded. This removes the shortcut so the result is always rebuilt from updatedRecord.

There are two ways to fix this. This PR takes the merger-side approach, which also protects any other payload that mutates the incoming record in place and returns the same reference. The alternative, #19280, changes PostgresDebeziumAvroPayload to return a new record on backfill (preserving the merger shortcut). Only one of the two is needed; opening this to compare the approaches.

Adds TestPostgresDebeziumToastV6ReadMerge, which is red without the fix (read returns the raw sentinel) and green with it.

Impact

Correctness is restored for payloads that backfill by mutating the incoming record in place. Trade-off: the removed shortcut was a general fast-path for the common "newer record wins" case of every CUSTOM merge-mode payload (not just Debezium), so the result is now always rebuilt via convertAvroRecord + BufferedRecords.fromEngineRecord. That adds a per-record avro-to-engine round-trip on the CUSTOM-mode merge path (v6 MOR and any custom-payload table), used by both snapshot reads and compaction. The payload-side alternative #19280 avoids this by returning a new record only when a backfill occurs; that is the main reason to prefer one approach over the other.

Risk Level

low. Behavior change is confined to the record merge path and covered by a new functional test.

Documentation Update

none

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Aug 26, 2026
@codecov-commenter

codecov-commenter commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.84%. Comparing base (29b7a52) to head (b093faf).
⚠️ Report is 8 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19749      +/-   ##
============================================
- Coverage     77.96%   77.84%   -0.13%     
- Complexity    33458    33492      +34     
============================================
  Files          2539     2539              
  Lines        140939   141367     +428     
  Branches      17012    17081      +69     
============================================
+ Hits         109890   110050     +160     
- Misses        23388    23657     +269     
+ Partials       7661     7660       -1     
Components Coverage Δ
hudi-common 83.40% <100.00%> (-0.08%) ⬇️
hudi-client 82.96% <ø> (-0.12%) ⬇️
hudi-flink 85.62% <ø> (+0.01%) ⬆️
hudi-spark-datasource 71.87% <ø> (-0.52%) ⬇️
hudi-utilities 74.26% <ø> (-0.06%) ⬇️
hudi-cli 15.06% <ø> (ø)
hudi-hadoop 69.24% <ø> (ø)
hudi-sync 75.54% <ø> (-0.03%) ⬇️
hudi-io 79.85% <ø> (+0.09%) ⬆️
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 64.27% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 51.05% <92.85%> (+0.01%) ⬆️
flink-integration-tests 48.99% <0.00%> (-0.01%) ⬇️
hadoop-mr-java-client 43.71% <0.00%> (-0.03%) ⬇️
spark-client-hadoop-common 50.54% <0.00%> (-0.01%) ⬇️
spark-java-tests 52.03% <100.00%> (+0.02%) ⬆️
spark-scala-tests 46.66% <85.71%> (+0.03%) ⬆️
utilities 36.40% <0.00%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...on/model/debezium/PostgresDebeziumAvroPayload.java 93.02% <100.00%> (-1.27%) ⬇️

... and 26 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lokeshj1703
lokeshj1703 marked this pull request as ready for review August 26, 2026 14:12

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This PR removes the updatedRecord == newerAvroRecord identity shortcut in HoodieAvroRecordMerger.merge so in-place payload backfills (e.g. PostgresDebeziumAvroPayload TOAST) survive, and adds a v6 MOR regression test. The fix addresses a real correctness bug; one behavioral side effect of always rebuilding the result is worth double-checking in the inline comment. Please take a look at the inline comment, and this should be ready for a Hudi committer or PMC member to take it from here.

@yihua yihua left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@yihua

yihua commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

@danny0405 could you help check why Flink test fails on this one? Any suggestions on fixing the issue?

@rahil-c

rahil-c commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

The red job is test-common-and-other-modules (and the same one on Azure, UT FT common & other modules). test-utilities was only cancelled as collateral, and the TestPreWriteValidatorUtils#testRunValidatorsInParallel blip is an unrelated timing flake that passed on retry.

The one real failure is:

[ERROR] org.apache.hudi.client.TestFlinkWriteClientFunctional.testInsertAndUpsertWriteFilesAndCommitMetadata(HoodieTableType, boolean)[1]
[ERROR]   Run 1..4: ...:168->assertWriteStatuses:450 expected: <2> but was: <3>

(both COW parameterizations, all 4 surefire retries)

Why it fails

It isn't really Flink-specific - removing the shortcut also drops the HoodieOperation that newer was carrying.

HoodieWriteMergeHandle.write builds the incoming record with BufferedRecords.fromHoodieRecord(..., deleteContext), which sets hoodieOperation = D (or -U) for a delete. In the COW upsert leg of that test, id2 is exactly that: HoodieOperation.DELETE, ordering value 12 (so isCommitTimeOrderingDelete is false), non-empty payload, _hoodie_is_deleted unset. So the merger goes down the payload path, and combineAndGetUpdateValue hands back the very same GenericRecord it was constructed with (BaseAvroPayload keeps the incoming record in a field; getRecord(schema) returns that reference when the schema matches). That makes updatedRecord == newerAvroRecord true, and the old shortcut returned newer with the delete marker intact.

With the shortcut gone we fall into the rebuild, whose isDelete argument is updatedValue.isEmpty() - which is always false inside if (updatedValue.isPresent()). So the result comes back with hoodieOperation == null, mergeResult.isDelete() is false, HoodieWriteMergeHandle.writeRecord takes the write branch instead of the delete branch, and id2 is written as a normal record: numWrites 2 -> 3 (numDeletes 1 -> 0 would have failed on the next assertion too).

In other words, the shortcut was not purely an optimization - it was the only thing propagating newer's HoodieOperation through the merge.

Suggested fix

Keep the identity branch, but rebuild the data from the merged Avro record while preserving everything else newer carried:

if (updatedRecord == newerAvroRecord) {
  // Some payloads (e.g. PostgresDebeziumAvroPayload's TOAST backfill) merge by mutating the
  // incoming Avro record in place and returning the same reference, so `newer`'s engine-native
  // record can be stale. Refresh the data from the Avro result, but keep everything else that
  // `newer` carried - in particular the D/-U operation, which the write path relies on.
  return new BufferedRecord<>(newer.getRecordKey(), newer.getOrderingValue(),
      recordContext.convertAvroRecord(updatedRecord), newer.getSchemaId(), newer.getHoodieOperation());
}

Verified locally on this branch (a0bed56, JDK 11, -Dspark3.5 -Dscala-2.12 -Dflink2.2):

  • without the fix: TestFlinkWriteClientFunctional -> Tests run: 9, Failures: 2 with the same expected: <2> but was: <3>
  • with the fix: Tests run: 9, Failures: 0, Errors: 0
  • TestPostgresDebeziumToastV6ReadMerge -> Tests: succeeded 1, failed 0 (the backfilled record is still rebuilt from updatedRecord, so the fix for this PR is preserved)

Two side notes while you're in there:

  1. updatedValue.isEmpty() in the remaining rebuild branch is dead-false; a literal false would read more honestly.
  2. This is the cost the PR description already flags - the identity case now pays an Avro -> engine conversion per merged record on every CUSTOM-payload table, not just Debezium ones. If that turns out to matter, the payload-side alternative (fix(debezium): keep Postgres toasted columns on MOR read (v6/v8) #19280) avoids it, since only an actual TOAST backfill would allocate.

return newer;
}
// Do not short-circuit to `newer` when updatedRecord == newerAvroRecord. Some payloads
// (e.g. PostgresDebeziumAvroPayload's TOAST backfill via mergeToastedValuesIfPresent)

@danny0405 danny0405 Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we fix the payload instead of the merger, either:

  • always make the payload immutable and create new one when backfill modifications are needed;
  • impl #equals for all kinds of payloads for more detailed equation check.

so that we avoid perf regression for existing payloads.

@danny0405 danny0405 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rahil-c

rahil-c commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

@lokeshj1703 @danny0405 - I tried Danny's first suggestion (make the payload immutable and return a new record on backfill) locally, and it works: it fixes the TOAST bug, keeps the Flink test green, and leaves the merger hot path untouched so there is no perf regression for existing payloads. Patch below if you want to drop it on the branch.

This supersedes the merger-side suggestion in my earlier comment - Danny's approach is better and I withdraw that one.

The change

Revert HoodieAvroRecordMerger to master (keep the updatedRecord == newerAvroRecord shortcut) and change only PostgresDebeziumAvroPayload:

// both combineAndGetUpdateValue overloads
if (insertOrDeleteRecord.isPresent()) {
  return Option.of(mergeToastedValuesIfPresent(insertOrDeleteRecord.get(), currentValue));
}
return insertOrDeleteRecord;
/**
 * 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;

  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 (incoming.get(field.name()) != null
        && (containsStringToastedValues(incomingRecord, field) || containsBytesToastedValues(incomingRecord, field))) {
      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;
}

The copy-on-write bit matters for Danny's perf point: a record with no TOAST sentinel allocates nothing and still takes the merger shortcut, so the only rows that change behaviour are the ones that actually carry __debezium_unavailable_value.

Verified locally

JDK 11, -Dspark3.5 -Dscala-2.12 -Dflink2.2, merger reverted to master:

Test Result
TestPostgresDebeziumToastV6ReadMerge (this PR's test) Tests: succeeded 1, failed 0
TestFlinkWriteClientFunctional Tests run: 9, Failures: 0, Errors: 0
TestPostgresDebeziumAvroPayload 8/8
TestMySqlDebeziumAvroPayload 13/13
TestBufferedRecordMerger 18/18

Compiles clean with checkstyle enabled.

On the second suggestion (#equals on payloads)

I don't think that one can work here. The merger's check is updatedRecord == newerAvroRecord on the Avro records, not on the payloads - and after an in-place backfill those two are literally the same object, so any equals, reference or deep, returns true and the shortcut still fires. Detecting the mutation by comparison would mean snapshotting every incoming record before calling the payload, which costs strictly more than making the payload immutable.

Why the Flink test was failing, for the record

Removing the shortcut also dropped the HoodieOperation that newer carried, because the rebuild passes updatedValue.isEmpty() as isDelete and that is always false inside if (updatedValue.isPresent()). In the COW upsert leg of TestFlinkWriteClientFunctional, id2 is a HoodieOperation.DELETE record whose delete-ness lives only in the operation field (no _hoodie_is_deleted, non-empty payload), so combineAndGetUpdateValue returned the same reference, the shortcut used to preserve the marker, and without it the row got written instead of deleted - numWrites 2 -> 3. The payload-side fix avoids this entirely, since the shortcut stays in place for every record that isn't being backfilled.

… result survives

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.
@lokeshj1703
lokeshj1703 force-pushed the oss-avro-merger-toast-fix branch from a0bed56 to b093faf Compare August 27, 2026 07:44
@lokeshj1703 lokeshj1703 changed the title fix(common): rebuild merge result so in-place payload backfills survive fix(debezium): backfill Postgres TOAST columns on a copy so the merge result survives Aug 27, 2026
@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

@danny0405
danny0405 merged commit d43fa7f into apache:master Aug 27, 2026
23 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M PR with lines of changes in (100, 300]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Postgres Debezium TOAST sentinel leaks to readers on table-version-6 MOR tables

7 participants