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

zhouyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new 08417cfb18 [VL] Add native Kafka connector (#13183)
08417cfb18 is described below

commit 08417cfb18459a6f3f40e1427745a2a78f5d6b06
Author: Reema <[email protected]>
AuthorDate: Tue Oct 20 11:28:21 2026 +0300

    [VL] Add native Kafka connector (#13183)
---
 .github/workflows/velox_kafka.yml             |   9 +-
 cpp/velox/CMakeLists.txt                      |  18 +-
 cpp/velox/compute/VeloxBackend.cc             |  11 +
 cpp/velox/compute/VeloxBackend.h              |   4 +
 cpp/velox/compute/VeloxConnectorIds.h         |   2 +
 cpp/velox/compute/VeloxRuntime.cc             |  16 ++
 cpp/velox/config/VeloxConfig.h                |   1 +
 cpp/velox/operators/reader/KafkaConnector.cc  |  58 ++++
 cpp/velox/operators/reader/KafkaConnector.h   |  87 ++++++
 cpp/velox/operators/reader/KafkaDataSource.cc | 377 ++++++++++++++++++++++++++
 cpp/velox/operators/reader/KafkaDataSource.h  |  96 +++++++
 cpp/velox/operators/reader/KafkaSplit.h       |  72 +++++
 cpp/velox/tests/CMakeLists.txt                |   4 +
 cpp/velox/tests/KafkaConnectorTest.cc         | 367 +++++++++++++++++++++++++
 14 files changed, 1113 insertions(+), 9 deletions(-)

diff --git a/.github/workflows/velox_kafka.yml 
b/.github/workflows/velox_kafka.yml
index 33a5303dbe..3f2a367e13 100644
--- a/.github/workflows/velox_kafka.yml
+++ b/.github/workflows/velox_kafka.yml
@@ -80,10 +80,9 @@ jobs:
           ./dev/builddeps-veloxbe.sh --enable_vcpkg=ON --enable_kafka=ON \
             --build_arrow=OFF --build_tests=ON --build_benchmarks=OFF 
--build_examples=OFF \
             --enable_s3=OFF --enable_gcs=OFF --enable_hdfs=OFF 
--enable_abfs=OFF
-      # TODO: Re-enable after https://github.com/apache/gluten/pull/13183 is 
merged.
-      # - name: Run native Kafka connector tests
-      #   run: ./cpp/build/velox/tests/velox_kafka_connector_test
-      # TODO: Re-enable when the Kafka plan-conversion tests are added.
+      - name: Run native Kafka connector tests
+        run: ./cpp/build/velox/tests/velox_kafka_connector_test
+      # TODO: Re-enable when the Kafka plan-conversion tests are added in 
#13236.
       # - name: Run Kafka and file plan conversion tests
       #   run: |
       #     ./cpp/build/velox/tests/velox_plan_conversion_test \
@@ -147,7 +146,7 @@ jobs:
       #     ./build/mvn -B -ntp test -pl gluten-ut/spark35 -Pbackends-velox 
-Pspark-3.5 \
       #       -Pscala-2.12 -Pjava-17 -Pspark-ut -Pfast-build \
       #       
-Dsuites=org.apache.gluten.execution.VeloxKafkaScanValidationSuite
-      # TODO: Re-enable when VeloxGlutenKafkaScanSuite is added.
+      # TODO: Re-enable when VeloxGlutenKafkaScanSuite is added in #13237.
       # - name: Check native micro-batch reads and headers fallback
       #   run: |
       #     ./build/mvn -B -ntp test -pl backends-velox -Pbackends-velox 
-Pspark-3.5 \
diff --git a/cpp/velox/CMakeLists.txt b/cpp/velox/CMakeLists.txt
index 86f23171f5..fd5ff0a9d4 100644
--- a/cpp/velox/CMakeLists.txt
+++ b/cpp/velox/CMakeLists.txt
@@ -219,6 +219,11 @@ if(ENABLE_S3)
   find_package(ZLIB)
 endif()
 
+if(ENABLE_KAFKA)
+  list(APPEND VELOX_SRCS operators/reader/KafkaDataSource.cc
+       operators/reader/KafkaConnector.cc)
+endif()
+
 if(ENABLE_GPU)
   list(
     APPEND
@@ -508,11 +513,16 @@ set(CMAKE_FIND_LIBRARY_SUFFIXES 
${CMAKE_FIND_LIBRARY_SUFFIXES_BCK})
 
 # Find and link librdkafka for Kafka streaming support
 if(ENABLE_KAFKA)
-  target_compile_definitions(velox PUBLIC ENABLE_KAFKA)
+  target_compile_definitions(gluten_velox_impl PUBLIC ENABLE_KAFKA)
+  # Native tests link the implementation target directly and use both Kafka
+  # APIs.
   find_package(RdKafka CONFIG QUIET)
-  if(RdKafka_FOUND AND TARGET RdKafka::rdkafka++)
+  if(RdKafka_FOUND
+     AND TARGET RdKafka::rdkafka++
+     AND TARGET RdKafka::rdkafka)
     message(STATUS "Found RdKafka: ${RdKafka_DIR}")
-    target_link_libraries(velox PUBLIC RdKafka::rdkafka++)
+    target_link_libraries(gluten_velox_impl PUBLIC RdKafka::rdkafka++
+                                                   RdKafka::rdkafka)
   else()
     # Link the C++ client and C mock API.
     find_package(PkgConfig QUIET)
@@ -520,7 +530,7 @@ if(ENABLE_KAFKA)
       pkg_check_modules(RDKAFKA QUIET IMPORTED_TARGET rdkafka++ rdkafka)
       if(RDKAFKA_FOUND)
         message(STATUS "Found librdkafka via pkg-config: ${RDKAFKA_LIBRARIES}")
-        target_link_libraries(velox PUBLIC PkgConfig::RDKAFKA)
+        target_link_libraries(gluten_velox_impl PUBLIC PkgConfig::RDKAFKA)
       else()
         message(
           FATAL_ERROR
diff --git a/cpp/velox/compute/VeloxBackend.cc 
b/cpp/velox/compute/VeloxBackend.cc
index c131c008f4..7633216249 100644
--- a/cpp/velox/compute/VeloxBackend.cc
+++ b/cpp/velox/compute/VeloxBackend.cc
@@ -27,6 +27,10 @@
 #include "operators/plannodes/RowVectorStream.h"
 #include "utils/ConfigExtractor.h"
 
+#ifdef ENABLE_KAFKA
+#include "operators/reader/KafkaConnector.h"
+#endif
+
 #ifdef GLUTEN_ENABLE_QAT
 #include "utils/qat/QatCodec.h"
 #endif
@@ -426,6 +430,13 @@ std::shared_ptr<facebook::velox::connector::Connector> 
VeloxBackend::createValue
   return std::make_shared<ValueStreamConnector>(connectorId, 
hiveConnectorConfig_, dynamicFilterEnabled);
 }
 
+#ifdef ENABLE_KAFKA
+std::shared_ptr<facebook::velox::connector::Connector> 
VeloxBackend::createKafkaConnector(
+    const std::string& connectorId) const {
+  return std::make_shared<KafkaConnector>(connectorId, hiveConnectorConfig_);
+}
+#endif
+
 #ifdef GLUTEN_ENABLE_GPU
 std::shared_ptr<facebook::velox::connector::Connector> 
VeloxBackend::createCudfHiveConnector(
     const std::string& connectorId,
diff --git a/cpp/velox/compute/VeloxBackend.h b/cpp/velox/compute/VeloxBackend.h
index 5e129b9059..a7592d718b 100644
--- a/cpp/velox/compute/VeloxBackend.h
+++ b/cpp/velox/compute/VeloxBackend.h
@@ -96,6 +96,10 @@ class VeloxBackend {
       const std::string& connectorId,
       bool dynamicFilterEnabled) const;
 
+#ifdef ENABLE_KAFKA
+  std::shared_ptr<facebook::velox::connector::Connector> 
createKafkaConnector(const std::string& connectorId) const;
+#endif
+
 #ifdef GLUTEN_ENABLE_GPU
   std::shared_ptr<facebook::velox::connector::Connector> 
createCudfHiveConnector(
       const std::string& connectorId,
diff --git a/cpp/velox/compute/VeloxConnectorIds.h 
b/cpp/velox/compute/VeloxConnectorIds.h
index 73b526229f..751d890fec 100644
--- a/cpp/velox/compute/VeloxConnectorIds.h
+++ b/cpp/velox/compute/VeloxConnectorIds.h
@@ -26,11 +26,13 @@ struct VeloxConnectorIds {
   std::string iceberg;
   std::string delta;
   std::string iterator;
+  std::string kafka;
   std::string cudfHive;
   bool hiveRegistered{false};
   bool icebergRegistered{false};
   bool deltaRegistered{false};
   bool iteratorRegistered{false};
+  bool kafkaRegistered{false};
   bool cudfHiveRegistered{false};
 };
 
diff --git a/cpp/velox/compute/VeloxRuntime.cc 
b/cpp/velox/compute/VeloxRuntime.cc
index 5c4621a209..4420976572 100644
--- a/cpp/velox/compute/VeloxRuntime.cc
+++ b/cpp/velox/compute/VeloxRuntime.cc
@@ -230,6 +230,7 @@ VeloxConnectorIds makeScopedConnectorIds(uint64_t 
runtimeId) {
       .iceberg = makeScopedConnectorId(kIcebergConnectorId, runtimeId),
       .delta = 
makeScopedConnectorId(delta::DeltaConnectorFactory::kDeltaConnectorName, 
runtimeId),
       .iterator = makeScopedConnectorId(kIteratorConnectorId, runtimeId),
+      .kafka = makeScopedConnectorId(kKafkaConnectorId, runtimeId),
       .cudfHive = makeScopedConnectorId(kCudfHiveConnectorId, runtimeId)};
 }
 
@@ -316,6 +317,15 @@ void VeloxRuntime::registerConnectors() {
       velox::connector::hasConnector(connectorIds_.iterator),
       "Scoped iterator connector not found after registration: " + 
connectorIds_.iterator);
 
+#ifdef ENABLE_KAFKA
+  connectorIds_.kafkaRegistered =
+      
velox::connector::registerConnector(backend->createKafkaConnector(connectorIds_.kafka));
+  GLUTEN_CHECK(connectorIds_.kafkaRegistered, "Failed to register scoped Kafka 
connector: " + connectorIds_.kafka);
+  GLUTEN_CHECK(
+      velox::connector::hasConnector(connectorIds_.kafka),
+      "Scoped Kafka connector not found after registration: " + 
connectorIds_.kafka);
+#endif
+
 #ifdef GLUTEN_ENABLE_GPU
   if (veloxCfg_->get<bool>(kCudfEnableTableScan, kCudfEnableTableScanDefault) 
&&
       veloxCfg_->get<bool>(kCudfEnabled, kCudfEnabledDefault)) {
@@ -336,6 +346,12 @@ void VeloxRuntime::unregisterConnectors() {
     velox::connector::unregisterConnector(connectorIds_.cudfHive);
     connectorIds_.cudfHiveRegistered = false;
   }
+#endif
+#ifdef ENABLE_KAFKA
+  if (connectorIds_.kafkaRegistered) {
+    velox::connector::unregisterConnector(connectorIds_.kafka);
+    connectorIds_.kafkaRegistered = false;
+  }
 #endif
   if (connectorIds_.iteratorRegistered) {
     velox::connector::unregisterConnector(connectorIds_.iterator);
diff --git a/cpp/velox/config/VeloxConfig.h b/cpp/velox/config/VeloxConfig.h
index 91069e86a2..8d9a1ed2e0 100644
--- a/cpp/velox/config/VeloxConfig.h
+++ b/cpp/velox/config/VeloxConfig.h
@@ -134,6 +134,7 @@ const uint64_t kVeloxMemReclaimMaxWaitMsDefault = 3600000; 
// 60min
 
 const std::string kHiveConnectorId = "test-hive";
 const std::string kIcebergConnectorId = "test-iceberg";
+const std::string kKafkaConnectorId = "kafka-stream";
 
 const std::string kVeloxCacheEnabled = 
"spark.gluten.sql.columnar.backend.velox.cacheEnabled";
 
diff --git a/cpp/velox/operators/reader/KafkaConnector.cc 
b/cpp/velox/operators/reader/KafkaConnector.cc
new file mode 100644
index 0000000000..03469b1018
--- /dev/null
+++ b/cpp/velox/operators/reader/KafkaConnector.cc
@@ -0,0 +1,58 @@
+/*
+ * 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.
+ */
+
+#include "KafkaConnector.h"
+#include "KafkaDataSource.h"
+
+#include <folly/String.h>
+
+namespace gluten {
+
+KafkaColumnHandle::KafkaColumnHandle(std::string name, 
facebook::velox::TypePtr type)
+    : name_(std::move(name)), type_(std::move(type)) {
+  using namespace facebook::velox;
+  static const std::unordered_map<std::string, std::pair<KafkaField, TypePtr>> 
kFields = {
+      {"key", {KafkaField::kKey, VARBINARY()}},
+      {"value", {KafkaField::kValue, VARBINARY()}},
+      {"topic", {KafkaField::kTopic, VARCHAR()}},
+      {"partition", {KafkaField::kPartition, INTEGER()}},
+      {"offset", {KafkaField::kOffset, BIGINT()}},
+      {"timestamp", {KafkaField::kTimestamp, TIMESTAMP()}},
+      {"timestamptype", {KafkaField::kTimestampType, INTEGER()}},
+  };
+  auto lowerName = name_;
+  folly::toLowerAscii(lowerName);
+  auto it = kFields.find(lowerName);
+  VELOX_USER_CHECK(it != kFields.end(), "Unsupported Kafka column: {}", name_);
+  VELOX_USER_CHECK(
+      type_->equivalent(*it->second.second),
+      "Kafka column {} must be of type {}, got {}",
+      name_,
+      it->second.second->toString(),
+      type_->toString());
+  field_ = it->second.first;
+}
+
+std::unique_ptr<facebook::velox::connector::DataSource> 
KafkaConnector::createDataSource(
+    const facebook::velox::RowTypePtr& outputType,
+    const facebook::velox::connector::ConnectorTableHandlePtr& /*tableHandle*/,
+    const facebook::velox::connector::ColumnHandleMap& columnHandles,
+    facebook::velox::connector::ConnectorQueryCtx* connectorQueryCtx) {
+  return std::make_unique<KafkaDataSource>(outputType, columnHandles, 
connectorQueryCtx->memoryPool());
+}
+
+} // namespace gluten
diff --git a/cpp/velox/operators/reader/KafkaConnector.h 
b/cpp/velox/operators/reader/KafkaConnector.h
new file mode 100644
index 0000000000..34e75e415c
--- /dev/null
+++ b/cpp/velox/operators/reader/KafkaConnector.h
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include "velox/connectors/Connector.h"
+
+namespace gluten {
+
+class KafkaTableHandle : public 
facebook::velox::connector::ConnectorTableHandle {
+ public:
+  explicit KafkaTableHandle(std::string connectorId) : 
ConnectorTableHandle(std::move(connectorId)) {}
+
+  const std::string& name() const override {
+    static const std::string kName = "KafkaTableHandle";
+    return kName;
+  }
+
+  folly::dynamic serialize() const override {
+    VELOX_NYI();
+  }
+};
+
+/// A column of Spark's Kafka source schema.
+enum class KafkaField { kKey, kValue, kTopic, kPartition, kOffset, kTimestamp, 
kTimestampType };
+
+/// Column handle naming one of the Kafka source columns: key, value, topic, 
partition, offset,
+/// timestamp or timestampType. Throws a user error for other columns, e.g. 
headers.
+class KafkaColumnHandle : public facebook::velox::connector::ColumnHandle {
+ public:
+  KafkaColumnHandle(std::string name, facebook::velox::TypePtr type);
+
+  const std::string& name() const override {
+    return name_;
+  }
+
+  const facebook::velox::TypePtr& type() const {
+    return type_;
+  }
+
+  KafkaField field() const {
+    return field_;
+  }
+
+ private:
+  const std::string name_;
+  const facebook::velox::TypePtr type_;
+  KafkaField field_;
+};
+
+/// Connector reading Kafka topic partitions through librdkafka. Each 
KafkaConnectorSplit is read
+/// by a KafkaDataSource.
+class KafkaConnector : public facebook::velox::connector::Connector {
+ public:
+  KafkaConnector(const std::string& id, std::shared_ptr<const 
facebook::velox::config::ConfigBase> config)
+      : Connector(id, std::move(config)) {}
+
+  std::unique_ptr<facebook::velox::connector::DataSource> createDataSource(
+      const facebook::velox::RowTypePtr& outputType,
+      const facebook::velox::connector::ConnectorTableHandlePtr& tableHandle,
+      const facebook::velox::connector::ColumnHandleMap& columnHandles,
+      facebook::velox::connector::ConnectorQueryCtx* connectorQueryCtx) 
override;
+
+  std::unique_ptr<facebook::velox::connector::DataSink> createDataSink(
+      facebook::velox::RowTypePtr inputType,
+      facebook::velox::connector::ConnectorInsertTableHandlePtr 
connectorInsertTableHandle,
+      facebook::velox::connector::ConnectorQueryCtx* connectorQueryCtx,
+      facebook::velox::connector::CommitStrategy commitStrategy) override {
+    VELOX_UNSUPPORTED("KafkaConnector does not support data sinks");
+  }
+};
+
+} // namespace gluten
diff --git a/cpp/velox/operators/reader/KafkaDataSource.cc 
b/cpp/velox/operators/reader/KafkaDataSource.cc
new file mode 100644
index 0000000000..20c1ae575d
--- /dev/null
+++ b/cpp/velox/operators/reader/KafkaDataSource.cc
@@ -0,0 +1,377 @@
+/*
+ * 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.
+ */
+
+#include "KafkaDataSource.h"
+
+#include <algorithm>
+#include <chrono>
+#include <limits>
+
+#include <glog/logging.h>
+#include <librdkafka/rdkafka.h>
+#include <librdkafka/rdkafkacpp.h>
+
+#include "velox/vector/FlatVector.h"
+
+using namespace facebook::velox;
+
+namespace gluten {
+
+namespace {
+
+// Spark's default for kafkaConsumer.pollTimeoutMs.
+constexpr int64_t kDefaultPollTimeoutMs = 120'000;
+constexpr int kConsumeTimeoutMs = 100;
+// Spark requires a group id on executors; librdkafka's KafkaConsumer does too.
+const std::string kDefaultGroupId = "gluten-kafka-executor";
+
+// Java client properties that have no librdkafka counterpart, or that the 
reader must control.
+bool isIgnoredProperty(const std::string& key) {
+  return key == "key.deserializer" || key == "value.deserializer" || key == 
"auto.offset.reset" ||
+      key == "enable.auto.commit";
+}
+
+void setProperty(RdKafka::Conf& conf, const std::string& key, const 
std::string& value) {
+  std::string errstr;
+  VELOX_CHECK(
+      conf.set(key, value, errstr) == RdKafka::Conf::CONF_OK, "Failed to set 
Kafka property {}: {}", key, errstr);
+}
+
+int64_t getLogEndOffset(RdKafka::KafkaConsumer& consumer, const std::string& 
topic, int32_t partition, int timeoutMs) {
+  auto* client = consumer.c_ptr();
+  std::unique_ptr<rd_kafka_AdminOptions_t, 
decltype(&rd_kafka_AdminOptions_destroy)> options(
+      rd_kafka_AdminOptions_new(client, RD_KAFKA_ADMIN_OP_LISTOFFSETS), 
rd_kafka_AdminOptions_destroy);
+  char errstr[512];
+  const auto err = rd_kafka_AdminOptions_set_request_timeout(options.get(), 
timeoutMs, errstr, sizeof(errstr));
+  VELOX_CHECK(err == RD_KAFKA_RESP_ERR_NO_ERROR, "Failed to set Kafka offset 
request timeout: {}", errstr);
+  std::unique_ptr<rd_kafka_error_t, decltype(&rd_kafka_error_destroy)> 
isolationError(
+      rd_kafka_AdminOptions_set_isolation_level(options.get(), 
RD_KAFKA_ISOLATION_LEVEL_READ_UNCOMMITTED),
+      rd_kafka_error_destroy);
+  if (isolationError) {
+    VELOX_FAIL("Failed to set Kafka offset request isolation: {}", 
rd_kafka_error_string(isolationError.get()));
+  }
+  std::unique_ptr<rd_kafka_topic_partition_list_t, 
decltype(&rd_kafka_topic_partition_list_destroy)> partitions(
+      rd_kafka_topic_partition_list_new(1), 
rd_kafka_topic_partition_list_destroy);
+  rd_kafka_topic_partition_list_add(partitions.get(), topic.c_str(), 
partition)->offset = RD_KAFKA_OFFSET_SPEC_LATEST;
+  std::unique_ptr<rd_kafka_queue_t, decltype(&rd_kafka_queue_destroy)> queue(
+      rd_kafka_queue_new(client), rd_kafka_queue_destroy);
+  rd_kafka_ListOffsets(client, partitions.get(), options.get(), queue.get());
+  std::unique_ptr<rd_kafka_event_t, decltype(&rd_kafka_event_destroy)> event(
+      rd_kafka_queue_poll(queue.get(), timeoutMs), rd_kafka_event_destroy);
+  VELOX_CHECK_NOT_NULL(event, "Timed out getting Kafka log end offset for 
{}-{}", topic, partition);
+  VELOX_CHECK(
+      rd_kafka_event_error(event.get()) == RD_KAFKA_RESP_ERR_NO_ERROR,
+      "Failed to get Kafka log end offset for {}-{}: {}",
+      topic,
+      partition,
+      rd_kafka_event_error_string(event.get()));
+  const auto* result = rd_kafka_event_ListOffsets_result(event.get());
+  VELOX_CHECK_NOT_NULL(result, "Missing Kafka log end offset result for 
{}-{}", topic, partition);
+  size_t count = 0;
+  const auto** infos = rd_kafka_ListOffsets_result_infos(result, &count);
+  VELOX_CHECK_EQ(count, 1, "Expected one Kafka log end offset result");
+  const auto* info = rd_kafka_ListOffsetsResultInfo_topic_partition(infos[0]);
+  VELOX_CHECK(
+      info->err == RD_KAFKA_RESP_ERR_NO_ERROR,
+      "Failed to get Kafka log end offset for {}-{}: {}",
+      topic,
+      partition,
+      rd_kafka_err2str(info->err));
+  VELOX_CHECK_GE(info->offset, 0, "Invalid Kafka log end offset for {}-{}", 
topic, partition);
+  return info->offset;
+}
+
+int32_t toSparkTimestampType(RdKafka::MessageTimestamp::MessageTimestampType 
type) {
+  // Matches org.apache.kafka.common.record.TimestampType ids.
+  switch (type) {
+    case RdKafka::MessageTimestamp::MSG_TIMESTAMP_CREATE_TIME:
+      return 0;
+    case RdKafka::MessageTimestamp::MSG_TIMESTAMP_LOG_APPEND_TIME:
+      return 1;
+    default:
+      return -1;
+  }
+}
+
+} // namespace
+
+KafkaDataSource::KafkaDataSource(
+    const RowTypePtr& outputType,
+    const connector::ColumnHandleMap& columnHandles,
+    memory::MemoryPool* pool)
+    : outputType_(outputType), pool_(pool) {
+  fields_.reserve(outputType_->size());
+  for (const auto& outputName : outputType_->names()) {
+    auto it = columnHandles.find(outputName);
+    VELOX_CHECK(it != columnHandles.end(), "Missing column handle for Kafka 
output column {}", outputName);
+    auto handle = std::dynamic_pointer_cast<const 
KafkaColumnHandle>(it->second);
+    VELOX_CHECK_NOT_NULL(handle, "Wrong type of column handle for Kafka output 
column {}", outputName);
+    fields_.push_back(handle->field());
+  }
+}
+
+KafkaDataSource::~KafkaDataSource() {
+  closeConsumer();
+}
+
+void KafkaDataSource::addSplit(std::shared_ptr<connector::ConnectorSplit> 
split) {
+  VELOX_CHECK_NULL(split_, "Previous Kafka split has not been fully 
processed");
+  VELOX_CHECK_NOT_NULL(split, "Kafka split must not be null");
+  split_ = std::dynamic_pointer_cast<const KafkaConnectorSplit>(split);
+  VELOX_CHECK_NOT_NULL(split_, "Wrong type of split for KafkaDataSource: {}", 
split->toString());
+  nextOffset_ = split_->startOffset;
+  splitDone_ = nextOffset_ >= split_->endOffset;
+  if (splitDone_) {
+    return;
+  }
+
+  std::unique_ptr<RdKafka::Conf> 
conf(RdKafka::Conf::create(RdKafka::Conf::CONF_GLOBAL));
+  std::string errstr;
+  for (const auto& [key, value] : split_->kafkaParams) {
+    if (isIgnoredProperty(key)) {
+      continue;
+    }
+    // The params are Java client properties; skip the ones librdkafka does 
not know.
+    const auto result = conf->set(key, value, errstr);
+    if (result == RdKafka::Conf::CONF_UNKNOWN) {
+      VLOG(1) << "Ignoring unsupported Java Kafka property " << key;
+    } else {
+      VELOX_USER_CHECK(result == RdKafka::Conf::CONF_OK, "Invalid Kafka 
property {}: {}", key, errstr);
+    }
+  }
+  if (split_->kafkaParams.find("group.id") == split_->kafkaParams.end()) {
+    setProperty(*conf, "group.id", kDefaultGroupId);
+  }
+  // Match the Java client default; Spark computes end offsets from the high 
watermark.
+  if (split_->kafkaParams.find("isolation.level") == 
split_->kafkaParams.end()) {
+    setProperty(*conf, "isolation.level", "read_uncommitted");
+  }
+  // Offsets are tracked by Spark, never commit them to the consumer group.
+  setProperty(*conf, "enable.auto.commit", "false");
+  setProperty(*conf, "enable.auto.offset.store", "false");
+  // Report aged-out offsets as errors instead of silently resetting.
+  setProperty(*conf, "auto.offset.reset", "error");
+  // Detects reaching the log end when trailing offsets (e.g. transaction 
markers) carry no record.
+  setProperty(*conf, "enable.partition.eof", "true");
+
+  consumer_.reset(RdKafka::KafkaConsumer::create(conf.get(), errstr));
+  VELOX_CHECK_NOT_NULL(consumer_, "Failed to create Kafka consumer: {}", 
errstr);
+
+  std::unique_ptr<RdKafka::TopicPartition> topicPartition(
+      RdKafka::TopicPartition::create(split_->topic, split_->partition, 
split_->startOffset));
+  std::vector<RdKafka::TopicPartition*> partitions{topicPartition.get()};
+  const auto err = consumer_->assign(partitions);
+  VELOX_CHECK(err == RdKafka::ERR_NO_ERROR, "Failed to assign {}: {}", 
split_->toString(), RdKafka::err2str(err));
+}
+
+std::optional<RowVectorPtr> KafkaDataSource::next(uint64_t size, 
ContinueFuture& /*future*/) {
+  if (split_ == nullptr) {
+    return nullptr;
+  }
+  if (!splitDone_) {
+    auto messages = poll(std::max<uint64_t>(size, 1));
+    if (!messages.empty()) {
+      return toRowVector(messages);
+    }
+  }
+  VELOX_CHECK(splitDone_);
+  closeConsumer();
+  split_.reset();
+  return nullptr;
+}
+
+std::vector<std::unique_ptr<RdKafka::Message>> KafkaDataSource::poll(uint64_t 
maxRows) {
+  std::vector<std::unique_ptr<RdKafka::Message>> messages;
+  const auto pollTimeoutMs = split_->pollTimeoutMs > 0 ? split_->pollTimeoutMs 
: kDefaultPollTimeoutMs;
+  const auto deadline = std::chrono::steady_clock::now() + 
std::chrono::milliseconds(pollTimeoutMs);
+
+  while (!splitDone_ && messages.size() < maxRows) {
+    // Only wait for more records while the batch is empty.
+    std::unique_ptr<RdKafka::Message> msg(consumer_->consume(messages.empty() 
? kConsumeTimeoutMs : 0));
+    switch (msg->err()) {
+      case RdKafka::ERR_NO_ERROR: {
+        const auto offset = msg->offset();
+        if (offset < nextOffset_) {
+          break;
+        }
+        if (offset >= split_->endOffset) {
+          splitDone_ = true;
+          break;
+        }
+        completedBytes_ += msg->len() + msg->key_len();
+        messages.push_back(std::move(msg));
+        nextOffset_ = offset + 1;
+        splitDone_ = nextOffset_ >= split_->endOffset;
+        break;
+      }
+      case RdKafka::ERR__PARTITION_EOF: {
+        // Offsets below EOF without a record are compacted records or 
transaction markers.
+        splitDone_ = msg->offset() >= split_->endOffset;
+        if (!splitDone_) {
+          // A read_committed EOF can be the last stable offset, or a follower 
can lag the leader.
+          // Query the leader's physical log end before treating the missing 
tail as data loss.
+          const auto timeoutMs = 
static_cast<int>(std::min<int64_t>(pollTimeoutMs, 
std::numeric_limits<int>::max()));
+          const auto logEndOffset = getLogEndOffset(*consumer_, split_->topic, 
split_->partition, timeoutMs);
+          if (logEndOffset < split_->endOffset) {
+            VELOX_USER_CHECK(
+                !split_->failOnDataLoss,
+                "Kafka offsets [{}, {}) of {}-{} are no longer available. Some 
data may have been lost. "
+                "Set the source option failOnDataLoss to false to ignore 
this.",
+                logEndOffset,
+                split_->endOffset,
+                split_->topic,
+                split_->partition);
+            LOG(WARNING) << "Kafka offsets [" << logEndOffset << ", " << 
split_->endOffset << ") of " << split_->topic
+                         << "-" << split_->partition << " are no longer 
available";
+            splitDone_ = true;
+          }
+        }
+        break;
+      }
+      case RdKafka::ERR__TIMED_OUT:
+        if (!messages.empty()) {
+          return messages;
+        }
+        break;
+      case RdKafka::ERR__AUTO_OFFSET_RESET:
+      case RdKafka::ERR_OFFSET_OUT_OF_RANGE: {
+        VELOX_USER_CHECK(
+            !split_->failOnDataLoss,
+            "Kafka offset {} of {}-{} is no longer available: {}. Some data 
may have been lost. "
+            "Set the source option failOnDataLoss to false to ignore this.",
+            nextOffset_,
+            split_->topic,
+            split_->partition,
+            msg->errstr());
+        LOG(WARNING) << "Kafka offset " << nextOffset_ << " of " << 
split_->topic << "-" << split_->partition
+                     << " is no longer available: " << msg->errstr();
+        // Retention may have removed only the beginning of the split. 
Preserve the remaining
+        // available records when Spark permits data loss instead of dropping 
the entire split.
+        int64_t lowOffset;
+        int64_t highOffset;
+        const auto timeoutMs = 
static_cast<int>(std::min<int64_t>(pollTimeoutMs, 
std::numeric_limits<int>::max()));
+        auto err =
+            consumer_->query_watermark_offsets(split_->topic, 
split_->partition, &lowOffset, &highOffset, timeoutMs);
+        VELOX_CHECK(err == RdKafka::ERR_NO_ERROR, "Failed to get Kafka offset 
range: {}", RdKafka::err2str(err));
+        nextOffset_ = std::max(nextOffset_, lowOffset);
+        splitDone_ = nextOffset_ >= std::min(highOffset, split_->endOffset);
+        if (!splitDone_) {
+          std::unique_ptr<RdKafka::TopicPartition> partition(
+              RdKafka::TopicPartition::create(split_->topic, 
split_->partition, nextOffset_));
+          err = consumer_->seek(*partition, timeoutMs);
+          VELOX_CHECK(err == RdKafka::ERR_NO_ERROR, "Failed to seek Kafka 
offset: {}", RdKafka::err2str(err));
+        }
+        break;
+      }
+      default:
+        // librdkafka retries transient errors itself; the poll timeout bounds 
how long we wait.
+        LOG(WARNING) << "Kafka consume error on " << split_->toString() << ": 
" << msg->errstr();
+        break;
+    }
+
+    if (!splitDone_ && std::chrono::steady_clock::now() >= deadline) {
+      if (!messages.empty()) {
+        return messages;
+      }
+      VELOX_FAIL(
+          "Timed out after {} ms fetching offset {} of {}-{}",
+          pollTimeoutMs,
+          nextOffset_,
+          split_->topic,
+          split_->partition);
+    }
+  }
+  return messages;
+}
+
+RowVectorPtr KafkaDataSource::toRowVector(const 
std::vector<std::unique_ptr<RdKafka::Message>>& messages) {
+  std::vector<VectorPtr> children;
+  children.reserve(fields_.size());
+  for (size_t i = 0; i < fields_.size(); ++i) {
+    children.push_back(makeColumn(fields_[i], outputType_->childAt(i), 
messages));
+  }
+  completedRows_ += messages.size();
+  return std::make_shared<RowVector>(pool_, outputType_, BufferPtr(nullptr), 
messages.size(), std::move(children));
+}
+
+VectorPtr KafkaDataSource::makeColumn(
+    KafkaField field,
+    const TypePtr& type,
+    const std::vector<std::unique_ptr<RdKafka::Message>>& messages) {
+  const auto numRows = messages.size();
+  switch (field) {
+    case KafkaField::kKey:
+    case KafkaField::kValue:
+    case KafkaField::kTopic: {
+      auto vector = BaseVector::create<FlatVector<StringView>>(type, numRows, 
pool_);
+      for (size_t i = 0; i < numRows; ++i) {
+        const auto& msg = *messages[i];
+        // FlatVector<StringView>::set copies non-inlined strings into the 
vector's buffers.
+        if (field == KafkaField::kTopic) {
+          const auto topic = msg.topic_name();
+          vector->set(i, StringView(topic));
+        } else if (field == KafkaField::kKey) {
+          if (msg.key_pointer() == nullptr) {
+            vector->setNull(i, true);
+          } else {
+            vector->set(i, StringView(static_cast<const 
char*>(msg.key_pointer()), msg.key_len()));
+          }
+        } else if (msg.payload() == nullptr) {
+          vector->setNull(i, true);
+        } else {
+          vector->set(i, StringView(static_cast<const char*>(msg.payload()), 
msg.len()));
+        }
+      }
+      return vector;
+    }
+    case KafkaField::kPartition:
+    case KafkaField::kTimestampType: {
+      auto vector = BaseVector::create<FlatVector<int32_t>>(type, numRows, 
pool_);
+      for (size_t i = 0; i < numRows; ++i) {
+        vector->set(
+            i,
+            field == KafkaField::kPartition ? messages[i]->partition()
+                                            : 
toSparkTimestampType(messages[i]->timestamp().type));
+      }
+      return vector;
+    }
+    case KafkaField::kOffset: {
+      auto vector = BaseVector::create<FlatVector<int64_t>>(type, numRows, 
pool_);
+      for (size_t i = 0; i < numRows; ++i) {
+        vector->set(i, messages[i]->offset());
+      }
+      return vector;
+    }
+    case KafkaField::kTimestamp: {
+      auto vector = BaseVector::create<FlatVector<Timestamp>>(type, numRows, 
pool_);
+      for (size_t i = 0; i < numRows; ++i) {
+        vector->set(i, 
Timestamp::fromMillis(messages[i]->timestamp().timestamp));
+      }
+      return vector;
+    }
+  }
+  VELOX_UNREACHABLE();
+}
+
+void KafkaDataSource::closeConsumer() {
+  if (consumer_ != nullptr) {
+    consumer_->close();
+    consumer_.reset();
+  }
+}
+
+} // namespace gluten
diff --git a/cpp/velox/operators/reader/KafkaDataSource.h 
b/cpp/velox/operators/reader/KafkaDataSource.h
new file mode 100644
index 0000000000..59484b5e2a
--- /dev/null
+++ b/cpp/velox/operators/reader/KafkaDataSource.h
@@ -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.
+ */
+
+#pragma once
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "operators/reader/KafkaConnector.h"
+#include "operators/reader/KafkaSplit.h"
+#include "velox/connectors/Connector.h"
+
+namespace RdKafka {
+class KafkaConsumer;
+class Message;
+} // namespace RdKafka
+
+namespace gluten {
+
+/// Reads the offset range [startOffset, endOffset) of one Kafka topic 
partition per split and
+/// produces rows in Spark's Kafka source schema. Offsets are never committed 
back to Kafka; Spark
+/// tracks progress in its own checkpoint.
+class KafkaDataSource : public facebook::velox::connector::DataSource {
+ public:
+  KafkaDataSource(
+      const facebook::velox::RowTypePtr& outputType,
+      const facebook::velox::connector::ColumnHandleMap& columnHandles,
+      facebook::velox::memory::MemoryPool* pool);
+
+  ~KafkaDataSource() override;
+
+  void addSplit(std::shared_ptr<facebook::velox::connector::ConnectorSplit> 
split) override;
+
+  std::optional<facebook::velox::RowVectorPtr> next(uint64_t size, 
facebook::velox::ContinueFuture& future) override;
+
+  void addDynamicFilter(
+      facebook::velox::column_index_t /*outputChannel*/,
+      const std::shared_ptr<facebook::velox::common::Filter>& /*filter*/) 
override {
+    VELOX_UNSUPPORTED("KafkaDataSource does not support dynamic filters");
+  }
+
+  uint64_t getCompletedBytes() override {
+    return completedBytes_;
+  }
+
+  uint64_t getCompletedRows() override {
+    return completedRows_;
+  }
+
+ private:
+  friend class KafkaConnectorTest;
+
+  // Polls up to 'maxRows' messages of the current split. Sets 'splitDone_' 
once the end offset is
+  // reached.
+  std::vector<std::unique_ptr<RdKafka::Message>> poll(uint64_t maxRows);
+
+  facebook::velox::RowVectorPtr toRowVector(const 
std::vector<std::unique_ptr<RdKafka::Message>>& messages);
+
+  facebook::velox::VectorPtr makeColumn(
+      KafkaField field,
+      const facebook::velox::TypePtr& type,
+      const std::vector<std::unique_ptr<RdKafka::Message>>& messages);
+
+  void closeConsumer();
+
+  const facebook::velox::RowTypePtr outputType_;
+  facebook::velox::memory::MemoryPool* const pool_;
+  // The Kafka field for each output column.
+  std::vector<KafkaField> fields_;
+
+  std::shared_ptr<const KafkaConnectorSplit> split_;
+  std::unique_ptr<RdKafka::KafkaConsumer> consumer_;
+  // The next offset expected from the current split.
+  int64_t nextOffset_{0};
+  bool splitDone_{false};
+
+  uint64_t completedBytes_{0};
+  uint64_t completedRows_{0};
+};
+
+} // namespace gluten
diff --git a/cpp/velox/operators/reader/KafkaSplit.h 
b/cpp/velox/operators/reader/KafkaSplit.h
new file mode 100644
index 0000000000..df1e48a3c7
--- /dev/null
+++ b/cpp/velox/operators/reader/KafkaSplit.h
@@ -0,0 +1,72 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <string>
+#include <unordered_map>
+
+#include <fmt/format.h>
+
+#include "velox/connectors/Connector.h"
+
+namespace gluten {
+
+/// Split for one Kafka topic partition, covering offsets [startOffset, 
endOffset).
+/// Built from the substrait ReadRel.StreamKafka split info sent by the JVM, 
which
+/// mirrors Spark's KafkaBatchInputPartition.
+class KafkaConnectorSplit : public facebook::velox::connector::ConnectorSplit {
+ public:
+  KafkaConnectorSplit(
+      const std::string& connectorId,
+      std::string topic,
+      int32_t partition,
+      int64_t startOffset,
+      int64_t endOffset,
+      int64_t pollTimeoutMs,
+      bool failOnDataLoss,
+      std::unordered_map<std::string, std::string> kafkaParams)
+      : ConnectorSplit(connectorId),
+        topic(std::move(topic)),
+        partition(partition),
+        startOffset(startOffset),
+        endOffset(endOffset),
+        pollTimeoutMs(pollTimeoutMs),
+        failOnDataLoss(failOnDataLoss),
+        kafkaParams(std::move(kafkaParams)) {}
+
+  std::string toString() const override {
+    return fmt::format(
+        "KafkaConnectorSplit[topic={}, partition={}, startOffset={}, 
endOffset={}]",
+        topic,
+        partition,
+        startOffset,
+        endOffset);
+  }
+
+  const std::string topic;
+  const int32_t partition;
+  const int64_t startOffset;
+  // Exclusive.
+  const int64_t endOffset;
+  const int64_t pollTimeoutMs;
+  const bool failOnDataLoss;
+  // Kafka consumer properties with the "kafka." prefix already stripped, e.g. 
bootstrap.servers.
+  const std::unordered_map<std::string, std::string> kafkaParams;
+};
+
+} // namespace gluten
diff --git a/cpp/velox/tests/CMakeLists.txt b/cpp/velox/tests/CMakeLists.txt
index f8afb308cd..9abc4b6aa5 100644
--- a/cpp/velox/tests/CMakeLists.txt
+++ b/cpp/velox/tests/CMakeLists.txt
@@ -176,3 +176,7 @@ endif()
 if(ENABLE_ENHANCED_FEATURES)
   add_velox_test(velox_iceberg_test SOURCES iceberg/IcebergWriteTest.cc)
 endif()
+
+if(ENABLE_KAFKA)
+  add_velox_test(velox_kafka_connector_test SOURCES KafkaConnectorTest.cc)
+endif()
diff --git a/cpp/velox/tests/KafkaConnectorTest.cc 
b/cpp/velox/tests/KafkaConnectorTest.cc
new file mode 100644
index 0000000000..026da1b421
--- /dev/null
+++ b/cpp/velox/tests/KafkaConnectorTest.cc
@@ -0,0 +1,367 @@
+/*
+ * 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.
+ */
+
+#include <gtest/gtest.h>
+#include <librdkafka/rdkafka.h>
+#include <librdkafka/rdkafka_mock.h>
+#include <librdkafka/rdkafkacpp.h>
+
+#include "operators/reader/KafkaConnector.h"
+#include "operators/reader/KafkaDataSource.h"
+#include "velox/common/base/tests/GTestUtils.h"
+#include "velox/vector/FlatVector.h"
+#include "velox/vector/tests/utils/VectorTestBase.h"
+
+using namespace facebook::velox;
+
+namespace gluten {
+namespace {
+
+constexpr char kConnectorId[] = "kafka-test";
+constexpr char kTopic[] = "native-kafka-test";
+
+// Supply metadata independently of the mock broker's timestamp support.
+class KafkaMetadataMessage final : public RdKafka::Message {
+ public:
+  KafkaMetadataMessage(
+      int32_t partition,
+      RdKafka::MessageTimestamp::MessageTimestampType timestampType,
+      int64_t timestamp)
+      : partition_(partition), timestamp_{timestampType, timestamp} {}
+
+  int32_t partition() const override {
+    return partition_;
+  }
+
+  RdKafka::MessageTimestamp timestamp() const override {
+    return timestamp_;
+  }
+
+  std::string errstr() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  RdKafka::ErrorCode err() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  RdKafka::Topic* topic() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  std::string topic_name() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  void* payload() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  size_t len() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  const std::string* key() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  const void* key_pointer() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  size_t key_len() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  int64_t offset() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  void* msg_opaque() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  int64_t latency() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  rd_kafka_message_t* c_ptr() override {
+    VELOX_UNREACHABLE();
+  }
+
+  Status status() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  RdKafka::Headers* headers() override {
+    VELOX_UNREACHABLE();
+  }
+
+  RdKafka::Headers* headers(RdKafka::ErrorCode* /*error*/) override {
+    VELOX_UNREACHABLE();
+  }
+
+  int32_t broker_id() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  int32_t leader_epoch() const override {
+    VELOX_UNREACHABLE();
+  }
+
+  RdKafka::Error* offset_store() override {
+    VELOX_UNREACHABLE();
+  }
+
+ private:
+  const int32_t partition_;
+  const RdKafka::MessageTimestamp timestamp_;
+};
+
+} // namespace
+
+class KafkaConnectorTest : public ::testing::Test, public test::VectorTestBase 
{
+ protected:
+  static void SetUpTestCase() {
+    
memory::MemoryManager::testingSetInstance(memory::MemoryManager::Options{});
+  }
+
+  void SetUp() override {
+    auto* conf = rd_kafka_conf_new();
+    char error[512];
+    ASSERT_EQ(rd_kafka_conf_set(conf, "test.mock.num.brokers", "1", error, 
sizeof(error)), RD_KAFKA_CONF_OK);
+    producer_ = rd_kafka_new(RD_KAFKA_PRODUCER, conf, error, sizeof(error));
+    ASSERT_NE(producer_, nullptr) << error;
+    cluster_ = rd_kafka_handle_mock_cluster(producer_);
+    ASSERT_NE(cluster_, nullptr);
+    ASSERT_EQ(rd_kafka_mock_topic_create(cluster_, kTopic, 1, 1), 
RD_KAFKA_RESP_ERR_NO_ERROR);
+  }
+
+  void TearDown() override {
+    if (producer_ != nullptr) {
+      rd_kafka_destroy(producer_);
+    }
+  }
+
+  void produce(const std::optional<std::string>& key, const 
std::optional<std::string>& value) {
+    // librdkafka's macros cast arguments without grouping them, so 
parenthesize each conditional.
+    ASSERT_EQ(
+        rd_kafka_producev(
+            producer_,
+            RD_KAFKA_V_TOPIC(kTopic),
+            RD_KAFKA_V_PARTITION(0),
+            RD_KAFKA_V_MSGFLAGS(RD_KAFKA_MSG_F_COPY),
+            RD_KAFKA_V_KEY((key ? key->data() : nullptr), (key ? key->size() : 
0)),
+            RD_KAFKA_V_VALUE((value ? const_cast<char*>(value->data()) : 
nullptr), (value ? value->size() : 0)),
+            RD_KAFKA_V_END),
+        RD_KAFKA_RESP_ERR_NO_ERROR);
+    ASSERT_EQ(rd_kafka_flush(producer_, 10'000), RD_KAFKA_RESP_ERR_NO_ERROR);
+  }
+
+  std::shared_ptr<KafkaConnectorSplit> split(
+      int64_t start,
+      int64_t end,
+      bool failOnDataLoss = true,
+      std::unordered_map<std::string, std::string> kafkaParams = {}) {
+    kafkaParams.emplace("bootstrap.servers", 
rd_kafka_mock_cluster_bootstraps(cluster_));
+    return std::make_shared<KafkaConnectorSplit>(
+        kConnectorId, kTopic, 0, start, end, 10'000, failOnDataLoss, 
std::move(kafkaParams));
+  }
+
+  std::unique_ptr<KafkaDataSource> source(const RowTypePtr& type) {
+    connector::ColumnHandleMap columns;
+    for (size_t i = 0; i < type->size(); ++i) {
+      columns.emplace(type->nameOf(i), 
std::make_shared<KafkaColumnHandle>(type->nameOf(i), type->childAt(i)));
+    }
+    return std::make_unique<KafkaDataSource>(type, columns, pool());
+  }
+
+  RowVectorPtr toRowVector(KafkaDataSource& reader, const 
std::vector<std::unique_ptr<RdKafka::Message>>& messages) {
+    return reader.toRowVector(messages);
+  }
+
+  rd_kafka_t* producer_{nullptr};
+  rd_kafka_mock_cluster_t* cluster_{nullptr};
+};
+
+TEST_F(KafkaConnectorTest, columnValidation) {
+  EXPECT_EQ(KafkaColumnHandle("timestampType", INTEGER()).field(), 
KafkaField::kTimestampType);
+  VELOX_ASSERT_THROW(KafkaColumnHandle("offset", VARCHAR()), "Kafka column 
offset must be of type BIGINT");
+  VELOX_ASSERT_THROW(KafkaColumnHandle("headers", VARBINARY()), "Unsupported 
Kafka column: headers");
+}
+
+TEST_F(KafkaConnectorTest, projectedPartitionAndTimestampMetadata) {
+  using TimestampType = RdKafka::MessageTimestamp;
+  std::vector<std::unique_ptr<RdKafka::Message>> messages;
+  messages.push_back(
+      std::make_unique<KafkaMetadataMessage>(3, 
TimestampType::MSG_TIMESTAMP_CREATE_TIME, 1'700'000'000'123));
+  messages.push_back(
+      std::make_unique<KafkaMetadataMessage>(7, 
TimestampType::MSG_TIMESTAMP_LOG_APPEND_TIME, 1'700'000'456'789));
+  messages.push_back(std::make_unique<KafkaMetadataMessage>(11, 
TimestampType::MSG_TIMESTAMP_NOT_AVAILABLE, -1));
+
+  // Prune and reorder the metadata columns from Spark's Kafka schema.
+  auto reader = source(ROW({"timestampType", "partition", "timestamp"}, 
{INTEGER(), INTEGER(), TIMESTAMP()}));
+  auto batch = toRowVector(*reader, messages);
+  ASSERT_EQ(batch->size(), 3);
+
+  const std::vector<int32_t> expectedTypes{0, 1, -1};
+  const std::vector<int32_t> expectedPartitions{3, 7, 11};
+  // Spark converts the unavailable -1 ms sentinel to a non-null timestamp.
+  const std::vector<Timestamp> expectedTimestamps{
+      Timestamp(1'700'000'000, 123'000'000), Timestamp(1'700'000'456, 
789'000'000), Timestamp(-1, 999'000'000)};
+  auto* timestampTypes = batch->childAt(0)->as<FlatVector<int32_t>>();
+  auto* partitions = batch->childAt(1)->as<FlatVector<int32_t>>();
+  auto* timestamps = batch->childAt(2)->as<FlatVector<Timestamp>>();
+  for (vector_size_t row = 0; row < batch->size(); ++row) {
+    SCOPED_TRACE(row);
+    ASSERT_FALSE(timestampTypes->isNullAt(row));
+    ASSERT_FALSE(partitions->isNullAt(row));
+    ASSERT_FALSE(timestamps->isNullAt(row));
+    EXPECT_EQ(timestampTypes->valueAt(row), expectedTypes[row]);
+    EXPECT_EQ(partitions->valueAt(row), expectedPartitions[row]);
+    EXPECT_EQ(timestamps->valueAt(row), expectedTimestamps[row]);
+  }
+}
+
+TEST_F(KafkaConnectorTest, emptyAndConsecutiveSplits) {
+  auto reader = source(ROW({"offset"}, {BIGINT()}));
+  ContinueFuture future = ContinueFuture::makeEmpty();
+  // Empty ranges do not need consumer properties or a Kafka connection.
+  auto empty = std::make_shared<KafkaConnectorSplit>(
+      kConnectorId, kTopic, 0, 4, 4, 10'000, true, 
std::unordered_map<std::string, std::string>{});
+  reader->addSplit(empty);
+  VELOX_ASSERT_THROW(reader->addSplit(empty), "Previous Kafka split has not 
been fully processed");
+  EXPECT_EQ(reader->next(10, future).value(), nullptr);
+  reader->addSplit(empty);
+  EXPECT_EQ(reader->next(10, future).value(), nullptr);
+  EXPECT_EQ(reader->getCompletedRows(), 0);
+  EXPECT_EQ(reader->getCompletedBytes(), 0);
+  VELOX_ASSERT_THROW(reader->addSplit(nullptr), "Kafka split must not be 
null");
+}
+
+TEST_F(KafkaConnectorTest, exclusiveOffsetsAndProjectedBinaryRows) {
+  const std::string key("binary\0key", 10);
+  const std::string value("a long binary\0value preserved after polling", 43);
+  produce("excluded", "before range");
+  produce(key, value);
+  produce(std::nullopt, std::nullopt);
+  produce("", "");
+  produce("excluded", "after range");
+
+  // Prune and reorder the Spark Kafka schema. Reading in single-row batches 
also checks that
+  // strings remain owned by the output vector after librdkafka messages have 
been destroyed.
+  auto reader = source(ROW({"value", "offset", "key", "topic"}, {VARBINARY(), 
BIGINT(), VARBINARY(), VARCHAR()}));
+  reader->addSplit(split(1, 3));
+  ContinueFuture future = ContinueFuture::makeEmpty();
+  auto first = reader->next(1, future).value();
+  ASSERT_NE(first, nullptr);
+  ASSERT_EQ(first->size(), 1);
+  EXPECT_EQ(first->childAt(0)->as<FlatVector<StringView>>()->valueAt(0).str(), 
value);
+  EXPECT_EQ(first->childAt(1)->as<FlatVector<int64_t>>()->valueAt(0), 1);
+  EXPECT_EQ(first->childAt(2)->as<FlatVector<StringView>>()->valueAt(0).str(), 
key);
+  EXPECT_EQ(first->childAt(3)->as<FlatVector<StringView>>()->valueAt(0).str(), 
kTopic);
+
+  auto second = reader->next(1, future).value();
+  ASSERT_NE(second, nullptr);
+  ASSERT_EQ(second->size(), 1);
+  EXPECT_TRUE(second->childAt(0)->isNullAt(0));
+  EXPECT_TRUE(second->childAt(2)->isNullAt(0));
+  EXPECT_EQ(second->childAt(1)->as<FlatVector<int64_t>>()->valueAt(0), 2);
+  EXPECT_EQ(reader->next(1, future).value(), nullptr);
+
+  reader->addSplit(split(3, 4));
+  auto third = reader->next(10, future).value();
+  ASSERT_NE(third, nullptr);
+  ASSERT_EQ(third->size(), 1);
+  EXPECT_FALSE(third->childAt(0)->isNullAt(0));
+  EXPECT_FALSE(third->childAt(2)->isNullAt(0));
+  EXPECT_EQ(third->childAt(0)->as<FlatVector<StringView>>()->valueAt(0).str(), 
"");
+  EXPECT_EQ(third->childAt(2)->as<FlatVector<StringView>>()->valueAt(0).str(), 
"");
+  EXPECT_EQ(third->childAt(1)->as<FlatVector<int64_t>>()->valueAt(0), 3);
+  EXPECT_EQ(reader->next(10, future).value(), nullptr);
+  EXPECT_EQ(reader->getCompletedRows(), 3);
+  EXPECT_EQ(reader->getCompletedBytes(), key.size() + value.size());
+}
+
+TEST_F(KafkaConnectorTest, offsetOutOfRange) {
+  produce("key", "value");
+  ContinueFuture future = ContinueFuture::makeEmpty();
+  auto reader = source(ROW({"offset"}, {BIGINT()}));
+  reader->addSplit(split(100, 101));
+  VELOX_ASSERT_THROW(reader->next(1, future), "is no longer available");
+
+  auto ignoreDataLoss = source(ROW({"offset"}, {BIGINT()}));
+  ignoreDataLoss->addSplit(split(100, 101, false));
+  EXPECT_EQ(ignoreDataLoss->next(1, future).value(), nullptr);
+  EXPECT_EQ(ignoreDataLoss->getCompletedRows(), 0);
+}
+
+TEST_F(KafkaConnectorTest, missingLogTail) {
+  produce("key", "value");
+  ContinueFuture future = ContinueFuture::makeEmpty();
+  for (const auto* isolationLevel : {"read_uncommitted", "read_committed"}) {
+    SCOPED_TRACE(isolationLevel);
+    const std::unordered_map<std::string, std::string> 
params{{"isolation.level", isolationLevel}};
+    auto reader = source(ROW({"offset"}, {BIGINT()}));
+    // Emulate a split planned before the log was truncated: offset 0 still 
exists, but 1 does not.
+    reader->addSplit(split(0, 2, false, params));
+    auto batch = reader->next(10, future).value();
+    ASSERT_NE(batch, nullptr);
+    ASSERT_EQ(batch->size(), 1);
+    EXPECT_EQ(batch->childAt(0)->as<FlatVector<int64_t>>()->valueAt(0), 0);
+    EXPECT_EQ(reader->next(1, future).value(), nullptr);
+    EXPECT_EQ(reader->getCompletedRows(), 1);
+
+    // Completing the truncated range must release the split and allow another 
one.
+    reader->addSplit(split(0, 1, true, params));
+    ASSERT_NE(reader->next(1, future).value(), nullptr);
+    EXPECT_EQ(reader->next(1, future).value(), nullptr);
+
+    auto failOnDataLoss = source(ROW({"offset"}, {BIGINT()}));
+    failOnDataLoss->addSplit(split(0, 2, true, params));
+    ASSERT_NE(failOnDataLoss->next(1, future).value(), nullptr);
+    VELOX_ASSERT_THROW(failOnDataLoss->next(1, future), "Some data may have 
been lost");
+  }
+}
+
+TEST_F(KafkaConnectorTest, missingLogTailWithoutRecords) {
+  ContinueFuture future = ContinueFuture::makeEmpty();
+  auto reader = source(ROW({"offset"}, {BIGINT()}));
+  reader->addSplit(split(0, 1, false));
+  EXPECT_EQ(reader->next(1, future).value(), nullptr);
+  EXPECT_EQ(reader->getCompletedRows(), 0);
+
+  auto failOnDataLoss = source(ROW({"offset"}, {BIGINT()}));
+  failOnDataLoss->addSplit(split(0, 1));
+  VELOX_ASSERT_THROW(failOnDataLoss->next(1, future), "Some data may have been 
lost");
+}
+
+TEST_F(KafkaConnectorTest, invalidKafkaProperty) {
+  auto reader = source(ROW({"offset"}, {BIGINT()}));
+  auto invalid = std::make_shared<KafkaConnectorSplit>(
+      kConnectorId,
+      kTopic,
+      0,
+      0,
+      1,
+      10'000,
+      true,
+      std::unordered_map<std::string, std::string>{{"isolation.level", 
"invalid"}});
+  VELOX_ASSERT_THROW(reader->addSplit(invalid), "Invalid Kafka property 
isolation.level");
+}
+
+} // namespace gluten


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to