This is an automated email from the ASF dual-hosted git repository.
loserwang1024 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git
The following commit(s) were added to refs/heads/master by this push:
new 4b678829b [FLINK-40740][pipeline-connector/fluss] Prevent duplicate
split initialization during discovery (#4555)
4b678829b is described below
commit 4b678829ba2c0f17c68ff015113844f05945d702
Author: Xiaobing Fang <[email protected]>
AuthorDate: Tue Sep 29 14:19:09 2026 +0800
[FLINK-40740][pipeline-connector/fluss] Prevent duplicate split
initialization during discovery (#4555)
---
.../source/enumerator/FlussSourceEnumerator.java | 42 +-
.../enumerator/FlussSourceEnumeratorTest.java | 437 +++++++++++++++++++++
2 files changed, 459 insertions(+), 20 deletions(-)
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/source/enumerator/FlussSourceEnumerator.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/source/enumerator/FlussSourceEnumerator.java
index efe8945ee..0d30fcb61 100644
---
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/source/enumerator/FlussSourceEnumerator.java
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/source/enumerator/FlussSourceEnumerator.java
@@ -82,8 +82,9 @@ import java.util.stream.Collectors;
* <ol>
* <li>{@link #getSubscribedTableBuckets()} — discovers subscribed tables
and enumerates all
* table-buckets including partitions (async).
- * <li>{@link #checkTableBucketChanges} — compares discovered table-buckets
with already-assigned
- * ones and triggers split creation for new table-buckets (callback).
+ * <li>{@link #checkTableBucketChanges} — compares discovered table-buckets
with assigned,
+ * pending, and initializing ones and triggers split creation for new
table-buckets
+ * (callback).
* <li>{@link #initPendingBucketSplits} — resolves starting offsets and
creates splits for new
* table-buckets (async).
* <li>{@link #handleTableBucketChanges} — marks physical table paths as
assigned and distributes
@@ -120,7 +121,7 @@ public class FlussSourceEnumerator
private final Map<TablePath, Long> pendingRemovalRequests;
private final Map<TablePath, Set<Integer>> removalAcknowledgements;
private final Map<TableBucket, TablePath> pendingRemovalBuckets;
- private final Set<TablePath> initializingTablePaths;
+ private final Set<PhysicalTablePath> initializingPhysicalTablePaths;
private final Map<TablePath, Long> removalFences;
private final Map<String, FlussSplitBase> fencedFreshSplits;
private List<TableBucketInfo> lastDiscoveredTableBuckets;
@@ -158,7 +159,7 @@ public class FlussSourceEnumerator
this.pendingRemovalRequests = new HashMap<>();
this.removalAcknowledgements = new HashMap<>();
this.pendingRemovalBuckets = new HashMap<>();
- this.initializingTablePaths = new HashSet<>();
+ this.initializingPhysicalTablePaths = new HashSet<>();
this.removalFences = new HashMap<>();
this.fencedFreshSplits = new HashMap<>();
this.lastDiscoveredTableBuckets = Collections.emptyList();
@@ -287,8 +288,8 @@ public class FlussSourceEnumerator
//
-------------------------------------------------------------------------
/**
- * Compares the discovered table-buckets against assigned and pending
{@link PhysicalTablePath}s
- * and triggers split creation for newly discovered table-buckets.
+ * Compares the discovered table-buckets against assigned, pending, and
initializing {@link
+ * PhysicalTablePath}s and triggers split creation for newly discovered
table-buckets.
*/
private void checkTableBucketChanges(DiscoveryResult discoveryResult,
Throwable error) {
if (error != null) {
@@ -308,18 +309,17 @@ public class FlussSourceEnumerator
private void initializeNewTableBuckets(List<TableBucketInfo> allBuckets) {
- Set<PhysicalTablePath> assignedOrPendingPhysicalTablePaths =
- new HashSet<>(assignedPhysicalTablePaths);
+ Set<PhysicalTablePath> knownPhysicalTablePaths = new
HashSet<>(assignedPhysicalTablePaths);
+ knownPhysicalTablePaths.addAll(initializingPhysicalTablePaths);
pendingPartitionSplitAssignment.values().stream()
.flatMap(Set::stream)
.map(FlussSplitBase::getPhysicalTablePath)
- .forEach(assignedOrPendingPhysicalTablePaths::add);
+ .forEach(knownPhysicalTablePaths::add);
List<TableBucketInfo> newBuckets = new ArrayList<>();
for (TableBucketInfo info : allBuckets) {
- if
(!initializingTablePaths.contains(info.physicalTablePath.getTablePath())
- &&
!pendingRemovalTablePaths.contains(info.physicalTablePath.getTablePath())
- &&
!assignedOrPendingPhysicalTablePaths.contains(info.physicalTablePath)) {
+ if
(!pendingRemovalTablePaths.contains(info.physicalTablePath.getTablePath())
+ &&
!knownPhysicalTablePaths.contains(info.physicalTablePath)) {
newBuckets.add(info);
}
}
@@ -330,11 +330,9 @@ public class FlussSourceEnumerator
}
LOG.info("Discovered {} new table-bucket(s) to initialize.",
newBuckets.size());
- Set<TablePath> initializingPaths =
- newBuckets.stream()
- .map(info -> info.physicalTablePath.getTablePath())
- .collect(Collectors.toSet());
- initializingTablePaths.addAll(initializingPaths);
+ Set<PhysicalTablePath> initializingPaths =
+ newBuckets.stream().map(info ->
info.physicalTablePath).collect(Collectors.toSet());
+ initializingPhysicalTablePaths.addAll(initializingPaths);
context.callAsync(
() -> initPendingBucketSplits(newBuckets),
(splits, error) -> handleTableBucketChanges(splits, error,
initializingPaths));
@@ -388,7 +386,8 @@ public class FlussSourceEnumerator
}
Set<TablePath> clearedTablePaths = new HashSet<>();
for (TablePath tablePath : pendingRemovalTablePaths) {
- if (!initializingTablePaths.contains(tablePath)
+ if (initializingPhysicalTablePaths.stream()
+ .noneMatch(path ->
path.getTablePath().equals(tablePath))
&& removalAcknowledgements
.getOrDefault(tablePath, Collections.emptySet())
.containsAll(expectedReaders)) {
@@ -634,11 +633,14 @@ public class FlussSourceEnumerator
* distributes the splits to registered readers.
*/
private void handleTableBucketChanges(
- List<FlussSplitBase> newSplits, Throwable error, Set<TablePath>
initializingPaths) {
- initializingTablePaths.removeAll(initializingPaths);
+ List<FlussSplitBase> newSplits,
+ Throwable error,
+ Set<PhysicalTablePath> initializingPaths) {
+ initializingPhysicalTablePaths.removeAll(initializingPaths);
if (error != null) {
boolean hasActiveInitializingPath =
initializingPaths.stream()
+ .map(PhysicalTablePath::getTablePath)
.anyMatch(
tablePath ->
subscribedTablePaths.contains(tablePath)
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/source/enumerator/FlussSourceEnumeratorTest.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/source/enumerator/FlussSourceEnumeratorTest.java
index 3fe9653a4..bb0731193 100644
---
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/source/enumerator/FlussSourceEnumeratorTest.java
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/source/enumerator/FlussSourceEnumeratorTest.java
@@ -55,6 +55,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.time.Duration;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@@ -475,6 +476,168 @@ class FlussSourceEnumeratorTest {
}
}
+ /**
+ * Tests that running several periodic discovery rounds before the async
split initialization
+ * triggered by the first round completes does not queue duplicate
initializations for the same
+ * table-bucket.
+ */
+ @Test
+ void testMultiplePeriodicDiscoveryRoundsBeforeInitDoNotDuplicateSplits()
throws Throwable {
+ String tableName = "multi_bucket_dup";
+ createPkTable(tableName, 3);
+
+ FlussDefaultDiscoverer discoverer = new FlussDefaultDiscoverer();
+ String pattern = fqnRegex(DATABASE_NAME, tableName);
+
+ AtomicInteger offsetInitializationCount = new AtomicInteger();
+ OffsetsInitializer earliestOffsetsInitializer =
OffsetsInitializer.earliest();
+ OffsetsInitializer countingOffsetsInitializer =
+ (partitionName, bucketIds, retriever) -> {
+ offsetInitializationCount.incrementAndGet();
+ return earliestOffsetsInitializer.getBucketOffsets(
+ partitionName, bucketIds, retriever);
+ };
+
+ try (MockSplitEnumeratorContext<FlussSplitBase> context =
+ new MockSplitEnumeratorContext<>(NUM_READERS)) {
+ FlussSourceEnumerator enumerator =
+ newEnumerator(
+ context,
+ discoverer,
+ pattern,
+ countingOffsetsInitializer,
+ DISCOVERY_INTERVAL_MS);
+ try {
+ enumerator.start();
+ registerAllReaders(context, enumerator);
+
+ // Simulates a slow/backed-up async initialization: several
discovery rounds run
+ // before any of the resulting one-time (init) callables are
drained.
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+
+ int queuedInitCount = context.getOneTimeCallables().size();
+
+ while (!context.getOneTimeCallables().isEmpty()) {
+ context.runNextOneTimeCallable();
+ }
+
+ assertThat(assignedBucketIds(context, tableName))
+ .containsExactlyInAnyOrder(0, 1, 2);
+ assertThat(queuedInitCount)
+ .as(
+ "Each already-discovered table-bucket must
only be queued for "
+ + "initialization once, even across
repeated discovery "
+ + "rounds")
+ .isEqualTo(1);
+ assertThat(offsetInitializationCount.get())
+ .as("The offsets initializer must only be invoked once
for the table")
+ .isEqualTo(1);
+
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(context.getOneTimeCallables())
+ .as("Nothing new to discover once every bucket is
already assigned")
+ .isEmpty();
+ } finally {
+ enumerator.close();
+ }
+ }
+ }
+
+ /**
+ * Tests that when no readers are registered yet, a completed table-bucket
initialization lands
+ * in the pending assignment map instead of being lost or re-queued, and
that once readers
+ * register, all pending splits are assigned exactly once.
+ */
+ @Test
+ void testPendingAssignmentSurvivesDiscoveryUntilReadersRegister() throws
Throwable {
+ String tableName = "pending_no_reader";
+ createPkTable(tableName, 3);
+
+ FlussDefaultDiscoverer discoverer = new FlussDefaultDiscoverer();
+ String pattern = fqnRegex(DATABASE_NAME, tableName);
+
+ try (MockSplitEnumeratorContext<FlussSplitBase> context =
+ new MockSplitEnumeratorContext<>(NUM_READERS)) {
+ FlussSourceEnumerator enumerator = newEnumerator(context,
discoverer, pattern);
+ try {
+ enumerator.start();
+
+ // No readers registered yet: initialization completes into
the pending
+ // assignment map instead of being assigned.
+ runDiscoveryCycle(context);
+ assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+
+ // A subsequent discovery round must not re-discover or
re-queue the same
+ // table-buckets while they are only pending (not yet
assigned).
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(context.getOneTimeCallables()).isEmpty();
+
+ registerAllReaders(context, enumerator);
+
+ assertThat(assignedBucketIds(context, tableName))
+ .containsExactlyInAnyOrder(0, 1, 2);
+ } finally {
+ enumerator.close();
+ }
+ }
+ }
+
+ /**
+ * Tests that once one partition's initialization completes, a subsequent
discovery round does
+ * not re-queue initialization for another partition whose initialization
is still in flight.
+ */
+ @Test
+ void
testDiscoveryDoesNotDropStillInitializingPartitionAfterAnotherCompletes()
+ throws Throwable {
+ String tableName = "two_partition_inflight";
+ createPartitionedPkTable(tableName, 3);
+ insertPartitionRow(tableName, 1, "p1", "a");
+
+ FlussDefaultDiscoverer discoverer = new FlussDefaultDiscoverer();
+ String pattern = fqnRegex(DATABASE_NAME, tableName);
+
+ try (MockSplitEnumeratorContext<FlussSplitBase> context =
+ new MockSplitEnumeratorContext<>(NUM_READERS)) {
+ FlussSourceEnumerator enumerator = newEnumerator(context,
discoverer, pattern);
+ try {
+ enumerator.start();
+ registerAllReaders(context, enumerator);
+
+ // Round 1: discovers partition p1 and queues its
initialization, without
+ // draining it.
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(context.getOneTimeCallables()).hasSize(1);
+
+ // Partition p2 appears before p1's initialization has
completed.
+ insertPartitionRow(tableName, 2, "p2", "b");
+
+ // Round 2: discovers p2 and queues its initialization; p1 is
still in flight.
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(context.getOneTimeCallables()).hasSize(2);
+
+ context.runNextOneTimeCallable();
+ assertThat(assignedSplitIds(context, tableName))
+ .containsExactlyInAnyOrder(expectedSplitIds(tableName,
"p1"));
+ assertThat(context.getOneTimeCallables()).hasSize(1);
+
+ // Round 3: p1 is now assigned, p2 is still in flight (its
callable has not been
+ // drained yet). This round must not re-queue p2's
initialization.
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(context.getOneTimeCallables())
+ .as("A still in-flight partition must not be re-queued
for initialization")
+ .hasSize(1);
+
+ context.runNextOneTimeCallable();
+ assertThat(assignedSplitIds(context, tableName))
+ .containsExactlyInAnyOrder(expectedSplitIds(tableName,
"p1", "p2"));
+ } finally {
+ enumerator.close();
+ }
+ }
+ }
+
@Test
void testDiscoveryFailsWhenTableDiscoveryFails() throws Throwable {
RuntimeException discoveryFailure =
@@ -552,6 +715,89 @@ class FlussSourceEnumeratorTest {
}
}
+ /**
+ * Tests that after an offset-initialization failure, restarting the
enumerator from a
+ * checkpoint taken at that point fully rediscovers and initializes every
table-bucket exactly
+ * once. The failed instance is discarded rather than retried in place:
{@link
+ * MockSplitEnumeratorContext}'s stored worker error is not cleared once
thrown, so it cannot be
+ * reused to observe a same-instance retry.
+ */
+ @Test
+ void
testDiscoveryFullyRediscoversAfterOffsetInitializationFailureOnRestart() throws
Throwable {
+ String tableName = "offset_failure_restart";
+ createPkTable(tableName, 3);
+
+ RuntimeException offsetFailure =
+ new RuntimeException("Injected offset initialization failure");
+ OffsetsInitializer failingOffsetsInitializer =
+ (partitionName, bucketIds, retriever) -> {
+ throw offsetFailure;
+ };
+
+ FlussSourceEnumState checkpoint;
+ try (MockSplitEnumeratorContext<FlussSplitBase> context =
+ new MockSplitEnumeratorContext<>(NUM_READERS)) {
+ FlussSourceEnumerator enumerator =
+ newEnumerator(
+ context,
+ new FlussDefaultDiscoverer(),
+ fqnRegex(DATABASE_NAME, tableName),
+ failingOffsetsInitializer,
+ DISCOVERY_INTERVAL_MS);
+ try {
+ enumerator.start();
+ registerAllReaders(context, enumerator);
+
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThatThrownBy(context::runNextOneTimeCallable)
+ .isInstanceOf(FlinkRuntimeException.class)
+ .hasMessage("Failed to initialize splits for new
table-buckets.")
+ .hasRootCauseMessage("Injected offset initialization
failure");
+ assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+
+ checkpoint = enumerator.snapshotState(1L);
+
assertThat(checkpoint.getAssignedPhysicalTablePaths()).isEmpty();
+ assertThat(checkpoint.getRemainingSplits()).isEmpty();
+ } finally {
+ enumerator.close();
+ }
+ }
+
+ FlussSourceEnumStateSerializer serializer = new
FlussSourceEnumStateSerializer();
+ FlussSourceEnumState restoredCheckpoint =
+ serializer.deserialize(serializer.getVersion(),
serializer.serialize(checkpoint));
+
+ try (MockSplitEnumeratorContext<FlussSplitBase> restoredContext =
+ new MockSplitEnumeratorContext<>(NUM_READERS)) {
+ org.apache.fluss.config.Configuration flussConfig =
+ FLUSS_CLUSTER_EXTENSION.getClientConfig();
+ FlussSourceEnumerator restoredEnumerator =
+ new FlussSourceEnumerator(
+ restoredContext,
+ new FlussDefaultDiscoverer(),
+ flussConfig,
+ buildSourceConfig(flussConfig,
fqnRegex(DATABASE_NAME, tableName)),
+ OffsetsInitializer.earliest(),
+ DISCOVERY_INTERVAL_MS,
+ restoredCheckpoint,
+ LeaseContext.fromConf(
+ new
org.apache.flink.configuration.Configuration()));
+ try {
+ restoredEnumerator.start();
+ registerAllReaders(restoredContext, restoredEnumerator);
+
+ runDiscoveryCycle(restoredContext);
+ assertThat(assignedBucketIds(restoredContext, tableName))
+ .containsExactlyInAnyOrder(0, 1, 2);
+
+ restoredContext.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(restoredContext.getOneTimeCallables()).isEmpty();
+ } finally {
+ restoredEnumerator.close();
+ }
+ }
+ }
+
@Test
void testDiscoveryFailsWhenOffsetInitializationIsIncomplete() throws
Throwable {
String tableName = "incomplete_offsets";
@@ -805,6 +1051,74 @@ class FlussSourceEnumeratorTest {
}
}
+ /**
+ * Tests that a table-bucket whose initialization is still in flight at
snapshot time (i.e. not
+ * yet reflected in {@link FlussSourceEnumState}) is fully re-discovered
and initialized exactly
+ * once after restoring from that snapshot.
+ */
+ @Test
+ void
testSnapshotDuringInFlightInitializationAllowsFullRediscoveryAfterRestore()
+ throws Throwable {
+ String tableName = "inflight_snapshot_restore";
+ createPkTable(tableName, 3);
+
+ FlussSourceEnumState checkpoint;
+ try (MockSplitEnumeratorContext<FlussSplitBase> context =
+ new MockSplitEnumeratorContext<>(NUM_READERS)) {
+ FlussSourceEnumerator enumerator =
+ newEnumerator(
+ context,
+ new FlussDefaultDiscoverer(),
+ fqnRegex(DATABASE_NAME, tableName));
+ try {
+ enumerator.start();
+ registerAllReaders(context, enumerator);
+
+ // Discovered but not drained: tracked as in-flight, not yet
pending or assigned.
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(context.getOneTimeCallables()).hasSize(1);
+
+ checkpoint = enumerator.snapshotState(1L);
+
assertThat(checkpoint.getAssignedPhysicalTablePaths()).isEmpty();
+ assertThat(checkpoint.getRemainingSplits()).isEmpty();
+ } finally {
+ enumerator.close();
+ }
+ }
+
+ FlussSourceEnumStateSerializer serializer = new
FlussSourceEnumStateSerializer();
+ FlussSourceEnumState restoredCheckpoint =
+ serializer.deserialize(serializer.getVersion(),
serializer.serialize(checkpoint));
+
+ try (MockSplitEnumeratorContext<FlussSplitBase> restoredContext =
+ new MockSplitEnumeratorContext<>(NUM_READERS)) {
+ org.apache.fluss.config.Configuration flussConfig =
+ FLUSS_CLUSTER_EXTENSION.getClientConfig();
+ FlussSourceEnumerator restoredEnumerator =
+ new FlussSourceEnumerator(
+ restoredContext,
+ new FlussDefaultDiscoverer(),
+ flussConfig,
+ buildSourceConfig(flussConfig,
fqnRegex(DATABASE_NAME, tableName)),
+ OffsetsInitializer.earliest(),
+ DISCOVERY_INTERVAL_MS,
+ restoredCheckpoint,
+ LeaseContext.fromConf(
+ new
org.apache.flink.configuration.Configuration()));
+ try {
+ restoredEnumerator.start();
+ registerAllReaders(restoredContext, restoredEnumerator);
+
+ runDiscoveryCycle(restoredContext);
+
+ assertThat(assignedBucketIds(restoredContext, tableName))
+ .containsExactlyInAnyOrder(0, 1, 2);
+ } finally {
+ restoredEnumerator.close();
+ }
+ }
+ }
+
// =====================================================================
// FlussTableSubscriber tests — subscription-table driven add/remove
// =====================================================================
@@ -1056,6 +1370,68 @@ class FlussSourceEnumeratorTest {
}
}
+ @Test
+ void testResubscriptionWaitsForAllInitializingPartitions() throws
Throwable {
+ String subscriptionTable = "sub_partition_inflight";
+ String targetTable = "tgt_partition_inflight";
+ TablePath tablePath = TablePath.of(DATABASE_NAME, targetTable);
+ createSubscriptionTable(subscriptionTable);
+ createPartitionedPkTable(targetTable, 3);
+ insertPartitionRow(targetTable, 1, "p1", "a");
+ insertSubscription(subscriptionTable, targetTable);
+
+ FlussSubscriberTableDiscoverer subscriber =
+ new FlussSubscriberTableDiscoverer(DATABASE_NAME + "." +
subscriptionTable, 100);
+ try (MockSplitEnumeratorContext<FlussSplitBase> context =
+ new MockSplitEnumeratorContext<>(NUM_READERS)) {
+ FlussSourceEnumerator enumerator = newEnumerator(context,
subscriber, null);
+ try {
+ enumerator.start();
+ registerAllReaders(context, enumerator);
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ insertPartitionRow(targetTable, 2, "p2", "b");
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(context.getOneTimeCallables()).hasSize(2);
+
+ tBatchEnv
+ .executeSql(
+ String.format(
+ "DELETE FROM %s WHERE table_name =
'%s.%s'",
+ subscriptionTable, DATABASE_NAME,
targetTable))
+ .await();
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ long requestId =
+ latestSubscriptionEvent(context, 0)
+ .getPendingRemovalRequests()
+ .get(tablePath);
+ acknowledgeRemoval(enumerator, tablePath, requestId);
+ insertSubscription(subscriptionTable, targetTable);
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(context.getOneTimeCallables()).hasSize(2);
+
+ // Reader acknowledgements cannot release the other
partition's in-flight work.
+ context.runNextOneTimeCallable();
+
assertThat(enumerator.snapshotState(1L).getPendingRemovalTablePaths())
+ .containsExactly(tablePath);
+ assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+ assertThat(context.getOneTimeCallables()).hasSize(1);
+
+ context.runNextOneTimeCallable();
+
assertThat(enumerator.snapshotState(2L).getPendingRemovalTablePaths()).isEmpty();
+ assertThat(context.getSplitsAssignmentSequence()).isEmpty();
+ assertThat(context.getOneTimeCallables()).hasSize(1);
+
+ context.runNextOneTimeCallable();
+ assertThat(assignedSplitIds(context, targetTable))
+
.containsExactlyInAnyOrder(expectedSplitIds(targetTable, "p1", "p2"));
+ context.runPeriodicCallable(DISCOVERY_CALLABLE_INDEX);
+ assertThat(context.getOneTimeCallables()).isEmpty();
+ } finally {
+ enumerator.close();
+ }
+ }
+ }
+
@Test
void testLateInitializationDoesNotReviveDeletedSubscription() throws
Throwable {
String subscriptionTable = "sub_late_init";
@@ -1506,6 +1882,46 @@ class FlussSourceEnumeratorTest {
.collect(Collectors.toSet());
}
+ /** Returns the bucket ids assigned so far for the given table, across all
assignments. */
+ private static List<Integer> assignedBucketIds(
+ MockSplitEnumeratorContext<FlussSplitBase> context, String
tableName) {
+ return context.getSplitsAssignmentSequence().stream()
+ .flatMap(assignment ->
assignment.assignment().values().stream())
+ .flatMap(List::stream)
+ .filter(split ->
split.getPhysicalTablePath().getTableName().equals(tableName))
+ .map(split -> split.getTableBucket().getBucket())
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Returns the full split id of every split assigned so far for the given
table, across all
+ * assignments. A {@link List} (not a {@link Set}) is returned so
duplicate splits are not
+ * silently hidden.
+ */
+ private static List<String> assignedSplitIds(
+ MockSplitEnumeratorContext<FlussSplitBase> context, String
tableName) {
+ return context.getSplitsAssignmentSequence().stream()
+ .flatMap(assignment ->
assignment.assignment().values().stream())
+ .flatMap(List::stream)
+ .filter(split ->
split.getPhysicalTablePath().getTableName().equals(tableName))
+ .map(FlussSplitBase::splitId)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Builds the expected {@link FlussSplitBase#splitId()} values for buckets
0..2 of the given
+ * table and partitions.
+ */
+ private static String[] expectedSplitIds(String tableName, String...
partitionNames) {
+ List<String> ids = new ArrayList<>();
+ for (String partitionName : partitionNames) {
+ for (int bucket = 0; bucket < 3; bucket++) {
+ ids.add(DATABASE_NAME + "." + tableName + "." + partitionName
+ "." + bucket);
+ }
+ }
+ return ids.toArray(new String[0]);
+ }
+
/** Returns the set of table names in the most recent assignment only. */
private static Set<String> latestAssignmentTableNames(
MockSplitEnumeratorContext<FlussSplitBase> context) {
@@ -1560,6 +1976,27 @@ class FlussSourceEnumeratorTest {
}
}
+ private void createPartitionedPkTable(String tableName, int numBuckets)
throws Exception {
+ tBatchEnv
+ .executeSql(
+ String.format(
+ "CREATE TABLE %s (id INT, ds STRING, val
STRING, "
+ + "PRIMARY KEY (id, ds) NOT ENFORCED)
PARTITIONED BY (ds) "
+ + "WITH ('bucket.num' = '%d')",
+ tableName, numBuckets))
+ .await();
+ }
+
+ private void insertPartitionRow(String tableName, int id, String
partitionValue, String val)
+ throws Exception {
+ tBatchEnv
+ .executeSql(
+ String.format(
+ "INSERT INTO %s VALUES (%d, '%s', '%s')",
+ tableName, id, partitionValue, val))
+ .await();
+ }
+
private void createSubscriptionTable(String tableName) throws Exception {
tBatchEnv
.executeSql(