[
https://issues.apache.org/jira/browse/FLINK-41036?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Mohsen Rezaei updated FLINK-41036:
----------------------------------
Description:
If a Postgres CDC job is restored from a checkpoint taken while a large
transaction was in flight, the restore runs out of heap (OutOfMemoryError). The
transaction does not need to touch a captured table; its tables only need to be
in the publication.
The cause is that heartbeats move the checkpointed offset into the middle of
the transaction. On restore, Debezium then re-reads the transaction from BEGIN
and keeps one entry per message in memory until it reaches the stored offset
(unbounded set, reported in
[debezium/dbz#2905|https://github.com/debezium/dbz/issues/2905]).
Reproduced on unmodified apache/flink-cdc master (abf18d7a8), Debezium
1.9.8.Final, Flink 1.20.3, PostgreSQL 16, {{-Xmx512m}} on restore.
h3. When it happens
* The publication contains tables the source does not capture (e.g. a
user-managed or shared publication; the default {{filtered}} autocreate mode
cannot trigger it).
* One transaction on those tables with a very large number of WAL messages.
* A checkpoint is taken during it and the job is later restored from it (e.g.
after a TaskManager failure).
h3. Evidence
Table {{a}} is captured and gets one row; then one transaction inserts
8,000,000 rows into {{b}} (in the publication, not captured). Stream split
offset logged at each checkpoint:
||Checkpoint||heartbeat.interval.ms=1000||heartbeat.interval.ms=0||
|9 / 8|{{0/1524A10}} (last commit)|{{0/1524900}} (last commit)|
|15 / 12|{{0/10741438}} (inside the transaction)|{{0/1524900}}|
|18 / 16|{{0/3E1157C8}} (inside the transaction)|{{0/1524900}}|
No record for {{a}} is emitted during the transaction, so only the heartbeat
moves the offset ({{IncrementalSourceRecordEmitter}} L118-120).
Restore from the heartbeat run's checkpoint (kill -9 after the commit,
{{-Xmx512m}}):
{code}
Looking for WAL restart position for last commit LSN 'LSN{0/1524A10}' and last
change LSN 'LSN{0/4D6AFAB8}'
Searching for WAL resume position
ERROR ErrorHandler - Producer failure
java.lang.OutOfMemoryError: Java heap space
at java.util.HashSet.add(HashSet.java:221)
at
io.debezium.connector.postgresql.connection.WalPositionLocator.resumeFromLsn(WalPositionLocator.java:63)
at
io.debezium.connector.postgresql.PostgresStreamingChangeEventSource.searchWalPosition(PostgresStreamingChangeEventSource.java:407)
...
{code}
The heap histogram shows about 2M {{HashMap$Node}} and {{Lsn}} instances after
10 s, roughly 60-80 bytes per message (heap dump available).
Control: restoring the {{heartbeat.interval.ms=0}} checkpoint (stored offset
equals the last commit) finds the resume position after 5 ms and stays at about
38 MiB.
{{heartbeat.interval.ms=0}} is not a workaround: the slot then does not advance
during the transaction ({{confirmed_flush_lsn}} stayed at {{0/1524900}} while
the server was at {{0/5682ADD8}}), and {{PostgresSourceConfigFactory}} L90-94
says the heartbeat is required for Postgres.
h3. Possible directions
* Do not apply a heartbeat offset to the checkpointed stream split while a
transaction is in flight; keep the last-commit position until COMMIT.
* Skip the position search when everything since the last commit was filtered
out.
* Bound the memory of the search in Debezium (see the linked issue).
h3. Reproduction
Harness attached ({{FlinkRepro.java}}, {{run.sh}}, {{restore.sh}}).
# Postgres 16 with {{wal_level=logical}}; tables {{a}}, {{b}}; publication
{{FOR TABLE a, b}} ({{publication.autocreate.mode=disabled}}).
# Start {{FlinkRepro}} (-Xmx1g), heartbeat 1000 ms, checkpoint interval 2 s,
startup mode latest.
# Insert one row into {{a}}, then 8,000,000 rows into {{b}} in one transaction.
# About 12 s after the commit, kill -9 the job.
# Restart with {{-Xmx512m}} and {{execution.savepoint.path}} set to the latest
{{chk-N}} directory.
h3. Related
* FLINK-40538, FLINK-39265
* Debezium: [debezium/dbz#2905|https://github.com/debezium/dbz/issues/2905]
(unbounded {{{}WalPositionLocator.lsnSeen{}}})
[~loserwang1024] [~leonard] [~eskabetxe] since you have worked on the Postgres
CDC restore path (FLINK-40538), I would appreciate your thoughts on the
proposed fix.
was:
When a Postgres CDC source ({{{}PostgresIncrementalSource{}}}) is restored from
a checkpoint or savepoint that was taken while a large transaction was in
flight, the restore re-reads the whole transaction up to the stored position
and keeps one entry per message in memory. The TaskManager runs out of heap
({{{}OutOfMemoryError{}}}). The transaction does not need to touch any captured
table: it is enough that its tables are in the publication.
The root cause is that heartbeat events move the checkpointed stream offset to
a position in the middle of a transaction, which forces the expensive WAL
position search on restore. I reproduced this end to end with an unmodified on
{{{}apache/flink-cdc{}}}'s {{master}} and show that the failure disappears when
the offset is not moved by heartbeats.
h3. Trigger conditions
# A publication that contains tables the source does not capture (for example
a user-managed {{FOR ALL TABLES}} publication, or one shared with other
consumers). With the default publication.autocreate.mode=filtered the
publication only holds the captured tables and this cannot happen.
# One transaction with a very large number of WAL messages on such tables.
# A checkpoint or savepoint is taken while that transaction is being processed
(certain with a 30 s heartbeat and a long transaction).
# The job is restored from it, for example after a TaskManager failure.
h3. Evidence
h4. 1. The checkpointed offset moves into the transaction because of heartbeats
The reader logs the stream split offset at every checkpoint ("Stream split
offset on checkpoint N", {{{}IncrementalSourceReader L572{}}}). Table a
(captured) receives one row, then a single transaction inserts 8,000,000 rows
into table b (in the publication, not captured).
{{{}heartbeat.interval.ms=1000{}}}:
{code:java}
checkpoint 6 : Offset{lsn=LSN{0/1524868}, txId=732} before the insert
checkpoint 9 : Offset{lsn=LSN{0/1524A10}, txId=734} last commit on 'a'
checkpoint 12: Offset{lsn=LSN{0/1524A10}, txId=734}
checkpoint 15: Offset{lsn=LSN{0/10741438}, txId=735} inside the big
transaction
checkpoint 18: Offset{lsn=LSN{0/3E1157C8}, txId=735} inside the big
transaction
{code}
No record for table a was emitted during the transaction, so the only thing
that moved the offset is the heartbeat ({{{}IncrementalSourceRecordEmitter
L118-120{}}} calls {{updateStreamSplitState}} for heartbeat records). The same
run with {{heartbeat.interval.ms=0}} keeps the offset at the last commit for
the whole transaction:
{code:java}
checkpoint 8 : Offset{lsn=LSN{0/1524900}, txId=734}
checkpoint 12: Offset{lsn=LSN{0/1524900}, txId=734}
checkpoint 16: Offset{lsn=LSN{0/1524900}, txId=734}
{code}
h4. 2. Restoring from such a checkpoint runs out of memory
The job was killed with {{kill -9}} about 12 s after the commit and restored
from the latest retained checkpoint ({{{}chk-19{}}}, heartbeat 1 s) with
{{{}-Xmx512m{}}}:
{code:java}
Retrieved latest position from stored offset 'LSN{0/4D6AFAB8}'
Looking for WAL restart position for last commit LSN 'LSN{0/1524A10}' and last
change LSN 'LSN{0/4D6AFAB8}'
Searching for WAL resume position
ERROR ErrorHandler - Producer failure
java.lang.OutOfMemoryError: Java heap space
at java.util.HashMap.resize(HashMap.java:702)
at java.util.HashMap.putVal(HashMap.java:661)
at java.util.HashMap.put(HashMap.java:610)
at java.util.HashSet.add(HashSet.java:221)
at
io.debezium.connector.postgresql.connection.WalPositionLocator.resumeFromLsn(WalPositionLocator.java:63)
at
io.debezium.connector.postgresql.PostgresStreamingChangeEventSource.lambda$searchWalPosition$1(PostgresStreamingChangeEventSource.java:410)
at
io.debezium.connector.postgresql.connection.pgoutput.PgOutputMessageDecoder.decodeInsert(PgOutputMessageDecoder.java:413)
at
io.debezium.connector.postgresql.connection.pgoutput.PgOutputMessageDecoder.processNotEmptyMessage(PgOutputMessageDecoder.java:191)
at
io.debezium.connector.postgresql.connection.PostgresReplicationConnection$1.readPending(PostgresReplicationConnection.java:611)
at
io.debezium.connector.postgresql.PostgresStreamingChangeEventSource.searchWalPosition(PostgresStreamingChangeEventSource.java:407)
at
io.debezium.connector.postgresql.PostgresStreamingChangeEventSource.execute(PostgresStreamingChangeEventSource.java:200)
at
org.apache.flink.cdc.connectors.postgres.source.fetch.PostgresStreamFetchTask$StreamSplitReadTask.execute(PostgresStreamFetchTask.java:223)
{code}
Class histogram of the TaskManager heap ten seconds into the search ({{{}jcmd
GC.class_histogram{}}}):
||Class||Instances||Bytes||
|java.util.HashMap$Node|1,920,337|61 MB|
|io.debezium.connector.postgresql.connection.Lsn|2,126,036|51 MB|
That is about 60-80 bytes per message, so the memory needed grows linearly with
the distance between the start of the transaction and the stored position (a
heap dump of about 750 MB was written with {{-XX:+HeapDumpOnOutOfMemoryError}}
and can be added).
h4. 3. Control: same restore without heartbeat finishes the search immediately
Restoring chk-19 of the {{heartbeat.interval.ms=0}} run (stored offset
{{{}0/1524900{}}}, equal to the last commit) with the same {{{}-Xmx512m{}}}:
{code:java}
Looking for WAL restart position for last commit LSN 'LSN{0/1524900}' and last
change LSN 'LSN{0/1524900}'
Searching for WAL resume position
WAL resume position 'LSN{0/1524A10}' discovered
MEM used=38MiB max=494MiB
{code}
The resume position is found 5 ms after the search starts, because the first
message after the stored offset is already past it. Heap stays at about 38 MiB,
and the job keeps running and checkpointing.
h4. 4. Why heartbeat.interval.ms=0 is not a usable workaround
In the run without heartbeat the replication slot does not move at all while
the large transaction is consumed: at the end of the run
{{confirmed_flush_lsn}} was {{0/1524900}} while {{pg_current_wal_lsn()}} was
{{{}0/5682ADD8{}}}. With heartbeat enabled confirmed_flush_lsn followed the
consumer ({{{}0/4D6AFAB8{}}}). Without heartbeat the slot retains WAL for the
whole transaction (and for any other activity that is filtered out), and
{{PostgresSourceConfigFactory L90-94}} states that the heartbeat is required
for Postgres ("we have to enable heartbeat for PG to make sure
{{DebeziumChangeConsumer#handleBatch}} is invoked after job restart"). The
default is 30 s ({{{}PostgresSourceOptions L74-77{}}}).
h3. Analysis
# Debezium emits a heartbeat after dispatched events, including events that
are filtered out (rate-limited by {{{}heartbeat.interval.ms{}}}), and filtered
messages still advance lsn and lsn_proc.
# {{IncrementalSourceRecordEmitter#processElement}} applies the heartbeat
offset to the stream split ({{{}L118-120{}}}), so the next checkpoint stores an
lsn_proc in the middle of a transaction.
# On restore {{hasCompletelyProcessedPosition()}} is true for that offset, so
{{PostgresStreamingChangeEventSource}} runs {{{}searchWalPosition(){}}}.
pgoutput re-sends the whole transaction from {{BEGIN}} and Debezium's
{{WalPositionLocator}} keeps every LSN in a HashSet ({{{}lsnSeen{}}}) until the
stored position shows up. Memory is proportional to the distance from {{BEGIN}}
to the stored position. The unbounded set is Debezium behaviour and is reported
there separately:
[debezium/dbz#2905|https://github.com/debezium/dbz/issues/2905].
The Flink CDC part is that the checkpointed offset can point into the middle of
a transaction at all. Without heartbeats the stored offset stays at the last
commit (evidence 1 and 3).
h3. Possible directions
* Do not apply a heartbeat offset to the checkpointed stream split while a
transaction is in flight; keep the last-commit position until {{{}COMMIT{}}}.
The slot can still be advanced through the commit path.
* Skip the position search when everything since the last commit was filtered
out.
* Bound the memory of the search in Debezium (see the linked issue).
h3. Reproduction
The harness (FlinkRepro.java, run.sh, restore.sh) is attached. In short:
# docker run {{postgres:16}} with {{{}wal_level=logical{}}}; create tables a
and b and a publication dbz_pub FOR TABLE a, b
(publication.autocreate.mode=disabled).
# Start FlinkRepro ({{{}-Xmx1g{}}}) with {{{}heartbeat.interval.ms=1000{}}},
checkpoint interval 2 s, startup mode latest.
# Insert one row into a, then insert 8,000,000 rows into b in one transaction.
# About 12 s after the commit, {{kill -9}} the job.
# Restart FlinkRepro with {{-Xmx512m}} and {{execution.savepoint.path}} set to
the latest retained chk-N directory.
Result: {{OutOfMemoryError}} in {{{}WalPositionLocator.resumeFromLsn{}}}. With
{{heartbeat.interval.ms=0}} (control) the same restore completes in
milliseconds.
{code:java}
import java.time.Duration;
import java.util.Properties;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.cdc.connectors.base.options.StartupOptions;
import org.apache.flink.cdc.connectors.postgres.source.PostgresSourceBuilder;
import org.apache.flink.cdc.debezium.JsonDebeziumDeserializationSchema;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.sink.DiscardingSink;
/** args: <checkpointDir> <heartbeatMs> [restoreFromCheckpointPath] */
public class FlinkRepro {
public static void main(String[] a) throws Exception {
Configuration conf = new Configuration();
conf.setString("state.checkpoints.dir", "file://" + a[0]);
conf.setString("execution.checkpointing.externalized-checkpoint-retention",
"RETAIN_ON_CANCELLATION");
conf.setString("restart-strategy.type", "none");
if (a.length > 2) conf.setString("execution.savepoint.path", "file://"
+ a[2]);
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment(conf);
env.setParallelism(1);
env.enableCheckpointing(2000);
Properties dbz = new Properties();
dbz.setProperty("publication.name", "dbz_pub");
dbz.setProperty("publication.autocreate.mode", "disabled");
var src =
PostgresSourceBuilder.PostgresIncrementalSource.<String>builder()
.hostname("localhost").port(55432).database("postgres")
.schemaList("public").tableList("public.a")
.username("postgres").password("pw")
.slotName("flink_slot").decodingPluginName("pgoutput")
.deserializer(new JsonDebeziumDeserializationSchema())
.startupOptions(StartupOptions.latest())
.heartbeatInterval(Duration.ofMillis(Long.parseLong(a[1])))
.debeziumProperties(dbz)
.build();
env.fromSource(src, WatermarkStrategy.noWatermarks(), "pg").addSink(new
DiscardingSink<>());
Runtime rt = Runtime.getRuntime();
Thread t = new Thread(() -> {
try {
while (true) {
Thread.sleep(5000);
System.out.printf("MEM used=%dMiB max=%dMiB%n",
(rt.totalMemory()-rt.freeMemory())>>20, rt.maxMemory()>>20);
}
} catch (Exception e) {}
});
t.setDaemon(true); t.start();
env.execute("repro");
}
}
{code}
h3. Related
* FLINK-40538, FLINK-39265
* Debezium: [debezium/dbz#2905|https://github.com/debezium/dbz/issues/2905]
(unbounded {{{}WalPositionLocator.lsnSeen{}}})
[~loserwang1024] [~leonard] [~eskabetxe] since you have worked on the Postgres
CDC restore path (FLINK-40538), I would appreciate your thoughts on the
proposed fix.
> [postgres-cdc] Restoring from a checkpoint taken inside a large transaction
> runs out of heap
> --------------------------------------------------------------------------------------------
>
> Key: FLINK-41036
> URL: https://issues.apache.org/jira/browse/FLINK-41036
> Project: Flink
> Issue Type: Bug
> Components: Flink CDC
> Environment: * apache/flink-cdc master, commit {{abf18d7a8}}
> ({{3.8-SNAPSHOT}}), unmodified; Debezium {{1.9.8.Final}}
> * Flink {{{}1.20.3{}}}, local {{{}MiniCluster{}}}, parallelism 1, checkpoint
> interval 2 s, externalized checkpoints ({{{}RETAIN_ON_CANCELLATION{}}})
> * PostgreSQL {{16.15}} ({{{}postgres:16{}}} container), {{{}pgoutput{}}},
> one slot, one user-managed publication
> * JDK 17, {{{}-XX:+UseSerialGC{}}}, {{-Xmx512m}} on restore
> Reporter: Mohsen Rezaei
> Priority: Major
>
> If a Postgres CDC job is restored from a checkpoint taken while a large
> transaction was in flight, the restore runs out of heap (OutOfMemoryError).
> The transaction does not need to touch a captured table; its tables only need
> to be in the publication.
> The cause is that heartbeats move the checkpointed offset into the middle of
> the transaction. On restore, Debezium then re-reads the transaction from
> BEGIN and keeps one entry per message in memory until it reaches the stored
> offset (unbounded set, reported in
> [debezium/dbz#2905|https://github.com/debezium/dbz/issues/2905]).
> Reproduced on unmodified apache/flink-cdc master (abf18d7a8), Debezium
> 1.9.8.Final, Flink 1.20.3, PostgreSQL 16, {{-Xmx512m}} on restore.
> h3. When it happens
> * The publication contains tables the source does not capture (e.g. a
> user-managed or shared publication; the default {{filtered}} autocreate mode
> cannot trigger it).
> * One transaction on those tables with a very large number of WAL messages.
> * A checkpoint is taken during it and the job is later restored from it (e.g.
> after a TaskManager failure).
> h3. Evidence
> Table {{a}} is captured and gets one row; then one transaction inserts
> 8,000,000 rows into {{b}} (in the publication, not captured). Stream split
> offset logged at each checkpoint:
> ||Checkpoint||heartbeat.interval.ms=1000||heartbeat.interval.ms=0||
> |9 / 8|{{0/1524A10}} (last commit)|{{0/1524900}} (last commit)|
> |15 / 12|{{0/10741438}} (inside the transaction)|{{0/1524900}}|
> |18 / 16|{{0/3E1157C8}} (inside the transaction)|{{0/1524900}}|
> No record for {{a}} is emitted during the transaction, so only the heartbeat
> moves the offset ({{IncrementalSourceRecordEmitter}} L118-120).
> Restore from the heartbeat run's checkpoint (kill -9 after the commit,
> {{-Xmx512m}}):
> {code}
> Looking for WAL restart position for last commit LSN 'LSN{0/1524A10}' and
> last change LSN 'LSN{0/4D6AFAB8}'
> Searching for WAL resume position
> ERROR ErrorHandler - Producer failure
> java.lang.OutOfMemoryError: Java heap space
> at java.util.HashSet.add(HashSet.java:221)
> at
> io.debezium.connector.postgresql.connection.WalPositionLocator.resumeFromLsn(WalPositionLocator.java:63)
> at
> io.debezium.connector.postgresql.PostgresStreamingChangeEventSource.searchWalPosition(PostgresStreamingChangeEventSource.java:407)
> ...
> {code}
> The heap histogram shows about 2M {{HashMap$Node}} and {{Lsn}} instances
> after 10 s, roughly 60-80 bytes per message (heap dump available).
> Control: restoring the {{heartbeat.interval.ms=0}} checkpoint (stored offset
> equals the last commit) finds the resume position after 5 ms and stays at
> about 38 MiB.
> {{heartbeat.interval.ms=0}} is not a workaround: the slot then does not
> advance during the transaction ({{confirmed_flush_lsn}} stayed at
> {{0/1524900}} while the server was at {{0/5682ADD8}}), and
> {{PostgresSourceConfigFactory}} L90-94 says the heartbeat is required for
> Postgres.
> h3. Possible directions
> * Do not apply a heartbeat offset to the checkpointed stream split while a
> transaction is in flight; keep the last-commit position until COMMIT.
> * Skip the position search when everything since the last commit was filtered
> out.
> * Bound the memory of the search in Debezium (see the linked issue).
> h3. Reproduction
> Harness attached ({{FlinkRepro.java}}, {{run.sh}}, {{restore.sh}}).
> # Postgres 16 with {{wal_level=logical}}; tables {{a}}, {{b}}; publication
> {{FOR TABLE a, b}} ({{publication.autocreate.mode=disabled}}).
> # Start {{FlinkRepro}} (-Xmx1g), heartbeat 1000 ms, checkpoint interval 2 s,
> startup mode latest.
> # Insert one row into {{a}}, then 8,000,000 rows into {{b}} in one
> transaction.
> # About 12 s after the commit, kill -9 the job.
> # Restart with {{-Xmx512m}} and {{execution.savepoint.path}} set to the
> latest {{chk-N}} directory.
> h3. Related
> * FLINK-40538, FLINK-39265
> * Debezium: [debezium/dbz#2905|https://github.com/debezium/dbz/issues/2905]
> (unbounded {{{}WalPositionLocator.lsnSeen{}}})
> [~loserwang1024] [~leonard] [~eskabetxe] since you have worked on the
> Postgres CDC restore path (FLINK-40538), I would appreciate your thoughts on
> the proposed fix.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)