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 24903099c838 perf(flink): use a shared work-stealing split pool for
Source V2 bounded reads (#19520)
24903099c838 is described below
commit 24903099c838a58256567fbecbdafc7aa4c4dc8d
Author: ericyuan915 <[email protected]>
AuthorDate: Fri Aug 7 02:09:58 2026 -0700
perf(flink): use a shared work-stealing split pool for Source V2 bounded
reads (#19520)
* perf(flink): use a shared work-stealing split pool for Source V2 bounded
reads
Bounded reads inherit the streaming split provider, which pins every split
to one subtask at discovery and never rebalances, so a subtask that drew a
heavier share keeps working while its peers sit idle. On a bounded COW
backfill (~16.4K splits, parallelism 32) the readers finished 78 minutes
apart and the job took 3.80 h instead of 2.77 h.
The affinity pays for itself only in streaming, where a file group
accumulates log files across commits and successive splits of one file id
must stay on one reader. A bounded read has exactly one split per file
group, no cross-commit continuation and no ordering relationship between
splits, so any reader can read any split.
Add GlobalHoodieSplitProvider, a single shared pool ordered by the existing
HoodieSourceSplitComparator whose getNext ignores the subtask id, and select
it on the non-streaming branch of HoodieSource.createEnumerator. Streaming
keeps DefaultHoodieSplitProvider and the existing assigners. The provider is
now chosen on the streaming/bounded branch rather than before it, so restore
replays checkpointed splits into whichever provider was chosen. No
enumerator
change is needed: with one pool, getNext returning empty already means
globally drained.
Closes #19516
* test(flink): cover the metadata-driven bounded incremental branch, not
just full scan
READ_START_COMMIT=earliest leaves the analyzer's startInstant empty, so
IncrementalInputSplits.inputSplits() took its fullTableScan branch and the
COW_INCREMENTAL parameter never reached the metadata-driven branch that the
one-split-per-file-group argument cites.
Split the parameter in two: COW_INCREMENTAL now starts from a real completed
commit so fullTableScan is false, and COW_INCREMENTAL_FROM_EARLIEST keeps
the
full-scan fallback covered.
To make the branch observable, the modes that start from a real commit
write a
last commit that only touches par5 and par6. The metadata-driven branch
derives
its read partitions from that commit alone, while a full table scan lists
par1
through par6, so asserting the split partitions pins which branch produced
them.
---
.../java/org/apache/hudi/source/HoodieSource.java | 23 +-
.../source/split/GlobalHoodieSplitProvider.java | 134 ++++++
.../TestHoodieSourceEnumeratorRouting.java | 472 +++++++++++++++++++++
.../TestHoodieStaticSplitEnumerator.java | 85 ++++
.../split/TestGlobalHoodieSplitProvider.java | 298 +++++++++++++
5 files changed, 1006 insertions(+), 6 deletions(-)
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/HoodieSource.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/HoodieSource.java
index 7126dfd290ef..a4f8084cfba0 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/HoodieSource.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/HoodieSource.java
@@ -34,6 +34,7 @@ import org.apache.hudi.source.reader.HoodieSourceReader;
import org.apache.hudi.source.reader.function.SplitReaderFunction;
import org.apache.hudi.source.split.DefaultHoodieSplitDiscover;
import org.apache.hudi.source.split.DefaultHoodieSplitProvider;
+import org.apache.hudi.source.split.GlobalHoodieSplitProvider;
import org.apache.hudi.source.split.HoodieContinuousSplitDiscover;
import org.apache.hudi.source.split.HoodieSourceSplit;
import org.apache.hudi.source.split.HoodieSourceSplitSerializer;
@@ -135,23 +136,33 @@ public class HoodieSource<T> extends FileIndexReader
implements Source<T, Hoodie
private SplitEnumerator<HoodieSourceSplit, HoodieSplitEnumeratorState>
createEnumerator(
SplitEnumeratorContext<HoodieSourceSplit> enumContext,
@Nullable HoodieSplitEnumeratorState enumeratorState) {
+ final boolean streaming = scanContext.isStreaming();
+
+ // Streaming keeps per-subtask assignment (DefaultHoodieSplitProvider) so
that a file id's
+ // successive incremental splits stay affine to one reader. Bounded reads
instead use a shared
+ // work-stealing pool: the full split set is known up front and each split
is independent and
+ // order-free (one split per file group, no cross-commit continuation), so
any reader can read
+ // any split. Serving from one pool keeps every reader busy until it is
drained, which removes
+ // the straggler tail that count-balanced, non-stealing assignment
produces.
HoodieSplitProvider splitProvider;
- HoodieSplitAssigner splitAssigner =
HoodieSplitAssigners.createHoodieSplitAssigner(
- scanContext.getConf(), enumContext.currentParallelism());
-
- if (enumeratorState == null) {
+ if (streaming) {
+ HoodieSplitAssigner splitAssigner =
HoodieSplitAssigners.createHoodieSplitAssigner(
+ scanContext.getConf(), enumContext.currentParallelism());
splitProvider = new DefaultHoodieSplitProvider(splitAssigner);
} else {
+ splitProvider = new GlobalHoodieSplitProvider();
+ }
+
+ if (enumeratorState != null) {
log.info(
"Hoodie source restored {} splits from state for table {}",
enumeratorState.getPendingSplitStates().size(), tableName);
List<HoodieSourceSplit> pendingSplits =
enumeratorState.getPendingSplitStates().stream().map(HoodieSourceSplitState::getSplit).collect(Collectors.toList());
- splitProvider = new DefaultHoodieSplitProvider(splitAssigner);
splitProvider.onDiscoveredSplits(pendingSplits);
}
- if (scanContext.isStreaming()) {
+ if (streaming) {
HoodieContinuousSplitDiscover discover = new DefaultHoodieSplitDiscover(
scanContext);
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/GlobalHoodieSplitProvider.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/GlobalHoodieSplitProvider.java
new file mode 100644
index 000000000000..a3a582ece4ca
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/split/GlobalHoodieSplitProvider.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.source.split;
+
+import org.apache.hudi.common.util.Option;
+
+import javax.annotation.Nullable;
+
+import java.util.Collection;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.PriorityBlockingQueue;
+import java.util.stream.Collectors;
+
+/**
+ * Split provider that serves splits from a single shared pool, ignoring the
requesting subtask id
+ * (work stealing): whichever reader asks next gets the next pending split, so
all readers stay busy
+ * until the pool is fully drained.
+ *
+ * <p>Intended for BOUNDED (batch) reads driven by
+ * {@link org.apache.hudi.source.enumerator.HoodieStaticSplitEnumerator}.
There the complete split
+ * set is known up front and every split is independent: exactly one split per
file group, no
+ * cross-commit continuation and no ordering relationship between splits. That
holds for all bounded
+ * query modes, including the CDC one, where a file group's changes are sorted
inside a single split
+ * rather than spread over several. Any reader can therefore safely read any
split.
+ *
+ * <p>Contrast with {@link DefaultHoodieSplitProvider}, which pins each split
to one subtask (by
+ * hashing the file id, or round-robin on the split number) and never
rebalances: a subtask that
+ * drew a heavier share runs long while its peers sit idle. Because that
assignment balances split
+ * <em>count</em> rather than bytes or records, and cannot steal, even small
per-subtask skew is
+ * unrecoverable and shows up as a declining tail at the end of a bounded read.
+ *
+ * <p>NOT used for streaming reads: the continuous enumerator keeps
per-subtask assignment (via
+ * {@link DefaultHoodieSplitProvider}) so that a file id's successive
incremental splits stay affine
+ * to one reader, and so bucket id to subtask alignment is preserved for
bucket index tables.
+ *
+ * <p>Splits are served oldest-commit-first via {@link
HoodieSourceSplitComparator}, the same
+ * ordering the per-subtask queues use. Thread safe: a {@link
PriorityBlockingQueue} backs the pool,
+ * so {@link #pendingSplitCount()} can be read from the I/O threads for the
unassigned splits gauge
+ * while the coordinator thread assigns.
+ */
+public class GlobalHoodieSplitProvider implements HoodieSplitProvider {
+ public static final int INITIAL_POOL_CAPACITY = 20;
+
+ // Shared pool of unassigned splits, ordered by commit time (oldest first).
+ private final Queue<HoodieSourceSplit> pendingSplits;
+ private CompletableFuture<Void> availableFuture;
+
+ public GlobalHoodieSplitProvider() {
+ this.pendingSplits =
+ new PriorityBlockingQueue<>(INITIAL_POOL_CAPACITY, new
HoodieSourceSplitComparator());
+ }
+
+ @Override
+ public Option<HoodieSourceSplit> getNext(int taskId, @Nullable String
hostname) {
+ // Work stealing: the subtask id and hostname are intentionally ignored,
so any requesting
+ // reader gets the next split from the shared pool. Empty means the pool
is globally drained;
+ // for the static enumerator (shouldWaitForMoreSplits() == false) that
correctly triggers
+ // signalNoMoreSplits for the requesting reader.
+ HoodieSourceSplit next = pendingSplits.poll();
+ return next == null ? Option.empty() : Option.of(next);
+ }
+
+ @Override
+ public void onDiscoveredSplits(Collection<HoodieSourceSplit> splits) {
+ addSplits(splits);
+ }
+
+ @Override
+ public void onUnassignedSplits(Collection<HoodieSourceSplit> splits) {
+ // Splits handed back by a failed reader (addSplitsBack) return to the
shared pool and are
+ // picked up by whichever reader asks next, which need not be the failed
subtask. Readers that
+ // already received no-more-splits are done, but any reader still asking
can claim them.
+ addSplits(splits);
+ }
+
+ private void addSplits(Collection<HoodieSourceSplit> splits) {
+ if (splits.isEmpty()) {
+ return;
+ }
+ pendingSplits.addAll(splits);
+ completeAvailableFuturesIfNeeded();
+ }
+
+ @Override
+ public Collection<HoodieSourceSplitState> state() {
+ return pendingSplits.stream()
+ .map(split -> new HoodieSourceSplitState(split,
HoodieSourceSplitStatus.UNASSIGNED))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public synchronized CompletableFuture<Void> isAvailable() {
+ if (availableFuture == null) {
+ availableFuture = new CompletableFuture<>();
+ }
+ return availableFuture;
+ }
+
+ @Override
+ public int pendingSplitCount() {
+ return pendingSplits.size();
+ }
+
+ @Override
+ public long pendingRecords() {
+ throw new UnsupportedOperationException(
+ "Pending records is not supported in GlobalHoodieSplitProvider.");
+ }
+
+ private synchronized void completeAvailableFuturesIfNeeded() {
+ if (availableFuture != null && !pendingSplits.isEmpty()) {
+ availableFuture.complete(null);
+ // Cleared only once completed, so a waiter never loses the future it is
blocked on.
+ availableFuture = null;
+ }
+ }
+}
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieSourceEnumeratorRouting.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieSourceEnumeratorRouting.java
new file mode 100644
index 000000000000..3b129099a2a3
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieSourceEnumeratorRouting.java
@@ -0,0 +1,472 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.source.enumerator;
+
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.configuration.HadoopConfigurations;
+import org.apache.hudi.source.HoodieScanContext;
+import org.apache.hudi.source.HoodieSource;
+import org.apache.hudi.source.reader.HoodieRecordEmitter;
+import org.apache.hudi.source.reader.function.HoodieSplitReaderFunction;
+import org.apache.hudi.source.split.DefaultHoodieSplitProvider;
+import org.apache.hudi.source.split.GlobalHoodieSplitProvider;
+import org.apache.hudi.source.split.HoodieCdcSourceSplit;
+import org.apache.hudi.source.split.HoodieSourceSplit;
+import org.apache.hudi.source.split.HoodieSourceSplitComparator;
+import org.apache.hudi.source.split.HoodieSourceSplitState;
+import org.apache.hudi.source.split.HoodieSourceSplitStatus;
+import org.apache.hudi.source.split.HoodieSplitProvider;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
+import org.apache.hudi.table.format.InternalSchemaManager;
+import org.apache.hudi.util.HoodieSchemaConverter;
+import org.apache.hudi.util.StreamerUtil;
+import org.apache.hudi.utils.TestConfigurations;
+import org.apache.hudi.utils.TestData;
+
+import org.apache.flink.api.connector.source.ReaderInfo;
+import org.apache.flink.api.connector.source.SourceEvent;
+import org.apache.flink.api.connector.source.SplitEnumerator;
+import org.apache.flink.api.connector.source.SplitEnumeratorContext;
+import org.apache.flink.api.connector.source.SplitsAssignment;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.metrics.groups.SplitEnumeratorMetricGroup;
+import org.apache.flink.metrics.groups.UnregisteredMetricsGroup;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.logical.RowType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.function.BiConsumer;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests which split provider {@link HoodieSource} wires into the enumerator.
+ *
+ * <p>Bounded reads use the shared work-stealing pool ({@link
GlobalHoodieSplitProvider}); streaming
+ * keeps per-subtask assignment ({@link DefaultHoodieSplitProvider}) so that a
file id's successive
+ * incremental splits stay affine to one reader. Because {@code
HoodieSource.createEnumerator}
+ * handles fresh creation and restore in the same method, both paths are
covered for every mode.
+ *
+ * <p>These tests also assert the property that makes work stealing safe for
bounded reads: every
+ * bounded query mode emits exactly one split per file group, so there is no
cross-commit
+ * continuation and no ordering relationship between splits that a shared pool
could break.
+ *
+ * <p>Lives in the enumerator package so it can read the package-private
+ * {@link AbstractHoodieSplitEnumerator#splitProvider}.
+ */
+public class TestHoodieSourceEnumeratorRouting {
+
+ @TempDir
+ File tempDir;
+
+ private Configuration conf;
+ private StoragePath tablePath;
+ private HoodieTableMetaClient metaClient;
+
+ /**
+ * The bounded query modes {@code HoodieSource.createBatchHoodieSplits()}
covers. All of them are
+ * routed to the shared pool, so all of them are exercised here.
+ *
+ * <p>Incremental appears three times on purpose. {@code
IncrementalInputSplits.inputSplits()}
+ * branches on {@code fullTableScan}, which is true when the query consumes
from the earliest
+ * instant, and the two sides build their file slice set differently: the
full scan lists the
+ * table directly, while the other side derives partitions and files from
the commit metadata of
+ * the instants in range (and, when CDC is on, leaves through the CDC
extractor entirely). Only
+ * covering {@code earliest} would leave the metadata-driven branch untested.
+ */
+ private enum BoundedMode {
+ COW_SNAPSHOT(HoodieTableType.COPY_ON_WRITE,
FlinkOptions.QUERY_TYPE_SNAPSHOT, false, IncrementalStart.NOT_INCREMENTAL),
+ MOR_SNAPSHOT(HoodieTableType.MERGE_ON_READ,
FlinkOptions.QUERY_TYPE_SNAPSHOT, false, IncrementalStart.NOT_INCREMENTAL),
+ MOR_READ_OPTIMIZED(HoodieTableType.MERGE_ON_READ,
FlinkOptions.QUERY_TYPE_READ_OPTIMIZED, false,
IncrementalStart.NOT_INCREMENTAL),
+ COW_INCREMENTAL(HoodieTableType.COPY_ON_WRITE,
FlinkOptions.QUERY_TYPE_INCREMENTAL, false, IncrementalStart.LAST_COMMIT),
+ COW_INCREMENTAL_FROM_EARLIEST(HoodieTableType.COPY_ON_WRITE,
FlinkOptions.QUERY_TYPE_INCREMENTAL, false, IncrementalStart.EARLIEST),
+ COW_INCREMENTAL_CDC(HoodieTableType.COPY_ON_WRITE,
FlinkOptions.QUERY_TYPE_INCREMENTAL, true, IncrementalStart.LAST_COMMIT);
+
+ private final HoodieTableType tableType;
+ private final String queryType;
+ private final boolean cdcEnabled;
+ private final IncrementalStart incrementalStart;
+
+ BoundedMode(HoodieTableType tableType, String queryType, boolean
cdcEnabled, IncrementalStart incrementalStart) {
+ this.tableType = tableType;
+ this.queryType = queryType;
+ this.cdcEnabled = cdcEnabled;
+ this.incrementalStart = incrementalStart;
+ }
+
+ boolean isIncremental() {
+ return incrementalStart != IncrementalStart.NOT_INCREMENTAL;
+ }
+ }
+
+ /**
+ * Where an incremental mode starts reading, which is what decides the
{@code fullTableScan}
+ * branch: {@code earliest} leaves {@code startInstant} empty and takes the
full scan,
+ * a real completion time takes the metadata-driven branch.
+ */
+ private enum IncrementalStart {
+ NOT_INCREMENTAL,
+ EARLIEST,
+ LAST_COMMIT
+ }
+
+ @BeforeEach
+ public void setUp() {
+ conf = TestConfigurations.getDefaultConf(tempDir.getAbsolutePath());
+ tablePath = new StoragePath(tempDir.getAbsolutePath());
+ }
+
+ @ParameterizedTest
+ @EnumSource(BoundedMode.class)
+ public void testBoundedReadUsesSharedSplitPool(BoundedMode mode) throws
Exception {
+ HoodieSource<RowData> source = prepareBoundedSource(mode);
+ MockSplitEnumeratorContext context = new MockSplitEnumeratorContext();
+
+ SplitEnumerator<HoodieSourceSplit, HoodieSplitEnumeratorState> enumerator =
+ source.createEnumerator(context);
+
+ assertInstanceOf(HoodieStaticSplitEnumerator.class, enumerator,
+ "Bounded read should use the static enumerator for mode " + mode);
+ assertInstanceOf(GlobalHoodieSplitProvider.class, providerOf(enumerator),
+ "Bounded read should use the shared work-stealing pool for mode " +
mode);
+ List<HoodieSourceSplit> splits = pendingSplits(enumerator);
+ assertOneSplitPerFileGroup(splits, mode);
+ if (mode.incrementalStart == IncrementalStart.LAST_COMMIT) {
+ // Guards the parameterization: if the start commit stopped making
fullTableScan false, these
+ // splits would come from a full table listing and cover par1 through
par6, and this mode
+ // would silently stop exercising the metadata-driven branch.
+ assertEquals(new HashSet<>(Arrays.asList("par5", "par6")),
+
splits.stream().map(HoodieSourceSplit::getPartitionPath).collect(Collectors.toSet()),
+ "Mode " + mode + " should read only the partitions written by the
start commit, "
+ + "which is what distinguishes the incremental branch from a
full table scan");
+ }
+ if (mode.cdcEnabled) {
+ // Likewise, a full table scan would bypass the CDC extractor and yield
plain splits.
+ splits.forEach(split -> assertInstanceOf(HoodieCdcSourceSplit.class,
split,
+ "CDC mode should produce CDC splits"));
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(BoundedMode.class)
+ public void testBoundedRestoreKeepsSharedSplitPool(BoundedMode mode) throws
Exception {
+ HoodieSource<RowData> source = prepareBoundedSource(mode);
+ // Snapshot the real splits of this mode, then restore from a subset of
them.
+ List<HoodieSourceSplit> discovered =
+ pendingSplits(source.createEnumerator(new
MockSplitEnumeratorContext()));
+ assertFalse(discovered.isEmpty(), "Expected at least one split for mode "
+ mode);
+ List<HoodieSourceSplit> checkpointed = discovered.subList(0, 1);
+
+ MockSplitEnumeratorContext context = new MockSplitEnumeratorContext();
+ SplitEnumerator<HoodieSourceSplit, HoodieSplitEnumeratorState> restored =
+ source.restoreEnumerator(context, enumeratorStateOf(checkpointed));
+
+ assertInstanceOf(HoodieStaticSplitEnumerator.class, restored,
+ "Restored bounded read should still use the static enumerator for mode
" + mode);
+ assertInstanceOf(GlobalHoodieSplitProvider.class, providerOf(restored),
+ "Restored bounded read should still use the shared work-stealing pool
for mode " + mode);
+ assertEquals(checkpointed.size(), providerOf(restored).pendingSplitCount(),
+ "Restore should replay exactly the checkpointed splits into the shared
pool "
+ + "and must not re-run split discovery for mode " + mode);
+ }
+
+ /**
+ * A restored pending split is not owned by any subtask: whichever reader
asks for work claims it.
+ * Parameterized over the requesting subtask so the assertion is
deterministic - under per-subtask
+ * pinning only the one subtask the file id hashes to could ever receive it.
+ */
+ @ParameterizedTest
+ @ValueSource(ints = {0, 1, 2, 3})
+ public void testRestoredSplitIsClaimedByWhicheverSubtaskAsks(int
requestingSubtask) throws Exception {
+ HoodieSource<RowData> source =
prepareBoundedSource(BoundedMode.COW_SNAPSHOT);
+ List<HoodieSourceSplit> discovered =
+ pendingSplits(source.createEnumerator(new
MockSplitEnumeratorContext()));
+ List<HoodieSourceSplit> checkpointed = discovered.subList(0, 1);
+
+ MockSplitEnumeratorContext context = new MockSplitEnumeratorContext();
+ SplitEnumerator<HoodieSourceSplit, HoodieSplitEnumeratorState> restored =
+ source.restoreEnumerator(context, enumeratorStateOf(checkpointed));
+ restored.start();
+ for (int subtask = 0; subtask < 4; subtask++) {
+ context.registerReader(new ReaderInfo(subtask, "localhost"));
+ }
+
+ restored.handleSplitRequest(requestingSubtask, "localhost");
+
+ assertEquals(checkpointed,
context.getAssignedSplits().get(requestingSubtask),
+ "The restored split should go to whichever subtask asked for work");
+ assertFalse(context.getNoMoreSplitsSignaled().contains(requestingSubtask),
+ "The requesting subtask received a split, so it should not be told
no-more-splits");
+ }
+
+ @Test
+ public void testStreamingReadKeepsPerSubtaskProvider() throws Exception {
+ HoodieSource<RowData> source = prepareStreamingSource();
+
+ SplitEnumerator<HoodieSourceSplit, HoodieSplitEnumeratorState> enumerator =
+ source.createEnumerator(new MockSplitEnumeratorContext());
+
+ assertInstanceOf(HoodieContinuousSplitEnumerator.class, enumerator,
+ "Streaming read should use the continuous enumerator");
+ assertInstanceOf(DefaultHoodieSplitProvider.class, providerOf(enumerator),
+ "Streaming read must keep per-subtask assignment for file id
affinity");
+ }
+
+ @Test
+ public void testStreamingRestoreKeepsPerSubtaskProvider() throws Exception {
+ HoodieSource<RowData> source = prepareStreamingSource();
+ List<HoodieSourceSplit> checkpointed = Collections.singletonList(
+ new HoodieSourceSplit(0, null, Option.empty(), tablePath.toString(),
"par1",
+ FlinkOptions.REALTIME_PAYLOAD_COMBINE, "20260126034717000",
"file-0", Option.empty()));
+
+ SplitEnumerator<HoodieSourceSplit, HoodieSplitEnumeratorState> restored =
+ source.restoreEnumerator(new MockSplitEnumeratorContext(),
enumeratorStateOf(checkpointed));
+
+ assertInstanceOf(HoodieContinuousSplitEnumerator.class, restored,
+ "Restored streaming read should use the continuous enumerator");
+ assertInstanceOf(DefaultHoodieSplitProvider.class, providerOf(restored),
+ "Restored streaming read must keep per-subtask assignment");
+ assertEquals(1, providerOf(restored).pendingSplitCount(),
+ "Restored split should be replayed into the per-subtask provider");
+ }
+
+ // Helper methods
+
+ private static HoodieSplitProvider providerOf(
+ SplitEnumerator<HoodieSourceSplit, HoodieSplitEnumeratorState>
enumerator) {
+ return ((AbstractHoodieSplitEnumerator) enumerator).splitProvider;
+ }
+
+ private static List<HoodieSourceSplit> pendingSplits(
+ SplitEnumerator<HoodieSourceSplit, HoodieSplitEnumeratorState>
enumerator) {
+ return providerOf(enumerator).state().stream()
+ .map(HoodieSourceSplitState::getSplit)
+ .collect(Collectors.toList());
+ }
+
+ private static HoodieSplitEnumeratorState
enumeratorStateOf(List<HoodieSourceSplit> splits) {
+ List<HoodieSourceSplitState> states = splits.stream()
+ .map(split -> new HoodieSourceSplitState(split,
HoodieSourceSplitStatus.UNASSIGNED))
+ .collect(Collectors.toList());
+ return new HoodieSplitEnumeratorState(states, Option.empty(),
Option.empty());
+ }
+
+ /**
+ * Asserts the invariant that makes a shared pool safe for a bounded read:
one split per file
+ * group, hence no cross-commit continuation and no ordering relationship
between splits.
+ */
+ private static void assertOneSplitPerFileGroup(List<HoodieSourceSplit>
splits, BoundedMode mode) {
+ assertFalse(splits.isEmpty(), "Expected at least one split for mode " +
mode);
+ Set<String> fileIds = splits.stream()
+ .map(HoodieSourceSplit::getFileId)
+ .collect(Collectors.toSet());
+ assertEquals(splits.size(), fileIds.size(),
+ "Mode " + mode + " must emit exactly one split per file group,
otherwise splits of the "
+ + "same file group could be read concurrently by different
readers");
+ }
+
+ private HoodieSource<RowData> prepareBoundedSource(BoundedMode mode) throws
Exception {
+ conf.set(FlinkOptions.TABLE_TYPE, mode.tableType.name());
+ conf.set(FlinkOptions.READ_AS_STREAMING, false);
+ if (mode.tableType == HoodieTableType.MERGE_ON_READ) {
+ // Compact the first commit so the MOR file groups own a base file (a
read-optimized read
+ // sees nothing otherwise); the second commit below is then written as
logs only, so a
+ // snapshot read exercises real base + log file slices.
+ conf.set(FlinkOptions.COMPACTION_ASYNC_ENABLED, true);
+ conf.set(FlinkOptions.COMPACTION_DELTA_COMMITS, 1);
+ }
+ if (mode.cdcEnabled) {
+ conf.set(FlinkOptions.CDC_ENABLED, true);
+ conf.set(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true); // for batch update
+ }
+
+ TestData.writeData(TestData.DATA_SET_INSERT, conf);
+ if (mode.tableType == HoodieTableType.MERGE_ON_READ) {
+ conf.set(FlinkOptions.COMPACTION_ASYNC_ENABLED, false);
+ }
+ TestData.writeData(TestData.DATA_SET_UPDATE_INSERT, conf);
+ if (mode.incrementalStart == IncrementalStart.LAST_COMMIT) {
+ // A last commit that only touches par5 and par6, so the partitions of
the resulting splits
+ // show which branch produced them: the metadata-driven branch derives
its read partitions
+ // from this commit alone, a full table scan would list par1 through
par6.
+ TestData.writeData(TestData.DATA_SET_INSERT_SEPARATE_PARTITION, conf);
+ }
+ metaClient = StreamerUtil.createMetaClient(conf);
+
+ conf.set(FlinkOptions.QUERY_TYPE, mode.queryType);
+ if (mode.incrementalStart == IncrementalStart.EARLIEST) {
+ conf.set(FlinkOptions.READ_START_COMMIT,
FlinkOptions.START_COMMIT_EARLIEST);
+ } else if (mode.incrementalStart == IncrementalStart.LAST_COMMIT) {
+ conf.set(FlinkOptions.READ_START_COMMIT, lastCompletionTime());
+ }
+ return createSource();
+ }
+
+ private HoodieSource<RowData> prepareStreamingSource() throws Exception {
+ conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name());
+ conf.set(FlinkOptions.READ_AS_STREAMING, true);
+
+ TestData.writeData(TestData.DATA_SET_INSERT, conf);
+ metaClient = StreamerUtil.createMetaClient(conf);
+
+ return createSource();
+ }
+
+ private String lastCompletionTime() {
+ List<String> commits =
metaClient.getCommitsTimeline().filterCompletedInstants()
+ .getInstantsAsStream()
+ .map(HoodieInstant::getCompletionTime)
+ .collect(Collectors.toList());
+ assertTrue(commits.size() > 1, "Expected more than one commit to read
changes from");
+ return commits.get(commits.size() - 1);
+ }
+
+ private HoodieSource<RowData> createSource() {
+ RowType rowType = TestConfigurations.ROW_TYPE;
+ HoodieScanContext scanContext = HoodieScanContext.builder()
+ .conf(conf)
+ .path(tablePath)
+ .rowType(rowType)
+ .startInstant(conf.get(FlinkOptions.READ_START_COMMIT))
+ .endInstant(conf.get(FlinkOptions.READ_END_COMMIT))
+
.maxCompactionMemoryInBytes(conf.get(FlinkOptions.COMPACTION_MAX_MEMORY))
+ .maxPendingSplits(1000)
+ .skipCompaction(conf.get(FlinkOptions.READ_STREAMING_SKIP_COMPACT))
+ .skipClustering(conf.get(FlinkOptions.READ_STREAMING_SKIP_CLUSTERING))
+
.skipInsertOverwrite(conf.get(FlinkOptions.READ_STREAMING_SKIP_INSERT_OVERWRITE))
+ .cdcEnabled(conf.get(FlinkOptions.CDC_ENABLED))
+ .isStreaming(conf.get(FlinkOptions.READ_AS_STREAMING))
+ .build();
+ HoodieSchema schema = HoodieSchemaConverter.convertToSchema(rowType);
+ HadoopStorageConfiguration hadoopConf =
+ new
HadoopStorageConfiguration(HadoopConfigurations.getHadoopConf(conf));
+ InternalSchemaManager internalSchemaManager =
InternalSchemaManager.get(hadoopConf, metaClient);
+
+ return new HoodieSource<>(
+ scanContext,
+ () -> new HoodieSplitReaderFunction(
+ conf,
+ schema,
+ schema,
+ internalSchemaManager,
+ conf.get(FlinkOptions.MERGE_TYPE),
+ Collections.emptyList(),
+ false),
+ new HoodieSourceSplitComparator(),
+ metaClient,
+ new HoodieRecordEmitter<>());
+ }
+
+ /**
+ * Minimal mock of {@link SplitEnumeratorContext} for the wiring assertions
above.
+ */
+ private static class MockSplitEnumeratorContext implements
SplitEnumeratorContext<HoodieSourceSplit> {
+ private final Map<Integer, ReaderInfo> registeredReaders = new HashMap<>();
+ private final Map<Integer, List<HoodieSourceSplit>> assignedSplits = new
HashMap<>();
+ private final List<Integer> noMoreSplitsSignaled = new ArrayList<>();
+
+ void registerReader(ReaderInfo readerInfo) {
+ registeredReaders.put(readerInfo.getSubtaskId(), readerInfo);
+ }
+
+ Map<Integer, List<HoodieSourceSplit>> getAssignedSplits() {
+ return assignedSplits;
+ }
+
+ List<Integer> getNoMoreSplitsSignaled() {
+ return noMoreSplitsSignaled;
+ }
+
+ @Override
+ public SplitEnumeratorMetricGroup metricGroup() {
+ return UnregisteredMetricsGroup.createSplitEnumeratorMetricGroup();
+ }
+
+ @Override
+ public void sendEventToSourceReader(int subtaskId, SourceEvent event) {
+ // No-op for testing
+ }
+
+ @Override
+ public int currentParallelism() {
+ return Math.max(registeredReaders.size(), 1);
+ }
+
+ @Override
+ public Map<Integer, ReaderInfo> registeredReaders() {
+ return new HashMap<>(registeredReaders);
+ }
+
+ @Override
+ public void assignSplits(SplitsAssignment<HoodieSourceSplit>
newSplitAssignments) {
+ newSplitAssignments.assignment().forEach((subtask, splits) ->
+ assignedSplits.computeIfAbsent(subtask, k -> new
ArrayList<>()).addAll(splits));
+ }
+
+ @Override
+ public void assignSplit(HoodieSourceSplit split, int subtask) {
+ assignedSplits.computeIfAbsent(subtask, k -> new
ArrayList<>()).add(split);
+ }
+
+ @Override
+ public void signalNoMoreSplits(int subtask) {
+ noMoreSplitsSignaled.add(subtask);
+ }
+
+ @Override
+ public <T> void callAsync(Callable<T> callable, BiConsumer<T, Throwable>
handler) {
+ // No-op: split discovery is not exercised by these wiring tests.
+ }
+
+ @Override
+ public <T> void callAsync(Callable<T> callable, BiConsumer<T, Throwable>
handler, long initialDelay, long period) {
+ // No-op: split discovery is not exercised by these wiring tests.
+ }
+
+ @Override
+ public void runInCoordinatorThread(Runnable runnable) {
+ runnable.run();
+ }
+ }
+}
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieStaticSplitEnumerator.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieStaticSplitEnumerator.java
index 70a564bd6fb4..84822c21cdd6 100644
---
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieStaticSplitEnumerator.java
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/enumerator/TestHoodieStaticSplitEnumerator.java
@@ -20,6 +20,7 @@ package org.apache.hudi.source.enumerator;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.source.split.DefaultHoodieSplitProvider;
+import org.apache.hudi.source.split.GlobalHoodieSplitProvider;
import org.apache.hudi.source.split.HoodieSourceSplit;
import org.apache.hudi.source.split.SplitRequestEvent;
import org.apache.hudi.source.split.assign.HoodieSplitNumberAssigner;
@@ -252,6 +253,90 @@ public class TestHoodieStaticSplitEnumerator {
"Should throw IllegalArgumentException for unknown source event type");
}
+ @Test
+ public void testGlobalProviderWorkStealingAcrossSubtasks() {
+ // With the shared work-stealing pool a single reader can drain every
split: the enumerator no
+ // longer pins splits to a subtask. DefaultHoodieSplitProvider with a
number/hash assigner would
+ // hand most of these to other subtasks and starve reader 0.
+ GlobalHoodieSplitProvider globalProvider = new GlobalHoodieSplitProvider();
+ HoodieStaticSplitEnumerator globalEnumerator =
+ new HoodieStaticSplitEnumerator("test-table", context, globalProvider);
+ globalProvider.onDiscoveredSplits(Arrays.asList(split1, split2, split3));
+ globalEnumerator.start();
+
+ context.registerReader(new ReaderInfo(0, "localhost"));
+ context.registerReader(new ReaderInfo(1, "localhost"));
+
+ // Reader 0 keeps finishing and asking for more; it takes all three splits
by itself.
+ globalEnumerator.handleSplitRequest(0, "localhost");
+ globalEnumerator.handleSplitRequest(0, "localhost");
+ globalEnumerator.handleSplitRequest(0, "localhost");
+
+ assertEquals(3, context.getAssignedSplits().get(0).size(),
+ "A single reader should be able to steal the entire pool");
+ assertFalse(context.getNoMoreSplitsSignaled().contains(0),
+ "No-more-splits must not fire while the pool still had splits");
+ }
+
+ @Test
+ public void testGlobalProviderSignalsNoMoreSplitsOnlyWhenPoolEmpty() {
+ GlobalHoodieSplitProvider globalProvider = new GlobalHoodieSplitProvider();
+ HoodieStaticSplitEnumerator globalEnumerator =
+ new HoodieStaticSplitEnumerator("test-table", context, globalProvider);
+ globalProvider.onDiscoveredSplits(Collections.singletonList(split1)); //
one split, two readers
+ globalEnumerator.start();
+
+ context.registerReader(new ReaderInfo(0, "localhost"));
+ context.registerReader(new ReaderInfo(1, "localhost"));
+
+ globalEnumerator.handleSplitRequest(0, "localhost"); // reader 0 takes the
only split
+ globalEnumerator.handleSplitRequest(1, "localhost"); // reader 1 finds the
shared pool empty
+
+ assertTrue(context.getAssignedSplits().containsKey(0), "Reader 0 should
receive the split");
+ assertFalse(context.getNoMoreSplitsSignaled().contains(0),
+ "Reader 0 got a split, so it should not be told no-more-splits");
+ assertTrue(context.getNoMoreSplitsSignaled().contains(1),
+ "Reader 1 should be told no-more-splits once the shared pool is
drained");
+ }
+
+ @Test
+ public void
testGlobalProviderAddSplitsBackAfterOtherReadersGotNoMoreSplits() {
+ // Failure recovery once the pool has already been drained and some
readers have finished:
+ // the split a failed reader hands back must land in the shared pool and
stay claimable by a
+ // subtask that is neither the failed one nor an already-finished one.
Under per-subtask
+ // pinning it would instead be re-pinned to hash(fileId), possibly a
reader that is already
+ // done, and never be read.
+ GlobalHoodieSplitProvider globalProvider = new GlobalHoodieSplitProvider();
+ HoodieStaticSplitEnumerator globalEnumerator =
+ new HoodieStaticSplitEnumerator("test-table", context, globalProvider);
+ globalProvider.onDiscoveredSplits(Collections.singletonList(split1));
+ globalEnumerator.start();
+
+ context.registerReader(new ReaderInfo(0, "localhost"));
+ context.registerReader(new ReaderInfo(1, "localhost"));
+ context.registerReader(new ReaderInfo(2, "localhost"));
+
+ globalEnumerator.handleSplitRequest(0, "localhost"); // reader 0 takes the
only split
+ globalEnumerator.handleSplitRequest(1, "localhost"); // pool is drained,
reader 1 finishes
+ assertTrue(context.getNoMoreSplitsSignaled().contains(1),
+ "Reader 1 should already have been told no-more-splits");
+
+ // Reader 0 fails mid-split and its split is returned.
+ context.unregisterReader(0);
+ globalEnumerator.addSplitsBack(Collections.singletonList(split1), 0);
+ assertEquals(1, globalProvider.pendingSplitCount(),
+ "Returned split should be back in the shared pool");
+
+ // Reader 2, which has neither failed nor finished, claims it.
+ globalEnumerator.handleSplitRequest(2, "localhost");
+
+ assertEquals(Collections.singletonList(split1),
context.getAssignedSplits().get(2),
+ "A different, still-running subtask should claim the returned split");
+ assertFalse(context.getNoMoreSplitsSignaled().contains(2),
+ "Reader 2 got the returned split, so it should not be told
no-more-splits");
+ assertEquals(0, globalProvider.pendingSplitCount(), "Pool should be
drained again");
+ }
+
private HoodieSourceSplit createTestSplit(int splitNum, String fileId) {
return new HoodieSourceSplit(
splitNum,
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestGlobalHoodieSplitProvider.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestGlobalHoodieSplitProvider.java
new file mode 100644
index 000000000000..807c306c504f
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/split/TestGlobalHoodieSplitProvider.java
@@ -0,0 +1,298 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.source.split;
+
+import org.apache.hudi.common.util.Option;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link GlobalHoodieSplitProvider}.
+ *
+ * <p>The distinguishing behavior from {@link DefaultHoodieSplitProvider} is
that splits are NOT
+ * pinned to a subtask: any requesting subtask gets the next split from a
single shared pool (work
+ * stealing), and the pool drains fully regardless of which subtasks do the
asking.
+ */
+public class TestGlobalHoodieSplitProvider {
+ private GlobalHoodieSplitProvider provider;
+ private HoodieSourceSplit split1;
+ private HoodieSourceSplit split2;
+ private HoodieSourceSplit split3;
+
+ @BeforeEach
+ public void setUp() {
+ provider = new GlobalHoodieSplitProvider();
+ split1 = createTestSplit(1, "file1");
+ split2 = createTestSplit(2, "file2");
+ split3 = createTestSplit(3, "file3");
+ }
+
+ @Test
+ public void testGetNextFromEmptyProvider() {
+ assertFalse(provider.getNext(0, null).isPresent(),
+ "Should return empty option when no splits available");
+ }
+
+ @Test
+ public void testAnySubtaskGetsNextSplit() {
+ provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3));
+
+ // Three unrelated subtask ids each pull one split from the shared pool;
together they drain it,
+ // and every discovered split is handed out exactly once.
+ Set<String> served = new HashSet<>();
+ served.add(requireSplit(provider.getNext(0, null)).splitId());
+ served.add(requireSplit(provider.getNext(5, null)).splitId());
+ served.add(requireSplit(provider.getNext(99, "some-host")).splitId());
+
+ assertEquals(
+ new HashSet<>(Arrays.asList(split1.splitId(), split2.splitId(),
split3.splitId())),
+ served,
+ "Every split should be served exactly once across arbitrary subtasks");
+ assertFalse(provider.getNext(0, null).isPresent(), "Pool should be
drained");
+ assertEquals(0, provider.pendingSplitCount());
+ }
+
+ @Test
+ public void testSingleSubtaskCanDrainEntirePool() {
+ // Work stealing: one reader may take every split.
DefaultHoodieSplitProvider would instead pin
+ // most of these to other subtasks and starve this one.
+ provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3));
+
+ assertTrue(provider.getNext(7, null).isPresent());
+ assertTrue(provider.getNext(7, null).isPresent());
+ assertTrue(provider.getNext(7, null).isPresent());
+ assertFalse(provider.getNext(7, null).isPresent(),
+ "Fourth request should be empty once the single reader drained the
pool");
+ }
+
+ @Test
+ public void testServedOldestCommitFirstRegardlessOfSubtask() {
+ HoodieSourceSplit early = createSplitWithCommit(1, "20260126034716930",
"file_early");
+ HoodieSourceSplit middle = createSplitWithCommit(2, "20260126034717000",
"file_middle");
+ HoodieSourceSplit late = createSplitWithCommit(3, "20260126034718000",
"file_late");
+
+ // Discover out of order and request from different subtasks: ordering is
by commit time, not by
+ // requester or insertion order.
+ provider.onDiscoveredSplits(Arrays.asList(late, early, middle));
+
+ assertEquals(early.splitId(), requireSplit(provider.getNext(3,
null)).splitId());
+ assertEquals(middle.splitId(), requireSplit(provider.getNext(8,
null)).splitId());
+ assertEquals(late.splitId(), requireSplit(provider.getNext(0,
null)).splitId());
+ }
+
+ @Test
+ public void testOnUnassignedSplitsReturnedToPoolForAnySubtask() {
+ provider.onDiscoveredSplits(Collections.singletonList(split1));
+ HoodieSourceSplit taken = requireSplit(provider.getNext(0, null));
+ assertEquals(0, provider.pendingSplitCount());
+
+ // A failed reader hands the split back; a different subtask can pick it
up.
+ provider.onUnassignedSplits(Collections.singletonList(taken));
+ assertEquals(1, provider.pendingSplitCount(), "Returned split should be
back in the pool");
+ assertEquals(taken.splitId(), requireSplit(provider.getNext(4,
null)).splitId());
+ }
+
+ @Test
+ public void testPendingSplitCount() {
+ assertEquals(0, provider.pendingSplitCount(), "Initially should have 0
pending splits");
+
+ provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3));
+ assertEquals(3, provider.pendingSplitCount());
+
+ provider.getNext(0, null);
+ provider.getNext(1, null);
+ assertEquals(1, provider.pendingSplitCount(),
+ "Count should drop as splits are served to any subtask");
+ }
+
+ @Test
+ public void testMultipleDiscoveryCalls() {
+ provider.onDiscoveredSplits(Collections.singletonList(split1));
+ provider.onDiscoveredSplits(Arrays.asList(split2, split3));
+ assertEquals(3, provider.pendingSplitCount(), "All discovered splits
accumulate in the pool");
+ }
+
+ @Test
+ public void testEmptyDiscoveredSplits() {
+ provider.onDiscoveredSplits(Collections.emptyList());
+ assertEquals(0, provider.pendingSplitCount());
+ assertFalse(provider.getNext(0, null).isPresent());
+ }
+
+ @Test
+ public void testState() {
+ provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3));
+
+ Collection<HoodieSourceSplitState> states = provider.state();
+ assertEquals(3, states.size(), "State should contain all pending splits");
+ for (HoodieSourceSplitState state : states) {
+ assertEquals(HoodieSourceSplitStatus.UNASSIGNED, state.getStatus(),
+ "Pending splits should be UNASSIGNED");
+ }
+ }
+
+ @Test
+ public void testStateAfterConsumingSomeSplits() {
+ provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3));
+ provider.getNext(0, null);
+ provider.getNext(1, null);
+
+ assertEquals(1, provider.state().size(), "State should only reflect the
remaining split");
+ }
+
+ @Test
+ public void testStateRoundTripsThroughRediscovery() {
+ // Mirrors the enumerator restore path: snapshot pending splits, rebuild a
fresh provider and
+ // re-discover them. No split is lost or duplicated.
+ provider.onDiscoveredSplits(Arrays.asList(split1, split2, split3));
+ provider.getNext(0, null); // one assigned, two remain pending in the
checkpoint
+
+ List<HoodieSourceSplit> checkpointed = new ArrayList<>();
+ for (HoodieSourceSplitState state : provider.state()) {
+ checkpointed.add(state.getSplit());
+ }
+
+ GlobalHoodieSplitProvider restored = new GlobalHoodieSplitProvider();
+ restored.onDiscoveredSplits(checkpointed);
+ assertEquals(2, restored.pendingSplitCount());
+ assertTrue(restored.getNext(0, null).isPresent());
+ assertTrue(restored.getNext(0, null).isPresent());
+ assertFalse(restored.getNext(0, null).isPresent());
+ }
+
+ @Test
+ public void testIsAvailable() {
+ CompletableFuture<Void> future = provider.isAvailable();
+ assertNotNull(future, "isAvailable should return a future");
+ assertFalse(future.isDone(), "Future should not be completed with no
splits");
+ assertSame(future, provider.isAvailable(),
+ "The same future should be returned until it completes");
+ }
+
+ @Test
+ public void testIsAvailableCompletesOnDiscovery() {
+ CompletableFuture<Void> future = provider.isAvailable();
+
+ provider.onDiscoveredSplits(Collections.singletonList(split1));
+
+ assertTrue(future.isDone(), "Future should complete once splits land in
the pool");
+ assertFalse(provider.isAvailable().isDone(),
+ "A fresh, uncompleted future should be handed out afterwards");
+ }
+
+ @Test
+ public void testPendingRecordsUnsupported() {
+ assertThrows(UnsupportedOperationException.class, () ->
provider.pendingRecords());
+ }
+
+ @Test
+ public void testConcurrentDrainServesEachSplitExactlyOnce() throws Exception
{
+ final int splitCount = 500;
+ final int readerCount = 8;
+ List<HoodieSourceSplit> splits = new ArrayList<>();
+ for (int i = 0; i < splitCount; i++) {
+ splits.add(createTestSplit(i, "file" + i));
+ }
+ provider.onDiscoveredSplits(splits);
+
+ ConcurrentLinkedQueue<String> served = new ConcurrentLinkedQueue<>();
+ CountDownLatch start = new CountDownLatch(1);
+ List<Future<?>> drains = new ArrayList<>();
+ ExecutorService readers = Executors.newFixedThreadPool(readerCount);
+ try {
+ for (int reader = 0; reader < readerCount; reader++) {
+ final int subtaskId = reader;
+ drains.add(readers.submit(() -> {
+ start.await();
+ Option<HoodieSourceSplit> next;
+ while ((next = provider.getNext(subtaskId, null)).isPresent()) {
+ served.add(next.get().splitId());
+ }
+ return null;
+ }));
+ }
+ start.countDown();
+ readers.shutdown();
+ assertTrue(readers.awaitTermination(30, TimeUnit.SECONDS), "Readers
should drain the pool");
+ for (Future<?> drain : drains) {
+ drain.get(); // surface any failure inside a reader thread
+ }
+ } finally {
+ readers.shutdownNow();
+ }
+
+ assertEquals(splitCount, served.size(), "No split should be served twice");
+ assertEquals(splitCount, new HashSet<>(served).size(), "No split should be
lost");
+ assertEquals(0, provider.pendingSplitCount(), "Pool should be fully
drained");
+ }
+
+ private static HoodieSourceSplit requireSplit(Option<HoodieSourceSplit>
option) {
+ assertTrue(option.isPresent(), "Expected a split to be available");
+ return option.get();
+ }
+
+ private HoodieSourceSplit createSplitWithCommit(int splitNum, String
latestCommit, String basePath) {
+ return new HoodieSourceSplit(
+ splitNum,
+ basePath,
+ Option.empty(),
+ "/table/path",
+ "/table/path/partition1",
+ "read_optimized",
+ latestCommit,
+ "file" + splitNum,
+ Option.empty());
+ }
+
+ private HoodieSourceSplit createTestSplit(int splitNum, String fileId) {
+ return new HoodieSourceSplit(
+ splitNum,
+ "40e603a8-3cc1-4d09-b0a5-1432992b4bf7_1-0" + splitNum +
"_20260126034717000.parquet",
+ Option.empty(),
+ "/table/path",
+ "/table/path/partition1",
+ "read_optimized",
+ "2026012603471700" + splitNum,
+ fileId,
+ Option.empty());
+ }
+}