Skip to content

[FLINK-38647] Support changelog-mode=upsert + null-tolerance#4437

Open
cansakiroglu wants to merge 2 commits into
apache:masterfrom
cansakiroglu:FLINK-38647-postgres-changelog-mode-and-serializer
Open

[FLINK-38647] Support changelog-mode=upsert + null-tolerance#4437
cansakiroglu wants to merge 2 commits into
apache:masterfrom
cansakiroglu:FLINK-38647-postgres-changelog-mode-and-serializer

Conversation

@cansakiroglu

Copy link
Copy Markdown

FLINK-38647 reports a NullPointerException from the Postgres Pipeline source when a captured table has REPLICA IDENTITY DEFAULT and an UPDATE arrives. Root cause: PostgresDataSource hardcodes DebeziumChangelogMode.ALL, which makes the deserializer extract a before-image that is null under DEFAULT replication, hence NPE'ing.

This change fixes the issue from two angles:

  1. Connector side: expose 'changelog-mode' YAML option on the Postgres Pipeline connector. Accepts 'all' (default, current behaviour) or 'upsert'. In upsert mode the source emits UPDATE events with before == null and only after populated, so the pipeline runs cleanly under REPLICA IDENTITY DEFAULT without requiring FULL (which increases WAL volume).

  2. Runtime side: make serializer null-tolerant. Three changes:

  • copy() null-guards each recordDataSerializer.copy() call so the chained CopyingChainingOutput.pushToOperator path stops NPE'ing on null before/after.
  • serialize() writes 2 leading boolean presence flags before conditionally writing each record. Required because the previous serialize() already skipped writing null fields, but deserialize() always tried to read two records back.
  • deserialize() reads the 2 flag bytes and skips the corresponding read when absent.

FLINK-38647 reports a NullPointerException from the Postgres Pipeline source
when a captured table has REPLICA IDENTITY DEFAULT and an UPDATE arrives that
does not change the primary key. Root cause: PostgresDataSource hardcodes
DebeziumChangelogMode.ALL, which makes the deserializer extract a before-image
that is null under DEFAULT replication, NPE'ing in
DebeziumSchemaDataTypeInference.inferStruct.

This change fixes the issue from two angles:

1. Connector side — expose 'changelog-mode' YAML option on the Postgres
   Pipeline connector (mirrors the legacy SQL connector). Accepts 'all'
   (default, current behaviour) or 'upsert'. In upsert mode the source emits
   UPDATE events with before == null and only after populated, so the
   pipeline runs cleanly under REPLICA IDENTITY DEFAULT without requiring
   FULL (which roughly multiplies WAL volume per UPDATE by column count).

2. Runtime side — make DataChangeEventSerializer null-tolerant. Three changes:
   - copy() null-guards each recordDataSerializer.copy() call so the chained
     CopyingChainingOutput.pushToOperator path stops NPE'ing on null
     before/after.
   - serialize() writes 2 leading boolean presence flags before conditionally
     writing each record. Required because the previous serialize() already
     skipped writing null fields, but deserialize() always tried to read two
     records back — the wire format itself couldn't roundtrip a null-before
     UPDATE.
   - deserialize() reads the 2 flag bytes and skips the corresponding read
     when absent.

Wire format change is symmetric but breaking vs current 3.6.x. Production
checkpoints/savepoints written by older versions cannot be restored by the
new code without a snapshot-version-aware deserialize path; see the PR
description for the proposed compat strategy (bumping
DataChangeEventSerializerSnapshot.CURRENT_VERSION to 2 and reading old
format when version == 1). Happy to add that in a follow-up commit on this
PR — wanted reviewers' opinion on the strategy first.

Signed-off-by: Mehmet Can Şakiroğlu <cansakiroglu@gmail.com>
@cansakiroglu cansakiroglu changed the title [FLINK-38647] Support changelog-mode=upsert + null-tolerancy [FLINK-38647] Support changelog-mode=upsert + null-tolerance Jun 4, 2026
Comment on lines +285 to +289
@Experimental
public static final ConfigOption<String> CHANGELOG_MODE =
ConfigOptions.key("changelog-mode")
.stringType()
.defaultValue("all")

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.

I noticed the existing SQL connector (flink-connector-postgres-cdc) defines this option using enumType(DebeziumChangelogMode.class). It might be worth aligning with that approach — it would give us compile-time safety and also avoid the null handling concern in the factory. Something like:

public static final ConfigOption<DebeziumChangelogMode> CHANGELOG_MODE =
        ConfigOptions.key("changelog-mode")
                .enumType(DebeziumChangelogMode.class)
                .defaultValue(DebeziumChangelogMode.ALL);

