This is an automated email from the ASF dual-hosted git repository.

SteNicholas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/celeborn.git


The following commit(s) were added to refs/heads/main by this push:
     new b10546448 [CELEBORN-2348] Support end-to-end shuffle integrity check 
for Flink
b10546448 is described below

commit b10546448657d430cc33d8607012a34135217498
Author: Nicholas Jiang <[email protected]>
AuthorDate: Fri Jun 5 10:42:12 2026 +0800

    [CELEBORN-2348] Support end-to-end shuffle integrity check for Flink
    
    ### What changes were proposed in this pull request?
    
    This PR extends the end-to-end shuffle integrity checks introduced in 
CELEBORN-894 (Spark-only) to Flink workloads, covering both the regular and the 
tiered (hybrid) read paths. When the check is enabled, the write side records a 
per subpartition CRC32 + byte count and the driver validates it against what 
the reader actually consumed, failing the read on a mismatch.
    
    - **Write side**: `FlinkShuffleClientImpl` hashes each push payload (the 
body after the batch header) into `PushState` via a zero-copy `ByteBuffer` view 
and reports the per-subpartition CRC32/bytes at `MapperEnd`, reusing the 
existing `crc32PerPartition` / `bytesWrittenPerPartition` plumbing. The 
constructor fails fast if the write-side `BATCH_HEADER_SIZE` ever diverges from 
the read-side `BufferUtils.HEADER_LENGTH_PREFIX`.
    - **Read side**: `RemoteBufferStreamReader` and 
`CelebornChannelBufferReader` accumulate the read CRC32/bytes through a shared 
`ReadIntegrityTracker` and report them at the last partition's stream end. The 
tracker owns the per-path framing/stripping and disables itself on any 
unexpected buffer shape (wrong component count, or a buffer shorter than the 
batch header) rather than risk a false mismatch.
    - **Driver side**: `ReadReducerPartitionEnd` is reused for MAP partitions, 
and `MapPartitionCommitHandler.finishPartition` combines the recorded 
write-side checksums over the consumed subpartition range, failing closed on a 
mismatch or missing metadata.
    - Add zero-copy `ByteBuffer` overloads to `CelebornCRC32`, `CommitMetadata` 
and `PushState`.
    - Minor: drop a stray (cosmetic, no-op) unary plus in 
`handleReducerPartitionEnd`'s failure branch and update the client config doc.
    
    ### Why are the changes needed?
    
    CELEBORN-894 added end-to-end integrity verification only for Spark. Flink 
workloads — including hybrid/tiered shuffle — had no equivalent guard, so 
silent shuffle data corruption (bit flips, truncation, mis-framing) could go 
undetected and surface as wrong results rather than a failed task. This PR 
brings the same write-vs-read checksum/byte-count validation to Flink so such 
corruption fails the read instead of being silently consumed.
    
    ### Does this PR resolve a correctness bug?
    
    - [ ] Yes
    
    ### Does this PR introduce _any_ user-facing change?
    
    - [x] Yes
    
    The existing `celeborn.client.shuffle.integrityCheck.enabled` config now 
also applies to Flink (previously Spark-only); its documentation is updated 
accordingly. The default remains `false`, so there is no behavior change unless 
the check is explicitly enabled.
    
    ### How was this patch tested?
    
    Added unit and integration tests:
    
    - `CelebornCRC32Test` / `CommitMetadataTest`: the new `ByteBuffer` 
overloads (single and split header/data), order-independence, and corruption / 
byte-count-mismatch detection.
    - `MapPartitionCommitHandlerTest`: `finishPartition` success and all 
failure branches (no metadata, missing map partition, out-of-bounds range, 
checksum mismatch, byte-count mismatch), concurrent recording, and the 
expired-shuffle race.
    - `ReadIntegrityTrackerTest`: report-once / disable semantics and per-path 
framing for both the regular and tiered read paths.
    - `RemoteBufferStreamReaderTest`: the stream-end-after-close race (a failed 
report must not notify the failure listener on a closed channel).
    - `CelebornBufferStreamTest`: the `hasRemainingPartitions` location-index 
boundary.
    - `WordCountTest` (`WordCountTestWithIntegrityCheck`) and 
`HybridShuffleWordCountTest`: end-to-end Flink runs with the check enabled, on 
both the regular and hybrid shuffle paths.
    
    Closes #3718 from SteNicholas/CELEBORN-2348.
    
    Authored-by: Nicholas Jiang <[email protected]>
    Signed-off-by: Nicholas Jiang <[email protected]>
---
 .../flink/tiered/CelebornChannelBufferReader.java  |  37 ++-
 .../flink/tiered/CelebornTierConsumerAgent.java    |   3 +-
 .../plugin/flink/ReadIntegrityTracker.java         | 174 ++++++++++++
 .../plugin/flink/RemoteBufferStreamReader.java     |  29 +-
 .../flink/RemoteShuffleInputGateDelegation.java    |   6 +-
 .../plugin/flink/client/CelebornBufferStream.java  |  16 ++
 .../flink/client/FlinkShuffleClientImpl.java       |  17 ++
 .../plugin/flink/ReadIntegrityTrackerTest.java     | 264 ++++++++++++++++++
 .../plugin/flink/RemoteBufferStreamReaderTest.java |  90 +++++++
 .../flink/client/CelebornBufferStreamTest.java     |  51 ++++
 .../apache/celeborn/client/ShuffleClientImpl.java  |   2 +-
 .../apache/celeborn/client/LifecycleManager.scala  |  22 +-
 .../celeborn/client/commit/CommitHandler.scala     |   4 +-
 .../client/commit/MapPartitionCommitHandler.scala  | 107 +++++++-
 .../commit/MapPartitionCommitHandlerTest.scala     | 299 +++++++++++++++++++++
 .../org/apache/celeborn/common/CelebornCRC32.java  |  22 ++
 .../org/apache/celeborn/common/CommitMetadata.java |  18 ++
 .../apache/celeborn/common/write/PushState.java    |   7 +
 .../org/apache/celeborn/common/CelebornConf.scala  |   2 +-
 .../apache/celeborn/common/CelebornCRC32Test.java  |  44 +++
 .../apache/celeborn/common/CommitMetadataTest.java |  87 ++++++
 docs/configuration/client.md                       |   2 +-
 .../tests/flink/HybridShuffleWordCountTest.scala   |  22 +-
 .../celeborn/tests/flink/WordCountTest.scala       |   7 +
 24 files changed, 1310 insertions(+), 22 deletions(-)

diff --git 
a/client-flink/common-tiered/src/main/java/org/apache/celeborn/plugin/flink/tiered/CelebornChannelBufferReader.java
 
b/client-flink/common-tiered/src/main/java/org/apache/celeborn/plugin/flink/tiered/CelebornChannelBufferReader.java
index e033d1283..94659e681 100644
--- 
a/client-flink/common-tiered/src/main/java/org/apache/celeborn/plugin/flink/tiered/CelebornChannelBufferReader.java
+++ 
b/client-flink/common-tiered/src/main/java/org/apache/celeborn/plugin/flink/tiered/CelebornChannelBufferReader.java
@@ -43,10 +43,12 @@ import org.apache.celeborn.common.network.util.NettyUtils;
 import org.apache.celeborn.common.protocol.PbNotifyRequiredSegment;
 import org.apache.celeborn.common.protocol.PbReadAddCredit;
 import org.apache.celeborn.common.util.JavaUtils;
+import org.apache.celeborn.plugin.flink.ReadIntegrityTracker;
 import org.apache.celeborn.plugin.flink.ShuffleResourceDescriptor;
 import org.apache.celeborn.plugin.flink.client.CelebornBufferStream;
 import org.apache.celeborn.plugin.flink.client.FlinkShuffleClientImpl;
 import org.apache.celeborn.plugin.flink.protocol.SubPartitionReadData;
