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 1b9e0713fb1 Fix subscription WAL replay stalls (#18969)
1b9e0713fb1 is described below

commit 1b9e0713fb162a3da4018a1032cd1f3d77892793
Author: Caideyipi <[email protected]>
AuthorDate: Wed Oct 21 19:54:45 2026 +0800

    Fix subscription WAL replay stalls (#18969)
    
    Skip non-search WAL separators while fast-forwarding committed progress, 
and avoid rescanning covered WAL prefixes on later commits. Move ACK retention 
metadata scans outside the in-flight event map lock so event recycling can 
proceed during slow scans.
    
    Add regression coverage for separator handling, retention-bound reuse, and 
ACK recycling during a blocked retention scan.
---
 .../consensus/ConsensusPrefetchingQueue.java       |  51 +++++-
 .../broker/consensus/ProgressWALIterator.java      |   5 +
 .../consensus/ConsensusPrefetchingQueueTest.java   | 178 ++++++++++++++++++++-
 .../broker/consensus/ProgressWALIteratorTest.java  |  48 ++++++
 4 files changed, 271 insertions(+), 11 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 1268689749f..24366687030 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
@@ -3987,8 +3987,7 @@ public class ConsensusPrefetchingQueue {
             return null;
           }
 
-          final boolean committed =
-              commitAndRefreshWalRetention(commitWriterId, 
commitWriterProgress);
+          final boolean committed = commitWriterProgress(commitWriterId, 
commitWriterProgress);
           if (!committed) {
             if (!silent) {
               LOGGER.warn(
@@ -4006,6 +4005,12 @@ public class ConsensusPrefetchingQueue {
           cleanUpEvent(ev, false);
           return null;
         });
+    if (acked.get()) {
+      // Retention refresh can read metadata from every newly relevant WAL 
file. Keep that I/O out
+      // of ConcurrentHashMap.compute so recycling and requeueing other 
in-flight events can
+      // proceed while an ACK advances committed progress.
+      refreshCommittedWalRetentionBoundAndNotify();
+    }
     return !found.get()
         ? InFlightAckResult.MISSING
         : acked.get() ? InFlightAckResult.ACKED : InFlightAckResult.REJECTED;
@@ -4526,12 +4531,20 @@ public class ConsensusPrefetchingQueue {
 
   private boolean commitAndRefreshWalRetention(
       final WriterId writerId, final WriterProgress writerProgress) {
+    final boolean committed = commitWriterProgress(writerId, writerProgress);
+    if (committed) {
+      refreshCommittedWalRetentionBoundAndNotify();
+    }
+    return committed;
+  }
+
+  private boolean commitWriterProgress(
+      final WriterId writerId, final WriterProgress writerProgress) {
     final boolean committed =
         commitManager.commit(
             consumerGroupId, topicName, consensusGroupId, writerId, 
writerProgress);
     if (committed) {
       lastProgressTimeMs = System.currentTimeMillis();
-      refreshCommittedWalRetentionBoundAndNotify();
     }
     return committed;
   }
@@ -4584,7 +4597,7 @@ public class ConsensusPrefetchingQueue {
     return true;
   }
 
-  private boolean canReuseCommittedWalRetentionBound(final RegionProgress 
committedRegionProgress) {
+  private boolean canSkipCoveredWalPrefix(final RegionProgress 
committedRegionProgress) {
     if (!isProgressAtLeast(committedRegionProgress, 
lastCommittedProgressForRetention)
         || Objects.isNull(lastRetainedWalFileForRetention)
         || !lastRetainedWalFileForRetention.exists()
@@ -4593,6 +4606,22 @@ public class ConsensusPrefetchingQueue {
       return false;
     }
 
+    return true;
+  }
+
+  private boolean canReuseCommittedWalRetentionBound(final RegionProgress 
committedRegionProgress) {
+    if (!canSkipCoveredWalPrefix(committedRegionProgress)) {
+      return false;
+    }
+
+    // The live WAL file must be retained regardless of committed progress. 
Its metadata is not
+    // sealed yet, so there is no cached requirement to check until the WAL 
rolls.
+    if (consensusReqReader instanceof WALNode
+        && committedRetainedMinVersionId
+            == ((WALNode) consensusReqReader).getCurrentWALFileVersion()) {
+      return true;
+    }
+
     final WalFileCommitRequirement requirement =
         walFileCommitRequirements.get(committedRetainedMinVersionId);
     return Objects.nonNull(requirement) && 
!requirement.isCoveredBy(committedRegionProgress);
@@ -4640,8 +4669,8 @@ public class ConsensusPrefetchingQueue {
         return false;
       }
 
-      // The previous boundary file remains the first uncommitted file while 
its cached
-      // requirement is still uncovered by monotonically advancing committed 
progress.
+      // The previous boundary still applies while its sealed requirement is 
uncovered, or while
+      // it is the current live WAL file. Both cases require monotonically 
advancing progress.
       if (canReuseCommittedWalRetentionBound(committedRegionProgress)) {
         lastCommittedProgressForRetention = committedRegionProgress;
         return false;
@@ -4673,8 +4702,18 @@ public class ConsensusPrefetchingQueue {
     }
 
     WALFileUtils.ascSortByVersionId(walFiles);
+    // Sealed WAL files before the previous boundary were already covered by 
committed progress.
+    // They may remain on disk because of the replay cursor or topic 
retention; rescanning their
+    // metadata on every later commit makes sparse topics spend most of their 
time in retention.
+    final long firstVersionToCheck =
+        canSkipCoveredWalPrefix(committedRegionProgress)
+            ? committedRetainedMinVersionId
+            : Long.MIN_VALUE;
     for (final File walFile : walFiles) {
       final long versionId = WALFileUtils.parseVersionId(walFile.getName());
+      if (versionId < firstVersionToCheck) {
+        continue;
+      }
       if (versionId >= currentWalVersion) {
         return new CommittedWalRetentionBound(Math.max(0L, currentWalVersion), 
walFile);
       }
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIterator.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIterator.java
index 07438858a35..c048677a0af 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIterator.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIterator.java
@@ -428,6 +428,11 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
       final long physicalTime = physicalTimes.get(entryIndex);
       final int nodeId = nodeIds.get(entryIndex);
       final long localSeq = localSeqs.get(entryIndex);
+      if (nodeId < 0 && physicalTime == 0L && localSeq < 0L) {
+        // Non-search WAL entries (such as separators) do not participate in 
subscription
+        // progress and must not prevent a covered historical file from being 
skipped.
+        continue;
+      }
       if (nodeId < 0
           || physicalTime < 0
           || localSeq < 0
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 a985a0f617d..a8c880495c9 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
@@ -851,9 +851,8 @@ public class ConsensusPrefetchingQueueTest {
               WALFileUtils.getLogFileName(2L, 3L, 
WALFileStatus.CONTAINS_SEARCH_INDEX));
       writeWalMetadata(firstWal, 1L, 2L, 1002L, 7);
       writeWalMetadata(nextWal, 2L, 4L, 1004L, 7);
-      try (WALWriter ignored = new WALWriter(liveWal, WALFileVersion.V3)) {
-        // Keep a live successor so both data WAL files are eligible for 
boundary checks.
-      }
+      // The v2 metadata becomes eligible for retention checks only after the 
mocked WAL rolls.
+      writeWalMetadata(liveWal, 3L, 7L, 1007L, 7);
       assertEquals(3, WALFileUtils.listAllWALFiles(walDirectory).length);
       assertFalse(ProgressWALIterator.isHeaderOnlyWalFile(firstWal));
 
@@ -866,7 +865,8 @@ public class ConsensusPrefetchingQueueTest {
       final WALNode walNode = mock(WALNode.class);
       when(walNode.getLogDirectory()).thenReturn(walDirectory);
       when(walNode.getSortedWalFilesSnapshot()).thenReturn(new File[] 
{firstWal, nextWal, liveWal});
-      when(walNode.getCurrentWALFileVersion()).thenReturn(2L);
+      final AtomicLong currentWalVersion = new AtomicLong(2L);
+      when(walNode.getCurrentWALFileVersion()).thenAnswer(ignored -> 
currentWalVersion.get());
       assertEquals(2L, walNode.getCurrentWALFileVersion());
 
       final IoTConsensusServerImpl serverImpl = 
mock(IoTConsensusServerImpl.class);
@@ -936,9 +936,43 @@ public class ConsensusPrefetchingQueueTest {
       assertEquals(0L, retentionSupplier.get().getAsLong());
 
       committedProgress.set(
-          new RegionProgress(Collections.singletonMap(writerId, new 
WriterProgress(1001L, 1L))));
+          new RegionProgress(Collections.singletonMap(writerId, new 
WriterProgress(1004L, 4L))));
+      assertTrue((Boolean) refreshRetentionBound.invoke(queue));
+      // Advancing past v1 must not reopen v0, which is still retained for 
replay.
+      assertEquals(2, progressWalReaderOpenCount.get());
+      assertEquals(2L, committedBound.getLong(queue));
+
+      committedProgress.set(
+          new RegionProgress(Collections.singletonMap(writerId, new 
WriterProgress(1005L, 5L))));
+      assertFalse((Boolean) refreshRetentionBound.invoke(queue));
+      // The current WAL file stays retained without repeatedly scanning the 
sealed prefix.
+      assertEquals(2, progressWalReaderOpenCount.get());
+      assertEquals(0L, retentionSupplier.get().getAsLong());
+
+      final File nextLiveWal =
+          new File(
+              walDirectory,
+              WALFileUtils.getLogFileName(3L, 4L, 
WALFileStatus.CONTAINS_SEARCH_INDEX));
+      try (WALWriter ignored = new WALWriter(nextLiveWal, WALFileVersion.V3)) {
+        // Rolling v2 makes its previously live metadata a sealed retention 
requirement.
+      }
+      currentWalVersion.set(3L);
+      committedProgress.set(
+          new RegionProgress(Collections.singletonMap(writerId, new 
WriterProgress(1006L, 6L))));
+      assertFalse((Boolean) refreshRetentionBound.invoke(queue));
+      assertEquals(3, progressWalReaderOpenCount.get());
+      assertEquals(2L, committedBound.getLong(queue));
+
+      committedProgress.set(
+          new RegionProgress(Collections.singletonMap(writerId, new 
WriterProgress(1007L, 7L))));
       assertTrue((Boolean) refreshRetentionBound.invoke(queue));
       assertEquals(3, progressWalReaderOpenCount.get());
+      assertEquals(3L, committedBound.getLong(queue));
+
+      committedProgress.set(
+          new RegionProgress(Collections.singletonMap(writerId, new 
WriterProgress(1001L, 1L))));
+      assertTrue((Boolean) refreshRetentionBound.invoke(queue));
+      assertEquals(4, progressWalReaderOpenCount.get());
       assertEquals(0L, committedBound.getLong(queue));
       assertEquals(0L, retentionSupplier.get().getAsLong());
     } finally {
@@ -2517,6 +2551,140 @@ public class ConsensusPrefetchingQueueTest {
     }
   }
 
+  @Test
+  public void testAckRetentionRefreshDoesNotBlockEventRecycling() throws 
Exception {
+    for (final boolean silent : new boolean[] {false, true}) {
+      final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
+      final File systemDir = temporaryFolder.newFolder("ack-retention-system-" 
+ silent);
+      final File walDirectory = temporaryFolder.newFolder("ack-retention-wal-" 
+ silent);
+      final int originalRecycleInterval =
+          CommonDescriptor.getInstance()
+              .getConfig()
+              .getSubscriptionRecycleUncommittedEventIntervalMs();
+      final CountDownLatch retentionReadStarted = new CountDownLatch(1);
+      final CountDownLatch releaseRetentionRead = new CountDownLatch(1);
+      final AtomicBoolean blockRetentionRead = new AtomicBoolean(false);
+      final ExecutorService executor = Executors.newFixedThreadPool(2);
+      ConsensusPrefetchingQueue queue = null;
+      try {
+        
IoTDBDescriptor.getInstance().getConfig().setSystemDir(systemDir.getAbsolutePath());
+        final File firstWal =
+            new File(
+                walDirectory,
+                WALFileUtils.getLogFileName(0L, 0L, 
WALFileStatus.CONTAINS_SEARCH_INDEX));
+        final File nextWal =
+            new File(
+                walDirectory,
+                WALFileUtils.getLogFileName(1L, 1L, 
WALFileStatus.CONTAINS_SEARCH_INDEX));
+        final File liveWal =
+            new File(
+                walDirectory,
+                WALFileUtils.getLogFileName(2L, 2L, 
WALFileStatus.CONTAINS_SEARCH_INDEX));
+        writeWalMetadata(firstWal, 1L, 1L, 1001L, 7);
+        writeWalMetadata(nextWal, 2L, 2L, 1002L, 7);
+        try (WALWriter ignored = new WALWriter(liveWal, WALFileVersion.V3)) {
+          // Keep both data files sealed for retention checks.
+        }
+        final WALNode walNode = mock(WALNode.class);
+        when(walNode.getLogDirectory()).thenReturn(walDirectory);
+        when(walNode.getSortedWalFilesSnapshot())
+            .thenReturn(new File[] {firstWal, nextWal, liveWal});
+        when(walNode.getCurrentWALFileVersion()).thenReturn(2L);
+        when(walNode.getCurrentSearchIndex()).thenReturn(2L);
+        final IoTConsensusServerImpl serverImpl = 
mock(IoTConsensusServerImpl.class);
+        when(serverImpl.getConsensusReqReader()).thenReturn(walNode);
+        when(serverImpl.getWriterSafeFrontierTracker()).thenReturn(new 
WriterSafeFrontierTracker());
+        final ConsensusLogToTabletConverter converter = 
mock(ConsensusLogToTabletConverter.class);
+        when(converter.convert(any()))
+            .thenReturn(Collections.singletonList(createTablet()), 
Collections.emptyList());
+        when(converter.getDatabaseName()).thenReturn("db");
+        queue =
+            new ConsensusPrefetchingQueue(
+                "consumerGroup",
+                "topic",
+                TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE,
+                new DataRegionId(1),
+                serverImpl,
+                new SubscriptionWalRetentionPolicy(
+                    "topic",
+                    SubscriptionWalRetentionPolicy.UNBOUNDED,
+                    SubscriptionWalRetentionPolicy.UNBOUNDED),
+                converter,
+                newCommitManager(systemDir),
+                new RegionProgress(Collections.emptyMap()),
+                1L,
+                1L,
+                true) {
+              @Override
+              ProgressWALReader openProgressWALReader(final File walFile) 
throws IOException {
+                if (blockRetentionRead.get()) {
+                  retentionReadStarted.countDown();
+                  try {
+                    
assertTrue(releaseRetentionRead.await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+                  } catch (final InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new IOException(e);
+                  }
+                }
+                return super.openProgressWALReader(walFile);
+              }
+            };
+        queue.setSubscriptionMemoryManager(new SubscriptionMemoryManager(1024L 
* 1024L));
+        assertTrue(pendingEntries(queue).offer(createRequest(1L)));
+        assertTrue(pendingEntries(queue).offer(createRequest(2L)));
+        queue.drivePrefetchOnce();
+        final Method commitAndRetention =
+            ConsensusPrefetchingQueue.class.getDeclaredMethod(
+                "commitAndRefreshWalRetention", WriterId.class, 
WriterProgress.class);
+        commitAndRetention.setAccessible(true);
+        commitAndRetention.invoke(
+            queue, new WriterId("DataRegion[1]", 7), new WriterProgress(1000L, 
0L));
+        final SubscriptionEvent event = queue.poll("consumer");
+        assertNotNull(event);
+        assertTrue(queue.getRetainedTabletBytes() > 0L);
+        final Field timestampField =
+            SubscriptionEvent.class.getDeclaredField("lastPolledTimestamp");
+        timestampField.setAccessible(true);
+        ((AtomicLong) 
timestampField.get(event)).set(System.currentTimeMillis() - 1000L);
+        CommonDescriptor.getInstance()
+            .getConfig()
+            .setSubscriptionRecycleUncommittedEventIntervalMs(1);
+
+        final ConsensusPrefetchingQueue activeQueue = queue;
+        blockRetentionRead.set(true);
+        final Future<Boolean> ack =
+            executor.submit(
+                () ->
+                    silent
+                        ? activeQueue.ackSilent("consumer", 
event.getCommitContext())
+                        : activeQueue.ack("consumer", 
event.getCommitContext()));
+        assertTrue(retentionReadStarted.await(TEST_TIMEOUT_SECONDS, 
TimeUnit.SECONDS));
+        final Method recycle =
+            
ConsensusPrefetchingQueue.class.getDeclaredMethod("recycleInFlightEvents");
+        recycle.setAccessible(true);
+        // A slow retention read must not keep the event-map bucket locked by 
ACK and stall
+        // a shared prefetch worker trying to recycle that event.
+        executor.submit(() -> recycle.invoke(activeQueue)).get(5, 
TimeUnit.SECONDS);
+        assertEquals(0L, activeQueue.getSubscriptionUncommittedEventCount());
+        assertEquals(0L, activeQueue.getRetainedTabletBytes());
+        assertFalse(ack.isDone());
+        releaseRetentionRead.countDown();
+        assertTrue(ack.get(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+      } finally {
+        releaseRetentionRead.countDown();
+        executor.shutdownNow();
+        assertTrue(executor.awaitTermination(TEST_TIMEOUT_SECONDS, 
TimeUnit.SECONDS));
+        if (queue != null) {
+          queue.close();
+        }
+        CommonDescriptor.getInstance()
+            .getConfig()
+            
.setSubscriptionRecycleUncommittedEventIntervalMs(originalRecycleInterval);
+        
IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir);
+      }
+    }
+  }
+
   @Test
   public void testLateAckDoesNotWaitForPrefetchReadLock() throws Exception {
     final String originalSystemDir = 
IoTDBDescriptor.getInstance().getConfig().getSystemDir();
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIteratorTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIteratorTest.java
index 5202b0ef1b7..3c2080fe673 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIteratorTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ProgressWALIteratorTest.java
@@ -1193,6 +1193,11 @@ public class ProgressWALIteratorTest {
     try {
       try (WALWriter writer = new WALWriter(localWal, WALFileVersion.V3)) {
         writer.write(searchableEntry(1L), singleEntryMeta(19, 1L, 1L, 100L, 7, 
1L));
+        final ByteBuffer separator = 
ByteBuffer.allocate(WALInfoEntry.FIXED_SERIALIZED_SIZE);
+        
separator.put(WALEntryType.CONTINUOUS_SAME_SEARCH_INDEX_SEPARATOR_NODE.getCode());
+        separator.putLong(-1L);
+        writer.write(
+            separator, singleEntryMeta(WALInfoEntry.FIXED_SERIALIZED_SIZE, 
-1L, -1L, 0L, -1, -1L));
         writer.write(searchableEntry(2L), singleEntryMeta(19, 2L, 1L, 200L, 7, 
2L));
       }
       try (WALWriter writer = new WALWriter(followerWal, WALFileVersion.V3)) {
@@ -1253,6 +1258,49 @@ public class ProgressWALIteratorTest {
     }
   }
 
+  @Test
+  public void testAdvanceToKeepsIncompleteWriterMetadata() throws Exception {
+    final Path dir = 
Files.createTempDirectory("progress-wal-iterator-incomplete-progress");
+    final File historyWal =
+        dir.resolve(WALFileUtils.getLogFileName(0, 0, 
WALFileStatus.CONTAINS_SEARCH_INDEX))
+            .toFile();
+    final File targetWal =
+        dir.resolve(WALFileUtils.getLogFileName(1, 2, 
WALFileStatus.CONTAINS_SEARCH_INDEX))
+            .toFile();
+    try {
+      try (WALWriter writer = new WALWriter(targetWal, WALFileVersion.V3)) {
+        writer.write(searchableEntry(6L), singleEntryMeta(19, 6L, 1L, 600L, 7, 
6L));
+        writer.write(searchableEntry(7L), singleEntryMeta(19, 7L, 1L, 700L, 7, 
7L));
+      }
+      // Only the complete non-search sentinel may be ignored. A missing 
writer, time or
+      // sequence on a searchable entry must still force sequential replay.
+      final long[][] incompleteProgress = {
+        {100L, -1L, -1L}, {0L, -1L, 1L}, {0L, 7L, -1L}, {-1L, 7L, 1L}
+      };
+      for (final long[] progress : incompleteProgress) {
+        try (WALWriter writer = new WALWriter(historyWal, WALFileVersion.V3)) {
+          writer.write(
+              searchableEntry(-1L),
+              singleEntryMeta(19, -1L, 1L, progress[0], (int) progress[1], 
progress[2]));
+          writer.write(searchableEntry(2L), singleEntryMeta(19, 2L, 1L, 200L, 
7, 2L));
+        }
+        try (ProgressWALIterator iterator = new 
ProgressWALIterator(dir.toFile(), 6L)) {
+          iterator.advanceTo(6L, (physicalTime, nodeId, localSeq) -> true);
+          final ProgressWALIterator.ScanBudget budget =
+              new ProgressWALIterator.ScanBudget(1, Long.MAX_VALUE);
+          assertFalse(iterator.hasNext(budget));
+          assertTrue(budget.hasYielded());
+          assertEquals(-1L, iterator.next().getSearchIndex());
+        }
+        Files.delete(historyWal.toPath());
+      }
+    } finally {
+      Files.deleteIfExists(historyWal.toPath());
+      Files.deleteIfExists(targetWal.toPath());
+      Files.deleteIfExists(dir);
+    }
+  }
+
   @Test
   public void testRefreshAfterExhaustionDiscoversNextWalFile() throws 
Exception {
     final Path dir = 
Files.createTempDirectory("progress-wal-iterator-refresh-after-exhaustion");

Reply via email to