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

HeartSaVioR pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new c369cf28e26d [SPARK-49442][SS] Cache Kafka partition metadata to 
reduce DescribeTopics RPCs per micro-batch
c369cf28e26d is described below

commit c369cf28e26d3c941e177a0e894308dbef58bbeb
Author: Vinod KC <[email protected]>
AuthorDate: Tue Jul 21 07:55:08 2026 +0900

    [SPARK-49442][SS] Cache Kafka partition metadata to reduce DescribeTopics 
RPCs per micro-batch
    
    ### What changes were proposed in this pull request?
    
    Adds a new source option `partition.metadata.cache.ttl.ms` to 
`KafkaOffsetReaderAdmin`. When set to a positive value, the driver caches the 
set of assigned `TopicPartition`s and reuses it for all offset fetch calls 
within the TTL window, skipping the `DescribeTopics` RPC to the broker. The 
cache is invalidated on admin reset (i.e., on retry). Default is `-1` (caching 
disabled, preserving existing behavior).
    
    ### Why are the changes needed?
    
    Every micro-batch unconditionally called 
`consumerStrategy.assignedTopicPartitions(admin)`, which issues a 
`DescribeTopics` RPC to the Kafka broker even though partition assignments are 
stable in most production deployments. For deployments with large partition 
counts and short trigger intervals, this generates unnecessary metadata traffic.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Adds a new source option `partition.metadata.cache.ttl.ms` (long, 
default `-1`). Valid values are `-1` (caching disabled) or any positive integer 
representing milliseconds. Documentation is updated accordingly. Existing 
behavior is unchanged when the option is not set.
    
    > **Note:** when caching is enabled, newly added partitions will not be 
discovered until the cache expires.
    
    ### How was this patch tested?
    
    Added tests in `KafkaOffsetReaderSuite` `KafkaSourceProviderSuite`, 
`KafkaOffsetReaderSuite` and `KafkaSourceProviderSuite`.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, 'Generated-by:  Claude (Sonnet 4.6)
    
    Closes #57351 from vinodkc/spark-49442.
    
    Authored-by: Vinod KC <[email protected]>
    Signed-off-by: Jungtaek Lim <[email protected]>
---
 .../sql/kafka010/KafkaOffsetReaderAdmin.scala      | 31 +++++++-
 .../spark/sql/kafka010/KafkaSourceProvider.scala   | 10 +++
 .../sql/kafka010/KafkaOffsetReaderSuite.scala      | 86 ++++++++++++++++++++++
 .../sql/kafka010/KafkaSourceProviderSuite.scala    | 21 ++++++
 .../structured-streaming-kafka-integration.md      | 13 ++++
 5 files changed, 159 insertions(+), 2 deletions(-)

diff --git 
a/connector/kafka-0-10-sql/src/main/scala/org/apache/spark/sql/kafka010/KafkaOffsetReaderAdmin.scala
 
