This is an automated email from the ASF dual-hosted git repository.
luoyuxia pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git
The following commit(s) were added to refs/heads/main by this push:
new 671d2bbba [lake/tiering] Add pendingRecordLag metric for lake tiering
(#3469)
671d2bbba is described below
commit 671d2bbbacf16dfc54cdc46e74e3dc6d9eb43716
Author: Junbo Wang <[email protected]>
AuthorDate: Fri Jul 10 10:19:44 2026 +0800
[lake/tiering] Add pendingRecordLag metric for lake tiering (#3469)
---
.../java/org/apache/fluss/metrics/MetricNames.java | 1 +
.../org/apache/fluss/server/log/LogTablet.java | 82 ++++++++++++++++++++++
.../org/apache/fluss/server/replica/Replica.java | 9 ++-
.../replica/NotifyReplicaLakeTableOffsetTest.java | 40 +++++++++++
.../apache/fluss/server/replica/ReplicaTest.java | 64 +++++++++++++++++
.../maintenance/observability/monitor-metrics.md | 7 +-
6 files changed, 197 insertions(+), 6 deletions(-)
diff --git
a/fluss-common/src/main/java/org/apache/fluss/metrics/MetricNames.java
b/fluss-common/src/main/java/org/apache/fluss/metrics/MetricNames.java
index f56ca967c..dc93a5e3d 100644
--- a/fluss-common/src/main/java/org/apache/fluss/metrics/MetricNames.java
+++ b/fluss-common/src/main/java/org/apache/fluss/metrics/MetricNames.java
@@ -231,6 +231,7 @@ public class MetricNames {
// for tablet
public static final String LAKE_PENDING_RECORDS = "pendingRecords";
+ public static final String LAKE_PENDING_RECORDS_LAG = "pendingRecordsLag";
// for log tablet
public static final String LOG_NUM_SEGMENTS = "numSegments";
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/log/LogTablet.java
b/fluss-server/src/main/java/org/apache/fluss/server/log/LogTablet.java
index 23c60c82e..150979d4c 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/log/LogTablet.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/log/LogTablet.java
@@ -132,6 +132,9 @@ public final class LogTablet {
private volatile long lakeLogStartOffset = Long.MAX_VALUE;
private volatile long lakeLogEndOffset = -1L;
private volatile long lakeMaxTimestamp = -1;
+ // Best-effort estimate under concurrent high-watermark and lake-progress
updates.
+ // Metric reads are allowed to observe transient intermediate states.
+ private volatile long estimatedPendingStartTimeMs = -1L;
private LogTablet(
File dataDir,
@@ -277,6 +280,34 @@ public final class LogTablet {
return lakeMaxTimestamp;
}
+ /**
+ * Returns the timestamp lag between the latest local log record and the
latest tiered lake log
+ * record.
+ */
+ public long getTimestampLag() {
+ return lakeMaxTimestamp < 0L ? -1L : localMaxTimestamp() -
lakeMaxTimestamp;
+ }
+
+ /**
+ * Returns the elapsed time, in milliseconds, since the oldest committed
record in this bucket
+ * became pending for lake tiering.
+ *
+ * <p>Returns 0 when there are no committed records pending lake tiering
or when the pending
+ * start time estimate is not initialized yet.
+ */
+ public long getPendingRecordsLag(long currentTimeMs) {
+ if (estimatedPendingStartTimeMs < 0L ||
!hasPendingLakeTieringRecords()) {
+ return 0L;
+ }
+ return Math.max(0L, currentTimeMs - estimatedPendingStartTimeMs);
+ }
+
+ /** Returns the estimated start time of the current pending lake-tiering
backlog for tests. */
+ @VisibleForTesting
+ public long getEstimatedPendingStartTimeMs() {
+ return estimatedPendingStartTimeMs;
+ }
+
public int getWriterIdCount() {
return writerStateManager.writerIdCount();
}
@@ -482,6 +513,7 @@ public final class LogTablet {
if (newHighWatermark.getMessageOffset() < 0) {
throw new IllegalArgumentException("High watermark offset should
be non-negative");
}
+ long previousHighWatermark = highWatermarkMetadata.getMessageOffset();
synchronized (lock) {
if (newHighWatermark.getMessageOffset() <
highWatermarkMetadata.getMessageOffset()) {
LOG.warn(
@@ -493,6 +525,7 @@ public final class LogTablet {
highWatermarkMetadata = newHighWatermark;
// TODO log offset listener to update log offset.
}
+ onHighWatermarkUpdated(previousHighWatermark,
newHighWatermark.getMessageOffset());
LOG.trace(
"Setting high watermark {} for bucket {}",
newHighWatermark,
@@ -599,13 +632,62 @@ public final class LogTablet {
public void updateLakeLogEndOffset(long lakeLogEndOffset) {
if (lakeLogEndOffset > this.lakeLogEndOffset) {
this.lakeLogEndOffset = lakeLogEndOffset;
+ // Lake-tiering progress advanced via the end offset; re-estimate
the pending start
+ // time so the lag is corrected (and cleared once caught up) even
when the lake max
+ // timestamp is not updated in the same notification.
+ onLakeProgressUpdated();
}
}
public void updateLakeMaxTimestamp(long lakeMaxTimestamp) {
if (lakeMaxTimestamp > this.lakeMaxTimestamp) {
this.lakeMaxTimestamp = lakeMaxTimestamp;
+ onLakeProgressUpdated();
+ }
+ }
+
+ private void onHighWatermarkUpdated(long previousHighWatermark, long
currentHighWatermark) {
+ if (!isDataLakeEnabled) {
+ return;
+ }
+ long firstPendingOffset = Math.max(lakeLogEndOffset,
localLogStartOffset());
+ boolean hadPendingBeforeUpdate = firstPendingOffset <
previousHighWatermark;
+ boolean hasPendingAfterUpdate = firstPendingOffset <
currentHighWatermark;
+ if (!hasPendingAfterUpdate) {
+ clearPendingStartTime();
+ } else if (!hadPendingBeforeUpdate) {
+ markPendingStartTimeNow();
+ }
+ }
+
+ private void onLakeProgressUpdated() {
+ if (!hasPendingLakeTieringRecords()) {
+ clearPendingStartTime();
+ return;
+ }
+ long timestampLag = getTimestampLag();
+ if (timestampLag < 0L) {
+ return;
}
+ long candidatePendingStartTimeMs = Math.max(0L, clock.milliseconds() -
timestampLag);
+ advancePendingStartTime(candidatePendingStartTimeMs);
+ }
+
+ private boolean hasPendingLakeTieringRecords() {
+ return Math.max(lakeLogEndOffset, localLogStartOffset()) <
getHighWatermark();
+ }
+
+ private void clearPendingStartTime() {
+ estimatedPendingStartTimeMs = -1L;
+ }
+
+ private void markPendingStartTimeNow() {
+ estimatedPendingStartTimeMs = clock.milliseconds();
+ }
+
+ private void advancePendingStartTime(long candidatePendingStartTimeMs) {
+ estimatedPendingStartTimeMs =
+ Math.max(estimatedPendingStartTimeMs,
candidatePendingStartTimeMs);
}
public void loadWriterSnapshot(long lastOffset) throws IOException {
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java
b/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java
index e11dd69c1..1b0407e81 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java
@@ -598,11 +598,10 @@ public final class Replica {
return getLogHighWatermark() - lakeLogEndOffset;
});
lakeTieringMetricGroup.gauge(
- MetricNames.LOG_LAKE_TIMESTAMP_LAG,
- () ->
- logTablet.getLakeMaxTimestamp() < 0L
- ? -1
- : logTablet.localMaxTimestamp() -
logTablet.getLakeMaxTimestamp());
+ MetricNames.LOG_LAKE_TIMESTAMP_LAG,
logTablet::getTimestampLag);
+ lakeTieringMetricGroup.gauge(
+ MetricNames.LAKE_PENDING_RECORDS_LAG,
+ () -> logTablet.getPendingRecordsLag(clock.milliseconds()));
}
private void onBecomeNewFollower(int standbyReplica) {
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/replica/NotifyReplicaLakeTableOffsetTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/replica/NotifyReplicaLakeTableOffsetTest.java
index 0d0b25204..8b5795644 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/replica/NotifyReplicaLakeTableOffsetTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/replica/NotifyReplicaLakeTableOffsetTest.java
@@ -18,18 +18,27 @@
package org.apache.fluss.server.replica;
import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.record.MemoryLogRecords;
import org.apache.fluss.rpc.messages.NotifyLakeTableOffsetResponse;
import org.apache.fluss.server.entity.LakeBucketOffset;
import org.apache.fluss.server.entity.NotifyLakeTableOffsetData;
import org.assertj.core.api.AssertionsForClassTypes;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import java.time.Duration;
import java.util.Collections;
import java.util.concurrent.CompletableFuture;
+import static org.apache.fluss.record.LogRecordBatch.CURRENT_LOG_MAGIC_VALUE;
+import static org.apache.fluss.record.TestData.DATA1;
+import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE;
import static org.apache.fluss.record.TestData.DATA1_TABLE_ID;
+import static org.apache.fluss.record.TestData.DEFAULT_SCHEMA_ID;
+import static
org.apache.fluss.testutils.DataTestUtils.createRecordsWithoutBaseLogOffset;
+import static org.assertj.core.api.Assertions.assertThat;
/** Test for notify replica lakehouse data info. */
class NotifyReplicaLakeTableOffsetTest extends ReplicaTestBase {
@@ -48,6 +57,37 @@ class NotifyReplicaLakeTableOffsetTest extends
ReplicaTestBase {
notifyAndVerify(tb, replica, 2, 20L, 30L, System.currentTimeMillis());
}
+ @Test
+ void testNotifyLakeTableOffsetClearsPendingRecordsLagWhenCaughtUp() throws
Exception {
+ TableBucket tb = makeTableBucket(false);
+ makeLogTableAsLeader(tb, false);
+ Replica replica = replicaManager.getReplicaOrException(tb);
+ replica.updateIsDataLakeEnabled(true);
+
+ long initialTimestamp = manualClock.milliseconds();
+ MemoryLogRecords records =
+ createRecordsWithoutBaseLogOffset(
+ DATA1_ROW_TYPE,
+ DEFAULT_SCHEMA_ID,
+ 0,
+ initialTimestamp,
+ CURRENT_LOG_MAGIC_VALUE,
+ DATA1,
+ replica.getLogFormat());
+ replica.appendRecordsToLeader(records, 0);
+
replica.getLogTablet().updateHighWatermark(replica.getLocalLogEndOffset());
+
+ manualClock.advanceTime(Duration.ofSeconds(30));
+
assertThat(replica.getLogTablet().getPendingRecordsLag(manualClock.milliseconds()))
+ .isEqualTo(Duration.ofSeconds(30).toMillis());
+
+ notifyAndVerify(tb, replica, 1, 0L, replica.getLocalLogEndOffset(),
initialTimestamp);
+
+
assertThat(replica.getLogTablet().getPendingRecordsLag(manualClock.milliseconds()))
+ .isZero();
+
assertThat(replica.getLogTablet().getEstimatedPendingStartTimeMs()).isEqualTo(-1L);
+ }
+
private void notifyAndVerify(
TableBucket tb,
Replica replica,
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java
index 4eefc0e2f..d37e91dc6 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java
@@ -25,6 +25,10 @@ import org.apache.fluss.metadata.SchemaGetter;
import org.apache.fluss.metadata.SchemaInfo;
import org.apache.fluss.metadata.TableBucket;
import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.metrics.Gauge;
+import org.apache.fluss.metrics.MetricNames;
+import org.apache.fluss.metrics.groups.AbstractMetricGroup;
+import org.apache.fluss.metrics.groups.MetricGroup;
import org.apache.fluss.record.ChangeType;
import org.apache.fluss.record.KvRecordBatch;
import org.apache.fluss.record.KvRecordTestUtils;
@@ -748,6 +752,57 @@ final class ReplicaTest extends ReplicaTestBase {
verifyGetKeyValues(kvTablet, expectedKeyValues);
}
+ @Test
+ void testPendingRecordsLagStartsFromNewBacklogAfterIdle() throws Exception
{
+ Replica logReplica =
+ makeLogReplica(DATA1_PHYSICAL_TABLE_PATH, new
TableBucket(DATA1_TABLE_ID, 1));
+ makeLogReplicaAsLeader(logReplica);
+ logReplica.updateIsDataLakeEnabled(true);
+
+ long initialTimestamp = manualClock.milliseconds();
+ MemoryLogRecords firstBatch =
+ createRecordsWithoutBaseLogOffset(
+ DATA1_ROW_TYPE,
+ DEFAULT_SCHEMA_ID,
+ 0,
+ initialTimestamp,
+ CURRENT_LOG_MAGIC_VALUE,
+ DATA1,
+ LogFormat.ARROW);
+ logReplica.appendRecordsToLeader(firstBatch, 0);
+
logReplica.getLogTablet().updateHighWatermark(logReplica.getLocalLogEndOffset());
+
logReplica.getLogTablet().updateLakeLogEndOffset(logReplica.getLocalLogEndOffset());
+ logReplica.getLogTablet().updateLakeMaxTimestamp(initialTimestamp);
+
+ assertThat(getLakeTieringGaugeValue(logReplica,
MetricNames.LAKE_PENDING_RECORDS_LAG))
+ .isZero();
+
+ manualClock.advanceTime(Duration.ofMinutes(10));
+ long newBatchTimestamp = manualClock.milliseconds();
+ MemoryLogRecords secondBatch =
+ createRecordsWithoutBaseLogOffset(
+ DATA1_ROW_TYPE,
+ DEFAULT_SCHEMA_ID,
+ 0,
+ newBatchTimestamp,
+ CURRENT_LOG_MAGIC_VALUE,
+ DATA1,
+ LogFormat.ARROW);
+ logReplica.appendRecordsToLeader(secondBatch, 0);
+
logReplica.getLogTablet().updateHighWatermark(logReplica.getLocalLogEndOffset());
+
+ assertThat(getLakeTieringGaugeValue(logReplica,
MetricNames.LOG_LAKE_TIMESTAMP_LAG))
+ .isEqualTo(Duration.ofMinutes(10).toMillis());
+ assertThat(getLakeTieringGaugeValue(logReplica,
MetricNames.LAKE_PENDING_RECORDS_LAG))
+ .isZero();
+ assertThat(logReplica.getLogTablet().getEstimatedPendingStartTimeMs())
+ .isEqualTo(manualClock.milliseconds());
+
+ manualClock.advanceTime(Duration.ofSeconds(5));
+ assertThat(getLakeTieringGaugeValue(logReplica,
MetricNames.LAKE_PENDING_RECORDS_LAG))
+ .isEqualTo(Duration.ofSeconds(5).toMillis());
+ }
+
@Test
void testUpdateIsDataLakeEnabled() throws Exception {
Replica logReplica =
@@ -770,6 +825,15 @@ final class ReplicaTest extends ReplicaTestBase {
assertThat(logReplica.getLogTablet().isDataLakeEnabled()).isFalse();
}
+ @SuppressWarnings("unchecked")
+ private long getLakeTieringGaugeValue(Replica replica, String metricName) {
+ MetricGroup lakeTieringMetricGroup =
replica.bucketMetrics().addGroup("lakeTiering");
+ Gauge<Long> gauge =
+ (Gauge<Long>)
+ ((AbstractMetricGroup)
lakeTieringMetricGroup).getMetrics().get(metricName);
+ return gauge.getValue();
+ }
+
private void makeLogReplicaAsLeader(Replica replica) throws Exception {
makeLeaderReplica(
replica,
diff --git a/website/docs/maintenance/observability/monitor-metrics.md
b/website/docs/maintenance/observability/monitor-metrics.md
index 5383250b8..50ae66ea3 100644
--- a/website/docs/maintenance/observability/monitor-metrics.md
+++ b/website/docs/maintenance/observability/monitor-metrics.md
@@ -880,7 +880,7 @@ Some metrics might not be exposed when using other JVM
implementations (e.g. IBM
<td>Gauge</td>
</tr>
<tr>
- <td rowspan="2">table_bucket_lakeTiering</td>
+ <td rowspan="3">table_bucket_lakeTiering</td>
<td>pendingRecords</td>
<td>The number of records lag between the latest log record and the
latest tiered lake log record for this table bucket. Returns -1 if row count is
disabled (WAL mode or v0.9 old table) and no tiering has completed.</td>
<td>Gauge</td>
@@ -890,6 +890,11 @@ Some metrics might not be exposed when using other JVM
implementations (e.g. IBM
<td>The timestamp lag between the latest log record and the latest
tiered lake log record for this table bucket, in milliseconds.</td>
<td>Gauge</td>
</tr>
+ <tr>
+ <td>pendingRecordsLag</td>
+ <td>The elapsed time, in milliseconds, since the oldest committed record
in this table bucket became pending for lake tiering. Returns 0 when no
committed records are pending lake tiering.</td>
+ <td>Gauge</td>
+ </tr>
<tr>
<td rowspan="3">table_bucket_remoteLog</td>
<td>numSegments</td>