Comment on lines +208 to +210
String changelogModeRaw = config.get(CHANGELOG_MODE);
DebeziumChangelogMode changelogMode;
switch (changelogModeRaw.toLowerCase()) {

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.

If enumType is adopted for CHANGELOG_MODE, this switch block could be simplified to a single config.get(CHANGELOG_MODE) call.
Also, it might be good to add a primary key validation for upsert mode, similar to what the SQL connector does in PostgreSQLTableFactory.

Comment on lines +70 to +71
target.writeBoolean(beforePresent);
target.writeBoolean(afterPresent);

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 looks like a wire format breaking change — old deserializers would interpret these 2 boolean bytes as the start of the before RecordData, which could cause stream corruption on checkpoint restore.Would it be possible to bump DataChangeEventSerializerSnapshot.CURRENT_VERSION to 2 in this PR and add a version-aware fallback?
I think this would be important to include before merging, since checkpoint restoration is a production-critical path.

@haruki-830

Copy link
Copy Markdown
Contributor

Nice PR! I've left a few inline comments with suggestions on the implementation. It would also be great to have some test coverage — mainly roundtrip tests for the serializer with null before/after, and a basic unit test. Looking forward to the updates!

…n, versioned deserialize, tests

- PostgresDataSourceOptions: define 'changelog-mode' via
  enumType(DebeziumChangelogMode.class), aligning with the SQL connector;
  invalid values now fail at option validation.
- PostgresDataSourceFactory: drop the string switch; validate that all
  captured tables have a primary key when changelog-mode=upsert, mirroring
  PostgreSQLTableFactory.
- DataChangeEventSerializer: introduce CURRENT_VERSION = 2 and a
  version-aware deserialize(int, DataInputView) that still reads the
  pre-flags op-dependent layout, following the
  PhysicalColumnSerializer/SchemaSerializer convention. DataChangeEvent is
  never persisted in checkpoint state, so the snapshot stays a
  SimpleTypeSerializerSnapshot; the versioned overload keeps the old layout
  readable if event bytes are ever embedded in versioned state.
- Tests: null-before UPDATE events in DataChangeEventSerializerTest test
  data (also exercised via EventSerializerTest), legacy-version and
  unknown-version deserialize tests, an upsert-mode roundtrip test, and
  factory tests for option parsing and PK validation.
@cansakiroglu cansakiroglu requested a review from haruki-830 July 6, 2026 13:47
@dev-donghwan

dev-donghwan commented Jul 9, 2026

Copy link
Copy Markdown

@cansakiroglu

Thank you for this PR.

This PR resolves the source part, but the sink part is still broken.

Case:

  1. flink-cdc pipeline -> Paimon sink (changelog-mode: upsert)
  2. the Paimon sink calls PaimonWriterHelper.convertEventToFullGenericRows()
  3. the UPDATE branch requires before -> NPE, and the job fails permanently
SinkWrapperException: Failed to handle event ... DataChangeEvent{tableId=..., before=null, ..., op=UPDATE}
Caused by: java.lang.NullPointerException:
  Cannot invoke "org.apache.flink.cdc.common.data.RecordData.getArity()" because "recordData" is null
  at PaimonWriterHelper.convertRecordDataToGenericRow(...)
  at PaimonWriterHelper.convertEventToFullGenericRows(...)   // UPDATE branch reads before() unconditionally

The reason is that a PostgreSQL upsert event does not have before, only after — the same failure pattern FLINK-39684 fixed for REPLACE in the same method.

We verified a one-line guard end-to-end (PG -> Paimon, DML + schema evolution):

case UPDATE:
    if (hasPrimaryKey && dataChangeEvent.before() != null) {   // skip UPDATE_BEFORE when there is nothing to build it from
        ...

For primary-key tables this is safe: +U-only and -U++U converge to the same merged state, and PK-changing updates never arrive as UPDATE (Debezium decomposes them into DELETE + CREATE). Repro test: DataChangeEvent.updateEvent(tableId, null, afterData) in PaimonWriterHelperTest — every existing test passes a non-null before.

Note: the Kafka sink has the same pattern — CanalJsonSerializationSchema and DebeziumJsonSerializationSchema read before() for UPDATE (and REPLACE) too.

Would you prefer to include the sink-side null-tolerance in this PR, or should we file a separate JIRA as a follow-up to FLINK-39684?

cc. @haruki-830 @leonardBang @yuxiqian

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants