Skip to content

fix(flink): wait for successful heartbeat writes before recommit - #19971

Open
cshuo wants to merge 1 commit into
apache:masterfrom
cshuo:fix_heartbeat_start
Open

cshuo wants to merge 1 commit into
apache:masterfrom
cshuo:fix_heartbeat_start

Conversation

@cshuo

@cshuo cshuo commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

Closes #19947.

During Flink recovery, recommit can reach heartbeat validation before a timed-out initial heartbeat write succeeds on a scheduled retry. If the file is absent, commit fails with last heartbeat 0. This affects both data-table and streaming metadata-table heartbeats.

Summary and Changelog

  • Add HoodieHeartbeatClient.awaitHeartbeat() with bounded waiting based on the existing lastHeartbeatTime, made volatile for visibility across threads.
  • Preserve start() semantics: initial write timeouts still defer to scheduled retries. Waiting does not replace commit-time expiry checks.
  • Start both required heartbeats before waiting in Flink recommit, sharing the configured heartbeat expiry window between the two waits.
  • Cover initial write timeouts, background recovery, wait timeout and interruption, and both Flink heartbeat paths.

Impact

Flink recommit waits for confirmed heartbeat writes before attempting commit and reports an explicit timeout if they remain unavailable. Adds an optional client API without new configuration. The shared waiting budget applies after heartbeat startup.

Risk Level

Low. Recovery can wait longer during storage delays, bounded by the existing heartbeat interval and tolerable-misses settings. Regression tests exercise timeout recovery and both heartbeat paths; existing heartbeat expiry validation remains in place.

Documentation Update

Added API Javadoc documenting readiness, timeout behavior, and the requirement to retain commit-time expiry validation.

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 Sep 16, 2026
@codecov-commenter

codecov-commenter commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.54839% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.32%. Comparing base (8af07e1) to head (c3420d4).
⚠️ Report is 22 commits behind head on master.

Files with missing lines Patch % Lines
...e/hudi/client/heartbeat/HoodieHeartbeatClient.java 94.44% 0 Missing and 1 partial ⚠️
...udi/client/FlinkStreamingMetadataWriteHandler.java 83.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19971      +/-   ##
============================================
+ Coverage     80.22%   80.32%   +0.09%     
- Complexity    34669    34751      +82     
============================================
  Files          2546     2545       -1     
  Lines        142452   142522      +70     
  Branches      17330    17505     +175     
============================================
+ Hits         114281   114474     +193     
+ Misses        20265    20143     -122     
+ Partials       7906     7905       -1     
Components Coverage Δ
hudi-common 83.86% <ø> (+0.01%) ⬆️
hudi-client 83.34% <94.44%> (+0.02%) ⬆️
hudi-flink 85.63% <92.30%> (+0.02%) ⬆️
hudi-spark-datasource 73.77% <ø> (+0.49%) ⬆️
hudi-utilities 78.14% <ø> (-0.05%) ⬇️
hudi-cli 69.99% <ø> (ø)
hudi-hadoop 70.90% <ø> (+0.09%) ⬆️
hudi-sync 76.02% <ø> (+0.02%) ⬆️
hudi-io 81.52% <ø> (-0.10%) ⬇️
hudi-timeline-service 83.34% <ø> (ø)
hudi-cloud 80.99% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 52.07% <93.54%> (+0.08%) ⬆️
flink-integration-tests 49.11% <54.83%> (-0.02%) ⬇️
hadoop-mr-java-client 43.90% <0.00%> (-0.08%) ⬇️
integration-tests 13.46% <0.00%> (-0.01%) ⬇️
spark-client-hadoop-common 38.54% <0.00%> (-0.01%) ⬇️
spark-java-tests 52.27% <0.00%> (+0.10%) ⬆️
spark-scala-tests 46.90% <0.00%> (-0.06%) ⬇️
utilities 36.85% <0.00%> (+<0.01%) ⬆️

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

Files with missing lines Coverage Δ
...org/apache/hudi/client/HoodieFlinkWriteClient.java 93.23% <100.00%> (+0.23%) ⬆️
...e/hudi/client/heartbeat/HoodieHeartbeatClient.java 87.78% <94.44%> (+1.06%) ⬆️
...udi/client/FlinkStreamingMetadataWriteHandler.java 77.77% <83.33%> (+1.11%) ⬆️

... and 69 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.

@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

// Since scheduler submits the task to a thread, no guarantee when that thread will get CPU
// cycles to generate the first heartbeat.
// Attempt the first heartbeat synchronously. A timed-out write is retried by the scheduler;
// callers that need a confirmed heartbeat before proceeding can use awaitHeartbeat().

@danny0405 danny0405 Sep 16, 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.

didn't quite get this, the heartbeat is updated instantly in memory right? and the heartbeat validaiton always goes through heartbeatClient.isHeartbeatExpired(instantTime).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The in-memory lastHeartbeatTime is updated only after writeHeartbeatFile() succeeds. If the initial write times out (no exception will be thrown), it remains null, while start() schedules retries and returns normally.

You’re right that validation calls isHeartbeatExpired(), but with a null timestamp it falls back to storage. If no heartbeat file exists, it reads 0 and treats the heartbeat as expired.

See comment here:

// lastHeartbeatTime can be null when the heartbeat is not in the internal map, or when it is in the
// map but no heartbeat has been generated yet (e.g. the first write timed out). In both cases fall
// back to reading the last heartbeat time from DFS (returns 0 if no heartbeat file exists, which is
// correctly treated as expired).

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.

the updateHeartbeat(String instantTime) already sets up the heartbat time through heartbeat.setLastHeartbeatTime(newHeartbeatTime);

