Skip to content

perf(flink): preempt inactive write buckets on memory exhaustion - #19728

Open
fhan688 wants to merge 3 commits into
apache:masterfrom
fhan688:preempt-inactive-write-buckets-on-memory-exhaustion
Open

perf(flink): preempt inactive write buckets on memory exhaustion#19728
fhan688 wants to merge 3 commits into
apache:masterfrom
fhan688:preempt-inactive-write-buckets-on-memory-exhaustion

Conversation

@fhan688

@fhan688 fhan688 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

Closes #19664.

This is a follow-up to #19692, which made the Flink writer gracefully fall back to a diverged-record bucket when a write bucket cannot acquire additional memory segments.

However, the current bucket may fail to allocate a memory segment while reclaimable segments are still retained by other inactive write buckets. In that case, directly using the diverged-record fallback can cause unnecessary bucket divergence and may leave reclaimable managed memory unused.

This PR allows the writer to preemptively flush and dispose an inactive write bucket before falling back to the existing divergence path.

Summary and Changelog

When a write bucket cannot allocate another memory segment, the writer now attempts to reclaim memory from the largest non-empty inactive bucket and retries the allocation once.

The changes include:

  • Add PreemptiveMemorySegmentPool, which wraps an existing MemorySegmentPool.
  • Track the bucket currently performing writeRow as the active memory owner.
  • Trigger a memory-reclamation callback when segment allocation fails.
  • Select the largest non-empty bucket other than the active bucket as the reclamation victim.
  • Flush and dispose the selected bucket using the lifecycle handling introduced in fix(flink): prevent reusing diverged buffers on memory exhaustion #19692.
  • Retry the failed segment allocation exactly once after successful reclamation.
  • Prevent recursive preemption while a reclamation callback is running.
  • Preserve the existing diverged-record fallback when:
    • there is no eligible inactive bucket;
    • reclamation does not release enough memory; or
    • the current record still cannot fit into the bounded memory pool.
  • Reuse the inactive-bucket selection logic in the existing failed-write reclamation path.
  • Add focused unit tests for heap and Flink managed-memory pools.
  • Extend the memory-exhaustion integration test to cover both the default and LSM Tree storage layouts.

No code was copied from another project.

Impact

There are no changes to the storage format, public APIs, configuration options, or default configuration values.

For normal writes with sufficient memory, behavior remains unchanged apart from lightweight active-bucket tracking.

Under memory pressure, an inactive bucket may be flushed earlier so that its memory segments can be reused by the active bucket. This reduces unnecessary bucket divergence and improves progress when memory is distributed across multiple buffered buckets.

Earlier flushing under severe memory pressure may produce smaller write batches, but this only occurs after a segment allocation has already failed.

Risk Level

Medium.

The change affects the Flink writer's bucket flushing and managed-memory lifecycle under memory exhaustion. The risk is mitigated by:

  • retrying allocation only once;
  • excluding the currently active bucket from reclamation;
  • preventing recursive preemption;
  • reusing the existing flush-and-dispose lifecycle;
  • preserving the fallback behavior introduced in fix(flink): prevent reusing diverged buffers on memory exhaustion #19692;
  • verifying that all managed-memory pages are returned;
  • testing both the default and LSM Tree bucket implementations.

The following validations passed:

  • TestPreemptiveMemorySegmentPool: 5 tests passed.
  • TestBucketStreamWriteMemoryExhaustion: 2 parameterized scenarios passed for the default and LSM Tree layouts.
  • Flink 1.18 reactor compilation passed.
  • Flink 2.1 adapter build passed.
  • Checkstyle, RAT, and git diff --check passed.

Documentation Update

None.

This is an internal memory-reclamation improvement and does not introduce or modify any user-facing configuration or API.

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:L PR with lines of changes in (300, 1000] label Aug 25, 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 the contribution! This PR adds a PreemptiveMemorySegmentPool that lets a Flink write bucket reclaim managed memory from the largest inactive bucket (flushing and disposing it) before falling back to the diverged-record path introduced in #19692, with owner tracking, a bounded single retry, and a recursion guard. I traced the preemption mechanism, the owner-tracking lifecycle, reentrancy of the mid-write flush, the divergence interaction, tracer accounting, and subclass coverage, and did not flag any correctness issues from this automated pass — a Hudi committer or PMC member can take it from here for a final review. One minor naming nit on the new boolean field; otherwise the code is clean and well-structured.

