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

danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new d10b868d90c9 fix(flink): remove the Source V2 read teardown race via 
materialized bounded minibatches (#19202)
d10b868d90c9 is described below

commit d10b868d90c97bd7297c0ac18fcb7fcf3deac8af
Author: Vova Kolmakov <[email protected]>
AuthorDate: Fri Jul 17 07:51:08 2026 +0700

    fix(flink): remove the Source V2 read teardown race via materialized 
bounded minibatches (#19202)
    
    * fix(flink): make CDC read iterator teardown thread-safe to de-flake 
stream-read ITs
    
    CdcFileSplitsIterator is drained on the Flink task thread but can be closed 
from the split-fetcher thread during job teardown, racing on its non-volatile 
recordIterator/imageManager fields (and the nested BaseImageIterator.cdcItr) 
and surfacing an NPE that flakes testStreamReadFromSpecifiedCommitWithChangelog.
    
    Synchronize hasNext()/next()/close() on the iterator and add a closed flag 
so a concurrent close() cannot null out state mid-read and the iterator reports 
a well-defined end-of-stream afterwards. Extend the test's 
isAcceptableTerminalFailure teardown-race tolerance to the CDC-iterator frame, 
since BatchRecords drives hasNext()/next() as separate calls and a close() 
between them still ends a force-terminated drain with a benign 
NoSuchElementException.
    
    * addressed review comments: split next() guard and use class refs for CDC 
teardown-frame check
    
    * test(flink): retry a bare await timeout in the CDC stream-read IT
    
    The flink2.1 CI shard failed testStreamReadFromSpecifiedCommitWithChangelog 
with
    "Unexpected job failure" caused by a TimeoutException from 
tableResult.await(): the
    streaming read had not collected its expected rows within the window 
(CI-load slowness,
    not the teardown race this PR already tolerates and retries). A bare 
TimeoutException
    was rethrown as an AssertionError, bypassing submitAndFetchWithRetry, which 
only retries
    short results.
    
    Treat a bare await timeout as a retryable short read: cancel the 
still-running job and
    return the rows collected so far so the retry loop re-submits a fresh job. 
Also widen
    the await window from 30s to 60s so a slow shard is less likely to time out 
at all.
    Test-only; no production change.
    
    * addressed review comments: use equals for CDC teardown-frame class match
    
    * addressed review comments: redesign Source V2 read path to materialize 
bounded minibatches
    
    The reviewers asked for a root-cause fix rather than the 
CdcFileSplitsIterator synchronization mitigation. The real flaw was that 
BatchRecords did lazy reading: it held a live ClosableIterator drained on the 
Flink task thread, while a forced-cancel teardown closed that same iterator on 
the split-fetcher thread, so read and close genuinely ran on different threads.
    
    This reworks the Source V2 read path so a reader function is a per-split 
cursor that owns the record iterator, the CdcImageManager and the file-group 
readers. HoodieSourceSplitReader.fetch() opens a split, reads one bounded 
minibatch (DEFAULT_MINI_BATCH_SIZE), and closes the split's resources on the 
same split-fetcher thread on EOF, failure or cancellation. BatchRecords now 
carries a materialized list instead of a live iterator, so read and close 
happen on one thread and the cross-thr [...]
    
    Because Flink's columnar readers and the CDC/MOR row projections return the 
same reused RowData on every next(), readBatch deep-copies each record before 
buffering it and re-applies the RowKind, which the serializer does not 
round-trip.
    
    With read and close single-threaded, the CdcFileSplitsIterator 
synchronization and closed-flag guards are reverted, and the test-side 
teardown-race tolerance in ITTestHoodieDataSource is removed: 
isAcceptableTerminalFailure now accepts only the SuccessException happy path. 
The independent await-timeout retry is kept.
    
    * addressed review comments: narrow the await-timeout retry to a bare 
top-level TimeoutException
    
    TableResult.await(long, TimeUnit) throws its own timeout bare, so only a 
top-level TimeoutException means the await window elapsed with the job still 
running. A genuine job failure arrives wrapped in an ExecutionException that 
may itself embed a TimeoutException (checkpoint expiry, RPC timeout); walking 
the whole cause chain misclassified such a failure as a slow shard, cancelled 
and retried it, and then reported a row-count mismatch with the real cause 
discarded. isAwaitTimeout now i [...]
    
    * addressed review comments: regroup copySerializer field and rename test 
open-count accessor
    
    * addressed review comments: drop redundant RowKind re-apply after 
RowDataSerializer.copy
    
    * addressed review comments: unblock fetch() on wakeUp and close the 
file-group reader on init failure
    
    P1: fetch() now drains an I/O-backed minibatch, so wakeUp() sets a volatile 
flag the drain polls between records; on a wake-up fetch() returns promptly 
without finishing/closing, keeping the split-close on the fetcher thread.
    P2: HoodieSplitReaderFunction.createRecordIterator retains the 
HoodieFileGroupReader in a local and closes it when getClosableIterator() 
fails, suppressing the close error onto the original exception.
    
    ---------
    
    Co-authored-by: Vova Kolmakov <[email protected]>
---
 .../apache/hudi/source/reader/BatchRecords.java    |  64 ++--
 .../source/reader/HoodieSourceSplitReader.java     |  80 ++--
 .../function/AbstractSplitReaderFunction.java      | 107 +++++-
 .../function/HoodieCdcSplitReaderFunction.java     |  27 +-
 .../reader/function/HoodieSplitReaderFunction.java |  43 ++-
 .../reader/function/SplitReaderFunction.java       |  46 ++-
 .../apache/hudi/table/format/cdc/CdcIterators.java |   5 +
 .../hudi/source/reader/TestBatchRecords.java       | 249 +++---------
 .../source/reader/TestHoodieSourceSplitReader.java | 418 ++++++++++++++++++---
 .../function/TestAbstractSplitReaderFunction.java  | 153 +++++++-
 .../function/TestHoodieCdcSplitReaderFunction.java |  11 +-
 .../function/TestHoodieSplitReaderFunction.java    |  92 ++++-
 .../apache/hudi/table/ITTestHoodieDataSource.java  | 148 +++-----
 13 files changed, 992 insertions(+), 451 deletions(-)

diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/BatchRecords.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/BatchRecords.java
index c69b7634ad1a..95db8365ac7b 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/BatchRecords.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/BatchRecords.java
@@ -19,7 +19,6 @@
 package org.apache.hudi.source.reader;
 
 import org.apache.hudi.common.util.ValidationUtils;
-import org.apache.hudi.common.util.collection.ClosableIterator;
 
 import org.apache.flink.connector.base.source.reader.RecordsBySplits;
 import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
@@ -27,41 +26,50 @@ import 
org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
 import javax.annotation.Nullable;
 
 import java.util.Collections;
+import java.util.List;
 import java.util.Set;
 
 /**
- * Implementation of RecordsWithSplitIds with a list record inside.
+ * Implementation of {@link RecordsWithSplitIds} backed by a materialized, 
bounded minibatch of
+ * records for a single split.
  *
- * Type parameters: <T> – record type
+ * <p>The records are already drained (and copied) on the split-fetcher thread 
before this batch is
+ * enqueued, so {@link #nextRecordFromSplit()} only walks an in-memory list 
and holds no live I/O
+ * resource. Record reading and resource teardown therefore stay on the same 
(split-fetcher) thread,
+ * which removes the cross-thread teardown race a live-iterator batch would be 
exposed to. The
+ * underlying iterator and its I/O resources are owned and closed by the 
reader function (see
+ * {@code AbstractSplitReaderFunction#closeCurrentSplit}).
+ *
+ * @param <T> record type
  */
 public class BatchRecords<T> implements 
RecordsWithSplitIds<HoodieRecordWithPosition<T>> {
   private String splitId;
   private String nextSplitId;
-  private final ClosableIterator<T> recordIterator;
+  private final List<T> records;
   private final Set<String> finishedSplits;
   private final HoodieRecordWithPosition<T> recordAndPosition;
 
-  // point to current read position within the records list
-  private int position;
+  // points to the current read position within the records list
+  private int index;
 
   BatchRecords(
       String splitId,
-      ClosableIterator<T> recordIterator,
+      List<T> records,
       int fileOffset,
       long startingRecordOffset,
       Set<String> finishedSplits) {
     ValidationUtils.checkArgument(
         finishedSplits != null, "finishedSplits can be empty but not null");
     ValidationUtils.checkArgument(
-        recordIterator != null, "recordIterator can be empty but not null");
+        records != null, "records can be empty but not null");
 
     this.splitId = splitId;
     this.nextSplitId = splitId;
-    this.recordIterator = recordIterator;
+    this.records = records;
     this.finishedSplits = finishedSplits;
     this.recordAndPosition = new HoodieRecordWithPosition<>();
     this.recordAndPosition.set(null, fileOffset, startingRecordOffset);
-    this.position = 0;
+    this.index = 0;
   }
 
   @Nullable
@@ -69,7 +77,7 @@ public class BatchRecords<T> implements 
RecordsWithSplitIds<HoodieRecordWithPosi
   public String nextSplit() {
     if (splitId.equals(nextSplitId)) {
       // set the nextSplitId to null to indicate no more splits
-      // this class only contains record for one split
+      // this class only contains records for one split
       nextSplitId = null;
       return splitId;
     } else {
@@ -80,12 +88,11 @@ public class BatchRecords<T> implements 
RecordsWithSplitIds<HoodieRecordWithPosi
   @Nullable
   @Override
   public HoodieRecordWithPosition<T> nextRecordFromSplit() {
-    if (recordIterator.hasNext()) {
-      recordAndPosition.record(recordIterator.next());
-      position = position + 1;
+    if (index < records.size()) {
+      recordAndPosition.record(records.get(index));
+      index++;
       return recordAndPosition;
     } else {
-      recordIterator.close();
       return null;
     }
   }
@@ -97,29 +104,14 @@ public class BatchRecords<T> implements 
RecordsWithSplitIds<HoodieRecordWithPosi
 
   @Override
   public void recycle() {
-    if (recordIterator != null) {
-      recordIterator.close();
-    }
-  }
-
-  public void seek(long startingRecordOffset) {
-    for (long i = 0; i < startingRecordOffset; ++i) {
-      if (recordIterator.hasNext()) {
-        position = position + 1;
-        recordIterator.next();
-      } else {
-        throw new IllegalStateException(
-            String.format(
-                "Invalid starting record offset %d for split %s",
-                startingRecordOffset,
-                splitId));
-      }
-    }
+    // No-op: the minibatch is fully materialized, so there is no live 
iterator or I/O resource to
+    // release here. The underlying reader is owned and closed by the reader 
function on the
+    // split-fetcher thread (AbstractSplitReaderFunction#closeCurrentSplit).
   }
 
   public static <T> BatchRecords<T> forRecords(
-      String splitId, ClosableIterator<T> recordIterator, int fileOffset, long 
startingRecordOffset) {
-    return new BatchRecords<>(splitId, recordIterator, fileOffset, 
startingRecordOffset, Set.of());
+      String splitId, List<T> records, int fileOffset, long 
startingRecordOffset) {
+    return new BatchRecords<>(splitId, records, fileOffset, 
startingRecordOffset, Set.of());
   }
 
   public static <T> RecordsWithSplitIds<HoodieRecordWithPosition<T>> 
lastBatchRecords(String splitId) {
@@ -130,4 +122,4 @@ public class BatchRecords<T> implements 
RecordsWithSplitIds<HoodieRecordWithPosi
     // in SourceReaderBase for bounded (batch) reads.
     return new RecordsBySplits<>(Collections.emptyMap(), Set.of(splitId));
   }
-}
\ No newline at end of file
+}
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceSplitReader.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceSplitReader.java
index 04c55e6a2963..e53c5f82eed9 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceSplitReader.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/HoodieSourceSplitReader.java
@@ -45,21 +45,31 @@ import java.util.Set;
 /**
  * The split reader of Hoodie source.
  *
- * <p>Each call to {@link #fetch()} reads one split and returns it as a single
- * {@link RecordsWithSplitIds} batch. Flink's {@code SourceReaderBase} is 
responsible for
- * draining all records from the batch (via {@code nextRecordFromSplit()}) and 
marking
- * the split finished (via {@code finishedSplits()}) before calling {@link 
#fetch()} again.
+ * <p>Each call to {@link #fetch()} returns one bounded minibatch of the 
currently open split, so a
+ * single split spans multiple {@code fetch()} calls. When the open split is 
exhausted its resources
+ * are closed on this (split-fetcher) thread and a finish signal ({@code 
finishedSplits()}) is
+ * returned so Flink's {@code SourceReaderBase} can advance / reach 
end-of-input. All record reading
+ * and resource teardown for a split therefore happen on the same thread, 
which removes the
+ * cross-thread teardown race a live-iterator batch would be exposed to.
  *
  * @param <T> record type
  */
 @Slf4j
 public class HoodieSourceSplitReader<T> implements 
SplitReader<HoodieRecordWithPosition<T>, HoodieSourceSplit> {
+  // Upper bound on the number of records materialized per fetch() call (one 
minibatch). Kept as a
+  // fixed constant for now (mirrors RecordIterators.DEFAULT_BATCH_SIZE); it 
can be promoted to a
+  // Flink option later if a tunable per-fetch bound is ever needed.
+  private static final int DEFAULT_MINI_BATCH_SIZE = 2048;
+
   private final SerializableComparator<HoodieSourceSplit> splitComparator;
   private final Queue<HoodieSourceSplit> splits;
   private final FlinkStreamReadMetrics readerMetrics;
   private final SplitReaderFunction<T> readerFunction;
   private final Option<RecordLimiter> recordLimiter;
   private transient HoodieSourceSplit currentSplit;
+  // Set by wakeUp() (possibly from another thread) to stop the in-flight 
minibatch drain promptly.
+  // Reset at the start of every fetch(), so it only ever means "a wakeUp() 
landed during THIS fetch".
+  private volatile boolean wokenUp;
 
   public HoodieSourceSplitReader(
       String tableName,
@@ -77,27 +87,47 @@ public class HoodieSourceSplitReader<T> implements 
SplitReader<HoodieRecordWithP
 
   @Override
   public RecordsWithSplitIds<HoodieRecordWithPosition<T>> fetch() throws 
IOException {
-    // finish current split.
-    if (currentSplit != null) {
-      return finishSplit();
+    // A wakeUp() only needs to unblock an in-progress fetch(); Flink's 
SplitFetcher drives shutdown
+    // off its own 'closed' flag (set before wakeUp() and checked before the 
next fetch()), not off a
+    // lasting wakeUp effect. Start each cycle from a clean flag so the drain 
below reacts only to a
+    // wakeUp that lands during THIS fetch.
+    wokenUp = false;
+    if (currentSplit == null) {
+      // Limit already satisfied: drain any remaining locally-queued splits as 
immediately finished
+      // so that Flink's SourceReaderBase can reach end-of-input cleanly.
+      if (recordLimiter.map(RecordLimiter::isLimitReached).orElse(false)) {
+        return drainRemainingAsSplitsFinished();
+      }
+      HoodieSourceSplit nextSplit = splits.poll();
+      if (nextSplit == null) {
+        // return an empty result, which will lead to split fetch to be idle.
+        // SplitFetcherManager will then close idle fetcher.
+        return new RecordsBySplits<>(Collections.emptyMap(), 
Collections.emptySet());
+      }
+      currentSplit = nextSplit;
+      readerFunction.open(currentSplit);
     }
 
-    // Limit already satisfied: drain any remaining locally-queued splits as 
immediately finished
-    // so that Flink's SourceReaderBase can reach end-of-input cleanly.
-    if (recordLimiter.map(RecordLimiter::isLimitReached).orElse(false)) {
-      return drainRemainingAsSplitsFinished();
+    // Read the next bounded minibatch of the open split, unless the global 
limit is already reached.
+    if (!recordLimiter.map(RecordLimiter::isLimitReached).orElse(false)) {
+      BatchRecords<T> batch = readerFunction.readBatch(currentSplit, 
DEFAULT_MINI_BATCH_SIZE, () -> wokenUp);
+      if (batch != null) {
+        // Partial (woken) or full minibatch; the split is not finished either 
way.
+        return recordLimiter.map(rl -> rl.wrap(batch)).orElse(batch);
+      }
+      if (wokenUp) {
+        // Woken before any record was buffered: return promptly WITHOUT 
finishing or closing the
+        // split, so it resumes on the next fetch(), or the fetcher observes 
shutdown and closes it
+        // on this (split-fetcher) thread. This branch must stay inside the 
!isLimitReached block:
+        // a wakeUp coinciding with the limit-reached path below must still 
finish the split.
+        return new RecordsBySplits<>(Collections.emptyMap(), 
Collections.emptySet());
+      }
     }
 
-    HoodieSourceSplit nextSplit = splits.poll();
-    if (nextSplit != null) {
-      currentSplit = nextSplit;
-      RecordsWithSplitIds<HoodieRecordWithPosition<T>> records = 
readerFunction.read(nextSplit);
-      return recordLimiter.map(rl -> rl.wrap(records)).orElse(records);
-    } else {
-      // return an empty result, which will lead to split fetch to be idle.
-      // SplitFetcherManager will then close idle fetcher.
-      return new RecordsBySplits<>(Collections.emptyMap(), 
Collections.emptySet());
-    }
+    // Split exhausted (or the limit was reached mid-split): close its 
resources on this
+    // (split-fetcher) thread first, then emit the finish signal so 
SourceReaderBase can advance.
+    readerFunction.closeCurrentSplit();
+    return finishSplit();
   }
 
   @Override
@@ -120,13 +150,17 @@ public class HoodieSourceSplitReader<T> implements 
SplitReader<HoodieRecordWithP
 
   @Override
   public void wakeUp() {
-    // Nothing to do
+    // Flink calls this (while holding SplitFetcher.lock) to unblock a fetch() 
that is draining a
+    // minibatch, e.g. on shutdown. Keep it a non-blocking plain volatile 
write; the drain loop in
+    // readBatch polls the flag between records and returns promptly. The 
actual resource teardown
+    // still happens on the split-fetcher thread via 
close()/closeCurrentSplit().
+    wokenUp = true;
   }
 
   /**
    * SourceSplitReader only reads splits sequentially. When waiting for 
watermark alignment
    * the SourceOperator will stop processing and recycling the fetched 
batches. Based on this the
-   * {@code pauseOrResumeSplits} and the {@code wakeUp} are left empty.
+   * {@code pauseOrResumeSplits} is left empty.
    * @param splitsToPause splits to pause
    * @param splitsToResume splits to resume
    */
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/AbstractSplitReaderFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/AbstractSplitReaderFunction.java
index 0f97b612fa21..d317dd7baa76 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/AbstractSplitReaderFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/AbstractSplitReaderFunction.java
@@ -18,19 +18,40 @@
 
 package org.apache.hudi.source.reader.function;
 
+import org.apache.hudi.common.util.ValidationUtils;
+import org.apache.hudi.common.util.collection.ClosableIterator;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.configuration.HadoopConfigurations;
 import org.apache.hudi.source.ExpressionPredicates;
+import org.apache.hudi.source.reader.BatchRecords;
+import org.apache.hudi.source.split.HoodieSourceSplit;
 import org.apache.hudi.table.format.InternalSchemaManager;
 import org.apache.hudi.util.FlinkWriteClients;
 
 import org.apache.flink.configuration.Configuration;
 import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.runtime.typeutils.RowDataSerializer;
+import org.apache.flink.table.types.logical.RowType;
 
+import java.util.ArrayList;
 import java.util.List;
+import java.util.function.BooleanSupplier;
 
 /**
- * Abstract implementation of SplitReaderFunction.
+ * Abstract implementation of {@link SplitReaderFunction} that provides the 
per-split cursor
+ * machinery shared by all reader functions.
+ *
+ * <p>A subclass only supplies how to create the record iterator for a split
+ * ({@link #createRecordIterator(HoodieSourceSplit)}) and the {@link RowType} 
of the records it
+ * produces ({@link #producedRowType()}); this base drives {@link 
#open(HoodieSourceSplit)},
+ * {@link #readBatch(HoodieSourceSplit, int, 
java.util.function.BooleanSupplier)}, {@link #closeCurrentSplit()} and {@link 
#close()},
+ * all on the single split-fetcher thread.
+ *
+ * <p>Because Flink's columnar readers (and the CDC/MOR row projections) 
return the same reused
+ * {@link RowData} object on every {@code next()}, {@link #readBatch} copies 
each record before
+ * buffering it - materializing raw references would make every entry in a 
minibatch alias the last
+ * row. {@link RowDataSerializer#copy(RowData)} preserves the record's {@code 
RowKind}, so no
+ * re-apply is needed.
  */
 public abstract class AbstractSplitReaderFunction implements 
SplitReaderFunction<RowData> {
 
@@ -40,6 +61,11 @@ public abstract class AbstractSplitReaderFunction implements 
SplitReaderFunction
   protected final boolean emitDelete;
   private transient HoodieWriteConfig writeConfig;
   private transient org.apache.hadoop.conf.Configuration hadoopConf;
+  private transient RowDataSerializer copySerializer;
+
+  // Per-split cursor state (split-fetcher thread only).
+  private transient ClosableIterator<RowData> currentIterator;
+  private transient long nextRecordOffset;
 
   public AbstractSplitReaderFunction(
       Configuration conf,
@@ -52,6 +78,85 @@ public abstract class AbstractSplitReaderFunction implements 
SplitReaderFunction
     this.emitDelete = emitDelete;
   }
 
+  /**
+   * Creates the record iterator (and its underlying I/O resources) for {@code 
split}. Closing the
+   * returned iterator must release all of those resources.
+   */
+  protected abstract ClosableIterator<RowData> 
createRecordIterator(HoodieSourceSplit split);
+
+  /** The {@link RowType} of the records produced by {@link 
#createRecordIterator}. */
+  protected abstract RowType producedRowType();
+
+  @Override
+  public void open(HoodieSourceSplit split) {
+    this.currentIterator = createRecordIterator(split);
+    try {
+      // Skip the records already emitted before the last checkpoint so a 
recovered split resumes at
+      // the right position; matches the validation the old BatchRecords#seek 
performed.
+      long consumed = split.getConsumed();
+      for (long i = 0; i < consumed; i++) {
+        if (currentIterator.hasNext()) {
+          currentIterator.next();
+        } else {
+          throw new IllegalStateException(
+              String.format("Invalid starting record offset %d for split %s", 
consumed, split.splitId()));
+        }
+      }
+      this.nextRecordOffset = consumed;
+    } catch (RuntimeException | Error e) {
+      // Close on failure so the split's I/O resources are released even if 
the resume-skip fails.
+      closeCurrentSplit();
+      throw e;
+    }
+  }
+
+  @Override
+  public BatchRecords<RowData> readBatch(HoodieSourceSplit split, int 
batchSize, BooleanSupplier wakeupSignal) {
+    ValidationUtils.checkState(currentIterator != null,
+        "readBatch called before open for split " + split.splitId());
+    RowDataSerializer serializer = getCopySerializer();
+    List<RowData> buffer = new ArrayList<>();
+    try {
+      // Poll wakeupSignal between records so a wakeUp() lands promptly: 
materialization stops early
+      // and whatever is buffered so far is returned as a partial minibatch 
(null if nothing yet).
+      while (buffer.size() < batchSize && !wakeupSignal.getAsBoolean() && 
currentIterator.hasNext()) {
+        RowData next = currentIterator.next();
+        buffer.add(serializer.copy(next));
+      }
+    } catch (RuntimeException | Error e) {
+      // Close on failure so the split's I/O resources are released even if a 
mid-drain read fails.
+      closeCurrentSplit();
+      throw e;
+    }
+    if (buffer.isEmpty()) {
+      return null;
+    }
+    long startingRecordOffset = nextRecordOffset;
+    nextRecordOffset += buffer.size();
+    return BatchRecords.forRecords(split.splitId(), buffer, 
split.getFileOffset(), startingRecordOffset);
+  }
+
+  @Override
+  public void closeCurrentSplit() {
+    if (currentIterator != null) {
+      currentIterator.close();
+      currentIterator = null;
+    }
+    nextRecordOffset = 0;
+  }
+
+  @Override
+  public void close() throws Exception {
+    closeCurrentSplit();
+  }
+
+  private RowDataSerializer getCopySerializer() {
+    if (copySerializer == null) {
+      copySerializer = new RowDataSerializer(producedRowType());
+    }
+    return copySerializer;
+  }
+
   protected HoodieWriteConfig getWriteConfig() {
     if (writeConfig == null) {
       writeConfig = FlinkWriteClients.getHoodieClientConfig(conf);
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java
index b539bc835f14..2f6afd833534 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java
@@ -40,8 +40,6 @@ import org.apache.hudi.configuration.OptionsResolver;
 import org.apache.hudi.exception.HoodieException;
 import org.apache.hudi.exception.HoodieIOException;
 import org.apache.hudi.source.ExpressionPredicates;
-import org.apache.hudi.source.reader.BatchRecords;
-import org.apache.hudi.source.reader.HoodieRecordWithPosition;
 import org.apache.hudi.source.split.HoodieCdcSourceSplit;
 import org.apache.hudi.source.split.HoodieSourceSplit;
 import org.apache.hudi.table.format.FilePathUtils;
@@ -56,9 +54,9 @@ import org.apache.hudi.table.format.mor.MergeOnReadTableState;
 import org.apache.hudi.util.StreamerUtil;
 
 import lombok.extern.slf4j.Slf4j;
-import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
 import org.apache.flink.table.data.RowData;
 import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.RowType;
 import org.apache.hadoop.fs.Path;
 
 import java.io.IOException;
@@ -81,7 +79,6 @@ public class HoodieCdcSplitReaderFunction extends 
AbstractSplitReaderFunction {
   private final List<DataType> fieldTypes;
   private final MergeOnReadTableState tableState;
   private transient HoodieTableMetaClient metaClient;
-  private transient ClosableIterator<RowData> currentIterator;
   // Fallback reader for non-CDC splits (e.g. snapshot reads when 
read.start-commit='earliest')
   private transient HoodieSplitReaderFunction fallbackReaderFunction;
 
@@ -110,12 +107,12 @@ public class HoodieCdcSplitReaderFunction extends 
AbstractSplitReaderFunction {
   }
 
   @Override
-  public RecordsWithSplitIds<HoodieRecordWithPosition<RowData>> 
read(HoodieSourceSplit split) {
+  protected ClosableIterator<RowData> createRecordIterator(HoodieSourceSplit 
split) {
     if (!(split instanceof HoodieCdcSourceSplit)) {
       // Non-CDC splits arrive when reading from 'earliest' with no prior CDC 
history
       // (i.e. instantRange is empty → snapshot path). Fall back to the 
standard MOR reader
       // which emits all records as INSERT rows, matching the expected 
snapshot behaviour.
-      return getFallbackReaderFunction().read(split);
+      return getFallbackReaderFunction().createRecordIterator(split);
     }
     HoodieCdcSourceSplit cdcSplit = (HoodieCdcSourceSplit) split;
 
@@ -132,21 +129,15 @@ public class HoodieCdcSplitReaderFunction extends 
AbstractSplitReaderFunction {
             mode,
             imageManager);
 
-    currentIterator = new 
CdcIterators.CdcFileSplitsIterator(cdcSplit.getChanges(), imageManager, 
recordIteratorFunc);
-    BatchRecords<RowData> records = BatchRecords.forRecords(
-        split.splitId(), currentIterator, split.getFileOffset(), 
split.getConsumed());
-    records.seek(split.getConsumed());
-    return records;
+    // The CdcFileSplitsIterator owns the imageManager and its per-split 
record iterators; closing it
+    // (via the base class closeCurrentSplit) releases them. The base class 
handles the consumed-offset
+    // skip and the minibatch materialization uniformly with the MOR/COW path.
+    return new CdcIterators.CdcFileSplitsIterator(cdcSplit.getChanges(), 
imageManager, recordIteratorFunc);
   }
 
   @Override
-  public void close() throws Exception {
-    if (currentIterator != null) {
-      currentIterator.close();
-    }
-    if (fallbackReaderFunction != null) {
-      fallbackReaderFunction.close();
-    }
+  protected RowType producedRowType() {
+    return tableState.getRequiredRowType();
   }
 
   // -------------------------------------------------------------------------
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieSplitReaderFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieSplitReaderFunction.java
index 96ade6d4d77b..0ebc5c39cdbb 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieSplitReaderFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieSplitReaderFunction.java
@@ -29,16 +29,15 @@ import org.apache.hudi.common.util.ValidationUtils;
 import org.apache.hudi.common.util.collection.ClosableIterator;
 import org.apache.hudi.exception.HoodieIOException;
 import org.apache.hudi.source.ExpressionPredicates;
-import org.apache.hudi.source.reader.BatchRecords;
-import org.apache.hudi.source.reader.HoodieRecordWithPosition;
 import org.apache.hudi.source.split.HoodieSourceSplit;
 import org.apache.hudi.table.format.FormatUtils;
 import org.apache.hudi.table.format.InternalSchemaManager;
+import org.apache.hudi.util.HoodieSchemaConverter;
 import org.apache.hudi.util.StreamerUtil;
 
 import org.apache.flink.configuration.Configuration;
-import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
 import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.logical.RowType;
 
 import java.io.IOException;
 import java.util.Collections;
@@ -52,7 +51,6 @@ public class HoodieSplitReaderFunction extends 
AbstractSplitReaderFunction {
   private final HoodieSchema tableSchema;
   private final HoodieSchema requiredSchema;
   private final String mergeType;
-  private transient HoodieFileGroupReader<RowData> fileGroupReader;
 
   public HoodieSplitReaderFunction(
       Configuration configuration,
@@ -72,28 +70,39 @@ public class HoodieSplitReaderFunction extends 
AbstractSplitReaderFunction {
   }
 
   @Override
-  public RecordsWithSplitIds<HoodieRecordWithPosition<RowData>> 
read(HoodieSourceSplit split) {
-    final String splitId = split.splitId();
+  protected ClosableIterator<RowData> createRecordIterator(HoodieSourceSplit 
split) {
     HoodieTableMetaClient metaClient = StreamerUtil.metaClientForReader(conf, 
getHadoopConf());
-
+    // Closing the returned iterator cascade-closes the whole 
HoodieFileGroupReader, so the base
+    // class only has to close the iterator in closeCurrentSplit(). But 
getClosableIterator() runs
+    // initRecordIterators(), which opens the reader's base-file iterator / 
record buffer before the
+    // wrapping iterator is returned; if it throws, the reader is only a local 
here and nothing else
+    // would close it. Keep it in a local and close it in the failure path.
+    HoodieFileGroupReader<RowData> fileGroupReader = 
createFileGroupReader(split, metaClient);
     try {
-      this.fileGroupReader = createFileGroupReader(split, metaClient);
-      final ClosableIterator<RowData> recordIterator = 
fileGroupReader.getClosableIterator();
-      BatchRecords<RowData> records = BatchRecords.forRecords(splitId, 
recordIterator, split.getFileOffset(), split.getConsumed());
-      records.seek(split.getConsumed());
-      return records;
+      return fileGroupReader.getClosableIterator();
     } catch (IOException e) {
+      closeSuppressing(fileGroupReader, e);
       throw new HoodieIOException("Failed to read from file group: " + 
split.getFileId(), e);
+    } catch (RuntimeException | Error e) {
+      closeSuppressing(fileGroupReader, e);
+      throw e;
     }
   }
 
-  @Override
-  public void close() throws Exception {
-    if (fileGroupReader != null) {
-      fileGroupReader.close();
+  /** Closes {@code reader}, attaching any close failure to {@code primary} as 
a suppressed exception. */
+  private static void closeSuppressing(HoodieFileGroupReader<RowData> reader, 
Throwable primary) {
+    try {
+      reader.close();
+    } catch (Exception closeError) {
+      primary.addSuppressed(closeError);
     }
   }
 
+  @Override
+  protected RowType producedRowType() {
+    return HoodieSchemaConverter.convertToRowType(requiredSchema);
+  }
+
   /**
    * Creates a {@link HoodieFileGroupReader} for the given split.
    *
@@ -101,7 +110,7 @@ public class HoodieSplitReaderFunction extends 
AbstractSplitReaderFunction {
    * @param metaClient The table meta client for schema and config resolution
    * @return A {@link HoodieFileGroupReader} instance
    */
-  private HoodieFileGroupReader<RowData> 
createFileGroupReader(HoodieSourceSplit split, HoodieTableMetaClient 
metaClient) {
+  protected HoodieFileGroupReader<RowData> 
createFileGroupReader(HoodieSourceSplit split, HoodieTableMetaClient 
metaClient) {
     // Create FileSlice from split information
     FileSlice fileSlice = new FileSlice(
         new HoodieFileGroupId(split.getPartitionPath(), split.getFileId()),
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/SplitReaderFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/SplitReaderFunction.java
index 6f7bf0f18ebe..46fb343aa3d4 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/SplitReaderFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/SplitReaderFunction.java
@@ -18,19 +18,55 @@
 
 package org.apache.hudi.source.reader.function;
 
-import org.apache.hudi.source.reader.HoodieRecordWithPosition;
+import org.apache.hudi.source.reader.BatchRecords;
 import org.apache.hudi.source.split.HoodieSourceSplit;
 
-import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
-
 import java.io.Serializable;
+import java.util.function.BooleanSupplier;
 
 /**
- * Interface for split read function.
+ * Interface for a split read function.
+ *
+ * <p>A reader function is a stateful, per-split cursor driven entirely on the 
Flink split-fetcher
+ * thread by {@link 
org.apache.hudi.source.reader.HoodieSourceSplitReader#fetch()}:
+ * {@link #open(HoodieSourceSplit)} creates the record iterator and its 
underlying I/O resources for
+ * a split, {@link #readBatch(HoodieSourceSplit, int, 
java.util.function.BooleanSupplier)} drains the next bounded minibatch, and
+ * {@link #closeCurrentSplit()} releases the split's resources once it is 
exhausted. Because open,
+ * read and close all run on the same thread, no record or I/O resource is 
ever touched concurrently.
+ *
+ * @param <T> record type
  */
 public interface SplitReaderFunction<T> extends Serializable {
 
-  RecordsWithSplitIds<HoodieRecordWithPosition<T>> read(HoodieSourceSplit 
split);
+  /**
+   * Opens {@code split} for reading: creates the record iterator and its 
underlying I/O resources,
+   * and skips the records already consumed ({@link 
HoodieSourceSplit#getConsumed()}) so a recovered
+   * split resumes at the right position.
+   */
+  void open(HoodieSourceSplit split);
+
+  /**
+   * Drains up to {@code batchSize} records from the currently open split into 
a materialized
+   * {@link BatchRecords} minibatch. Returns {@code null} once the split is 
exhausted.
+   *
+   * <p>{@code wakeupSignal} is polled between records: once it returns {@code 
true} materialization
+   * stops early and the records buffered so far are returned as a 
(non-finishing) partial minibatch;
+   * if nothing has been buffered yet {@code null} is returned. This lets a 
blocking {@code fetch()}
+   * unblock promptly on {@link 
org.apache.hudi.source.reader.HoodieSourceSplitReader#wakeUp()}
+   * without touching any resource off the split-fetcher thread.
+   */
+  BatchRecords<T> readBatch(HoodieSourceSplit split, int batchSize, 
BooleanSupplier wakeupSignal);
+
+  /**
+   * Closes the currently open split's iterator and I/O resources. Called when 
the split is
+   * exhausted, a read fails, or the read is stopped early. Safe to call when 
no split is open.
+   */
+  void closeCurrentSplit();
 
+  /**
+   * Closes the reader function entirely (idempotent). Invoked by
+   * {@link org.apache.hudi.source.reader.HoodieSourceSplitReader#close()} on 
the split-fetcher
+   * thread.
+   */
   void close() throws Exception;
 }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcIterators.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcIterators.java
index a4a0c33d6eca..104276583ae0 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcIterators.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/cdc/CdcIterators.java
@@ -101,6 +101,11 @@ public final class CdcIterators {
   /**
    * Iterates over an ordered sequence of {@link HoodieCDCFileSplit}s, 
delegating
    * per-split record reading to a user-supplied factory function.
+   *
+   * <p>Not thread-safe by design: in the Source V2 read path this iterator is 
created, drained into a
+   * materialized minibatch, and closed entirely on the single split-fetcher 
thread (see
+   * {@code AbstractSplitReaderFunction}); the legacy {@link CdcInputFormat} 
path likewise reads and
+   * closes it on one thread. No method is ever invoked concurrently, so no 
synchronization is needed.
    */
   public static class CdcFileSplitsIterator implements 
ClosableIterator<RowData> {
     private CdcImageManager imageManager;
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestBatchRecords.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestBatchRecords.java
index 69af5702f1f4..5b111dddcc03 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestBatchRecords.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestBatchRecords.java
@@ -18,14 +18,11 @@
 
 package org.apache.hudi.source.reader;
 
-import org.apache.hudi.common.util.collection.ClosableIterator;
-
 import org.junit.jupiter.api.Test;
 
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 
@@ -36,16 +33,15 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
- * Test cases for {@link BatchRecords}.
+ * Test cases for {@link BatchRecords}, which now holds a materialized, 
bounded minibatch of records.
  */
 public class TestBatchRecords {
 
   @Test
-  public void testForRecordsWithEmptyIterator() {
+  public void testForRecordsWithEmptyList() {
     String splitId = "test-split-1";
-    ClosableIterator<String> emptyIterator = 
createClosableIterator(Collections.emptyList());
 
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
emptyIterator, 0, 0L);
+    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
Collections.emptyList(), 0, 0L);
 
     assertNotNull(batchRecords);
     assertEquals(splitId, batchRecords.nextSplit());
@@ -57,9 +53,8 @@ public class TestBatchRecords {
   public void testForRecordsWithMultipleRecords() {
     String splitId = "test-split-2";
     List<String> records = Arrays.asList("record1", "record2", "record3");
-    ClosableIterator<String> iterator = createClosableIterator(records);
 
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
iterator, 0, 0L);
+    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
records, 0, 0L);
 
     // Verify split ID
     assertEquals(splitId, batchRecords.nextSplit());
@@ -86,46 +81,13 @@ public class TestBatchRecords {
     assertNull(batchRecords.nextRecordFromSplit());
   }
 
-  @Test
-  public void testSeekToStartingOffset() {
-    String splitId = "test-split-3";
-    List<String> records = Arrays.asList("record1", "record2", "record3", 
"record4", "record5");
-    ClosableIterator<String> iterator = createClosableIterator(records);
-
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
iterator, 0, 2L);
-    batchRecords.seek(2L);
-
-    // After seeking to offset 2, we should start from record3
-    batchRecords.nextSplit();
-
-    HoodieRecordWithPosition<String> record = 
batchRecords.nextRecordFromSplit();
-    assertNotNull(record);
-    assertEquals("record3", record.record());
-  }
-
-  @Test
-  public void testSeekBeyondAvailableRecords() {
-    String splitId = "test-split-4";
-    List<String> records = Arrays.asList("record1", "record2");
-    ClosableIterator<String> iterator = createClosableIterator(records);
-
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
iterator, 0, 0L);
-
-    IllegalStateException exception = 
assertThrows(IllegalStateException.class, () -> {
-      batchRecords.seek(10L);
-    });
-
-    assertTrue(exception.getMessage().contains("Invalid starting record 
offset"));
-  }
-
   @Test
   public void testFileOffsetPersistence() {
     String splitId = "test-split-5";
     int fileOffset = 5;
     List<String> records = Arrays.asList("record1", "record2");
-    ClosableIterator<String> iterator = createClosableIterator(records);
 
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
iterator, fileOffset, 0L);
+    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
records, fileOffset, 0L);
     batchRecords.nextSplit();
 
     HoodieRecordWithPosition<String> record1 = 
batchRecords.nextRecordFromSplit();
@@ -140,12 +102,11 @@ public class TestBatchRecords {
   @Test
   public void testConstructorWithFinishedSplits() {
     String splitId = "test-split-7";
-    List<String> records = Arrays.asList("record1");
-    ClosableIterator<String> iterator = createClosableIterator(records);
+    List<String> records = Collections.singletonList("record1");
     Set<String> finishedSplits = new HashSet<>(Arrays.asList("split1", 
"split2"));
 
     BatchRecords<String> batchRecords = new BatchRecords<>(
-        splitId, iterator, 0, 0L, finishedSplits);
+        splitId, records, 0, 0L, finishedSplits);
 
     assertEquals(2, batchRecords.finishedSplits().size());
     assertTrue(batchRecords.finishedSplits().contains("split1"));
@@ -157,10 +118,9 @@ public class TestBatchRecords {
     String splitId = "test-split-8";
     long startingRecordOffset = 10L;
     List<String> records = Arrays.asList("A", "B", "C");
-    ClosableIterator<String> iterator = createClosableIterator(records);
 
     BatchRecords<String> batchRecords = BatchRecords.forRecords(
-        splitId, iterator, 0, startingRecordOffset);
+        splitId, records, 0, startingRecordOffset);
     batchRecords.nextSplit();
 
     // First record should be at startingRecordOffset + 1
@@ -176,13 +136,37 @@ public class TestBatchRecords {
     assertEquals(startingRecordOffset + 3, record3.recordOffset());
   }
 
+  @Test
+  public void testOffsetContinuityAcrossMinibatches() {
+    // Two consecutive minibatches of the same split: the reader function 
threads the running
+    // record offset so that offsets stay monotonic across batch boundaries 
(batch 2 starts where
+    // batch 1 left off). This mirrors AbstractSplitReaderFunction#readBatch.
+    String splitId = "test-split-continuity";
+
+    BatchRecords<String> batch1 = BatchRecords.forRecords(splitId, 
Arrays.asList("a", "b", "c"), 0, 0L);
+    batch1.nextSplit();
+    assertEquals(1L, batch1.nextRecordFromSplit().recordOffset());
+    assertEquals(2L, batch1.nextRecordFromSplit().recordOffset());
+    assertEquals(3L, batch1.nextRecordFromSplit().recordOffset());
+    assertNull(batch1.nextRecordFromSplit());
+
+    // batch 2 opens at startingRecordOffset = 3 (the count already consumed 
by batch 1)
+    BatchRecords<String> batch2 = BatchRecords.forRecords(splitId, 
Arrays.asList("d", "e"), 0, 3L);
+    batch2.nextSplit();
+    HoodieRecordWithPosition<String> d = batch2.nextRecordFromSplit();
+    assertEquals("d", d.record());
+    assertEquals(4L, d.recordOffset());
+    HoodieRecordWithPosition<String> e = batch2.nextRecordFromSplit();
+    assertEquals("e", e.record());
+    assertEquals(5L, e.recordOffset());
+  }
+
   @Test
   public void testSplitIdReturnedOnlyOnce() {
     String splitId = "test-split-9";
-    List<String> records = Arrays.asList("record1");
-    ClosableIterator<String> iterator = createClosableIterator(records);
+    List<String> records = Collections.singletonList("record1");
 
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
iterator, 0, 0L);
+    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
records, 0, 0L);
 
     assertEquals(splitId, batchRecords.nextSplit());
     assertNull(batchRecords.nextSplit());
@@ -191,187 +175,64 @@ public class TestBatchRecords {
   }
 
   @Test
-  public void testRecycleClosesIterator() {
+  public void testRecycleIsNoOp() {
+    // The minibatch is fully materialized, so recycle() releases nothing and 
is a harmless no-op:
+    // it must not throw and must not affect the still-readable records.
     String splitId = "test-split-10";
     List<String> records = Arrays.asList("record1", "record2");
-    MockClosableIterator<String> mockIterator = new 
MockClosableIterator<>(records);
 
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
mockIterator, 0, 0L);
+    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
records, 0, 0L);
+    batchRecords.nextSplit();
 
     batchRecords.recycle();
 
-    assertTrue(mockIterator.isClosed(), "Iterator should be closed after 
recycle");
-  }
-
-  @Test
-  public void testRecycleWithNullIterator() {
-    // Test that recycle handles null iterator gracefully (though in practice 
this shouldn't happen)
-    // This tests the null check in recycle() method
-    String splitId = "test-split-11";
-    ClosableIterator<String> emptyIterator = 
createClosableIterator(Collections.emptyList());
-
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
emptyIterator, 0, 0L);
-
-    // Should not throw exception
-    batchRecords.recycle();
+    assertNotNull(batchRecords.nextRecordFromSplit(), "records remain readable 
after recycle");
+    assertNotNull(batchRecords.nextRecordFromSplit());
+    assertNull(batchRecords.nextRecordFromSplit());
   }
 
   @Test
   public void testNextRecordFromSplitAfterExhaustion() {
     String splitId = "test-split-12";
-    List<String> records = Arrays.asList("record1");
-    ClosableIterator<String> iterator = createClosableIterator(records);
+    List<String> records = Collections.singletonList("record1");
 
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
iterator, 0, 0L);
+    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
records, 0, 0L);
     batchRecords.nextSplit();
 
     // Read the only record
     assertNotNull(batchRecords.nextRecordFromSplit());
 
-    // After exhaustion, should return null
+    // After exhaustion, should return null (and stay null)
     assertNull(batchRecords.nextRecordFromSplit());
     assertNull(batchRecords.nextRecordFromSplit());
   }
 
-  @Test
-  public void testSeekWithZeroOffset() {
-    String splitId = "test-split-13";
-    List<String> records = Arrays.asList("record1", "record2", "record3");
-    ClosableIterator<String> iterator = createClosableIterator(records);
-
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
iterator, 0, 0L);
-
-    // Seeking to 0 should not skip any records
-    batchRecords.seek(0L);
-    batchRecords.nextSplit();
-
-    HoodieRecordWithPosition<String> record = 
batchRecords.nextRecordFromSplit();
-    assertNotNull(record);
-    assertEquals("record1", record.record());
-  }
-
   @Test
   public void testConstructorNullValidation() {
     String splitId = "test-split-14";
-    List<String> records = Arrays.asList("record1");
-    ClosableIterator<String> iterator = createClosableIterator(records);
+    List<String> records = Collections.singletonList("record1");
 
     // Test null finishedSplits
-    assertThrows(IllegalArgumentException.class, () -> {
-      new BatchRecords<>(splitId, iterator, 0, 0L, null);
-    });
-
-    // Test null recordIterator
-    assertThrows(IllegalArgumentException.class, () -> {
-      new BatchRecords<>(splitId, null, 0, 0L, new HashSet<>());
-    });
+    assertThrows(IllegalArgumentException.class, () ->
+        new BatchRecords<>(splitId, records, 0, 0L, null));
+
+    // Test null records
+    assertThrows(IllegalArgumentException.class, () ->
+        new BatchRecords<>(splitId, null, 0, 0L, new HashSet<>()));
   }
 
   @Test
   public void testRecordPositionReusability() {
     String splitId = "test-split-15";
     List<String> records = Arrays.asList("A", "B", "C");
-    ClosableIterator<String> iterator = createClosableIterator(records);
 
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
iterator, 0, 0L);
+    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
records, 0, 0L);
     batchRecords.nextSplit();
 
     HoodieRecordWithPosition<String> pos1 = batchRecords.nextRecordFromSplit();
     HoodieRecordWithPosition<String> pos2 = batchRecords.nextRecordFromSplit();
 
-    // Should reuse the same object
+    // Should reuse the same object (safe because SourceReaderBase emits each 
record before the next)
     assertTrue(pos1 == pos2, "Should reuse the same HoodieRecordWithPosition 
object");
   }
-
-  @Test
-  public void testSeekUpdatesPosition() {
-    String splitId = "test-split-16";
-    List<String> records = Arrays.asList("r1", "r2", "r3", "r4", "r5");
-    ClosableIterator<String> iterator = createClosableIterator(records);
-
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
iterator, 5, 10L);
-
-    // Seek to offset 3
-    batchRecords.seek(3L);
-
-    batchRecords.nextSplit();
-
-    // After seeking 3, next record should be r4 (4th record)
-    HoodieRecordWithPosition<String> record = 
batchRecords.nextRecordFromSplit();
-    assertNotNull(record);
-    assertEquals("r4", record.record());
-  }
-
-  @Test
-  public void testIteratorClosedAfterExhaustion() {
-    String splitId = "test-split-17";
-    List<String> records = Arrays.asList("record1");
-    MockClosableIterator<String> mockIterator = new 
MockClosableIterator<>(records);
-
-    BatchRecords<String> batchRecords = BatchRecords.forRecords(splitId, 
mockIterator, 0, 0L);
-    batchRecords.nextSplit();
-
-    // Read records
-    batchRecords.nextRecordFromSplit();
-
-    // Trigger close operation
-    batchRecords.nextRecordFromSplit();
-
-    // After exhaustion, nextRecordFromSplit should close the iterator
-    assertTrue(mockIterator.isClosed(), "Iterator should be closed after 
exhaustion");
-  }
-
-  /**
-   * Helper method to create a ClosableIterator from a list of items.
-   */
-  private <T> ClosableIterator<T> createClosableIterator(List<T> items) {
-    Iterator<T> iterator = items.iterator();
-    return new ClosableIterator<T>() {
-      @Override
-      public void close() {
-        // No-op for test
-      }
-
-      @Override
-      public boolean hasNext() {
-        return iterator.hasNext();
-      }
-
-      @Override
-      public T next() {
-        return iterator.next();
-      }
-    };
-  }
-
-  /**
-   * Mock closable iterator for testing close behavior.
-   */
-  private static class MockClosableIterator<T> implements ClosableIterator<T> {
-    private final Iterator<T> iterator;
-    private boolean closed = false;
-
-    public MockClosableIterator(List<T> items) {
-      this.iterator = items.iterator();
-    }
-
-    @Override
-    public void close() {
-      closed = true;
-    }
-
-    @Override
-    public boolean hasNext() {
-      return iterator.hasNext();
-    }
-
-    @Override
-    public T next() {
-      return iterator.next();
-    }
-
-    public boolean isClosed() {
-      return closed;
-    }
-  }
 }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestHoodieSourceSplitReader.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestHoodieSourceSplitReader.java
index 4dbae4896e77..ccb6c2bbf893 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestHoodieSourceSplitReader.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/TestHoodieSourceSplitReader.java
@@ -19,7 +19,6 @@
 package org.apache.hudi.source.reader;
 
 import org.apache.hudi.common.util.Option;
-import org.apache.hudi.common.util.collection.ClosableIterator;
 import org.apache.hudi.source.reader.function.SplitReaderFunction;
 import org.apache.hudi.source.split.HoodieSourceSplit;
 import org.apache.hudi.source.split.SerializableComparator;
@@ -33,11 +32,17 @@ import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiConsumer;
+import java.util.function.BooleanSupplier;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -183,13 +188,235 @@ public class TestHoodieSourceSplitReader {
   }
 
   @Test
-  public void testWakeUp() {
+  public void testWakeUp() throws IOException {
     TestSplitReaderFunction readerFunction = new TestSplitReaderFunction();
     HoodieSourceSplitReader<String> reader =
         new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, 
readerFunction, null, Option.empty());
 
-    // wakeUp is a no-op, should not throw any exception
+    // wakeUp() now sets a flag but must not throw, and the flag is reset at 
the top of each fetch()
+    // so a wakeUp with no in-flight drain leaves the next fetch() unaffected.
     reader.wakeUp();
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> result = 
reader.fetch();
+    assertNotNull(result);
+    assertNull(result.nextSplit());
+  }
+
+  // -------------------------------------------------------------------------
+  //  wakeUp — cooperative cancellation of the minibatch drain
+  // -------------------------------------------------------------------------
+
+  @Test
+  public void testWakeUpMidDrainReturnsPartialBatchAndResumes() throws 
IOException {
+    // A wakeUp() landing after the first record stops the drain between 
records: fetch() returns the
+    // 1 record buffered so far as a NON-finishing batch without closing the 
split; a later fetch()
+    // resumes the rest with continuous offsets, and the split is closed only 
once at true EOF.
+    List<String> testData = Arrays.asList("r1", "r2", "r3", "r4", "r5");
+    TestSplitReaderFunction readerFunction = new 
TestSplitReaderFunction(testData);
+    HoodieSourceSplitReader<String> reader =
+        new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, 
readerFunction, null, Option.empty());
+    boolean[] fired = {false};
+    readerFunction.setDrainProbe((buffered, hasNext) -> {
+      if (buffered == 1 && !fired[0]) {
+        fired[0] = true;
+        reader.wakeUp();
+      }
+    });
+
+    HoodieSourceSplit split = createTestSplit(1, "file1");
+    reader.handleSplitsChanges(new 
SplitsAddition<>(Collections.singletonList(split)));
+
+    // First fetch: woken after r1 -> partial batch of 1, split not finished, 
not closed.
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b1 = reader.fetch();
+    assertEquals(split.splitId(), b1.nextSplit());
+    HoodieRecordWithPosition<String> first = b1.nextRecordFromSplit();
+    assertNotNull(first);
+    assertEquals("r1", first.record());
+    assertEquals(1L, first.recordOffset());
+    assertNull(b1.nextRecordFromSplit(), "drain must stop at the first record 
on wake-up");
+    assertTrue(b1.finishedSplits().isEmpty(), "woken split must not be 
finished");
+    assertEquals(0, readerFunction.getCloseCurrentSplitCount(), "woken split 
must not be closed");
+
+    // Second fetch: resumes the remaining records with continuous offsets 
(2..5).
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b2 = reader.fetch();
+    assertEquals(split.splitId(), b2.nextSplit());
+    HoodieRecordWithPosition<String> next = b2.nextRecordFromSplit();
+    assertNotNull(next);
+    assertEquals("r2", next.record());
+    assertEquals(2L, next.recordOffset(), "offset continues across the wake 
boundary");
+    assertEquals(3, drainRecordCount(b2), "r3, r4, r5 remain");
+    assertTrue(b2.finishedSplits().isEmpty());
+
+    // Third fetch: true EOF -> finish signal, split closed exactly once, 
opened exactly once.
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b3 = reader.fetch();
+    assertTrue(b3.finishedSplits().contains(split.splitId()));
+    assertEquals(1, readerFunction.getCloseCurrentSplitCount());
+    assertEquals(1, readerFunction.getOpenCount());
+  }
+
+  @Test
+  public void testWakeUpBeforeAnyRecordReturnsEmptyNonFinishingBatch() throws 
IOException {
+    // A wakeUp() landing before any record is buffered must return an empty 
NON-finishing batch, not
+    // a finish signal: the split stays open (not closed) and resumes on the 
next fetch().
+    List<String> testData = Arrays.asList("r1", "r2", "r3");
+    TestSplitReaderFunction readerFunction = new 
TestSplitReaderFunction(testData);
+    HoodieSourceSplitReader<String> reader =
+        new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, 
readerFunction, null, Option.empty());
+    boolean[] fired = {false};
+    readerFunction.setDrainProbe((buffered, hasNext) -> {
+      // Wake at the start of the drain (count 0) while data is still 
available.
+      if (buffered == 0 && hasNext && !fired[0]) {
+        fired[0] = true;
+        reader.wakeUp();
+      }
+    });
+
+    HoodieSourceSplit split = createTestSplit(1, "file1");
+    reader.handleSplitsChanges(new 
SplitsAddition<>(Collections.singletonList(split)));
+
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b1 = reader.fetch();
+    assertNull(b1.nextSplit(), "empty non-finishing batch carries no split 
records");
+    assertTrue(b1.finishedSplits().isEmpty(), "must not finish the split on 
wake-up");
+    assertEquals(0, readerFunction.getCloseCurrentSplitCount(), "split must 
stay open");
+
+    // Next fetch resumes and returns all records (the one-shot wake has 
fired).
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b2 = reader.fetch();
+    assertEquals(split.splitId(), b2.nextSplit());
+    assertEquals(3, drainRecordCount(b2));
+  }
+
+  @Test
+  public void testWakeUpCoincidingWithEofDefersFinishByOneFetch() throws 
IOException {
+    // readBatch returning null is ambiguous between true-EOF and woken-empty. 
When a wakeUp lands
+    // exactly at genuine exhaustion, fetch() returns an empty non-finishing 
batch once (deferring the
+    // finish), and the next fetch() finishes the split - it must never stall.
+    List<String> testData = Arrays.asList("r1", "r2");
+    TestSplitReaderFunction readerFunction = new 
TestSplitReaderFunction(testData);
+    HoodieSourceSplitReader<String> reader =
+        new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, 
readerFunction, null, Option.empty());
+    boolean[] fired = {false};
+    readerFunction.setDrainProbe((buffered, hasNext) -> {
+      // Wake only at the start of a drain that finds the cursor already 
exhausted.
+      if (buffered == 0 && !hasNext && !fired[0]) {
+        fired[0] = true;
+        reader.wakeUp();
+      }
+    });
+
+    HoodieSourceSplit split = createTestSplit(1, "file1");
+    reader.handleSplitsChanges(new 
SplitsAddition<>(Collections.singletonList(split)));
+
+    // First fetch drains both records (cursor still had data at drain start, 
so no wake).
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b1 = reader.fetch();
+    assertEquals(2, drainRecordCount(b1));
+    assertEquals(0, readerFunction.getCloseCurrentSplitCount());
+
+    // Second fetch: cursor is exhausted at drain start -> wake fires -> empty 
non-finishing, deferred.
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b2 = reader.fetch();
+    assertTrue(b2.finishedSplits().isEmpty(), "finish deferred by the 
coinciding wake-up");
+    assertEquals(0, readerFunction.getCloseCurrentSplitCount());
+
+    // Third fetch: no wake now -> true EOF finishes and closes the split.
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b3 = reader.fetch();
+    assertTrue(b3.finishedSplits().contains(split.splitId()));
+    assertEquals(1, readerFunction.getCloseCurrentSplitCount());
+  }
+
+  @Test
+  public void testWakeUpWithLimitReachedStillFinishesSplit() throws 
IOException {
+    // Guard: the woken-resume short-circuit must live strictly on the 
readBatch-returned-null path.
+    // A wakeUp coinciding with the limit-reached path must still finish the 
split, never loop forever
+    // returning non-finishing batches. The limiter wakes the reader exactly 
when the limit is reached.
+    List<String> testData = Arrays.asList("r1", "r2", "r3");
+    TestSplitReaderFunction readerFunction = new 
TestSplitReaderFunction(testData);
+    AtomicReference<HoodieSourceSplitReader<String>> holder = new 
AtomicReference<>();
+    RecordLimiter wakingLimiter = new RecordLimiter(2L) {
+      @Override
+      public boolean isLimitReached() {
+        boolean reached = super.isLimitReached();
+        if (reached && holder.get() != null) {
+          holder.get().wakeUp();
+        }
+        return reached;
+      }
+    };
+    HoodieSourceSplitReader<String> reader =
+        new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, 
readerFunction, null, Option.of(wakingLimiter));
+    holder.set(reader);
+
+    HoodieSourceSplit split = createTestSplit(1, "file1");
+    reader.handleSplitsChanges(new 
SplitsAddition<>(Collections.singletonList(split)));
+
+    // First fetch returns the split data; the limit wrapper caps consumption 
at 2 records.
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b1 = reader.fetch();
+    assertEquals(split.splitId(), b1.nextSplit());
+    assertEquals(2, drainRecordCount(b1), "limit caps drained records at 2");
+
+    // Second fetch hits the limit-reached path (which wakes the reader); it 
must finish, not defer.
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b2 = reader.fetch();
+    assertTrue(b2.finishedSplits().contains(split.splitId()), "limit-reached 
path must finish the split");
+    assertEquals(1, readerFunction.getCloseCurrentSplitCount());
+  }
+
+  @Test
+  public void testWakeUpPartialBatchRespectsLimit() throws IOException {
+    // A partial (woken) batch must not double-count against the pushed-down 
limit: with limit=3 over a
+    // 5-record split and a wake after the first record, the total emitted 
across batches stays at 3.
+    List<String> testData = Arrays.asList("r1", "r2", "r3", "r4", "r5");
+    TestSplitReaderFunction readerFunction = new 
TestSplitReaderFunction(testData);
+    HoodieSourceSplitReader<String> reader =
+        new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, 
readerFunction, null, Option.of(new RecordLimiter(3L)));
+    boolean[] fired = {false};
+    readerFunction.setDrainProbe((buffered, hasNext) -> {
+      if (buffered == 1 && !fired[0]) {
+        fired[0] = true;
+        reader.wakeUp();
+      }
+    });
+
+    HoodieSourceSplit split = createTestSplit(1, "file1");
+    reader.handleSplitsChanges(new 
SplitsAddition<>(Collections.singletonList(split)));
+
+    int total = 0;
+    // Drain fetches until the split is finished; assert the limit caps the 
total at 3.
+    for (int i = 0; i < 10; i++) {
+      RecordsWithSplitIds<HoodieRecordWithPosition<String>> batch = 
reader.fetch();
+      if (batch.nextSplit() != null) {
+        total += drainRecordCount(batch);
+      }
+      if (batch.finishedSplits().contains(split.splitId())) {
+        break;
+      }
+    }
+    assertEquals(3, total, "pushed-down limit must cap the total across 
partial woken batches");
+  }
+
+  @Test
+  public void testCloseReleasesWokenStillOpenSplit() throws Exception {
+    // Unit proxy for the real shutdown path (SplitFetcher.run() -> 
splitReader.close() on the fetcher
+    // thread): a split left open by a wake-up is released when the reader is 
closed.
+    List<String> testData = Arrays.asList("r1", "r2", "r3");
+    TestSplitReaderFunction readerFunction = new 
TestSplitReaderFunction(testData);
+    HoodieSourceSplitReader<String> reader =
+        new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, 
readerFunction, null, Option.empty());
+    boolean[] fired = {false};
+    readerFunction.setDrainProbe((buffered, hasNext) -> {
+      if (buffered == 1 && !fired[0]) {
+        fired[0] = true;
+        reader.wakeUp();
+      }
+    });
+
+    HoodieSourceSplit split = createTestSplit(1, "file1");
+    reader.handleSplitsChanges(new 
SplitsAddition<>(Collections.singletonList(split)));
+
+    // Woken fetch leaves the split open (not closed).
+    reader.fetch();
+    assertEquals(0, readerFunction.getCloseCurrentSplitCount());
+
+    // close() on the (split-fetcher) thread releases the still-open split.
+    reader.close();
+    assertEquals(1, readerFunction.getCloseCurrentSplitCount());
+    assertTrue(readerFunction.isClosed());
   }
 
   @Test
@@ -221,7 +448,7 @@ public class TestHoodieSourceSplitReader {
 
     reader.fetch();
 
-    assertEquals(1, readerFunction.getReadCount());
+    assertEquals(1, readerFunction.getOpenCount());
     assertEquals(split, readerFunction.getLastReadSplit());
   }
 
@@ -246,7 +473,7 @@ public class TestHoodieSourceSplitReader {
 
     assertNotNull(result);
     assertNull(result.nextSplit());
-    assertEquals(0, readerFunction.getReadCount(), "Should not read any 
splits");
+    assertEquals(0, readerFunction.getOpenCount(), "Should not read any 
splits");
   }
 
   @Test
@@ -347,8 +574,8 @@ public class TestHoodieSourceSplitReader {
     assertTrue(drainBatch.finishedSplits().contains(split2.splitId()),
         "split2 should be drained as finished once limit is reached");
     assertNull(drainBatch.nextSplit());
-    // readerFunction.read() was called only once (for split1, never for 
split2)
-    assertEquals(1, readerFunction.getReadCount());
+    // readerFunction was opened only once (for split1, never for split2)
+    assertEquals(1, readerFunction.getOpenCount());
   }
 
   @Test
@@ -368,8 +595,8 @@ public class TestHoodieSourceSplitReader {
     assertTrue(finished.contains(split1.splitId()));
     assertTrue(finished.contains(split2.splitId()));
     assertNull(batch.nextSplit());
-    // readerFunction.read() was never called
-    assertEquals(0, readerFunction.getReadCount());
+    // readerFunction was never opened
+    assertEquals(0, readerFunction.getOpenCount());
   }
 
   @Test
@@ -449,6 +676,78 @@ public class TestHoodieSourceSplitReader {
     assertEquals(5, drainRecordCount(batch));
   }
 
+  // -------------------------------------------------------------------------
+  //  Minibatch / resume tests
+  // -------------------------------------------------------------------------
+
+  @Test
+  public void testSplitSpanningMultipleMinibatches() throws IOException {
+    // A split larger than the mini-batch bound (2048) is emitted as multiple 
non-finished batches,
+    // then one finish signal. The reader function is opened once and closed 
once across the split,
+    // and the record offset stays continuous across the minibatch boundary.
+    int n = 2049;
+    List<String> testData = IntStream.range(0, n).mapToObj(i -> "r" + 
i).collect(Collectors.toList());
+    TestSplitReaderFunction readerFunction = new 
TestSplitReaderFunction(testData);
+    HoodieSourceSplitReader<String> reader =
+        new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, 
readerFunction, null, Option.empty());
+
+    HoodieSourceSplit split = createTestSplit(1, "file1");
+    reader.handleSplitsChanges(new 
SplitsAddition<>(Collections.singletonList(split)));
+
+    // First minibatch: 2048 records, offsets 1..2048, split not yet finished.
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b1 = reader.fetch();
+    assertEquals(split.splitId(), b1.nextSplit());
+    long lastOffset = 0L;
+    int c1 = 0;
+    HoodieRecordWithPosition<String> rec;
+    while ((rec = b1.nextRecordFromSplit()) != null) {
+      lastOffset = rec.recordOffset();
+      c1++;
+    }
+    assertEquals(2048, c1);
+    assertEquals(2048L, lastOffset);
+    assertTrue(b1.finishedSplits().isEmpty(), "split not finished after the 
first minibatch");
+
+    // Second minibatch: the remaining record, offset continues at 2049.
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b2 = reader.fetch();
+    assertEquals(split.splitId(), b2.nextSplit());
+    HoodieRecordWithPosition<String> only = b2.nextRecordFromSplit();
+    assertNotNull(only);
+    assertEquals(2049L, only.recordOffset());
+    assertNull(b2.nextRecordFromSplit());
+    assertTrue(b2.finishedSplits().isEmpty());
+
+    // Third fetch: split exhausted -> finish signal.
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> b3 = reader.fetch();
+    assertTrue(b3.finishedSplits().contains(split.splitId()));
+
+    assertEquals(1, readerFunction.getOpenCount(), "split opened exactly once 
across minibatches");
+    assertEquals(1, readerFunction.getCloseCurrentSplitCount(), "split closed 
exactly once at EOF");
+  }
+
+  @Test
+  public void testResumeSkipsConsumedRecords() throws IOException {
+    // A recovered split carries a consumed offset; open() skips that many 
records and the emitted
+    // offsets resume at consumed+1.
+    List<String> testData = Arrays.asList("r1", "r2", "r3", "r4", "r5");
+    TestSplitReaderFunction readerFunction = new 
TestSplitReaderFunction(testData);
+    HoodieSourceSplitReader<String> reader =
+        new HoodieSourceSplitReader<>(TABLE_NAME, readerContext, 
readerFunction, null, Option.empty());
+
+    HoodieSourceSplit split = createTestSplit(1, "file1");
+    split.updatePosition(0, 2L); // 2 records already consumed before recovery
+    reader.handleSplitsChanges(new 
SplitsAddition<>(Collections.singletonList(split)));
+
+    RecordsWithSplitIds<HoodieRecordWithPosition<String>> batch = 
reader.fetch();
+    assertEquals(split.splitId(), batch.nextSplit());
+    HoodieRecordWithPosition<String> first = batch.nextRecordFromSplit();
+    assertNotNull(first);
+    assertEquals("r3", first.record(), "should resume past the 2 consumed 
records");
+    assertEquals(3L, first.recordOffset(), "offset resumes at consumed + 1");
+    // r4, r5 remain
+    assertEquals(2, drainRecordCount(batch));
+  }
+
   /**
    * Fetches the next batch that contains actual split data, skipping 
split-finish signal batches.
    * Split-finish batches have non-empty {@code finishedSplits()} but no 
records.
@@ -495,14 +794,29 @@ public class TestHoodieSourceSplitReader {
   }
 
   /**
-   * Test implementation of SplitReaderFunction.
+   * Test implementation of the stateful {@link SplitReaderFunction} cursor 
contract: {@code open}
+   * materializes the split's data and honors the consumed-offset skip, {@code 
readBatch} drains a
+   * bounded minibatch, and {@code closeCurrentSplit}/{@code close} release it.
    */
   private static class TestSplitReaderFunction implements 
SplitReaderFunction<String> {
     private final List<String> testData;
-    private int readCount = 0;
+    private int openCount = 0;
+    private int closeCurrentSplitCount = 0;
     private HoodieSourceSplit lastReadSplit = null;
     private boolean closed = false;
 
+    // per-split cursor
+    private Iterator<String> cursor;
+    private long nextRecordOffset;
+
+    // Optional hook invoked as (bufferedCount, cursorHasNext) at readBatch 
start (count 0) and after
+    // each buffered record, so a test can call reader.wakeUp() at a precise 
point in the drain.
+    private BiConsumer<Integer, Boolean> drainProbe;
+
+    void setDrainProbe(BiConsumer<Integer, Boolean> drainProbe) {
+      this.drainProbe = drainProbe;
+    }
+
     public TestSplitReaderFunction() {
       this(Collections.emptyList());
     }
@@ -512,25 +826,63 @@ public class TestHoodieSourceSplitReader {
     }
 
     @Override
-    public RecordsWithSplitIds<HoodieRecordWithPosition<String>> 
read(HoodieSourceSplit split) {
-      readCount++;
+    public void open(HoodieSourceSplit split) {
+      openCount++;
       lastReadSplit = split;
-      ClosableIterator<String> iterator = createClosableIterator(testData);
-      return BatchRecords.forRecords(
-          split.splitId(),
-          iterator,
-          split.getFileOffset(),
-          split.getConsumed()
-      );
+      cursor = testData.iterator();
+      long consumed = split.getConsumed();
+      for (long i = 0; i < consumed; i++) {
+        if (cursor.hasNext()) {
+          cursor.next();
+        } else {
+          throw new IllegalStateException(
+              "Invalid starting record offset " + consumed + " for split " + 
split.splitId());
+        }
+      }
+      nextRecordOffset = consumed;
     }
 
     @Override
-    public void close() throws Exception {
+    public BatchRecords<String> readBatch(HoodieSourceSplit split, int 
batchSize, BooleanSupplier wakeupSignal) {
+      List<String> buffer = new ArrayList<>();
+      if (drainProbe != null) {
+        drainProbe.accept(0, cursor.hasNext());
+      }
+      while (buffer.size() < batchSize && !wakeupSignal.getAsBoolean() && 
cursor.hasNext()) {
+        buffer.add(cursor.next());
+        if (drainProbe != null) {
+          drainProbe.accept(buffer.size(), cursor.hasNext());
+        }
+      }
+      if (buffer.isEmpty()) {
+        return null;
+      }
+      long startingRecordOffset = nextRecordOffset;
+      nextRecordOffset += buffer.size();
+      return BatchRecords.forRecords(split.splitId(), buffer, 
split.getFileOffset(), startingRecordOffset);
+    }
+
+    @Override
+    public void closeCurrentSplit() {
+      closeCurrentSplitCount++;
+      cursor = null;
+    }
+
+    @Override
+    public void close() {
+      // Mirror AbstractSplitReaderFunction#close(): releasing the reader also 
releases any split
+      // still open (e.g. one left open by a wake-up), on the split-fetcher 
thread.
+      closeCurrentSplit();
       closed = true;
     }
 
-    public int getReadCount() {
-      return readCount;
+    // Number of splits opened; mirrors the old per-split read() count.
+    public int getOpenCount() {
+      return openCount;
+    }
+
+    public int getCloseCurrentSplitCount() {
+      return closeCurrentSplitCount;
     }
 
     public HoodieSourceSplit getLastReadSplit() {
@@ -540,25 +892,5 @@ public class TestHoodieSourceSplitReader {
     public boolean isClosed() {
       return closed;
     }
-
-    private ClosableIterator<String> createClosableIterator(List<String> 
items) {
-      Iterator<String> iterator = items.iterator();
-      return new ClosableIterator<String>() {
-        @Override
-        public void close() {
-          // No-op
-        }
-
-        @Override
-        public boolean hasNext() {
-          return iterator.hasNext();
-        }
-
-        @Override
-        public String next() {
-          return iterator.next();
-        }
-      };
-    }
   }
 }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestAbstractSplitReaderFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestAbstractSplitReaderFunction.java
index d785be90bb87..85cf06a665b5 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestAbstractSplitReaderFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestAbstractSplitReaderFunction.java
@@ -18,20 +18,26 @@
 
 package org.apache.hudi.source.reader.function;
 
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.collection.ClosableIterator;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.source.ExpressionPredicates;
+import org.apache.hudi.source.reader.BatchRecords;
 import org.apache.hudi.source.reader.HoodieRecordWithPosition;
 import org.apache.hudi.source.split.HoodieSourceSplit;
 import org.apache.hudi.table.format.InternalSchemaManager;
 import org.apache.hudi.utils.TestConfigurations;
 
 import org.apache.flink.configuration.Configuration;
-import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
+import org.apache.flink.table.data.GenericRowData;
 import org.apache.flink.table.data.RowData;
 import org.apache.flink.table.expressions.FieldReferenceExpression;
 import org.apache.flink.table.expressions.ValueLiteralExpression;
 import org.apache.flink.table.types.AtomicDataType;
+import org.apache.flink.table.types.logical.IntType;
+import org.apache.flink.table.types.logical.RowType;
 import org.apache.flink.table.types.logical.VarCharType;
+import org.apache.flink.types.RowKind;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
@@ -39,10 +45,13 @@ import org.junit.jupiter.api.io.TempDir;
 import java.io.File;
 import java.util.Collections;
 import java.util.List;
+import java.util.function.BooleanSupplier;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
@@ -70,8 +79,8 @@ public class TestAbstractSplitReaderFunction {
 
   /**
    * Minimal concrete implementation that exposes the protected helper methods
-   * ({@code getWriteConfig()} / {@code getHadoopConf()}) for testing, and 
leaves
-   * {@code read()} / {@code close()} as no-ops.
+   * ({@code getWriteConfig()} / {@code getHadoopConf()}) for testing. The 
template methods return an
+   * empty iterator / a trivial row type; the constructor and singleton tests 
never drive the cursor.
    */
   private static class MinimalSplitReaderFunction extends 
AbstractSplitReaderFunction {
 
@@ -84,12 +93,13 @@ public class TestAbstractSplitReaderFunction {
     }
 
     @Override
-    public RecordsWithSplitIds<HoodieRecordWithPosition<RowData>> 
read(HoodieSourceSplit split) {
-      return null;
+    protected ClosableIterator<RowData> createRecordIterator(HoodieSourceSplit 
split) {
+      return ClosableIterator.wrap(Collections.<RowData>emptyIterator());
     }
 
     @Override
-    public void close() throws Exception {
+    protected RowType producedRowType() {
+      return RowType.of(new IntType());
     }
 
     HoodieWriteConfig writeConfigForTest() {
@@ -243,4 +253,135 @@ public class TestAbstractSplitReaderFunction {
     assertSame(wc2, fn2.writeConfigForTest(),
         "fn2's writeConfig must remain the same singleton across calls");
   }
+
+  // -----------------------------------------------------------------------
+  //  readBatch — copy-on-materialize (object-reuse regression guard)
+  // -----------------------------------------------------------------------
+
+  @Test
+  public void testReadBatchCopiesReusedRecordObjects() {
+    // Columnar readers return the SAME mutable RowData object on every 
next(); readBatch must copy
+    // each record, otherwise every entry in a materialized minibatch would 
alias the last row.
+    ReusedObjectSplitReaderFunction fn =
+        new ReusedObjectSplitReaderFunction(conf, mockInternalSchemaManager, 
3);
+    HoodieSourceSplit split = createSplit();
+
+    fn.open(split);
+    BatchRecords<RowData> batch = fn.readBatch(split, 10, () -> false);
+    assertNotNull(batch);
+    batch.nextSplit();
+
+    RowData r0 = batch.nextRecordFromSplit().record();
+    RowData r1 = batch.nextRecordFromSplit().record();
+    RowData r2 = batch.nextRecordFromSplit().record();
+    assertNull(batch.nextRecordFromSplit());
+
+    // Distinct values despite the source reusing a single object.
+    assertEquals(0, r0.getInt(0));
+    assertEquals(1, r1.getInt(0));
+    assertEquals(2, r2.getInt(0));
+    assertNotSame(r0, r1, "records must be copies, not the reused source 
object");
+    assertNotSame(r1, r2);
+    // RowKind is preserved across the copy.
+    assertEquals(RowKind.DELETE, r0.getRowKind());
+    assertEquals(RowKind.INSERT, r1.getRowKind());
+    assertEquals(RowKind.DELETE, r2.getRowKind());
+  }
+
+  // -----------------------------------------------------------------------
+  //  readBatch — wake-up signal (cooperative cancellation between records)
+  // -----------------------------------------------------------------------
+
+  @Test
+  public void testReadBatchStopsOnWakeupSignal() {
+    // The wakeupSignal is polled between records; once it trips, 
materialization stops early and the
+    // records buffered so far are returned as a partial minibatch with 
continuous offsets.
+    ReusedObjectSplitReaderFunction fn =
+        new ReusedObjectSplitReaderFunction(conf, mockInternalSchemaManager, 
5);
+    HoodieSourceSplit split = createSplit();
+    fn.open(split);
+
+    // Returns false, false, true: the loop buffers 2 records, then the 3rd 
poll stops it.
+    int[] polls = {0};
+    BooleanSupplier signal = () -> (++polls[0]) > 2;
+
+    BatchRecords<RowData> batch = fn.readBatch(split, 10, signal);
+    assertNotNull(batch);
+    batch.nextSplit();
+
+    // nextRecordFromSplit() returns the same reused position wrapper each 
call, so read each record's
+    // value/offset before advancing. Offsets are 1-based and continuous from 
the starting offset (0).
+    HoodieRecordWithPosition<RowData> rec = batch.nextRecordFromSplit();
+    assertNotNull(rec);
+    assertEquals(0, rec.record().getInt(0));
+    assertEquals(1L, rec.recordOffset());
+
+    rec = batch.nextRecordFromSplit();
+    assertNotNull(rec);
+    assertEquals(1, rec.record().getInt(0));
+    assertEquals(2L, rec.recordOffset());
+
+    assertNull(batch.nextRecordFromSplit(), "materialization must stop at 2 
records on wake-up");
+  }
+
+  @Test
+  public void testReadBatchReturnsNullWhenWokenBeforeAnyRecord() {
+    // A wake-up that lands before the first record is buffered yields an 
empty batch, signalled as
+    // null (the same sentinel as EOF); HoodieSourceSplitReader.fetch() 
disambiguates the two.
+    ReusedObjectSplitReaderFunction fn =
+        new ReusedObjectSplitReaderFunction(conf, mockInternalSchemaManager, 
5);
+    HoodieSourceSplit split = createSplit();
+    fn.open(split);
+
+    assertNull(fn.readBatch(split, 10, () -> true));
+  }
+
+  private HoodieSourceSplit createSplit() {
+    return new HoodieSourceSplit(
+        1, "base", Option.of(Collections.emptyList()), "/tbl", "/part",
+        "read_optimized", "19700101000000000", "file1", Option.empty());
+  }
+
+  /**
+   * Reader function whose iterator returns the SAME mutable {@link 
GenericRowData} instance on every
+   * {@code next()} (mimicking a columnar reader), used to prove readBatch 
copies each record.
+   */
+  private static class ReusedObjectSplitReaderFunction extends 
AbstractSplitReaderFunction {
+    private final int count;
+
+    ReusedObjectSplitReaderFunction(Configuration conf, InternalSchemaManager 
ism, int count) {
+      super(conf, Collections.emptyList(), ism, false);
+      this.count = count;
+    }
+
+    @Override
+    protected ClosableIterator<RowData> createRecordIterator(HoodieSourceSplit 
split) {
+      return new ClosableIterator<RowData>() {
+        private final GenericRowData reused = new GenericRowData(1);
+        private int i = 0;
+
+        @Override
+        public boolean hasNext() {
+          return i < count;
+        }
+
+        @Override
+        public RowData next() {
+          reused.setField(0, i);
+          reused.setRowKind(i % 2 == 0 ? RowKind.DELETE : RowKind.INSERT);
+          i++;
+          return reused; // same object every call
+        }
+
+        @Override
+        public void close() {
+        }
+      };
+    }
+
+    @Override
+    protected RowType producedRowType() {
+      return RowType.of(new IntType());
+    }
+  }
 }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java
index b0d0579da084..d5dc3eca5770 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java
@@ -156,11 +156,11 @@ public class TestHoodieCdcSplitReaderFunction {
         1, "base.parquet", Option.empty(), tempDir.getAbsolutePath(),
         "", "read_optimized", "20230101000000000", "file-1", Option.empty());
 
-    Exception ex = assertThrows(Exception.class, () -> 
function.read(nonCdcSplit));
+    Exception ex = assertThrows(Exception.class, () -> 
function.open(nonCdcSplit));
     assertNotNull(ex);
     // Must not be IllegalArgumentException (which the old type-guard wrongly 
threw)
     if (ex instanceof IllegalArgumentException) {
-      throw new AssertionError("read() should not throw 
IllegalArgumentException for non-CDC split; "
+      throw new AssertionError("open() should not throw 
IllegalArgumentException for non-CDC split; "
           + "it should fall through to the fallback reader", ex);
     }
   }
@@ -223,7 +223,7 @@ public class TestHoodieCdcSplitReaderFunction {
   // -------------------------------------------------------------------------
 
   @Test
-  public void testReadAcceptsCdcSourceSplitType() {
+  public void testReadAcceptsCdcSourceSplitType() throws Exception {
     // Verify that HoodieCdcSourceSplit is accepted (cast doesn't throw).
     // Actual I/O would require a real Hoodie table, so we only check the
     // type-guard passes by catching the downstream I/O error rather than
@@ -237,7 +237,8 @@ public class TestHoodieCdcSplitReaderFunction {
         1, tempDir.getAbsolutePath(), 128 * 1024 * 1024L, "file-cdc",
         EMPTY_PARTITION_PATH, changes, "read_optimized", "20230101000000000");
 
-    // Should not throw exception
-    function.read(cdcSplit);
+    // Opening the split creates the CDC iterator lazily (no I/O yet); it must 
not throw.
+    function.open(cdcSplit);
+    function.close();
   }
 }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieSplitReaderFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieSplitReaderFunction.java
index 062b0c0ff11a..e6f8b76f986d 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieSplitReaderFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieSplitReaderFunction.java
@@ -23,11 +23,17 @@ import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.schema.internal.InternalSchema;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.read.HoodieFileGroupReader;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.exception.HoodieIOException;
 import org.apache.hudi.source.ExpressionPredicates;
+import org.apache.hudi.source.split.HoodieSourceSplit;
 import org.apache.hudi.table.format.InternalSchemaManager;
+import org.apache.hudi.util.StreamerUtil;
 import org.apache.hudi.utils.TestConfigurations;
 
 import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.data.RowData;
 import org.apache.flink.table.expressions.FieldReferenceExpression;
 import org.apache.flink.table.expressions.ValueLiteralExpression;
 import org.apache.flink.table.types.AtomicDataType;
@@ -35,14 +41,23 @@ import org.apache.flink.table.types.logical.VarCharType;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.mockito.MockedStatic;
 
 import java.io.File;
+import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
@@ -284,7 +299,8 @@ public class TestHoodieSplitReaderFunction {
 
   @Test
   public void testReadMethodSignature() {
-    // Verify that the read method returns CloseableIterator
+    // Verify the reader function constructs via its public signature (the 
read path is driven
+    // through open/readBatch on the split-fetcher thread).
     HoodieSplitReaderFunction function =
         new HoodieSplitReaderFunction(
             conf,
@@ -444,4 +460,78 @@ public class TestHoodieSplitReaderFunction {
 
     assertNotNull(function);
   }
+
+  // -------------------------------------------------------------------------
+  //  createRecordIterator — file-group reader cleanup when iterator init fails
+  // -------------------------------------------------------------------------
+
+  @Test
+  public void testCreateRecordIteratorClosesReaderWhenInitFails() throws 
Exception {
+    // getClosableIterator() runs initRecordIterators(), which can open the 
reader's I/O resources and
+    // then throw. The reader is only a local in createRecordIterator, so the 
failure path must close
+    // it (nothing else would), preserving the original failure as the cause.
+    HoodieFileGroupReader<RowData> reader = mockReader();
+    IOException initFailure = new IOException("init failed");
+    when(reader.getClosableIterator()).thenThrow(initFailure);
+
+    HoodieSplitReaderFunction function = readerFunctionReturning(reader);
+    HoodieSourceSplit split = createSplit();
+
+    try (MockedStatic<StreamerUtil> mockedStreamerUtil = 
mockStatic(StreamerUtil.class)) {
+      mockedStreamerUtil.when(() -> StreamerUtil.metaClientForReader(any(), 
any()))
+          .thenReturn(mockMetaClient);
+
+      HoodieIOException thrown =
+          assertThrows(HoodieIOException.class, () -> 
function.createRecordIterator(split));
+      assertSame(initFailure, thrown.getCause(), "the original init failure 
must be the cause");
+    }
+    verify(reader, times(1)).close();
+  }
+
+  @Test
+  public void testCreateRecordIteratorSuppressesCloseError() throws Exception {
+    // A close() failure on the cleanup path must not mask the init failure: 
it is attached as a
+    // suppressed exception on the original.
+    HoodieFileGroupReader<RowData> reader = mockReader();
+    IOException initFailure = new IOException("init failed");
+    IOException closeFailure = new IOException("close failed");
+    when(reader.getClosableIterator()).thenThrow(initFailure);
+    doThrow(closeFailure).when(reader).close();
+
+    HoodieSplitReaderFunction function = readerFunctionReturning(reader);
+    HoodieSourceSplit split = createSplit();
+
+    try (MockedStatic<StreamerUtil> mockedStreamerUtil = 
mockStatic(StreamerUtil.class)) {
+      mockedStreamerUtil.when(() -> StreamerUtil.metaClientForReader(any(), 
any()))
+          .thenReturn(mockMetaClient);
+
+      HoodieIOException thrown =
+          assertThrows(HoodieIOException.class, () -> 
function.createRecordIterator(split));
+      assertSame(initFailure, thrown.getCause());
+      assertEquals(1, initFailure.getSuppressed().length, "close failure must 
be suppressed, not lost");
+      assertSame(closeFailure, initFailure.getSuppressed()[0]);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private static HoodieFileGroupReader<RowData> mockReader() {
+    return mock(HoodieFileGroupReader.class);
+  }
+
+  private HoodieSplitReaderFunction 
readerFunctionReturning(HoodieFileGroupReader<RowData> reader) {
+    return new HoodieSplitReaderFunction(
+        conf, tableSchema, requiredSchema, mockInternalSchemaManager,
+        "AVRO_PAYLOAD", Collections.emptyList(), false) {
+      @Override
+      protected HoodieFileGroupReader<RowData> 
createFileGroupReader(HoodieSourceSplit split, HoodieTableMetaClient 
metaClient) {
+        return reader;
+      }
+    };
+  }
+
+  private static HoodieSourceSplit createSplit() {
+    return new HoodieSourceSplit(
+        1, "base", Option.of(Collections.emptyList()), "/tbl", "/part",
+        "read_optimized", "19700101000000000", "file1", Option.empty());
+  }
 }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
index 07c3f0c05ecd..a656a1314eac 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
@@ -99,6 +99,7 @@ import java.util.Set;
 import java.util.TreeSet;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
@@ -4133,10 +4134,11 @@ public class ITTestHoodieDataSource {
    * the collected rows.
    *
    * <p>The streaming job is terminated by a forced {@link 
CollectSinkTableFactory.SuccessException} once
-   * {@code expectedNum} rows are collected. A benign teardown race (see 
{@link #isAcceptableTerminalFailure})
-   * can instead end the job before all rows are emitted, leaving a short 
result. Re-reading the already
-   * committed table is idempotent, so retry up to {@link 
#MAX_STREAM_READ_ATTEMPTS} times when the result
-   * is short; this keeps the race from surfacing as a confusing row-count 
assertion failure.
+   * {@code expectedNum} rows are collected. On a slow CI shard the {@code 
await} window can elapse before
+   * the sink reaches {@code expectedNum} (a bare timeout - see {@link 
#isAwaitTimeout}), leaving a short
+   * result. Re-reading the already committed table is idempotent, so retry up 
to
+   * {@link #MAX_STREAM_READ_ATTEMPTS} times when the result is short; this 
keeps a slow shard from
+   * surfacing as a confusing row-count (or "Unexpected job failure") 
assertion failure.
    */
   private List<Row> submitAndFetchWithRetry(TableEnvironment tEnv, String 
select, String sinkDDL, int expectedNum) {
     List<Row> rows = Collections.emptyList();
@@ -4174,46 +4176,35 @@ public class ITTestHoodieDataSource {
   private List<Row> fetchResultWithExpectedNum(TableEnvironment tEnv, 
TableResult tableResult) {
     try {
       // wait the continuous streaming query to be terminated by forced 
exception with expected row number
-      // and max waiting timeout is 30s
-      tableResult.await(30, TimeUnit.SECONDS);
+      // and max waiting timeout is 60s (kept generous so a slow CI shard does 
not time out before the
+      // sink collects its rows; a bare timeout is still handled as a 
retryable short read below)
+      tableResult.await(60, TimeUnit.SECONDS);
     } catch (Throwable e) {
-      // Acceptable terminal causes:
-      //   1. SuccessException: the sink reached its expected row count and 
intentionally
-      //      threw to terminate the streaming job. This is the happy path.
-      //   2. IOException("Stream is closed!") wrapped as HoodieIOException: a 
benign
-      //      error-attribution race between the source-side 
cascading-shutdown path and
-      //      the sink-side SuccessException terminator. When the sink throws
-      //      SuccessException to end the job, the chained source's 
SplitFetcher can close
-      //      the underlying Hadoop FSDataInputStream while the mailbox is 
still draining
-      //      a BatchRecords queued earlier; the next row-group read on the 
now-closed
-      //      stream surfaces an IOException("Stream is closed!"). With
-      //      restart-strategy.fixed-delay.attempts=0 (set in beforeEach to 
keep tests
-      //      deterministic) that IOException becomes the job's reported 
failure cause
-      //      instead of the sink's SuccessException, even though the sink has 
already
-      //      collected the expected rows by then - i.e. the functional 
outcome is
-      //      unchanged, only the error-attribution differs. Production paths 
correctly
-      //      fail the job on stream-closed-mid-read (the right behavior for 
real I/O
-      //      failures), so this tolerance is scoped to the 
SuccessException-based test
-      //      pattern below and is NOT mirrored in production code.
-      //   3. NullPointerException from 
ParquetColumnarRowSplitReader#readNextRowGroup: the
-      //      same benign teardown race as (2), observed with different 
timing. When the
-      //      SplitFetcher's close() fully completes first, 
ParquetColumnarRowSplitReader#close
-      //      nulls out its `reader` field, so the in-flight row-group read on 
the task thread
-      //      surfaces as a NullPointerException (reader.readNextRowGroup() on 
a null reader)
-      //      instead of an IOException("Stream is closed!"). Same functional 
outcome - the
-      //      sink has already collected the expected rows - only the error 
symptom differs.
-      //      Tolerated narrowly (an NPE originating from that exact frame) 
for the same
-      //      reason as (2), and likewise NOT mirrored in production code.
-      if (!isAcceptableTerminalFailure(e)) {
-        throw new AssertionError("Unexpected job failure", e);
-      }
-      // The races (2)/(3) usually fire after the sink has collected its 
expected rows, but can also fire
-      // before - ending the read with a short result. Log the tolerated cause 
so an incomplete read is
-      // diagnosable; submitAndFetchWithRetry re-reads when the collected 
count is below the expectation.
-      if (!isSuccessException(e)) {
-        log.warn("Streaming read terminated by a tolerated teardown race ({}); 
collected {} rows so far.",
-            describeTerminalCause(e),
+      // The only acceptable terminal cause is the sink reaching its expected 
row count and throwing
+      // SuccessException to terminate the streaming job (the happy path). The 
Source V2 read path now
+      // reads and closes each split's I/O on a single (split-fetcher) thread, 
so the former teardown
+      // races (a closed Parquet stream / a closed CDC iterator surfacing on 
the task thread) can no
+      // longer happen; any other terminal failure is a real error and fails 
the test.
+      //
+      // A bare await-window TimeoutException is not a terminal failure at all 
- the sink simply had not
+      // reached expectedNum yet (typically CI-load slowness), so the job is 
still running. Cancel it and
+      // let submitAndFetchWithRetry re-submit a fresh job, rather than 
treating a slow shard as a hard
+      // failure.
+      if (isAwaitTimeout(e)) {
+        // Cancel the still-running job (best-effort, bounded) so it cannot 
keep writing to the shared
+        // CollectSinkTableFactory.RESULT after the retry's re-submit clears 
it.
+        tableResult.getJobClient().ifPresent(jobClient -> {
+          try {
+            jobClient.cancel().get(30, TimeUnit.SECONDS);
+          } catch (Exception ignored) {
+            // best-effort cancel; the subsequent re-submit clears RESULT and 
starts a fresh job
+          }
+        });
+        log.warn("Streaming read did not reach the expected row count within 
the await window; "
+                + "cancelled the job and will retry. Collected {} rows so 
far.",
             
CollectSinkTableFactory.RESULT.values().stream().mapToInt(List::size).sum());
+      } else if (!isSuccessException(e)) {
+        throw new AssertionError("Unexpected job failure", e);
       }
     }
     tEnv.executeSql("DROP TABLE IF EXISTS sink");
@@ -4223,56 +4214,20 @@ public class ITTestHoodieDataSource {
   }
 
   /**
-   * Whether {@code e} (or any of its causes) is one of the terminal failures 
that
-   * {@link #fetchResultWithExpectedNum} is allowed to swallow. See the 
comment at the call
-   * site for the rationale.
-   */
-  private static boolean isAcceptableTerminalFailure(Throwable e) {
-    Throwable cur = e;
-    while (cur != null) {
-      if (cur instanceof CollectSinkTableFactory.SuccessException) {
-        return true;
-      }
-      String msg = cur.getMessage();
-      if (msg != null && msg.contains("Stream is closed")) {
-        return true;
-      }
-      // The NPE twin of the "Stream is closed!" teardown race (cause #3 at 
the call site):
-      // a NullPointerException whose own stack trace originates from
-      // ParquetColumnarRowSplitReader#readNextRowGroup, i.e. 
reader.readNextRowGroup() ran on a
-      // null `reader` that ParquetColumnarRowSplitReader#close had just 
nulled out. Scoped to
-      // that exact frame so genuine NPEs - and the legitimate 
IOException("expecting more
-      // rows...") thrown from the same method - still fail the test.
-      if (isNullPointerException(cur) && containsReadNextRowGroupFrame(cur)) {
-        return true;
-      }
-      cur = cur.getCause();
-    }
-    return false;
-  }
-
-  /**
-   * True for a real {@link NullPointerException} as well as one wrapped in 
Flink's
-   * {@code SerializedThrowable} when the failure is propagated back from the 
cluster (its
-   * {@code toString()} preserves the original {@code 
java.lang.NullPointerException} prefix).
+   * Whether {@code e} is a bare {@link TimeoutException} thrown directly by
+   * {@link org.apache.flink.table.api.TableResult#await(long, TimeUnit)} - 
i.e. the await window elapsed
+   * before the sink reached its expected row count and threw
+   * {@link CollectSinkTableFactory.SuccessException}, leaving the job still 
running (never terminated),
+   * so the caller cancels it and retries the read rather than swallowing it.
+   *
+   * <p>Only the top-level exception is inspected, never the cause chain: 
{@code await} throws its own
+   * timeout bare, whereas a genuine job failure arrives wrapped in an {@link 
ExecutionException} that
+   * may itself embed a {@link TimeoutException} (checkpoint expiry, RPC 
timeout). Walking the chain
+   * would misclassify such a real failure as a slow shard - cancel it, retry, 
and finally report a
+   * row-count mismatch with the true cause discarded.
    */
-  private static boolean isNullPointerException(Throwable t) {
-    return t instanceof NullPointerException
-        || t.toString().startsWith(NullPointerException.class.getName());
-  }
-
-  /**
-   * Whether {@code t}'s stack trace (preserved even through {@code 
SerializedThrowable})
-   * contains a {@code ParquetColumnarRowSplitReader#readNextRowGroup} frame.
-   */
-  private static boolean containsReadNextRowGroupFrame(Throwable t) {
-    for (StackTraceElement frame : t.getStackTrace()) {
-      if (frame.getClassName().endsWith("ParquetColumnarRowSplitReader")
-          && "readNextRowGroup".equals(frame.getMethodName())) {
-        return true;
-      }
-    }
-    return false;
+  private static boolean isAwaitTimeout(Throwable e) {
+    return e instanceof TimeoutException;
   }
 
   /**
@@ -4287,15 +4242,4 @@ public class ITTestHoodieDataSource {
     }
     return false;
   }
-
-  /**
-   * Short description of {@code e}'s root cause, for logging which tolerated 
terminal failure fired.
-   */
-  private static String describeTerminalCause(Throwable e) {
-    Throwable root = e;
-    while (root.getCause() != null) {
-      root = root.getCause();
-    }
-    return root.getClass().getSimpleName() + ": " + root.getMessage();
-  }
 }

Reply via email to