leonardBang commented on code in PR #4494:
URL: https://github.com/apache/flink-cdc/pull/4494#discussion_r3861173931
##########
flink-cdc-common/src/main/java/org/apache/flink/cdc/common/source/discover/TableDiscovererFactory.java:
##########
@@ -26,10 +26,6 @@
public interface TableDiscovererFactory extends
ObjectIdDiscovererFactory<TableId> {
/** Compatibility alias for the old single identifier, now meaning
storage-side type. */
Review Comment:
Could we keep the `identifier()` compatibility alias here?
`TableDiscovererFactory` is `@PublicEvolving`, and the current master API
intentionally retains this default method as the legacy name for `type()`.
Removing it in an unrelated Fluss source PR can break third-party discoverer
code compiled against the current SPI and also leaves the compatibility-alias
Javadoc attached to `objectIdClass()`. Unless this removal is part of a
separately reviewed SPI migration, restoring the alias would avoid an
unnecessary compatibility regression.
##########
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:
##########
@@ -0,0 +1,595 @@
+/*
+ * 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.flink.cdc.connectors.fluss.source.enumerator;
+
+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.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.source.discover.TableDiscoverer;
+import org.apache.flink.cdc.common.source.discover.TableDiscovererFactory;
+import
org.apache.flink.cdc.connectors.fluss.source.discover.FlussDefaultDiscoverer;
+import
org.apache.flink.cdc.connectors.fluss.source.split.FlussHybridSnapshotLogSplit;
+import org.apache.flink.cdc.connectors.fluss.source.split.FlussLogSplit;
+import org.apache.flink.cdc.connectors.fluss.source.split.FlussSplitBase;
+import org.apache.flink.util.FlinkRuntimeException;
+
+import org.apache.fluss.client.Connection;
+import org.apache.fluss.client.ConnectionFactory;
+import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.client.initializer.BucketOffsetsRetrieverImpl;
+import org.apache.fluss.client.initializer.OffsetsInitializer;
+import org.apache.fluss.client.initializer.SnapshotOffsetsInitializer;
+import org.apache.fluss.client.metadata.KvSnapshots;
+import org.apache.fluss.metadata.PartitionInfo;
+import org.apache.fluss.metadata.PhysicalTablePath;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.metadata.TablePath;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * The enumerator for Fluss source. It discovers tables using {@link
TableDiscoverer}, queries their
+ * metadata (schema, bucket count, partitions), and generates {@link
FlussSplitBase}s for each
+ * table-bucket pair, assigning them to readers in a round-robin fashion.
+ *
+ * <p>The enumeration follows a four-phase pattern:
+ *
+ * <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 #initPendingBucketSplits} — resolves starting offsets and
creates splits for new
+ * table-buckets (async).
+ * <li>{@link #handleTableBucketChanges} — marks physical table paths as
assigned and distributes
+ * splits to readers (callback).
+ * </ol>
+ *
+ * <p>Tracking is done at {@link PhysicalTablePath} granularity (i.e.
tablePath + partitionName), so
+ * newly created partitions of an already-known table will be discovered and
assigned.
+ *
+ * <p>The starting offsets for each bucket are resolved via the {@link
OffsetsInitializer}, which
+ * supports earliest, latest, and timestamp-based initialization strategies.
+ */
+public class FlussSourceEnumerator
+ implements SplitEnumerator<FlussSplitBase, FlussSourceEnumState> {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(FlussSourceEnumerator.class);
+
+ private final SplitEnumeratorContext<FlussSplitBase> context;
+ private final TableDiscoverer discoverer;
+ private final org.apache.fluss.config.Configuration flussConfig;
+ private final Configuration sourceConfig;
+ private final OffsetsInitializer offsetsInitializer;
+ private final long scanDiscoveryIntervalMs;
+
+ private final Set<PhysicalTablePath> assignedPhysicalTablePaths;
+ private final Map<Integer, Set<FlussSplitBase>>
pendingPartitionSplitAssignment;
+
+ private transient Connection connection;
+ private transient Admin admin;
+
+ public FlussSourceEnumerator(
+ SplitEnumeratorContext<FlussSplitBase> context,
+ TableDiscoverer discoverer,
+ org.apache.fluss.config.Configuration flussConfig,
+ Configuration sourceConfig,
+ OffsetsInitializer offsetsInitializer,
+ long scanDiscoveryIntervalMs,
+ Set<PhysicalTablePath> assignedPhysicalTablePaths) {
+ this.context = context;
+ this.discoverer = discoverer;
+ this.flussConfig = flussConfig;
+ this.sourceConfig = sourceConfig;
+ this.offsetsInitializer = offsetsInitializer;
+ this.scanDiscoveryIntervalMs = scanDiscoveryIntervalMs;
+ this.assignedPhysicalTablePaths = assignedPhysicalTablePaths;
+ this.pendingPartitionSplitAssignment = new HashMap<>();
+ }
+
+ public FlussSourceEnumerator(
+ SplitEnumeratorContext<FlussSplitBase> context,
+ TableDiscoverer discoverer,
+ org.apache.fluss.config.Configuration flussConfig,
+ Configuration sourceConfig,
+ OffsetsInitializer offsetsInitializer,
+ long scanDiscoveryIntervalMs,
+ FlussSourceEnumState restoredState) {
+ this(
+ context,
+ discoverer,
+ flussConfig,
+ sourceConfig,
+ offsetsInitializer,
+ scanDiscoveryIntervalMs,
+ restoredState.getAssignedPhysicalTablePaths());
Review Comment:
Could we restore `getRemainingSplits()` into the pending assignments here?
`snapshotState()` persists splits whose startup offsets or snapshot IDs have
already been initialized but not yet assigned, while this constructor only
restores `assignedPhysicalTablePaths`. I reproduced this by checkpointing an
unassigned latest-mode split at offset 1, appending another record, and
restoring the enumerator: the restored split was rediscovered at offset 2
instead of retaining offset 1. Restoring `remainingSplits` made the test pass.
The restore path should also recalculate owners for the current parallelism and
prevent these restored paths from being initialized again by discovery.
##########
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:
##########
@@ -0,0 +1,595 @@
+/*
+ * 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.flink.cdc.connectors.fluss.source.enumerator;
+
+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.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.source.discover.TableDiscoverer;
+import org.apache.flink.cdc.common.source.discover.TableDiscovererFactory;
+import
org.apache.flink.cdc.connectors.fluss.source.discover.FlussDefaultDiscoverer;
+import
org.apache.flink.cdc.connectors.fluss.source.split.FlussHybridSnapshotLogSplit;
+import org.apache.flink.cdc.connectors.fluss.source.split.FlussLogSplit;
+import org.apache.flink.cdc.connectors.fluss.source.split.FlussSplitBase;
+import org.apache.flink.util.FlinkRuntimeException;
+
+import org.apache.fluss.client.Connection;
+import org.apache.fluss.client.ConnectionFactory;
+import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.client.initializer.BucketOffsetsRetrieverImpl;
+import org.apache.fluss.client.initializer.OffsetsInitializer;
+import org.apache.fluss.client.initializer.SnapshotOffsetsInitializer;
+import org.apache.fluss.client.metadata.KvSnapshots;
+import org.apache.fluss.metadata.PartitionInfo;
+import org.apache.fluss.metadata.PhysicalTablePath;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.metadata.TablePath;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * The enumerator for Fluss source. It discovers tables using {@link
TableDiscoverer}, queries their
+ * metadata (schema, bucket count, partitions), and generates {@link
FlussSplitBase}s for each
+ * table-bucket pair, assigning them to readers in a round-robin fashion.
+ *
+ * <p>The enumeration follows a four-phase pattern:
+ *
+ * <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 #initPendingBucketSplits} — resolves starting offsets and
creates splits for new
+ * table-buckets (async).
+ * <li>{@link #handleTableBucketChanges} — marks physical table paths as
assigned and distributes
+ * splits to readers (callback).
+ * </ol>
+ *
+ * <p>Tracking is done at {@link PhysicalTablePath} granularity (i.e.
tablePath + partitionName), so
+ * newly created partitions of an already-known table will be discovered and
assigned.
+ *
+ * <p>The starting offsets for each bucket are resolved via the {@link
OffsetsInitializer}, which
+ * supports earliest, latest, and timestamp-based initialization strategies.
+ */
+public class FlussSourceEnumerator
+ implements SplitEnumerator<FlussSplitBase, FlussSourceEnumState> {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(FlussSourceEnumerator.class);
+
+ private final SplitEnumeratorContext<FlussSplitBase> context;
+ private final TableDiscoverer discoverer;
+ private final org.apache.fluss.config.Configuration flussConfig;
+ private final Configuration sourceConfig;
+ private final OffsetsInitializer offsetsInitializer;
+ private final long scanDiscoveryIntervalMs;
+
+ private final Set<PhysicalTablePath> assignedPhysicalTablePaths;
+ private final Map<Integer, Set<FlussSplitBase>>
pendingPartitionSplitAssignment;
+
+ private transient Connection connection;
+ private transient Admin admin;
+
+ public FlussSourceEnumerator(
+ SplitEnumeratorContext<FlussSplitBase> context,
+ TableDiscoverer discoverer,
+ org.apache.fluss.config.Configuration flussConfig,
+ Configuration sourceConfig,
+ OffsetsInitializer offsetsInitializer,
+ long scanDiscoveryIntervalMs,
+ Set<PhysicalTablePath> assignedPhysicalTablePaths) {
+ this.context = context;
+ this.discoverer = discoverer;
+ this.flussConfig = flussConfig;
+ this.sourceConfig = sourceConfig;
+ this.offsetsInitializer = offsetsInitializer;
+ this.scanDiscoveryIntervalMs = scanDiscoveryIntervalMs;
+ this.assignedPhysicalTablePaths = assignedPhysicalTablePaths;
+ this.pendingPartitionSplitAssignment = new HashMap<>();
+ }
+
+ public FlussSourceEnumerator(
+ SplitEnumeratorContext<FlussSplitBase> context,
+ TableDiscoverer discoverer,
+ org.apache.fluss.config.Configuration flussConfig,
+ Configuration sourceConfig,
+ OffsetsInitializer offsetsInitializer,
+ long scanDiscoveryIntervalMs,
+ FlussSourceEnumState restoredState) {
+ this(
+ context,
+ discoverer,
+ flussConfig,
+ sourceConfig,
+ offsetsInitializer,
+ scanDiscoveryIntervalMs,
+ restoredState.getAssignedPhysicalTablePaths());
+ }
+
+ @Override
+ public void start() {
+ connection = ConnectionFactory.createConnection(flussConfig);
+ admin = connection.getAdmin();
+
+ // Open the discoverer with the full source configuration
+ try {
+ discoverer.open(
+ TableDiscovererFactory.createContext(
+ sourceConfig,
Thread.currentThread().getContextClassLoader()));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to open TableDiscoverer", e);
+ }
+
+ if (scanDiscoveryIntervalMs > 0) {
+ LOG.info(
+ "Starting Fluss source enumerator with a discovery
interval of {} ms.",
+ scanDiscoveryIntervalMs);
+ context.callAsync(
+ this::getSubscribedTableBuckets,
+ this::checkTableBucketChanges,
+ 0,
+ scanDiscoveryIntervalMs);
+ } else {
+ LOG.info("Starting Fluss source enumerator without periodic
discovery.");
+ context.callAsync(this::getSubscribedTableBuckets,
this::checkTableBucketChanges);
+ }
+ }
+
+ //
-------------------------------------------------------------------------
+ // Phase 1: Discover subscribed table-buckets (runs async)
+ //
-------------------------------------------------------------------------
+
+ /**
+ * Discovers all subscribed tables via the {@link TableDiscoverer}, then
queries their metadata
+ * (bucket count, partitions) and enumerates every individual
table-bucket. For partitioned
+ * tables, each partition contributes its own set of buckets.
+ *
+ * @return the full list of discovered table-bucket entries.
+ */
+ private List<TableBucketInfo> getSubscribedTableBuckets() throws Exception
{
+ List<TableBucketInfo> allBuckets = new ArrayList<>();
+ Set<TableId> discoveredTableIds = discoverer.discover();
+ Set<TablePath> subscribedPaths =
+ discoveredTableIds.stream()
+ .map(FlussDefaultDiscoverer::toTablePath)
+
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
+
+ for (TablePath tablePath : subscribedPaths) {
+ TableInfo tableInfo = admin.getTableInfo(tablePath).get();
+ int numBuckets = tableInfo.getNumBuckets();
+ long tableId = tableInfo.getTableId();
+
+ boolean hasPrimaryKey = tableInfo.hasPrimaryKey();
+
+ if (tableInfo.isPartitioned()) {
+ List<PartitionInfo> partitions =
admin.listPartitionInfos(tablePath).get();
+ for (PartitionInfo partitionInfo : partitions) {
+ long partitionId = partitionInfo.getPartitionId();
+ String partitionName = partitionInfo.getPartitionName();
+ PhysicalTablePath physicalTablePath =
+ PhysicalTablePath.of(tablePath, partitionName);
+ for (int bucket = 0; bucket < numBuckets; bucket++) {
+ TableBucket tableBucket = new TableBucket(tableId,
partitionId, bucket);
+ allBuckets.add(
+ new TableBucketInfo(physicalTablePath,
tableBucket, hasPrimaryKey));
+ }
+ }
+ } else {
+ PhysicalTablePath physicalTablePath =
PhysicalTablePath.of(tablePath);
+ for (int bucket = 0; bucket < numBuckets; bucket++) {
+ TableBucket tableBucket = new TableBucket(tableId, bucket);
+ allBuckets.add(
+ new TableBucketInfo(physicalTablePath,
tableBucket, hasPrimaryKey));
+ }
+ }
+ }
+ return allBuckets;
+ }
+
+ //
-------------------------------------------------------------------------
+ // Phase 2: Check for table-bucket changes (callback)
+ //
-------------------------------------------------------------------------
+
+ /**
+ * Compares the discovered table-buckets against already-assigned {@link
PhysicalTablePath}s and
+ * triggers split creation for newly discovered table-buckets.
+ */
+ private void checkTableBucketChanges(List<TableBucketInfo> allBuckets,
Throwable error) {
+ if (error != null) {
+ throw new FlinkRuntimeException("Failed to discover subscribed
table-buckets.", error);
+ }
+
+ List<TableBucketInfo> newBuckets = new ArrayList<>();
+ for (TableBucketInfo info : allBuckets) {
+ if (!assignedPhysicalTablePaths.contains(info.physicalTablePath)) {
+ newBuckets.add(info);
+ }
+ }
+
+ if (newBuckets.isEmpty()) {
+ LOG.debug("No new table-buckets discovered.");
+ return;
+ }
+
+ LOG.info("Discovered {} new table-bucket(s) to initialize.",
newBuckets.size());
+ context.callAsync(
+ () -> initPendingBucketSplits(newBuckets),
this::handleTableBucketChanges);
+ }
+
+ //
-------------------------------------------------------------------------
+ // Phase 3: Create pending splits for new table-buckets (runs async)
+ //
-------------------------------------------------------------------------
+
+ /**
+ * Groups the new table-buckets by {@link TablePath} (for the {@link
BucketOffsetsRetrieverImpl}
+ * instance) and then by partition name (for batch offset resolution via
the {@link
+ * OffsetsInitializer}), and creates {@link FlussSplitBase} instances.
+ *
+ * <p>For primary key tables with {@link SnapshotOffsetsInitializer}
("full" startup mode), KV
+ * snapshots are retrieved: buckets with a snapshot get a {@link
FlussHybridSnapshotLogSplit},
+ * buckets without a snapshot fall back to a {@link FlussLogSplit}.
+ */
+ private List<FlussSplitBase> initPendingBucketSplits(List<TableBucketInfo>
newBuckets)
+ throws Exception {
+ List<FlussSplitBase> newSplits = new ArrayList<>();
+
+ // Group by tablePath (for retriever), then by partitionName (for
batch offset resolution)
+ Map<TablePath, Map<String, List<TableBucketInfo>>> grouped = new
LinkedHashMap<>();
+ for (TableBucketInfo info : newBuckets) {
+ grouped.computeIfAbsent(
+ info.physicalTablePath.getTablePath(), k -> new
LinkedHashMap<>())
+ .computeIfAbsent(
+ info.physicalTablePath.getPartitionName(), k ->
new ArrayList<>())
+ .add(info);
+ }
+
+ for (Map.Entry<TablePath, Map<String, List<TableBucketInfo>>>
tableEntry :
+ grouped.entrySet()) {
+ TablePath tablePath = tableEntry.getKey();
+ LOG.info("Initializing bucket splits for table: {}", tablePath);
+ OffsetsInitializer.BucketOffsetsRetriever retriever =
+ new BucketOffsetsRetrieverImpl(admin, tablePath);
+
+ // Check once per table whether this is a PK table in full
(snapshot) mode
+ boolean isPrimaryKeyTable =
+ tableEntry.getValue().values().stream()
+ .flatMap(List::stream)
+ .findFirst()
+ .map(info -> info.hasPrimaryKey)
+ .orElse(false);
+ boolean readSnapshot =
+ isPrimaryKeyTable && offsetsInitializer instanceof
SnapshotOffsetsInitializer;
+
+ for (Map.Entry<String, List<TableBucketInfo>> partitionEntry :
+ tableEntry.getValue().entrySet()) {
+ String partitionName = partitionEntry.getKey();
+ List<TableBucketInfo> bucketInfos = partitionEntry.getValue();
+
+ if (readSnapshot) {
+ newSplits.addAll(
+ initHybridSnapshotLogSplits(
+ tablePath, partitionName, bucketInfos,
retriever));
+ } else {
+ newSplits.addAll(
+ initLogTableSplits(tablePath, partitionName,
bucketInfos, retriever));
+ }
+ }
+ }
+ return newSplits;
+ }
+
+ /**
+ * Creates splits for primary key table buckets in "full" startup mode.
Retrieves KV snapshots
+ * and creates {@link FlussHybridSnapshotLogSplit} for buckets with a
snapshot, and falls back
+ * to {@link FlussLogSplit} for buckets without a snapshot.
+ */
+ private List<FlussSplitBase> initHybridSnapshotLogSplits(
+ TablePath tablePath,
+ @Nullable String partitionName,
+ List<TableBucketInfo> bucketInfos,
+ OffsetsInitializer.BucketOffsetsRetriever retriever)
+ throws Exception {
+ List<FlussSplitBase> splits = new ArrayList<>();
+
+ // Get KV snapshots for this table/partition
+ KvSnapshots kvSnapshots =
Review Comment:
Could we acquire and checkpoint a KV snapshot lease before creating these
hybrid splits? The split stores a concrete snapshot ID, but the Fluss server
retains only a bounded number of completed snapshots, so an unleased snapshot
may be pruned while a slow full scan is still running or before the job
restores from a checkpoint/savepoint. I reproduced this with
`kv.snapshot.num-retained=1`: after the enumerator checkpointed snapshot 0 and
two newer snapshots were created, `createBatchScanner(tableBucket, snapshotId)`
failed with `KvSnapshotNotExistException`; acquiring a lease made the same test
pass. The native Fluss source persists its lease ID and releases snapshots only
after their consumption is checkpointed. Could we implement the corresponding
lease lifecycle here and add this recovery test?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]