cc @yihua


@Nullable
private String currentOwnerId;
private boolean preempting;

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: could you rename preempting to isPreempting? Boolean fields typically read as predicates in this codebase (e.g. isDiverged), and the is prefix makes the guard in nextSegment() a bit easier to parse at a glance.

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

@codecov-commenter

codecov-commenter commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.41667% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.79%. Comparing base (e0fe585) to head (b31934a).
⚠️ Report is 25 commits behind head on master.

Files with missing lines Patch % Lines
.../hudi/sink/buffer/PreemptiveMemorySegmentPool.java 85.18% 0 Missing and 4 partials ⚠️
...java/org/apache/hudi/sink/StreamWriteFunction.java 85.71% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19728      +/-   ##
============================================
- Coverage     77.88%   73.79%   -4.10%     
+ Complexity    33264    31759    -1505     
============================================
  Files          2533     2540       +7     
  Lines        140342   141003     +661     
  Branches      16912    17162     +250     
============================================
- Hits         109310   104048    -5262     
- Misses        23401    29766    +6365     
+ Partials       7631     7189     -442     
Components Coverage Δ
hudi-common 82.87% <ø> (-0.49%) ⬇️
hudi-client 82.07% <ø> (-0.92%) ⬇️
hudi-flink 85.65% <85.41%> (+0.05%) ⬆️
hudi-spark-datasource 47.42% <ø> (-24.95%) ⬇️
hudi-utilities 74.39% <ø> (+0.05%) ⬆️
hudi-cli 15.06% <ø> (ø)
hudi-hadoop 69.06% <ø> (-0.03%) ⬇️
hudi-sync 75.51% <ø> (+0.02%) ⬆️
hudi-io 79.85% <ø> (+0.09%) ⬆️
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 64.27% <ø> (-0.06%) ⬇️
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 51.06% <85.41%> (+0.12%) ⬆️
flink-integration-tests 49.00% <85.41%> (-0.12%) ⬇️
hadoop-mr-java-client 43.72% <ø> (-0.18%) ⬇️
integration-tests 13.56% <0.00%> (-0.04%) ⬇️
spark-client-hadoop-common 50.54% <ø> (-0.07%) ⬇️
spark-java-tests 52.03% <ø> (+0.06%) ⬆️
spark-scala-tests 33.16% <ø> (-13.35%) ⬇️
utilities 36.40% <ø> (-0.14%) ⬇️

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

Files with missing lines Coverage Δ
...java/org/apache/hudi/sink/StreamWriteFunction.java 80.86% <85.71%> (+2.14%) ⬆️
.../hudi/sink/buffer/PreemptiveMemorySegmentPool.java 85.18% <85.18%> (ø)

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

@fhan688 fhan688 closed this Aug 25, 2026
@fhan688 fhan688 reopened this Aug 25, 2026

@cshuo cshuo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thks for the optimization, left some comments.


@Nullable
private String currentOwnerId;
private boolean preempting;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is preempting flag necessary, Could you clarify the purpose of the flag or add some comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is preempting flag necessary, Could you clarify the purpose of the flag or add some comments.

Yes, the flag is needed as a re-entrancy guard.

The reclamation callback flushes and disposes a victim while that victim may still be registered. If the callback path transitively requests another page, re-entering the reclaimer could select and flush the same victim recursively.

I kept the flag and added comments to clarify both its purpose and the guarded branch in nextSegment(). This behavior is also covered by testRetriesAllocationOnceWithoutNestedPreemption.

