This is an automated email from the ASF dual-hosted git repository.

jt2594838 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb.git


The following commit(s) were added to refs/heads/master by this push:
     new 83f6428629d Fix subscription WAL replay cursor boundaries (#18810)
83f6428629d is described below

commit 83f6428629d05d97956866b58f8423cecd21a2c0
Author: Caideyipi <[email protected]>
AuthorDate: Sat Oct 10 10:46:40 2026 +0800

    Fix subscription WAL replay cursor boundaries (#18810)
    
    * Fix subscription AT_END replay cursor boundary
    
    * Fix beginning seek counting the empty WAL sentinel as a gap
---
 .../consensus/ConsensusPrefetchingQueue.java       |  18 +-
 .../ConsensusPrefetchingQueueSeekTest.java         | 241 +++++++++++++++++++++
 .../consensus/ConsensusPrefetchingQueueTest.java   |  53 ++++-
 3 files changed, 304 insertions(+), 8 deletions(-)

diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
index 8d2f0af7cec..22fd01f6c47 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java
@@ -1103,11 +1103,17 @@ public class ConsensusPrefetchingQueue {
           "resolved first uncovered replayable WAL record");
     }
     return ReplayLocateDecision.atEnd(
-        consensusReqReader.getCurrentSearchIndex(),
+        nextSearchIndexAfterCurrent(),
         effectiveRecoveryRegionProgress,
         "all locally replayable WAL records are already covered");
   }
 