You’re right that validation calls isHeartbeatExpired(), but with a null timestamp

this is the reader hearbeat behavior but since the commit happens on the coordinator which shares the same heartbeat client which got updated from writer heartbeat.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

the updateHeartbeat(String instantTime) already sets up the heartbat time through heartbeat.setLastHeartbeatTime(newHeartbeatTime);

The timestamp is assigned only after the storage write succeeds without timeout exception. The relevant execution order in updateHeartbeat() is:

try {
  writeHeartbeatFile(instantTime); // May throw TimeoutException.
  // ...
  heartbeat.setLastHeartbeatTime(newHeartbeatTime); // Skipped on timeout.
} catch (TimeoutException te) {
  // Log the timeout without updating the timestamp; retry on the next tick.
}

If the initial write times out, updateHeartbeat() catches the exception and returns normally, leaving lastHeartbeatTime null. start() then schedules retries and returns.

The coordinator can proceed directly to recommit before a retry succeeds. Even though it uses the same heartbeat client, the timestamp has never been set. isHeartbeatExpired() therefore falls back to storage; if the heartbeat file is absent, it reads 0 and rejects the commit:

Caused by: org.apache.hudi.exception.HoodieException:
Heartbeat for instant XXX has expired, last heartbeat 0
    at org.apache.hudi.client.heartbeat.WriterHeartbeatUtils.abortIfHeartbeatExpired(WriterHeartbeatUtils.java:101)
    at org.apache.hudi.client.BaseHoodieWriteClient.commitStats(BaseHoodieWriteClient.java:277)
    at org.apache.hudi.client.BaseHoodieWriteClient.commitStats(BaseHoodieWriteClient.java:252)

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.

🤖 One data point that may help here: the gap is new since #18904. Before that writeHeartbeatFile() was a plain blocking storage.create, so start() returned only after the write landed (or threw) and the in-memory timestamp was always set on the coordinator's client. With the bounded future.get(heartbeatWriteTimeoutMs) + cancel(true), a timed-out first write is swallowed in the TimeoutException branch of updateHeartbeat() without setLastHeartbeatTime(), so start() can now return with the map entry present but lastHeartbeatTime == null, and isHeartbeatExpired() falls back to storage where the file may not exist yet.

@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 change adds HoodieHeartbeatClient.awaitHeartbeat() and has Flink recommit wait for confirmed data-table and metadata-table heartbeat writes before committing, so a timed-out first write no longer surfaces as last heartbeat 0. The core wait loop looks correct; the two inline comments are about the Flink caller — the size of the wait budget on the coordinator's main-thread entry points, and the metadata-table budget being derived from the data-table config even though the MDT heartbeat client does not inherit those settings. 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.

}
if (isStreamingWriteMetadataTable) {
long remainingMs = Math.max(0, timeoutMs - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - waitStarted));
this.streamingMetadataWriteHandler.awaitHeartbeat(instantTime, remainingMs);

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.

🤖 The budget passed here comes from the data-table config, but the MDT heartbeat client runs on the MDT write config, and HoodieMetadataWriteUtils.createMetadataWriteConfig doesn't copy hoodie.client.heartbeat.interval_in_ms / tolerable.misses across, so the MDT scheduler always retries on the 60s default. If a user sets a shorter data-table interval (say 10s x 10 = 100s budget, or anything under 60s), the MDT retry can't fire inside the window and this wait times out every time — the exact scenario the PR is fixing. Would it make sense to compute the MDT budget from metadataWriter.getWriteClient().getConfig() inside FlinkStreamingMetadataWriteHandler.awaitHeartbeat instead of sharing the data-table window?

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

}
// Recommit has no data-writing phase in which a timed-out first heartbeat can recover.
// Start both heartbeats before waiting and share the configured heartbeat expiry window.
long timeoutMs = getConfig().getHoodieClientHeartbeatIntervalInMs() * getConfig().getHoodieClientHeartbeatTolerableMisses();

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.

🤖 With defaults this is 60s x 10 = 10 minutes, and restartHeartbeat runs on the JobManager main thread via StreamWriteOperatorCoordinator.start()/resetToCheckpoint()restoreEvents, and via subtaskReset()executeSync. The pre-existing synchronous first write already blocks up to 60s per heartbeat, but adding up to 10 more minutes on the coordinator's main-thread entry points seems like it could stall the JM (RPC main thread, TM heartbeat handling) well past Flink's default heartbeat.timeout. Was a smaller bound — e.g. one or two heartbeat intervals, enough for the first scheduled retry — considered? @danny0405 curious whether you're comfortable with a wait of this size on these coordinator callbacks.

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

// Since scheduler submits the task to a thread, no guarantee when that thread will get CPU
// cycles to generate the first heartbeat.
// Attempt the first heartbeat synchronously. A timed-out write is retried by the scheduler;
// callers that need a confirmed heartbeat before proceeding can use awaitHeartbeat().

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.

🤖 One data point that may help here: the gap is new since #18904. Before that writeHeartbeatFile() was a plain blocking storage.create, so start() returned only after the write landed (or threw) and the in-memory timestamp was always set on the coordinator's client. With the bounded future.get(heartbeatWriteTimeoutMs) + cancel(true), a timed-out first write is swallowed in the TimeoutException branch of updateHeartbeat() without setLastHeartbeatTime(), so start() can now return with the map entry present but lastHeartbeatTime == null, and isHeartbeatExpired() falls back to storage where the file may not exist yet.

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.

Flink recommit can fail when the initial heartbeat write times out

5 participants