Skip to content

fix(debezium): keep Postgres toasted columns on MOR read (v6/v8)#19280

Open
linliu-code wants to merge 1 commit into
apache:masterfrom
linliu-code:fix-debezium-toasted-inplace-merge
Open

fix(debezium): keep Postgres toasted columns on MOR read (v6/v8)#19280
linliu-code wants to merge 1 commit into
apache:masterfrom
linliu-code:fix-debezium-toasted-inplace-merge

Conversation

@linliu-code

@linliu-code linliu-code commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

On a Merge-on-Read table written with PostgresDebeziumAvroPayload, a snapshot read could return the raw sentinel string __debezium_unavailable_value for TOASTed columns (large STRING/BYTES columns Debezium could not capture in an UPDATE) instead of the real value. This affected pre-9 table versions (6 and 8); version 9 resolves toasted values through the FILL_UNAVAILABLE partial-update handler and was unaffected.

Root cause: the payload fills toasted columns from the base value by mutating the incoming avro record in place and returning that same reference. HoodieAvroRecordMerger has an identity short-circuit — when the payload returns the same object it was handed, the merger returns the engine-backed newer record rather than rebuilding. Since newer is a separate avro conversion of the same row, the in-place fill was dropped and the sentinel survived into the merged result.

Summary and Changelog

Make the payload return a new record only when it actually fills a toasted column (a lazy shallow copy), leaving the record untouched otherwise. The merger's identity short-circuit then still fires for every normal merge, and only the toasted case falls through to rebuild, so the fill survives. HoodieAvroRecordMerger is unchanged.

  • PostgresDebeziumAvroPayload.mergeToastedValuesIfPresent now returns an IndexedRecord (a distinct shallow copy when it fills a toasted column, else the original) instead of mutating in place; both combineAndGetUpdateValue overloads return that record.
  • Adds TestDebeziumToastedValue, a parameterized functional test over table versions 6, 8 and 9.

Impact

Fixes wrong data (sentinel placeholder leaking into query results) for Postgres Debezium toasted columns on MOR snapshot reads for table versions 6 and 8. No public API change. The common merge path is unchanged (the merger's zero-copy short-circuit still applies); only a toasted-column merge does one extra shallow record copy.

Risk Level

low

The change is confined to PostgresDebeziumAvroPayload; the record merger is untouched. Covered by the new parameterized functional test across table versions 6, 8 and 9.

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

@linliu-code linliu-code changed the title Fix Debezium Postgres TOASTed-column fill dropped by HoodieAvroRecordMerger fix: Fix Debezium Postgres TOASTed-column fill dropped by HoodieAvroRecordMerger Jul 13, 2026

@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! The PR removes an incorrect short-circuit in HoodieAvroRecordMerger#merge so that a payload's in-place Avro mutation (e.g. PostgresDebeziumAvroPayload filling TOASTed columns from the base value) survives the merge on MoR table versions 6/8, with a new functional test covering versions 6, 8 and 9. The correctness reasoning holds up; one behavioral edge case around record operation metadata is worth double-checking in the inline comment. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here. A couple of minor consistency nits in the new test file; the merger fix and its comment look clean.

@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

@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Jul 13, 2026
@linliu-code
linliu-code force-pushed the fix-debezium-toasted-inplace-merge branch from 2b9be8a to ece2039 Compare July 13, 2026 21:27
@linliu-code linliu-code changed the title fix: Fix Debezium Postgres TOASTed-column fill dropped by HoodieAvroRecordMerger [MINOR] Keep Postgres toasted columns on merge-on-read snapshot read Jul 13, 2026
@linliu-code
linliu-code force-pushed the fix-debezium-toasted-inplace-merge branch from ece2039 to d30ce02 Compare July 13, 2026 21:31
@linliu-code linliu-code changed the title [MINOR] Keep Postgres toasted columns on merge-on-read snapshot read fix(debezium): keep Postgres toasted columns on MOR read (v6/v8) Jul 13, 2026

@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! The PR makes PostgresDebeziumAvroPayload return a fresh (shallow-copied) record only when it fills a TOASTed column, so the merger's identity short-circuit no longer discards the fill on MOR reads for table versions 6/8. The core mechanism looks sound; one edge case around the record's concrete type is worth double-checking in the inline comment. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here. One minor naming nit in the test; the production change and its Javadoc are clean.

&& (containsStringToastedValues(incomingRecord, field) || containsBytesToastedValues(incomingRecord, field))) {
((GenericRecord) incomingRecord).put(field.name(), ((GenericData.Record) currentRecord).get(field.name()));
if (filled == null) {
filled = new GenericData.Record((GenericData.Record) incomingRecord, false);

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.

🤖 Could incomingRecord here ever be a SerializableIndexedRecord rather than a GenericData.Record? BaseAvroPayload.getRecord() returns a SerializableIndexedRecord when the stored record's schema isn't reference-equal to the requested schema, and in the avro file-group-reader path AvroRecordContext.convertToAvroRecord just casts newer.getRecord(). If so, new GenericData.Record((GenericData.Record) incomingRecord, false) would throw a ClassCastException on a toasted fill. The old in-place code used only the GenericRecord interface for STRING columns, so it was immune — building the copy from incomingRecord.getSchema() and copying via the interface might be safer.

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

// 2. Newer log records at LSN 20, each carrying the toasted sentinel in one column:
// id=1 -> name updated, `city` toasted (must keep base "NYC")
// id=2 -> name toasted (must keep base "bob"), `city` updated
val update = Seq(

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.

🤖 nit: val update is a singular/verb form for what is actually a sequence of records — could you rename it to val updates or val logRecords to make it read as a collection?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

On a Merge-on-Read table written with PostgresDebeziumAvroPayload, a snapshot
read could return the raw sentinel string `__debezium_unavailable_value` for
TOASTed columns (large STRING/BYTES columns Debezium could not capture in an
UPDATE) instead of the real value. This affected pre-9 table versions (6 and 8);
version 9 resolves toasted values through the FILL_UNAVAILABLE partial-update
handler and was unaffected.

The payload fills toasted columns from the base value by mutating the incoming
avro record in place and returning that same reference. HoodieAvroRecordMerger
has an identity short-circuit: when the payload returns the same object it was
handed, the merger returns the engine-backed `newer` record rather than
rebuilding. Since `newer` is a separate avro conversion of the same row, the
in-place fill was dropped and the sentinel survived into the merged result.

Make the payload return a new record only when it actually fills a toasted
column (a lazy shallow copy), leaving the record untouched otherwise. The
merger's identity short-circuit then still fires for every normal merge, and
only the toasted case falls through to rebuild, so the fill survives. The
merger is unchanged.

TestDebeziumToastedValue writes a base row then a toasted-update delta on a MOR
table and asserts the snapshot read keeps the prior base value, parameterized
over table versions 6, 8 and 9.
@linliu-code
linliu-code force-pushed the fix-debezium-toasted-inplace-merge branch from d30ce02 to 02e915d Compare July 17, 2026 16:08
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.

3 participants