KAFKA-20036 Handle LogCleaner segment overflow caused by compression level changes - #21379
Conversation
# Conflicts: # checkstyle/suppressions.xml
Since the integration test is expensive and generating 2GB of data is not ideal, I removed it. |
Exercised this patch via a producer flow with positive results. Instead of throwing a exception, the cleaner now yield undersized log files. Typically, these files will be naturally merged in future compaction passes as the dataset accrues duplicate keys |
# Conflicts: # storage/src/main/java/org/apache/kafka/storage/internals/log/Cleaner.java
| LogTestUtils.initializeLogDirWithOverflowedSegment(dir) | ||
| val sourceSegments = log.logSegments.asScala.take(2).toSeq | ||
| val singleBatchSize = sourceSegments.head.log.batches.asScala.map(_.sizeInBytes).max | ||
| // Allow 1.5 batches: fits the first batch, but adding a second batch overflows. |
There was a problem hiding this comment.
This line seems redundant given the comment in next line.
| cleanedSegments.add(currentCleaned); | ||
|
|
||
| // swap in all cleaned segments (maybe multiple if overflow occurred) | ||
| logger.info("Swapping in {} cleaned segment(s) for segment(s) {} in log {}", cleanedSegments.size(), segments, log); |
There was a problem hiding this comment.
Could we log the cleaned segments, instead of just the size?
|
Could you please take a look at these failing e2e tests? tests/kafkatest/tests/connect/connect_distributed_test.py::ConnectDistributedTest.test_exactly_once_source I noticed they failed during my test run. Since I'm traveling right now, I haven't had the chance to dive deeper and verify if they are related to this PR. |
When I ran this test on the trunk branch, they were flaky as well. also create Jira to trace this test.
This one all passed |
| logger.info("Swapping in cleaned segment {} for segment(s) {} in log {}", cleaned, segments, log); | ||
| log.replaceSegments(List.of(cleaned), segments); | ||
| } catch (LogCleaningAbortedException e) { | ||
| cleanedSegments.forEach(segment -> { |
There was a problem hiding this comment.
Could you eliminate the duplicate code?
Stream.concat(cleanedSegments.stream(), Stream.of(currentCleaned))
.distinct()
.forEach(segment -> {
try {
segment.deleteIfExists();
} catch (Exception deleteException) {
e.addSuppressed(deleteException);
}
});
throw e;
I will merge it after all E2E tests pass. I just reran them last night |
…level changes (apache#21379) We add a new map to record which topic partitions have experienced overflow. When an overflow occurs, the next time the group is processed, we reduce the segment size by a factor of 0.9 to prevent the overflow from happening again. If the partition still overflows, we continue to multiply the ratio by 0.9 on subsequent attempts until the partition is successfully cleaned. Reviewers: Jun Rao <junrao@gmail.com>, Chia-Ping Tsai <chia7712@gmail.com>
…level changes (apache#21379) We add a new map to record which topic partitions have experienced overflow. When an overflow occurs, the next time the group is processed, we reduce the segment size by a factor of 0.9 to prevent the overflow from happening again. If the partition still overflows, we continue to multiply the ratio by 0.9 on subsequent attempts until the partition is successfully cleaned. Reviewers: Jun Rao <junrao@gmail.com>, Chia-Ping Tsai <chia7712@gmail.com>
|
@m1a2st : The description of the PR is outdated and doesn't match the implementation. Could you update it? The outdated description is already in the git log. Not sure if it can be fixed. |
|
@junrao sorry for the misleading message. We could revert the original patch and then commit a new one with an updated message. WDYT? |
|
@chia7712 It's probably not worth it. We can just update the description in the PR. |
Copy that. We will update it after CoC Asia. |
| outputBuffer.flip(); | ||
| MemoryRecords retained = MemoryRecords.readableRecords(outputBuffer); | ||
|
|
||
| // While groupSegmentsBySize() ensures source segments don't exceed Integer.MAX_VALUE, |
There was a problem hiding this comment.
Claude found a bug in this PR. The problem is that checkBatchRetention() modifies transactionMetadata, which stores the ongoing txn state and is reused during cleaning. When we detect an overflow, we rewind the cleaning position to Optional.of(position - result.bytesRead()), but the corresponding transactionMetadata is not rewound. When the cleaning resumes, since transactionMetadata is not accurate, we can have all sorts of bad outcomes. For example, an aborted record could now appear as a committed record after cleaning.
There was a problem hiding this comment.
Nice find. This approach does corrupt the txn index ... I think we could revert it from 4.4 first, and then we could discuss the better approach for trunk.
There was a problem hiding this comment.
@m1a2st would you mind opening a PR for 4.4? it has some conflicts.
There was a problem hiding this comment.
One possibility to fix this issue in trunk is to return the filtered result to the caller on overflow. Instead of rewinding the input segment, the caller will roll a new segment and append the returned result to the new segment. We still need to decouple the updating of the txn index in the filterTo logic. Claude suggested the following.
Stage the index appends instead of writing them during the filter.
1. In CleanedTransactionMetadata, replace the immediate cleanedIndex.ifPresent(index -> index.append(...)) with adding to a List<AbortedTxn> pendingAbortedTxns, plus a flushPendingTo(TransactionIndex) that appends and clears.
2. In cleanInto, flush that list right after a successful dest.append(result.maxOffset(), retained).
3. On overflow, return the saved records + maxOffset and leave the pending list untouched. cleanSegments finalizes the outgoing segment, creates the new one (base offset = first batch of the retained buffer), appends the saved records, then flushes the pending list into the new segment's index.
filterTo then runs exactly once per chunk, so the destructive parts of the state machine — ongoingAbortedTxns.remove, ongoingCommittedTxns.remove, consumeAbortedTxnsUpTo's poll — are each consumed once, and the stats double-count goes away with it. The index entry lands in whichever segment its data landed in, by construction. That's the invariant you expected the code to already have.
There is a bug, fyi: #21379 (review) Reviewers: Parker Chang <parkerhiphop027@gmail.com>, Chia-Ping Tsai <chia7712@gmail.com>
Previously,
Cleaner#cleanSegmentsalways wrote into a singledestination segment. If that destination segment overflowed in offset
range or size during cleaning—for example, because decompression
expanded the data or because multiple source segments were compacted
into the same destination segment—the cleaner would throw
LogSegmentOffsetOverflowException, invokesplitOverflowedSegmenttosplit the source segment, abort the entire cleaning pass, and retry
later.
This change removes that restart path. When the cleaner detects that the
current destination segment is about to exceed the configured limits, it
now finalizes the current segment (
onBecomeInactiveSegment+flush)and immediately continues writing into a new destination segment
starting from the overflow point.
Reviewers: Jun Rao junrao@gmail.com, Chia-Ping Tsai
chia7712@gmail.com