+import org.apache.celeborn.plugin.flink.utils.BufferUtils;
 
 /** Wrap the {@link CelebornBufferStream}, used in flink hybrid shuffle 
integration strategy now. */
 public class CelebornChannelBufferReader {
@@ -80,6 +82,9 @@ public class CelebornChannelBufferReader {
 
   private volatile ConcurrentHashMap<Integer, Integer> 
subPartitionRequiredSegmentIds;
 
+  // Reports the read CRC32/bytes over the data payload at stream end.
+  private final ReadIntegrityTracker integrityTracker;
+
   /** Note this field is to record the number of backlog before the read is 
set up. */
   private int numBackLog = 0;
 
@@ -90,7 +95,8 @@ public class CelebornChannelBufferReader {
       int startSubIdx,
       int endSubIdx,
       BiConsumer<ByteBuf, TieredStorageSubpartitionId> dataListener,
-      BiConsumer<Throwable, TieredStorageSubpartitionId> failureListener) {
+      BiConsumer<Throwable, TieredStorageSubpartitionId> failureListener,
+      boolean integrityCheckEnabled) {
     this.client = client;
     this.shuffleId = shuffleDescriptor.getShuffleId();
     this.partitionId = shuffleDescriptor.getPartitionId();
@@ -99,6 +105,14 @@ public class CelebornChannelBufferReader {
     this.subPartitionIndexEnd = endSubIdx;
     this.dataListener = dataListener;
     this.failureListener = failureListener;
+    this.integrityTracker =
+        new ReadIntegrityTracker(
+            client,
+            this.shuffleId,
+            this.partitionId,
+            subPartitionIndexStart,
+            subPartitionIndexEnd,
+            integrityCheckEnabled);
     this.subPartitionRequiredSegmentIds = JavaUtils.newConcurrentHashMap();
     for (int subPartitionId = subPartitionIndexStart;
         subPartitionId <= subPartitionIndexEnd;
@@ -292,17 +306,18 @@ public class CelebornChannelBufferReader {
   }
 
   public void dataReceived(SubPartitionReadData readData) {
+    ByteBuf flinkBuffer = readData.getFlinkBuffer();
     LOG.debug(
         "Remote buffer stream reader get stream id {} subPartitionId {} 
received readable bytes {}.",
         readData.getStreamId(),
         readData.getSubPartitionId(),
-        readData.getFlinkBuffer().readableBytes());
+        flinkBuffer.readableBytes());
     checkState(
         readData.getSubPartitionId() >= subPartitionIndexStart
             && readData.getSubPartitionId() <= subPartitionIndexEnd,
         "Wrong sub partition id: " + readData.getSubPartitionId());
-    dataListener.accept(
-        readData.getFlinkBuffer(), new 
TieredStorageSubpartitionId(readData.getSubPartitionId()));
+    integrityTracker.accumulateTieredBuffer(flinkBuffer, 
BufferUtils.HEADER_LENGTH_PREFIX);
+    dataListener.accept(flinkBuffer, new 
TieredStorageSubpartitionId(readData.getSubPartitionId()));
     int numRequested = bufferManager.tryRequestBuffersIfNeeded();
     if (numRequested > 0) {
       bufferManager.decreaseRequiredCredits(numRequested);
@@ -314,7 +329,21 @@ public class CelebornChannelBufferReader {
     long streamId = streamEnd.getStreamId();
     LOG.debug("Buffer stream reader get stream end for {}", streamId);
     if (!closed && !CelebornBufferStream.isEmptyStream(bufferStream)) {
+      // Check before the move, which would otherwise advance the location 
index.
+      boolean lastPartition = !bufferStream.hasRemainingPartitions();
       bufferStream.moveToNextPartitionIfPossible(streamId, 
this::sendRequireSegmentId, true);
+      if (lastPartition) {
+        integrityTracker.report(
+            e -> {
+              if (!closed) {
+                for (int subPartitionId = subPartitionIndexStart;
+                    subPartitionId <= subPartitionIndexEnd;
+                    subPartitionId++) {
+                  failureListener.accept(e, new 
TieredStorageSubpartitionId(subPartitionId));
+                }
+              }
+            });
+      }
     }
   }
 
diff --git 
a/client-flink/common-tiered/src/main/java/org/apache/celeborn/plugin/flink/tiered/CelebornTierConsumerAgent.java
 
b/client-flink/common-tiered/src/main/java/org/apache/celeborn/plugin/flink/tiered/CelebornTierConsumerAgent.java
index 7bfd9d163..04c361dfd 100644
--- 
a/client-flink/common-tiered/src/main/java/org/apache/celeborn/plugin/flink/tiered/CelebornTierConsumerAgent.java
+++ 
b/client-flink/common-tiered/src/main/java/org/apache/celeborn/plugin/flink/tiered/CelebornTierConsumerAgent.java
@@ -434,7 +434,8 @@ public class CelebornTierConsumerAgent implements 
TierConsumerAgent {
             subPartitionIdSet.getStartIndex(),
             subPartitionIdSet.getEndIndex(),
             getDataListener(partitionId),
-            getFailureListener(partitionId));
+            getFailureListener(partitionId),
+            conf.clientShuffleIntegrityCheckEnabled());
 
     for (int id = subPartitionIdSet.getStartIndex(); id <= 
subPartitionIdSet.getEndIndex(); id++) {
       TieredStorageSubpartitionId subPartitionId = new 
TieredStorageSubpartitionId(id);
diff --git 
a/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/ReadIntegrityTracker.java
 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/ReadIntegrityTracker.java
new file mode 100644
index 000000000..1b98b52f0
--- /dev/null
+++ 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/ReadIntegrityTracker.java
@@ -0,0 +1,174 @@
+/*
+ * 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.celeborn.plugin.flink;
+
+import java.io.IOException;
+import java.util.function.Consumer;
+
+import org.apache.flink.shaded.netty4.io.netty.buffer.ByteBuf;
+import org.apache.flink.shaded.netty4.io.netty.buffer.CompositeByteBuf;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.celeborn.common.CommitMetadata;
+import org.apache.celeborn.plugin.flink.client.FlinkShuffleClientImpl;
+import org.apache.celeborn.plugin.flink.utils.BufferUtils;
+
+/**
+ * Accumulates the read-side {@link CommitMetadata} (CRC32 + byte count) over 
a consumed
+ * subpartition range and reports it to the driver at stream end. Each read 
path strips its own
+ * framing via {@code accumulate*Buffer} so the hashed bytes match what the 
writer hashed (the body
+ * after the batch header).
+ */
+public class ReadIntegrityTracker {
+  private static final Logger LOG = 
LoggerFactory.getLogger(ReadIntegrityTracker.class);
+
+  private final FlinkShuffleClientImpl client;
+  private final int shuffleId;
+  private final int partitionId;
+  private final int subPartitionIndexStart;
+  private final int subPartitionIndexEnd;
+  private final CommitMetadata readCommitMetadata = new CommitMetadata();
+  private volatile boolean enabled;
+  // True once report() has run; the report is terminal whether it succeeded 
or failed.
+  private volatile boolean reportAttempted = false;
+
+  public ReadIntegrityTracker(
+      FlinkShuffleClientImpl client,
+      int shuffleId,
+      int partitionId,
+      int subPartitionIndexStart,
+      int subPartitionIndexEnd,
+      boolean enabled) {
+    this.client = client;
+    this.shuffleId = shuffleId;
+    this.partitionId = partitionId;
+    this.subPartitionIndexStart = subPartitionIndexStart;
+    this.subPartitionIndexEnd = subPartitionIndexEnd;
+    this.enabled = enabled;
+  }
+
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  /** Permanently disables tracking for this stream so nothing is reported at 
stream end. */
+  void disable() {
+    enabled = false;
+  }
+
+  /** Strips the {@code headerPrefix}-byte batch header; a too-short buffer 
disables tracking. */
+  private void accumulatePlainBuffer(ByteBuf flinkBuffer, int headerPrefix) {
+    if (flinkBuffer.readableBytes() < headerPrefix) {
+      disableForUnexpectedFraming(
+          String.format("a buffer shorter than the %d-byte batch header", 
headerPrefix));
+      return;
+    }
+    readCommitMetadata.addData(
+        flinkBuffer.nioBuffer(
+            flinkBuffer.readerIndex() + headerPrefix, 
flinkBuffer.readableBytes() - headerPrefix));
+  }
+
+  /**
+   * Regular path: strips the {@link BufferUtils#HEADER_LENGTH_PREFIX}-byte 
header. This path never
+   * splits buffers, so a composite disables tracking.
+   */
+  public void accumulateRegularBuffer(ByteBuf flinkBuffer) {
+    if (!enabled) {
+      return;
+    }
+    if (flinkBuffer instanceof CompositeByteBuf) {
+      disableForUnexpectedFraming("an unexpected composite buffer on the 
regular read path");
+      return;
+    }
+    accumulatePlainBuffer(flinkBuffer, BufferUtils.HEADER_LENGTH_PREFIX);
+  }
+
+  /**
+   * Tiered path: strips the {@code headerPrefix}-byte header. Accepts a plain 
buffer or a
+   * two-component composite (header + data); other shapes disable tracking.
+   */
+  public void accumulateTieredBuffer(ByteBuf flinkBuffer, int headerPrefix) {
+    if (!enabled) {
+      return;
+    }
+    if (flinkBuffer instanceof CompositeByteBuf) {
+      CompositeByteBuf composite = (CompositeByteBuf) flinkBuffer;
+      if (composite.numComponents() != 2) {
+        disableForUnexpectedFraming(
+            String.format("a composite buffer with %d components", 
composite.numComponents()));
+        return;
+      }
+      ByteBuf header = composite.component(0);
+      ByteBuf data = composite.component(1);
+      if (header.readableBytes() < headerPrefix) {
+        disableForUnexpectedFraming(
+            String.format(
+                "a header component shorter than the %d-byte batch header", 
headerPrefix));
+        return;
+      }
+      readCommitMetadata.addData(
+          header.nioBuffer(
+              header.readerIndex() + headerPrefix, header.readableBytes() - 
headerPrefix),
+          data.nioBuffer(data.readerIndex(), data.readableBytes()));
+    } else {
+      accumulatePlainBuffer(flinkBuffer, headerPrefix);
+    }
+  }
+
+  /**
+   * Reports the accumulated metadata to the driver once, at the last 
partition's stream end. A
+   * failed report is logged and passed to {@code onFailure} — terminal, not 
retried, since that
+   * already fails the channel.
+   */
+  public void report(Consumer<IOException> onFailure) {
+    if (!enabled || reportAttempted) {
+      return;
+    }
+    reportAttempted = true;
+    try {
+      client.readReducerPartitionEnd(
+          shuffleId,
+          partitionId,
+          subPartitionIndexStart,
+          subPartitionIndexEnd,
+          readCommitMetadata.getChecksum(),
+          readCommitMetadata.getBytes());
+    } catch (IOException e) {
+      LOG.error(
+          "Integrity check report failed for shuffle {} partition {} 
subpartitions [{}, {}].",
+          shuffleId,
+          partitionId,
+          subPartitionIndexStart,
+          subPartitionIndexEnd,
+          e);
+      onFailure.accept(e);
+    }
+  }
+
+  private void disableForUnexpectedFraming(String reason) {
+    disable();
+    LOG.warn(
+        "Integrity check skipped for shuffle {} partition {} subpartitions 
[{}, {}] because the "
+            + "reader saw {}.",
+        shuffleId,
+        partitionId,
+        subPartitionIndexStart,
+        subPartitionIndexEnd,
+        reason);
+  }
+}
diff --git 
a/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/RemoteBufferStreamReader.java
 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/RemoteBufferStreamReader.java
index 632fa8793..37c18341e 100644
--- 
a/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/RemoteBufferStreamReader.java
+++ 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/RemoteBufferStreamReader.java
@@ -50,6 +50,8 @@ public class RemoteBufferStreamReader extends CreditListener {
   private CelebornBufferStream bufferStream;
   private volatile boolean closed = false;
   private Consumer<RequestMessage> messageConsumer;
+  // Reports the read CRC32/bytes at stream end; disabled lazily on unexpected 
composite buffers.
+  private final ReadIntegrityTracker integrityTracker;
 
   public RemoteBufferStreamReader(
       FlinkShuffleClientImpl client,
@@ -58,7 +60,8 @@ public class RemoteBufferStreamReader extends CreditListener {
       int endSubIdx,
       TransferBufferPool bufferPool,
       Consumer<ByteBuf> dataListener,
-      Consumer<Throwable> failureListener) {
+      Consumer<Throwable> failureListener,
+      boolean integrityCheckEnabled) {
     this.client = client;
     this.shuffleId = shuffleDescriptor.getShuffleId();
     this.partitionId = shuffleDescriptor.getPartitionId();
@@ -67,6 +70,14 @@ public class RemoteBufferStreamReader extends CreditListener 
{
     this.subPartitionIndexEnd = endSubIdx;
     this.dataListener = dataListener;
     this.failureListener = failureListener;
+    this.integrityTracker =
+        new ReadIntegrityTracker(
+            client,
+            this.shuffleId,
+            this.partitionId,
+            subPartitionIndexStart,
+            subPartitionIndexEnd,
+            integrityCheckEnabled);
     this.messageConsumer =
         requestMessage -> {
           if (requestMessage instanceof ReadData) {
@@ -148,16 +159,28 @@ public class RemoteBufferStreamReader extends 
CreditListener {
   }
 
   public void dataReceived(ReadData readData) {
+    ByteBuf flinkBuffer = readData.getFlinkBuffer();
     logger.debug(
         "Remote buffer stream reader get stream id {} received readable bytes 
{}.",
         readData.getStreamId(),
-        readData.getFlinkBuffer().readableBytes());
-    dataListener.accept(readData.getFlinkBuffer());
+        flinkBuffer.readableBytes());
+    integrityTracker.accumulateRegularBuffer(flinkBuffer);
+    dataListener.accept(flinkBuffer);
   }
 
   public void onStreamEnd(BufferStreamEnd streamEnd) {
     long streamId = streamEnd.getStreamId();
     logger.debug("Buffer stream reader get stream end for {}", streamId);
+    // Check before the move, which would otherwise advance the location index.
+    boolean lastPartition = !bufferStream.hasRemainingPartitions();
     bufferStream.moveToNextPartitionIfPossible(streamId, null, false);
+    if (lastPartition) {
+      integrityTracker.report(
+          e -> {
+            if (!closed) {
+              failureListener.accept(e);
+            }
+          });
+    }
   }
 }
diff --git 
a/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/RemoteShuffleInputGateDelegation.java
 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/RemoteShuffleInputGateDelegation.java
index 99f144c1c..0b496893b 100644
--- 
a/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/RemoteShuffleInputGateDelegation.java
+++ 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/RemoteShuffleInputGateDelegation.java
@@ -135,6 +135,8 @@ public class RemoteShuffleInputGateDelegation {
 
   private final ShuffleIOMetricGroup shuffleIOMetricGroup;
 
+  private final boolean shuffleIntegrityCheckEnabled;
+
   public RemoteShuffleInputGateDelegation(
       CelebornConf celebornConf,
       ShuffleIOOwnerContext ownerContext,
@@ -194,6 +196,7 @@ public class RemoteShuffleInputGateDelegation {
     this.numConcurrentReading = numConcurrentReading;
     this.availabilityHelper = availabilityHelper;
     this.partitionConnectionExceptionEnabled = 
celebornConf.partitionConnectionExceptionEnabled();
+    this.shuffleIntegrityCheckEnabled = 
celebornConf.clientShuffleIntegrityCheckEnabled();
     LOG.debug("Initial input gate with numConcurrentReading {}", 
this.numConcurrentReading);
   }
 
@@ -223,7 +226,8 @@ public class RemoteShuffleInputGateDelegation {
               endSubIndex,
               transferBufferPool,
               getDataListener(descriptor.getLeft(), shuffleIOMetricGroup),
-              getFailureListener(remoteDescriptor.getResultPartitionID()));
+              getFailureListener(remoteDescriptor.getResultPartitionID()),
+              shuffleIntegrityCheckEnabled);
 
       bufferReaders.add(reader);
       numSubPartitionsNotConsumed[descriptor.getLeft()] = 
numSubpartitionsPerChannel;
diff --git 
a/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/client/CelebornBufferStream.java
 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/client/CelebornBufferStream.java
index 04f8b2387..6588719f2 100644
--- 
a/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/client/CelebornBufferStream.java
+++ 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/client/CelebornBufferStream.java
@@ -28,6 +28,7 @@ import java.util.function.Supplier;
 
 import javax.annotation.Nullable;
 
+import com.google.common.annotations.VisibleForTesting;
 import org.apache.flink.shaded.netty4.io.netty.buffer.ByteBuf;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -159,6 +160,21 @@ public class CelebornBufferStream {
     return streamId;
   }
 
+  /** Returns whether this stream still has partition locations left to read. 
*/
+  public boolean hasRemainingPartitions() {
+    return currentLocationIndex.get() < locations.length;
+  }
+
+  /**
+   * Sets the current location index. {@code openStreamInternal} advances it 
via the network stack,
+   * which a unit test cannot drive, so tests poke it directly to reach the 
boundary readers rely
+   * on.
+   */
+  @VisibleForTesting
+  void setCurrentLocationIndex(int index) {
+    currentLocationIndex.set(index);
+  }
+
   public static CelebornBufferStream create(
       FlinkShuffleClientImpl client,
       FlinkTransportClientFactory dataClientFactory,
diff --git 
a/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/client/FlinkShuffleClientImpl.java
 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/client/FlinkShuffleClientImpl.java
index 9dfe48a9e..81a5cef22 100644
--- 
a/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/client/FlinkShuffleClientImpl.java
+++ 
b/client-flink/common/src/main/java/org/apache/celeborn/plugin/flink/client/FlinkShuffleClientImpl.java
@@ -72,6 +72,7 @@ import org.apache.celeborn.common.write.PushState;
 import org.apache.celeborn.plugin.flink.network.FlinkTransportClientFactory;
 import org.apache.celeborn.plugin.flink.network.ReadClientHandler;
 import 
org.apache.celeborn.plugin.flink.network.TransportFrameDecoderWithBufferSupplier;
+import org.apache.celeborn.plugin.flink.utils.BufferUtils;
 
 public class FlinkShuffleClientImpl extends ShuffleClientImpl {
   public static final Logger logger = 
LoggerFactory.getLogger(FlinkShuffleClientImpl.class);
@@ -179,6 +180,16 @@ public class FlinkShuffleClientImpl extends 
ShuffleClientImpl {
       UserIdentifier userIdentifier,
       int bufferSizeBytes) {
     super(appUniqueId, conf, userIdentifier);
+    // The integrity check is only correct while the write-side 
BATCH_HEADER_SIZE and read-side
+    // BufferUtils.HEADER_LENGTH_PREFIX stay equal, so fail fast if they ever 
diverge.
+    if (BATCH_HEADER_SIZE != BufferUtils.HEADER_LENGTH_PREFIX) {
+      throw new IllegalStateException(
+          String.format(
+              "Batch header size mismatch: write-side BATCH_HEADER_SIZE %d != 
read-side "
+                  + "BufferUtils.HEADER_LENGTH_PREFIX %d; the Flink shuffle 
integrity check would "
+                  + "produce false mismatches.",
+              BATCH_HEADER_SIZE, BufferUtils.HEADER_LENGTH_PREFIX));
+    }
     this.bufferSizeBytes = bufferSizeBytes;
     String module = TransportModuleConstants.DATA_MODULE;
     TransportConf dataTransportConf =
@@ -339,6 +350,12 @@ public class FlinkShuffleClientImpl extends 
ShuffleClientImpl {
     data.writeInt(nextBatchId);
     data.writeInt(totalLength - BATCH_HEADER_SIZE);
     data.resetWriterIndex();
+    // Hash the payload after the header via a zero-copy ByteBuffer view; the 
reader strips the same
+    // count (asserted equal in the constructor).
+    if (shuffleIntegrityCheckEnabled) {
+      int payloadLength = totalLength - BATCH_HEADER_SIZE;
+      pushState.addData(partitionId, data.nioBuffer(BATCH_HEADER_SIZE, 
payloadLength));
+    }
     logger.debug(
         "Do push data byteBuf size {} for app {} shuffle {} map {} attempt {} 
reduce {} batch {}.",
         totalLength,
diff --git 
a/client-flink/common/src/test/java/org/apache/celeborn/plugin/flink/ReadIntegrityTrackerTest.java
 
b/client-flink/common/src/test/java/org/apache/celeborn/plugin/flink/ReadIntegrityTrackerTest.java
new file mode 100644
index 000000000..d450b44cd
--- /dev/null
+++ 
b/client-flink/common/src/test/java/org/apache/celeborn/plugin/flink/ReadIntegrityTrackerTest.java
@@ -0,0 +1,264 @@
+/*
+ * 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.celeborn.plugin.flink;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.apache.flink.shaded.netty4.io.netty.buffer.ByteBuf;
+import org.apache.flink.shaded.netty4.io.netty.buffer.CompositeByteBuf;
+import org.apache.flink.shaded.netty4.io.netty.buffer.Unpooled;
+import org.junit.Test;
+
+import org.apache.celeborn.common.CommitMetadata;
+import org.apache.celeborn.plugin.flink.client.FlinkShuffleClientImpl;
+import org.apache.celeborn.plugin.flink.utils.BufferUtils;
+
+public class ReadIntegrityTrackerTest {
+
+  private static final int SHUFFLE_ID = 7;
+  private static final int PARTITION_ID = 3;
+  private static final int START_SUB_INDEX = 2;
+  private static final int END_SUB_INDEX = 5;
+  private static final int PREFIX = BufferUtils.HEADER_LENGTH_PREFIX;
+  private static final Consumer<IOException> NO_OP = e -> {};
+
+  private FlinkShuffleClientImpl mockClient() {
+    return mock(FlinkShuffleClientImpl.class);
+  }
+
+  private ReadIntegrityTracker newTracker(FlinkShuffleClientImpl client, 
boolean enabled) {
+    return new ReadIntegrityTracker(
+        client, SHUFFLE_ID, PARTITION_ID, START_SUB_INDEX, END_SUB_INDEX, 
enabled);
+  }
+
+  /** Deterministic, distinct bytes so different ranges produce different 
checksums. */
+  private static byte[] bytesOfLength(int length) {
+    byte[] bytes = new byte[length];
+    for (int i = 0; i < length; i++) {
+      bytes[i] = (byte) (i + 1);
+    }
+    return bytes;
+  }
+
+  /** Prepends a distinct {@link #PREFIX}-byte batch header to {@code 
payload}. */
+  private static byte[] frameWithPrefix(byte[] payload) {
+    byte[] framed = new byte[PREFIX + payload.length];
+    for (int i = 0; i < PREFIX; i++) {
+      framed[i] = (byte) (0x80 + i);
+    }
+    System.arraycopy(payload, 0, framed, PREFIX, payload.length);
+    return framed;
+  }
+
+  private void verifyReported(FlinkShuffleClientImpl client, CommitMetadata 
expected)
+      throws IOException {
+    verify(client)
+        .readReducerPartitionEnd(
+            SHUFFLE_ID,
+            PARTITION_ID,
+            START_SUB_INDEX,
+            END_SUB_INDEX,
+            expected.getChecksum(),
+            expected.getBytes());
+  }
+
+  @Test
+  public void reportSendsAccumulatedMetadataOnce() throws IOException {
+    FlinkShuffleClientImpl client = mockClient();
+    ReadIntegrityTracker tracker = newTracker(client, true);
+    byte[] payload = "regular-path-payload".getBytes();
+    
tracker.accumulateRegularBuffer(Unpooled.wrappedBuffer(frameWithPrefix(payload)));
+
+    tracker.report(NO_OP);
+    tracker.report(NO_OP);
+
+    CommitMetadata expected = new CommitMetadata();
+    expected.addData(ByteBuffer.wrap(payload));
+    verifyReported(client, expected);
+    verify(client, times(1))
+        .readReducerPartitionEnd(anyInt(), anyInt(), anyInt(), anyInt(), 
anyInt(), anyLong());
+  }
+
+  @Test
+  public void reportDoesNothingWhenDisabled() {
+    // Disabled at construction (integrity check off).
+    FlinkShuffleClientImpl disabledAtStart = mockClient();
+    newTracker(disabledAtStart, false).report(NO_OP);
+    verifyNoInteractions(disabledAtStart);
+
+    // Disabled mid-stream via disable().
+    FlinkShuffleClientImpl disabledLater = mockClient();
+    ReadIntegrityTracker tracker = newTracker(disabledLater, true);
+    tracker.disable();
+    assertFalse(tracker.isEnabled());
+    tracker.report(NO_OP);
+    verifyNoInteractions(disabledLater);
+  }
+
+  @Test
+  public void reportDoesNothingAfterAccumulateTriggersDisable() {
+    FlinkShuffleClientImpl client = mockClient();
+    ReadIntegrityTracker tracker = newTracker(client, true);
+    // Accumulate one good buffer, then a malformed one that disables tracking 
mid-stream.
+    tracker.accumulateTieredBuffer(Unpooled.wrappedBuffer(bytesOfLength(PREFIX 
+ 8)), PREFIX);
+    assertTrue(tracker.isEnabled());
+    tracker.accumulateTieredBuffer(Unpooled.wrappedBuffer(bytesOfLength(PREFIX 
- 1)), PREFIX);
+    assertFalse(tracker.isEnabled());
+    // report must not send the partial metadata.
+    tracker.report(NO_OP);
+    verifyNoInteractions(client);
+  }
+
+  @Test
+  public void reportPassesFailureToCallbackAndDoesNotRetry() throws 
IOException {
+    FlinkShuffleClientImpl client = mockClient();
+    IOException boom = new IOException("report failed");
+    doThrow(boom)
+        .when(client)
+        .readReducerPartitionEnd(anyInt(), anyInt(), anyInt(), anyInt(), 
anyInt(), anyLong());
+    ReadIntegrityTracker tracker = newTracker(client, true);
+
+    List<IOException> failures = new ArrayList<>();
+    tracker.report(failures::add);
+    // A failed report is still terminal: a second call must not re-issue the 
RPC.
+    tracker.report(failures::add);
+
+    assertEquals(1, failures.size());
+    assertSame(boom, failures.get(0));
+    verify(client, times(1))
+        .readReducerPartitionEnd(anyInt(), anyInt(), anyInt(), anyInt(), 
anyInt(), anyLong());
+  }
+
+  @Test
+  public void accumulateRegularBufferStripsBatchHeaderPrefix() throws 
IOException {
+    FlinkShuffleClientImpl client = mockClient();
+    ReadIntegrityTracker tracker = newTracker(client, true);
+    // Only the payload after the PREFIX-byte batch header (what the writer 
hashed) is accumulated.
+    byte[] framed = bytesOfLength(PREFIX + 24);
+    tracker.accumulateRegularBuffer(Unpooled.wrappedBuffer(framed));
+    assertTrue(tracker.isEnabled());
+    tracker.report(NO_OP);
+
+    CommitMetadata expected = new CommitMetadata();
+    expected.addData(ByteBuffer.wrap(Arrays.copyOfRange(framed, PREFIX, 
framed.length)));
+    verifyReported(client, expected);
+  }
+
+  @Test
+  public void accumulateRegularBufferDisablesOnUnexpectedFraming() {
+    // The regular path never splits large buffers, so any composite buffer is 
unexpected.
+    CompositeByteBuf composite = Unpooled.compositeBuffer();
+    composite.addComponent(true, Unpooled.wrappedBuffer(bytesOfLength(32)));
+    assertRegularBufferDisables(composite);
+
+    // A plain buffer too short to hold the batch header.
+    assertRegularBufferDisables(Unpooled.wrappedBuffer(bytesOfLength(PREFIX - 
1)));
+  }
+
+  /** Feeding {@code bad} to the regular path must disable tracking and report 
nothing. */
+  private void assertRegularBufferDisables(ByteBuf bad) {
+    FlinkShuffleClientImpl client = mockClient();
+    ReadIntegrityTracker tracker = newTracker(client, true);
+    tracker.accumulateRegularBuffer(bad);
+    assertFalse(tracker.isEnabled());
+    tracker.report(NO_OP);
+    verifyNoInteractions(client);
+  }
+
+  @Test
+  public void accumulateTieredBufferStripsPrefixFromPlainBuffer() throws 
IOException {
+    FlinkShuffleClientImpl client = mockClient();
+    ReadIntegrityTracker tracker = newTracker(client, true);
+    byte[] framed = bytesOfLength(PREFIX + 24);
+    tracker.accumulateTieredBuffer(Unpooled.wrappedBuffer(framed), PREFIX);
+    assertTrue(tracker.isEnabled());
+    tracker.report(NO_OP);
+
+    CommitMetadata expected = new CommitMetadata();
+    expected.addData(ByteBuffer.wrap(Arrays.copyOfRange(framed, PREFIX, 
framed.length)));
+    verifyReported(client, expected);
+  }
+
+  @Test
+  public void accumulateTieredBufferHashesHeaderTailAndDataOfComposite() 
throws IOException {
+    FlinkShuffleClientImpl client = mockClient();
+    ReadIntegrityTracker tracker = newTracker(client, true);
+    // Large buffers arrive as a header component (batch header) plus a data 
component.
+    byte[] header = bytesOfLength(BufferUtils.HEADER_LENGTH);
+    byte[] data = "tiered-data-component".getBytes();
+    CompositeByteBuf composite = Unpooled.compositeBuffer();
+    composite.addComponent(true, Unpooled.wrappedBuffer(header));
+    composite.addComponent(true, Unpooled.wrappedBuffer(data));
+
+    tracker.accumulateTieredBuffer(composite, PREFIX);
+    assertTrue(tracker.isEnabled());
+    tracker.report(NO_OP);
+
+    CommitMetadata expected = new CommitMetadata();
+    expected.addData(
+        ByteBuffer.wrap(Arrays.copyOfRange(header, PREFIX, header.length)), 
ByteBuffer.wrap(data));
+    verifyReported(client, expected);
+  }
+
+  @Test
+  public void accumulateTieredBufferDisablesOnUnexpectedFraming() {
+    // A composite with a component count other than the expected header + 
data.
+    CompositeByteBuf threeComponents = Unpooled.compositeBuffer();
+    threeComponents.addComponent(
+        true, 
Unpooled.wrappedBuffer(bytesOfLength(BufferUtils.HEADER_LENGTH)));
+    threeComponents.addComponent(true, 
Unpooled.wrappedBuffer(bytesOfLength(8)));
+    threeComponents.addComponent(true, 
Unpooled.wrappedBuffer(bytesOfLength(8)));
+    assertTieredBufferDisables(threeComponents);
+
+    // A composite whose header component is shorter than the batch-header 
prefix.
+    CompositeByteBuf shortHeader = Unpooled.compositeBuffer();
+    shortHeader.addComponent(true, Unpooled.wrappedBuffer(bytesOfLength(PREFIX 
- 1)));
+    shortHeader.addComponent(true, Unpooled.wrappedBuffer(bytesOfLength(8)));
+    assertTieredBufferDisables(shortHeader);
+
+    // A plain buffer shorter than the batch-header prefix.
+    assertTieredBufferDisables(Unpooled.wrappedBuffer(bytesOfLength(PREFIX - 
1)));
+  }
+
+  /** Feeding {@code bad} to the tiered path must disable tracking and report 
nothing. */
+  private void assertTieredBufferDisables(ByteBuf bad) {
+    FlinkShuffleClientImpl client = mockClient();
+    ReadIntegrityTracker tracker = newTracker(client, true);
+    tracker.accumulateTieredBuffer(bad, PREFIX);
+    assertFalse(tracker.isEnabled());
+    tracker.report(NO_OP);
+    verifyNoInteractions(client);
+  }
+}
diff --git 
a/client-flink/common/src/test/java/org/apache/celeborn/plugin/flink/RemoteBufferStreamReaderTest.java
 
b/client-flink/common/src/test/java/org/apache/celeborn/plugin/flink/RemoteBufferStreamReaderTest.java
new file mode 100644
index 000000000..bca319ef1
--- /dev/null
+++ 
b/client-flink/common/src/test/java/org/apache/celeborn/plugin/flink/RemoteBufferStreamReaderTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.celeborn.plugin.flink;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Test;
+
+import org.apache.celeborn.common.network.protocol.BufferStreamEnd;
+import org.apache.celeborn.plugin.flink.client.CelebornBufferStream;
+import org.apache.celeborn.plugin.flink.client.FlinkShuffleClientImpl;
+
+public class RemoteBufferStreamReaderTest {
+
+  private static final int SHUFFLE_ID = 1;
+  private static final int PARTITION_ID = 2;
+  private static final int START_SUB = 0;
+  private static final int END_SUB = 3;
+  private static final long STREAM_ID = 99L;
+
+  /** An opened reader whose last-partition stream end reports and the report 
RPC throws. */
+  private RemoteBufferStreamReader openedReaderWithFailingReport(
+      FlinkShuffleClientImpl client, List<Throwable> failures) throws 
IOException {
+    CelebornBufferStream stream = mock(CelebornBufferStream.class);
+    when(stream.hasRemainingPartitions()).thenReturn(false);
+    when(client.readBufferedPartition(anyInt(), anyInt(), anyInt(), anyInt(), 
anyBoolean()))
+        .thenReturn(stream);
+    doThrow(new IOException("report failed"))
+        .when(client)
+        .readReducerPartitionEnd(anyInt(), anyInt(), anyInt(), anyInt(), 
anyInt(), anyLong());
+    ShuffleResourceDescriptor descriptor =
+        new ShuffleResourceDescriptor(SHUFFLE_ID, 0, 0, PARTITION_ID);
+    RemoteBufferStreamReader reader =
+        new RemoteBufferStreamReader(
+            client, descriptor, START_SUB, END_SUB, null, buffer -> {}, 
failures::add, true);
+    reader.open(0);
+    return reader;
+  }
+
+  @Test
+  public void onStreamEndNotifiesFailureListenerWhenOpen() throws IOException {
+    FlinkShuffleClientImpl client = mock(FlinkShuffleClientImpl.class);
+    List<Throwable> failures = new ArrayList<>();
+    RemoteBufferStreamReader reader = openedReaderWithFailingReport(client, 
failures);
+
+    reader.onStreamEnd(new BufferStreamEnd(STREAM_ID));
+
+    assertEquals(1, failures.size());
+    assertTrue(failures.get(0) instanceof IOException);
+  }
+
+  @Test
+  public void onStreamEndSkipsFailureListenerAfterClose() throws IOException {
+    FlinkShuffleClientImpl client = mock(FlinkShuffleClientImpl.class);
+    List<Throwable> failures = new ArrayList<>();
+    RemoteBufferStreamReader reader = openedReaderWithFailingReport(client, 
failures);
+    // A close racing the stream end must swallow the report failure, not fail 
a dead channel.
+    reader.close();
+
+    reader.onStreamEnd(new BufferStreamEnd(STREAM_ID));
+
+    assertTrue(failures.isEmpty());
+  }
+}
diff --git 
a/client-flink/common/src/test/java/org/apache/celeborn/plugin/flink/client/CelebornBufferStreamTest.java
 
b/client-flink/common/src/test/java/org/apache/celeborn/plugin/flink/client/CelebornBufferStreamTest.java
new file mode 100644
index 000000000..1546d251f
--- /dev/null
+++ 
b/client-flink/common/src/test/java/org/apache/celeborn/plugin/flink/client/CelebornBufferStreamTest.java
@@ -0,0 +1,51 @@
+/*
+ * 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.celeborn.plugin.flink.client;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import org.apache.celeborn.common.protocol.PartitionLocation;
+
+public class CelebornBufferStreamTest {
+
+  // hasRemainingPartitions only reads locations.length, so null elements 
suffice here.
+  private static CelebornBufferStream streamWithLocations(int numLocations) {
+    return new CelebornBufferStream(
+        null, null, "shuffleKey", new PartitionLocation[numLocations], 0, 0, 
0L, null);
+  }
+
+  @Test
+  public void hasRemainingPartitionsTracksLocationIndexBoundary() {
+    // openStreamInternal advances currentLocationIndex past the partition it 
opened, so the last
+    // partition's stream end sees index == length, where lastPartition = 
!hasRemainingPartitions().
+    CelebornBufferStream stream = streamWithLocations(2);
+    assertTrue(stream.hasRemainingPartitions()); // index 0 < 2
+    stream.setCurrentLocationIndex(1);
+    assertTrue(stream.hasRemainingPartitions()); // index 1 < 2, more 
partitions remain
+    stream.setCurrentLocationIndex(2);
+    assertFalse(stream.hasRemainingPartitions()); // index 2 == 2, last 
partition consumed
+  }
+
+  @Test
+  public void hasRemainingPartitionsIsFalseWithoutLocations() {
+    assertFalse(streamWithLocations(0).hasRemainingPartitions());
+  }
+}
diff --git 
a/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java 
b/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
index be2bdf87d..225a9cb99 100644
--- a/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
+++ b/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
@@ -120,7 +120,7 @@ public class ShuffleClientImpl extends ShuffleClient {
 
   private final boolean pushExcludeWorkerOnFailureEnabled;
   private final boolean shuffleCompressionEnabled;
-  private final boolean shuffleIntegrityCheckEnabled;
+  protected final boolean shuffleIntegrityCheckEnabled;
 
   private final Set<String> pushExcludedWorkers = 
ConcurrentHashMap.newKeySet();
   private final ConcurrentHashMap<String, Long> fetchExcludedWorkers =
diff --git 
a/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala 
b/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala
index d8c48420d..f9508cfe6 100644
--- a/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala
+++ b/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala
@@ -466,6 +466,9 @@ class LifecycleManager(val appUniqueId: String, val conf: 
CelebornConf) extends
             attemptId,
             partitionId,
             numMappers,
+            numPartitions,
+            crc32PerPartition,
+            bytesWrittenPerPartition,
             serdeVersion)
         case _ =>
           throw new UnsupportedOperationException(s"Not support $partitionType 
yet")
@@ -474,7 +477,9 @@ class LifecycleManager(val appUniqueId: String, val conf: 
CelebornConf) extends
     case pb: ReadReducerPartitionEnd =>
       val partitionType = getPartitionType(pb.shuffleId)
       partitionType match {
-        case PartitionType.REDUCE =>
+        // Map partitions reuse this reducer-named RPC/handler; for MAP, 
partitionId is the map
+        // partition id and [startMapIndex, endMapIndex] is the consumed 
subpartition range.
+        case PartitionType.REDUCE | PartitionType.MAP =>
           handleReducerPartitionEnd(
             context,
             pb.shuffleId,
@@ -559,7 +564,7 @@ class LifecycleManager(val appUniqueId: String, val conf: 
CelebornConf) extends
           StatusCode.SUCCESS.getValue).build()
     } else {
       response = PbReadReducerPartitionEndResponse.newBuilder().setStatus(
-        
+StatusCode.READ_REDUCER_PARTITION_END_FAILED.getValue).setErrorMsg(errorMessage).build()
+        
StatusCode.READ_REDUCER_PARTITION_END_FAILED.getValue).setErrorMsg(errorMessage).build()
     }
     context.reply(response)
   }
@@ -1227,6 +1232,9 @@ class LifecycleManager(val appUniqueId: String, val conf: 
CelebornConf) extends
       attemptId: Int,
       partitionId: Int,
       numMappers: Int,
+      numPartitions: Int,
+      crc32PerPartition: Array[Int],
+      bytesWrittenPerPartition: Array[Long],
       serdeVersion: SerdeVersion): Unit = {
     def reply(result: Boolean): Unit = {
       val message =
@@ -1243,7 +1251,15 @@ class LifecycleManager(val appUniqueId: String, val 
conf: CelebornConf) extends
     }
 
     val (mapperAttemptFinishedSuccess, _) =
-      commitManager.finishMapperAttempt(shuffleId, mapId, attemptId, 
numMappers, partitionId)
+      commitManager.finishMapperAttempt(
+        shuffleId,
+        mapId,
+        attemptId,
+        numMappers,
+        partitionId,
+        numPartitions = numPartitions,
+        crc32PerPartition = crc32PerPartition,
+        bytesWrittenPerPartition = bytesWrittenPerPartition)
     reply(mapperAttemptFinishedSuccess)
   }
 
diff --git 
a/client/src/main/scala/org/apache/celeborn/client/commit/CommitHandler.scala 
b/client/src/main/scala/org/apache/celeborn/client/commit/CommitHandler.scala
index 4189ee5f7..f54c2b990 100644
--- 
a/client/src/main/scala/org/apache/celeborn/client/commit/CommitHandler.scala
+++ 
b/client/src/main/scala/org/apache/celeborn/client/commit/CommitHandler.scala
@@ -432,7 +432,9 @@ abstract class CommitHandler(
   }
 
   /**
-   * Invoked when a reduce partition finishes reading data to perform end to 
end integrity check validation
+   * Invoked when a partition (reduce or map) finishes reading data to perform 
end to end integrity
+   * check validation. See `MapPartitionCommitHandler.finishPartition` for the 
map-partition
+   * semantics of the parameters.
    */
   def finishPartition(
       shuffleId: Int,
diff --git 
a/client/src/main/scala/org/apache/celeborn/client/commit/MapPartitionCommitHandler.scala
 
b/client/src/main/scala/org/apache/celeborn/client/commit/MapPartitionCommitHandler.scala
index 9299768b6..04701f2f4 100644
--- 
a/client/src/main/scala/org/apache/celeborn/client/commit/MapPartitionCommitHandler.scala
+++ 
b/client/src/main/scala/org/apache/celeborn/client/commit/MapPartitionCommitHandler.scala
@@ -76,6 +76,19 @@ class MapPartitionCommitHandler(
   // shuffleId -> boolean, records whether the shuffle is visible at the 
segment level, facilitating future optimization of worker read and write 
processes
   private val shuffleIsSegmentGranularityVisible = 
JavaUtils.newConcurrentHashMap[Int, Boolean]
 
+  private val shuffleIntegrityCheckEnabled = 
conf.clientShuffleIntegrityCheckEnabled
+
+  // Write-side per-subpartition checksums of one finished map partition 
(indexed by subpartition).
+  private case class MapPartitionWriteMetadata(crc32: Array[Int], 
bytesWritten: Array[Long]) {
+    require(
+      crc32.length == bytesWritten.length,
+      s"crc32 length ${crc32.length} != bytesWritten length 
${bytesWritten.length}")
+  }
+
+  // shuffleId -> (mapPartitionId -> write-side metadata).
+  private val commitMetadataForMapPartition =
+    JavaUtils.newConcurrentHashMap[Int, ConcurrentHashMap[Int, 
MapPartitionWriteMetadata]]()
+
   override def getPartitionType(): PartitionType = {
     PartitionType.MAP
   }
@@ -133,6 +146,7 @@ class MapPartitionCommitHandler(
     inProcessMapPartitionEndIds.remove(shuffleId)
     shuffleSucceedPartitionIds.remove(shuffleId)
     shuffleIsSegmentGranularityVisible.remove(shuffleId)
+    commitMetadataForMapPartition.remove(shuffleId)
     super.removeExpiredShuffle(shuffleId)
   }
 
@@ -231,11 +245,46 @@ class MapPartitionCommitHandler(
           shuffleId,
           (k: Int) => ConcurrentHashMap.newKeySet[Integer]())
       resultPartitions.add(partitionId)
+
+      if (shuffleIntegrityCheckEnabled) {
+        recordMapPartitionCommitMetadata(
+          shuffleId,
+          partitionId,
+          numPartitions,
+          crc32PerPartition,
+          bytesWrittenPerPartition)
+      }
     }
 
     (dataCommitSuccess, false)
   }
 
+  /**
+   * Records a finished mapper's per-subpartition write-side checksums, keyed 
by (shuffleId,
+   * mapPartitionId), for later validation in [[finishPartition]]. A 
retried/duplicate attempt
+   * overwrites the prior record (last write wins), which is intentional: the 
reader validates
+   * against the last committed attempt. Visible for testing.
+   */
+  private[commit] def recordMapPartitionCommitMetadata(
+      shuffleId: Int,
+      mapPartitionId: Int,
+      numPartitions: Int,
+      crc32PerPartition: Array[Int],
+      bytesWrittenPerPartition: Array[Long]): Unit = {
+    if (crc32PerPartition == null || crc32PerPartition.length != numPartitions 
||
+      bytesWrittenPerPartition == null || bytesWrittenPerPartition.length != 
numPartitions) {
+      logWarning(
+        s"Skip recording commit metadata for shuffle $shuffleId map partition 
$mapPartitionId " +
+          s"because reported checksum arrays do not match numPartitions 
$numPartitions.")
+      return
+    }
+    commitMetadataForMapPartition
+      .computeIfAbsent(
+        shuffleId,
+        (_: Int) => JavaUtils.newConcurrentHashMap[Int, 
MapPartitionWriteMetadata]())
+      .put(mapPartitionId, MapPartitionWriteMetadata(crc32PerPartition, 
bytesWrittenPerPartition))
+  }
+
   override def registerShuffle(
       shuffleId: Int,
       numMappers: Int,
@@ -243,19 +292,69 @@ class MapPartitionCommitHandler(
       numPartitions: Int): Unit = {
     super.registerShuffle(shuffleId, numMappers, isSegmentGranularityVisible, 
numPartitions)
     shuffleIsSegmentGranularityVisible.put(shuffleId, 
isSegmentGranularityVisible)
+    // The outer metadata map is created lazily by 
recordMapPartitionCommitMetadata.
   }
 
   override def isSegmentGranularityVisible(shuffleId: Int): Boolean = {
     shuffleIsSegmentGranularityVisible.get(shuffleId)
   }
 
+  /**
+   * Validates a map partition read over the consumed `[startSubIndex, 
endSubIndex]` range against
+   * the order-independent combination of the write-side checksums over that 
range. The params
+   * rename the trait's reducer-oriented signature 
(partitionId/startMapIndex/endMapIndex) to map
+   * semantics. A reader reaches stream end only after commit, so the 
write-side metadata is already
+   * recorded; missing metadata fails closed.
+   */
   override def finishPartition(
       shuffleId: Int,
-      partitionId: Int,
-      startMapIndex: Int,
-      endMapIndex: Int,
+      mapPartitionId: Int,
+      startSubIndex: Int,
+      endSubIndex: Int,
       actualCommitMetadata: CommitMetadata): (Boolean, String) = {
-    throw new UnsupportedOperationException()
+    if (!shuffleIntegrityCheckEnabled) {
+      return (true, "")
+    }
+    val perMapPartition = commitMetadataForMapPartition.get(shuffleId)
+    if (perMapPartition == null) {
+      return (
+        false,
+        s"No write-side commit metadata recorded for shuffle $shuffleId when 
validating " +
+          s"map partition $mapPartitionId subpartitions [$startSubIndex, 
$endSubIndex].")
+    }
+    val subPartitionMetadata = perMapPartition.get(mapPartitionId)
+    if (subPartitionMetadata == null) {
+      return (
+        false,
+        s"No write-side commit metadata recorded for shuffle $shuffleId map 
partition " +
+          s"$mapPartitionId when validating subpartitions [$startSubIndex, 
$endSubIndex].")
+    }
+    val crc32PerSubPartition = subPartitionMetadata.crc32
+    val bytesPerSubPartition = subPartitionMetadata.bytesWritten
+    if (startSubIndex < 0 || endSubIndex >= crc32PerSubPartition.length ||
+      startSubIndex > endSubIndex) {
+      return (
+        false,
+        s"Invalid subpartition range [$startSubIndex, $endSubIndex] for 
shuffle $shuffleId " +
+          s"map partition $mapPartitionId with ${crc32PerSubPartition.length} 
subpartitions.")
+    }
+    val expectedCommitMetadata = new CommitMetadata()
+    var subIndex = startSubIndex
+    while (subIndex <= endSubIndex) {
+      expectedCommitMetadata.addCommitData(
+        crc32PerSubPartition(subIndex),
+        bytesPerSubPartition(subIndex))
+      subIndex += 1
+    }
+    if (CommitMetadata.checkCommitMetadata(expectedCommitMetadata, 
actualCommitMetadata)) {
+      (true, "")
+    } else {
+      (
+        false,
+        s"Integrity check failed for shuffle $shuffleId map partition 
$mapPartitionId " +
+          s"subpartitions [$startSubIndex, $endSubIndex], expected 
$expectedCommitMetadata " +
+          s"but read $actualCommitMetadata.")
+    }
   }
 
   override def handleGetReducerFileGroup(
diff --git 
a/client/src/test/scala/org/apache/celeborn/client/commit/MapPartitionCommitHandlerTest.scala
 
b/client/src/test/scala/org/apache/celeborn/client/commit/MapPartitionCommitHandlerTest.scala
new file mode 100644
index 000000000..47a485b60
--- /dev/null
+++ 
b/client/src/test/scala/org/apache/celeborn/client/commit/MapPartitionCommitHandlerTest.scala
@@ -0,0 +1,299 @@
+/*
+ * 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.celeborn.client.commit
+
+import java.util.concurrent.{ConcurrentHashMap, ConcurrentLinkedQueue, 
CountDownLatch, ScheduledExecutorService, ThreadPoolExecutor, TimeUnit}
+
+import org.apache.celeborn.CelebornFunSuite
+import org.apache.celeborn.client.{ShuffleCommittedInfo, WorkerStatusTracker}
+import org.apache.celeborn.client.CommitManager.CommittedPartitionInfo
+import org.apache.celeborn.client.LifecycleManager.ShuffleAllocatedWorkers
+import org.apache.celeborn.common.{CelebornConf, CommitMetadata}
+import org.apache.celeborn.common.util.ThreadUtils
+
+class MapPartitionCommitHandlerTest extends CelebornFunSuite {
+
+  // The handler spins up daemon pools; skip the thread audit to avoid flaky 
leak warnings.
+  override protected val enableAutoThreadAudit = false
+
+  private var rpcPool: ThreadPoolExecutor = _
+  private var commitScheduler: ScheduledExecutorService = _
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    rpcPool = ThreadUtils.newDaemonCachedThreadPool("test-map-commit-rpc")
+    commitScheduler =
+      
ThreadUtils.newDaemonSingleThreadScheduledExecutor("test-map-commit-scheduler")
+  }
+
+  override def afterAll(): Unit = {
+    if (rpcPool != null) {
+      rpcPool.shutdownNow()
+    }
+    if (commitScheduler != null) {
+      commitScheduler.shutdownNow()
+    }
+    super.afterAll()
+  }
+
+  private def newHandler(integrityCheckEnabled: Boolean): 
MapPartitionCommitHandler = {
+    val conf = new CelebornConf()
+    conf.set(
+      CelebornConf.CLIENT_SHUFFLE_INTEGRITY_CHECK_ENABLED.key,
+      integrityCheckEnabled.toString)
+    new MapPartitionCommitHandler(
+      "test-app",
+      conf,
+      new ShuffleAllocatedWorkers(),
+      new CommittedPartitionInfo(),
+      new WorkerStatusTracker(conf, null),
+      rpcPool,
+      commitScheduler)
+  }
+
+  /** Write-side per-subpartition checksum/byte arrays computed over the given 
payloads. */
+  private def writeSideMetadata(payloads: Seq[Array[Byte]]): (Array[Int], 
Array[Long]) = {
+    val crc32PerSubPartition = new Array[Int](payloads.length)
+    val bytesPerSubPartition = new Array[Long](payloads.length)
+    payloads.zipWithIndex.foreach { case (payload, subIndex) =>
+      val commitMetadata = new CommitMetadata()
+      commitMetadata.addDataWithOffsetAndLength(payload, 0, payload.length)
+      crc32PerSubPartition(subIndex) = commitMetadata.getChecksum
+      bytesPerSubPartition(subIndex) = commitMetadata.getBytes
+    }
+    (crc32PerSubPartition, bytesPerSubPartition)
+  }
+
+  /** Read-side commit metadata accumulated over the inclusive subpartition 
range. */
+  private def readSideMetadata(
+      payloads: Seq[Array[Byte]],
+      startSubIndex: Int,
+      endSubIndex: Int): CommitMetadata = {
+    val commitMetadata = new CommitMetadata()
+    (startSubIndex to endSubIndex).foreach { subIndex =>
+      val payload = payloads(subIndex)
+      commitMetadata.addDataWithOffsetAndLength(payload, 0, payload.length)
+    }
+    commitMetadata
+  }
+
+  private val shuffleId = 1
+  private val mapPartitionId = 0
+  private val payloads =
+    Seq("sub-0-bytes".getBytes(), "sub-1-bytes".getBytes(), 
"sub-2-bytes".getBytes())
+  private val numPartitions = payloads.length
+
+  test("finishPartition returns success without validation when integrity 
check is disabled") {
+    val handler = newHandler(integrityCheckEnabled = false)
+    // No metadata recorded, yet a disabled check must short-circuit to 
success.
+    val (isValid, message) =
+      handler.finishPartition(shuffleId, mapPartitionId, 0, numPartitions - 1, 
new CommitMetadata())
+    assert(isValid)
+    assert(message.isEmpty)
+  }
+
+  test("finishPartition fails when no commit metadata was recorded for the 
shuffle") {
+    val handler = newHandler(integrityCheckEnabled = true)
+    // Neither registerShuffle nor recordMapPartitionCommitMetadata called for 
this shuffle.
+    val (isValid, message) =
+      handler.finishPartition(shuffleId, mapPartitionId, 0, numPartitions - 1, 
new CommitMetadata())
+    assert(!isValid)
+    assert(message.contains(s"No write-side commit metadata recorded for 
shuffle $shuffleId"))
+  }
+
+  test("finishPartition fails when no commit metadata was recorded for the map 
partition") {
+    val handler = newHandler(integrityCheckEnabled = true)
+    val (crc32PerSubPartition, bytesPerSubPartition) = 
writeSideMetadata(payloads)
+    // Record a different map partition so the outer map exists but this one's 
entry is absent.
+    handler.recordMapPartitionCommitMetadata(
+      shuffleId,
+      mapPartitionId + 1,
+      numPartitions,
+      crc32PerSubPartition,
+      bytesPerSubPartition)
+    val (isValid, message) =
+      handler.finishPartition(shuffleId, mapPartitionId, 0, numPartitions - 1, 
new CommitMetadata())
+    assert(!isValid)
+    assert(message.contains(s"shuffle $shuffleId map partition 
$mapPartitionId"))
+  }
+
+  test("recordMapPartitionCommitMetadata skips reported arrays that do not 
match numPartitions") {
+    val handler = newHandler(integrityCheckEnabled = true)
+    handler.registerShuffle(shuffleId, 1, isSegmentGranularityVisible = true, 
numPartitions)
+    // A crc32 array shorter than numPartitions is rejected, so validation 
later fails closed.
+    val (crc32PerSubPartition, bytesPerSubPartition) = 
writeSideMetadata(payloads)
+    handler.recordMapPartitionCommitMetadata(
+      shuffleId,
+      mapPartitionId,
+      numPartitions,
+      crc32PerSubPartition.dropRight(1),
+      bytesPerSubPartition)
+    val (isValid, message) =
+      handler.finishPartition(shuffleId, mapPartitionId, 0, numPartitions - 1, 
new CommitMetadata())
+    assert(!isValid)
+    assert(message.contains(s"map partition $mapPartitionId"))
+  }
+
+  test("finishPartition fails closed after the shuffle metadata was 
expired/removed") {
+    val handler = newHandler(integrityCheckEnabled = true)
+    handler.registerShuffle(shuffleId, 1, isSegmentGranularityVisible = true, 
numPartitions)
+    val (crc32PerSubPartition, bytesPerSubPartition) = 
writeSideMetadata(payloads)
+    handler.recordMapPartitionCommitMetadata(
+      shuffleId,
+      mapPartitionId,
+      numPartitions,
+      crc32PerSubPartition,
+      bytesPerSubPartition)
+    // Cleanup racing a late reader stream end drops the metadata; validation 
must fail, not pass.
+    handler.removeExpiredShuffle(shuffleId)
+    val actual = readSideMetadata(payloads, 0, numPartitions - 1)
+    val (isValid, message) =
+      handler.finishPartition(shuffleId, mapPartitionId, 0, numPartitions - 1, 
actual)
+    assert(!isValid)
+    assert(message.contains(s"No write-side commit metadata recorded for 
shuffle $shuffleId"))
+  }
+
+  test("finishPartition fails for an out-of-bounds subpartition range") {
+    val handler = newHandler(integrityCheckEnabled = true)
+    val (crc32PerSubPartition, bytesPerSubPartition) = 
writeSideMetadata(payloads)
+    handler.recordMapPartitionCommitMetadata(
+      shuffleId,
+      mapPartitionId,
+      numPartitions,
+      crc32PerSubPartition,
+      bytesPerSubPartition)
+    val (isValid, message) =
+      handler.finishPartition(shuffleId, mapPartitionId, 0, numPartitions, new 
CommitMetadata())
+    assert(!isValid)
+    assert(message.contains("Invalid subpartition range"))
+  }
+
+  test("finishPartition succeeds when the read-side metadata matches the 
write-side range") {
+    val handler = newHandler(integrityCheckEnabled = true)
+    val (crc32PerSubPartition, bytesPerSubPartition) = 
writeSideMetadata(payloads)
+    handler.recordMapPartitionCommitMetadata(
+      shuffleId,
+      mapPartitionId,
+      numPartitions,
+      crc32PerSubPartition,
+      bytesPerSubPartition)
+    val startSubIndex = 0
+    val endSubIndex = 1
+    val actual = readSideMetadata(payloads, startSubIndex, endSubIndex)
+    val (isValid, message) =
+      handler.finishPartition(shuffleId, mapPartitionId, startSubIndex, 
endSubIndex, actual)
+    assert(isValid)
+    assert(message.isEmpty)
+  }
+
+  test("finishPartition fails when the read-side metadata does not match the 
write-side range") {
+    val handler = newHandler(integrityCheckEnabled = true)
+    val (crc32PerSubPartition, bytesPerSubPartition) = 
writeSideMetadata(payloads)
+    handler.recordMapPartitionCommitMetadata(
+      shuffleId,
+      mapPartitionId,
+      numPartitions,
+      crc32PerSubPartition,
+      bytesPerSubPartition)
+    val startSubIndex = 0
+    val endSubIndex = 1
+    // Flip a byte in the consumed payload to simulate a corrupted read.
+    val corrupted = payloads(endSubIndex).clone()
+    corrupted(0) = (corrupted(0) ^ 0x01).toByte
+    val actual =
+      readSideMetadata(payloads.updated(endSubIndex, corrupted), 
startSubIndex, endSubIndex)
+    val (isValid, message) =
+      handler.finishPartition(shuffleId, mapPartitionId, startSubIndex, 
endSubIndex, actual)
+    assert(!isValid)
+    assert(message.contains("Integrity check failed"))
+  }
+
+  test(
+    "finishPartition fails on a byte-count mismatch even when the checksum 
coincidentally matches") {
+    val handler = newHandler(integrityCheckEnabled = true)
+    val (crc32PerSubPartition, bytesPerSubPartition) = 
writeSideMetadata(payloads)
+    handler.recordMapPartitionCommitMetadata(
+      shuffleId,
+      mapPartitionId,
+      numPartitions,
+      crc32PerSubPartition,
+      bytesPerSubPartition)
+    val startSubIndex = 0
+    val endSubIndex = 1
+    // Expected checksum but a wrong byte count (as if a CRC32 collision hid a 
short/over read).
+    val expected = new CommitMetadata()
+    (startSubIndex to endSubIndex).foreach { i =>
+      expected.addCommitData(crc32PerSubPartition(i), bytesPerSubPartition(i))
+    }
+    val actual = new CommitMetadata(expected.getChecksum, expected.getBytes + 
1)
+    assert(expected.getChecksum == actual.getChecksum)
+    assert(expected.getBytes != actual.getBytes)
+    val (isValid, message) =
+      handler.finishPartition(shuffleId, mapPartitionId, startSubIndex, 
endSubIndex, actual)
+    assert(!isValid)
+    assert(message.contains("Integrity check failed"))
+  }
+
+  test("recordMapPartitionCommitMetadata is safe under concurrent mappers") {
+    val handler = newHandler(integrityCheckEnabled = true)
+    val numMapPartitions = 16
+    handler.registerShuffle(
+      shuffleId,
+      numMapPartitions,
+      isSegmentGranularityVisible = true,
+      numPartitions)
+    val (crc32PerSubPartition, bytesPerSubPartition) = 
writeSideMetadata(payloads)
+
+    val startLatch = new CountDownLatch(1)
+    val doneLatch = new CountDownLatch(numMapPartitions)
+    val errors = new ConcurrentLinkedQueue[Throwable]()
+    (0 until numMapPartitions).foreach { mapPartition =>
+      val thread = new Thread(new Runnable {
+        override def run(): Unit = {
+          try {
+            startLatch.await()
+            handler.recordMapPartitionCommitMetadata(
+              shuffleId,
+              mapPartition,
+              numPartitions,
+              crc32PerSubPartition,
+              bytesPerSubPartition)
+          } catch {
+            case t: Throwable => errors.add(t)
+          } finally {
+            doneLatch.countDown()
+          }
+        }
+      })
+      thread.setDaemon(true)
+      thread.start()
+    }
+    // Release all threads at once to maximize contention on the shuffle's 
metadata map.
+    startLatch.countDown()
+    assert(doneLatch.await(30, TimeUnit.SECONDS), "concurrent record timed 
out")
+    assert(errors.isEmpty, s"concurrent record threw: 
${errors.toArray.mkString(", ")}")
+
+    // Every mapper's metadata must have been recorded and independently 
validate.
+    (0 until numMapPartitions).foreach { mapPartition =>
+      val actual = readSideMetadata(payloads, 0, numPartitions - 1)
+      val (isValid, message) =
+        handler.finishPartition(shuffleId, mapPartition, 0, numPartitions - 1, 
actual)
+      assert(isValid, s"map partition $mapPartition failed validation: 
$message")
+    }
+  }
+}
diff --git a/common/src/main/java/org/apache/celeborn/common/CelebornCRC32.java 
b/common/src/main/java/org/apache/celeborn/common/CelebornCRC32.java
index c4c71efa6..87997e3a0 100644
--- a/common/src/main/java/org/apache/celeborn/common/CelebornCRC32.java
+++ b/common/src/main/java/org/apache/celeborn/common/CelebornCRC32.java
@@ -17,6 +17,7 @@
 
 package org.apache.celeborn.common;
 
+import java.nio.ByteBuffer;
 import java.util.Objects;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.zip.CRC32;
@@ -45,6 +46,19 @@ public class CelebornCRC32 {
     return (int) hashFunction.getValue();
   }
 
+  static int compute(ByteBuffer buffer) {
+    CRC32 hashFunction = new CRC32();
+    hashFunction.update(buffer);
+    return (int) hashFunction.getValue();
+  }
+
+  static int compute(ByteBuffer first, ByteBuffer second) {
+    CRC32 hashFunction = new CRC32();
+    hashFunction.update(first);
+    hashFunction.update(second);
+    return (int) hashFunction.getValue();
+  }
+
   static int combine(int first, int second) {
     first =
         (((byte) second + (byte) first) & 0xFF)
@@ -68,6 +82,14 @@ public class CelebornCRC32 {
     addChecksum(compute(bytes, offset, length));
   }
 
+  void addData(ByteBuffer buffer) {
+    addChecksum(compute(buffer));
+  }
+
+  void addData(ByteBuffer first, ByteBuffer second) {
+    addChecksum(compute(first, second));
+  }
+
   int get() {
     return current.get();
   }
diff --git 
a/common/src/main/java/org/apache/celeborn/common/CommitMetadata.java 
b/common/src/main/java/org/apache/celeborn/common/CommitMetadata.java
index 39b84883c..814d944ba 100644
--- a/common/src/main/java/org/apache/celeborn/common/CommitMetadata.java
+++ b/common/src/main/java/org/apache/celeborn/common/CommitMetadata.java
@@ -17,6 +17,7 @@
 
 package org.apache.celeborn.common;
 
+import java.nio.ByteBuffer;
 import java.util.Objects;
 import java.util.concurrent.atomic.AtomicLong;
 
@@ -40,6 +41,23 @@ public class CommitMetadata {
     this.crc.addData(rawDataBuf, offset, length);
   }
 
+  /**
+   * Accumulates checksum and byte count over the remaining bytes of {@code 
buffer} (a transient
+   * {@code nioBuffer} view, so no copy), advancing its position to the limit.
+   */
+  public void addData(ByteBuffer buffer) {
+    this.bytes.addAndGet(buffer.remaining());
+    this.crc.addData(buffer);
+  }
+
+  /**
+   * Like {@link #addData(ByteBuffer)} but over two concatenated buffers (e.g. 
a split header/data).
+   */
+  public void addData(ByteBuffer first, ByteBuffer second) {
+    this.bytes.addAndGet((long) first.remaining() + second.remaining());
+    this.crc.addData(first, second);
+  }
+
   public void addCommitData(CommitMetadata commitMetadata) {
     addCommitData(commitMetadata.getChecksum(), commitMetadata.getBytes());
   }
diff --git 
a/common/src/main/java/org/apache/celeborn/common/write/PushState.java 
b/common/src/main/java/org/apache/celeborn/common/write/PushState.java
index 46714c4e8..e213bf6a1 100644
--- a/common/src/main/java/org/apache/celeborn/common/write/PushState.java
+++ b/common/src/main/java/org/apache/celeborn/common/write/PushState.java
@@ -18,6 +18,7 @@
 package org.apache.celeborn.common.write;
 
 import java.io.IOException;
+import java.nio.ByteBuffer;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicReference;
@@ -136,4 +137,10 @@ public class PushState {
         commitMetadataMap.computeIfAbsent(partitionId, id -> new 
CommitMetadata());
     commitMetadata.addDataWithOffsetAndLength(data, offset, length);
   }
+
+  public void addData(int partitionId, ByteBuffer data) {
+    CommitMetadata commitMetadata =
+        commitMetadataMap.computeIfAbsent(partitionId, id -> new 
CommitMetadata());
+    commitMetadata.addData(data);
+  }
 }
diff --git 
a/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala 
b/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
index 32c4eb73d..f81dacc56 100644
--- a/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
+++ b/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
@@ -5712,7 +5712,7 @@ object CelebornConf extends Logging {
     buildConf("celeborn.client.shuffle.integrityCheck.enabled")
       .categories("client")
       .version("0.6.1")
-      .doc("When `true`, enables end-to-end integrity checks for Spark 
workloads.")
+      .doc("When `true`, enables end-to-end integrity checks for Spark and 
Flink workloads.")
       .booleanConf
       .createWithDefault(false)
 
diff --git 
a/common/src/test/java/org/apache/celeborn/common/CelebornCRC32Test.java 
b/common/src/test/java/org/apache/celeborn/common/CelebornCRC32Test.java
index 296e37d9c..c5bcc2b0a 100644
--- a/common/src/test/java/org/apache/celeborn/common/CelebornCRC32Test.java
+++ b/common/src/test/java/org/apache/celeborn/common/CelebornCRC32Test.java
@@ -20,6 +20,8 @@ package org.apache.celeborn.common;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotEquals;
 
+import java.nio.ByteBuffer;
+
 import org.junit.Test;
 
 // Test data is generated from https://crccalc.com/ using "CRC-32/ISO-HDLC".
@@ -63,6 +65,48 @@ public class CelebornCRC32Test {
     assertEquals(2918445923L, crc.get() & 0xFFFFFFFFL);
   }
 
+  @Test
+  public void testComputeByteBuffer() {
+    byte[] data = "testdata".getBytes();
+    // A ByteBuffer view must hash to the same value as the byte[] overload.
+    assertEquals(CelebornCRC32.compute(data), 
CelebornCRC32.compute(ByteBuffer.wrap(data)));
+    // A sliced view (offset 4, length 4) must match the offset/length 
overload.
+    assertEquals(
+        CelebornCRC32.compute(data, 4, 4), 
CelebornCRC32.compute(ByteBuffer.wrap(data, 4, 4)));
+  }
+
+  @Test
+  public void testComputeTwoByteBuffers() {
+    byte[] first = "test".getBytes();
+    byte[] second = "data".getBytes();
+    byte[] concatenated = "testdata".getBytes();
+    // Hashing two buffers must equal hashing their concatenation.
+    assertEquals(
+        CelebornCRC32.compute(concatenated),
+        CelebornCRC32.compute(ByteBuffer.wrap(first), 
ByteBuffer.wrap(second)));
+  }
+
+  @Test
+  public void testAddDataByteBuffer() {
+    byte[] data = "testdata".getBytes();
+    CelebornCRC32 fromBuffer = new CelebornCRC32();
+    fromBuffer.addData(ByteBuffer.wrap(data, 4, 4));
+    CelebornCRC32 fromBytes = new CelebornCRC32();
+    fromBytes.addData(data, 4, 4);
+    assertEquals(fromBytes.get(), fromBuffer.get());
+  }
+
+  @Test
+  public void testAddDataTwoByteBuffers() {
+    byte[] first = "test".getBytes();
+    byte[] second = "data".getBytes();
+    CelebornCRC32 fromTwoBuffers = new CelebornCRC32();
+    fromTwoBuffers.addData(ByteBuffer.wrap(first), ByteBuffer.wrap(second));
+    CelebornCRC32 fromConcatenation = new CelebornCRC32();
+    fromConcatenation.addData("testdata".getBytes(), 0, 8);
+    assertEquals(fromConcatenation.get(), fromTwoBuffers.get());
+  }
+
   @Test
   public void testToString() {
     CelebornCRC32 crc = new CelebornCRC32(123456789);
diff --git 
a/common/src/test/java/org/apache/celeborn/common/CommitMetadataTest.java 
b/common/src/test/java/org/apache/celeborn/common/CommitMetadataTest.java
index 3451d386f..a228a796c 100644
--- a/common/src/test/java/org/apache/celeborn/common/CommitMetadataTest.java
+++ b/common/src/test/java/org/apache/celeborn/common/CommitMetadataTest.java
@@ -18,6 +18,10 @@
 package org.apache.celeborn.common;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.ByteBuffer;
 
 import org.junit.Assert;
 import org.junit.Test;
@@ -52,6 +56,89 @@ public class CommitMetadataTest {
         metadata1.getChecksum());
   }
 
+  @Test
+  public void testAddDataByteBuffer() {
+    byte[] data = "testdata".getBytes();
+    // The ByteBuffer overload (read path) must match the byte[] overload 
(write path).
+    CommitMetadata fromBuffer = new CommitMetadata();
+    fromBuffer.addData(ByteBuffer.wrap(data));
+    CommitMetadata fromBytes = new CommitMetadata();
+    fromBytes.addDataWithOffsetAndLength(data, 0, data.length);
+    assertEquals(fromBytes.getBytes(), fromBuffer.getBytes());
+    assertEquals(fromBytes.getChecksum(), fromBuffer.getChecksum());
+  }
+
+  @Test
+  public void testAddDataTwoByteBuffers() {
+    byte[] header = "test".getBytes();
+    byte[] body = "data".getBytes();
+    // A split header/data buffer must hash equal to its concatenation.
+    CommitMetadata fromTwoBuffers = new CommitMetadata();
+    fromTwoBuffers.addData(ByteBuffer.wrap(header), ByteBuffer.wrap(body));
+    CommitMetadata fromConcatenation = new CommitMetadata();
+    fromConcatenation.addData(ByteBuffer.wrap("testdata".getBytes()));
+    assertEquals(fromConcatenation.getBytes(), fromTwoBuffers.getBytes());
+    assertEquals(fromConcatenation.getChecksum(), 
fromTwoBuffers.getChecksum());
+  }
+
+  @Test
+  public void testRangeCombineDetectsCorruption() {
+    // Mirror the map-partition flow: per-subpartition write checksums 
combined vs read bytes
+    // served.
+    byte[][] perSubpartition = {
+      "sub-0-bytes".getBytes(), "sub-1-bytes".getBytes(), 
"sub-2-bytes".getBytes()
+    };
+    int startSubIndex = 0;
+    int endSubIndex = 1;
+
+    CommitMetadata expected = new CommitMetadata();
+    for (int i = startSubIndex; i <= endSubIndex; i++) {
+      CommitMetadata writeSide = new CommitMetadata();
+      writeSide.addDataWithOffsetAndLength(perSubpartition[i], 0, 
perSubpartition[i].length);
+      expected.addCommitData(writeSide.getChecksum(), writeSide.getBytes());
+    }
+
+    // A faithful read over the same range matches.
+    CommitMetadata cleanRead = new CommitMetadata();
+    for (int i = startSubIndex; i <= endSubIndex; i++) {
+      cleanRead.addData(ByteBuffer.wrap(perSubpartition[i]));
+    }
+    assertTrue(CommitMetadata.checkCommitMetadata(expected, cleanRead));
+
+    // A single flipped byte on the read path is detected.
+    byte[] corrupted = perSubpartition[endSubIndex].clone();
+    corrupted[0] ^= 0x01;
+    CommitMetadata corruptedRead = new CommitMetadata();
+    corruptedRead.addData(ByteBuffer.wrap(perSubpartition[startSubIndex]));
+    corruptedRead.addData(ByteBuffer.wrap(corrupted));
+    assertFalse(CommitMetadata.checkCommitMetadata(expected, corruptedRead));
+  }
+
+  @Test
+  public void testRangeCombineIsOrderIndependent() {
+    // Tiered reads accumulate interleaved buffers while the driver combines 
per-subpartition
+    // checksums in order; the combine must be order-independent for the two 
to match.
+    byte[][] perSubpartition = {
+      "sub-0-bytes".getBytes(), "sub-1-bytes".getBytes(), 
"sub-2-bytes".getBytes()
+    };
+
+    CommitMetadata expected = new CommitMetadata();
+    for (byte[] payload : perSubpartition) {
+      CommitMetadata writeSide = new CommitMetadata();
+      writeSide.addDataWithOffsetAndLength(payload, 0, payload.length);
+      expected.addCommitData(writeSide.getChecksum(), writeSide.getBytes());
+    }
+
+    // Read side accumulates the same bytes in reverse order; the result must 
be identical.
+    CommitMetadata reversedRead = new CommitMetadata();
+    for (int i = perSubpartition.length - 1; i >= 0; i--) {
+      reversedRead.addData(ByteBuffer.wrap(perSubpartition[i]));
+    }
+    assertEquals(expected.getChecksum(), reversedRead.getChecksum());
+    assertEquals(expected.getBytes(), reversedRead.getBytes());
+    assertTrue(CommitMetadata.checkCommitMetadata(expected, reversedRead));
+  }
+
   @Test
   public void testCheckCommitMetadata() {
     CommitMetadata expected = new 
CommitMetadata(CelebornCRC32.compute("testdata".getBytes()), 8);
diff --git a/docs/configuration/client.md b/docs/configuration/client.md
index 52d3d984e..aec9fcc5f 100644
--- a/docs/configuration/client.md
+++ b/docs/configuration/client.md
@@ -114,7 +114,7 @@ license: |
 | celeborn.client.shuffle.dynamicResourceEnabled | false | false | When 
enabled, the ChangePartitionManager will obtain candidate workers from the 
availableWorkers pool during heartbeats when worker resource change. | 0.6.0 |  
| 
 | celeborn.client.shuffle.dynamicResourceFactor | 0.5 | false | The 
ChangePartitionManager will check whether (unavailable workers / shuffle 
allocated workers) is more than the factor before obtaining candidate workers 
from the requestSlots RPC response when 
`celeborn.client.shuffle.dynamicResourceEnabled` set true | 0.6.0 |  | 
 | celeborn.client.shuffle.expired.checkInterval | 60s | false | Interval for 
client to check expired shuffles. | 0.3.0 | 
celeborn.shuffle.expired.checkInterval | 
-| celeborn.client.shuffle.integrityCheck.enabled | false | false | When 
`true`, enables end-to-end integrity checks for Spark workloads. | 0.6.1 |  | 
+| celeborn.client.shuffle.integrityCheck.enabled | false | false | When 
`true`, enables end-to-end integrity checks for Spark and Flink workloads. | 
0.6.1 |  | 
 | celeborn.client.shuffle.manager.port | 0 | false | Port used by the 
LifecycleManager on the Driver. | 0.3.0 | celeborn.shuffle.manager.port | 
 | celeborn.client.shuffle.partition.type | REDUCE | false | Type of shuffle's 
partition. | 0.3.0 | celeborn.shuffle.partition.type | 
 | celeborn.client.shuffle.partitionSplit.mode | SOFT | false | soft: the 
shuffle file size might be larger than split threshold. hard: the shuffle file 
size will be limited to split threshold. | 0.3.0 | 
celeborn.shuffle.partitionSplit.mode | 
diff --git 
a/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/HybridShuffleWordCountTest.scala
 
b/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/HybridShuffleWordCountTest.scala
index 1bfa24733..6794f23df 100644
--- 
a/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/HybridShuffleWordCountTest.scala
+++ 
b/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/HybridShuffleWordCountTest.scala
@@ -29,6 +29,7 @@ import 
org.apache.flink.streaming.api.graph.StreamingJobGraphGenerator
 import org.scalatest.BeforeAndAfterAll
 import org.scalatest.funsuite.AnyFunSuite
 
+import org.apache.celeborn.common.CelebornConf
 import org.apache.celeborn.common.internal.Logging
 import org.apache.celeborn.service.deploy.MiniClusterFeature
 import org.apache.celeborn.service.deploy.worker.Worker
@@ -62,12 +63,23 @@ class HybridShuffleWordCountTest extends AnyFunSuite with 
Logging with MiniClust
     testLocalEnv()
   }
 
+  test("Celeborn Flink Hybrid Shuffle Integration test(Local) with integrity 
check - word count") {
+    assumeFlinkVersion()
+    testLocalEnv(integrityCheckEnabled = true)
+  }
+
   test(
     "Celeborn Flink Hybrid Shuffle Integration test(Flink mini cluster) single 
tier - word count") {
     assumeFlinkVersion()
     testInMiniCluster()
   }
 
+  test(
+    "Celeborn Flink Hybrid Shuffle Integration test(Flink mini cluster) single 
tier with integrity check - word count") {
+    assumeFlinkVersion()
+    testInMiniCluster(integrityCheckEnabled = true)
+  }
+
   private def assumeFlinkVersion(): Unit = {
     // Celeborn supports flink hybrid shuffle staring from flink 1.20
     val flinkVersion = sys.env.getOrElse("FLINK_VERSION", "")
@@ -77,7 +89,7 @@ class HybridShuffleWordCountTest extends AnyFunSuite with 
Logging with MiniClust
         FlinkVersion.v1_20))
   }
 
-  private def testLocalEnv(): Unit = {
+  private def testLocalEnv(integrityCheckEnabled: Boolean = false): Unit = {
     // set up execution environment
     val configuration = new Configuration
     val parallelism = NUM_PARALLELISM
@@ -88,6 +100,9 @@ class HybridShuffleWordCountTest extends AnyFunSuite with 
Logging with MiniClust
       "taskmanager.network.hybrid-shuffle.external-remote-tier-factory.class",
       "org.apache.celeborn.plugin.flink.tiered.CelebornTierFactory")
     configuration.setString("celeborn.master.endpoints", "localhost:9097")
+    if (integrityCheckEnabled) {
+      
configuration.setString(CelebornConf.CLIENT_SHUFFLE_INTEGRITY_CHECK_ENABLED.key,
 "true")
+    }
     configuration.set(ExecutionOptions.RUNTIME_MODE, 
RuntimeExecutionMode.BATCH)
     configuration.setString(
       "execution.batch-shuffle-mode",
@@ -115,7 +130,7 @@ class HybridShuffleWordCountTest extends AnyFunSuite with 
Logging with MiniClust
     checkFlushingFileLength()
   }
 
-  private def testInMiniCluster(): Unit = {
+  private def testInMiniCluster(integrityCheckEnabled: Boolean = false): Unit 
= {
     // set up execution environment
     val configuration = new Configuration
     val parallelism = NUM_PARALLELISM
@@ -126,6 +141,9 @@ class HybridShuffleWordCountTest extends AnyFunSuite with 
Logging with MiniClust
       "taskmanager.network.hybrid-shuffle.external-remote-tier-factory.class",
       "org.apache.celeborn.plugin.flink.tiered.CelebornTierFactory")
     configuration.setString("celeborn.master.endpoints", "localhost:9097")
+    if (integrityCheckEnabled) {
+      
configuration.setString(CelebornConf.CLIENT_SHUFFLE_INTEGRITY_CHECK_ENABLED.key,
 "true")
+    }
     configuration.set(ExecutionOptions.RUNTIME_MODE, 
RuntimeExecutionMode.BATCH)
     configuration.setString(
       "execution.batch-shuffle-mode",
diff --git 
a/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/WordCountTest.scala
 
b/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/WordCountTest.scala
index fa8860efc..e3cf46191 100644
--- 
a/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/WordCountTest.scala
+++ 
b/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/WordCountTest.scala
@@ -127,6 +127,13 @@ class WordCountTest extends WordCountTestBase {
   override protected def getWorkerConf: Map[String, String] = Map()
 }
 
+class WordCountTestWithIntegrityCheck extends WordCountTestBase {
+  override protected def getMasterConf: Map[String, String] = Map()
+  override protected def getWorkerConf: Map[String, String] = Map()
+  override protected def getClientConf: Map[String, String] =
+    Map(CelebornConf.CLIENT_SHUFFLE_INTEGRITY_CHECK_ENABLED.key -> "true")
+}
+
 class WordCountTestWithAuthentication extends WordCountTestBase {
 
   private val authConfig = Map(


Reply via email to