This is an automated email from the ASF dual-hosted git repository.
showuon pushed a commit to branch 4.3
in repository https://gitbox.apache.org/repos/asf/kafka.git
The following commit(s) were added to refs/heads/4.3 by this push:
new 52f6edacf1b KAFKA-20732: seed highestOffsetInRemoteStorage before
size-based remote retention (#22661)
52f6edacf1b is described below
commit 52f6edacf1b15c29f5285781df779393b7ad6b0a
Author: Adam Horky <[email protected]>
AuthorDate: Wed Jul 1 07:58:15 2026 +0200
KAFKA-20732: seed highestOffsetInRemoteStorage before size-based remote
retention (#22661)
JIRA: https://issues.apache.org/jira/browse/KAFKA-20732
**Problem**
On a freshly elected leader of a tiered partition, `RemoteLogManager`
size-retention can run while `highestOffsetInRemoteStorage` is still
`-1` — it is seeded only by the copy and follower tasks, which race the
expiration task (both are scheduled with `initialDelay=0` on separate
thread pools). `UnifiedLog.onlyLocalLogSegmentsSize()` then filters
`baseOffset >= -1` and counts the entire local log, which is also
counted in `remoteLogSizeBytes` for the same offsets. The apparent size
roughly doubles, a false `retention.bytes` breach fires, and
in-retention data is deleted from the remote tier and — via the
resulting `logStartOffset` advance, replicated to followers — the local
tier on all replicas.
The follower path already guards this: `RLMFollowerTask.execute()` seeds
the offset with the comment *"so that the local log segments are not
deleted before they are copied to remote storage."* The
leader/expiration path, which runs size-retention, had no equivalent
step.
**Fix**
In `cleanupExpiredRemoteLogSegments`, when
`highestOffsetInRemoteStorage() == -1`, seed it from remote metadata
(`findHighestRemoteOffset`) before computing size-retention. It is a
no-op once the offset is already seeded.
**Testing**
Added
`RemoteLogManagerTest.testSizeRetentionDoesNotOverDeleteOnFreshLeaderUntilHighestRemoteOffsetSeeded`
— fails without the fix (deletes both copied segments), passes with it.
Also reproduced and fix-validated on a live 3.9.0 cluster (details in
the JIRA).
Reviewers: Luke Chen <[email protected]>, Kamal Chandraprakash
<[email protected]>
---
.../log/remote/storage/RemoteLogManager.java | 18 +++++++
.../log/remote/storage/RemoteLogManagerTest.java | 59 ++++++++++++++++++++++
2 files changed, 77 insertions(+)
diff --git
a/storage/src/main/java/org/apache/kafka/server/log/remote/storage/RemoteLogManager.java
b/storage/src/main/java/org/apache/kafka/server/log/remote/storage/RemoteLogManager.java
index 27713827fef..8f521f623e4 100644
---
a/storage/src/main/java/org/apache/kafka/server/log/remote/storage/RemoteLogManager.java
+++
b/storage/src/main/java/org/apache/kafka/server/log/remote/storage/RemoteLogManager.java
@@ -1347,6 +1347,20 @@ public class RemoteLogManager implements Closeable,
AsyncOffsetReader {
return new RemoteLogMetadataStats(epochsSet, metadataCount,
sizeInBytes, copyFinishedSegmentsSizeInBytes);
}
+ // On a freshly-elected leader, highestOffsetInRemoteStorage is
unseeded (-1) until RLMCopyTask /
+ // RLMFollowerTask seed it; RLMExpirationTask does not. If size-based
retention runs while it is -1,
+ // UnifiedLog.onlyLocalLogSegmentsSize() (filter baseOffset >=
highestOffsetInRemoteStorage()) returns
+ // the whole local log -- including segments already copied to remote
-- which buildRetentionSizeData
+ // then double-counts against the remote size, over-deleting
in-retention data from both tiers.
+ private void maybeSeedHighestOffsetInRemoteStorage(UnifiedLog log)
throws RemoteStorageException {
+ if (log.highestOffsetInRemoteStorage() == -1) {
+ OffsetAndEpoch highestRemoteOffsetAndEpoch =
findHighestRemoteOffset(topicIdPartition, log);
+ if (highestRemoteOffsetAndEpoch.offset() >= 0) {
+
log.updateHighestOffsetInRemoteStorage(highestRemoteOffsetAndEpoch.offset());
+ }
+ }
+ }
+
/**
* Cleanup expired and dangling remote log segments.
*/
@@ -1383,6 +1397,10 @@ public class RemoteLogManager implements Closeable,
AsyncOffsetReader {
// Build the leader epoch map by filtering the epochs that do not
have any records.
NavigableMap<Integer, Long> epochWithOffsets =
buildFilteredLeaderEpochMap(leaderEpochCache.epochWithOffsets());
+ // Seed highestOffsetInRemoteStorage before size-retention to
avoid the fresh-leader double-count
+ // (no-op once RLMCopyTask/RLMFollowerTask have seeded it).
+ maybeSeedHighestOffsetInRemoteStorage(log);
+
long logStartOffset = log.logStartOffset();
long logEndOffset = log.logEndOffset();
Optional<RetentionSizeData> retentionSizeData =
buildRetentionSizeData(log.config().retentionSize,
diff --git
a/storage/src/test/java/org/apache/kafka/server/log/remote/storage/RemoteLogManagerTest.java
b/storage/src/test/java/org/apache/kafka/server/log/remote/storage/RemoteLogManagerTest.java
index 234bb0f06f4..b05bb754eed 100644
---
a/storage/src/test/java/org/apache/kafka/server/log/remote/storage/RemoteLogManagerTest.java
+++
b/storage/src/test/java/org/apache/kafka/server/log/remote/storage/RemoteLogManagerTest.java
@@ -2809,6 +2809,65 @@ public class RemoteLogManagerTest {
}
}
+ @Test
+ public void
testSizeRetentionDoesNotOverDeleteOnFreshLeaderUntilHighestRemoteOffsetSeeded()
+ throws RemoteStorageException, ExecutionException,
InterruptedException {
+ // Regression test: on a freshly-elected leader,
highestOffsetInRemoteStorage is unseeded (-1) until
+ // RLMCopyTask/RLMFollowerTask seed it. While it is -1, the real
UnifiedLog.onlyLocalLogSegmentsSize()
+ // (filter baseOffset >= highestOffsetInRemoteStorage()) returns the
whole local log -- including
+ // segments already copied to remote -- so buildRetentionSizeData
double-counts that overlap against the
+ // remote size and over-deletes both tiers. Here
onlyLocalLogSegmentsSize() is wired to the real, mutable
+ // highestOffsetInRemoteStorage (the field the fix seeds) with that
exact behaviour:
+ // unseeded (-1) -> whole local log (2048, the
copied-but-not-yet-evicted overlap)
+ // seeded -> only the uncopied tail (0)
+ // RED without the seeding fix: highest stays -1 ->
onlyLocalLogSegmentsSize()=2048 ->
+ // total = 2048 (remote) + 2048 = 4096 > 2048 -> both segments
deleted, logStartOffset -> 200.
+ // GREEN with it: RLMExpirationTask seeds highest=199 ->
onlyLocalLogSegmentsSize()=0 ->
+ // total = 2048 == cap -> nothing deleted.
+ long segmentSize = 1024L;
+ long retentionSize = 2 * segmentSize;
+ AtomicLong highestRemote = new AtomicLong(-1L); // fresh leader:
unseeded
+ when(mockLog.highestOffsetInRemoteStorage()).thenAnswer(inv ->
highestRemote.get());
+ doAnswer(inv -> {
+ long offset = inv.getArgument(0);
+ if (offset > highestRemote.get()) {
+ highestRemote.set(offset);
+ }
+ return null;
+ }).when(mockLog).updateHighestOffsetInRemoteStorage(anyLong());
+ when(mockLog.onlyLocalLogSegmentsSize()).thenAnswer(inv ->
highestRemote.get() < 0 ? 2 * segmentSize : 0L);
+
+ Map<String, Long> logProps = new HashMap<>();
+ logProps.put("retention.bytes", retentionSize);
+ logProps.put("retention.ms", -1L);
+ when(mockLog.config()).thenReturn(new LogConfig(logProps));
+
+ List<EpochEntry> epochEntries = List.of(epochEntry0);
+ checkpoint.write(epochEntries);
+ LeaderEpochFileCache cache = new LeaderEpochFileCache(tp, checkpoint,
scheduler);
+ when(mockLog.leaderEpochCache()).thenReturn(cache);
+
when(mockLog.topicPartition()).thenReturn(leaderTopicIdPartition.topicPartition());
+ when(mockLog.logEndOffset()).thenReturn(200L);
+ when(mockLog.size()).thenReturn(2 * segmentSize);
+
+ // 2 copied remote segments [0..99],[100..199], 1024 each; the seeding
fix derives highest from these.
+ List<RemoteLogSegmentMetadata> metadataList =
listRemoteLogSegmentMetadata(
+ leaderTopicIdPartition, 2, 100, (int) segmentSize,
epochEntries, RemoteLogSegmentState.COPY_SEGMENT_FINISHED);
+
when(remoteLogMetadataManager.listRemoteLogSegments(leaderTopicIdPartition))
+ .thenAnswer(ans -> metadataList.iterator());
+
when(remoteLogMetadataManager.listRemoteLogSegments(leaderTopicIdPartition, 0))
+ .thenAnswer(ans -> metadataList.iterator());
+
when(remoteLogMetadataManager.updateRemoteLogSegmentMetadata(any(RemoteLogSegmentMetadataUpdate.class)))
+ .thenReturn(CompletableFuture.runAsync(() -> { }));
+
when(remoteLogMetadataManager.highestOffsetForEpoch(any(TopicIdPartition.class),
anyInt()))
+ .thenReturn(Optional.of(199L));
+
+ remoteLogManager.new
RLMExpirationTask(leaderTopicIdPartition).cleanupExpiredRemoteLogSegments();
+
+ verify(remoteStorageManager, never()).deleteLogSegmentData(any());
+ assertEquals(0L, currentLogStartOffset.get());
+ }
+
@ParameterizedTest(name =
"testDeletionOnOverlappingRetentionBreachedSegments retentionSize={0}
retentionMs={1}")
@CsvSource(value = {"0, -1", "-1, 0"})
public void testDeletionOnOverlappingRetentionBreachedSegments(long
retentionSize,