You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@kafka.apache.org by ch...@apache.org on 2026/09/11 23:56:26 UTC
(kafka) branch 4.4 updated: KAFKA-20977: Fix RemoteCopyLagSegments off-by-one on single-record segments (#23246)
This is an automated email from the ASF dual-hosted git repository.
chia7712 pushed a commit to branch 4.4
in repository https://gitbox.apache.org/repos/asf/kafka.git
The following commit(s) were added to refs/heads/4.4 by this push:
new a46fdf2b79a KAFKA-20977: Fix RemoteCopyLagSegments off-by-one on single-record segments (#23246)
a46fdf2b79a is described below
commit a46fdf2b79a4d20887bc8e7d248a28f51f328f92
Author: Avishek Das <av...@gmail.com>
AuthorDate: Fri Aug 28 09:15:00 2026 +0530
KAFKA-20977: Fix RemoteCopyLagSegments off-by-one on single-record segments (#23246)
### JIRA
https://issues.apache.org/jira/browse/KAFKA-20977
### Problem
`UnifiedLog.onlyLocalLogSegmentsCount()` / `onlyLocalLogSegmentsSize()`
filtered segments with
`baseOffset() >= highestOffsetInRemoteStorage()`.
`highestOffsetInRemoteStorage` holds the **end**
offset of the last segment already copied to remote, so a segment whose
base-offset equals that
value has itself already been copied. This happens for **single-record
segments**
(`baseOffset == endOffset == highestOffsetInRemoteStorage`), which are
common on low-throughput
tiered-storage partitions. The `>=` comparison wrongly counted that
already-copied boundary
segment as "only local".
`RemoteLogManager.RLMCopyTask.recordLagStats` derives
`RemoteCopyLagSegments` as
`onlyLocalLogSegmentsCount() - 1` (the `- 1` removes the active segment,
per KAFKA-16895) and only
refreshes the gauge after a copy. So on a partition that has gone idle
the metric stays latched at a
**phantom lag of 1** even though every closed segment is already in
remote storage and only the
active segment remains local. `RemoteCopyLagBytes` and the size-based
retention total
(`onlyLocalLogSegmentsSize + remoteLogSizeBytes`) are inflated the same
way. Summed across
partitions this looks like a growing copy backlog when nothing is
actually lagging.
This is a residual off-by-one on top of KAFKA-16895: that fix removed
the *active* segment from the count; this one removes the
*already-copied boundary* segment.
### Change
Use a strict `>` in both `onlyLocalLogSegmentsCount()` and
`onlyLocalLogSegmentsSize()` — a segment
is "only local" iff its base-offset is strictly greater than
`highestOffsetInRemoteStorage()`.
Javadoc added explaining the boundary condition.
### Tests
Added two `UnifiedLogTest` cases:
- `onlyLocalLogSegmentsExcludeCopiedSingleRecordBoundarySegment` —
single-record segments; asserts
the count/lag is 0 once all closed segments are copied. **Fails on the
previous `>=`**
(`expected: <2> but was: <3>`), passes with the fix.
- `onlyLocalLogSegmentsWithMultiRecordSegmentsIsUnaffectedByFix` —
multi-record segments; guards the
common case where `>=` and `>` agree so the fix does not change
correct behavior.
Verified locally:
```
./gradlew :storage:test --tests "*UnifiedLogTest.onlyLocalLogSegments*"
```
Both pass; `checkstyleMain`, `checkstyleTest`, and `spotbugsMain` are
clean.
Reviewers: Kamal Chandraprakash <ka...@gmail.com>
Co-authored-by: imavishek <im...@users.noreply.github.com>
---
.../kafka/storage/internals/log/UnifiedLog.java | 15 ++++++-
.../storage/internals/log/UnifiedLogTest.java | 50 ++++++++++++++++++++++
2 files changed, 63 insertions(+), 2 deletions(-)
diff --git a/storage/src/main/java/org/apache/kafka/storage/internals/log/UnifiedLog.java b/storage/src/main/java/org/apache/kafka/storage/internals/log/UnifiedLog.java
index affa31076ec..0d4a2d65625 100644
--- a/storage/src/main/java/org/apache/kafka/storage/internals/log/UnifiedLog.java
+++ b/storage/src/main/java/org/apache/kafka/storage/internals/log/UnifiedLog.java
@@ -2104,16 +2104,27 @@ public class UnifiedLog implements AutoCloseable {
/**
* The log size in bytes for all segments that are only in local log but not yet in remote log.
+ *
+ * <p>A segment is considered "only local" (not yet in remote) when its base-offset is strictly
+ * greater than {@link #highestOffsetInRemoteStorage()}. The strict {@code >} (rather than
+ * {@code >=}) matters: {@code highestOffsetInRemoteStorage} holds the end-offset of the last
+ * segment already copied to remote, so the segment whose base-offset equals that value has
+ * itself been copied. This arises for single-record segments (base-offset == end-offset), which
+ * are common on low-throughput partitions; counting such a segment as local would double-count a
+ * segment that is already in remote storage.
*/
public long onlyLocalLogSegmentsSize() {
- return LogSegments.sizeInBytes(logSegments().stream().filter(s -> s.baseOffset() >= highestOffsetInRemoteStorage()).collect(Collectors.toList()));
+ return LogSegments.sizeInBytes(logSegments().stream().filter(s -> s.baseOffset() > highestOffsetInRemoteStorage()).collect(Collectors.toList()));
}
/**
* The number of segments that are only in local log but not yet in remote log.
+ *
+ * <p>See {@link #onlyLocalLogSegmentsSize()} for why the base-offset comparison is a strict
+ * {@code >} against {@link #highestOffsetInRemoteStorage()} rather than {@code >=}.
*/
public long onlyLocalLogSegmentsCount() {
- return logSegments().stream().filter(s -> s.baseOffset() >= highestOffsetInRemoteStorage()).count();
+ return logSegments().stream().filter(s -> s.baseOffset() > highestOffsetInRemoteStorage()).count();
}
/**
diff --git a/storage/src/test/java/org/apache/kafka/storage/internals/log/UnifiedLogTest.java b/storage/src/test/java/org/apache/kafka/storage/internals/log/UnifiedLogTest.java
index 48953d133a5..4fa95365843 100644
--- a/storage/src/test/java/org/apache/kafka/storage/internals/log/UnifiedLogTest.java
+++ b/storage/src/test/java/org/apache/kafka/storage/internals/log/UnifiedLogTest.java
@@ -467,6 +467,56 @@ public class UnifiedLogTest {
assertTrue(deletedSegments > 0, "At least one segment should be deleted");
}
+ @Test
+ public void onlyLocalLogSegmentsExcludeCopiedBoundarySegment() throws IOException {
+ Supplier<MemoryRecords> records = () -> singletonRecords("test".getBytes(), "test".getBytes(), mockTime.milliseconds());
+ LogConfig config = new LogTestUtils.LogConfigBuilder()
+ .remoteLogStorageEnable(true)
+ .build();
+ log = createLog(logDir, config, true);
+
+ // Build single-record segments: closed segments at base-offsets 0, 1 and 2 (each with
+ // end-offset == base-offset), plus an empty active segment at base-offset 3.
+ for (int i = 0; i < 3; i++) {
+ log.appendAsLeader(records.get(), 0);
+ log.roll();
+ }
+ assertEquals(3L, log.logEndOffset());
+ assertEquals(4, log.numberOfSegments());
+
+ // Nothing copied yet (highestOffsetInRemoteStorage == -1): every segment is local-only.
+ assertEquals(4L, log.onlyLocalLogSegmentsCount());
+
+ // Segments up to offset 1 copied. The closed single-record segment at base-offset 2 is
+ // genuinely uncopied, so it is counted alongside the active segment => lag 1.
+ log.updateHighestOffsetInRemoteStorage(1L);
+ assertEquals(2L, log.onlyLocalLogSegmentsCount());
+
+ // Every closed segment is now copied; highestOffsetInRemoteStorage == base-offset of the last
+ // (single-record) segment. Only the empty active segment remains local-only => lag must be 0.
+ // With the previous ">=" comparison the copied boundary segment was still counted, giving 1.
+ log.updateHighestOffsetInRemoteStorage(2L);
+ assertEquals(1L, log.onlyLocalLogSegmentsCount());
+ assertEquals(log.activeSegment().size(), log.onlyLocalLogSegmentsSize());
+
+ // Two closed segments of two records each plus an empty active segment
+ for (int seg = 0; seg < 2; seg++) {
+ log.appendAsLeader(records.get(), 0);
+ log.appendAsLeader(records.get(), 0);
+ log.roll();
+ }
+ assertEquals(7L, log.logEndOffset());
+ // segments 0 - 2 contain 1 record each and seg 3 - 4 contain 2 records each, and the active segment is empty.
+ assertEquals(6, log.numberOfSegments());
+
+ log.updateHighestOffsetInRemoteStorage(4L);
+ assertEquals(2L, log.onlyLocalLogSegmentsCount());
+
+ // Both closed segments copied: only the active segment is local-only => lag 0.
+ log.updateHighestOffsetInRemoteStorage(6L);
+ assertEquals(1L, log.onlyLocalLogSegmentsCount());
+ }
+
@Test
public void shouldDeleteLocalLogSegmentsWhenPolicyIsEmptyWithMsRetention() throws IOException {
long oldTimestamp = mockTime.milliseconds() - 20000;