+  private long nextSearchIndexAfterCurrent() {
+    // The reader reports the last local WAL index, while the subscription 
cursor is the next
+    // index to read. Starting at the last index would replay it and report 
one spurious WAL gap.
+    return consensusReqReader.getCurrentSearchIndex() + 1L;
+  }
+
   protected ReplayLocateDecision locateReplayStartForRegionProgress(
       final RegionProgress regionProgress, final boolean seekAfter) {
     if (!(consensusReqReader instanceof WALNode)) {
@@ -3068,19 +3074,19 @@ public class ConsensusPrefetchingQueue {
   // ======================== Seek ========================
 
   /**
-   * Seeks to the earliest available WAL position. The actual position depends 
on WAL retention: if
-   * old files have been reclaimed, the earliest available position may be 
later than 0.
+   * Seeks to the earliest available WAL position. Local consensus search 
indexes start at 1; 0
+   * denotes an empty WAL. If old files have been reclaimed, replay may start 
at a later index.
    */
   public void seekToBeginning() {
-    seekToResolvedPosition(0L, new RegionProgress(Collections.emptyMap()), 
"beginning");
+    seekToResolvedPosition(
+        FIRST_CONSENSUS_SEARCH_INDEX, new 
RegionProgress(Collections.emptyMap()), "beginning");
   }
 
   /**
    * Seeks to the current WAL write position. After this, only newly written 
data will be consumed.
    */
   public void seekToEnd() {
-    seekToResolvedPosition(
-        consensusReqReader.getCurrentSearchIndex(), 
computeTailRegionProgress(), "end");
+    seekToResolvedPosition(nextSearchIndexAfterCurrent(), 
computeTailRegionProgress(), "end");
   }
 
   public void seekToRegionProgress(final RegionProgress regionProgress) {
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueSeekTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueSeekTest.java
new file mode 100644
index 00000000000..54fcb704f00
--- /dev/null
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueSeekTest.java
@@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.subscription.broker.consensus;
+
+import org.apache.iotdb.commons.conf.CommonConfig;
+import org.apache.iotdb.commons.conf.CommonDescriptor;
+import org.apache.iotdb.commons.consensus.DataRegionId;
+import org.apache.iotdb.consensus.common.request.IndexedConsensusRequest;
+import org.apache.iotdb.consensus.iot.IoTConsensusServerImpl;
+import org.apache.iotdb.consensus.iot.SubscriptionWalRetentionPolicy;
+import org.apache.iotdb.consensus.iot.WriterSafeFrontierTracker;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
+import 
org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.InsertRowNode;
+import org.apache.iotdb.db.queryengine.plan.statement.StatementTestUtils;
+import org.apache.iotdb.db.storageengine.dataregion.wal.node.WALNode;
+import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
+import org.apache.iotdb.db.subscription.resource.SubscriptionMemoryManager;
+import 
org.apache.iotdb.db.subscription.task.execution.ConsensusSubscriptionPrefetchExecutor;
+import 
org.apache.iotdb.db.subscription.task.execution.ConsensusSubscriptionPrefetchExecutorManager;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress;
+import 
org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollResponseType;
+import org.apache.iotdb.rpc.subscription.payload.poll.TabletsPayload;
+
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.write.record.Tablet;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.powermock.api.mockito.PowerMockito;
+import org.powermock.core.classloader.annotations.PowerMockIgnore;
+import org.powermock.core.classloader.annotations.PrepareForTest;
+import org.powermock.modules.junit4.PowerMockRunner;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(PowerMockRunner.class)
+@PrepareForTest(ConsensusSubscriptionPrefetchExecutorManager.class)
+@PowerMockIgnore({"com.sun.org.apache.xerces.*", "javax.xml.*", "org.xml.*", 
"javax.management.*"})
+public class ConsensusPrefetchingQueueSeekTest {
+
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test(timeout = 10_000L)
+  public void testSeekToBeginningReplaysFirstLocalEntryWithoutWalGap() throws 
Exception {
+    assertBeginningReplay(Collections.singletonList(createRequest(1L, 1L, 7)), 
2L, 0L);
+  }
+
+  @Test(timeout = 10_000L)
+  public void testSeekToBeginningPreservesFollowerEntryBeforeFirstLocalEntry() 
throws Exception {
+    assertBeginningReplay(
+        Arrays.asList(createRequest(-1L, 10L, 8), createRequest(1L, 1L, 7)), 
2L, 0L);
+  }
+
+  @Test(timeout = 10_000L)
+  public void testSeekToBeginningCountsOnlyMissingValidLocalIndexes() throws 
Exception {
+    // Local indexes 1 and 2 are absent. Index 0 is the empty-WAL sentinel, 
not a missing entry.
+    assertBeginningReplay(
+        Arrays.asList(createRequest(3L, 3L, 7), createRequest(4L, 4L, 7)), 5L, 
2L);
+  }
+
+  private void assertBeginningReplay(
+      final List<IndexedConsensusRequest> requests,
+      final long expectedNextIndex,
+      final long expectedSkippedEntries)
+      throws Exception {
+    final CommonConfig config = CommonDescriptor.getInstance().getConfig();
+    final int originalBatchMaxDelay = 
config.getSubscriptionConsensusBatchMaxDelayInMs();
+    final int originalPrefetchThreads =
+        config.getSubscriptionConsensusPrefetchExecutorMaxThreadNum();
+    final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final File systemDir = temporaryFolder.newFolder();
+    ConsensusSubscriptionPrefetchExecutor executor = null;
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      config.setSubscriptionConsensusBatchMaxDelayInMs(0);
+      config.setSubscriptionConsensusPrefetchExecutorMaxThreadNum(1);
+      
IoTDBDescriptor.getInstance().getConfig().setSystemDir(systemDir.getAbsolutePath());
+
+      // Supply an isolated runtime even in builds where subscriptions are 
disabled. Seek reset
+      // and replay still run through the real serial prefetch worker and the 
public queue API.
+      executor = new ConsensusSubscriptionPrefetchExecutor();
+      final ConsensusSubscriptionPrefetchExecutorManager manager =
+          mock(ConsensusSubscriptionPrefetchExecutorManager.class);
+      
PowerMockito.mockStatic(ConsensusSubscriptionPrefetchExecutorManager.class);
+      
PowerMockito.when(ConsensusSubscriptionPrefetchExecutorManager.getInstance())
+          .thenReturn(manager);
+      when(manager.getExecutor()).thenReturn(executor);
+
+      final WALNode walNode = mock(WALNode.class);
+      when(walNode.getCurrentSearchIndex()).thenReturn(expectedNextIndex - 1L);
+      when(walNode.getLogDirectory()).thenReturn(systemDir);
+      final IoTConsensusServerImpl server = mock(IoTConsensusServerImpl.class);
+      when(server.getConsensusReqReader()).thenReturn(walNode);
+      when(server.getWriterSafeFrontierTracker()).thenReturn(new 
WriterSafeFrontierTracker());
+      final ConsensusLogToTabletConverter converter = 
mock(ConsensusLogToTabletConverter.class);
+      when(converter.getDatabaseName()).thenReturn("db");
+      when(converter.isTableModel()).thenReturn(true);
+      when(converter.convert(any()))
+          .thenAnswer(
+              invocation ->
+                  Collections.singletonList(
+                      createTablet(((InsertRowNode) 
invocation.getArgument(0)).getTime())));
+
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              new DataRegionId(1),
+              server,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              new ConsensusSubscriptionCommitManager(
+                  (consumerGroupId, topicName, regionId) ->
+                      
ConsensusSubscriptionCommitManager.ConfigNodeProgressQueryResult.absent()),
+              new RegionProgress(Collections.emptyMap()),
+              expectedNextIndex,
+              1L,
+              true) {
+            @Override
+            protected ProgressWALIterator createSubscriptionWALIterator(
+                final long startSearchIndex) {
+              final Iterator<IndexedConsensusRequest> retainedEntries =
+                  requests.stream()
+                      .filter(
+                          request ->
+                              request.getSearchIndex() < 0
+                                  || request.getSearchIndex() >= 
startSearchIndex)
+                      .iterator();
+              final ProgressWALIterator iterator = 
mock(ProgressWALIterator.class);
+              when(iterator.hasNext()).thenAnswer(ignored -> 
retainedEntries.hasNext());
+              when(iterator.next()).thenAnswer(ignored -> 
retainedEntries.next());
+              return iterator;
+            }
+          };
+      queue.setSubscriptionMemoryManager(new SubscriptionMemoryManager(16L * 
1024 * 1024));
+
+      queue.seekToBeginning();
+
+      final List<Long> actualTimestamps = new ArrayList<>();
+      final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
+      while (actualTimestamps.size() < requests.size() && System.nanoTime() < 
deadline) {
+        final SubscriptionEvent event = queue.poll("consumer");
+        if (event == null) {
+          TimeUnit.MILLISECONDS.sleep(10L);
+          continue;
+        }
+        assertEquals(
+            SubscriptionPollResponseType.TABLETS.getType(),
+            event.getCurrentResponse().getResponseType());
+        for (final Tablet tablet :
+            ((TabletsPayload) 
event.getCurrentResponse().getPayload()).getTablets()) {
+          assertEquals(1, tablet.getRowSize());
+          actualTimestamps.add(tablet.getTimestamps()[0]);
+        }
+      }
+      final List<Long> expectedTimestamps = new ArrayList<>();
+      for (final IndexedConsensusRequest request : requests) {
+        expectedTimestamps.add(((InsertRowNode) 
request.getRequests().get(0)).getTime());
+      }
+      Collections.sort(expectedTimestamps);
+      Collections.sort(actualTimestamps);
+      assertEquals(expectedTimestamps, actualTimestamps);
+      assertEquals(expectedNextIndex, queue.getCurrentReadSearchIndex());
+      assertEquals(expectedSkippedEntries, queue.getWalGapSkippedEntries());
+      verify(converter, times(requests.size())).convert(any());
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      if (executor != null) {
+        executor.shutdown();
+      }
+      config.setSubscriptionConsensusBatchMaxDelayInMs(originalBatchMaxDelay);
+      
config.setSubscriptionConsensusPrefetchExecutorMaxThreadNum(originalPrefetchThreads);
+      
IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
+  private static IndexedConsensusRequest createRequest(
+      final long searchIndex, final long localSeq, final int writerNodeId) {
+    return new IndexedConsensusRequest(
+            searchIndex,
+            localSeq,
+            Collections.singletonList(
+                
StatementTestUtils.genInsertRowNode(Math.toIntExact(localSeq))))
+        .setPhysicalTime(1000L + localSeq)
+        .setNodeId(writerNodeId);
+  }
+
+  private static Tablet createTablet(final long timestamp) {
+    final Tablet tablet =
+        new Tablet(
+            "sensors",
+            Arrays.asList("device", "temperature"),
+            Arrays.asList(TSDataType.STRING, TSDataType.DOUBLE),
+            Arrays.asList(ColumnCategory.TAG, ColumnCategory.FIELD),
+            1);
+    tablet.addTimestamp(0, timestamp);
+    tablet.addValue(0, 0, "d1");
+    tablet.addValue(0, 1, 36.5);
+    tablet.setRowSize(1);
+    return tablet;
+  }
+}
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueTest.java
index 0f321341401..190a21cca3f 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueTest.java
@@ -612,12 +612,13 @@ public class ConsensusPrefetchingQueueTest {
       reader.currentSearchIndex = 5L;
       final ConsensusPrefetchingQueue.ReplayLocateDecision tailDecision =
           queue.scanReplayStartForRequests(
-              Collections.singletonList(createRequest(-1L, 11L, 101L, 
8)).iterator(),
+              Arrays.asList(createRequest(5L, 10L, 100L, 8), 
createRequest(-1L, 11L, 101L, 8))
+                  .iterator(),
               regionProgress,
               true);
 
       assertEquals(ConsensusPrefetchingQueue.ReplayLocateStatus.AT_END, 
tailDecision.getStatus());
-      assertEquals(5L, tailDecision.getStartSearchIndex());
+      assertEquals(6L, tailDecision.getStartSearchIndex());
       assertEquals(
           committedProgress,
           
tailDecision.getRecoveryRegionProgress().getWriterPositions().get(formerLeader));
@@ -1129,6 +1130,7 @@ public class ConsensusPrefetchingQueueTest {
           queue.scanReplayStartForRequests(Collections.emptyIterator(), 
requestedProgress, true);
 
       assertEquals(ConsensusPrefetchingQueue.ReplayLocateStatus.AT_END, 
decision.getStatus());
+      assertEquals(6L, decision.getStartSearchIndex());
       assertEquals(requestedProgress, decision.getRecoveryRegionProgress());
     } finally {
       if (queue != null) {
@@ -1138,6 +1140,53 @@ public class ConsensusPrefetchingQueueTest {
     }
   }
 
+  @Test
+  public void testAtEndInitializationHasNoTailGap() throws Exception {
+    final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final File systemDir = temporaryFolder.newFolder("atEndNoTailGap");
+    final File walDirectory = temporaryFolder.newFolder("atEndNoTailGapWal");
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      final DataRegionId regionId = new DataRegionId(7);
+      final WALNode walNode = mock(WALNode.class);
+      when(walNode.getLogDirectory()).thenReturn(walDirectory);
+      when(walNode.getCurrentSearchIndex()).thenReturn(5L);
+      final IoTConsensusServerImpl serverImpl = 
mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(walNode);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new 
WriterSafeFrontierTracker());
+      final RegionProgress committedProgress =
+          new RegionProgress(
+              Collections.singletonMap(
+                  new WriterId(regionId.toString(), 7), new 
WriterProgress(1000L, 5L)));
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              mock(ConsensusLogToTabletConverter.class),
+              newCommitManager(systemDir),
+              committedProgress,
+              6L,
+              1L,
+              true);
+
+      assertNull(queue.poll("consumer"));
+      assertEquals(6L, queue.getCurrentReadSearchIndex());
+      assertEquals(0L, queue.getRawWalGap());
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      
IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
   @Test
   public void 
testAtEndInitializationPreservesPendingQueueRegistrationBoundary() throws 
Exception {
     final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();

Reply via email to