@Override
public MemorySegment nextSegment() {
MemorySegment segment = delegate.nextSegment();
if (segment != null || currentOwnerId == null || preempting) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you clarify when will currentOwnerId be null?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Could you clarify when will currentOwnerId be null?

currentOwnerId is non-null only while bucket.writeRow() is serializing a row. It is null before and after writeRow(), including while a new buffer is being created.

For a buffer-creation allocation failure, owner-aware preemption is intentionally skipped and the existing creation-failure recovery in StreamWriteFunction flushes the largest non-empty bucket and retries the creation.

I expanded the condition and added comments to make these cases explicit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the clarification. I don't think we need to expand the condition here. The existing behavior looks fine; adding some comments to explain should be sufficient.

@@ -149,6 +150,8 @@ public class StreamWriteFunction extends AbstractStreamWriteFunction<HoodieFlink

protected transient MemorySegmentPool memorySegmentPool;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we still need to keep memorySegmentPool, seems we can use preemptiveMemorySegmentPool throughout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do we still need to keep memorySegmentPool, seems we can use preemptiveMemorySegmentPool throughout.

Good point. Both fields referenced the same wrapped pool after initialization.

I removed memorySegmentPool and now use preemptiveMemorySegmentPool throughout, including buffer creation, owner tracking, cleanup, and the test accessor.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

As discussed in #19692 (comment), Now that memory exhaustion during writeRow() already triggers MemoryReclaimer#reclaim to flush the largest inactive bucket, we don't need to eagerly flush bucketToFlush when failedBucket is diverged?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As discussed in #19692 (comment), Now that memory exhaustion during writeRow() already triggers MemoryReclaimer#reclaim to flush the largest inactive bucket, we don't need to eagerly flush bucketToFlush when failedBucket is diverged?

Agreed. With owner-aware preemption, an allocation failure during writeRow() already attempts to flush the largest inactive bucket before the current bucket can diverge. Eagerly flushing another bucket after divergence is therefore redundant.

I simplified the diverged path to flush and dispose only the failed bucket before retrying. The largest-bucket fallback is still retained for buffer-creation failures, where there is no active owner and preemptive reclamation is not triggered.

@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 the contribution! This PR adds a PreemptiveMemorySegmentPool that lets a Flink write bucket reclaim managed memory from the largest inactive non-empty bucket (flush + dispose) and retry allocation once before falling back to the existing diverged-record path. I traced the divergence/preemption interaction, the re-entrancy guard (preempting), the currentOwnerId lifecycle, page/tracer accounting, and the reclaimMemoryAfterFailedWrite simplification against the PR-head sources, and the logic holds up — the removed dual-flush is redundant once a bucket diverges (divergence only occurs after preemptMemory already found no inactive non-empty bucket), and a preemption flush failure propagates out of nextSegment rather than being silently swallowed by BinaryInMemorySortBuffer.write (which only catches EOFException). No new issues flagged from this automated pass beyond the points already under discussion in the inline threads — a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

@cshuo cshuo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thks for the updating, some minor comments.

.orElse(null);

if (failedBucket == null) {
RowDataBucket bucketToFlush = findLargestNonEmptyBucketExcluding(bucketID);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since this branch performs the same largest-non-empty-bucket selection and flush as preemptMemory(bucketID), we can reuse it here:

if (!preemptMemory(bucketID)) {
    throw new HoodieException(
        "Not enough memory pages to create a RowData buffer and no non-empty bucket can be flushed");
}
return;

@Override
public MemorySegment nextSegment() {
MemorySegment segment = delegate.nextSegment();
if (segment != null || currentOwnerId == null || preempting) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the clarification. I don't think we need to expand the condition here. The existing behavior looks fine; adding some comments to explain should be sufficient.

int initialFreePages = pipeline.freePages();
try {
boolean reclaimedOtherBucketBeforeDivergedBucket = false;
boolean preemptedInactiveBucket = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The updated test verifies successful preemption of an inactive bucket, but it no longer explicitly verifies the fallback when no eligible victim exists.

Could we retain or add a scenario where only the current bucket holds buffered rows, the next record exhausts the remaining pages, and the record fits after the diverged bucket is flushed and disposed? This would cover reclaim() returning false, followed by flushing the diverged bucket and successfully retrying the record, while also verifying that no records or memory pages are lost.

@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 adds a PreemptiveMemorySegmentPool that lets a Flink write bucket reclaim managed memory by flushing the largest inactive bucket before falling back to the existing diverged-record path. I traced the reclaim/retry flow, the re-entrancy guard, owner lifecycle, tracer accounting, and the simplified diverged-bucket branch, and did not find a correctness issue in this pass. No correctness issues found. A few style/readability suggestions in the inline comments. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here.; a Hudi committer or PMC member can take it from here for a final review.
. One minor naming-consistency suggestion below; otherwise the code is clean and well-documented.

cc @yihua

boolean reclaim(String excludedOwnerId);
}

private final MemorySegmentPool delegate;

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: the code mixes "preempt" (class name, StreamWriteFunction#preemptMemory) and "reclaim" (MemoryReclaimer#reclaim, memoryReclaimer) for the same concept — could you settle on one verb so the vocabulary stays consistent?

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

@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

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

Labels

size:L PR with lines of changes in (300, 1000]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Flink writer may corrupt RowData when write buffer memory is exhausted

5 participants