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 4acf90c3d89 [Subscription] Resume realtime delivery after WAL gap 
recovery (#18912)
4acf90c3d89 is described below

commit 4acf90c3d8956e79d141c63093c58a807b0bdf4b
Author: Caideyipi <[email protected]>
AuthorDate: Mon Oct 19 13:57:05 2026 +0800

    [Subscription] Resume realtime delivery after WAL gap recovery (#18912)
---
 .../consensus/ConsensusPrefetchingQueue.java       |  23 ++-
 .../ConsensusPrefetchingQueueDeliveryTest.java     |  16 +-
 ...nsensusPrefetchingQueueWalBackpressureTest.java | 219 ++++++++++++++++++++-
 3 files changed, 245 insertions(+), 13 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 6ed389ddafb..ae71c4fea2a 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
@@ -2286,14 +2286,15 @@ public class ConsensusPrefetchingQueue {
       subscriptionWALIterator.advanceTo(fromIndex, 
this::isWriterProgressCoveredForWalFastForward);
     }
     // A pending-queue jump can span millions of WAL entries when real-time 
delivery has overflowed.
-    // Keep gap recovery within the normal per-round budget so sparse topic 
filtering cannot hold
-    // the
-    // queue read lock indefinitely and block unsubscribe from acquiring the 
close write lock.
+    // Stop replay at the pending boundary. Admitted requests should use 
direct delivery instead of
+    // being replayed and discarded as duplicates. Gap recovery still shares 
the normal scan budget,
+    // so sparse filtering cannot hold the read lock indefinitely and block 
unsubscribe.
     final MaterializationResult pumpResult =
         pumpFromSubscriptionWAL(
             batchState,
             expectedSeekGeneration,
             maxWalEntries,
+            toIndex,
             maxTablets,
             maxBatchBytes,
             walScanBudget);
@@ -2353,6 +2354,7 @@ public class ConsensusPrefetchingQueue {
             batchState,
             expectedSeekGeneration,
             maxWalEntries,
+            Long.MAX_VALUE,
             maxTablets,
             maxBatchBytes,
             walScanBudget);
@@ -2380,6 +2382,7 @@ public class ConsensusPrefetchingQueue {
       final DeliveryBatchState batchState,
       final long expectedSeekGeneration,
       final int maxWalEntries,
+      final long endSearchIndexExclusive,
       final int maxTablets,
       final long maxBatchBytes,
       final ProgressWALIterator.ScanBudget walScanBudget) {
@@ -2406,6 +2409,14 @@ public class ConsensusPrefetchingQueue {
       reportUnreadableWalReplayIfNecessary();
       try {
         final IndexedConsensusRequest walEntry = 
subscriptionWALIterator.next();
+        if (hasLocalSearchIndex(walEntry) && walEntry.getSearchIndex() >= 
endSearchIndexExclusive) {
+          // A sparse WAL can jump past the pending boundary. Preserve its 
next request while
+          // handing the admitted entry back to the pending path, and report 
only the missing gap.
+          subscriptionWALIterator.retry(walEntry);
+          reportWalGapBefore(endSearchIndexExclusive);
+          nextExpectedSearchIndex.set(endSearchIndexExclusive);
+          break;
+        }
         entriesRead++;
 
         if (isBeforeLocalCursor(walEntry)) {
@@ -2495,6 +2506,11 @@ public class ConsensusPrefetchingQueue {
     }
 
     final long actualSearchIndex = request.getSearchIndex();
+    reportWalGapBefore(actualSearchIndex);
+    nextExpectedSearchIndex.set(actualSearchIndex + 1);
+  }
+
+  private void reportWalGapBefore(final long actualSearchIndex) {
     final long expectedSearchIndex = nextExpectedSearchIndex.get();
     if (actualSearchIndex > expectedSearchIndex) {
       final long skippedEntries = actualSearchIndex - expectedSearchIndex;
@@ -2508,7 +2524,6 @@ public class ConsensusPrefetchingQueue {
           skippedEntries,
           totalSkippedEntries);
     }
-    nextExpectedSearchIndex.set(actualSearchIndex + 1);
   }
 
   private void ensureSubscriptionWalReadable(final 
ProgressWALIterator.ScanBudget walScanBudget) {
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDeliveryTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDeliveryTest.java
index e553a509766..1f1a14535ca 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDeliveryTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDeliveryTest.java
@@ -257,7 +257,9 @@ public class ConsensusPrefetchingQueueDeliveryTest {
     config.setSubscriptionConsensusBatchMaxTabletCount(2);
     writeWal(1, true);
     currentSearchIndex.set(2);
-    final ConsensusPrefetchingQueue queue = newQueue();
+    // Keep the mixed batch deterministic when first-use class loading 
exhausts a scan slice.
+    // Bounded scan yielding is covered by 
ConsensusPrefetchingQueueWalBackpressureTest.
+    final ConsensusPrefetchingQueue queue = newQueue(true);
     assertNull(queue.poll(CONSUMER));
     assertTrue(pendingEntries(queue).offer(request(2, 2, WRITER)));
     queue.drivePrefetchOnce();
@@ -591,6 +593,10 @@ public class ConsensusPrefetchingQueueDeliveryTest {
   }
 
   private ConsensusPrefetchingQueue newQueue() {
+    return newQueue(false);
+  }
+
+  private ConsensusPrefetchingQueue newQueue(final boolean 
unboundedWalFixture) {
     final IoTConsensusServerImpl server = mock(IoTConsensusServerImpl.class);
     when(server.getConsensusReqReader()).thenReturn(walNode);
     when(server.getWriterSafeFrontierTracker()).thenReturn(new 
WriterSafeFrontierTracker());
@@ -614,6 +620,14 @@ public class ConsensusPrefetchingQueueDeliveryTest {
           @Override
           protected ProgressWALIterator createSubscriptionWALIterator(final 
long startSearchIndex) {
             walIteratorCreations.incrementAndGet();
+            if (unboundedWalFixture) {
+              return new ProgressWALIterator(walNode, startSearchIndex) {
+                @Override
+                boolean hasNext(final ScanBudget scanBudget) {
+                  return super.hasNext(null);
+                }
+              };
+            }
             return super.createSubscriptionWALIterator(startSearchIndex);
           }
         };
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueWalBackpressureTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueWalBackpressureTest.java
index 204a557367e..adcf18a50d8 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueWalBackpressureTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueWalBackpressureTest.java
@@ -22,11 +22,16 @@ 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.commons.memory.AtomicLongMemoryBlock;
+import org.apache.iotdb.commons.memory.IMemoryBlock;
 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.consensus.iot.logdispatcher.IoTConsensusMemoryManager;
+import 
org.apache.iotdb.consensus.iot.subscription.SubscriptionQueueRejectionReason;
 import org.apache.iotdb.db.conf.IoTDBDescriptor;
+import org.apache.iotdb.db.pipe.resource.memory.PipeMemoryWeightUtil;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.InsertNode;
 import 
org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.RelationalInsertRowNode;
 import org.apache.iotdb.db.queryengine.plan.statement.StatementTestUtils;
@@ -62,6 +67,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
@@ -247,6 +253,181 @@ public class ConsensusPrefetchingQueueWalBackpressureTest 
{
     }
   }
 
+  @Test
+  public void testWalGapRecoveryResumesRealtimeAfterConsensusMemoryRejection() 
throws Exception {
+    assertWalGapRecoveryResumesRealtime(false, false, false);
+  }
+
+  @Test
+  public void testWalGapRecoveryResumesRealtimeAfterAck() throws Exception {
+    assertWalGapRecoveryResumesRealtime(true, false, false);
+  }
+
+  @Test
+  public void testWalGapRecoveryResumesPendingWhenWalStartsAfterGap() throws 
Exception {
+    assertWalGapRecoveryResumesRealtime(false, true, false);
+  }
+
+  @Test
+  public void testWalGapRecoveryPreservesFollowerBeforePendingBoundary() 
throws Exception {
+    assertWalGapRecoveryResumesRealtime(false, false, true);
+  }
+
+  private void assertWalGapRecoveryResumesRealtime(
+      final boolean memoryBackpressure,
+      final boolean missingWalGap,
+      final boolean followerBeforePending)
+      throws Exception {
+    final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+    final CommonConfig config = CommonDescriptor.getInstance().getConfig();
+    final int originalBatchMaxWalEntries = 
config.getSubscriptionConsensusBatchMaxWalEntries();
+    final int originalBatchMaxTabletCount = 
config.getSubscriptionConsensusBatchMaxTabletCount();
+    final int originalBatchMaxDelay = 
config.getSubscriptionConsensusBatchMaxDelayInMs();
+    final long originalWatermarkInterval = 
config.getSubscriptionConsensusWatermarkIntervalMs();
+    final IoTConsensusMemoryManager requestMemoryManager = 
IoTConsensusMemoryManager.getInstance();
+    final IMemoryBlock originalRequestMemoryBlock = 
requestMemoryManager.getMemoryBlock();
+    final double originalQueueRatio = 
requestMemoryManager.getMaxMemoryRatioForQueue();
+    final AtomicLongMemoryBlock requestMemoryBlock =
+        new AtomicLongMemoryBlock("subscription-gap-recovery", null, 
1_000_000L);
+    final File systemDir = temporaryFolder.newFolder("system-gap-recovery");
+    final File walDirectory = temporaryFolder.newFolder("wal-gap-recovery");
+    final int requestCount = 8;
+    ConsensusPrefetchingQueue queue = null;
+    try {
+      config.setSubscriptionConsensusBatchMaxWalEntries(128);
+      config.setSubscriptionConsensusBatchMaxTabletCount(1);
+      config.setSubscriptionConsensusBatchMaxDelayInMs(0);
+      config.setSubscriptionConsensusWatermarkIntervalMs(0L);
+      writeSealedWal(walDirectory, requestCount, missingWalGap ? 2L : 1L, 
followerBeforePending);
+
+      final WALNode walNode = mock(WALNode.class);
+      when(walNode.getLogDirectory()).thenReturn(walDirectory);
+      when(walNode.getCurrentSearchIndex()).thenReturn((long) requestCount);
+      when(walNode.getCurrentWALFileVersion()).thenReturn(1L);
+      when(walNode.getCurrentWALMetaDataSnapshot()).thenReturn(new 
WALMetaData());
+      final IoTConsensusServerImpl serverImpl = 
mock(IoTConsensusServerImpl.class);
+      when(serverImpl.getConsensusReqReader()).thenReturn(walNode);
+      when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new 
WriterSafeFrontierTracker());
+      final List<Long> conversions = new ArrayList<>();
+      final ConsensusLogToTabletConverter converter = 
mock(ConsensusLogToTabletConverter.class);
+      when(converter.convert(any()))
+          .thenAnswer(
+              invocation -> {
+                conversions.add(((InsertNode) 
invocation.getArgument(0)).getSearchIndex());
+                return Collections.singletonList(createTablet());
+              });
+      when(converter.getDatabaseName()).thenReturn("db");
+      final DataRegionId regionId = new DataRegionId(1);
+      final ConsensusSubscriptionCommitManager commitManager = 
newCommitManager(systemDir);
+      queue =
+          new ConsensusPrefetchingQueue(
+              "consumerGroup",
+              "topic",
+              followerBeforePending
+                  ? TopicConstant.ORDER_MODE_MULTI_WRITER_VALUE
+                  : TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+              regionId,
+              serverImpl,
+              new SubscriptionWalRetentionPolicy(
+                  "topic",
+                  SubscriptionWalRetentionPolicy.UNBOUNDED,
+                  SubscriptionWalRetentionPolicy.UNBOUNDED),
+              converter,
+              commitManager,
+              new RegionProgress(Collections.emptyMap()),
+              1L,
+              1L,
+              true);
+      if (followerBeforePending) {
+        queue.setActiveWriterNodeIds(Set.of(WRITER_NODE_ID, WRITER_NODE_ID + 
1));
+      }
+      final long oneTabletBytes = 
PipeMemoryWeightUtil.calculateTabletSizeInBytes(createTablet());
+      final TrackingSubscriptionMemoryManager memoryManager =
+          new TrackingSubscriptionMemoryManager(
+              memoryBackpressure ? oneTabletBytes * 4L : oneTabletBytes * 
requestCount * 4L);
+      queue.setSubscriptionMemoryManager(memoryManager);
+      assertNull(queue.poll("consumer"));
+
+      // A rejected request creates a WAL-only hole. Once consensus memory is 
released, subsequent
+      // requests are admitted again and must be delivered from pending after 
that hole is filled.
+      requestMemoryManager.init(requestMemoryBlock, 1.0);
+      requestMemoryBlock.forceAllocateWithoutLimitation(1_000_000L);
+      assertFalse(pendingEntries(queue).offer(createRequest(1L)));
+      assertEquals(
+          
SubscriptionQueueRejectionReason.CONSENSUS_REQUEST_MEMORY_LIMIT.getCode(),
+          queue.getLastAdmissionRejectionCode());
+      requestMemoryBlock.release(1_000_000L);
+      for (long index = 2L; index <= requestCount; index++) {
+        assertTrue(pendingEntries(queue).offer(createRequest(index)));
+      }
+
+      long deliveredLocalSeq = missingWalGap ? 1L : 0L;
+      for (int round = 0; round < 32 && deliveredLocalSeq < requestCount; 
round++) {
+        queue.drivePrefetchOnce();
+        assertMemoryBounded(queue, memoryManager);
+        SubscriptionEvent event;
+        while ((event = queue.poll("consumer")) != null) {
+          assertEquals(WRITER_NODE_ID, 
event.getCommitContext().getWriterId().getNodeId());
+          deliveredLocalSeq++;
+          assertEquals(
+              deliveredLocalSeq, 
event.getCommitContext().getWriterProgress().getLocalSeq());
+          assertTrue(queue.ack("consumer", event.getCommitContext()));
+        }
+        assertEquals(
+            followerBeforePending ? oneTabletBytes : 0L, 
memoryManager.getUsedMemorySizeInBytes());
+      }
+      assertEquals(requestCount, deliveredLocalSeq);
+      if (followerBeforePending) {
+        // The replicated writer's later timestamp lets local requests drain 
in order. Removing
+        // the local writer then releases that final follower entry without 
reading it again.
+        queue.setActiveWriterNodeIds(Collections.singleton(WRITER_NODE_ID + 
1));
+        queue.drivePrefetchOnce();
+        final SubscriptionEvent follower = queue.poll("consumer");
+        assertNotNull(follower);
+        assertEquals(WRITER_NODE_ID + 1, 
follower.getCommitContext().getWriterId().getNodeId());
+        assertEquals(
+            new WriterProgress(2000L, 1L), 
follower.getCommitContext().getWriterProgress());
+        assertTrue(queue.ack("consumer", follower.getCommitContext()));
+        assertNull(queue.poll("consumer"));
+        assertEquals(0L, memoryManager.getUsedMemorySizeInBytes());
+      }
+      assertEquals(
+          missingWalGap ? 0L : (followerBeforePending ? 2L : 1L),
+          queue.getWalPathAcceptedEntries());
+      assertEquals(requestCount - 1L, queue.getPendingPathAcceptedEntries());
+      assertEquals(1L, queue.getRealtimeAdmissionRejectionCount());
+      assertEquals(missingWalGap ? 1L : 0L, queue.getWalGapSkippedEntries());
+      assertEquals(0L, queue.getRetainedRequestBytes());
+      assertEquals(0L, requestMemoryBlock.getUsedMemoryInBytes());
+      assertTrue(pendingEntries(queue).isEmpty());
+      assertEquals(
+          new WriterProgress(1000L + requestCount, requestCount),
+          commitManager
+              .getCommittedRegionProgress("consumerGroup", "topic", regionId)
+              .getWriterPositions()
+              .get(new WriterId(regionId.toString(), WRITER_NODE_ID)));
+      if (!memoryBackpressure) {
+        assertEquals(
+            missingWalGap
+                ? Arrays.asList(2L, 3L, 4L, 5L, 6L, 7L, 8L)
+                : (followerBeforePending
+                    ? Arrays.asList(1L, -1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L)
+                    : Arrays.asList(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L)),
+            conversions);
+      }
+    } finally {
+      if (queue != null) {
+        queue.close();
+      }
+      requestMemoryManager.init(originalRequestMemoryBlock, 
originalQueueRatio);
+      
config.setSubscriptionConsensusBatchMaxWalEntries(originalBatchMaxWalEntries);
+      
config.setSubscriptionConsensusBatchMaxTabletCount(originalBatchMaxTabletCount);
+      config.setSubscriptionConsensusBatchMaxDelayInMs(originalBatchMaxDelay);
+      
config.setSubscriptionConsensusWatermarkIntervalMs(originalWatermarkInterval);
+      
IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+    }
+  }
+
   @Test
   public void testAckResumesPendingSuffixWithoutWalReplay() throws Exception {
     final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
@@ -690,6 +871,21 @@ public class ConsensusPrefetchingQueueWalBackpressureTest {
 
   private static void writeSealedWal(final File walDirectory, final int 
requestCount)
       throws Exception {
+    writeSealedWal(walDirectory, requestCount, 1L);
+  }
+
+  private static void writeSealedWal(
+      final File walDirectory, final int requestCount, final long 
firstSearchIndex)
+      throws Exception {
+    writeSealedWal(walDirectory, requestCount, firstSearchIndex, false);
+  }
+
+  private static void writeSealedWal(
+      final File walDirectory,
+      final int requestCount,
+      final long firstSearchIndex,
+      final boolean followerBeforePending)
+      throws Exception {
     final File historicalWal =
         new File(
             walDirectory, WALFileUtils.getLogFileName(0L, 0L, 
WALFileStatus.CONTAINS_SEARCH_INDEX));
@@ -699,8 +895,11 @@ public class ConsensusPrefetchingQueueWalBackpressureTest {
             WALFileUtils.getLogFileName(1L, requestCount, 
WALFileStatus.CONTAINS_SEARCH_INDEX));
 
     try (WALWriter writer = new WALWriter(historicalWal, WALFileVersion.V3)) {
-      for (long searchIndex = 1L; searchIndex <= requestCount; searchIndex++) {
+      for (long searchIndex = firstSearchIndex; searchIndex <= requestCount; 
searchIndex++) {
         writeWalEntry(writer, searchIndex);
+        if (searchIndex == 1L && followerBeforePending) {
+          writeWalEntry(writer, -1L, 2000L, WRITER_NODE_ID + 1, 1L);
+        }
       }
     }
     try (WALWriter ignored = new WALWriter(liveWal, WALFileVersion.V3)) {
@@ -710,6 +909,16 @@ public class ConsensusPrefetchingQueueWalBackpressureTest {
 
   private static void writeWalEntry(final WALWriter writer, final long 
searchIndex)
       throws Exception {
+    writeWalEntry(writer, searchIndex, 1000L + searchIndex, WRITER_NODE_ID, 
searchIndex);
+  }
+
+  private static void writeWalEntry(
+      final WALWriter writer,
+      final long searchIndex,
+      final long physicalTime,
+      final int writerNodeId,
+      final long localSeq)
+      throws Exception {
     final RelationalInsertRowNode insertNode =
         StatementTestUtils.genInsertRowNode(Math.toIntExact(searchIndex));
     insertNode.setSearchIndex(searchIndex);
@@ -719,13 +928,7 @@ public class ConsensusPrefetchingQueueWalBackpressureTest {
     walEntry.serialize(new WALByteBufferForTest(buffer));
 
     final WALMetaData metaData = new WALMetaData();
-    metaData.add(
-        walEntry.serializedSize(),
-        searchIndex,
-        1L,
-        1000L + searchIndex,
-        WRITER_NODE_ID,
-        searchIndex);
+    metaData.add(walEntry.serializedSize(), searchIndex, 1L, physicalTime, 
writerNodeId, localSeq);
     writer.write(buffer, metaData);
   }
 

Reply via email to