b/connector/kafka-0-10-sql/src/main/scala/org/apache/spark/sql/kafka010/KafkaOffsetReaderAdmin.scala
index 69f346af19a8..f182e156c76e 100644
--- 
a/connector/kafka-0-10-sql/src/main/scala/org/apache/spark/sql/kafka010/KafkaOffsetReaderAdmin.scala
+++ 
b/connector/kafka-0-10-sql/src/main/scala/org/apache/spark/sql/kafka010/KafkaOffsetReaderAdmin.scala
@@ -57,6 +57,13 @@ private[kafka010] class KafkaOffsetReaderAdmin(
   private[kafka010] val offsetFetchAttemptIntervalMs =
     
readerOptions.getOrElse(KafkaSourceProvider.FETCH_OFFSET_RETRY_INTERVAL_MS, 
"1000").toLong
 
+  private val partitionMetadataCacheTtlMs: Long =
+    
readerOptions.getOrElse(KafkaSourceProvider.PARTITION_METADATA_CACHE_TTL_MS, 
"-1").toLong
+
+  // Protected by this.synchronized (always accessed inside withRetries, which 
holds the lock).
+  private var cachedPartitions: Set[TopicPartition] = Set.empty
+  private var cacheTimestampMs: Long = 0L
+
   /**
    * An AdminClient used in the driver to query the latest Kafka offsets.
    * This only queries the offsets because AdminClient has no functionality to 
commit offsets like
@@ -127,7 +134,7 @@ private[kafka010] class KafkaOffsetReaderAdmin(
       logDebug(s"Assigned partitions: $partitions. Seeking to 
$partitionOffsets")
       partitionOffsets
     }
-    val partitions = withRetries { 
consumerStrategy.assignedTopicPartitions(admin) }
+    val partitions = withRetries { resolvePartitions() }
     // Obtain TopicPartition offsets with late binding support
     offsetRangeLimit match {
       case EarliestOffsetRangeLimit => partitions.map {
@@ -439,12 +446,30 @@ private[kafka010] class KafkaOffsetReaderAdmin(
     }
   }
 
+  // Must be called inside withRetries (which holds this.synchronized).
+  private def resolvePartitions(): Set[TopicPartition] = {
+    if (partitionMetadataCacheTtlMs <= 0) {
+      return consumerStrategy.assignedTopicPartitions(admin)
+    }
+    val now = System.currentTimeMillis()
+    if (cacheTimestampMs > 0 && (now - cacheTimestampMs) < 
partitionMetadataCacheTtlMs) {
+      logDebug(s"Reusing cached partitions (age ${now - cacheTimestampMs}ms < 
" +
+        s"${partitionMetadataCacheTtlMs}ms TTL): $cachedPartitions")
+      cachedPartitions
+    } else {
+      val fresh = consumerStrategy.assignedTopicPartitions(admin)
+      cachedPartitions = fresh
+      cacheTimestampMs = now
+      fresh
+    }
+  }
+
   private def partitionsAssignedToAdmin(
       body: ju.Set[TopicPartition] => Map[TopicPartition, Long])
     : Map[TopicPartition, Long] = {
 
     withRetries {
-      val partitions = consumerStrategy.assignedTopicPartitions(admin).asJava
+      val partitions = resolvePartitions().asJava
       logDebug(s"Partitions assigned: $partitions.")
       body(partitions)
     }
@@ -493,5 +518,7 @@ private[kafka010] class KafkaOffsetReaderAdmin(
   private def resetAdmin(): Unit = synchronized {
     stopAdmin()
     _admin = null  // will automatically get reinitialized again
+    cachedPartitions = Set.empty
+    cacheTimestampMs = 0L
   }
 }
diff --git 
a/connector/kafka-0-10-sql/src/main/scala/org/apache/spark/sql/kafka010/KafkaSourceProvider.scala
 
b/connector/kafka-0-10-sql/src/main/scala/org/apache/spark/sql/kafka010/KafkaSourceProvider.scala
index ff2f16d26b93..b5a7bcb98fba 100644
--- 
a/connector/kafka-0-10-sql/src/main/scala/org/apache/spark/sql/kafka010/KafkaSourceProvider.scala
+++ 
b/connector/kafka-0-10-sql/src/main/scala/org/apache/spark/sql/kafka010/KafkaSourceProvider.scala
@@ -274,6 +274,15 @@ private[kafka010] class KafkaSourceProvider extends 
DataSourceRegister
       if (p <= 0) throw new IllegalArgumentException("minPartitions must be 
positive")
     }
 
+    if (params.contains(PARTITION_METADATA_CACHE_TTL_MS)) {
+      val v = params(PARTITION_METADATA_CACHE_TTL_MS).toLong
+      if (v != -1 && v <= 0) {
+        throw new IllegalArgumentException(
+          "Option 'partition.metadata.cache.ttl.ms' must be a positive number 
of milliseconds, " +
+          "or -1 to disable caching")
+      }
+    }
+
     if (params.contains(MAX_RECORDS_PER_PARTITION_OPTION_KEY)) {
       val p = params(MAX_RECORDS_PER_PARTITION_OPTION_KEY).toLong
       if (p <= 0) {
@@ -577,6 +586,7 @@ private[kafka010] object KafkaSourceProvider extends 
Logging {
   private[kafka010] val DEFAULT_MAX_TRIGGER_DELAY = "15m"
   private[kafka010] val FETCH_OFFSET_NUM_RETRY = "fetchoffset.numretries"
   private[kafka010] val FETCH_OFFSET_RETRY_INTERVAL_MS = 
"fetchoffset.retryintervalms"
+  private[kafka010] val PARTITION_METADATA_CACHE_TTL_MS = 
"partition.metadata.cache.ttl.ms"
   private[kafka010] val CONSUMER_POLL_TIMEOUT = "kafkaconsumer.polltimeoutms"
   private[kafka010] val STARTING_OFFSETS_BY_TIMESTAMP_STRATEGY_KEY =
     "startingoffsetsbytimestampstrategy"
diff --git 
a/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaOffsetReaderSuite.scala
 
b/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaOffsetReaderSuite.scala
index 341ad14a4fbb..bed76cb8990a 100644
--- 
a/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaOffsetReaderSuite.scala
+++ 
b/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaOffsetReaderSuite.scala
@@ -323,4 +323,90 @@ class KafkaOffsetReaderSuite extends SharedSparkSession 
with KafkaTest {
       reader.close()
     }
   }
+
+  // SPARK-49442: When partition.metadata.cache.ttl.ms is set, repeated 
fetchLatestOffsets
+  // calls within the TTL window must reuse the cached partition set and issue 
only one
+  // DescribeTopics RPC to the broker, regardless of how many micro-batches 
run.
+  test("SPARK-49442: partition.metadata.cache.ttl.ms suppresses redundant " +
+      "DescribeTopics RPCs within the TTL window") {
+    val topic = newTopic()
+    testUtils.createTopic(topic, partitions = 3)
+
+    val ttlMs = 300000 // 5 minutes
+    val describeCount = new AtomicInteger(0)
+    val countingStrategy = new SubscribeStrategy(Seq(topic)) {
+      override def assignedTopicPartitions(admin: Admin): Set[TopicPartition] 
= {
+        describeCount.incrementAndGet()
+        super.assignedTopicPartitions(admin)
+      }
+    }
+
+    val reader = new KafkaOffsetReaderAdmin(
+      countingStrategy,
+      KafkaSourceProvider.kafkaParamsForDriver(Map(
+        "bootstrap.servers" -> testUtils.brokerAddress
+      )),
+      CaseInsensitiveMap(Map(
+        KafkaSourceProvider.PARTITION_METADATA_CACHE_TTL_MS -> ttlMs.toString
+      )),
+      ""
+    )
+
+    try {
+      val numBatches = 5
+      for (_ <- 1 to numBatches) {
+        reader.fetchLatestOffsets(None)
+      }
+      // All 5 fetches fall within the TTL window, so the partition set is 
resolved only once.
+      assert(describeCount.get() === 1,
+        s"Expected 1 DescribeTopics call but got ${describeCount.get()}")
+    } finally {
+      reader.close()
+    }
+  }
+
+  test("SPARK-49442: partition.metadata.cache.ttl.ms refreshes after TTL 
expires") {
+    val topic = newTopic()
+    testUtils.createTopic(topic, partitions = 3)
+
+    val ttlMs = 2000 // large enough to not expire during two sequential 
fetches
+    val describeCount = new AtomicInteger(0)
+    val countingStrategy = new SubscribeStrategy(Seq(topic)) {
+      override def assignedTopicPartitions(admin: Admin): Set[TopicPartition] 
= {
+        describeCount.incrementAndGet()
+        super.assignedTopicPartitions(admin)
+      }
+    }
+
+    val reader = new KafkaOffsetReaderAdmin(
+      countingStrategy,
+      KafkaSourceProvider.kafkaParamsForDriver(Map(
+        "bootstrap.servers" -> testUtils.brokerAddress
+      )),
+      CaseInsensitiveMap(Map(
+        KafkaSourceProvider.PARTITION_METADATA_CACHE_TTL_MS -> ttlMs.toString
+      )),
+      ""
+    )
+
+    try {
+      // First fetch populates the cache (1 RPC).
+      reader.fetchLatestOffsets(None)
+      assert(describeCount.get() === 1)
+
+      // Fetches within the TTL reuse the cache (still 1 RPC).
+      reader.fetchLatestOffsets(None)
+      assert(describeCount.get() === 1)
+
+      // Sleep past the TTL so the next fetch must refresh.
+      Thread.sleep(ttlMs + 50)
+
+      // Cache has expired: one more RPC expected.
+      reader.fetchLatestOffsets(None)
+      assert(describeCount.get() === 2,
+        s"Expected 2 DescribeTopics calls after TTL expiry but got 
${describeCount.get()}")
+    } finally {
+      reader.close()
+    }
+  }
 }
diff --git 
a/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaSourceProviderSuite.scala
 
b/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaSourceProviderSuite.scala
index 303394b44579..2601b118132f 100644
--- 
a/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaSourceProviderSuite.scala
+++ 
b/connector/kafka-0-10-sql/src/test/scala/org/apache/spark/sql/kafka010/KafkaSourceProviderSuite.scala
@@ -133,4 +133,25 @@ class KafkaSourceProviderSuite extends SparkFunSuite {
     val provider = new KafkaSourceProvider()
     provider.getTable(options).newScanBuilder(options).build()
   }
+
+  test("SPARK-49442: partition.metadata.cache.ttl.ms validation") {
+    val sparkEnv = mock(classOf[SparkEnv])
+    when(sparkEnv.conf).thenReturn(new SparkConf())
+    SparkEnv.set(sparkEnv)
+
+    // -1 (disabled) and positive values are valid; validation fires inside 
toMicroBatchStream
+    Seq("-1", "1", "30000").foreach { v =>
+      val options = buildKafkaSourceCaseInsensitiveStringMap(
+        KafkaSourceProvider.PARTITION_METADATA_CACHE_TTL_MS -> v)
+      getKafkaDataSourceScan(options).toMicroBatchStream("dummy")
+    }
+    // 0 and other non-positive values (except -1) are invalid
+    Seq("0", "-2", "-100").foreach { v =>
+      val options = buildKafkaSourceCaseInsensitiveStringMap(
+        KafkaSourceProvider.PARTITION_METADATA_CACHE_TTL_MS -> v)
+      intercept[IllegalArgumentException] {
+        getKafkaDataSourceScan(options).toMicroBatchStream("dummy")
+      }
+    }
+  }
 }
diff --git a/docs/streaming/structured-streaming-kafka-integration.md 
b/docs/streaming/structured-streaming-kafka-integration.md
index 6cefcb561f22..364fc77f1ef1 100644
--- a/docs/streaming/structured-streaming-kafka-integration.md
+++ b/docs/streaming/structured-streaming-kafka-integration.md
@@ -481,6 +481,19 @@ The following configurations are optional:
   <td>streaming and batch</td>
   <td>milliseconds to wait before retrying to fetch Kafka offsets</td>
 </tr>
+<tr>
+  <td>partition.metadata.cache.ttl.ms</td>
+  <td>long</td>
+  <td>-1</td>
+  <td>streaming and batch</td>
+  <td>How long (in milliseconds) to cache the set of assigned Kafka 
topic-partitions in the driver
+  before issuing a fresh <code>DescribeTopics</code> RPC to the broker. When 
set to a positive value,
+  repeated offset fetches within the TTL window reuse the cached partition set 
instead of querying
+  the broker each time, reducing metadata load on the broker and on the 
driver. This mirrors
+  the semantics of the Kafka client's own <code>metadata.max.age.ms</code> 
setting. Set to -1
+  (the default) to disable caching (existing behavior). Note: newly added 
partitions will not be
+  discovered until the cache expires.</td>
+</tr>
 <tr>
   <td>maxOffsetsPerTrigger</td>
   <td>long</td>


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

Reply via email to