This is an automated email from the ASF dual-hosted git repository.
HeartSaVioR pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new c0f276233497 [SPARK-58386][SS] Restore backward compatibility of
SupportsRealTimeRead.nextWithTimeout
c0f276233497 is described below
commit c0f27623349747dc414e4560d0dc9e0cdb11229a
Author: Yuchen Liu <[email protected]>
AuthorDate: Sat Aug 8 13:34:56 2026 +0900
[SPARK-58386][SS] Restore backward compatibility of
SupportsRealTimeRead.nextWithTimeout
Backport of #57580 to branch-4.2. Cherry-picked cleanly from the merged
master commit 041ed937785 (no conflicts).
### What changes were proposed in this pull request?
Two changes for the public Real-Time Mode (RTM) connector SPI:
1. Restore the single-argument `nextWithTimeout(Long timeoutMs)` method on
the `Evolving` `SupportsRealTimeRead` interface, which SPARK-55699 replaced
with `nextWithTimeout(Long startTimeMs, Long timeoutMs)`. Both overloads are
now `default` methods:
- `nextWithTimeout(Long)` throws `UnsupportedOperationException` by
default and is the method a third-party source is expected to implement.
- `nextWithTimeout(Long, Long)` is the overload the engine invokes; by
default it delegates to `nextWithTimeout(Long)`, ignoring `startTimeMs`. It is
meant for engine-internal sources that need the engine's reference start time.
A source overrides whichever one it needs. The engine always invokes the
two-argument overload, so a source that overrides only the single-argument
variant (as external Spark 4.1-era sources do) is driven through the
delegation. Internal implementors (`LowLatencyMemoryStream`, Kafka) already
override the two-argument variant and are unchanged.
2. Add `StreamingRealTimeModeSourceCompatSuite`, a backward-compatibility
guard. It defines a self-contained RTM streaming source written exclusively
against the public `Evolving` connector APIs (`TableProvider`,
`Table`/`SupportsRead`, `ScanBuilder`/`Scan`, `MicroBatchStream` +
`SupportsRealTimeMode`, `PartitionReaderFactory`, `SupportsRealTimeRead`, and
custom `Offset`/`PartitionOffset`) and runs it end-to-end for both
`nextWithTimeout` entry points.
### Why are the changes needed?
SPARK-55699 changed the sole abstract method on the public interface rather
than adding an overload, which is a source- and binary-incompatible break. An
external source (a custom MQTT Real-Time Mode connector) that implemented
`nextWithTimeout(Long)` no longer compiles or links after upgrading from Spark
4.1 to 4.2. The `startTimeMs` parameter is only needed for the engine-internal
manual-clock path, so it should not be forced on external implementors.
### Does this PR introduce _any_ user-facing change?
Yes. It restores the Spark 4.1 `nextWithTimeout(Long)` method on the
`SupportsRealTimeRead` interface, so external sources written against Spark 4.1
compile and link again.
### How was this patch tested?
`StreamingRealTimeModeSourceCompatSuite`, which drives a public-API-only
RTM source end-to-end for both `nextWithTimeout` entry points. Verified it
compiles and passes on branch-4.2.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)
Closes #57860 from eason-yuchen-liu/SC-234671-backport-4.2.
Authored-by: Yuchen Liu
<[email protected]>
Signed-off-by: Jungtaek Lim <[email protected]>
---
.../read/streaming/SupportsRealTimeRead.java | 52 +++-
.../StreamingRealTimeModeSourceCompatSuite.scala | 271 +++++++++++++++++++++
2 files changed, 318 insertions(+), 5 deletions(-)
diff --git
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/streaming/SupportsRealTimeRead.java
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/streaming/SupportsRealTimeRead.java
index 5542781f333a..50150fc11fee 100644
---
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/streaming/SupportsRealTimeRead.java
+++
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/streaming/SupportsRealTimeRead.java
@@ -25,7 +25,22 @@ import org.apache.spark.sql.connector.read.PartitionReader;
/**
* A variation on {@link PartitionReader} for use with low latency streaming
processing.
- *
+ * <p>
+ * <b>Which method to implement.</b> A source reader must provide a {@code
nextWithTimeout} that
+ * proceeds to the next record, blocking until one is available or a timeout
elapses. There are two
+ * overloads, and a source should override exactly one:
+ * <ul>
+ * <li>{@link #nextWithTimeout(Long)} -- implement this in the common case.
It receives only the
+ * timeout and is all a third-party source needs.</li>
+ * <li>{@link #nextWithTimeout(Long, Long)} -- the overload the execution
engine actually invokes.
+ * Its default implementation ignores the extra parameter and delegates
to
+ * {@link #nextWithTimeout(Long)}. Override it only for engine-internal
sources that must
+ * observe the engine's reference start time (used together with the
engine-internal low
+ * latency clock); that clock is not part of the third-party contract,
so most sources should
+ * not override this overload.</li>
+ * </ul>
+ * Because both overloads are {@code default} methods, overriding neither is
not caught at compile
+ * time -- it fails at runtime when {@link #nextWithTimeout(Long)} throws.
*/
@Evolving
public interface SupportsRealTimeRead<T> extends PartitionReader<T> {
@@ -78,12 +93,39 @@ public interface SupportsRealTimeRead<T> extends
PartitionReader<T> {
* Alternative function to be called than next(), that proceed to the next
record. The different
* from next() is that, if there is no more records, the call needs to
keep waiting until
* the timeout.
- * @param startTimeMs the base time (milliseconds) the was used to
calculate the timeout.
- * Sources should use it as the reference time to start
waiting for the next
- * record instead of getting the latest time from
LowLatencyClock.
+ * <p>
+ * This is the recommended method to implement for a source. It is enough
for any source that
+ * does not need the engine's reference start time (see {@link
#nextWithTimeout(Long, Long)}).
+ * The engine always invokes {@link #nextWithTimeout(Long, Long)}, whose
default implementation
+ * delegates here, so overriding only this method is sufficient. If a
source overrides neither
+ * overload, this default implementation throws to surface the mistake.
* @param timeoutMs if no result is available after this timeout
(milliseconds), return
* @return {@link RecordStatus} describing whether a record is available
and its arrival time
* @throws IOException
*/
- RecordStatus nextWithTimeout(Long startTimeMs, Long timeoutMs) throws
IOException;
+ default RecordStatus nextWithTimeout(Long timeoutMs) throws IOException {
+ throw new UnsupportedOperationException(
+ "A SupportsRealTimeRead implementation must override either " +
+ "nextWithTimeout(Long) or nextWithTimeout(Long, Long).");
+ }
+
+ /**
+ * The overload of {@link #nextWithTimeout(Long)} that the execution
engine actually invokes. In
+ * addition to the timeout it receives {@code startTimeMs}, the reference
time the engine used
+ * to compute that timeout, so the engine and the source agree on when the
wait started (this
+ * matters when the engine runs against its internal low latency clock,
e.g. a manual clock in
+ * tests). The default implementation ignores {@code startTimeMs} and
delegates to
+ * {@link #nextWithTimeout(Long)}.
+ * <p>
+ * This is intended for engine-internal sources. The reference clock is
not part of the
+ * third-party contract, so third-party sources should implement {@link
#nextWithTimeout(Long)}
+ * and leave this overload to its default.
+ * @param startTimeMs the base time (milliseconds) that was used to
calculate the timeout
+ * @param timeoutMs if no result is available after this timeout
(milliseconds), return
+ * @return {@link RecordStatus} describing whether a record is available
and its arrival time
+ * @throws IOException
+ */
+ default RecordStatus nextWithTimeout(Long startTimeMs, Long timeoutMs)
throws IOException {
+ return nextWithTimeout(timeoutMs);
+ }
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingRealTimeModeSourceCompatSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingRealTimeModeSourceCompatSuite.scala
new file mode 100644
index 000000000000..6b20ddf08dac
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingRealTimeModeSourceCompatSuite.scala
@@ -0,0 +1,271 @@
+/*
+ * 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.spark.sql.streaming
+
+import java.util
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.connector.catalog.{SupportsRead, Table,
TableCapability, TableProvider}
+import org.apache.spark.sql.connector.expressions.Transform
+import org.apache.spark.sql.connector.read.{InputPartition, PartitionReader,
PartitionReaderFactory, Scan, ScanBuilder}
+import org.apache.spark.sql.connector.read.streaming.{MicroBatchStream,
Offset, PartitionOffset, SupportsRealTimeMode, SupportsRealTimeRead}
+import
org.apache.spark.sql.connector.read.streaming.SupportsRealTimeRead.RecordStatus
+import org.apache.spark.sql.execution.streaming.sources.ContinuousMemorySink
+import org.apache.spark.sql.sources.DataSourceRegister
+import org.apache.spark.sql.types.{IntegerType, StringType, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+import org.apache.spark.unsafe.types.UTF8String
+
+// scalastyle:off
+/**
+ *
============================================================================================
+ * BACKWARD-COMPATIBILITY GUARD -- DO NOT MODIFY THE SOURCE DEFINITIONS BELOW.
+ *
============================================================================================
+ *
+ * The classes in this file define a self-contained Real-Time Mode (RTM)
streaming source that is
+ * written EXCLUSIVELY against the public, `@Evolving` connector APIs an
external connector author
+ * has access to:
+ *
+ * - `org.apache.spark.sql.sources.DataSourceRegister`
+ * - `org.apache.spark.sql.connector.catalog.{TableProvider, Table,
SupportsRead,
+ * TableCapability}`
+ * - `org.apache.spark.sql.connector.read.{ScanBuilder, Scan,
InputPartition, PartitionReader,
+ * PartitionReaderFactory}`
+ * - `org.apache.spark.sql.connector.read.streaming.{MicroBatchStream,
Offset, PartitionOffset,
+ * SupportsRealTimeMode, SupportsRealTimeRead}`
+ *
+ * It deliberately does NOT use any `private[spark]` / internal helper (no
`LowLatencyMemoryStream`,
+ * `LongOffset`, `SimpleTableProvider`, RPC endpoints, or the engine-internal
low latency clock).
+ * Its purpose is to pin the source-level backward compatibility of the RTM
connector SPI: if a
+ * future change to `SupportsRealTimeMode` or `SupportsRealTimeRead` breaks
external implementors
+ * the way SPARK-55699 did (it replaced `nextWithTimeout(Long)` rather than
adding an overload, see
+ * SPARK-58386), this file will FAIL TO COMPILE, catching the incompatibility
at build time.
+ *
+ * `SupportsRealTimeRead` offers two `nextWithTimeout` overloads and a source
overrides exactly one.
+ * Both are covered here so the guard tracks either entry point:
+ * - `CompatOneArgPartitionReader` overrides only `nextWithTimeout(Long)` --
the method a
+ * third-party source is expected to implement. The engine invokes the
two-arg overload, so this
+ * reader is exercised through the interface's default delegation.
+ * - `CompatTwoArgPartitionReader` overrides `nextWithTimeout(Long, Long)`
directly -- the
+ * overload the engine invokes.
+ * Keep both.
+ *
+ * When you add a genuinely new REQUIRED method to one of these interfaces,
prefer a `default`
+ * method so this frozen source keeps compiling. If a required change is truly
unavoidable, updating
+ * this file is a strong signal that external connectors will also break --
treat it accordingly.
+ *
============================================================================================
+ */
+// scalastyle:on
+
+/** Deterministic, never-changing dataset the guard source serves. */
+private object CompatRealTimeData {
+ val schema: StructType =
+ new StructType().add("value", IntegerType).add("name", StringType)
+
+ // A fixed, finite dataset. Frozen on purpose -- do not change.
+ val records: Array[(Int, String)] = Array((1, "a"), (2, "b"), (3, "c"))
+}
+
+/** A public-API `Offset`: the number of records consumed so far. */
+private case class CompatOffset(consumed: Int) extends Offset {
+ override def json(): String = consumed.toString
+}
+
+/** A public-API per-partition `PartitionOffset`. */
+private case class CompatPartitionOffset(partitionId: Int, offset: Int)
extends PartitionOffset
+
+/** A serializable `InputPartition` carrying its slice of the frozen dataset.
*/
+private case class CompatInputPartition(
+ partitionId: Int,
+ startOffset: Int,
+ rows: Array[(Int, String)])
+ extends InputPartition
+
+/**
+ * Shared reader logic for both `nextWithTimeout` entry points. Concrete
subclasses only pick which
+ * overload of [[SupportsRealTimeRead#nextWithTimeout]] to override; both
route to [[pollNext]].
+ */
+private abstract class CompatRealTimePartitionReaderBase(partition:
CompatInputPartition)
+ extends SupportsRealTimeRead[InternalRow] {
+
+ private var pos = 0
+ private var currentRow: InternalRow = _
+
+ private def toRow(i: Int): InternalRow = {
+ val (v, n) = partition.rows(i)
+ InternalRow(v, UTF8String.fromString(n))
+ }
+
+ /** Return the next record, or wait until the timeout elapses and report no
record. */
+ protected final def pollNext(timeoutMs: java.lang.Long): RecordStatus = {
+ if (pos < partition.rows.length) {
+ val (value, _) = partition.rows(pos)
+ currentRow = toRow(pos)
+ pos += 1
+ // Report the record along with a deterministic synthetic arrival time,
exercising the
+ // arrival-time branch of RecordStatus.
+ return RecordStatus.newStatusWithArrivalTimeMs(value.toLong)
+ }
+ // Exhausted this batch's data: keep waiting until the caller's timeout
elapses, then report
+ // no record -- the same wait-until-timeout behavior a real source has.
Measured against the
+ // wall clock, as a third-party source without the engine's reference
clock would do.
+ val startNs = System.nanoTime()
+ var elapsedMs = 0L
+ while (elapsedMs < timeoutMs) {
+ Thread.sleep(10L)
+ elapsedMs = (System.nanoTime() - startNs) / 1000000L
+ }
+ RecordStatus.newStatusWithoutArrivalTime(false)
+ }
+
+ override def getOffset: PartitionOffset =
+ CompatPartitionOffset(partition.partitionId, partition.startOffset + pos)
+
+ override def next(): Boolean = {
+ if (pos < partition.rows.length) {
+ currentRow = toRow(pos)
+ pos += 1
+ true
+ } else {
+ false
+ }
+ }
+
+ override def get(): InternalRow = currentRow
+
+ override def close(): Unit = {}
+}
+
+/**
+ * Reader that overrides ONLY the single-argument `nextWithTimeout(Long)` --
the method a
+ * third-party source is expected to implement. Reached through the two-arg
default delegation.
+ */
+private class CompatOneArgPartitionReader(partition: CompatInputPartition)
+ extends CompatRealTimePartitionReaderBase(partition) {
+ override def nextWithTimeout(timeoutMs: java.lang.Long): RecordStatus =
pollNext(timeoutMs)
+}
+
+/** Reader that overrides the two-argument `nextWithTimeout(Long, Long)` the
engine invokes. */
+private class CompatTwoArgPartitionReader(partition: CompatInputPartition)
+ extends CompatRealTimePartitionReaderBase(partition) {
+ override def nextWithTimeout(
+ startTimeMs: java.lang.Long, timeoutMs: java.lang.Long): RecordStatus =
pollNext(timeoutMs)
+}
+
+/** A public-API `PartitionReaderFactory`, parameterized by which reader
overload to use. */
+private class CompatRealTimeReaderFactory(twoArg: Boolean) extends
PartitionReaderFactory {
+ override def createReader(partition: InputPartition):
PartitionReader[InternalRow] = {
+ val p = partition.asInstanceOf[CompatInputPartition]
+ if (twoArg) new CompatTwoArgPartitionReader(p) else new
CompatOneArgPartitionReader(p)
+ }
+}
+
+/** The RTM stream: a public `MicroBatchStream` that also implements
`SupportsRealTimeMode`. */
+private class CompatRealTimeStream(twoArg: Boolean)
+ extends MicroBatchStream with SupportsRealTimeMode {
+ override def initialOffset(): Offset = CompatOffset(0)
+ override def deserializeOffset(json: String): Offset =
CompatOffset(json.toInt)
+ override def commit(end: Offset): Unit = {}
+ override def stop(): Unit = {}
+
+ override def latestOffset(): Offset =
CompatOffset(CompatRealTimeData.records.length)
+ override def planInputPartitions(start: Offset, end: Offset):
Array[InputPartition] = {
+ val from = start.asInstanceOf[CompatOffset].consumed
+ val to = end.asInstanceOf[CompatOffset].consumed
+ Array(CompatInputPartition(0, from, CompatRealTimeData.records.slice(from,
to)))
+ }
+ override def createReaderFactory(): PartitionReaderFactory =
+ new CompatRealTimeReaderFactory(twoArg)
+
+ override def planInputPartitions(start: Offset): Array[InputPartition] = {
+ val from = start.asInstanceOf[CompatOffset].consumed
+ Array(CompatInputPartition(0, from, CompatRealTimeData.records.drop(from)))
+ }
+ override def mergeOffsets(offsets: Array[PartitionOffset]): Offset = {
+ val maxOffset =
offsets.map(_.asInstanceOf[CompatPartitionOffset].offset).max
+ CompatOffset(maxOffset)
+ }
+}
+
+/** Scan + ScanBuilder wired to the RTM stream, using only public APIs. */
+private class CompatRealTimeScan(twoArg: Boolean) extends ScanBuilder with
Scan {
+ override def build(): Scan = this
+ override def readSchema(): StructType = CompatRealTimeData.schema
+ override def toMicroBatchStream(checkpointLocation: String):
MicroBatchStream =
+ new CompatRealTimeStream(twoArg)
+}
+
+/**
+ * The top-level source, registered as a `TableProvider` +
`DataSourceRegister`. Loaded by fully
+ * qualified class name via `spark.readStream.format(...)`, so it doesn't need
a `META-INF/services`
+ * registration entry. The `twoArg` option selects which `nextWithTimeout`
overload the reader
+ * implements.
+ */
+class CompatRealTimeSourceProvider extends TableProvider with
DataSourceRegister {
+ override def shortName(): String = "compat-realtime-source"
+
+ override def inferSchema(options: CaseInsensitiveStringMap): StructType =
+ CompatRealTimeData.schema
+
+ override def getTable(
+ schema: StructType,
+ partitioning: Array[Transform],
+ properties: util.Map[String, String]): Table = new CompatRealTimeTable
+}
+
+private class CompatRealTimeTable extends Table with SupportsRead {
+ override def name(): String = "compat-realtime-source"
+ override def schema(): StructType = CompatRealTimeData.schema
+ override def capabilities(): util.Set[TableCapability] =
+ util.EnumSet.of(TableCapability.MICRO_BATCH_READ)
+ override def newScanBuilder(options: CaseInsensitiveStringMap): ScanBuilder =
+ new CompatRealTimeScan(options.getBoolean("twoArg", false))
+}
+
+/**
+ * SPARK-58386: a compile-time and runtime backward-compatibility guard for
the public Real-Time
+ * Mode connector SPI (`SupportsRealTimeMode` / `SupportsRealTimeRead`). The
frozen source above
+ * must keep compiling against these interfaces, and these tests run it
end-to-end through a real
+ * RTM streaming query to prove an external-style source is still driven
correctly, for both
+ * `nextWithTimeout` entry points.
+ */
+class StreamingRealTimeModeSourceCompatSuite extends
StreamRealTimeModeManualClockSuiteBase {
+ import testImplicits._
+
+ private def runSourceEndToEnd(twoArg: Boolean): Unit = {
+ val df = spark.readStream
+ .format(classOf[CompatRealTimeSourceProvider].getName)
+ .option("twoArg", twoArg)
+ .load()
+ .selectExpr("concat(cast(value as string), '-', name) as output")
+
+ testStream(df, OutputMode.Update, Map.empty, new ContinuousMemorySink())(
+ StartStream(),
+ CheckAnswerWithTimeout(10000, "1-a", "2-b", "3-c"),
+ StopStream
+ )
+ }
+
+ test("RTM source implementing single-arg nextWithTimeout reads end-to-end") {
+ runSourceEndToEnd(twoArg = false)
+ }
+
+ test("RTM source implementing two-arg nextWithTimeout reads end-to-end") {
+ runSourceEndToEnd(twoArg = true)
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]