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

fcsaky pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-connector-kudu.git


The following commit(s) were added to refs/heads/main by this push:
     new b823ed6  [FLINK-36855] Source API implementation
b823ed6 is described below

commit b823ed696e74b1efadc54c0cdbffc5c989aba30e
Author: martongreber <[email protected]>
AuthorDate: Fri Mar 14 15:52:27 2025 +0100

    [FLINK-36855] Source API implementation
---
 flink-connector-kudu/pom.xml                       |  17 ++
 .../kudu/connector/reader/KuduReaderConfig.java    | 115 ++++++++-
 .../flink/connector/kudu/source/KuduSource.java    | 133 +++++++++++
 .../connector/kudu/source/KuduSourceBuilder.java   |  80 +++++++
 .../source/enumerator/KuduSourceEnumerator.java    | 260 +++++++++++++++++++++
 .../enumerator/KuduSourceEnumeratorState.java      |  57 +++++
 .../KuduSourceEnumeratorStateSerializer.java       |  96 ++++++++
 .../kudu/source/reader/KuduRecordEmitter.java      |  43 ++++
 .../kudu/source/reader/KuduSourceReader.java       |  73 ++++++
 .../kudu/source/reader/KuduSourceSplitReader.java  | 105 +++++++++
 .../kudu/source/split/KuduSourceSplit.java         |  70 ++++++
 .../source/split/KuduSourceSplitSerializer.java    |  54 +++++
 .../kudu/source/split/SplitFinishedEvent.java      |  42 ++++
 .../kudu/source/utils/KuduSourceUtils.java         |  49 ++++
 .../kudu/source/utils/KuduSplitGenerator.java      | 122 ++++++++++
 .../connector/kudu/connector/KuduTestBase.java     |   9 +-
 .../connector/kudu/source/KuduSourceITCase.java    | 132 +++++++++++
 .../connector/kudu/source/KuduSourceTest.java      |  81 +++++++
 .../connector/kudu/source/KuduSourceTestBase.java  | 126 ++++++++++
 .../KuduSourceEnumeratorStateSerializerTest.java   |  85 +++++++
 .../enumerator/KuduSourceEnumeratorTest.java       | 138 +++++++++++
 .../source/enumerator/KuduSplitGeneratorTest.java  | 154 ++++++++++++
 .../source/reader/KuduSourceSplitReaderTest.java   |  80 +++++++
 .../split/KuduSourceSplitSerializerTest.java       |  60 +++++
 pom.xml                                            |  32 +++
 25 files changed, 2205 insertions(+), 8 deletions(-)

diff --git a/flink-connector-kudu/pom.xml b/flink-connector-kudu/pom.xml
index 22817ff..6be5785 100644
--- a/flink-connector-kudu/pom.xml
+++ b/flink-connector-kudu/pom.xml
@@ -39,6 +39,11 @@ under the License.
                        <artifactId>flink-clients</artifactId>
                </dependency>
 
+               <dependency>
+                       <groupId>org.apache.flink</groupId>
+                       <artifactId>flink-connector-base</artifactId>
+               </dependency>
+
                <dependency>
                        <groupId>org.apache.flink</groupId>
                        <artifactId>flink-streaming-java</artifactId>
@@ -64,11 +69,23 @@ under the License.
                        <artifactId>flink-table-runtime</artifactId>
                </dependency>
 
+               <dependency>
+                       <groupId>org.apache.flink</groupId>
+                       <artifactId>flink-test-utils</artifactId>
+                       <scope>test</scope>
+               </dependency>
+
                <dependency>
                        <groupId>org.apache.kudu</groupId>
                        <artifactId>kudu-client</artifactId>
                </dependency>
 
+               <dependency>
+                       <groupId>org.apache.flink</groupId>
+                       <artifactId>flink-connector-test-utils</artifactId>
+                       <scope>test</scope>
+               </dependency>
+
                <dependency>
                        <groupId>org.apache.kudu</groupId>
                        <artifactId>kudu-test-utils</artifactId>
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/connector/reader/KuduReaderConfig.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/connector/reader/KuduReaderConfig.java
index 71d56de..59e0bc6 100644
--- 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/connector/reader/KuduReaderConfig.java
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/connector/reader/KuduReaderConfig.java
@@ -21,6 +21,7 @@ import org.apache.flink.annotation.PublicEvolving;
 import org.apache.flink.connector.kudu.format.KuduRowInputFormat;
 
 import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.kudu.client.ReplicaSelection;
 
 import java.io.Serializable;
 
