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 4943c730d7a [Fix] Guard stopped Pipe polling and retain LOAD payloads 
until replicated (#18913)
4943c730d7a is described below

commit 4943c730d7aac7612b6a5c700625e94628a970cb
Author: Caideyipi <[email protected]>
AuthorDate: Tue Oct 20 10:03:05 2026 +0800

    [Fix] Guard stopped Pipe polling and retain LOAD payloads until replicated 
(#18913)
    
    * [Fix] Pause ConfigNode Pipe events polled after stop
    
    * [Fix] Retain LOAD payloads until V1 reports replication progress
    
    Treat the default IoTConsensus V1 WAL watermark as unconfirmed replication 
in both retention and the staged-directory cleaner. Keep immediate release for 
V2 and protocols without a V1 WAL. Cover commit payload readability, terminal 
task recovery, and cleanup before the first watermark.
---
 .../pipe/agent/task/PipeConfigNodeSubtask.java     |  12 +-
 .../pipe/agent/task/PipeConfigNodeSubtaskTest.java | 150 +++++++++++++++++++++
 .../db/storageengine/load/LoadTaskRetention.java   |  26 ++--
 .../db/storageengine/load/LoadTsFileCleaner.java   |  24 ++--
 .../storageengine/load/LoadTsFileCleanerTest.java  |  42 ++++++
 .../storageengine/load/LoadTsFileManagerTest.java  |  48 ++++++-
 6 files changed, 270 insertions(+), 32 deletions(-)

diff --git 
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/pipe/agent/task/PipeConfigNodeSubtask.java
 
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/pipe/agent/task/PipeConfigNodeSubtask.java
index 95376af638b..9162c792c5a 100644
--- 
a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/pipe/agent/task/PipeConfigNodeSubtask.java
+++ 
b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/pipe/agent/task/PipeConfigNodeSubtask.java
@@ -176,16 +176,22 @@ public class PipeConfigNodeSubtask extends 
PipeAbstractSinkSubtask {
    */
   @Override
   protected boolean executeOnce() throws Exception {
-    if (isClosed.get()) {
+    if (isClosed.get() || shouldStopSubmittingSelf.get()) {
       return false;
     }
 
     final Event event = lastEvent != null ? lastEvent : source.supply();
-    // Record the last event for retry when exception occurs
+    // Keep the event for retry or restart if supply() returns after the task 
is stopped.
     setLastEvent(event);
 
+    // close() may have released the last event before supply() returned.
+    if (isClosed.get()) {
+      clearReferenceCountAndReleaseLastEvent(event);
+      return false;
+    }
+
     try {
-      if (event == null) {
+      if (event == null || shouldStopSubmittingSelf.get()) {
         return false;
       }
 
diff --git 
a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/agent/task/PipeConfigNodeSubtaskTest.java
 
b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/agent/task/PipeConfigNodeSubtaskTest.java
new file mode 100644
index 00000000000..2ca2aca5302
--- /dev/null
+++ 
b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/agent/task/PipeConfigNodeSubtaskTest.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.confignode.manager.pipe.agent.task;
+
+import org.apache.iotdb.commons.consensus.index.impl.MinimumProgressIndex;
+import org.apache.iotdb.commons.pipe.agent.plugin.builtin.BuiltinPipePlugin;
+import org.apache.iotdb.commons.pipe.agent.task.meta.PipeTaskMeta;
+import 
org.apache.iotdb.commons.pipe.agent.task.subtask.PipeAbstractSinkSubtask;
+import org.apache.iotdb.commons.pipe.config.constant.PipeSinkConstant;
+import org.apache.iotdb.commons.pipe.event.EnrichedEvent;
+import org.apache.iotdb.pipe.api.PipeConnector;
+import org.apache.iotdb.pipe.api.PipeExtractor;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+public class PipeConfigNodeSubtaskTest {
+
+  private PipeConfigNodeSubtask subtask;
+  private PipeExtractor source;
+  private PipeConnector sink;
+  private boolean subtaskClosed;
+
+  @Before
+  public void setUp() throws Exception {
+    source = mock(PipeExtractor.class);
+    sink = mock(PipeConnector.class);
+    subtask =
+        new PipeConfigNodeSubtask(
+            "PipeConfigNodeSubtaskTest",
+            System.currentTimeMillis(),
+            Collections.emptyMap(),
+            Collections.emptyMap(),
+            Collections.singletonMap(
+                PipeSinkConstant.CONNECTOR_KEY,
+                BuiltinPipePlugin.DO_NOTHING_CONNECTOR.getPipePluginName()),
+            new PipeTaskMeta(MinimumProgressIndex.INSTANCE, 
Integer.MIN_VALUE));
+    final Field sourceField = 
PipeConfigNodeSubtask.class.getDeclaredField("source");
+    sourceField.setAccessible(true);
+    sourceField.set(subtask, source);
+    final Field sinkField = 
PipeAbstractSinkSubtask.class.getDeclaredField("outputPipeSink");
+    sinkField.setAccessible(true);
+    sinkField.set(subtask, sink);
+  }
+
+  @After
+  public void tearDown() {
+    if (!subtaskClosed) {
+      subtask.close();
+    }
+  }
+
+  @Test
+  public void testStoppedSubtaskDoesNotPollSource() throws Exception {
+    assertFalse(subtask.executeOnce());
+    verifyZeroInteractions(source, sink);
+  }
+
+  @Test
+  public void testStopDuringSupplyRetainsEventUntilRestart() throws Exception {
+    assertStopDuringSupply(false);
+  }
+
+  @Test
+  public void testDropDuringSupplyReleasesEvent() throws Exception {
+    assertStopDuringSupply(true);
+  }
+
+  private void assertStopDuringSupply(final boolean drop) throws Exception {
+    final EnrichedEvent event = mock(EnrichedEvent.class);
+    final CountDownLatch supplyStarted = new CountDownLatch(1);
+    final CountDownLatch supplyReleased = new CountDownLatch(1);
+    final ExecutorService worker = Executors.newSingleThreadExecutor();
+    when(source.supply())
+        .thenAnswer(
+            invocation -> {
+              supplyStarted.countDown();
+              assertTrue(supplyReleased.await(10, TimeUnit.SECONDS));
+              return event;
+            });
+    subtask.allowSubmittingSelf();
+    try {
+      final Future<Boolean> execution = worker.submit(() -> 
subtask.executeOnce());
+      assertTrue(supplyStarted.await(10, TimeUnit.SECONDS));
+      assertTrue(subtask.disallowSubmittingSelf());
+      if (drop) {
+        subtask.close();
+        subtaskClosed = true;
+      }
+      supplyReleased.countDown();
+
+      assertFalse(execution.get(10, TimeUnit.SECONDS));
+      assertFalse(subtask.executeOnce());
+      verify(sink, never()).transfer(event);
+      verify(event, never()).decreaseReferenceCount(anyString(), anyBoolean());
+
+      if (drop) {
+        verify(event).clearReferenceCount(anyString());
+      } else {
+        verifyZeroInteractions(sink);
+        subtask.allowSubmittingSelf();
+        assertTrue(subtask.executeOnce());
+        verify(source).supply();
+        verify(sink).transfer(event);
+        verify(event).decreaseReferenceCount(anyString(), eq(true));
+      }
+    } finally {
+      supplyReleased.countDown();
+      worker.shutdownNow();
+      assertTrue(worker.awaitTermination(10, TimeUnit.SECONDS));
+    }
+  }
+}
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/LoadTaskRetention.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/LoadTaskRetention.java
index 828df844ed7..6197bbccc98 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/LoadTaskRetention.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/LoadTaskRetention.java
@@ -62,14 +62,7 @@ final class LoadTaskRetention {
 
   /** Checks whether the staged directory must be retained for follower WAL 
synchronization. */
   boolean mustRetain(final long searchIndex) {
-    // IoTConsensusV2 replicates through pipe, so no replica reads these bytes 
back out of the WAL
-    // and there is no index to wait for: the staged directory can go as soon 
as the task ends.
-    if (LoadConsensusCompat.usesIoTConsensusV2()) {
-      return false;
-    }
-    final long safelyDeletedSearchIndex = currentSafelyDeletedSearchIndex();
-    return safelyDeletedSearchIndex != 
ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX
-        && safelyDeletedSearchIndex < searchIndex;
+    return currentSafelyDeletedSearchIndex() < searchIndex;
   }
 
   /** Takes ownership of a staged directory, postponing deletion until WAL 
watermarks allow it. */
@@ -122,14 +115,14 @@ final class LoadTaskRetention {
     }
 
     final long safelyDeletedSearchIndex = currentSafelyDeletedSearchIndex();
-    final long boundary =
-        safelyDeletedSearchIndex == 
ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX
-            ? Long.MAX_VALUE
-            : safelyDeletedSearchIndex;
+    // The default V1 watermark means no replica progress has been confirmed 
yet.
+    if (safelyDeletedSearchIndex == 
ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX) {
+      return;
+    }
 
     for (final Map.Entry<String, RetainedTaskDir> entry : 
loadId2RetainedTaskDir.entrySet()) {
       final RetainedTaskDir retained = entry.getValue();
-      if (retained.reachedSearchIndex() <= boundary
+      if (retained.reachedSearchIndex() <= safelyDeletedSearchIndex
           && loadId2RetainedTaskDir.remove(entry.getKey(), retained)) {
         LOGGER.info(
             StorageEngineMessages
@@ -165,10 +158,15 @@ final class LoadTaskRetention {
   }
 
   private long currentSafelyDeletedSearchIndex() {
+    // V2 replicates through pipe. Other protocols without a V1 WAL (for 
example Ratis) do not
+    // expand staged references from this node's WAL either, so neither has a 
watermark to wait for.
+    if (LoadConsensusCompat.usesIoTConsensusV2()) {
+      return Long.MAX_VALUE;
+    }
     return dataRegion
         .getWALNode()
         .map(IWALNode::getSafelyDeletedSearchIndex)
-        .orElse(ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX);
+        .orElse(Long.MAX_VALUE);
   }
 
   // -------------------------------------------------------------------------
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/LoadTsFileCleaner.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/LoadTsFileCleaner.java
index ee440de7790..93a537c1e4a 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/LoadTsFileCleaner.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/LoadTsFileCleaner.java
@@ -350,14 +350,22 @@ public class LoadTsFileCleaner {
   }
 
   private static boolean replicasReached(final DataRegion dataRegion, final 
long searchIndex) {
-    final long safelyDeletedSearchIndex =
-        dataRegion
-            .getWALNode()
-            .map(wal -> wal.getSafelyDeletedSearchIndex())
-            .orElse(ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX);
-
-    return safelyDeletedSearchIndex == 
ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX
-        || safelyDeletedSearchIndex >= searchIndex;
+    // IoTConsensusV2 and consensus implementations without a WAL replicate 
the staged payload
+    // directly, so no follower can read it back from this node's WAL. 
IoTConsensus V1, however,
+    // must report a real watermark before its staged references are safe to 
delete.
+    if (LoadConsensusCompat.usesIoTConsensusV2()) {
+      return true;
+    }
+    return dataRegion
+        .getWALNode()
+        .map(
+            wal -> {
+              final long safelyDeletedSearchIndex = 
wal.getSafelyDeletedSearchIndex();
+              return safelyDeletedSearchIndex
+                      != ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX
+                  && safelyDeletedSearchIndex >= searchIndex;
+            })
+        .orElse(true);
   }
 
   // -------------------------------------------------------------------------
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileCleanerTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileCleanerTest.java
index ca25a96ad4b..01482f2dfe0 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileCleanerTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileCleanerTest.java
@@ -22,6 +22,7 @@ package org.apache.iotdb.db.storageengine.load;
 import org.apache.iotdb.commons.conf.ConfigurationFileUtils;
 import org.apache.iotdb.commons.conf.TrimProperties;
 import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
+import org.apache.iotdb.consensus.iot.log.ConsensusReqReader;
 import org.apache.iotdb.db.conf.IoTDBConfig;
 import org.apache.iotdb.db.conf.IoTDBDescriptor;
 import 
org.apache.iotdb.db.queryengine.plan.planner.plan.node.load.LoadTsFileConsensusNode;
@@ -186,6 +187,47 @@ public class LoadTsFileCleanerTest {
     }
   }
 
+  @Test
+  public void testAFinishedTaskWaitsForTheFirstWatermark() throws Exception {
+    final String loadId = "unreported-watermark-task";
+    final File taskDir = new File(tempDir, loadId);
+    Assert.assertTrue(taskDir.mkdirs());
+    stageOneChunk(taskDir, 5L);
+    final LoadTsFileCleaner cleaner = LoadTsFileCleaner.getInstance();
+    cleaner.register(loadId, dataRegion, taskDir, 7L, 
LoadTsFileConsensusOp.ABORT);
+
+    walWatermark.set(ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX);
+    cleaner.sweepRegisteredTasks();
+    Assert.assertTrue("an unreported watermark does not confirm replication", 
taskDir.exists());
+
+    walWatermark.set(5L);
+    cleaner.sweepRegisteredTasks();
+    Assert.assertTrue("the terminal command is not replicated yet", 
taskDir.exists());
+
+    walWatermark.set(7L);
+    cleaner.sweepRegisteredTasks();
+    Assert.assertFalse("the terminal command is replicated", taskDir.exists());
+  }
+
+  @Test
+  public void testIoTConsensusV2DoesNotWaitForAWALWatermark() throws Exception 
{
+    final String loadId = "v2-finished-task";
+    final File taskDir = new File(tempDir, loadId);
+    Assert.assertTrue(taskDir.mkdirs());
+    stageOneChunk(taskDir, 5L);
+    final LoadTsFileCleaner cleaner = LoadTsFileCleaner.getInstance();
+    cleaner.register(loadId, dataRegion, taskDir, 7L, 
LoadTsFileConsensusOp.ABORT);
+    walWatermark.set(ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX);
+
+    LoadConsensusCompat.setIoTConsensusV2ForTest(Boolean.TRUE);
+    try {
+      cleaner.sweepRegisteredTasks();
+      Assert.assertFalse("V2 does not expand staged references from the WAL", 
taskDir.exists());
+    } finally {
+      LoadConsensusCompat.setIoTConsensusV2ForTest(null);
+    }
+  }
+
   @Test
   public void 
testAFinishedCommittedTaskIsReleasedOnceItsStagedFilesAreComplete() throws 
Exception {
     final String loadId = "finished-task";
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java
index c98b3afa195..360fa83c6af 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/LoadTsFileManagerTest.java
@@ -21,6 +21,7 @@ package org.apache.iotdb.db.storageengine.load;
 
 import org.apache.iotdb.common.rpc.thrift.TTimePartitionSlot;
 import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
+import org.apache.iotdb.consensus.iot.log.ConsensusReqReader;
 import org.apache.iotdb.db.conf.IoTDBConfig;
 import org.apache.iotdb.db.conf.IoTDBDescriptor;
 import org.apache.iotdb.db.exception.load.LoadFileException;
@@ -634,10 +635,8 @@ public class LoadTsFileManagerTest {
   }
 
   @Test
-  public void 
testAbortedConsensusTaskReleasesStagedFilesWhenNoWatermarkIsReported()
-      throws Exception {
-    // Without a consensus watermark (single replica) nothing can read the 
staged bytes back, so the
-    // directory of a finished task is deleted right away.
+  public void testAbortedConsensusTaskReleasesStagedFilesWithoutAWAL() throws 
Exception {
+    // Protocols without a V1 WAL do not expand staged references from the WAL.
     final String loadId = "aborted-load";
     final LoadTsFileManager manager = new LoadTsFileManager(dataRegion);
     final List<LoadTsFileConsensusNode.PieceRef> refs =
@@ -660,8 +659,8 @@ public class LoadTsFileManagerTest {
     try (final WALNode walNode =
         new WALNode("load-retention-test", new File(tempDir, 
"wal").getAbsolutePath())) {
       Mockito.when(dataRegion.getWALNode()).thenReturn(Optional.of(walNode));
-      // The watermark lags behind the command, so a follower may still need 
the staged bytes.
-      walNode.setSafelyDeletedSearchIndex(5L);
+      // Until V1 reports its first watermark, a follower may still need the 
staged bytes.
+      
walNode.setSafelyDeletedSearchIndex(ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX);
 
       final LoadTsFileManager manager = new LoadTsFileManager(dataRegion);
       final String loadId = "retained-load";
@@ -682,6 +681,10 @@ public class LoadTsFileManagerTest {
       assertTrue(progress.isFile());
       assertNotNull(LoadTsFileProgress.readTerminal(progress));
 
+      // A reported watermark that still lags behind the command must keep the 
staged bytes too.
+      walNode.setSafelyDeletedSearchIndex(5L);
+      assertTrue(staged.isFile());
+
       // Once every follower got past the command, the directory is released.
       walNode.setSafelyDeletedSearchIndex(10L);
       assertFalse(staged.exists());
@@ -708,6 +711,27 @@ public class LoadTsFileManagerTest {
     assertFalse(manager.prepare(prepareNode("finished-load"), 
Collections.emptyMap()));
   }
 
+  @Test
+  public void testRecoveredFinishedTaskWaitsForTheFirstWatermark() throws 
Exception {
+    
useWatermarkWALNode(ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX);
+    final File taskDir = new File(tempDir, 
"root.load_manager_test-0/recovered-finished-load");
+    assertTrue(taskDir.mkdirs());
+    final File stagedFile = new File(taskDir, "123.tsfile");
+    Files.write(stagedFile.toPath(), new byte[] {1, 2, 3});
+    new 
LoadTsFileProgress(stagedFile).recordTerminal(LoadTsFileConsensusOp.ABORT, 7L);
+
+    final LoadTsFileManager manager = new LoadTsFileManager(dataRegion);
+    try {
+      assertTrue("a restart must keep bytes until replication is confirmed", 
stagedFile.isFile());
+      assertFalse(manager.prepare(prepareNode(taskDir.getName()), 
Collections.emptyMap()));
+
+      advanceWatermark(7L);
+      assertFalse(taskDir.exists());
+    } finally {
+      manager.stop();
+    }
+  }
+
   @Test
   public void testIoTConsensusV2WaitsForNoIndexBeforeReleasing() {
     LoadConsensusCompat.setIoTConsensusV2ForTest(Boolean.TRUE);
@@ -778,7 +802,17 @@ public class LoadTsFileManagerTest {
   public void testCommitKeepsTheStagedBytesWhileAReplicaMayStillNeedThem() 
throws Exception {
     // The watermark lags behind the COMMIT command, so a replica that catches 
up from this node's
     // WAL still has to expand the pieces of the task from the staging 
directory.
-    useWatermarkWALNode(5L);
+    assertCommitKeepsTheStagedBytes(5L);
+  }
+
+  @Test
+  public void testCommitKeepsTheStagedBytesBeforeTheFirstWatermark() throws 
Exception {
+    
assertCommitKeepsTheStagedBytes(ConsensusReqReader.DEFAULT_SAFELY_DELETED_SEARCH_INDEX);
+  }
+
+  private void assertCommitKeepsTheStagedBytes(final long 
safelyDeletedSearchIndex)
+      throws Exception {
+    useWatermarkWALNode(safelyDeletedSearchIndex);
     emulateStagedFileImport();
 
     final String loadId = "committed-load";

Reply via email to