This is an automated email from the ASF dual-hosted git repository.
dongjoon-hyun 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 26b3fe6d79a8 [SPARK-58451][CONNECT] Filter null extension elements
returned by GetStatus plugins
26b3fe6d79a8 is described below
commit 26b3fe6d79a8f4b8fa40a6ccab15e03b0a98ac5f
Author: haoyangeng-db <[email protected]>
AuthorDate: Fri Aug 7 13:52:26 2026 -0700
[SPARK-58451][CONNECT] Filter null extension elements returned by GetStatus
plugins
### What changes were proposed in this pull request?
`SparkConnectGetStatusHandler` collects response extensions from every
registered `GetStatusPlugin` and adds them to the response with
`GetStatusResponse.Builder.addExtensions` /
`OperationStatus.Builder.addExtensions`. Those protobuf builder methods reject
`null`, so a plugin that returns a list containing a `null` element makes the
whole `GetStatus` RPC fail with a NullPointerException that escapes the
per-plugin `try`/`catch` isolation.
This filters `null` elements out inside that isolation boundary, at both
the request level and the operation level, so one misbehaving plugin can no
longer break the RPC for everyone.
### Why are the changes needed?
The handler already goes to some length to isolate plugin failures: each
plugin call is wrapped in `try`/`catch NonFatal`, and a throwing plugin is
logged and skipped so healthy plugins still contribute. A `null` list element
defeats that, because the NullPointerException is raised later by the builder,
outside the `try` block, and fails the request. Plugins are third-party
extension points, so the handler should not assume their returned lists are
null-free.
### Does this PR introduce _any_ user-facing change?
No. With well-behaved plugins the behavior is unchanged; only the failure
mode for a plugin returning a null element changes, from a failed RPC to that
element being dropped.
### How was this patch tested?
Added `GetStatusHandlerSuite."GetStatus filters null extension elements
returned by a plugin"`, which registers a plugin returning a single-null list
alongside a healthy echo plugin and asserts the null is dropped while the
healthy plugin's extension survives, at both the request and operation levels.
### Was this patch authored or co-authored using generative AI tooling?
Co-authored by Claude Code.
Closes #57638 from haoyangeng-db/getstatus-plugin-null-extensions.
Authored-by: haoyangeng-db <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
---
.../service/SparkConnectGetStatusHandler.scala | 6 ++-
.../connect/service/GetStatusHandlerSuite.scala | 48 ++++++++++++++++++++++
2 files changed, 52 insertions(+), 2 deletions(-)
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectGetStatusHandler.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectGetStatusHandler.scala
index c37061ff2eba..fcc7089f27aa 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectGetStatusHandler.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectGetStatusHandler.scala
@@ -179,7 +179,8 @@ class SparkConnectGetStatusHandler(responseObserver:
StreamObserver[proto.GetSta
SparkConnectPluginRegistry.getStatusRegistry.flatMap { plugin =>
try {
plugin.processRequestExtensions(sessionHolder,
requestExtensions).toScala match {
- case Some(extensions) => extensions.asScala.toSeq
+ // Filter nulls inside the isolation boundary so a null element
can't NPE addExtensions.
+ case Some(extensions) => extensions.asScala.iterator.filter(_ !=
null).toSeq
case None => Seq.empty
}
} catch {
@@ -202,7 +203,8 @@ class SparkConnectGetStatusHandler(responseObserver:
StreamObserver[proto.GetSta
plugin
.processOperationExtensions(operationId, sessionHolder,
operationExtensions)
.toScala match {
- case Some(extensions) => extensions.asScala.toSeq
+ // Filter nulls inside the isolation boundary so a null element
can't NPE addExtensions.
+ case Some(extensions) => extensions.asScala.iterator.filter(_ !=
null).toSeq
case None => Seq.empty
}
} catch {
diff --git
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/GetStatusHandlerSuite.scala
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/GetStatusHandlerSuite.scala
index 21d96fd8a87d..e8e960fdb578 100644
---
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/GetStatusHandlerSuite.scala
+++
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/service/GetStatusHandlerSuite.scala
@@ -88,6 +88,28 @@ class NoOpGetStatusPlugin extends GetStatusPlugin {
Optional.empty()
}
+/**
+ * A plugin that returns a list containing a null element, to test null
filtering.
+ */
+class NullElementGetStatusPlugin extends GetStatusPlugin {
+ override def processRequestExtensions(
+ sessionHolder: SessionHolder,
+ requestExtensions: util.List[protobuf.Any]):
Optional[util.List[protobuf.Any]] =
+ listWithNull()
+
+ override def processOperationExtensions(
+ operationId: String,
+ sessionHolder: SessionHolder,
+ operationExtensions: util.List[protobuf.Any]):
Optional[util.List[protobuf.Any]] =
+ listWithNull()
+
+ private def listWithNull(): Optional[util.List[protobuf.Any]] = {
+ val result = new util.ArrayList[protobuf.Any]()
+ result.add(null)
+ Optional.of(result)
+ }
+}
+
/**
* A plugin that always throws a RuntimeException.
*/
@@ -344,6 +366,32 @@ class GetStatusHandlerSuite extends SharedSparkSession {
assert(opExtValues.contains(s"op-echo:${executeHolder.operationId}:safe"))
assert(opExtValues.contains(s"second-op:${executeHolder.operationId}:safe"))
}
+
+ test("GetStatus filters null extension elements returned by a plugin") {
+ SparkConnectPluginRegistry.setGetStatusPluginsForTesting(
+ Seq(new NullElementGetStatusPlugin(), new EchoGetStatusPlugin()))
+ val sessionHolder = SparkConnectTestUtils.createDummySessionHolder(spark)
+ val command = proto.Command.newBuilder().build()
+ val executeHolder =
SparkConnectTestUtils.createDummyExecuteHolder(sessionHolder, command)
+
+ val reqExt = protobuf.Any.pack(StringValue.of("data"))
+ val opExt = protobuf.Any.pack(StringValue.of("data"))
+ val response = sendGetOperationStatusRequest(
+ sessionHolder.sessionId,
+ operationIds = Seq(executeHolder.operationId),
+ userId = sessionHolder.userId,
+ requestExtensions = Seq(reqExt),
+ operationExtensions = Seq(opExt))
+
+ // The null element is dropped; only the healthy plugin's extension
survives, at both levels.
+ val responseExtValues = response.getExtensionsList.asScala
+ .map(_.unpack(classOf[StringValue]).getValue)
+ assert(responseExtValues == Seq("request-echo:data"))
+
+ val opExtValues =
response.getOperationStatusesList.asScala.head.getExtensionsList.asScala
+ .map(_.unpack(classOf[StringValue]).getValue)
+ assert(opExtValues == Seq(s"op-echo:${executeHolder.operationId}:data"))
+ }
}
private class GetStatusResponseObserver extends
StreamObserver[proto.GetStatusResponse] {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]