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 054f5dedae5 [Subscription] Report retained WAL file gaps and retry 
transient reads (#18818)
054f5dedae5 is described below

commit 054f5dedae5e0d12eaacd2c35e1d70dc31fb7d5e
Author: Caideyipi <[email protected]>
AuthorDate: Sat Oct 10 17:15:04 2026 +0800

    [Subscription] Report retained WAL file gaps and retry transient reads 
(#18818)
    
    * [Subscription] Report retained WAL file gaps and retry transient reads
    
    * [Subscription] Declare runtime Logback dependencies for WAL log assertions
---
 .../base/AbstractSubscriptionConsumer.java         |  18 +-
 .../base/SubscriptionConsumerLifecycleTest.java    |  50 ++++
 iotdb-core/datanode/pom.xml                        |  16 ++
 .../apache/iotdb/db/i18n/DataNodePipeMessages.java |  16 ++
 .../apache/iotdb/db/i18n/DataNodePipeMessages.java |  16 ++
 .../consensus/ConsensusPrefetchingQueue.java       |  56 ++++-
 .../broker/consensus/ProgressWALIterator.java      | 260 ++++++++++++++++-----
 ...nsensusSubscriptionPrefetchingQueueMetrics.java |  20 ++
 .../consensus/ConsensusPrefetchingQueueTest.java   |  32 +++
 .../broker/consensus/ProgressWALIteratorTest.java  | 207 +++++++++++++++-
 ...susSubscriptionPrefetchingQueueMetricsTest.java |  65 ++++++
 .../iotdb/commons/service/metric/enums/Metric.java |   2 +
 12 files changed, 690 insertions(+), 68 deletions(-)

diff --git 
a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumer.java
 
b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumer.java
index 4eecc4d028c..113971ac122 100644
--- 
a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumer.java
+++ 
b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumer.java
@@ -130,6 +130,8 @@ abstract class AbstractSubscriptionConsumer implements 
AutoCloseable {
   private final AtomicBoolean isClosed = new AtomicBoolean(true);
   private final AtomicReference<SubscriptionConsumerFencedException> 
fencedException =
       new AtomicReference<>();
+  private final AtomicReference<SubscriptionRuntimeCriticalException> 
pendingCriticalPollError =
+      new AtomicReference<>();
   // This variable indicates whether the consumer has ever been closed.
   private final AtomicBoolean isReleased = new AtomicBoolean(false);
 
@@ -779,6 +781,11 @@ abstract class AbstractSubscriptionConsumer implements 
AutoCloseable {
   protected List<SubscriptionMessage> multiplePoll(
       /* @NotNull */ final Set<String> topicNames, final long timeoutMs) {
     checkIfFenced();
+    final SubscriptionRuntimeCriticalException pendingError =
+        pendingCriticalPollError.getAndSet(null);
+    if (pendingError != null) {
+      throw pendingError;
+    }
     if (topicNames.isEmpty()) {
       return Collections.emptyList();
     }
@@ -865,10 +872,13 @@ abstract class AbstractSubscriptionConsumer implements 
AutoCloseable {
     // case.
     checkIfFenced();
 
-    // even if a SubscriptionRuntimeCriticalException is encountered, try to 
deliver the message to
-    // the client
-    if (messages.isEmpty() && 
Objects.nonNull(lastSubscriptionRuntimeCriticalException)) {
-      throw lastSubscriptionRuntimeCriticalException;
+    if (Objects.nonNull(lastSubscriptionRuntimeCriticalException)) {
+      if (messages.isEmpty()) {
+        throw lastSubscriptionRuntimeCriticalException;
+      }
+      // Deliver healthy sibling results once, then surface the critical error 
on the next poll.
+      // Otherwise a busy topic can indefinitely mask another topic's 
permanent WAL replay gap.
+      pendingCriticalPollError.compareAndSet(null, 
lastSubscriptionRuntimeCriticalException);
     }
 
     return messages;
diff --git 
a/iotdb-client/subscription/src/test/java/org/apache/iotdb/session/subscription/consumer/base/SubscriptionConsumerLifecycleTest.java
 
b/iotdb-client/subscription/src/test/java/org/apache/iotdb/session/subscription/consumer/base/SubscriptionConsumerLifecycleTest.java
index 75f1ed78650..51f35c5d820 100644
--- 
a/iotdb-client/subscription/src/test/java/org/apache/iotdb/session/subscription/consumer/base/SubscriptionConsumerLifecycleTest.java
+++ 
b/iotdb-client/subscription/src/test/java/org/apache/iotdb/session/subscription/consumer/base/SubscriptionConsumerLifecycleTest.java
@@ -22,6 +22,7 @@ package org.apache.iotdb.session.subscription.consumer.base;
 import org.apache.iotdb.common.rpc.thrift.TEndPoint;
 import 
org.apache.iotdb.rpc.subscription.exception.SubscriptionConsumerFencedException;
 import org.apache.iotdb.rpc.subscription.exception.SubscriptionException;
+import 
org.apache.iotdb.rpc.subscription.exception.SubscriptionRuntimeCriticalException;
 import 
org.apache.iotdb.rpc.subscription.exception.SubscriptionRuntimeNonCriticalException;
 import 
org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionCommitContext;
 import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollResponse;
@@ -230,6 +231,55 @@ public class SubscriptionConsumerLifecycleTest {
     Assert.assertTrue(consumer.isFenced());
   }
 
+  @Test
+  public void 
testCriticalParallelPollErrorIsDeliveredAfterHealthySiblingMessages()
+      throws Exception {
+    for (final boolean failedTaskFirst : new boolean[] {false, true}) {
+      final TestPullConsumer consumer = new TestPullConsumer();
+      final SubscriptionRuntimeCriticalException gap =
+          new SubscriptionRuntimeCriticalException("WAL replay gap");
+      final SubscriptionMessage message =
+          new SubscriptionMessage(
+              new SubscriptionCommitContext(0, 0, "healthy_topic", 
CONSUMER_GROUP_ID, 1L), 1L);
+      final CompletableFuture<List<SubscriptionMessage>> failed = new 
CompletableFuture<>();
+      failed.completeExceptionally(gap);
+      final CompletableFuture<List<SubscriptionMessage>> healthy =
+          
CompletableFuture.completedFuture(Collections.singletonList(message));
+      Assert.assertEquals(
+          Collections.singletonList(message),
+          consumer.collectMultiplePollResults(
+              failedTaskFirst ? Arrays.asList(failed, healthy) : 
Arrays.asList(healthy, failed),
+              new HashSet<>(Arrays.asList("healthy_topic", "gap_topic"))));
+      try {
+        consumer.multiplePoll(Collections.singleton("healthy_topic"), 100L);
+        Assert.fail("A healthy topic must not hide the pending critical WAL 
replay error");
+      } catch (final SubscriptionRuntimeCriticalException expected) {
+        Assert.assertSame(gap, expected);
+      }
+      Assert.assertTrue(consumer.multiplePoll(Collections.emptySet(), 
100L).isEmpty());
+      Assert.assertFalse(consumer.isFenced());
+    }
+  }
+
+  @Test
+  public void 
testCriticalParallelPollErrorWithoutMessagesIsDeliveredImmediately()
+      throws Exception {
+    final TestPullConsumer consumer = new TestPullConsumer();
+    final SubscriptionRuntimeCriticalException gap =
+        new SubscriptionRuntimeCriticalException("WAL replay gap");
+    final CompletableFuture<List<SubscriptionMessage>> failed = new 
CompletableFuture<>();
+    failed.completeExceptionally(gap);
+    try {
+      consumer.collectMultiplePollResults(
+          
Arrays.asList(CompletableFuture.completedFuture(Collections.emptyList()), 
failed),
+          Collections.singleton("gap_topic"));
+      Assert.fail("A critical WAL replay error must fail an otherwise empty 
poll");
+    } catch (final SubscriptionRuntimeCriticalException expected) {
+      Assert.assertSame(gap, expected);
+    }
+    Assert.assertTrue(consumer.multiplePoll(Collections.emptySet(), 
100L).isEmpty());
+  }
+
   @Test
   public void testFencedAsyncCommitFailsBeforeReadingMessages() throws 
Exception {
     final TestPullConsumer consumer = new TestPullConsumer();
diff --git a/iotdb-core/datanode/pom.xml b/iotdb-core/datanode/pom.xml
index 5cd76ae2ac2..48bc0490611 100644
--- a/iotdb-core/datanode/pom.xml
+++ b/iotdb-core/datanode/pom.xml
@@ -177,6 +177,16 @@
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-api</artifactId>
         </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-core</artifactId>
+            <scope>runtime</scope>
+        </dependency>
         <dependency>
             <groupId>net.java.dev.jna</groupId>
             <artifactId>jna</artifactId>
@@ -462,6 +472,12 @@
                         
<ignoredDependency>org.apache.iotdb:isession</ignoredDependency>
                         
<ignoredDependency>at.yawk.lz4:lz4-java</ignoredDependency>
                     </ignoredDependencies>
+                    <ignoredNonTestScopedDependencies>
+                        <!-- Logback supplies the server's runtime SLF4J 
backend and LogbackMetrics.
+                             Direct references in this module also support 
test log assertions. -->
+                        
<ignoredNonTestScopedDependency>ch.qos.logback:logback-classic</ignoredNonTestScopedDependency>
+                        
<ignoredNonTestScopedDependency>ch.qos.logback:logback-core</ignoredNonTestScopedDependency>
+                    </ignoredNonTestScopedDependencies>
                 </configuration>
             </plugin>
             <plugin>
diff --git 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
index a8896d1cf41..e6268fbe01e 100644
--- 
a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
@@ -2218,6 +2218,22 @@ public final class DataNodePipeMessages {
       "ProgressWALIterator: skipped {} unreadable retained WAL files in 
directory {}, "
           + "firstFile={}, lastFile={}, firstError={}; historical subscription 
data in these "
           + "files cannot be replayed";
+  public static final String
+      
LOG_PROGRESSWALITERATOR_SKIPPED_UNREADABLE_RETAINED_WAL_FILE_ARG_HISTORICAL_SUBSCRIPTION_DATA_MAY_BE_LOST_AE0DBAB1
 =
+          "ProgressWALIterator: skipped unreadable retained WAL file {}; 
historical subscription data may be lost";
+  public static final String
+      
MESSAGE_FILE_ARG_VERSIONID_ARG_FILESEARCHINDEXRANGE_ARG_ARG_ENTRYRANGE_ARG_ARG_SKIPPEDENTRIES_ARG_ERROR_ARG_0D6D77B0
 =
+          "file=%s, versionId=%d, fileSearchIndexRange=(%s, %s], 
entryRange=[%d, %s), skippedEntries=%s, error=%s";
+  public static final String
+      
MESSAGE_SUBSCRIPTION_WAL_REPLAY_SKIPPED_ARG_UNREADABLE_RETAINED_WAL_FILE_S_IN_QUEUE_ARG_REGION_ARG_HISTORICAL_DATA_MAY_BE_LOST_DETAILS_RECENT_FILES_FULL_DETAILS_IN_SERVER_LOG_ARG_FEDEFF7E
 =
+          "Subscription WAL replay skipped %d unreadable retained WAL file(s) 
in queue %s, region %s. Historical data may be lost. Details (recent files; 
full details in server log): %s";
+  public static final String MESSAGE_UNKNOWN_AD921D60 = "unknown";
+  public static final String
+      
MESSAGE_WAL_FILE_ARG_VERSIONID_ARG_ENTRYOFFSET_ARG_IS_TEMPORARILY_UNREADABLE_REPLAY_WILL_RETRY_WITHOUT_SKIPPING_ARG_EA11FBDD
 =
+          "WAL file %s (versionId=%d, entryOffset=%d) is temporarily 
unreadable; replay will retry without skipping: %s";
+  public static final String
+      
MESSAGE_FAILED_TO_REOPEN_WAL_FILE_ARG_AT_ENTRY_OFFSET_ARG_ITERATOR_COULD_NOT_SKIP_TO_THE_REQUESTED_POSITION_332B3AD9
 =
+          "failed to reopen WAL file %s at entry offset %s: iterator could not 
skip to the requested position";
   public static final String
       
LOG_ARG_WAL_GAP_ARG_ARG_SKIPPED_ARG_TOTAL_SKIPPED_ARG_CAUSE_UNDETERMINED_D4E6491A
 =
           "{}: WAL gap [{}, {}), skipped {}, total skipped {}; cause 
undetermined";
diff --git 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
index f9a4df76fb7..64cd9c7a2d6 100644
--- 
a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
+++ 
b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
@@ -2059,6 +2059,22 @@ public final class DataNodePipeMessages {
   public static final String 
PIPE_LOG_PROGRESSWALITERATOR_SKIPPED_UNREADABLE_RETAINED_WAL_FILES_FFC8455E =
       "ProgressWALIterator:跳过了 {} 个无法读取的保留 WAL 文件,directory={},firstFile={},"
           + "lastFile={},firstError={};这些文件中的历史订阅数据无法重放";
+  public static final String
+      
LOG_PROGRESSWALITERATOR_SKIPPED_UNREADABLE_RETAINED_WAL_FILE_ARG_HISTORICAL_SUBSCRIPTION_DATA_MAY_BE_LOST_AE0DBAB1
 =
+          "ProgressWALIterator:跳过了无法读取的保留 WAL 文件 {};历史订阅数据可能丢失";
+  public static final String
+      
MESSAGE_FILE_ARG_VERSIONID_ARG_FILESEARCHINDEXRANGE_ARG_ARG_ENTRYRANGE_ARG_ARG_SKIPPEDENTRIES_ARG_ERROR_ARG_0D6D77B0
 =
+          "file=%s,versionId=%d,fileSearchIndexRange=(%s, %s],entryRange=[%d, 
%s),skippedEntries=%s,error=%s";
+  public static final String
+      
MESSAGE_SUBSCRIPTION_WAL_REPLAY_SKIPPED_ARG_UNREADABLE_RETAINED_WAL_FILE_S_IN_QUEUE_ARG_REGION_ARG_HISTORICAL_DATA_MAY_BE_LOST_DETAILS_RECENT_FILES_FULL_DETAILS_IN_SERVER_LOG_ARG_FEDEFF7E
 =
+          "订阅 WAL 重放累计跳过了 %d 个无法读取的保留 WAL 文件,队列 %s,region 
%s。历史数据可能丢失。详情(近期文件;完整信息见服务端日志):%s";
+  public static final String MESSAGE_UNKNOWN_AD921D60 = "未知";
+  public static final String
+      
MESSAGE_WAL_FILE_ARG_VERSIONID_ARG_ENTRYOFFSET_ARG_IS_TEMPORARILY_UNREADABLE_REPLAY_WILL_RETRY_WITHOUT_SKIPPING_ARG_EA11FBDD
 =
+          "WAL 文件 %s(versionId=%d,entryOffset=%d)暂不可读;重放将重试而不跳过:%s";
+  public static final String
+      
MESSAGE_FAILED_TO_REOPEN_WAL_FILE_ARG_AT_ENTRY_OFFSET_ARG_ITERATOR_COULD_NOT_SKIP_TO_THE_REQUESTED_POSITION_332B3AD9
 =
+          "重新打开 WAL 文件 %s(条目偏移 %s)失败:迭代器无法跳到请求的位置";
   public static final String
       
LOG_ARG_WAL_GAP_ARG_ARG_SKIPPED_ARG_TOTAL_SKIPPED_ARG_CAUSE_UNDETERMINED_D4E6491A
 =
           "{}:WAL 缺口 [{}, {}),跳过 {} 条,累计跳过 {} 条;原因尚未确定";
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 af3bf6344a0..e24748edc0b 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
@@ -213,10 +213,16 @@ public class ConsensusPrefetchingQueue {
 
   private final AtomicLong walGapSkippedEntries = new AtomicLong(0);
 
+  private final AtomicLong unreadableWalFileCount = new AtomicLong(0);
+
+  private final AtomicLong unreadableWalEntryCount = new AtomicLong(0);
+
   private final AtomicReference<String> pendingWalReplayError = new 
AtomicReference<>();
 
   private int reportedSkippedBrokenWalFileCount = 0;
 
+  private long reportedSkippedBrokenWalEntryCount = 0L;
+
   /**
    * Guards queue state transitions that touch replay positioning, seek state, 
and writer buffers.
    */
@@ -853,7 +859,7 @@ public class ConsensusPrefetchingQueue {
       }
       final String walReplayError = pendingWalReplayError.getAndSet(null);
       if (Objects.nonNull(walReplayError)) {
-        return generateErrorResponse(walReplayError);
+        return generateErrorResponse(walReplayError, true);
       }
       if (!prefetchInitialized && !initPrefetch(regionProgress)) {
         return null;
@@ -2139,12 +2145,24 @@ public class ConsensusPrefetchingQueue {
     if (skippedBrokenWalFileCount <= reportedSkippedBrokenWalFileCount) {
       return;
     }
+    final long skippedBrokenWalEntryCount = 
subscriptionWALIterator.getSkippedBrokenWalEntryCount();
+    final long totalSkippedFiles =
+        unreadableWalFileCount.addAndGet(
+            skippedBrokenWalFileCount - reportedSkippedBrokenWalFileCount);
+    unreadableWalEntryCount.addAndGet(
+        skippedBrokenWalEntryCount - reportedSkippedBrokenWalEntryCount);
+    final String detail =
+        
subscriptionWALIterator.getSkippedBrokenWalFileDetails(reportedSkippedBrokenWalFileCount);
     reportedSkippedBrokenWalFileCount = skippedBrokenWalFileCount;
+    reportedSkippedBrokenWalEntryCount = skippedBrokenWalEntryCount;
     pendingWalReplayError.set(
         String.format(
             DataNodePipeMessages
-                
.SUBSCRIPTION_ERROR_WAL_REPLAY_SKIPPED_UNREADABLE_RETAINED_WAL_FILES_D60C5FB2,
-            skippedBrokenWalFileCount));
+                
.MESSAGE_SUBSCRIPTION_WAL_REPLAY_SKIPPED_ARG_UNREADABLE_RETAINED_WAL_FILE_S_IN_QUEUE_ARG_REGION_ARG_HISTORICAL_DATA_MAY_BE_LOST_DETAILS_RECENT_FILES_FULL_DETAILS_IN_SERVER_LOG_ARG_FEDEFF7E,
+            totalSkippedFiles,
+            getPrefetchingQueueId(),
+            consensusGroupId,
+            detail));
   }
 
   private void advanceWalReplayCursorIfPresent(final IndexedConsensusRequest 
request) {
@@ -2187,6 +2205,10 @@ public class ConsensusPrefetchingQueue {
       return;
     }
     reportUnreadableWalReplayIfNecessary();
+    if (subscriptionWALIterator.isWaitingForReadableWal()) {
+      // A retry must preserve the incomplete writer request and its 
file/entry offset.
+      return;
+    }
     if (!(consensusReqReader instanceof WALNode)) {
       return;
     }
@@ -2209,6 +2231,7 @@ public class ConsensusPrefetchingQueue {
   private void resetSubscriptionWALPosition(final long startSearchIndex) {
     closeSubscriptionWALIterator();
     reportedSkippedBrokenWalFileCount = 0;
+    reportedSkippedBrokenWalEntryCount = 0L;
     subscriptionWALIterator = createSubscriptionWALIterator(startSearchIndex);
   }
 
@@ -2227,7 +2250,12 @@ public class ConsensusPrefetchingQueue {
       // applies the pending realignment. Returning true keeps the worker 
scheduled for that round.
       return true;
     }
-    return Objects.nonNull(subscriptionWALIterator) && 
subscriptionWALIterator.hasNext();
+    if (Objects.isNull(subscriptionWALIterator)) {
+      return false;
+    }
+    final boolean readable = subscriptionWALIterator.hasNext();
+    reportUnreadableWalReplayIfNecessary();
+    return readable;
   }
 
   private void requestSubscriptionWalReset(
@@ -2261,6 +2289,7 @@ public class ConsensusPrefetchingQueue {
     if (Objects.isNull(subscriptionWALIterator)) {
       return;
     }
+    reportUnreadableWalReplayIfNecessary();
     try {
       subscriptionWALIterator.close();
     } catch (final IOException e) {
@@ -4155,9 +4184,14 @@ public class ConsensusPrefetchingQueue {
   }
 
   private SubscriptionEvent generateErrorResponse(final String errorMessage) {
+    return generateErrorResponse(errorMessage, false);
+  }
+
+  private SubscriptionEvent generateErrorResponse(
+      final String errorMessage, final boolean critical) {
     return new SubscriptionEvent(
         SubscriptionPollResponseType.ERROR.getType(),
-        new ErrorPayload(errorMessage, false),
+        new ErrorPayload(errorMessage, critical),
         
createNonCommittableContext(IoTDBDescriptor.getInstance().getConfig().getDataNodeId()));
   }
 
@@ -4214,6 +4248,16 @@ public class ConsensusPrefetchingQueue {
     return walGapSkippedEntries.get();
   }
 
+  /** Cumulative file-skip occurrences, including replay after an iterator 
reset or seek. */
+  public long getUnreadableWalFileCount() {
+    return unreadableWalFileCount.get();
+  }
+
+  /** Known skipped WAL entries/fragments; unreadable footers leave their 
entry counts unknown. */
+  public long getUnreadableWalEntryCount() {
+    return unreadableWalEntryCount.get();
+  }
+
   public long getEpochChangeCount() {
     return runtimeVersionChangeCount.get();
   }
@@ -4718,6 +4762,8 @@ public class ConsensusPrefetchingQueue {
     result.put("walPathAcceptedEntries", 
String.valueOf(getWalPathAcceptedEntries()));
     result.put("seekGeneration", String.valueOf(seekGeneration.get()));
     result.put("walGapSkippedEntries", 
String.valueOf(walGapSkippedEntries.get()));
+    result.put("unreadableWalFileCount", 
String.valueOf(getUnreadableWalFileCount()));
+    result.put("unreadableWalEntryCount", 
String.valueOf(getUnreadableWalEntryCount()));
     result.put("bufferedRealtimeEntryCount", 
String.valueOf(getRealtimeBufferedEntryCount()));
     result.put("lag", String.valueOf(getLag()));
     result.put("isClosed", String.valueOf(isClosed));
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 6572473cb58..cbf0649f224 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
@@ -23,10 +23,12 @@ import 
org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeType;
 import org.apache.iotdb.commons.request.IConsensusRequest;
 import org.apache.iotdb.consensus.common.request.IndexedConsensusRequest;
 import org.apache.iotdb.consensus.common.request.IoTConsensusRequest;
+import org.apache.iotdb.db.conf.IoTDBDescriptor;
 import org.apache.iotdb.db.i18n.DataNodePipeMessages;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.SearchNode;
 import org.apache.iotdb.db.storageengine.dataregion.wal.buffer.WALEntryType;
 import org.apache.iotdb.db.storageengine.dataregion.wal.buffer.WALInfoEntry;
+import 
org.apache.iotdb.db.storageengine.dataregion.wal.exception.BrokenWALFileException;
 import org.apache.iotdb.db.storageengine.dataregion.wal.io.ProgressWALReader;
 import org.apache.iotdb.db.storageengine.dataregion.wal.io.WALFileVersion;
 import org.apache.iotdb.db.storageengine.dataregion.wal.io.WALMetaData;
@@ -64,6 +66,7 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
   }
 
   private static final Logger LOGGER = 
LoggerFactory.getLogger(ProgressWALIterator.class);
+  private static final int MAX_SKIPPED_FILE_DETAILS = 8;
 
   private static final int SEARCH_INDEX_OFFSET =
       WALInfoEntry.FIXED_SERIALIZED_SIZE + PlanNodeType.BYTES;
@@ -82,10 +85,13 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
   private boolean currentReaderUsesLiveSnapshot = false;
   private int consumedEntryCountInCurrentFile = 0;
   private final Set<Long> skippedBrokenWalVersionIds = new HashSet<>();
-  private int unreportedSkippedBrokenWalFileCount = 0;
-  private String firstUnreportedSkippedBrokenWalFile;
-  private String lastUnreportedSkippedBrokenWalFile;
-  private String firstUnreportedSkippedBrokenWalError;
+  private final List<String> skippedBrokenWalFileDetails = new ArrayList<>();
+  private long skippedBrokenWalEntryCount;
+  private boolean retryCurrentReader;
+  private long eofRetryVersionId = -1L;
+  private int eofRetryEntryOffset = -1;
+  private long eofRetryFileLength = -1L;
+  private long eofRetryFileModified = -1L;
   private IOException lastError;
   private boolean incompleteScan = false;
   private String incompleteScanDetail;
@@ -194,7 +200,6 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
             
DataNodePipeMessages.PIPE_LOG_PROGRESSWALITERATOR_ERROR_READING_WAL_2DB46D41, 
e);
       }
       if (nextReady == null) {
-        logSkippedBrokenWalFilesIfNecessary();
         return false;
       }
     }
@@ -366,6 +371,23 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
     return skippedBrokenWalVersionIds.size();
   }
 
+  long getSkippedBrokenWalEntryCount() {
+    return skippedBrokenWalEntryCount;
+  }
+
+  boolean isWaitingForReadableWal() {
+    return retryCurrentReader;
+  }
+
+  String getSkippedBrokenWalFileDetails(final int firstFileIndex) {
+    final int firstAvailableIndex =
+        getSkippedBrokenWalFileCount() - skippedBrokenWalFileDetails.size();
+    final int start = Math.max(0, firstFileIndex - firstAvailableIndex);
+    return (firstFileIndex < firstAvailableIndex ? "...; " : "")
+        + String.join(
+            "; ", skippedBrokenWalFileDetails.subList(start, 
skippedBrokenWalFileDetails.size()));
+  }
+
   public boolean hasIncompleteScan() {
     return incompleteScan || hasReadError() || hasSkippedBrokenWalFiles();
   }
@@ -398,6 +420,21 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
 
   private IndexedConsensusRequest advance() throws IOException {
     while (true) {
+      if (retryCurrentReader) {
+        refresh();
+        final int retryFileIndex = 
findFileIndexByVersion(currentReaderVersionId);
+        if (retryFileIndex < 0) {
+          // Do not let a temporarily invisible file make replay silently move 
to its successor.
+          return null;
+        }
+        if (!openReaderAtIndex(retryFileIndex, 
consumedEntryCountInCurrentFile)) {
+          if (retryCurrentReader) {
+            return null;
+          }
+          continue;
+        }
+        retryCurrentReader = false;
+      }
       if (currentReader != null && currentReader.hasNext()) {
         try {
           final ByteBuffer buffer = currentReader.next();
@@ -430,25 +467,21 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
           }
           continue;
         } catch (final EOFException eofException) {
-          if (!currentReaderUsesLiveSnapshot) {
-            final IndexedConsensusRequest flushed = 
skipUnreadableCurrentWalFile(eofException);
-            if (flushed != null && !shouldSkip(flushed)) {
-              return flushed;
-            }
-            continue;
+          if (!isConfirmedRetainedWalTruncation()) {
+            deferCurrentReader(eofException);
+            return null;
           }
-          // Live snapshot metadata may get ahead of the bytes currently 
visible in the file. Treat
-          // EOF as "this snapshot is exhausted for now" instead of 
terminating the iterator.
-          final IndexedConsensusRequest flushed = flushPending();
+          final IndexedConsensusRequest flushed = 
skipUnreadableCurrentWalFile(eofException);
           if (flushed != null && !shouldSkip(flushed)) {
-            closeCurrentReader();
             return flushed;
           }
-          if (reopenLiveSnapshotReader()) {
-            continue;
-          }
-          return null;
+          continue;
         } catch (final IOException readException) {
+          if (!(readException instanceof BrokenWALFileException)
+              && !isCurrentEntryAboveSizeLimit()) {
+            deferCurrentReader(readException);
+            return null;
+          }
           final IndexedConsensusRequest flushed = 
skipUnreadableCurrentWalFile(readException);
           if (flushed != null && !shouldSkip(flushed)) {
             return flushed;
@@ -479,6 +512,9 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
       }
 
       if (!openNextReader()) {
+        if (retryCurrentReader) {
+          return null;
+        }
         final IndexedConsensusRequest flushed = flushPending();
         if (flushed != null && !shouldSkip(flushed)) {
           return flushed;
@@ -493,6 +529,9 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
       if (openReaderAtIndex(currentFileIndex, 0)) {
         return true;
       }
+      if (retryCurrentReader) {
+        return false;
+      }
     }
     return false;
   }
@@ -522,10 +561,14 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
     if (previousFileIndex < 0) {
       return openFirstReaderAfterVersion(currentReaderVersionId);
     }
+    final long versionToReopen = currentReaderVersionId;
     if (openReaderAtIndex(previousFileIndex, consumedEntryCountInCurrentFile)) 
{
       return true;
     }
-    return openFirstReaderAfterVersion(currentReaderVersionId);
+    if (retryCurrentReader) {
+      return false;
+    }
+    return openFirstReaderAfterVersion(versionToReopen);
   }
 
   private boolean openReaderAtIndex(final int fileIndex, final int 
skipEntries) throws IOException {
@@ -546,11 +589,13 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
       throws IOException {
     final File walFile = walFiles[fileIndex];
     final long versionId = walFileVersionIds[fileIndex];
+    currentFileIndex = fileIndex;
     final boolean useLiveSnapshot =
         liveWalNode != null && versionId == 
liveWalNode.getCurrentWALFileVersion();
 
+    ProgressWALReader reader = null;
     try {
-      final ProgressWALReader reader =
+      reader =
           useLiveSnapshot
               ? new ProgressWALReader(
                   walFile,
@@ -562,10 +607,14 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
         reader.close();
         markIncompleteScan(
             String.format(
-                "failed to reopen WAL file %s at entry offset %s: iterator 
could not skip to the requested position",
-                walFile.getName(), skipEntries),
+                DataNodePipeMessages
+                    
.MESSAGE_FAILED_TO_REOPEN_WAL_FILE_ARG_AT_ENTRY_OFFSET_ARG_ITERATOR_COULD_NOT_SKIP_TO_THE_REQUESTED_POSITION_332B3AD9,
+                walFile.getName(),
+                skipEntries),
             null);
-        resetCurrentFileTracking();
+        currentReaderVersionId = versionId;
+        consumedEntryCountInCurrentFile = skipEntries;
+        retryCurrentReader = true;
         return false;
       }
       currentReader = reader;
@@ -573,9 +622,19 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
       currentReaderVersionId = versionId;
       currentReaderUsesLiveSnapshot = useLiveSnapshot;
       consumedEntryCountInCurrentFile = skipEntries;
+      retryCurrentReader = false;
       return true;
     } catch (final IOException e) {
-      if (isNearLiveWalVersion(versionId)) {
+      if (reader != null) {
+        try {
+          reader.close();
+        } catch (final IOException closeException) {
+          e.addSuppressed(closeException);
+        }
+      }
+      if (isNearLiveWalVersion(versionId)
+          || !(e instanceof BrokenWALFileException)
+          || (liveWalNode == null && fileIndex == walFiles.length - 1)) {
         LOGGER.debug(
             DataNodePipeMessages
                 
.PIPE_LOG_PROGRESSWALITERATOR_FAILED_TO_OPEN_NEAR_LIVE_WAL_FILE_RETRYING_5AEB94AC,
@@ -592,33 +651,123 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
         }
         markIncompleteScan(
             String.format(
-                "failed to open near-live WAL file %s while replay scan was 
still in progress",
-                walFile.getName()),
+                DataNodePipeMessages
+                    
.MESSAGE_WAL_FILE_ARG_VERSIONID_ARG_ENTRYOFFSET_ARG_IS_TEMPORARILY_UNREADABLE_REPLAY_WILL_RETRY_WITHOUT_SKIPPING_ARG_EA11FBDD,
+                walFile.getAbsolutePath(),
+                versionId,
+                skipEntries,
+                summarizeException(e)),
             e);
+        currentReaderVersionId = versionId;
+        consumedEntryCountInCurrentFile = skipEntries;
+        retryCurrentReader = true;
         return false;
       }
-      recordSkippedBrokenWalFile(versionId, walFile, e);
+      recordSkippedBrokenWalFile(versionId, walFile, e, null, skipEntries);
+      pendingRequests.clear();
+      pendingSearchIndex = Long.MIN_VALUE;
+      pendingLocalSeq = Long.MIN_VALUE;
+      resetCurrentFileTracking();
       return false;
     }
   }
 
   private void recordSkippedBrokenWalFile(
-      final long versionId, final File walFile, final IOException error) {
+      final long versionId,
+      final File walFile,
+      final IOException error,
+      final WALMetaData metadata,
+      final int firstSkippedEntryOffset) {
     if (!skippedBrokenWalVersionIds.add(versionId)) {
       return;
     }
 
-    if (unreportedSkippedBrokenWalFileCount == 0) {
-      firstUnreportedSkippedBrokenWalFile = walFile.getName();
-      firstUnreportedSkippedBrokenWalError = summarizeException(error);
-    }
-    lastUnreportedSkippedBrokenWalFile = walFile.getName();
-    unreportedSkippedBrokenWalFileCount++;
-    LOGGER.debug(
+    final int entryCount = metadata == null ? -1 : 
metadata.getBuffersSize().size();
+    final long skippedEntries =
+        entryCount < 0 ? -1L : Math.max(0L, (long) entryCount - 
firstSkippedEntryOffset);
+    if (skippedEntries > 0L) {
+      skippedBrokenWalEntryCount += skippedEntries;
+    }
+    // Filename boundaries are file-level search-index bounds, not an entry 
count. Footer entry
+    // ordinals also include request fragments and entries from other writers.
+    final long firstSearchIndex = 
WALFileUtils.parseStartSearchIndex(walFile.getName());
+    final int fileIndex = findFileIndexByVersion(versionId);
+    final long lastSearchIndex =
+        fileIndex >= 0 && fileIndex + 1 < walFiles.length
+            ? WALFileUtils.parseStartSearchIndex(walFiles[fileIndex + 
1].getName())
+            : -1L;
+    final String detail =
+        String.format(
+            DataNodePipeMessages
+                
.MESSAGE_FILE_ARG_VERSIONID_ARG_FILESEARCHINDEXRANGE_ARG_ARG_ENTRYRANGE_ARG_ARG_SKIPPEDENTRIES_ARG_ERROR_ARG_0D6D77B0,
+            walFile.getAbsolutePath(),
+            versionId,
+            formatKnownValue(firstSearchIndex),
+            formatKnownValue(lastSearchIndex),
+            firstSkippedEntryOffset,
+            formatKnownValue(entryCount),
+            formatKnownValue(skippedEntries),
+            summarizeException(error));
+    if (skippedBrokenWalFileDetails.size() == MAX_SKIPPED_FILE_DETAILS) {
+      skippedBrokenWalFileDetails.remove(0);
+    }
+    skippedBrokenWalFileDetails.add(detail);
+    LOGGER.warn(
         DataNodePipeMessages
-            
.PIPE_LOG_PROGRESSWALITERATOR_FAILED_TO_PROCESS_WAL_FILE_SKIPPING_REMAINING_ENTRIES_093F14A9,
-        walFile.getName(),
+            
.LOG_PROGRESSWALITERATOR_SKIPPED_UNREADABLE_RETAINED_WAL_FILE_ARG_HISTORICAL_SUBSCRIPTION_DATA_MAY_BE_LOST_AE0DBAB1,
+        detail,
+        error);
+  }
+
+  private static String formatKnownValue(final long value) {
+    return value < 0 ? DataNodePipeMessages.MESSAGE_UNKNOWN_AD921D60 : 
String.valueOf(value);
+  }
+
+  private boolean isConfirmedRetainedWalTruncation() {
+    final File walFile = walFiles[currentFileIndex];
+    final boolean sealed =
+        liveWalNode != null
+            ? !isNearLiveWalVersion(currentReaderVersionId)
+            : currentFileIndex + 1 < walFiles.length;
+    final boolean confirmed =
+        !currentReaderUsesLiveSnapshot
+            && sealed
+            && eofRetryVersionId == currentReaderVersionId
+            && eofRetryEntryOffset == consumedEntryCountInCurrentFile
+            && eofRetryFileLength == walFile.length()
+            && eofRetryFileModified == walFile.lastModified();
+    eofRetryVersionId = currentReaderVersionId;
+    eofRetryEntryOffset = consumedEntryCountInCurrentFile;
+    eofRetryFileLength = walFile.length();
+    eofRetryFileModified = walFile.lastModified();
+    return confirmed;
+  }
+
+  private void deferCurrentReader(final IOException error) throws IOException {
+    // Keep the incomplete request and the offset of the first unread entry. 
Flushing or reopening
+    // at offset zero here would either deliver a partial request or duplicate 
its fragments.
+    markIncompleteScan(
+        String.format(
+            DataNodePipeMessages
+                
.MESSAGE_WAL_FILE_ARG_VERSIONID_ARG_ENTRYOFFSET_ARG_IS_TEMPORARILY_UNREADABLE_REPLAY_WILL_RETRY_WITHOUT_SKIPPING_ARG_EA11FBDD,
+            walFiles[currentFileIndex].getAbsolutePath(),
+            currentReaderVersionId,
+            consumedEntryCountInCurrentFile,
+            summarizeException(error)),
         error);
+    retryCurrentReader = true;
+    closeCurrentReader();
+  }
+
+  private boolean isCurrentEntryAboveSizeLimit() {
+    final int entryIndex = currentReader.getCurrentEntryIndex();
+    final List<Integer> sizes = currentReader.getMetaData().getBuffersSize();
+    // An entry above the supported limit is deterministically unreadable, 
even after reopening.
+    // Preserve the existing large-entry escape path, but report the resulting 
data gap explicitly.
+    return entryIndex >= 0
+        && entryIndex < sizes.size()
+        && sizes.get(entryIndex)
+            > 
IoTDBDescriptor.getInstance().getConfig().getWalEntrySizeLimitInByte();
   }
 
   private IndexedConsensusRequest skipUnreadableCurrentWalFile(final 
IOException error) {
@@ -631,6 +780,13 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
         failedFileIndex >= 0 && failedFileIndex < walFileVersionIds.length
             ? walFileVersionIds[failedFileIndex]
             : currentReaderVersionId;
+    final WALMetaData failedFileMetadata =
+        currentReader == null ? null : currentReader.getMetaData();
+    final int firstSkippedEntryOffset =
+        Math.max(
+            0,
+            consumedEntryCountInCurrentFile
+                - (isFailedEntryPartOfPendingRequest() ? 
pendingRequests.size() : 0));
     final IndexedConsensusRequest flushed =
         isFailedEntryPartOfPendingRequest() ? null : flushPending();
 
@@ -651,7 +807,8 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
           error);
       return flushed;
     }
-    recordSkippedBrokenWalFile(versionId, walFile, error);
+    recordSkippedBrokenWalFile(
+        versionId, walFile, error, failedFileMetadata, 
firstSkippedEntryOffset);
     return flushed;
   }
 
@@ -664,25 +821,6 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
             currentReader.getCurrentEntryLocalSeq());
   }
 
-  private void logSkippedBrokenWalFilesIfNecessary() {
-    if (unreportedSkippedBrokenWalFileCount == 0) {
-      return;
-    }
-
-    LOGGER.warn(
-        DataNodePipeMessages
-            
.PIPE_LOG_PROGRESSWALITERATOR_SKIPPED_UNREADABLE_RETAINED_WAL_FILES_FFC8455E,
-        unreportedSkippedBrokenWalFileCount,
-        logDirectory,
-        firstUnreportedSkippedBrokenWalFile,
-        lastUnreportedSkippedBrokenWalFile,
-        firstUnreportedSkippedBrokenWalError);
-    unreportedSkippedBrokenWalFileCount = 0;
-    firstUnreportedSkippedBrokenWalFile = null;
-    lastUnreportedSkippedBrokenWalFile = null;
-    firstUnreportedSkippedBrokenWalError = null;
-  }
-
   private static String summarizeException(final IOException error) {
     return error.getMessage() == null
         ? error.getClass().getSimpleName()
@@ -707,6 +845,9 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
       if (openReaderAtIndex(i, 0)) {
         return true;
       }
+      if (retryCurrentReader) {
+        return false;
+      }
     }
     resetCurrentFileTracking();
     return false;
@@ -783,6 +924,9 @@ public class ProgressWALIterator implements Closeable, 
Iterator<IndexedConsensus
     currentReaderVersionId = -1L;
     currentReaderUsesLiveSnapshot = false;
     consumedEntryCountInCurrentFile = 0;
+    retryCurrentReader = false;
+    eofRetryVersionId = -1L;
+    eofRetryEntryOffset = -1;
   }
 
   private void markIncompleteScan(final String detail, final IOException 
cause) {
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetrics.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetrics.java
index 0a549565b06..359da8f9239 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetrics.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetrics.java
@@ -116,6 +116,18 @@ public class ConsensusSubscriptionPrefetchingQueueMetrics 
implements IMetricSet
         queue,
         ConsensusPrefetchingQueue::getWalGapSkippedEntries,
         key.getTags());
+    metricService.createAutoGauge(
+        Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP.toString(),
+        MetricLevel.IMPORTANT,
+        queue,
+        ConsensusPrefetchingQueue::getUnreadableWalFileCount,
+        key.getTags());
+    metricService.createAutoGauge(
+        Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP_ENTRIES.toString(),
+        MetricLevel.IMPORTANT,
+        queue,
+        ConsensusPrefetchingQueue::getUnreadableWalEntryCount,
+        key.getTags());
     metricService.createAutoGauge(
         Metric.SUBSCRIPTION_CONSENSUS_ROUTING_EPOCH_CHANGE.toString(),
         MetricLevel.IMPORTANT,
@@ -309,6 +321,14 @@ public class ConsensusSubscriptionPrefetchingQueueMetrics 
implements IMetricSet
         MetricType.AUTO_GAUGE, Metric.SUBSCRIPTION_CONSENSUS_LAG.toString(), 
key.getTags());
     metricService.remove(
         MetricType.AUTO_GAUGE, 
Metric.SUBSCRIPTION_CONSENSUS_WAL_GAP.toString(), key.getTags());
+    metricService.remove(
+        MetricType.AUTO_GAUGE,
+        Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP.toString(),
+        key.getTags());
+    metricService.remove(
+        MetricType.AUTO_GAUGE,
+        Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP_ENTRIES.toString(),
+        key.getTags());
     metricService.remove(
         MetricType.AUTO_GAUGE,
         Metric.SUBSCRIPTION_CONSENSUS_ROUTING_EPOCH_CHANGE.toString(),
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 371bb149c4c..600073d7e3d 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
@@ -391,8 +391,15 @@ public class ConsensusPrefetchingQueueTest {
 
       final ProgressWALIterator iterator = mock(ProgressWALIterator.class);
       when(iterator.getSkippedBrokenWalFileCount()).thenReturn(1);
+      when(iterator.getSkippedBrokenWalEntryCount()).thenReturn(5L);
+      when(iterator.getSkippedBrokenWalFileDetails(0))
+          .thenReturn("file=_0-0-1.wal, versionId=0, error=EOFException");
       setSubscriptionWalIterator(queue, iterator);
       invokeReportUnreadableWalReplayIfNecessary(queue);
+      invokeReportUnreadableWalReplayIfNecessary(queue);
+      assertEquals(1L, queue.getUnreadableWalFileCount());
+      assertEquals(5L, queue.getUnreadableWalEntryCount());
+      assertEquals(0L, queue.getWalGapSkippedEntries());
 
       final SubscriptionEvent event = queue.poll("consumer");
       assertNotNull(event);
@@ -400,7 +407,32 @@ public class ConsensusPrefetchingQueueTest {
           SubscriptionPollResponseType.ERROR.getType(),
           event.getCurrentResponse().getResponseType());
       assertTrue(event.getCurrentResponse().getPayload() instanceof 
ErrorPayload);
+      final ErrorPayload error = (ErrorPayload) 
event.getCurrentResponse().getPayload();
+      assertTrue(error.isCritical());
+      assertTrue(error.getErrorMessage().contains("_0-0-1.wal"));
+      assertTrue(error.getErrorMessage().contains("EOFException"));
+      
assertTrue(error.getErrorMessage().contains(queue.getPrefetchingQueueId()));
+      
assertTrue(error.getErrorMessage().contains(queue.getConsensusGroupId().toString()));
       assertNull(queue.poll("consumer"));
+
+      final Method reset =
+          ConsensusPrefetchingQueue.class.getDeclaredMethod(
+              "resetSubscriptionWALPosition", long.class);
+      reset.setAccessible(true);
+      reset.invoke(queue, 1L);
+      assertEquals(1L, queue.getUnreadableWalFileCount());
+      assertEquals(5L, queue.getUnreadableWalEntryCount());
+      final ProgressWALIterator replacement = mock(ProgressWALIterator.class);
+      when(replacement.getSkippedBrokenWalFileCount()).thenReturn(2);
+      // No readable footer: file skips remain observable even when entry 
counts are unknown.
+      when(replacement.getSkippedBrokenWalFileDetails(0))
+          .thenReturn("file=_1-1-1.wal, skippedEntries=unknown, 
error=BrokenWALFileException");
+      setSubscriptionWalIterator(queue, replacement);
+      invokeReportUnreadableWalReplayIfNecessary(queue);
+      assertEquals(3L, queue.getUnreadableWalFileCount());
+      assertEquals(5L, queue.getUnreadableWalEntryCount());
+      assertTrue(
+          ((ErrorPayload) 
queue.poll("consumer").getCurrentResponse().getPayload()).isCritical());
     } finally {
       if (queue != null) {
         queue.close();
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 c800a2327e9..8af97b31964 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
@@ -32,13 +32,20 @@ import 
org.apache.iotdb.db.storageengine.dataregion.wal.node.WALNode;
 import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALFileStatus;
 import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALFileUtils;
 
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
 import org.junit.Assume;
 import org.junit.Test;
+import org.slf4j.LoggerFactory;
 
 import java.io.File;
 import java.nio.ByteBuffer;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -361,6 +368,13 @@ public class ProgressWALIteratorTest {
     final File lastWal =
         dir.resolve(WALFileUtils.getLogFileName(2, 2, 
WALFileStatus.CONTAINS_SEARCH_INDEX))
             .toFile();
+    final Logger logger = (Logger) 
LoggerFactory.getLogger(ProgressWALIterator.class);
+    final Level originalLevel = logger.getLevel();
+    logger.setLevel(Level.WARN);
+    final ListAppender<ILoggingEvent> appender = new ListAppender<>();
+    appender.setContext(logger.getLoggerContext());
+    appender.start();
+    logger.addAppender(appender);
 
     try {
       Files.write(firstBrokenWal.toPath(), new byte[128]);
@@ -372,6 +386,18 @@ public class ProgressWALIteratorTest {
 
       try (ProgressWALIterator iterator = new 
ProgressWALIterator(dir.toFile(), Long.MIN_VALUE)) {
         assertTrue(iterator.hasNext());
+        // A continuously readable iterator must log every skip before 
reaching exhaustion.
+        assertEquals(2, appender.list.size());
+        
assertTrue(appender.list.get(0).getFormattedMessage().contains(firstBrokenWal.getName()));
+        
assertTrue(appender.list.get(1).getFormattedMessage().contains(secondBrokenWal.getName()));
+        
assertTrue(appender.list.get(0).getFormattedMessage().contains("versionId=0"));
+        assertTrue(
+            
appender.list.get(0).getFormattedMessage().contains("error=BrokenWALFileException"));
+        assertEquals(0L, iterator.getSkippedBrokenWalEntryCount());
+        final String detail = iterator.getSkippedBrokenWalFileDetails(0);
+        assertTrue(detail.contains(firstBrokenWal.getName()));
+        assertTrue(detail.contains(secondBrokenWal.getName()));
+        assertTrue(detail.contains("skippedEntries=unknown"));
         assertEquals(2L, iterator.next().getSearchIndex());
         assertFalse(iterator.hasNext());
         assertTrue(iterator.hasSkippedBrokenWalFiles());
@@ -380,6 +406,9 @@ public class ProgressWALIteratorTest {
         assertFalse(iterator.hasReadError());
       }
     } finally {
+      logger.detachAppender(appender);
+      logger.setLevel(originalLevel);
+      appender.stop();
       Files.deleteIfExists(firstBrokenWal.toPath());
       Files.deleteIfExists(secondBrokenWal.toPath());
       Files.deleteIfExists(
@@ -421,6 +450,8 @@ public class ProgressWALIteratorTest {
         assertEquals(3L, iterator.next().getSearchIndex());
         assertFalse(iterator.hasNext());
         assertEquals(1, iterator.getSkippedBrokenWalFileCount());
+        assertEquals(1L, iterator.getSkippedBrokenWalEntryCount());
+        
assertTrue(iterator.getSkippedBrokenWalFileDetails(0).contains("entryRange=[1, 
2)"));
         assertTrue(iterator.hasIncompleteScan());
       }
     } finally {
@@ -459,6 +490,7 @@ public class ProgressWALIteratorTest {
         assertEquals(3L, iterator.next().getSearchIndex());
         assertFalse(iterator.hasNext());
         assertEquals(1, iterator.getSkippedBrokenWalFileCount());
+        assertEquals(2L, iterator.getSkippedBrokenWalEntryCount());
       }
     } finally {
       
IoTDBDescriptor.getInstance().getConfig().setWalEntrySizeLimitInByte(originalEntrySizeLimit);
@@ -468,6 +500,178 @@ public class ProgressWALIteratorTest {
     }
   }
 
+  @Test
+  public void testRetainedEofRetriesAndPreservesPendingFragments() throws 
Exception {
+    verifyEofRetryPreservesPendingFragments(false);
+  }
+
+  @Test
+  public void testNearLiveEofDoesNotBlacklistFileOnRepeatedReads() throws 
Exception {
+    verifyEofRetryPreservesPendingFragments(true);
+  }
+
+  private void verifyEofRetryPreservesPendingFragments(final boolean nearLive) 
throws Exception {
+    final Path dir = 
Files.createTempDirectory("progress-wal-iterator-eof-retry");
+    final File dataWal =
+        dir.resolve(WALFileUtils.getLogFileName(0, 0, 
WALFileStatus.CONTAINS_SEARCH_INDEX))
+            .toFile();
+    final File successorWal =
+        dir.resolve(WALFileUtils.getLogFileName(1, 1, 
WALFileStatus.CONTAINS_SEARCH_INDEX))
+            .toFile();
+    try {
+      writeFragmentedWal(dataWal, 38);
+      final WALMetaData successorMetadata = singleEntryMeta(19, 2L, 1L, 200L, 
7, 2L);
+      try (WALWriter writer = new WALWriter(successorWal, WALFileVersion.V3)) {
+        writer.write(searchableEntry(2L), successorMetadata);
+      }
+      final WALNode walNode = mock(WALNode.class);
+      when(walNode.getLogDirectory()).thenReturn(dir.toFile());
+      when(walNode.getCurrentWALFileVersion()).thenReturn(1L);
+      
when(walNode.getCurrentWALMetaDataSnapshot()).thenReturn(successorMetadata);
+      try (ProgressWALIterator iterator =
+          nearLive
+              ? new ProgressWALIterator(walNode, 1L)
+              : new ProgressWALIterator(dir.toFile(), 1L)) {
+        assertFalse(iterator.hasNext());
+        assertTrue(iterator.isWaitingForReadableWal());
+        assertEquals(0, iterator.getSkippedBrokenWalFileCount());
+        if (nearLive) {
+          assertFalse(iterator.hasNext());
+          assertEquals(0, iterator.getSkippedBrokenWalFileCount());
+        }
+        // Simulate bytes/footer becoming readable after the first EOF. The 
retry must resume at
+        // entry offset one, retaining the first fragment exactly once.
+        Files.delete(dataWal.toPath());
+        writeFragmentedWal(dataWal, 19);
+        assertTrue(iterator.hasNext());
+        final IndexedConsensusRequest recovered = iterator.next();
+        assertEquals(1L, recovered.getSearchIndex());
+        assertEquals(2, recovered.getRequests().size());
+        assertTrue(iterator.hasNext());
+        assertEquals(2L, iterator.next().getSearchIndex());
+        assertFalse(iterator.hasNext());
+        assertEquals(0, iterator.getSkippedBrokenWalFileCount());
+      }
+    } finally {
+      Files.deleteIfExists(dataWal.toPath());
+      Files.deleteIfExists(successorWal.toPath());
+      Files.deleteIfExists(dir);
+    }
+  }
+
+  @Test
+  public void testRepeatedEofInStableRetainedFileReportsTruncatedRequest() 
throws Exception {
+    final Path dir = 
Files.createTempDirectory("progress-wal-iterator-confirmed-truncation");
+    final File dataWal =
+        dir.resolve(WALFileUtils.getLogFileName(0, 0, 
WALFileStatus.CONTAINS_SEARCH_INDEX))
+            .toFile();
+    final File successorWal =
+        dir.resolve(WALFileUtils.getLogFileName(1, 1, 
WALFileStatus.CONTAINS_SEARCH_INDEX))
+            .toFile();
+    try {
+      writeFragmentedWal(dataWal, 38);
+      try (WALWriter writer = new WALWriter(successorWal, WALFileVersion.V3)) {
+        writer.write(searchableEntry(2L), singleEntryMeta(19, 2L, 1L, 200L, 7, 
2L));
+      }
+      try (ProgressWALIterator iterator = new 
ProgressWALIterator(dir.toFile(), 1L)) {
+        assertFalse(iterator.hasNext());
+        assertEquals(0, iterator.getSkippedBrokenWalFileCount());
+        assertTrue(iterator.hasNext());
+        assertEquals(2L, iterator.next().getSearchIndex());
+        assertEquals(1, iterator.getSkippedBrokenWalFileCount());
+        assertEquals(2L, iterator.getSkippedBrokenWalEntryCount());
+        final String detail = iterator.getSkippedBrokenWalFileDetails(0);
+        assertTrue(detail.contains(dataWal.getName()));
+        assertTrue(detail.contains("fileSearchIndexRange=(0, 1]"));
+        assertTrue(detail.contains("entryRange=[0, 2)"));
+        assertTrue(detail.contains("error=EOFException"));
+      }
+    } finally {
+      Files.deleteIfExists(dataWal.toPath());
+      Files.deleteIfExists(successorWal.toPath());
+      Files.deleteIfExists(dir);
+    }
+  }
+
+  @Test
+  public void 
testLiveSnapshotEofPreservesIncompleteRequestUntilBytesAreVisible() throws 
Exception {
+    final Path dir = 
Files.createTempDirectory("progress-wal-iterator-live-eof-retry");
+    final File liveWal =
+        dir.resolve(WALFileUtils.getLogFileName(0, 0, 
WALFileStatus.CONTAINS_SEARCH_INDEX))
+            .toFile();
+    try {
+      final WALMetaData metadata = singleEntryMeta(19, 1L, 1L, 100L, 7, 1L);
+      metadata.add(19, 1L, 1L, 100L, 7, 1L);
+      final WALNode walNode = mock(WALNode.class);
+      when(walNode.getLogDirectory()).thenReturn(dir.toFile());
+      when(walNode.getCurrentWALFileVersion()).thenReturn(0L);
+      when(walNode.getCurrentWALMetaDataSnapshot()).thenReturn(metadata);
+      try (WALWriter writer = new WALWriter(liveWal, WALFileVersion.V3)) {
+        writer.write(searchableEntry(1L), singleEntryMeta(19, 1L, 1L, 100L, 7, 
1L));
+        try (ProgressWALIterator iterator = new ProgressWALIterator(walNode, 
1L)) {
+          assertFalse(iterator.hasNext());
+          assertEquals(0, iterator.getSkippedBrokenWalFileCount());
+          writer.write(searchableEntry(1L), singleEntryMeta(19, 1L, 1L, 100L, 
7, 1L));
+          assertTrue(iterator.hasNext());
+          final IndexedConsensusRequest recovered = iterator.next();
+          assertEquals(1L, recovered.getSearchIndex());
+          assertEquals(2, recovered.getRequests().size());
+          assertEquals(0, iterator.getSkippedBrokenWalFileCount());
+        }
+      }
+    } finally {
+      Files.deleteIfExists(liveWal.toPath());
+      Files.deleteIfExists(dir);
+    }
+  }
+
+  private static void writeFragmentedWal(final File walFile, final int 
secondEntrySize)
+      throws Exception {
+    try (WALWriter writer = new WALWriter(walFile, WALFileVersion.V3)) {
+      writer.write(searchableEntry(1L), singleEntryMeta(19, 1L, 1L, 100L, 7, 
1L));
+      writer.write(searchableEntry(1L), singleEntryMeta(secondEntrySize, 1L, 
1L, 100L, 7, 1L));
+    }
+  }
+
+  @Test
+  public void testSkippedFileDetailsRemainBoundedDuringContinuousReplay() 
throws Exception {
+    final Path dir = 
Files.createTempDirectory("progress-wal-iterator-bounded-errors");
+    final List<File> files = new ArrayList<>();
+    try {
+      for (int version = 0; version < 10; version++) {
+        final File file =
+            dir.resolve(
+                    WALFileUtils.getLogFileName(
+                        version, version, WALFileStatus.CONTAINS_SEARCH_INDEX))
+                .toFile();
+        files.add(file);
+        Files.write(file.toPath(), new byte[128]);
+      }
+      final File successor =
+          dir.resolve(WALFileUtils.getLogFileName(10, 10, 
WALFileStatus.CONTAINS_SEARCH_INDEX))
+              .toFile();
+      files.add(successor);
+      try (WALWriter writer = new WALWriter(successor, WALFileVersion.V3)) {
+        writer.write(searchableEntry(11L), singleEntryMeta(19, 11L, 1L, 200L, 
7, 11L));
+      }
+      try (ProgressWALIterator iterator = new 
ProgressWALIterator(dir.toFile(), 1L)) {
+        assertTrue(iterator.hasNext());
+        assertEquals(10, iterator.getSkippedBrokenWalFileCount());
+        final String detail = iterator.getSkippedBrokenWalFileDetails(0);
+        assertEquals(8, detail.split("versionId=").length - 1);
+        assertFalse(detail.contains(files.get(0).getAbsolutePath()));
+        assertTrue(detail.contains(files.get(9).getAbsolutePath()));
+        assertEquals(2, 
iterator.getSkippedBrokenWalFileDetails(8).split("versionId=").length - 1);
+      }
+    } finally {
+      for (final File file : files) {
+        Files.deleteIfExists(file.toPath());
+        Files.deleteIfExists(file.toPath().resolveSibling(file.getName() + 
".broken"));
+      }
+      Files.deleteIfExists(dir);
+    }
+  }
+
   @Test
   public void testLiveWalReopenReusesMetadataSnapshot() throws Exception {
     final Path dir = 
Files.createTempDirectory("progress-wal-iterator-live-snapshot");
@@ -522,7 +726,8 @@ public class ProgressWALIteratorTest {
         assertFalse(iterator.hasNext());
         assertTrue(iterator.hasIncompleteScan());
         assertTrue(iterator.hasReadError());
-        assertTrue(iterator.getIncompleteScanDetail().contains("near-live WAL 
file"));
+        
assertTrue(iterator.getIncompleteScanDetail().contains(brokenLiveWal.getName()));
+        assertEquals(0, iterator.getSkippedBrokenWalFileCount());
       }
     } finally {
       Files.deleteIfExists(brokenLiveWal.toPath());
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetricsTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetricsTest.java
index 782cebe30cd..8038d9cb0ad 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetricsTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/metric/ConsensusSubscriptionPrefetchingQueueMetricsTest.java
@@ -30,9 +30,11 @@ import org.apache.iotdb.metrics.utils.MetricLevel;
 import org.apache.iotdb.metrics.utils.MetricType;
 
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
 
 import java.lang.reflect.Field;
 import java.util.Map;
+import java.util.function.ToDoubleFunction;
 
 import static org.junit.Assert.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
@@ -60,6 +62,10 @@ public class 
ConsensusSubscriptionPrefetchingQueueMetricsTest {
     when(firstQueue.getConsensusGroupId()).thenReturn(firstRegionId);
     when(secondQueue.getPrefetchingQueueId()).thenReturn(queueId);
     when(secondQueue.getConsensusGroupId()).thenReturn(secondRegionId);
+    when(firstQueue.getUnreadableWalFileCount()).thenReturn(4L);
+    when(firstQueue.getUnreadableWalEntryCount()).thenReturn(7L);
+    when(secondQueue.getUnreadableWalFileCount()).thenReturn(5L);
+    when(secondQueue.getUnreadableWalEntryCount()).thenReturn(9L);
     when(staleFirstQueue.getPrefetchingQueueId()).thenReturn(queueId);
     when(staleFirstQueue.getConsensusGroupId()).thenReturn(firstRegionId);
     when(metricService.getOrCreateRate(
@@ -103,6 +109,43 @@ public class 
ConsensusSubscriptionPrefetchingQueueMetricsTest {
 
       assertEquals(2, queueMap.size());
       assertEquals(2, rateMap.size());
+      for (final Metric metric :
+          new Metric[] {
+            Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP,
+            Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP_ENTRIES
+          }) {
+        @SuppressWarnings({"unchecked", "rawtypes"})
+        final ArgumentCaptor<ToDoubleFunction<ConsensusPrefetchingQueue>> 
mapper =
+            ArgumentCaptor.forClass((Class) ToDoubleFunction.class);
+        verify(metricService)
+            .createAutoGauge(
+                eq(metric.toString()),
+                eq(MetricLevel.IMPORTANT),
+                eq(firstQueue),
+                mapper.capture(),
+                eq(Tag.NAME.toString()),
+                eq(queueId),
+                eq(Tag.REGION.toString()),
+                eq(firstRegionId.toString()));
+        verify(metricService)
+            .createAutoGauge(
+                eq(metric.toString()),
+                eq(MetricLevel.IMPORTANT),
+                eq(secondQueue),
+                mapper.capture(),
+                eq(Tag.NAME.toString()),
+                eq(queueId),
+                eq(Tag.REGION.toString()),
+                eq(secondRegionId.toString()));
+        assertEquals(
+            metric == Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP ? 4.0 : 7.0,
+            mapper.getAllValues().get(0).applyAsDouble(firstQueue),
+            0.0);
+        assertEquals(
+            metric == Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP ? 5.0 : 9.0,
+            mapper.getAllValues().get(1).applyAsDouble(secondQueue),
+            0.0);
+      }
       verify(metricService)
           .createAutoGauge(
               eq(Metric.SUBSCRIPTION_CONSENSUS_LAG.toString()),
@@ -169,6 +212,28 @@ public class 
ConsensusSubscriptionPrefetchingQueueMetricsTest {
       metrics.deregister(firstQueue);
       assertEquals(1, queueMap.size());
       assertEquals(1, rateMap.size());
+      for (final Metric metric :
+          new Metric[] {
+            Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP,
+            Metric.SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP_ENTRIES
+          }) {
+        verify(metricService)
+            .remove(
+                MetricType.AUTO_GAUGE,
+                metric.toString(),
+                Tag.NAME.toString(),
+                queueId,
+                Tag.REGION.toString(),
+                firstRegionId.toString());
+        verify(metricService, never())
+            .remove(
+                MetricType.AUTO_GAUGE,
+                metric.toString(),
+                Tag.NAME.toString(),
+                queueId,
+                Tag.REGION.toString(),
+                secondRegionId.toString());
+      }
       verify(metricService)
           .remove(
               MetricType.AUTO_GAUGE,
diff --git 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Metric.java
 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Metric.java
index d412d74d0cc..e20919801e4 100644
--- 
a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Metric.java
+++ 
b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/service/metric/enums/Metric.java
@@ -216,6 +216,8 @@ public enum Metric {
   SUBSCRIPTION_EVENT_TRANSFER("subscription_event_transfer"),
   SUBSCRIPTION_CONSENSUS_LAG("subscription_consensus_lag"),
   SUBSCRIPTION_CONSENSUS_WAL_GAP("subscription_consensus_wal_gap"),
+  SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP("subscription_consensus_wal_file_gap"),
+  
SUBSCRIPTION_CONSENSUS_WAL_FILE_GAP_ENTRIES("subscription_consensus_wal_file_gap_entries"),
   
SUBSCRIPTION_CONSENSUS_ROUTING_EPOCH_CHANGE("subscription_consensus_routing_epoch_change"),
   SUBSCRIPTION_CONSENSUS_WATERMARK("subscription_consensus_watermark"),
   
SUBSCRIPTION_CONSENSUS_SEEK_GENERATION("subscription_consensus_seek_generation"),

Reply via email to