@@ -35,11 +36,34 @@ public class KuduReaderConfig implements Serializable {
 
     private final String masters;
     private final int rowLimit;
-
-    private KuduReaderConfig(String masters, int rowLimit) {
+    private final long splitSizeBytes;
+    private final int batchSizeBytes;
+    private final long scanRequestTimeout;
+    private final boolean prefetching;
+    private final long keepAlivePeriodMs;
+    private final ReplicaSelection replicaSelection;
+
+    private KuduReaderConfig(
+            String masters,
+            int rowLimit,
+            long splitSizeBytes,
+            int batchSizeBytes,
+            long scanRequestTimeout,
+            boolean prefetching,
+            long keepAlivePeriodMs,
+            ReplicaSelection replicaSelection) {
 
         this.masters = checkNotNull(masters, "Kudu masters cannot be null");
         this.rowLimit = checkNotNull(rowLimit, "Kudu rowLimit cannot be null");
+        this.splitSizeBytes = checkNotNull(splitSizeBytes, "Kudu split size 
cannot be null");
+        this.batchSizeBytes = checkNotNull(batchSizeBytes, "Kudu batch size 
cannot be null");
+        this.scanRequestTimeout =
+                checkNotNull(scanRequestTimeout, "Kudu scan request timeout 
cannot be null");
+        this.prefetching = checkNotNull(prefetching, "Kudu prefetching cannot 
be null");
+        this.keepAlivePeriodMs =
+                checkNotNull(keepAlivePeriodMs, "Kudu keep alive period ms 
cannot be null");
+        this.replicaSelection =
+                checkNotNull(replicaSelection, "Kudu replica selection cannot 
be null");
     }
 
     public String getMasters() {
@@ -50,26 +74,69 @@ public class KuduReaderConfig implements Serializable {
         return rowLimit;
     }
 
+    public long getSplitSizeBytes() {
+        return splitSizeBytes;
+    }
+
+    public int getBatchSizeBytes() {
+        return batchSizeBytes;
+    }
+
+    public long getScanRequestTimeout() {
+        return scanRequestTimeout;
+    }
+
+    public boolean isPrefetching() {
+        return prefetching;
+    }
+
+    public long getKeepAlivePeriodMs() {
+        return keepAlivePeriodMs;
+    }
+
+    public ReplicaSelection getReplicaSelection() {
+        return replicaSelection;
+    }
+
     @Override
     public String toString() {
         return new ToStringBuilder(this)
                 .append("masters", masters)
                 .append("rowLimit", rowLimit)
+                .append("splitSizeBytes", splitSizeBytes)
+                .append("batchSizeBytes", batchSizeBytes)
+                .append("scanRequestTimeout", scanRequestTimeout)
+                .append("prefetching", prefetching)
+                .append("keepAlivePeriodMs", keepAlivePeriodMs)
+                .append("replicaSelection", replicaSelection)
                 .toString();
     }
 
     /** Builder for the {@link KuduReaderConfig}. */
     public static class Builder {
-        private static final int DEFAULT_ROW_LIMIT = 0;
+        // Reference from AbstractKuduScannerBuilder limit Long.MAX_VALUE
+        private static final int DEFAULT_ROW_LIMIT = Integer.MAX_VALUE;
 
         private final String masters;
         private final int rowLimit;
+        // Reference from KuduScanTokenBuilder splitSizeBytes 
DEFAULT_SPLIT_SIZE_BYTES -1
+        private long splitSizeBytes = -1;
+        // Reference from BackupOptions DefaultScanBatchSize 1024 * 1024 * 20
+        private int batchSizeBytes = 1024 * 1024 * 20; // 20 MiB
+        // Reference from AsyncKuduClient DEFAULT_OPERATION_TIMEOUT_MS 30000
+        private long scanRequestTimeout = 30000;
+        // Reference from BackupOptions DefaultScanPrefetching false
+        private boolean prefetching = false;
+        // Reference from AsyncKuduClient DEFAULT_KEEP_ALIVE_PERIOD_MS 15000
+        private long keepAlivePeriodMs = 15000;
+        // Reference from BackupOptions DefaultScanLeaderOnly false
+        private ReplicaSelection replicaSelection = 
ReplicaSelection.CLOSEST_REPLICA;
 
         private Builder(String masters) {
             this(masters, DEFAULT_ROW_LIMIT);
         }
 
-        private Builder(String masters, Integer rowLimit) {
+        private Builder(String masters, int rowLimit) {
             this.masters = masters;
             this.rowLimit = rowLimit;
         }
@@ -82,8 +149,46 @@ public class KuduReaderConfig implements Serializable {
             return new Builder(masters, rowLimit);
         }
 
+        public Builder setSplitSizeBytes(long splitSizeBytes) {
+            this.splitSizeBytes = splitSizeBytes;
+            return this;
+        }
+
+        public Builder setBatchSizeBytes(int batchSizeBytes) {
+            this.batchSizeBytes = batchSizeBytes;
+            return this;
+        }
+
+        public Builder setScanRequestTimeout(long scanRequestTimeout) {
+            this.scanRequestTimeout = scanRequestTimeout;
+            return this;
+        }
+
+        public Builder setPrefetching(boolean prefetching) {
+            this.prefetching = prefetching;
+            return this;
+        }
+
+        public Builder setKeepAlivePeriodMs(long keepAlivePeriodMs) {
+            this.keepAlivePeriodMs = keepAlivePeriodMs;
+            return this;
+        }
+
+        public Builder setReplicaSelection(ReplicaSelection replicaSelection) {
+            this.replicaSelection = replicaSelection;
+            return this;
+        }
+
         public KuduReaderConfig build() {
-            return new KuduReaderConfig(masters, rowLimit);
+            return new KuduReaderConfig(
+                    masters,
+                    rowLimit,
+                    splitSizeBytes,
+                    batchSizeBytes,
+                    scanRequestTimeout,
+                    prefetching,
+                    keepAlivePeriodMs,
+                    replicaSelection);
         }
     }
 }
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/KuduSource.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/KuduSource.java
new file mode 100644
index 0000000..9e5d14b
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/KuduSource.java
@@ -0,0 +1,133 @@
+/*
+ * 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.connector.kudu.source;
+
+import org.apache.flink.annotation.PublicEvolving;
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.api.connector.source.Source;
+import org.apache.flink.api.connector.source.SourceReader;
+import org.apache.flink.api.connector.source.SourceReaderContext;
+import org.apache.flink.api.connector.source.SplitEnumerator;
+import org.apache.flink.api.connector.source.SplitEnumeratorContext;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.connector.kudu.connector.KuduTableInfo;
+import org.apache.flink.connector.kudu.connector.converter.RowResultConverter;
+import org.apache.flink.connector.kudu.connector.reader.KuduReaderConfig;
+import org.apache.flink.connector.kudu.source.enumerator.KuduSourceEnumerator;
+import 
org.apache.flink.connector.kudu.source.enumerator.KuduSourceEnumeratorState;
+import 
org.apache.flink.connector.kudu.source.enumerator.KuduSourceEnumeratorStateSerializer;
+import org.apache.flink.connector.kudu.source.reader.KuduSourceReader;
+import org.apache.flink.connector.kudu.source.reader.KuduSourceSplitReader;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplitSerializer;
+import org.apache.flink.core.io.SimpleVersionedSerializer;
+
+import java.time.Duration;
+
+/**
+ * A Flink {@link Source} for reading data from Apache Kudu. It supports both 
bounded and continuous
+ * unbounded modes, allowing it to function as a snapshot reader or as a 
CDC-like source that
+ * captures ongoing changes.
+ *
+ * <ul>
+ *   <li>If {@code Boundedness.BOUNDED} is set, the source reads a snapshot of 
the table at a given
+ *       point in time and emits only records from this fixed dataset.
+ *   <li>If {@code Boundedness.CONTINUOUS_UNBOUNDED} is set, the source 
performs differential scans
+ *       at configured intervals to continuously capture changes.
+ * </ul>
+ *
+ * <p>Key components:
+ *
+ * <ul>
+ *   <li>{@link KuduReaderConfig} - Configures the Kudu connection, including 
master addresses.
+ *   <li>{@link KuduTableInfo} - Specifies the target Kudu table, including 
its name and schema
+ *       details.
+ *   <li>{@link Boundedness} - Specifies whether the source behaves in bounded 
or unbounded mode.
+ *   <li>{@link Duration} - Defines the polling interval, i.e. the time 
between consecutive scans.
+ *   <li>{@link RowResultConverter} - Converts Kudu's {@code RowResult} into 
the desired output type
+ *       {@code OUT}.
+ * </ul>
+ *
+ * @param <OUT> The type of the records produced by this source.
+ */
+@PublicEvolving
+public class KuduSource<OUT> implements Source<OUT, KuduSourceSplit, 
KuduSourceEnumeratorState> {
+    private final KuduReaderConfig readerConfig;
+    private final KuduTableInfo tableInfo;
+    private final Boundedness boundedness;
+    private final Duration discoveryPeriod;
+    private final RowResultConverter<OUT> rowResultConverter;
+
+    private final Configuration configuration;
+
+    KuduSource(
+            KuduReaderConfig readerConfig,
+            KuduTableInfo tableInfo,
+            Boundedness boundedness,
+            Duration discoveryPeriod,
+            RowResultConverter<OUT> rowResultConverter) {
+        this.tableInfo = tableInfo;
+        this.readerConfig = readerConfig;
+        this.boundedness = boundedness;
+        this.discoveryPeriod = discoveryPeriod;
+        this.rowResultConverter = rowResultConverter;
+        this.configuration = new Configuration();
+    }
+
+    @Override
+    public Boundedness getBoundedness() {
+        return boundedness;
+    }
+
+    @Override
+    public SplitEnumerator<KuduSourceSplit, KuduSourceEnumeratorState> 
createEnumerator(
+            SplitEnumeratorContext<KuduSourceSplit> enumContext) {
+        return new KuduSourceEnumerator(
+                tableInfo, readerConfig, boundedness, discoveryPeriod, 
enumContext);
+    }
+
+    @Override
+    public SplitEnumerator<KuduSourceSplit, KuduSourceEnumeratorState> 
restoreEnumerator(
+            SplitEnumeratorContext<KuduSourceSplit> enumContext,
+            KuduSourceEnumeratorState checkpoint)
+            throws Exception {
+        return new KuduSourceEnumerator(
+                tableInfo, readerConfig, boundedness, discoveryPeriod, 
enumContext, checkpoint);
+    }
+
+    @Override
+    public SimpleVersionedSerializer<KuduSourceSplit> getSplitSerializer() {
+        return new KuduSourceSplitSerializer();
+    }
+
+    @Override
+    public SimpleVersionedSerializer<KuduSourceEnumeratorState>
+            getEnumeratorCheckpointSerializer() {
+        return new KuduSourceEnumeratorStateSerializer();
+    }
+
+    @Override
+    public SourceReader<OUT, KuduSourceSplit> createReader(SourceReaderContext 
readerContext)
+            throws Exception {
+        return new KuduSourceReader<>(
+                () -> new KuduSourceSplitReader(readerConfig),
+                configuration,
+                readerContext,
+                rowResultConverter);
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/KuduSourceBuilder.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/KuduSourceBuilder.java
new file mode 100644
index 0000000..0d1eeca
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/KuduSourceBuilder.java
@@ -0,0 +1,80 @@
+/*
+ * 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.connector.kudu.source;
+
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.connector.kudu.connector.KuduTableInfo;
+import org.apache.flink.connector.kudu.connector.converter.RowResultConverter;
+import org.apache.flink.connector.kudu.connector.reader.KuduReaderConfig;
+
+import java.time.Duration;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/**
+ * Builder to construct {@link KuduSource}.
+ *
+ * @param <OUT> type of the output records read from Kudu
+ */
+public class KuduSourceBuilder<OUT> {
+    private KuduReaderConfig readerConfig;
+    private KuduTableInfo tableInfo;
+    private Boundedness boundedness = Boundedness.BOUNDED;
+    private Duration discoveryPeriod = null;
+    private RowResultConverter<OUT> rowResultConverter;
+
+    public KuduSourceBuilder<OUT> setTableInfo(KuduTableInfo tableInfo) {
+        this.tableInfo = tableInfo;
+        return this;
+    }
+
+    public KuduSourceBuilder<OUT> setReaderConfig(KuduReaderConfig 
readerConfig) {
+        this.readerConfig = readerConfig;
+        return this;
+    }
+
+    public KuduSourceBuilder<OUT> setRowResultConverter(
+            RowResultConverter<OUT> rowResultConverter) {
+        this.rowResultConverter = rowResultConverter;
+        return this;
+    }
+
+    public KuduSourceBuilder<OUT> setBoundedness(Boundedness boundedness) {
+        this.boundedness = boundedness;
+        return this;
+    }
+
+    public KuduSourceBuilder<OUT> setDiscoveryPeriod(Duration discoveryPeriod) 
{
+        this.discoveryPeriod = discoveryPeriod;
+        return this;
+    }
+
+    public KuduSource<OUT> build() {
+        checkNotNull(tableInfo, "Table info must be provided.");
+        checkNotNull(readerConfig, "Reader config must be provided.");
+        checkNotNull(rowResultConverter, "RowResultConverter must be 
provided.");
+        if (boundedness == Boundedness.CONTINUOUS_UNBOUNDED) {
+            checkNotNull(
+                    discoveryPeriod,
+                    "Discovery period must be provided for 
CONTINUOUS_UNBOUNDED mode.");
+        }
+
+        return new KuduSource<>(
+                readerConfig, tableInfo, boundedness, discoveryPeriod, 
rowResultConverter);
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumerator.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumerator.java
new file mode 100644
index 0000000..dbd6edc
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumerator.java
@@ -0,0 +1,260 @@
+/*
+ * 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.connector.kudu.source.enumerator;
+
+import org.apache.flink.api.connector.source.Boundedness;
+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.connector.kudu.connector.KuduTableInfo;
+import org.apache.flink.connector.kudu.connector.reader.KuduReaderConfig;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+import org.apache.flink.connector.kudu.source.split.SplitFinishedEvent;
+import org.apache.flink.connector.kudu.source.utils.KuduSourceUtils;
+import org.apache.flink.connector.kudu.source.utils.KuduSplitGenerator;
+import org.apache.flink.util.FlinkRuntimeException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/**
+ * The Kudu source enumerator is responsible for discovering and assigning 
splits.
+ *
+ * <ul>
+ *   <li>If {@code Boundedness.BOUNDED} is set, the enumerator generates 
splits corresponding to the
+ *       current snapshot time and emits records only for this bounded set.
+ *   <li>If {@code Boundedness.CONTINUOUS_UNBOUNDED} is set, the enumerator 
follows a CDC-like
+ *       approach as described below.
+ * </ul>
+ *
+ * <p>To provide CDC-like functionality, the enumeration works as follows: 
Initially, we perform a
+ * snapshot read of the table and mark the snapshot time as t0. From that 
point onward, we perform
+ * differential scans in the time intervals t0 - t1, t1 - t2, and so on.
+ *
+ * <p>This approach means that new splits can only be enumerated once the 
current time range is
+ * fully processed.
+ *
+ * <p>The process is controlled as follows:
+ *
+ * <ul>
+ *   <li>Once a set of splits is enumerated for a time range, we track:
+ *       <ul>
+ *         <li><b>Unassigned splits</b>: Discovered but not yet assigned to 
readers.
+ *         <li><b>Pending splits</b>: Assigned but not yet fully processed.
+ *       </ul>
+ *   <li>A new set of splits is generated only when there are no remaining 
unassigned or pending
+ *       splits.
+ * </ul>
+ */
+public class KuduSourceEnumerator
+        implements SplitEnumerator<KuduSourceSplit, KuduSourceEnumeratorState> 
{
+    private static final Logger LOG = 
LoggerFactory.getLogger(KuduSourceEnumerator.class);
+
+    private final SplitEnumeratorContext<KuduSourceSplit> context;
+    private final List<Integer> readersAwaitingSplit;
+    private final KuduSplitGenerator splitGenerator;
+    private final Boundedness boundedness;
+    private final Duration discoveryInterval;
+
+    private long lastEndTimestamp;
+    private final List<KuduSourceSplit> unassigned;
+    private final List<KuduSourceSplit> pending;
+
+    public KuduSourceEnumerator(
+            KuduTableInfo tableInfo,
+            KuduReaderConfig readerConfig,
+            Boundedness boundedness,
+            Duration discoveryInterval,
+            SplitEnumeratorContext<KuduSourceSplit> context) {
+        this(
+                tableInfo,
+                readerConfig,
+                boundedness,
+                discoveryInterval,
+                context,
+                KuduSourceEnumeratorState.empty());
+    }
+
+    public KuduSourceEnumerator(
+            KuduTableInfo tableInfo,
+            KuduReaderConfig readerConfig,
+            Boundedness boundedness,
+            Duration discoveryInterval,
+            SplitEnumeratorContext<KuduSourceSplit> context,
+            KuduSourceEnumeratorState enumState) {
+        this.boundedness = checkNotNull(boundedness);
+        this.discoveryInterval = discoveryInterval;
+        this.context = checkNotNull(context);
+        this.readersAwaitingSplit = new ArrayList<>();
+        this.unassigned = enumState.getUnassigned();
+        this.pending = enumState.getPending();
+        this.splitGenerator = new KuduSplitGenerator(readerConfig, tableInfo);
+        this.lastEndTimestamp = enumState.getLastEndTimestamp();
+
+        validateConfiguration();
+    }
+
+    private void validateConfiguration() {
+        if (boundedness == Boundedness.CONTINUOUS_UNBOUNDED && 
discoveryInterval == null) {
+            throw new IllegalArgumentException(
+                    "discoveryInterval must be set for CONTINUOUS_UNBOUNDED 
mode.");
+        }
+    }
+
+    @Override
+    public void start() {
+        if (boundedness == Boundedness.CONTINUOUS_UNBOUNDED) {
+            context.callAsync(
+                    this::enumerateNewSplits, this::assignSplits, 0, 
discoveryInterval.toMillis());
+        } else if (boundedness == Boundedness.BOUNDED) {
+            context.callAsync(this::enumerateNewSplits, this::assignSplits);
+        }
+    }
+
+    @Override
+    public void handleSplitRequest(int subtaskId, @Nullable String 
requesterHostname) {
+        readersAwaitingSplit.add(subtaskId);
+        assignSplitsToReaders();
+    }
+
+    @Override
+    public void addSplitsBack(List<KuduSourceSplit> splits, int subtaskId) {
+        LOG.debug("Adding splits back: {}", splits);
+        pending.removeAll(splits);
+        unassigned.addAll(splits);
+        if (context.registeredReaders().containsKey(subtaskId)) {
+            readersAwaitingSplit.add(subtaskId);
+        }
+        assignSplitsToReaders();
+    }
+
+    @Override
+    public void addReader(int subtaskId) {
+        // The source is purely lazy-pull-based, nothing to do upon 
registration
+    }
+
+    @Override
+    public KuduSourceEnumeratorState snapshotState(long checkpointId) throws 
Exception {
+        return new KuduSourceEnumeratorState(lastEndTimestamp, unassigned, 
pending);
+    }
+
+    @Override
+    public void close() throws IOException {
+        try {
+            splitGenerator.close();
+        } catch (Exception e) {
+            throw new IOException("Error closing split generator", e);
+        }
+    }
+
+    @Override
+    public void handleSourceEvent(int subtaskId, SourceEvent sourceEvent) {
+        if (sourceEvent instanceof SplitFinishedEvent) {
+            SplitFinishedEvent splitFinishedEvent = (SplitFinishedEvent) 
sourceEvent;
+            LOG.debug(
+                    "Received SplitFinishedEvent from subtask {} for splits: 
{}",
+                    subtaskId,
+                    splitFinishedEvent.getFinishedSplits());
+            pending.removeAll(splitFinishedEvent.getFinishedSplits());
+            readersAwaitingSplit.add(subtaskId);
+            assignSplitsToReaders();
+        }
+    }
+
+    // This function is invoked repeatedly according to this.period if there 
are no outstanding
+    // splits.
+    // Outstanding meaning that there are no pending splits, and no enumerated 
but not assigned
+    // splits for the
+    // current period.
+    private List<KuduSourceSplit> enumerateNewSplits() {
+        if (!shouldEnumerateNewSplits()) {
+            return null;
+        }
+        List<KuduSourceSplit> newSplits;
+
+        if (isFirstSplitGeneration()) {
+            lastEndTimestamp = KuduSourceUtils.getCurrentHybridTime();
+            newSplits = 
splitGenerator.generateFullScanSplits(lastEndTimestamp);
+        } else {
+            long startHT = lastEndTimestamp;
+            long endHT = KuduSourceUtils.getCurrentHybridTime();
+            newSplits = splitGenerator.generateIncrementalSplits(startHT, 
endHT);
+            lastEndTimestamp = endHT;
+        }
+
+        return newSplits;
+    }
+
+    private void assignSplits(List<KuduSourceSplit> splits, Throwable error) {
+        if (error != null) {
+            throw new FlinkRuntimeException(
+                    "Failed to enumerate Kudu splits, shutting down job.", 
error);
+        }
+
+        if (splits != null) {
+            unassigned.addAll(splits);
+        }
+        assignSplitsToReaders();
+    }
+
+    private void assignSplitsToReaders() {
+        final Iterator<Integer> awaitingSubtasks = 
readersAwaitingSplit.iterator();
+
+        while (awaitingSubtasks.hasNext()) {
+            final int awaitingSubtask = awaitingSubtasks.next();
+
+            // If the reader that requested another split has failed in the 
meantime, remove
+            // it from the list of waiting readers
+            if (!context.registeredReaders().containsKey(awaitingSubtask)) {
+                awaitingSubtasks.remove();
+                continue;
+            }
+
+            final KuduSourceSplit nextSplit = 
KuduSourceUtils.getNextSplit(unassigned);
+            if (nextSplit != null) {
+                context.assignSplit(nextSplit, awaitingSubtask);
+                awaitingSubtasks.remove();
+                pending.add(nextSplit);
+            } else {
+                if (boundedness == Boundedness.BOUNDED) {
+                    awaitingSubtasks.remove();
+                    context.signalNoMoreSplits(awaitingSubtask);
+                }
+                break;
+            }
+        }
+    }
+
+    private Boolean shouldEnumerateNewSplits() {
+        return pending.isEmpty() && unassigned.isEmpty();
+    }
+
+    private Boolean isFirstSplitGeneration() {
+        return lastEndTimestamp == -1L;
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorState.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorState.java
new file mode 100644
index 0000000..b784ae9
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorState.java
@@ -0,0 +1,57 @@
+/*
+ * 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.connector.kudu.source.enumerator;
+
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/** The class storing state information for {@link KuduSourceEnumerator}. */
+public class KuduSourceEnumeratorState implements Serializable {
+    private static final long serialVersionUID = 1L;
+    private final long lastEndTimestamp;
+    private final List<KuduSourceSplit> unassigned;
+    private final List<KuduSourceSplit> pending;
+
+    public KuduSourceEnumeratorState(
+            long lastEndTimestamp,
+            List<KuduSourceSplit> unassigned,
+            List<KuduSourceSplit> pending) {
+        this.lastEndTimestamp = lastEndTimestamp;
+        this.unassigned = unassigned;
+        this.pending = pending;
+    }
+
+    public static KuduSourceEnumeratorState empty() {
+        return new KuduSourceEnumeratorState(-1L, new ArrayList<>(), new 
ArrayList<>());
+    }
+
+    long getLastEndTimestamp() {
+        return lastEndTimestamp;
+    }
+
+    List<KuduSourceSplit> getUnassigned() {
+        return unassigned;
+    }
+
+    List<KuduSourceSplit> getPending() {
+        return pending;
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorStateSerializer.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorStateSerializer.java
new file mode 100644
index 0000000..a34ca40
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorStateSerializer.java
@@ -0,0 +1,96 @@
+/*
+ * 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.connector.kudu.source.enumerator;
+
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplitSerializer;
+import org.apache.flink.core.io.SimpleVersionedSerializer;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/** The class that serializes and deserializes {@link 
KuduSourceEnumeratorState}. */
+public class KuduSourceEnumeratorStateSerializer
+        implements SimpleVersionedSerializer<KuduSourceEnumeratorState> {
+
+    private static final int CURRENT_VERSION = 1;
+    private final KuduSourceSplitSerializer splitSerializer = new 
KuduSourceSplitSerializer();
+
+    @Override
+    public int getVersion() {
+        return CURRENT_VERSION;
+    }
+
+    @Override
+    public byte[] serialize(KuduSourceEnumeratorState state) throws 
IOException {
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                DataOutputStream out = new DataOutputStream(baos)) {
+
+            out.writeLong(state.getLastEndTimestamp());
+            serializeSplits(out, state.getUnassigned());
+            serializeSplits(out, state.getPending());
+
+            return baos.toByteArray();
+        }
+    }
+
+    @Override
+    public KuduSourceEnumeratorState deserialize(int version, byte[] 
serialized)
+            throws IOException {
+        if (version != CURRENT_VERSION) {
+            throw new IllegalArgumentException("Unsupported version: " + 
version);
+        }
+
+        try (ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
+                DataInputStream in = new DataInputStream(bais)) {
+
+            long lastEndTimestamp = in.readLong();
+            List<KuduSourceSplit> unassigned = deserializeSplits(in);
+            List<KuduSourceSplit> pending = deserializeSplits(in);
+
+            return new KuduSourceEnumeratorState(lastEndTimestamp, unassigned, 
pending);
+        }
+    }
+
+    private void serializeSplits(DataOutputStream out, List<KuduSourceSplit> 
splits)
+            throws IOException {
+        out.writeInt(splits.size());
+        for (KuduSourceSplit split : splits) {
+            byte[] splitBytes = splitSerializer.serialize(split);
+            out.writeInt(splitBytes.length);
+            out.write(splitBytes);
+        }
+    }
+
+    private List<KuduSourceSplit> deserializeSplits(DataInputStream in) throws 
IOException {
+        int size = in.readInt();
+        List<KuduSourceSplit> splits = new ArrayList<>(size);
+        for (int i = 0; i < size; i++) {
+            int length = in.readInt();
+            byte[] splitBytes = new byte[length];
+            in.readFully(splitBytes);
+            splits.add(splitSerializer.deserialize(CURRENT_VERSION, 
splitBytes));
+        }
+        return splits;
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/reader/KuduRecordEmitter.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/reader/KuduRecordEmitter.java
new file mode 100644
index 0000000..f4f5d54
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/reader/KuduRecordEmitter.java
@@ -0,0 +1,43 @@
+/*
+ * 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.connector.kudu.source.reader;
+
+import org.apache.flink.api.connector.source.SourceOutput;
+import org.apache.flink.connector.base.source.reader.RecordEmitter;
+import org.apache.flink.connector.kudu.connector.converter.RowResultConverter;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+
+import org.apache.kudu.client.RowResult;
+
+/**
+ * The Kudu record emitter.
+ *
+ * @param <T> The type of the record.
+ */
+public class KuduRecordEmitter<T> implements RecordEmitter<RowResult, T, 
KuduSourceSplit> {
+    private final RowResultConverter<T> rowResultConverter;
+
+    public KuduRecordEmitter(RowResultConverter<T> rowResultConverter) {
+        this.rowResultConverter = rowResultConverter;
+    }
+
+    @Override
+    public void emitRecord(RowResult element, SourceOutput<T> output, 
KuduSourceSplit splitState) {
+        output.collect(rowResultConverter.convert(element));
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/reader/KuduSourceReader.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/reader/KuduSourceReader.java
new file mode 100644
index 0000000..9fd87d5
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/reader/KuduSourceReader.java
@@ -0,0 +1,73 @@
+/*
+ * 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.connector.kudu.source.reader;
+
+import org.apache.flink.api.connector.source.SourceReaderContext;
+import org.apache.flink.configuration.Configuration;
+import 
org.apache.flink.connector.base.source.reader.SingleThreadMultiplexSourceReaderBase;
+import org.apache.flink.connector.base.source.reader.splitreader.SplitReader;
+import org.apache.flink.connector.kudu.connector.converter.RowResultConverter;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+import org.apache.flink.connector.kudu.source.split.SplitFinishedEvent;
+
+import org.apache.kudu.client.RowResult;
+
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * The Kudu source reader.
+ *
+ * @param <OUT> the type of records read from the source.
+ */
+public class KuduSourceReader<OUT>
+        extends SingleThreadMultiplexSourceReaderBase<
+                RowResult, OUT, KuduSourceSplit, KuduSourceSplit> {
+
+    public KuduSourceReader(
+            Supplier<SplitReader<RowResult, KuduSourceSplit>> 
splitReaderSupplier,
+            Configuration config,
+            SourceReaderContext context,
+            RowResultConverter<OUT> rowResultConverter) {
+        super(splitReaderSupplier, new 
KuduRecordEmitter<>(rowResultConverter), config, context);
+    }
+
+    @Override
+    protected void onSplitFinished(Map<String, KuduSourceSplit> 
finishedSplits) {
+        context.sendSourceEventToCoordinator(
+                new SplitFinishedEvent(new 
ArrayList<>(finishedSplits.values())));
+    }
+
+    @Override
+    public void start() {
+        if (getNumberOfCurrentlyAssignedSplits() == 0) {
+            context.sendSplitRequest();
+        }
+    }
+
+    @Override
+    protected KuduSourceSplit initializedState(KuduSourceSplit split) {
+        return split;
+    }
+
+    @Override
+    protected KuduSourceSplit toSplitType(String splitId, KuduSourceSplit 
splitState) {
+        return splitState;
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/reader/KuduSourceSplitReader.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/reader/KuduSourceSplitReader.java
new file mode 100644
index 0000000..f7bf5d5
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/reader/KuduSourceSplitReader.java
@@ -0,0 +1,105 @@
+/*
+ * 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.connector.kudu.source.reader;
+
+import org.apache.flink.connector.base.source.reader.RecordsBySplits;
+import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
+import org.apache.flink.connector.base.source.reader.splitreader.SplitReader;
+import org.apache.flink.connector.base.source.reader.splitreader.SplitsChange;
+import org.apache.flink.connector.kudu.connector.reader.KuduReaderConfig;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+import org.apache.flink.connector.kudu.source.utils.KuduSourceUtils;
+
+import org.apache.kudu.client.KuduClient;
+import org.apache.kudu.client.KuduScanToken;
+import org.apache.kudu.client.KuduScanner;
+import org.apache.kudu.client.RowResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** The Kudu source reader that reads data for corresponding splits. */
+public class KuduSourceSplitReader implements SplitReader<RowResult, 
KuduSourceSplit> {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(KuduSourceSplitReader.class);
+    private final KuduClient kuduClient;
+    private final List<KuduSourceSplit> splits;
+    private final AtomicBoolean wakeUpFlag = new AtomicBoolean(false);
+
+    public KuduSourceSplitReader(KuduReaderConfig readerConfig) {
+        this.kuduClient = new 
KuduClient.KuduClientBuilder(readerConfig.getMasters()).build();
+        this.splits = new ArrayList<>();
+    }
+
+    @Override
+    public RecordsWithSplitIds<RowResult> fetch() throws IOException {
+        wakeUpFlag.compareAndSet(true, false);
+
+        final KuduSourceSplit currentSplit = 
KuduSourceUtils.getNextSplit(splits);
+        if (currentSplit == null) {
+            return new RecordsBySplits.Builder<RowResult>().build();
+        }
+
+        byte[] serializedToken = currentSplit.getSerializedScanToken();
+        KuduScanner scanner = 
KuduScanToken.deserializeIntoScanner(serializedToken, kuduClient);
+        RecordsBySplits.Builder<RowResult> builder = new 
RecordsBySplits.Builder<>();
+
+        try {
+            while (scanner.hasMoreRows()) {
+                for (RowResult row : scanner.nextRows()) {
+                    if (wakeUpFlag.get()) {
+                        LOG.debug("Wakeup signal received inside row 
iteration, stopping fetch.");
+                        scanner.close(); // Close the scanner
+                        splits.add(currentSplit); // Put the split back
+                        return new RecordsBySplits.Builder<RowResult>()
+                                .build(); // Return empty result
+                    }
+                    builder.add(currentSplit.splitId(), row);
+                }
+            }
+            builder.addFinishedSplit(
+                    currentSplit.splitId()); // Mark split as completed only 
after the loop
+
+        } finally {
+            scanner.close(); // Ensure scanner is always closed
+        }
+
+        return builder.build();
+    }
+
+    @Override
+    public void handleSplitsChanges(SplitsChange<KuduSourceSplit> 
splitsChanges) {
+        LOG.debug("Handling split change {}", splitsChanges);
+        splits.addAll(splitsChanges.splits());
+    }
+
+    @Override
+    public void wakeUp() {
+        LOG.debug("Wakeup called, setting flag.");
+        wakeUpFlag.set(true);
+    }
+
+    @Override
+    public void close() throws Exception {
+        kuduClient.close();
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/split/KuduSourceSplit.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/split/KuduSourceSplit.java
new file mode 100644
index 0000000..a57d398
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/split/KuduSourceSplit.java
@@ -0,0 +1,70 @@
+/*
+ * 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.connector.kudu.source.split;
+
+import org.apache.flink.api.connector.source.SourceSplit;
+
+import org.apache.commons.codec.digest.DigestUtils;
+
+import java.io.Serializable;
+import java.util.Arrays;
+
+/** The Kudu source split that wraps a Kudu scan token. */
+public class KuduSourceSplit implements SourceSplit, Serializable {
+
+    /**
+     * Serialized byte[] is already compact and Flink-optimized, whereas the 
full KuduScanToken
+     * object might include unnecessary metadata or complex internal 
structures.
+     */
+    private final byte[] serializedScanToken;
+
+    public KuduSourceSplit(byte[] serializedScanToken) {
+        this.serializedScanToken = serializedScanToken;
+    }
+
+    public byte[] getSerializedScanToken() {
+        return serializedScanToken;
+    }
+
+    @Override
+    public String splitId() {
+        return DigestUtils.sha256Hex(serializedScanToken);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null || getClass() != obj.getClass()) {
+            return false;
+        }
+        KuduSourceSplit that = (KuduSourceSplit) obj;
+        return Arrays.equals(serializedScanToken, that.serializedScanToken);
+    }
+
+    @Override
+    public int hashCode() {
+        return Arrays.hashCode(serializedScanToken);
+    }
+
+    @Override
+    public String toString() {
+        return "KuduSourceSplit{" + "splitId='" + splitId() + "'" + '}';
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/split/KuduSourceSplitSerializer.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/split/KuduSourceSplitSerializer.java
new file mode 100644
index 0000000..1dea4a6
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/split/KuduSourceSplitSerializer.java
@@ -0,0 +1,54 @@
+/*
+ * 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.connector.kudu.source.split;
+
+import org.apache.flink.core.io.SimpleVersionedSerializer;
+
+import java.io.IOException;
+
+/** The class that serializes and deserializes {@link KuduSourceSplit}. */
+public class KuduSourceSplitSerializer implements 
SimpleVersionedSerializer<KuduSourceSplit> {
+
+    private static final int CURRENT_VERSION = 1; // Versioning for future 
changes
+
+    @Override
+    public int getVersion() {
+        return CURRENT_VERSION;
+    }
+
+    @Override
+    public byte[] serialize(KuduSourceSplit obj) throws IOException {
+        if (obj == null || obj.getSerializedScanToken() == null) {
+            throw new IOException("KuduSourceSplit or serializedScanToken is 
null.");
+        }
+
+        return obj.getSerializedScanToken(); // Directly return the byte array
+    }
+
+    @Override
+    public KuduSourceSplit deserialize(int version, byte[] serialized) throws 
IOException {
+        if (version != CURRENT_VERSION) {
+            throw new IllegalArgumentException("Unsupported version: " + 
version);
+        }
+        if (serialized == null || serialized.length == 0) {
+            throw new IOException("Serialized data is empty or null.");
+        }
+
+        return new KuduSourceSplit(serialized); // Recreate the split with the 
byte array
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/split/SplitFinishedEvent.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/split/SplitFinishedEvent.java
new file mode 100644
index 0000000..6bb019b
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/split/SplitFinishedEvent.java
@@ -0,0 +1,42 @@
+/*
+ * 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.connector.kudu.source.split;
+
+import org.apache.flink.api.connector.source.SourceEvent;
+
+import java.util.List;
+
+/**
+ * A source event use to signal from {@link
+ * org.apache.flink.connector.kudu.source.reader.KuduSourceReader} to {@link
+ * org.apache.flink.connector.kudu.source.enumerator.KuduSourceEnumerator} the 
finished splits. This
+ * allows us to differentiate between enumerated but unassigned and pending 
splits in the
+ * enumerator.
+ */
+public class SplitFinishedEvent implements SourceEvent {
+
+    private final List<KuduSourceSplit> finishedSplits;
+
+    public SplitFinishedEvent(List<KuduSourceSplit> finishedSplits) {
+        this.finishedSplits = finishedSplits;
+    }
+
+    public List<KuduSourceSplit> getFinishedSplits() {
+        return finishedSplits;
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/utils/KuduSourceUtils.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/utils/KuduSourceUtils.java
new file mode 100644
index 0000000..2daa50d
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/utils/KuduSourceUtils.java
@@ -0,0 +1,49 @@
+/*
+ * 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.connector.kudu.source.utils;
+
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+
+import org.apache.kudu.util.HybridTimeUtil;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.concurrent.TimeUnit;
+
+/** Utility class for retrieving the next available Kudu source split. */
+public class KuduSourceUtils {
+
+    private KuduSourceUtils() {}
+
+    /** Retrieves and removes the next available split from the given 
collection. */
+    public static KuduSourceSplit getNextSplit(Collection<KuduSourceSplit> 
splits) {
+        if (splits == null || splits.isEmpty()) {
+            return null;
+        }
+        Iterator<KuduSourceSplit> iterator = splits.iterator();
+        KuduSourceSplit next = iterator.next();
+        iterator.remove();
+        return next;
+    }
+
+    public static long getCurrentHybridTime() {
+        return HybridTimeUtil.clockTimestampToHTTimestamp(
+                        System.currentTimeMillis(), TimeUnit.MILLISECONDS)
+                + 1;
+    }
+}
diff --git 
a/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/utils/KuduSplitGenerator.java
 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/utils/KuduSplitGenerator.java
new file mode 100644
index 0000000..a4baac4
--- /dev/null
+++ 
b/flink-connector-kudu/src/main/java/org/apache/flink/connector/kudu/source/utils/KuduSplitGenerator.java
@@ -0,0 +1,122 @@
+/*
+ * 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.connector.kudu.source.utils;
+
+import org.apache.flink.connector.kudu.connector.KuduTableInfo;
+import org.apache.flink.connector.kudu.connector.reader.KuduReaderConfig;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+
+import org.apache.kudu.client.AsyncKuduScanner;
+import org.apache.kudu.client.KuduClient;
+import org.apache.kudu.client.KuduException;
+import org.apache.kudu.client.KuduScanToken;
+import org.apache.kudu.client.KuduTable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The class responsible for producing scan tokens for given timestamps and 
returning them in the
+ * form of {@link KuduSourceSplit}.
+ */
+public class KuduSplitGenerator implements AutoCloseable {
+    private final KuduTableInfo tableInfo;
+    private final KuduClient kuduClient;
+    private final KuduReaderConfig readerConfig;
+
+    public KuduSplitGenerator(KuduReaderConfig readerConfig, KuduTableInfo 
tableInfo) {
+        this.tableInfo = tableInfo;
+        this.readerConfig = readerConfig;
+        this.kuduClient = new 
KuduClient.KuduClientBuilder(readerConfig.getMasters()).build();
+    }
+
+    public List<KuduSourceSplit> generateFullScanSplits(long 
snapshotTimestamp) {
+        if (snapshotTimestamp <= 0) {
+            throw new IllegalArgumentException(
+                    "Snapshot timestamp must be greater than 0, but was: " + 
snapshotTimestamp);
+        }
+        try {
+            List<KuduScanToken> tokens =
+                    obtainScanTokenBuilder(tableInfo.getName())
+                            .snapshotTimestampRaw(snapshotTimestamp)
+                            
.readMode(AsyncKuduScanner.ReadMode.READ_AT_SNAPSHOT)
+                            .build();
+            return serializeTokens(tokens);
+        } catch (Exception e) {
+            throw new RuntimeException("Error during full snapshot scan: " + 
e.getMessage(), e);
+        }
+    }
+
+    public List<KuduSourceSplit> generateIncrementalSplits(long startHT, long 
endHT) {
+        if (startHT <= 0 || endHT <= 0) {
+            throw new IllegalArgumentException(
+                    "Start and end timestamps must be greater than 0. Given 
startHT: "
+                            + startHT
+                            + ", endHT: "
+                            + endHT);
+        }
+
+        if (startHT >= endHT) {
+            throw new IllegalArgumentException(
+                    "Start timestamp must be less than end timestamp. Given 
startHT: "
+                            + startHT
+                            + ", endHT: "
+                            + endHT);
+        }
+
+        try {
+            List<KuduScanToken> tokens =
+                    
obtainScanTokenBuilder(tableInfo.getName()).diffScan(startHT, endHT).build();
+            return serializeTokens(tokens);
+        } catch (Exception e) {
+            throw new RuntimeException("Error during incremental diff scan: " 
+ e.getMessage(), e);
+        }
+    }
+
+    private List<KuduSourceSplit> serializeTokens(List<KuduScanToken> tokens) {
+        try {
+            List<KuduSourceSplit> splits = new ArrayList<>();
+            for (KuduScanToken token : tokens) {
+                splits.add(new KuduSourceSplit(token.serialize()));
+            }
+            return splits;
+        } catch (Exception e) {
+            throw new RuntimeException(
+                    "Error during source split serialization: " + 
e.getMessage(), e);
+        }
+    }
+
+    private KuduScanToken.KuduScanTokenBuilder obtainScanTokenBuilder(String 
tableName)
+            throws KuduException {
+        KuduTable table = kuduClient.openTable(tableName);
+        return kuduClient
+                .newScanTokenBuilder(table)
+                .limit(readerConfig.getRowLimit())
+                .setSplitSizeBytes(readerConfig.getSplitSizeBytes())
+                .batchSizeBytes(readerConfig.getBatchSizeBytes())
+                .scanRequestTimeout(readerConfig.getScanRequestTimeout())
+                .prefetching(readerConfig.isPrefetching())
+                .keepAlivePeriodMs(readerConfig.getKeepAlivePeriodMs())
+                .replicaSelection(readerConfig.getReplicaSelection());
+    }
+
+    @Override
+    public void close() throws Exception {
+        kuduClient.close();
+    }
+}
diff --git 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/connector/KuduTestBase.java
 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/connector/KuduTestBase.java
index 4ab1761..2b00efb 100644
--- 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/connector/KuduTestBase.java
+++ 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/connector/KuduTestBase.java
@@ -93,7 +93,9 @@ public class KuduTestBase {
                 new GenericContainer<>(DOCKER_IMAGE)
                         .withExposedPorts(KUDU_MASTER_PORT, 8051)
                         .withCommand("master")
-                        .withEnv("MASTER_ARGS", "--unlock_unsafe_flags")
+                        .withEnv(
+                                "MASTER_ARGS",
+                                "--unlock_unsafe_flags 
--time_source=system_unsync --use_hybrid_clock=true")
                         .withNetwork(network)
                         .withNetworkAliases("kudu-master");
         master.start();
@@ -111,7 +113,8 @@ public class KuduTestBase {
                             .withEnv(
                                     "TSERVER_ARGS",
                                     "--fs_wal_dir=/var/lib/kudu/tserver 
--logtostderr --unlock_unsafe_flags"
-                                            + " --use_hybrid_clock=false 
--rpc_advertised_addresses="
+                                            + " --time_source=system_unsync 
--use_hybrid_clock=true"
+                                            + " --rpc_advertised_addresses="
                                             + instanceName)
                             .withNetwork(network)
                             .withNetworkAliases(instanceName)
@@ -277,7 +280,7 @@ public class KuduTestBase {
         return masterAddress.toString();
     }
 
-    public KuduClient getClient() {
+    public static KuduClient getClient() {
         return kuduClient;
     }
 
diff --git 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/KuduSourceITCase.java
 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/KuduSourceITCase.java
new file mode 100644
index 0000000..7b0c751
--- /dev/null
+++ 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/KuduSourceITCase.java
@@ -0,0 +1,132 @@
+/*
+ * 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.connector.kudu.source;
+
+import org.apache.flink.api.common.JobID;
+import org.apache.flink.api.common.eventtime.WatermarkStrategy;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.client.program.ClusterClient;
+import 
org.apache.flink.connector.kudu.connector.converter.RowResultRowConverter;
+import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.functions.sink.SinkFunction;
+import org.apache.flink.test.junit5.InjectClusterClient;
+import org.apache.flink.test.junit5.MiniClusterExtension;
+import org.apache.flink.types.Row;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.time.Duration;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.function.Supplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** IT cases for using Kudu Source. */
+public class KuduSourceITCase extends KuduSourceTestBase {
+    @RegisterExtension
+    private static final MiniClusterExtension MINI_CLUSTER_RESOURCE =
+            new MiniClusterExtension(
+                    new MiniClusterResourceConfiguration.Builder()
+                            .setNumberTaskManagers(2)
+                            .build());
+
+    public static StreamExecutionEnvironment env;
+
+    private static Queue<Row> collectedRecords;
+
+    @BeforeEach
+    public void init() throws Exception {
+        super.init();
+        env = StreamExecutionEnvironment.getExecutionEnvironment();
+        collectedRecords = new ConcurrentLinkedDeque<>();
+    }
+
+    @Test
+    public void testRecordsFromSourceUnbounded(@InjectClusterClient 
ClusterClient<?> client)
+            throws Exception {
+
+        KuduSource<Row> kuduSource =
+                new KuduSourceBuilder<Row>()
+                        .setReaderConfig(getReaderConfig())
+                        .setTableInfo(getTableInfo())
+                        .setRowResultConverter(new RowResultRowConverter())
+                        .setBoundedness(Boundedness.CONTINUOUS_UNBOUNDED)
+                        .setDiscoveryPeriod(Duration.ofSeconds(1))
+                        .build();
+
+        env.fromSource(kuduSource, WatermarkStrategy.noWatermarks(), 
"KuduSource")
+                .returns(TypeInformation.of(Row.class))
+                .addSink(new TestingSinkFunction());
+
+        JobID jobID = env.executeAsync().getJobID();
+        waitExpectation(() -> collectedRecords.size() == 10);
+        assertThat(collectedRecords.size()).isEqualTo(10);
+        insertRows(10);
+        waitExpectation(() -> collectedRecords.size() == 20);
+        assertThat(collectedRecords.size()).isEqualTo(20);
+        client.cancel(jobID);
+    }
+
+    @Test
+    public void testRecordsFromSourceBounded() throws Exception {
+
+        KuduSource<Row> kuduSource =
+                new KuduSourceBuilder<Row>()
+                        .setReaderConfig(getReaderConfig())
+                        .setTableInfo(getTableInfo())
+                        .setRowResultConverter(new RowResultRowConverter())
+                        .build();
+
+        env.fromSource(kuduSource, WatermarkStrategy.noWatermarks(), 
"KuduSource")
+                .returns(TypeInformation.of(Row.class))
+                .addSink(new TestingSinkFunction());
+
+        env.execute();
+        assertThat(collectedRecords.size()).isEqualTo(10);
+    }
+
+    private static void waitExpectation(Supplier<Boolean> condition) throws 
Exception {
+        CompletableFuture<Void> future =
+                CompletableFuture.runAsync(
+                        () -> {
+                            while (!condition.get()) {
+                                try {
+                                    Thread.sleep(50);
+                                } catch (InterruptedException e) {
+                                    throw new RuntimeException(e);
+                                }
+                            }
+                        });
+        future.get();
+    }
+
+    /** A sink function to collect the records. */
+    static class TestingSinkFunction implements SinkFunction<Row> {
+
+        @Override
+        public void invoke(Row value, Context context) throws Exception {
+            collectedRecords.add(value);
+        }
+    }
+}
diff --git 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/KuduSourceTest.java
 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/KuduSourceTest.java
new file mode 100644
index 0000000..aef9143
--- /dev/null
+++ 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/KuduSourceTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.connector.kudu.source;
+
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.connector.kudu.connector.KuduTableInfo;
+import 
org.apache.flink.connector.kudu.connector.converter.RowResultRowConverter;
+import org.apache.flink.connector.kudu.connector.reader.KuduReaderConfig;
+import org.apache.flink.types.Row;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link KuduSource}. */
+public class KuduSourceTest {
+    @Test
+    void testNonExistentReaderConfig() {
+        KuduSourceBuilder<Row> builder =
+                new KuduSourceBuilder<Row>()
+                        .setTableInfo(KuduTableInfo.forTable("table"))
+                        .setRowResultConverter(new RowResultRowConverter());
+
+        assertThatThrownBy(builder::build)
+                .isInstanceOf(NullPointerException.class)
+                .hasMessageContaining("Reader config");
+    }
+
+    @Test
+    void testNonExistentTableInfo() {
+        KuduSourceBuilder<Row> builder =
+                new KuduSourceBuilder<Row>()
+                        
.setReaderConfig(KuduReaderConfig.Builder.setMasters("masters").build())
+                        .setRowResultConverter(new RowResultRowConverter());
+
+        assertThatThrownBy(builder::build)
+                .isInstanceOf(NullPointerException.class)
+                .hasMessageContaining("Table info");
+    }
+
+    @Test
+    void testNonExistentRowResultConverter() {
+        KuduSourceBuilder<Row> builder =
+                new KuduSourceBuilder<Row>()
+                        
.setReaderConfig(KuduReaderConfig.Builder.setMasters("masters").build())
+                        .setTableInfo(KuduTableInfo.forTable("table"));
+
+        assertThatThrownBy(builder::build)
+                .isInstanceOf(NullPointerException.class)
+                .hasMessageContaining("RowResultConverter");
+    }
+
+    @Test
+    void testNonExistentDiscoveryIntervalContinuousMode() {
+        KuduSourceBuilder<Row> builder =
+                new KuduSourceBuilder<Row>()
+                        
.setReaderConfig(KuduReaderConfig.Builder.setMasters("masters").build())
+                        .setTableInfo(KuduTableInfo.forTable("table"))
+                        .setBoundedness(Boundedness.CONTINUOUS_UNBOUNDED)
+                        .setRowResultConverter(new RowResultRowConverter());
+
+        assertThatThrownBy(builder::build)
+                .isInstanceOf(NullPointerException.class)
+                .hasMessageContaining("Discovery period");
+    }
+}
diff --git 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/KuduSourceTestBase.java
 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/KuduSourceTestBase.java
new file mode 100644
index 0000000..4eba650
--- /dev/null
+++ 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/KuduSourceTestBase.java
@@ -0,0 +1,126 @@
+/*
+ * 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.connector.kudu.source;
+
+import org.apache.flink.connector.kudu.connector.KuduTableInfo;
+import org.apache.flink.connector.kudu.connector.KuduTestBase;
+import org.apache.flink.connector.kudu.connector.reader.KuduReaderConfig;
+
+import org.apache.kudu.ColumnSchema;
+import org.apache.kudu.Schema;
+import org.apache.kudu.Type;
+import org.apache.kudu.client.CreateTableOptions;
+import org.apache.kudu.client.Insert;
+import org.apache.kudu.client.KuduClient;
+import org.apache.kudu.client.KuduException;
+import org.apache.kudu.client.KuduSession;
+import org.apache.kudu.client.KuduTable;
+import org.apache.kudu.client.PartialRow;
+import org.apache.kudu.client.SessionConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** A test base class for {@link KuduSource} related tests. */
+public class KuduSourceTestBase extends KuduTestBase {
+    private static KuduReaderConfig readerConfig;
+    private static KuduTableInfo tableInfo;
+    private static final String tableName = "test_table";
+    public static int lastInsertedKey = 0;
+
+    @BeforeEach
+    public void init() throws Exception {
+        readerConfig = 
KuduReaderConfig.Builder.setMasters(getMasterAddress()).build();
+        tableInfo = KuduTableInfo.forTable(getTableName());
+
+        createExampleTable();
+        insertRows(10);
+    }
+
+    @AfterEach
+    public void tearDown() throws Exception {
+        deleteTable();
+        lastInsertedKey = 0;
+    }
+
+    public static KuduReaderConfig getReaderConfig() {
+        return readerConfig;
+    }
+
+    public static KuduTableInfo getTableInfo() {
+        return tableInfo;
+    }
+
+    public static String getTableName() {
+        return tableName;
+    }
+
+    public static int getTestRowsCount() {
+        return lastInsertedKey;
+    }
+
+    public static void createExampleTable() throws KuduException {
+        KuduClient client = getClient();
+        // Set up a simple schema.
+        List<ColumnSchema> columns = new ArrayList<>(2);
+        columns.add(new ColumnSchema.ColumnSchemaBuilder("key", 
Type.INT32).key(true).build());
+        columns.add(
+                new ColumnSchema.ColumnSchemaBuilder("value", 
Type.STRING).nullable(true).build());
+        Schema schema = new Schema(columns);
+
+        CreateTableOptions cto = new CreateTableOptions();
+        List<String> hashKeys = new ArrayList<>(1);
+        hashKeys.add("key");
+        int numBuckets = 2;
+        cto.addHashPartitions(hashKeys, numBuckets);
+
+        client.createTable(tableName, schema, cto);
+    }
+
+    public static void deleteTable() throws KuduException {
+        if (getClient().tableExists(tableName)) {
+            getClient().deleteTable(tableName);
+        }
+    }
+
+    public static void insertRows(int numRows) throws KuduException {
+        KuduTable table = getClient().openTable(tableName);
+        KuduSession session = getClient().newSession();
+        
session.setFlushMode(SessionConfiguration.FlushMode.AUTO_FLUSH_BACKGROUND);
+        for (int i = 0; i < numRows; i++) {
+            Insert insert = table.newInsert();
+            PartialRow row = insert.getRow();
+            int currentKey = lastInsertedKey++; // Increment key to ensure 
uniqueness
+            row.addInt("key", currentKey);
+            // Make even-keyed row have a null 'value'.
+            if (i % 2 == 0) {
+                row.setNull("value");
+            } else {
+                row.addString("value", "value " + i);
+            }
+            session.apply(insert);
+        }
+
+        session.close();
+        if (session.countPendingErrors() != 0) {
+            throw new RuntimeException("Error inserting rows to Kudu");
+        }
+    }
+}
diff --git 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorStateSerializerTest.java
 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorStateSerializerTest.java
new file mode 100644
index 0000000..1c11c16
--- /dev/null
+++ 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorStateSerializerTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.connector.kudu.source.enumerator;
+
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link KuduSourceEnumeratorStateSerializer}. */
+public class KuduSourceEnumeratorStateSerializerTest {
+    private final KuduSourceEnumeratorStateSerializer serializer =
+            new KuduSourceEnumeratorStateSerializer();
+
+    @Test
+    public void testSerializeDeserialize() throws IOException {
+        byte[] token1 = {1, 2, 3};
+        byte[] token2 = {4, 5, 6};
+        byte[] token3 = {7, 8, 9};
+        byte[] token4 = {10, 11, 12};
+
+        List<KuduSourceSplit> unassigned =
+                Arrays.asList(new KuduSourceSplit(token1), new 
KuduSourceSplit(token2));
+        List<KuduSourceSplit> pending =
+                Arrays.asList(new KuduSourceSplit(token3), new 
KuduSourceSplit(token4));
+        KuduSourceEnumeratorState state =
+                new KuduSourceEnumeratorState(12345L, unassigned, pending);
+
+        byte[] serialized = serializer.serialize(state);
+        KuduSourceEnumeratorState deserialized =
+                serializer.deserialize(serializer.getVersion(), serialized);
+
+        
assertThat(deserialized.getLastEndTimestamp()).isEqualTo(state.getLastEndTimestamp());
+        
assertThat(deserialized.getUnassigned()).hasSameSizeAs(state.getUnassigned());
+        
assertThat(deserialized.getPending()).hasSameSizeAs(state.getPending());
+
+        for (int i = 0; i < unassigned.size(); i++) {
+            byte[] expected = unassigned.get(i).getSerializedScanToken();
+            
assertThat(deserialized.getUnassigned().get(i).getSerializedScanToken())
+                    .isEqualTo(expected);
+        }
+
+        for (int i = 0; i < pending.size(); i++) {
+            byte[] expected = pending.get(i).getSerializedScanToken();
+            
assertThat(deserialized.getPending().get(i).getSerializedScanToken())
+                    .isEqualTo(expected);
+        }
+    }
+
+    @Test
+    public void testSerializeDeserializeEmptyLists() throws IOException {
+        KuduSourceEnumeratorState state =
+                new KuduSourceEnumeratorState(
+                        67890L, Collections.emptyList(), 
Collections.emptyList());
+
+        byte[] serialized = serializer.serialize(state);
+        KuduSourceEnumeratorState deserialized =
+                serializer.deserialize(serializer.getVersion(), serialized);
+
+        
assertThat(state.getLastEndTimestamp()).isEqualTo(deserialized.getLastEndTimestamp());
+        assertThat(deserialized.getUnassigned()).isEmpty();
+        assertThat(deserialized.getPending()).isEmpty();
+    }
+}
diff --git 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorTest.java
 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorTest.java
new file mode 100644
index 0000000..814f159
--- /dev/null
+++ 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/enumerator/KuduSourceEnumeratorTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.connector.kudu.source.enumerator;
+
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.api.connector.source.SplitEnumeratorContext;
+import org.apache.flink.connector.kudu.connector.KuduTableInfo;
+import org.apache.flink.connector.kudu.connector.reader.KuduReaderConfig;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+import 
org.apache.flink.connector.testutils.source.reader.TestingSplitEnumeratorContext;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link KuduSourceEnumerator}.
+ *
+ * <p>Verifies that when a registered reader requests a split:
+ *
+ * <ul>
+ *   <li>An unassigned split moves to the enumerator context's split 
assignment.
+ *   <li>The enumerator tracks the split by adding it to the pending list.
+ * </ul>
+ */
+public class KuduSourceEnumeratorTest {
+
+    private TestingSplitEnumeratorContext<KuduSourceSplit> context;
+    private KuduSourceSplit split;
+    private final int subtaskId = 1;
+    private final long checkpointId = 1L;
+    private final String requesterHostname = "host";
+    private final KuduTableInfo tableInfo = KuduTableInfo.forTable("table");
+    private final KuduReaderConfig readerConfig =
+            KuduReaderConfig.Builder.setMasters("master").build();
+
+    @BeforeEach
+    void setup() {
+        this.context = new TestingSplitEnumeratorContext<>(1);
+    }
+
+    private KuduSourceEnumerator createEnumerator(
+            SplitEnumeratorContext<KuduSourceSplit> context, Boundedness 
boundedness) {
+        byte[] token = {1, 2, 3, 4, 5};
+        split = new KuduSourceSplit(token);
+
+        List<KuduSourceSplit> unassigned = new ArrayList<>();
+        unassigned.add(split);
+        List<KuduSourceSplit> pending = new ArrayList<>();
+
+        KuduSourceEnumeratorState state = new KuduSourceEnumeratorState(1L, 
unassigned, pending);
+
+        return new KuduSourceEnumerator(
+                tableInfo, readerConfig, boundedness, Duration.ofSeconds(1), 
context, state);
+    }
+
+    @ParameterizedTest
+    @EnumSource(Boundedness.class)
+    void testCheckpointNoSplitRequested(Boundedness boundedness) throws 
Exception {
+        try (KuduSourceEnumerator enumerator = createEnumerator(context, 
boundedness)) {
+            KuduSourceEnumeratorState state = 
enumerator.snapshotState(checkpointId);
+            assertThat(state.getUnassigned().size()).isEqualTo(1);
+            assertThat(state.getPending().size()).isEqualTo(0);
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSourceEnumerator", 
e);
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(Boundedness.class)
+    void testSplitRequestForRegisteredReader(Boundedness boundedness) throws 
Exception {
+        try (KuduSourceEnumerator enumerator = createEnumerator(context, 
boundedness)) {
+            context.registerReader(subtaskId, requesterHostname);
+            enumerator.addReader(subtaskId);
+            enumerator.handleSplitRequest(subtaskId, requesterHostname);
+            
assertThat(enumerator.snapshotState(checkpointId).getUnassigned().size()).isEqualTo(0);
+            
assertThat(enumerator.snapshotState(checkpointId).getPending().size()).isEqualTo(1);
+            assertThat(context.getSplitAssignments().size()).isEqualTo(1);
+            
assertThat(context.getSplitAssignments().get(subtaskId).getAssignedSplits())
+                    .contains(split);
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSourceEnumerator", 
e);
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(Boundedness.class)
+    void testSplitRequestForNonRegisteredReader(Boundedness boundedness) 
throws Exception {
+        try (KuduSourceEnumerator enumerator = createEnumerator(context, 
boundedness)) {
+            enumerator.handleSplitRequest(subtaskId, requesterHostname);
+            assertThat(context.getSplitAssignments().size()).isEqualTo(0);
+            
assertThat(enumerator.snapshotState(checkpointId).getUnassigned().size()).isEqualTo(1);
+            
assertThat(enumerator.snapshotState(checkpointId).getUnassigned().get(0))
+                    .isEqualTo(split);
+            
assertThat(enumerator.snapshotState(checkpointId).getPending().size()).isEqualTo(0);
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSourceEnumerator", 
e);
+        }
+    }
+
+    @Test
+    public void testCreateEnumeratorWithWrongConfig() {
+        assertThatThrownBy(
+                        () ->
+                                new KuduSourceEnumerator(
+                                        tableInfo,
+                                        readerConfig,
+                                        Boundedness.CONTINUOUS_UNBOUNDED,
+                                        null,
+                                        context,
+                                        KuduSourceEnumeratorState.empty()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("discoveryInterval must be set for 
CONTINUOUS_UNBOUNDED mode.");
+    }
+}
diff --git 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/enumerator/KuduSplitGeneratorTest.java
 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/enumerator/KuduSplitGeneratorTest.java
new file mode 100644
index 0000000..8b79f66
--- /dev/null
+++ 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/enumerator/KuduSplitGeneratorTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.connector.kudu.source.enumerator;
+
+import org.apache.flink.connector.kudu.connector.KuduTableInfo;
+import org.apache.flink.connector.kudu.connector.reader.KuduReaderConfig;
+import org.apache.flink.connector.kudu.source.KuduSourceTestBase;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+import org.apache.flink.connector.kudu.source.utils.KuduSourceUtils;
+import org.apache.flink.connector.kudu.source.utils.KuduSplitGenerator;
+
+import org.apache.kudu.client.KuduScanToken;
+import org.apache.kudu.client.KuduScanner;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link KuduSplitGenerator}. */
+public class KuduSplitGeneratorTest extends KuduSourceTestBase {
+
+    private final long startHT;
+    private final long endHT;
+    private final long currentHT;
+
+    public KuduSplitGeneratorTest() throws Exception {
+        currentHT = KuduSourceUtils.getCurrentHybridTime();
+        startHT = KuduSourceUtils.getCurrentHybridTime();
+        Thread.sleep(1000);
+        endHT = KuduSourceUtils.getCurrentHybridTime();
+    }
+
+    @Test
+    public void testGenerateGoodFullScanSplits() {
+        try (KuduSplitGenerator generator =
+                new KuduSplitGenerator(getReaderConfig(), getTableInfo())) {
+            List<KuduSourceSplit> splits = 
generator.generateFullScanSplits(currentHT);
+            assertThat(splits.size()).isGreaterThan(0);
+            // Check that all the splits can be properly deserialized into 
Kudu scanners.
+            for (KuduSourceSplit split : splits) {
+                try {
+                    KuduScanner scanner =
+                            KuduScanToken.deserializeIntoScanner(
+                                    split.getSerializedScanToken(), 
getClient());
+                } catch (Exception e) {
+                    Assertions.fail("Deserialization failed: " + 
e.getMessage());
+                }
+            }
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSplitGenerator", 
e);
+        }
+    }
+
+    @Test
+    public void testGenerateGoodIncrementalSplits() {
+        try (KuduSplitGenerator generator =
+                new KuduSplitGenerator(getReaderConfig(), getTableInfo())) {
+            List<KuduSourceSplit> splits = 
generator.generateIncrementalSplits(startHT, endHT);
+            assertThat(splits.size()).isGreaterThan(0);
+            // Check that all the splits can be properly deserialized into 
Kudu scanners.
+            for (KuduSourceSplit split : splits) {
+                try {
+                    KuduScanner scanner =
+                            KuduScanToken.deserializeIntoScanner(
+                                    split.getSerializedScanToken(), 
getClient());
+                } catch (Exception e) {
+                    Assertions.fail("Deserialization failed: " + 
e.getMessage());
+                }
+            }
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSplitGenerator", 
e);
+        }
+    }
+
+    @Test
+    public void testInvalidReaderConfigFullScan() {
+        KuduReaderConfig invalidReaderConfig =
+                KuduReaderConfig.Builder.setMasters("invalidMasters").build();
+        try (KuduSplitGenerator generator =
+                new KuduSplitGenerator(invalidReaderConfig, getTableInfo())) {
+
+            assertThatThrownBy(() -> 
generator.generateFullScanSplits(currentHT))
+                    .isInstanceOf(RuntimeException.class)
+                    .hasMessageContaining(
+                            "Error during full snapshot scan: Couldn't find a 
valid master");
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSplitGenerator", 
e);
+        }
+    }
+
+    @Test
+    public void testInvalidTableInfoFullScan() {
+        KuduTableInfo invalidTableInfo = 
KuduTableInfo.forTable("nonExistentTable");
+        try (KuduSplitGenerator generator =
+                new KuduSplitGenerator(getReaderConfig(), invalidTableInfo)) {
+
+            assertThatThrownBy(() -> 
generator.generateFullScanSplits(currentHT))
+                    .isInstanceOf(RuntimeException.class)
+                    .hasMessageContaining(
+                            "Error during full snapshot scan: the table does 
not exist");
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSplitGenerator", 
e);
+        }
+    }
+
+    @Test
+    public void testInvalidReaderConfigIncrementalScan() {
+        KuduReaderConfig invalidReaderConfig =
+                KuduReaderConfig.Builder.setMasters("invalidMasters").build();
+        try (KuduSplitGenerator generator =
+                new KuduSplitGenerator(invalidReaderConfig, getTableInfo())) {
+
+            assertThatThrownBy(() -> 
generator.generateIncrementalSplits(startHT, endHT))
+                    .isInstanceOf(RuntimeException.class)
+                    .hasMessageContaining(
+                            "Error during incremental diff scan: Couldn't find 
a valid master");
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSplitGenerator", 
e);
+        }
+    }
+
+    @Test
+    public void testInvalidTableInfoIncrementalScan() {
+        KuduTableInfo invalidTableInfo = 
KuduTableInfo.forTable("nonExistentTable");
+        try (KuduSplitGenerator generator =
+                new KuduSplitGenerator(getReaderConfig(), invalidTableInfo)) {
+
+            assertThatThrownBy(() -> 
generator.generateIncrementalSplits(startHT, endHT))
+                    .isInstanceOf(RuntimeException.class)
+                    .hasMessageContaining(
+                            "Error during incremental diff scan: the table 
does not exist");
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSplitGenerator", 
e);
+        }
+    }
+}
diff --git 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/reader/KuduSourceSplitReaderTest.java
 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/reader/KuduSourceSplitReaderTest.java
new file mode 100644
index 0000000..12f3bc1
--- /dev/null
+++ 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/reader/KuduSourceSplitReaderTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.connector.kudu.source.reader;
+
+import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
+import 
org.apache.flink.connector.base.source.reader.splitreader.SplitsAddition;
+import org.apache.flink.connector.kudu.source.KuduSourceTestBase;
+import org.apache.flink.connector.kudu.source.split.KuduSourceSplit;
+import org.apache.flink.connector.kudu.source.utils.KuduSourceUtils;
+import org.apache.flink.connector.kudu.source.utils.KuduSplitGenerator;
+
+import org.apache.kudu.client.RowResult;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link KuduSourceSplitReader}. */
+public class KuduSourceSplitReaderTest extends KuduSourceTestBase {
+
+    private List<KuduSourceSplit> generateSplits() {
+        List<KuduSourceSplit> splits;
+
+        try (KuduSplitGenerator generator =
+                new KuduSplitGenerator(getReaderConfig(), getTableInfo())) {
+            long now = KuduSourceUtils.getCurrentHybridTime();
+            splits = generator.generateFullScanSplits(now);
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close KuduSplitGenerator", 
e);
+        }
+        return splits;
+    }
+
+    @Test
+    public void testBasicRecordFetching() {
+        int recordsFetched = 0;
+
+        try (KuduSourceSplitReader splitReader = new 
KuduSourceSplitReader(getReaderConfig())) {
+            List<KuduSourceSplit> splits = generateSplits();
+            for (KuduSourceSplit split : splits) {
+                splitReader.handleSplitsChanges(
+                        new SplitsAddition<>(new 
ArrayList<>(Collections.singletonList(split))));
+                RecordsWithSplitIds<RowResult> fetchedRecordsWithSplitIds = 
splitReader.fetch();
+                assertThat(fetchedRecordsWithSplitIds.nextSplit()).isNotNull();
+
+                List<RowResult> records = new ArrayList<>();
+                RowResult nextRecordFromSplit = 
fetchedRecordsWithSplitIds.nextRecordFromSplit();
+                while (nextRecordFromSplit != null) {
+                    records.add(nextRecordFromSplit);
+                    nextRecordFromSplit = 
fetchedRecordsWithSplitIds.nextRecordFromSplit();
+                }
+
+                assertThat(records.size()).isGreaterThan(0);
+                recordsFetched += records.size();
+            }
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to close 
KuduSourceSplitReader", e);
+        }
+
+        assertThat(recordsFetched).isEqualTo(getTestRowsCount());
+    }
+}
diff --git 
a/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/split/KuduSourceSplitSerializerTest.java
 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/split/KuduSourceSplitSerializerTest.java
new file mode 100644
index 0000000..b2ccd3d
--- /dev/null
+++ 
b/flink-connector-kudu/src/test/java/org/apache/flink/connector/kudu/source/split/KuduSourceSplitSerializerTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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.connector.kudu.source.split;
+
+import org.junit.Test;
+
+import java.io.IOException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link KuduSourceSplitSerializer}. */
+public class KuduSourceSplitSerializerTest {
+
+    private final KuduSourceSplitSerializer splitSerializer = new 
KuduSourceSplitSerializer();
+
+    @Test
+    public void testSplitSerializeDeserialize() throws IOException {
+        byte[] token = {1, 2, 3, 4, 5};
+        KuduSourceSplit split = new KuduSourceSplit(token);
+
+        byte[] serialized = splitSerializer.serialize(split);
+        KuduSourceSplit deserialized =
+                splitSerializer.deserialize(splitSerializer.getVersion(), 
serialized);
+
+        
assertThat(split.getSerializedScanToken()).isEqualTo(deserialized.getSerializedScanToken());
+    }
+
+    @Test
+    public void testSplitSerializationNullToken() {
+        assertThatThrownBy(() -> splitSerializer.serialize(null))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("KuduSourceSplit or serializedScanToken 
is null.");
+    }
+
+    @Test
+    public void testSplitDeserializationEmptyData() {
+        assertThatThrownBy(
+                        () ->
+                                splitSerializer.deserialize(
+                                        splitSerializer.getVersion(), new 
byte[] {}))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Serialized data is empty or null.");
+    }
+}
diff --git a/pom.xml b/pom.xml
index 3447e2e..6ad1355 100644
--- a/pom.xml
+++ b/pom.xml
@@ -111,6 +111,12 @@ under the License.
                        <artifactId>log4j-1.2-api</artifactId>
                        <scope>test</scope>
                </dependency>
+
+               <dependency>
+                       <groupId>commons-codec</groupId>
+                       <artifactId>commons-codec</artifactId>
+                       <version>1.17.1</version>
+               </dependency>
        </dependencies>
 
        <!-- This section defines the module versions that are used if nothing 
else is specified. -->
@@ -122,6 +128,18 @@ under the License.
                                <version>${flink.version}</version>
                        </dependency>
 
+                       <dependency>
+                               <groupId>org.apache.flink</groupId>
+                               <artifactId>flink-connector-base</artifactId>
+                               <version>${flink.version}</version>
+                       </dependency>
+
+                       <dependency>
+                               <groupId>org.apache.flink</groupId>
+                               
<artifactId>flink-connector-test-utils</artifactId>
+                               <version>${flink.version}</version>
+                       </dependency>
+
                        <dependency>
                                <groupId>org.apache.flink</groupId>
                                <artifactId>flink-streaming-java</artifactId>
@@ -156,6 +174,20 @@ under the License.
                                <groupId>org.apache.flink</groupId>
                                <artifactId>flink-test-utils</artifactId>
                                <version>${flink.version}</version>
+                               <exclusions>
+                                       <exclusion>
+                                               
<groupId>org.apache.commons</groupId>
+                                               
<artifactId>commons-compress</artifactId>
+                                       </exclusion>
+                                       <exclusion>
+                                               
<groupId>org.xerial.snappy</groupId>
+                                               
<artifactId>snappy-java</artifactId>
+                                       </exclusion>
+                                       <exclusion>
+                                               
<groupId>org.apache.yetus</groupId>
+                                               
<artifactId>audience-annotations</artifactId>
+                                       </exclusion>
+                               </exclusions>
                        </dependency>
 
                        <!-- Flink ArchUnit -->

Reply via email to