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 576d6a7b3cf [Fix] Harden split LOAD recovery, deletion dispatch and 
memory bounds (#18930)
576d6a7b3cf is described below

commit 576d6a7b3cf48c3e912a4e598c54e99ddaad7856
Author: Caideyipi <[email protected]>
AuthorDate: Tue Oct 20 19:19:01 2026 +0800

    [Fix] Harden split LOAD recovery, deletion dispatch and memory bounds 
(#18930)
    
    * Fix LOAD partition recovery and PREPARE completeness
    
    * Fix LOAD recovery, deletion delivery and buffer eviction
---
 .../plan/scheduler/load/PieceDispatcher.java       |  63 ++---
 .../plan/scheduler/load/TsFileSplitConsumer.java   |  15 +-
 .../db/storageengine/load/TsFileWriterManager.java |  98 +++++---
 .../load/splitter/TsFileSplitter.java              |   8 +-
 .../write/TsFilePrecalculatedChunkWriter.java      |   8 +-
 .../plan/scheduler/load/PieceDispatcherTest.java   | 146 ++++++++++++
 .../scheduler/load/TsFileSplitConsumerTest.java    | 233 +++++++++++++++++++
 .../db/storageengine/load/TsFileSplitterTest.java  |  41 ++++
 .../load/TsFileWriterManagerRecoveryTest.java      | 254 +++++++++++++++++++++
 9 files changed, 805 insertions(+), 61 deletions(-)

diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/PieceDispatcher.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/PieceDispatcher.java
index 970b337b9ca..a56423ec2f8 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/PieceDispatcher.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/PieceDispatcher.java
@@ -35,10 +35,14 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.util.Comparator;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.NavigableSet;
 import java.util.Objects;
-import java.util.PriorityQueue;
+import java.util.Set;
+import java.util.TreeSet;
 
 /**
  * Buffers chunks and deletions of one source TsFile per target region 
partition, managing
@@ -63,9 +67,13 @@ class PieceDispatcher {
   private final Map<PartitionKey, ChunkOffsetCalculator> 
partition2OffsetCalculator =
       new HashMap<>();
 
-  /** Max-heap tracking (partitionKey, bufferedSize) for memory budget 
eviction. */
-  private final PriorityQueue<HeapEntry> largestPieceRegions =
-      new PriorityQueue<>((a, b) -> Long.compare(b.dataSize(), a.dataSize()));
+  /** One ordered entry per non-empty partition, so tracking stays bounded as 
chunks stream in. */
+  private final NavigableSet<PieceSize> largestPieceRegions =
+      new TreeSet<>(
+          Comparator.comparingLong(PieceSize::dataSize)
+              .reversed()
+              .thenComparing(entry -> entry.key().regionId())
+              .thenComparingLong(entry -> entry.key().timePartitionStart()));
 
   PieceDispatcher(
       final LoadSingleTsFileNode singleTsFileNode,
@@ -116,23 +124,29 @@ class PieceDispatcher {
         partition2ReplicaSetAndNode.computeIfAbsent(
             key, k -> new Pair<>(replicaSet, createPieceNode(k)));
 
+    largestPieceRegions.remove(new PieceSize(key, 
targetPair.getRight().getDataSize()));
     targetPair.getRight().addTsFileData(chunkData);
-    trackHeapEntry(key, targetPair.getRight().getDataSize());
+    trackPieceSize(key, targetPair.getRight().getDataSize());
   }
 
-  /** Broadcasts deletion data across every active partition buffer. */
-  void addDeletionToAll(final DeletionData deletionData) {
+  /** Buffers one copy per region; its receiver applies the deletion to all 
staged partitions. */
+  void addDeletionToAllRegions(final DeletionData deletionData) {
     if (deletionData == null || partition2ReplicaSetAndNode.isEmpty()) {
       return;
     }
 
     final long deletionSize = deletionData.getDataSize();
+    final Set<TConsensusGroupId> regions = new HashSet<>();
     for (final Map.Entry<PartitionKey, Pair<TRegionReplicaSet, 
LoadTsFilePieceNode>> entry :
         partition2ReplicaSetAndNode.entrySet()) {
+      if (!regions.add(entry.getKey().regionId())) {
+        continue;
+      }
       memoryBuffer.add(deletionSize);
       final LoadTsFilePieceNode pieceNode = entry.getValue().getRight();
+      largestPieceRegions.remove(new PieceSize(entry.getKey(), 
pieceNode.getDataSize()));
       pieceNode.addTsFileData(deletionData);
-      trackHeapEntry(entry.getKey(), pieceNode.getDataSize());
+      trackPieceSize(entry.getKey(), pieceNode.getDataSize());
     }
   }
 
@@ -143,7 +157,7 @@ class PieceDispatcher {
   /** Progressively dispatches largest buffered pieces until memory usage is 
back within budget. */
   boolean dispatchLargestUntilMemoryEnough() throws LoadFileException {
     while (!memoryBuffer.isMemoryEnough()) {
-      final PartitionKey key = pollLargestValidPartition();
+      final PartitionKey key = pollLargestPartition();
       if (key == null) {
         break;
       }
@@ -158,6 +172,7 @@ class PieceDispatcher {
       final long pieceSize = pieceNode.getDataSize();
 
       if (!dispatchCallback.dispatch(pieceNode, pair.getLeft())) {
+        trackPieceSize(key, pieceSize);
         return false;
       }
 
@@ -192,10 +207,12 @@ class PieceDispatcher {
   }
 
   // -------------------------------------------------------------------------
-  // Helpers & Eviction Heap Management
+  // Helpers & Eviction Tracking
   // -------------------------------------------------------------------------
 
   private void replacePieceNode(final PartitionKey key, final 
TRegionReplicaSet replicaSet) {
+    largestPieceRegions.remove(
+        new PieceSize(key, 
partition2ReplicaSetAndNode.get(key).getRight().getDataSize()));
     partition2ReplicaSetAndNode.put(key, new Pair<>(replicaSet, 
createPieceNode(key)));
   }
 
@@ -208,25 +225,15 @@ class PieceDispatcher {
         pieceIndex);
   }
 
-  private void trackHeapEntry(final PartitionKey key, final long size) {
-    largestPieceRegions.offer(new HeapEntry(key, size));
+  private void trackPieceSize(final PartitionKey key, final long size) {
+    if (size > 0) {
+      largestPieceRegions.add(new PieceSize(key, size));
+    }
   }
 
-  private PartitionKey pollLargestValidPartition() {
-    while (!largestPieceRegions.isEmpty()) {
-      final HeapEntry entry = largestPieceRegions.poll();
-      final Pair<TRegionReplicaSet, LoadTsFilePieceNode> pair =
-          partition2ReplicaSetAndNode.get(entry.key());
-      if (pair == null) {
-        continue;
-      }
-
-      final long actualSize = pair.getRight().getDataSize();
-      if (entry.dataSize() == actualSize && actualSize > 0) {
-        return entry.key();
-      }
-    }
-    return null;
+  private PartitionKey pollLargestPartition() {
+    final PieceSize entry = largestPieceRegions.pollFirst();
+    return entry == null ? null : entry.key();
   }
 
   void clear() {
@@ -242,5 +249,5 @@ class PieceDispatcher {
 
   private record PartitionKey(TConsensusGroupId regionId, long 
timePartitionStart) {}
 
-  private record HeapEntry(PartitionKey key, long dataSize) {}
+  private record PieceSize(PartitionKey key, long dataSize) {}
 }
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TsFileSplitConsumer.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TsFileSplitConsumer.java
index 0481d0ce42d..fcb704431f3 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TsFileSplitConsumer.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TsFileSplitConsumer.java
@@ -49,6 +49,7 @@ public class TsFileSplitConsumer implements 
TsFileSplitter.TsFileDataConsumer {
   private final Consumer<TTimePartitionSlot> progressIndexCallback;
 
   private final List<ChunkData> nonDirectionalChunkData = new ArrayList<>();
+  private boolean isProcessingDeletions;
 
   public TsFileSplitConsumer(
       final LoadSingleTsFileNode singleTsFileNode,
@@ -111,10 +112,17 @@ public class TsFileSplitConsumer implements 
TsFileSplitter.TsFileDataConsumer {
   }
 
   private boolean addOrSendDeletionData(final DeletionData deletionData) 
throws LoadFileException {
-    // Route pending chunks first to ensure deletions never precede the chunks 
they apply to
+    // The receiver broadcasts each deletion to all staged partitions of its 
region. Stage all
+    // chunks first, then send each deletion once per region, including 
partitions already evicted.
     routeChunkData();
-    dispatcher.addDeletionToAll(deletionData);
-    return true;
+    if (!isProcessingDeletions) {
+      if (!dispatcher.flushAll()) {
+        return false;
+      }
+      isProcessingDeletions = true;
+    }
+    dispatcher.addDeletionToAllRegions(deletionData);
+    return dispatcher.dispatchLargestUntilMemoryEnough();
   }
 
   // -------------------------------------------------------------------------
@@ -157,6 +165,7 @@ public class TsFileSplitConsumer implements 
TsFileSplitter.TsFileDataConsumer {
   /** Drops all unrouted chunks, clears partition buffers, and releases cached 
memory accounting. */
   void clear() {
     nonDirectionalChunkData.clear();
+    isProcessingDeletions = false;
     dispatcher.clear();
     memoryBuffer.clear();
   }
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/TsFileWriterManager.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/TsFileWriterManager.java
index d3d9efb1345..d72fd02e54b 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/TsFileWriterManager.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/TsFileWriterManager.java
@@ -45,6 +45,7 @@ import 
org.apache.iotdb.db.storageengine.load.splitter.ChunkPayloadRef;
 import org.apache.iotdb.db.storageengine.load.splitter.DeletionData;
 import org.apache.iotdb.db.storageengine.load.splitter.TsFileData;
 
+import org.apache.tsfile.common.conf.TSFileConfig;
 import org.apache.tsfile.common.constant.TsFileConstant;
 import org.apache.tsfile.enums.TSDataType;
 import org.apache.tsfile.exception.write.PageException;
@@ -54,6 +55,7 @@ import org.apache.tsfile.file.metadata.IChunkMetadata;
 import org.apache.tsfile.file.metadata.IDeviceID;
 import org.apache.tsfile.file.metadata.statistics.Statistics;
 import org.apache.tsfile.read.TimeValuePair;
+import org.apache.tsfile.read.TsFileSequenceReader;
 import org.apache.tsfile.read.common.Chunk;
 import org.apache.tsfile.utils.Pair;
 import org.apache.tsfile.utils.RamUsageEstimator;
@@ -71,10 +73,12 @@ import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
@@ -466,6 +470,37 @@ final class TsFileWriterManager {
   // Consensus Lifecycle: PREPARE & LOAD
   // -------------------------------------------------------------------------
 
+  private void validateStagedFiles(
+      final Map<TTimePartitionSlot, Long> timePartition2ExpectedDataEnd)
+      throws IOException, LoadFileException {
+    final Set<TTimePartitionSlot> partitions = new 
HashSet<>(timePartition2Writer.keySet());
+    if (timePartition2ExpectedDataEnd != null) {
+      partitions.addAll(timePartition2ExpectedDataEnd.keySet());
+    }
+    for (final TTimePartitionSlot partition : partitions) {
+      final TsFilePrecalculatedChunkWriter writer = 
timePartition2Writer.get(partition);
+      final File stagedFile =
+          writer == null
+              ? new File(taskDir, stagedFileName(partition) + 
TsFileConstant.TSFILE_SUFFIX)
+              : writer.getFile();
+      final LoadTsFileProgress progress = 
timePartition2Progress.get(partition);
+      final long fileLength = stagedFile.length();
+      final long expectedDataEnd = 
expectedDataEndOf(timePartition2ExpectedDataEnd, partition);
+      if (writer == null
+          || progress == null
+          || !progress.isReady(fileLength)
+          || (expectedDataEnd >= 0L && fileLength < expectedDataEnd)) {
+        throw new LoadFileException(
+            String.format(
+                
StorageEngineMessages.EXCEPTION_LOAD_CONSENSUS_STAGED_FILE_NOT_CONTINUOUS_F9408C19,
+                stagedFile.getAbsolutePath(),
+                uuidDirName(stagedFile),
+                Math.max(progress == null ? 0L : progress.getTotalLength(), 
expectedDataEnd),
+                fileLength));
+      }
+    }
+  }
+
   /**
    * Seals modification files, validates chunk continuity, and seals TsFiles 
in the PREPARE round.
    */
@@ -477,12 +512,16 @@ final class TsFileWriterManager {
     checkNotClosed();
     touch();
 
-    // 1. Close modification files first to freeze deletion views
+    // Validate every announced partition before sealing any file. A missing 
partition or a file
+    // without durable chunk records must not turn into a successful partial 
import.
+    validateStagedFiles(timePartition2ExpectedDataEnd);
+
+    // Close modification files to freeze deletion views.
     for (final ModificationFile modFile : 
timePartition2ModificationFile.values()) {
       modFile.close();
     }
 
-    // 2. Validate continuity and seal writer metadata zone
+    // Seal writer metadata zones only after every partition has passed 
validation.
     for (final Map.Entry<TTimePartitionSlot, TsFilePrecalculatedChunkWriter> 
entry :
         timePartition2Writer.entrySet()) {
       final TsFilePrecalculatedChunkWriter writer = entry.getValue();
@@ -490,23 +529,6 @@ final class TsFileWriterManager {
         continue;
       }
 
-      final LoadTsFileProgress progress = 
timePartition2Progress.get(entry.getKey());
-      if (progress != null && progress.exists() && progress.getTotalLength() > 
0) {
-        final long fileLength = writer.getFile().length();
-        final long expectedDataEnd =
-            expectedDataEndOf(timePartition2ExpectedDataEnd, entry.getKey());
-        if (!progress.isReady(fileLength)
-            || (expectedDataEnd >= 0L && fileLength < expectedDataEnd)) {
-          throw new LoadFileException(
-              String.format(
-                  StorageEngineMessages
-                      
.EXCEPTION_LOAD_CONSENSUS_STAGED_FILE_NOT_CONTINUOUS_F9408C19,
-                  writer.getFile().getAbsolutePath(),
-                  uuidDirName(writer.getFile()),
-                  Math.max(progress.getTotalLength(), expectedDataEnd),
-                  fileLength));
-        }
-      }
       // Rebuild from the staged devices so recovered writers also retain the 
table schemas that
       // Pipe needs to enumerate table data in the sealed file.
       if (PathUtils.isTableModelDatabase(dataRegion.getDatabaseName())) {
@@ -721,7 +743,21 @@ final class TsFileWriterManager {
       final TTimePartitionSlot timePartitionSlot =
           new TTimePartitionSlot(parseTimePartitionStart(tsFile.getName()));
       final long resumeOffset = progress.getTotalLength();
-      if (tsFile.length() > resumeOffset) {
+      final TsFileResource resource = new TsFileResource(tsFile);
+      boolean sealed = false;
+      if (resource.resourceFileExists()) {
+        try (final TsFileSequenceReader reader =
+            new TsFileSequenceReader(tsFile.getAbsolutePath(), false)) {
+          sealed = TSFileConfig.MAGIC_STRING.equals(reader.readTailMagic());
+        }
+        if (sealed) {
+          resource.deserialize();
+          resource.setStatus(TsFileResourceStatus.NORMAL);
+        }
+      }
+      // A resource sidecar and a completed footer record a successful 
PREPARE. Its coordinator
+      // may send COMMIT immediately after recovery, so preserve both the 
sealed bytes and marks.
+      if (!sealed && tsFile.length() > resumeOffset) {
         try (final FileChannel truncatingChannel =
             FileChannel.open(tsFile.toPath(), StandardOpenOption.READ, 
StandardOpenOption.WRITE)) {
           truncatingChannel.truncate(resumeOffset);
@@ -745,15 +781,15 @@ final class TsFileWriterManager {
       try {
         channel =
             FileChannel.open(tsFile.toPath(), StandardOpenOption.READ, 
StandardOpenOption.WRITE);
-        channel.position(resumeOffset);
+        channel.position(sealed ? tsFile.length() : resumeOffset);
         final TsFilePrecalculatedChunkWriter writer =
-            new TsFilePrecalculatedChunkWriter(tsFile, channel);
+            new TsFilePrecalculatedChunkWriter(tsFile, channel, sealed);
         writer.setRejectHoles(LoadConsensusCompat.usesIoTConsensusV2());
         writer.restoreChunkMetadata(restoreChunkMetadata(records));
         publishedLengths.put(tsFile, tsFile.length());
 
         timePartition2Writer.put(timePartitionSlot, writer);
-        timePartition2Resource.put(timePartitionSlot, new 
TsFileResource(tsFile));
+        timePartition2Resource.put(timePartitionSlot, resource);
         timePartition2Progress.put(timePartitionSlot, progress);
 
         final File modificationFile = 
ModificationFile.getExclusiveMods(tsFile);
@@ -928,11 +964,15 @@ final class TsFileWriterManager {
 
   /** The name a staged file of this region and time partition has. */
   private String stagedFileName(final TTimePartitionSlot timePartitionSlot) {
+    return stagedFilePrefix() + timePartitionSlot.getStartTime();
+  }
+
+  private String stagedFilePrefix() {
     return String.join(
         IoTDBConstant.FILE_NAME_SEPARATOR,
         dataRegion.getDatabaseName(),
         dataRegion.getDataRegionIdString(),
-        Long.toString(timePartitionSlot.getStartTime()));
+        "");
   }
 
   private static String uuidDirName(final File tsFile) {
@@ -940,10 +980,12 @@ final class TsFileWriterManager {
     return parent == null ? tsFile.getName() : parent.getName();
   }
 
-  private static long parseTimePartitionStart(final String tsFileName) {
-    final String baseName = tsFileName.substring(0, 
tsFileName.lastIndexOf('.'));
-    final int lastDash = 
baseName.lastIndexOf(IoTDBConstant.FILE_NAME_SEPARATOR);
-    return Long.parseLong(baseName.substring(lastDash + 1));
+  private long parseTimePartitionStart(final String tsFileName) {
+    // The partition may be negative: its sign is part of the number, not a 
name separator.
+    return Long.parseLong(
+        tsFileName.substring(
+            stagedFilePrefix().length(),
+            tsFileName.length() - TsFileConstant.TSFILE_SUFFIX.length()));
   }
 
   /** The bytes this piece added to each staged file, as the refs a replica 
reads them back with. */
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java
 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java
index 16109c64de2..9b17746f942 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/storageengine/load/splitter/TsFileSplitter.java
@@ -24,6 +24,7 @@ import org.apache.iotdb.commons.utils.TimePartitionUtils;
 import org.apache.iotdb.db.conf.IoTDBConfig;
 import org.apache.iotdb.db.conf.IoTDBDescriptor;
 import org.apache.iotdb.db.exception.load.LoadFileException;
+import org.apache.iotdb.db.i18n.DataNodeQueryMessages;
 import org.apache.iotdb.db.i18n.StorageEngineMessages;
 import org.apache.iotdb.db.storageengine.dataregion.modification.ModEntry;
 import 
org.apache.iotdb.db.storageengine.dataregion.modification.ModificationFile;
@@ -455,7 +456,12 @@ public class TsFileSplitter {
 
   private void handleModification(List<ModEntry> deletions) throws 
LoadFileException {
     for (final ModEntry mod : deletions) {
-      consumer.apply(new DeletionData(mod));
+      if (!consumer.apply(new DeletionData(mod))) {
+        throw new IllegalStateException(
+            String.format(
+                
DataNodeQueryMessages.DISPATCH_TSFILEDATA_ERROR_WHEN_PARSING_TSFILE_S,
+                tsFile.getPath()));
+      }
     }
   }
 
diff --git 
a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java
 
b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java
index b48b7d14cb1..7e375d9fe70 100644
--- 
a/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java
+++ 
b/iotdb-core/datanode/src/main/java/org/apache/tsfile/write/TsFilePrecalculatedChunkWriter.java
@@ -109,12 +109,18 @@ public class TsFilePrecalculatedChunkWriter implements 
AutoCloseable {
 
   public TsFilePrecalculatedChunkWriter(final File file, final FileChannel 
channel)
       throws IOException {
+    this(file, channel, false);
+  }
+
+  /** Resumes a staged file, preserving a completed PREPARE when it is already 
sealed. */
+  public TsFilePrecalculatedChunkWriter(
+      final File file, final FileChannel channel, final boolean sealed) throws 
IOException {
     this.file = file;
     this.out =
         new WritableFileChannelOutput(
             Objects.requireNonNull(
                 channel, 
StorageEngineMessages.EXCEPTION_CHANNEL_CANNOT_BE_NULL_0F79E0FB));
-    this.sealed = false;
+    this.sealed = sealed;
     this.maxPhysicalFileSize = channel.size();
     LOGGER.info(
         StorageEngineMessages
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/PieceDispatcherTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/PieceDispatcherTest.java
new file mode 100644
index 00000000000..b94ef5aa07b
--- /dev/null
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/PieceDispatcherTest.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.queryengine.plan.scheduler.load;
+
+import org.apache.iotdb.common.rpc.thrift.TConsensusGroupId;
+import org.apache.iotdb.common.rpc.thrift.TConsensusGroupType;
+import org.apache.iotdb.common.rpc.thrift.TRegionReplicaSet;
+import org.apache.iotdb.common.rpc.thrift.TTimePartitionSlot;
+import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
+import 
org.apache.iotdb.db.queryengine.plan.planner.plan.node.load.LoadSingleTsFileNode;
+import org.apache.iotdb.db.storageengine.dataregion.tsfile.TsFileResource;
+import org.apache.iotdb.db.storageengine.load.splitter.NonAlignedChunkData;
+
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.file.header.ChunkHeader;
+import org.apache.tsfile.file.metadata.StringArrayDeviceID;
+import org.apache.tsfile.file.metadata.enums.CompressionType;
+import org.apache.tsfile.file.metadata.enums.TSEncoding;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.lang.reflect.Field;
+import java.util.Collection;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+
+public class PieceDispatcherTest {
+
+  @Test
+  public void testEvictionQueueStaysBoundedAcrossChunksAndFlushes() throws 
Exception {
+    final MemoryBoundedBuffer buffer = Mockito.mock(MemoryBoundedBuffer.class);
+    final AtomicLong usedBytes = new AtomicLong();
+    final long budget = chunk(0L).getDataSize() * 5;
+    when(buffer.isMemoryEnough()).thenAnswer(ignored -> usedBytes.get() <= 
budget);
+    Mockito.doAnswer(
+            invocation -> {
+              usedBytes.addAndGet(-invocation.<Long>getArgument(0));
+              return null;
+            })
+        .when(buffer)
+        .release(anyLong());
+    final AtomicInteger dispatchedChunks = new AtomicInteger();
+    final PieceDispatcher dispatcher =
+        new PieceDispatcher(
+            loadNode(),
+            buffer,
+            (piece, region) -> {
+              dispatchedChunks.addAndGet(piece.getAllTsFileData().size());
+              return true;
+            });
+    final TRegionReplicaSet replicaSet =
+        new TRegionReplicaSet()
+            .setRegionId(new TConsensusGroupId(TConsensusGroupType.DataRegion, 
1));
+    final Field queueField = 
PieceDispatcher.class.getDeclaredField("largestPieceRegions");
+    queueField.setAccessible(true);
+
+    for (int i = 0; i < 128; i++) {
+      final NonAlignedChunkData chunk = chunk(i % 2);
+      usedBytes.addAndGet(chunk.getDataSize());
+      dispatcher.offerChunk(chunk, replicaSet);
+      assertTrue(dispatcher.dispatchLargestUntilMemoryEnough());
+      assertTrue(
+          "Eviction tracking must be bounded by the two live partition 
buffers",
+          ((Collection<?>) queueField.get(dispatcher)).size() <= 2);
+    }
+    assertTrue(dispatcher.flushAll());
+    assertEquals(128, dispatchedChunks.get());
+    assertEquals(0L, usedBytes.get());
+    assertTrue(
+        "Flushed pieces must leave no eviction entries",
+        ((Collection<?>) queueField.get(dispatcher)).isEmpty());
+  }
+
+  @Test
+  public void testFailedEvictionCanBeRetried() throws Exception {
+    final MemoryBoundedBuffer buffer = Mockito.mock(MemoryBoundedBuffer.class);
+    final AtomicBoolean memoryEnough = new AtomicBoolean(false);
+    when(buffer.isMemoryEnough()).thenAnswer(ignored -> memoryEnough.get());
+    Mockito.doAnswer(
+            ignored -> {
+              memoryEnough.set(true);
+              return null;
+            })
+        .when(buffer)
+        .release(anyLong());
+    final AtomicInteger attempts = new AtomicInteger();
+    final PieceDispatcher dispatcher =
+        new PieceDispatcher(loadNode(), buffer, (piece, region) -> 
attempts.incrementAndGet() > 1);
+    final NonAlignedChunkData chunk = chunk(0L);
+    dispatcher.offerChunk(
+        chunk,
+        new TRegionReplicaSet()
+            .setRegionId(new TConsensusGroupId(TConsensusGroupType.DataRegion, 
1)));
+
+    assertFalse(dispatcher.dispatchLargestUntilMemoryEnough());
+    assertTrue(dispatcher.dispatchLargestUntilMemoryEnough());
+    assertEquals(2, attempts.get());
+    Mockito.verify(buffer).release(chunk.getDataSize());
+  }
+
+  private static LoadSingleTsFileNode loadNode() {
+    final LoadSingleTsFileNode node = Mockito.mock(LoadSingleTsFileNode.class);
+    when(node.getPlanNodeId()).thenReturn(new PlanNodeId("bounded-queue"));
+    final TsFileResource resource = Mockito.mock(TsFileResource.class);
+    when(resource.getTsFile()).thenReturn(new File("bounded-queue.tsfile"));
+    when(node.getTsFileResource()).thenReturn(resource);
+    return node;
+  }
+
+  private static NonAlignedChunkData chunk(final long partition) {
+    final NonAlignedChunkData chunk =
+        new NonAlignedChunkData(
+            new StringArrayDeviceID("root", "test", "d1"),
+            new ChunkHeader(
+                "s1", 0, TSDataType.INT32, CompressionType.UNCOMPRESSED, 
TSEncoding.PLAIN, 0),
+            new TTimePartitionSlot(partition));
+    chunk.writeDecodePage(new long[] {partition + 1}, new Object[] {1}, 1);
+    chunk.endChunk();
+    return chunk;
+  }
+}
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TsFileSplitConsumerTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TsFileSplitConsumerTest.java
new file mode 100644
index 00000000000..93bf7dc4c7b
--- /dev/null
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/scheduler/load/TsFileSplitConsumerTest.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.iotdb.db.queryengine.plan.scheduler.load;
+
+import org.apache.iotdb.common.rpc.thrift.TConsensusGroupId;
+import org.apache.iotdb.common.rpc.thrift.TConsensusGroupType;
+import org.apache.iotdb.common.rpc.thrift.TRegionReplicaSet;
+import org.apache.iotdb.common.rpc.thrift.TTimePartitionSlot;
+import org.apache.iotdb.commons.path.MeasurementPath;
+import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
+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.LoadSingleTsFileNode;
+import org.apache.iotdb.db.storageengine.dataregion.DataRegion;
+import org.apache.iotdb.db.storageengine.dataregion.modification.ModEntry;
+import 
org.apache.iotdb.db.storageengine.dataregion.modification.ModificationFile;
+import 
org.apache.iotdb.db.storageengine.dataregion.modification.TreeDeletionEntry;
+import org.apache.iotdb.db.storageengine.dataregion.tsfile.TsFileResource;
+import org.apache.iotdb.db.storageengine.load.LoadTsFileManager;
+import 
org.apache.iotdb.db.storageengine.load.memory.LoadTsFileDataCacheMemoryBlock;
+import org.apache.iotdb.db.storageengine.load.splitter.DeletionData;
+import org.apache.iotdb.db.storageengine.load.splitter.NonAlignedChunkData;
+
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.file.header.ChunkHeader;
+import org.apache.tsfile.file.metadata.StringArrayDeviceID;
+import org.apache.tsfile.file.metadata.enums.CompressionType;
+import org.apache.tsfile.file.metadata.enums.TSEncoding;
+import org.apache.tsfile.utils.Pair;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.lang.reflect.Constructor;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+public class TsFileSplitConsumerTest {
+
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void testDeletionThatExceedsMemoryBudgetTriggersDispatch() throws 
Exception {
+    final int memoryBudget = 
IoTDBDescriptor.getInstance().getConfig().getThriftMaxFrameSize() >> 2;
+    final Constructor<LoadTsFileDataCacheMemoryBlock> constructor =
+        
LoadTsFileDataCacheMemoryBlock.class.getDeclaredConstructor(long.class);
+    constructor.setAccessible(true);
+    final LoadTsFileDataCacheMemoryBlock memoryBlock = 
constructor.newInstance(1024 * 1024L);
+
+    final TRegionReplicaSet replicaSet =
+        new TRegionReplicaSet()
+            .setRegionId(new TConsensusGroupId(TConsensusGroupType.DataRegion, 
1));
+    final DataPartitionBatchFetcher partitionFetcher =
+        Mockito.mock(DataPartitionBatchFetcher.class);
+    when(partitionFetcher.queryDataPartition(anyList(), anyString()))
+        .thenReturn(List.of(replicaSet));
+
+    final LoadSingleTsFileNode loadNode = 
Mockito.mock(LoadSingleTsFileNode.class);
+    when(loadNode.getPlanNodeId()).thenReturn(new 
PlanNodeId("load-memory-budget"));
+    final TsFileResource resource = Mockito.mock(TsFileResource.class);
+    when(resource.getTsFile()).thenReturn(new 
File("load-memory-budget.tsfile"));
+    when(loadNode.getTsFileResource()).thenReturn(resource);
+
+    final AtomicInteger dispatchedPieces = new AtomicInteger();
+    final TsFileSplitConsumer consumer =
+        new TsFileSplitConsumer(
+            loadNode,
+            memoryBlock,
+            new DataPartitionResolver(partitionFetcher, "root.test"),
+            null,
+            (pieceNode, targetReplicaSet) -> {
+              if 
(pieceNode.getAllTsFileData().stream().anyMatch(DeletionData.class::isInstance))
 {
+                dispatchedPieces.incrementAndGet();
+              }
+              return true;
+            });
+
+    final NonAlignedChunkData chunk =
+        new NonAlignedChunkData(
+            new StringArrayDeviceID("root", "test", "d1"),
+            new ChunkHeader(
+                "s1", 0, TSDataType.INT32, CompressionType.UNCOMPRESSED, 
TSEncoding.PLAIN, 0),
+            new TTimePartitionSlot(0L));
+    chunk.writeDecodePage(new long[] {1L}, new Object[] {1}, 1);
+    chunk.endChunk();
+
+    final ModEntry deletion = Mockito.mock(ModEntry.class);
+    when(deletion.serializedSize()).thenReturn(memoryBudget + 1);
+
+    try {
+      Assert.assertTrue(consumer.apply(chunk));
+      Assert.assertEquals(0, dispatchedPieces.get());
+
+      Assert.assertTrue(consumer.apply(new DeletionData(deletion)));
+
+      Assert.assertEquals(
+          "A deletion must trigger largest-first dispatch when its buffered 
piece exceeds budget",
+          1,
+          dispatchedPieces.get());
+    } finally {
+      consumer.clear();
+    }
+  }
+
+  @Test
+  public void testDeletionsAreSentOncePerRegionAfterAllPartitions() throws 
Exception {
+    final IoTDBConfig config = IoTDBDescriptor.getInstance().getConfig();
+    final String[] originalLoadDirs = config.getLoadTsFileDirs();
+    final File loadDir = temporaryFolder.newFolder("load");
+    config.setLoadTsFileDirs(new String[] {loadDir.getAbsolutePath()});
+    final LoadTsFileManager firstRegion = manager(1);
+    final LoadTsFileManager secondRegion = manager(2);
+    final Map<Integer, LoadTsFileManager> managers = Map.of(1, firstRegion, 2, 
secondRegion);
+    final TRegionReplicaSet firstReplicaSet = replicaSet(1);
+    final TRegionReplicaSet secondReplicaSet = replicaSet(2);
+    final DataPartitionBatchFetcher partitionFetcher =
+        Mockito.mock(DataPartitionBatchFetcher.class);
+    when(partitionFetcher.queryDataPartition(anyList(), anyString()))
+        .thenAnswer(
+            invocation -> {
+              final List<Pair<org.apache.tsfile.file.metadata.IDeviceID, 
TTimePartitionSlot>>
+                  slots = invocation.getArgument(0);
+              return slots.stream()
+                  .map(
+                      slot ->
+                          slot.right.getStartTime() < 2000L ? firstReplicaSet 
: secondReplicaSet)
+                  .toList();
+            });
+    final Constructor<LoadTsFileDataCacheMemoryBlock> constructor =
+        
LoadTsFileDataCacheMemoryBlock.class.getDeclaredConstructor(long.class);
+    constructor.setAccessible(true);
+    final TsFileSplitConsumer consumer =
+        new TsFileSplitConsumer(
+            loadNode(),
+            constructor.newInstance(1024 * 1024L),
+            new DataPartitionResolver(partitionFetcher, "root"),
+            null,
+            (piece, target) -> {
+              try {
+                managers
+                    .get(target.getRegionId().getId())
+                    .writePiece("load-deletions", piece.getAllTsFileData());
+                return true;
+              } catch (Exception e) {
+                throw new AssertionError(e);
+              }
+            });
+    final ModEntry firstDeletion =
+        new TreeDeletionEntry(new MeasurementPath("root.test.d1.s1"), 0L, 10L);
+    final ModEntry secondDeletion =
+        new TreeDeletionEntry(new MeasurementPath("root.test.d1.s1"), 20L, 
30L);
+    try {
+      for (long partition : new long[] {0L, 1000L, 2000L}) {
+        Assert.assertTrue(consumer.apply(chunk(partition)));
+      }
+      Assert.assertTrue(consumer.apply(new DeletionData(firstDeletion)));
+      Assert.assertTrue(consumer.apply(new DeletionData(secondDeletion)));
+      Assert.assertTrue(consumer.sendAllTsFileData());
+      for (long partition : new long[] {0L, 1000L, 2000L}) {
+        final int regionId = partition < 2000L ? 1 : 2;
+        final File taskDir = managers.get(regionId).getActiveTaskDirs().get(0);
+        final File staged =
+            new File(taskDir, "root.test-" + regionId + "-" + partition + 
".tsfile");
+        Assert.assertEquals(
+            "Each partition must receive every deletion exactly once",
+            List.of(firstDeletion, secondDeletion),
+            ModificationFile.readAllModifications(staged, false));
+      }
+    } finally {
+      consumer.clear();
+      firstRegion.stop();
+      secondRegion.stop();
+      config.setLoadTsFileDirs(originalLoadDirs);
+    }
+  }
+
+  private static LoadTsFileManager manager(final int regionId) {
+    final DataRegion region = Mockito.mock(DataRegion.class);
+    when(region.getDatabaseName()).thenReturn("root.test");
+    when(region.getDataRegionIdString()).thenReturn(String.valueOf(regionId));
+    return new LoadTsFileManager(region);
+  }
+
+  private static TRegionReplicaSet replicaSet(final int regionId) {
+    return new TRegionReplicaSet()
+        .setRegionId(new TConsensusGroupId(TConsensusGroupType.DataRegion, 
regionId));
+  }
+
+  private static LoadSingleTsFileNode loadNode() {
+    final LoadSingleTsFileNode node = Mockito.mock(LoadSingleTsFileNode.class);
+    when(node.getPlanNodeId()).thenReturn(new PlanNodeId("load-deletions"));
+    final TsFileResource resource = Mockito.mock(TsFileResource.class);
+    when(resource.getTsFile()).thenReturn(new File("load-deletions.tsfile"));
+    when(node.getTsFileResource()).thenReturn(resource);
+    return node;
+  }
+
+  private static NonAlignedChunkData chunk(final long partition) {
+    final NonAlignedChunkData chunk =
+        new NonAlignedChunkData(
+            new StringArrayDeviceID("root", "test", "d1"),
+            new ChunkHeader(
+                "s1", 0, TSDataType.INT32, CompressionType.UNCOMPRESSED, 
TSEncoding.PLAIN, 0),
+            new TTimePartitionSlot(partition));
+    chunk.writeDecodePage(new long[] {partition + 1}, new Object[] {1}, 1);
+    chunk.endChunk();
+    return chunk;
+  }
+}
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java
index 1797c401160..4ccbc98aec6 100644
--- 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileSplitterTest.java
@@ -21,7 +21,10 @@ package org.apache.iotdb.db.storageengine.load.splitter;
 
 import org.apache.iotdb.common.rpc.thrift.TTimePartitionSlot;
 import org.apache.iotdb.commons.conf.CommonDescriptor;
+import org.apache.iotdb.commons.path.MeasurementPath;
 import org.apache.iotdb.commons.utils.TimePartitionUtils;
+import 
org.apache.iotdb.db.storageengine.dataregion.modification.ModificationFile;
+import 
org.apache.iotdb.db.storageengine.dataregion.modification.TreeDeletionEntry;
 
 import org.apache.tsfile.common.conf.TSFileConfig;
 import org.apache.tsfile.enums.ColumnCategory;
@@ -61,9 +64,47 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
 
 public class TsFileSplitterTest {
 
+  @Test
+  public void testRejectedDeletionStopsSplitting() throws Exception {
+    final File source = Files.createTempFile("rejected-load-deletion", 
".tsfile").toFile();
+    final File mods = ModificationFile.getExclusiveMods(source);
+    try {
+      try (final TsFileIOWriter writer = new TsFileIOWriter(source)) {
+        writer.startChunkGroup(new StringArrayDeviceID("root", "sg", "d1"));
+        final ChunkWriterImpl chunkWriter =
+            new ChunkWriterImpl(new MeasurementSchema("s1", TSDataType.INT32));
+        chunkWriter.write(1L, 1);
+        chunkWriter.writeToFileWriter(writer);
+        writer.endChunkGroup();
+        writer.endFile();
+      }
+      try (final ModificationFile modificationFile = new 
ModificationFile(mods, false)) {
+        modificationFile.write(new TreeDeletionEntry(new 
MeasurementPath("root.sg.d1.s1"), 0L, 1L));
+        modificationFile.write(new TreeDeletionEntry(new 
MeasurementPath("root.sg.d1.s1"), 2L, 3L));
+      }
+      final AtomicInteger consumedDeletions = new AtomicInteger();
+      final TsFileSplitter splitter =
+          new TsFileSplitter(
+              source,
+              data -> {
+                if (data instanceof DeletionData) {
+                  consumedDeletions.incrementAndGet();
+                  return false;
+                }
+                return true;
+              });
+      Assert.assertThrows(IllegalStateException.class, 
splitter::splitTsFileByDataPartition);
+      Assert.assertEquals(1, consumedDeletions.get());
+    } finally {
+      Files.deleteIfExists(mods.toPath());
+      Files.deleteIfExists(source.toPath());
+    }
+  }
+
   // Verify the splitter initializes the v3 deserialize configuration for a 
valid v3 TsFile.
   @Test
   public void testSplitV3TsFile() throws Exception {
diff --git 
a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileWriterManagerRecoveryTest.java
 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileWriterManagerRecoveryTest.java
new file mode 100644
index 00000000000..9c76b75c6a1
--- /dev/null
+++ 
b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/storageengine/load/TsFileWriterManagerRecoveryTest.java
@@ -0,0 +1,254 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.storageengine.load;
+
+import org.apache.iotdb.common.rpc.thrift.TTimePartitionSlot;
+import org.apache.iotdb.commons.consensus.index.ProgressIndex;
+import org.apache.iotdb.commons.consensus.index.impl.SimpleProgressIndex;
+import org.apache.iotdb.db.exception.load.LoadFileException;
+import 
org.apache.iotdb.db.queryengine.plan.scheduler.load.ChunkOffsetCalculator;
+import org.apache.iotdb.db.storageengine.dataregion.DataRegion;
+import org.apache.iotdb.db.storageengine.dataregion.tsfile.TsFileResource;
+import org.apache.iotdb.db.storageengine.load.splitter.NonAlignedChunkData;
+
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.file.header.ChunkHeader;
+import org.apache.tsfile.file.metadata.IDeviceID;
+import org.apache.tsfile.file.metadata.StringArrayDeviceID;
+import org.apache.tsfile.file.metadata.enums.CompressionType;
+import org.apache.tsfile.file.metadata.enums.TSEncoding;
+import org.apache.tsfile.read.TsFileReader;
+import org.apache.tsfile.read.common.Path;
+import org.apache.tsfile.read.common.RowRecord;
+import org.apache.tsfile.read.expression.QueryExpression;
+import org.apache.tsfile.read.query.dataset.QueryDataSet;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class TsFileWriterManagerRecoveryTest {
+
+  private static final String DATABASE = "root.load_recovery_test";
+  private static final IDeviceID DEVICE =
+      new StringArrayDeviceID("root", "load_recovery_test", "d1");
+
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  private DataRegion dataRegion;
+
+  @Before
+  public void setUp() {
+    dataRegion = Mockito.mock(DataRegion.class);
+    Mockito.when(dataRegion.getDatabaseName()).thenReturn(DATABASE);
+    Mockito.when(dataRegion.getDataRegionIdString()).thenReturn("0");
+  }
+
+  @Test
+  public void testResumeNegativeTimePartition() throws Exception {
+    assertTimePartitionResumes(-604_800_000L);
+  }
+
+  @Test
+  public void testResumeMinimumTimePartition() throws Exception {
+    assertTimePartitionResumes(Long.MIN_VALUE);
+  }
+
+  @Test
+  public void testCommitPreparedFileAfterRecovery() throws Exception {
+    final File taskDir = temporaryFolder.newFolder("prepared-task");
+    final NonAlignedChunkData chunk = chunk(0L, 1);
+    new ChunkOffsetCalculator().assign(chunk);
+    final ProgressIndex progressIndex = new SimpleProgressIndex(1, 10L);
+    TsFileWriterManager manager = new TsFileWriterManager(dataRegion, taskDir);
+    try {
+      manager.writePiece(List.of(chunk));
+      manager.prepare(
+          true,
+          Map.of(chunk.getTimePartitionSlot(), progressIndex),
+          Map.of(chunk.getTimePartitionSlot(), chunkEnd(chunk)));
+      final File staged = stagedFile(taskDir, 0L);
+      final byte[] preparedBytes = Files.readAllBytes(staged.toPath());
+      manager.close(true);
+
+      manager = new TsFileWriterManager(dataRegion, taskDir).recoverFromDisk();
+
+      assertArrayEquals(
+          "Recovery must preserve a prepared TsFile",
+          preparedBytes,
+          Files.readAllBytes(staged.toPath()));
+      // The coordinator already received the PREPARE acknowledgement and only 
sends COMMIT.
+      manager.loadAll(true, false);
+      final ArgumentCaptor<TsFileResource> imported = 
ArgumentCaptor.forClass(TsFileResource.class);
+      Mockito.verify(dataRegion)
+          .loadNewTsFile(
+              imported.capture(),
+              Mockito.eq(false),
+              Mockito.eq(true),
+              Mockito.eq(false),
+              Mockito.any());
+      assertTrue(imported.getValue().isGeneratedByPipe());
+      assertEquals(progressIndex, 
imported.getValue().getMaxProgressIndexAfterClose());
+      assertPoints(staged, 0L, 1);
+    } finally {
+      manager.close();
+    }
+  }
+
+  private void assertTimePartitionResumes(final long partitionStart) throws 
Exception {
+    final File taskDir = temporaryFolder.newFolder("task");
+    final ChunkOffsetCalculator calculator = new ChunkOffsetCalculator();
+    final NonAlignedChunkData first = chunk(partitionStart, 1);
+    final NonAlignedChunkData second = chunk(partitionStart, 2);
+    calculator.assign(first);
+    calculator.assign(second);
+
+    TsFileWriterManager manager = new TsFileWriterManager(dataRegion, taskDir);
+    try {
+      manager.writePiece(List.of(first));
+      manager.close(true);
+      manager = new TsFileWriterManager(dataRegion, taskDir).recoverFromDisk();
+      manager.writePiece(List.of(second));
+      manager.prepare(
+          false, Collections.emptyMap(), Map.of(second.getTimePartitionSlot(), 
chunkEnd(second)));
+
+      // Both the chunk staged before recovery and the one appended afterwards 
must survive.
+      assertPoints(stagedFile(taskDir, partitionStart), partitionStart, 1, 2);
+    } finally {
+      manager.close();
+    }
+  }
+
+  @Test
+  public void testPrepareRejectsMissingPartition() throws Exception {
+    final File taskDir = temporaryFolder.newFolder("task");
+    final TsFileWriterManager manager = new TsFileWriterManager(dataRegion, 
taskDir);
+    final NonAlignedChunkData first = chunk(0L, 1);
+    final NonAlignedChunkData missing = chunk(604_800_000L, 1);
+    new ChunkOffsetCalculator().assign(first);
+    new ChunkOffsetCalculator().assign(missing);
+    final Map<TTimePartitionSlot, Long> expectedDataEnd =
+        Map.of(
+            first.getTimePartitionSlot(), chunkEnd(first),
+            missing.getTimePartitionSlot(), chunkEnd(missing));
+    try {
+      manager.writePiece(List.of(first));
+      final long unsealedLength = stagedFile(taskDir, 0L).length();
+      assertPrepareFails(manager, expectedDataEnd, stagedFile(taskDir, 
604_800_000L));
+      assertEquals(unsealedLength, stagedFile(taskDir, 0L).length());
+
+      manager.writePiece(List.of(missing));
+      manager.prepare(false, Collections.emptyMap(), expectedDataEnd);
+      assertPoints(stagedFile(taskDir, 0L), 0L, 1);
+      assertPoints(stagedFile(taskDir, 604_800_000L), 604_800_000L, 1);
+    } finally {
+      manager.close();
+    }
+  }
+
+  @Test
+  public void testPrepareRejectsPartitionWithoutRecordedChunks() throws 
Exception {
+    final File taskDir = temporaryFolder.newFolder("task");
+    final TsFileWriterManager manager = new TsFileWriterManager(dataRegion, 
taskDir);
+    final NonAlignedChunkData complete = chunk(0L, 1);
+    new ChunkOffsetCalculator().assign(complete);
+    final Map<TTimePartitionSlot, Long> expectedDataEnd =
+        Map.of(complete.getTimePartitionSlot(), chunkEnd(complete));
+    try {
+      // A failed piece may have opened the file without recording any durable 
chunk.
+      try {
+        manager.writePiece(List.of(chunk(0L, 1)));
+        fail("A chunk without a layout must fail after opening its staged 
file");
+      } catch (final IOException expected) {
+        assertTrue(stagedFile(taskDir, 0L).isFile());
+      }
+      assertPrepareFails(manager, expectedDataEnd, stagedFile(taskDir, 0L));
+
+      manager.writePiece(List.of(complete));
+      manager.prepare(false, Collections.emptyMap(), expectedDataEnd);
+      assertPoints(stagedFile(taskDir, 0L), 0L, 1);
+    } finally {
+      manager.close();
+    }
+  }
+
+  private static void assertPrepareFails(
+      final TsFileWriterManager manager,
+      final Map<TTimePartitionSlot, Long> expectedDataEnd,
+      final File missingDataFile)
+      throws Exception {
+    try {
+      manager.prepare(false, Collections.emptyMap(), expectedDataEnd);
+      fail("PREPARE must reject a partition whose announced data has not been 
staged");
+    } catch (final LoadFileException e) {
+      assertTrue(e.getMessage(), 
e.getMessage().contains(missingDataFile.getAbsolutePath()));
+    }
+  }
+
+  private static NonAlignedChunkData chunk(final long partitionStart, final 
int value) {
+    final NonAlignedChunkData chunk =
+        new NonAlignedChunkData(
+            DEVICE,
+            new ChunkHeader(
+                "s1", 0, TSDataType.INT32, CompressionType.UNCOMPRESSED, 
TSEncoding.PLAIN, 0),
+            new TTimePartitionSlot(partitionStart));
+    chunk.writeDecodePage(new long[] {partitionStart + value}, new Object[] 
{value}, 1);
+    chunk.endChunk();
+    return chunk;
+  }
+
+  private static long chunkEnd(final NonAlignedChunkData chunk) {
+    return chunk.getChunkLayout().offset() + chunk.getChunkLayout().length();
+  }
+
+  private static File stagedFile(final File taskDir, final long 
partitionStart) {
+    return new File(taskDir, DATABASE + "-0-" + partitionStart + ".tsfile");
+  }
+
+  private static void assertPoints(final File file, final long partitionStart, 
final int... values)
+      throws Exception {
+    try (final TsFileReader reader = new TsFileReader(file)) {
+      final QueryDataSet result =
+          reader.query(QueryExpression.create(List.of(new Path(DEVICE, "s1", 
false)), null));
+      for (final int value : values) {
+        assertTrue(result.hasNext());
+        final RowRecord row = result.next();
+        assertEquals(partitionStart + value, row.getTimestamp());
+        assertEquals(value, row.getField(0).getIntV());
+      }
+      assertFalse(result.hasNext());
+    }
+  }
+}

Reply via email to