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

dtenedor 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 308de008520e [SPARK-58109][SQL][PROTOBUF] Use Hadoop file reader for 
SQL from_protobuf descriptor files
308de008520e is described below

commit 308de008520e096fe6796451c20ddb6ec656b83e
Author: Kavpreet Grewal <[email protected]>
AuthorDate: Tue Jul 14 17:16:03 2026 -0700

    [SPARK-58109][SQL][PROTOBUF] Use Hadoop file reader for SQL from_protobuf 
descriptor files
    
    ### What changes were proposed in this pull request?
    
    SQL `from_protobuf`/`to_protobuf` read their descriptor-file argument with 
local NIO (`Files.readAllBytes`), so a descriptor stored on a distributed 
filesystem (e.g. `hdfs:`, `s3:`, `abfss:`) fails even though the same path 
works via the DataFrame API. This adds `ProtobufDescriptorFileReader`, which 
routes the descriptor read through the Hadoop `FileSystem` so those paths 
resolve, with a fallback to the existing local read for scheme-less paths. The 
read runs on the driver at plan time.
    
    ### Why are the changes needed?
    
    Added `ProtobufDescriptorFileReadSuite` (9 tests). Existing 
`ProtobufFunctionsSuite` still passes.
    
    ### Does this PR introduce _any_ user-facing change?
    
    It allows users to use the Hadoop file reader to read Protobuf descriptor 
files, with a fallback to the current Java NIO reader.
    
    ### How was this patch tested?
    
    Added `ProtobufDescriptorFileReadSuite` (9 tests). Existing 
`ProtobufFunctionsSuite` still passes.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 4.8
    
    Closes #57234 from kavpreetgrewal/SPARK-58109-protobuf-descriptor-hadoop-fs.
    
    Authored-by: Kavpreet Grewal <[email protected]>
    Signed-off-by: Daniel Tenedorio <[email protected]>
---
 .../protobuf/ProtobufDescriptorFileReadSuite.scala | 167 +++++++++++++++++++++
 .../expressions/toFromProtobufSqlFunctions.scala   |   9 +-
 .../util/ProtobufDescriptorFileReader.scala        |  98 ++++++++++++
 3 files changed, 270 insertions(+), 4 deletions(-)

diff --git 
a/connector/protobuf/src/test/scala/org/apache/spark/sql/protobuf/ProtobufDescriptorFileReadSuite.scala
 
b/connector/protobuf/src/test/scala/org/apache/spark/sql/protobuf/ProtobufDescriptorFileReadSuite.scala
new file mode 100644
index 000000000000..58b47d820fcb
--- /dev/null
+++ 
b/connector/protobuf/src/test/scala/org/apache/spark/sql/protobuf/ProtobufDescriptorFileReadSuite.scala
@@ -0,0 +1,167 @@
+/*
+ * 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.protobuf
+
+import java.io.{File, IOException}
+import java.net.URI
+import java.nio.file.Files
+
+import org.apache.hadoop.fs.{Path, RawLocalFileSystem}
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.AnalysisException
+import org.apache.spark.sql.catalyst.util.ProtobufDescriptorFileReader
+import org.apache.spark.sql.protobuf.utils.{ProtobufUtils => 
ConnectorProtobufUtils}
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * Regression tests for reading the SQL from_protobuf/to_protobuf 
descriptor-file argument via the
+ * Hadoop FileSystem (SPARK-58109). A `file:`-scheme path exercises the Hadoop 
path; a scheme-less
+ * path exercises the local fallback. Distributed filesystems 
(hdfs:/s3:/abfss:) can't run in a
+ * hermetic test, but all schemes flow through the same 
`path.getFileSystem(conf).open` call.
+ */
+class ProtobufDescriptorFileReadSuite extends SharedSparkSession with 
ProtobufTestBase {
+
+  // Register a `mockfs://` FileSystem whose open() always throws, so tests 
can exercise the
+  // reader's failure handling for an explicit-scheme path. Registered via the 
environment
+  // SparkConf because the reader resolves its Hadoop conf from
+  // `SparkHadoopUtil.get.newConfiguration(SparkEnv.get.conf)`, which does not 
observe post-startup
+  // mutations of the runtime Hadoop configuration.
+  override protected def sparkConf: SparkConf =
+    super.sparkConf.set("spark.hadoop.fs.mockfs.impl", 
classOf[ThrowingMockFileSystem].getName)
+
+  private val descPath: String = protobufDescriptorFile("functions_suite.desc")
+  private val messageName = "SimpleMessageJavaTypes"
+
+  private def runFromProtobuf(pathArg: String): Boolean = {
+    spark.range(1).selectExpr("CAST(NULL AS BINARY) AS 
value").createOrReplaceTempView("t_pb")
+    spark.sql(
+      s"SELECT from_protobuf(value, '$messageName', '$pathArg', map()) IS NULL 
AS r FROM t_pb")
+      .head().getBoolean(0)
+  }
+
+  test("from_protobuf reads a file: scheme descriptor path") {
+    val fileUri = new File(descPath).getCanonicalFile.toURI.toString
+    assert(fileUri.startsWith("file:"))
+    assert(runFromProtobuf(fileUri))
+  }
+
+  test("from_protobuf reads a scheme-less local descriptor path") {
+    val local = new File(descPath).getCanonicalPath
+    assert(new File(local).exists())
+    assert(runFromProtobuf(local))
+  }
+
+  test("to_protobuf reads a file: scheme descriptor path") {
+    val fileUri = new File(descPath).getCanonicalFile.toURI.toString
+    spark.range(1)
+      .selectExpr("named_struct('id', id) AS value")
+      .createOrReplaceTempView("t_pb_struct")
+    // Proves to_protobuf reads the descriptor via a file: URI and returns 
non-null encoded bytes.
+    val r = spark.sql(
+      s"SELECT to_protobuf(value, '$messageName', '$fileUri', map()) IS NOT 
NULL AS r " +
+      "FROM t_pb_struct").head().getBoolean(0)
+    assert(r)
+  }
+
+  test("missing descriptor file raises PROTOBUF_DESCRIPTOR_FILE_NOT_FOUND") {
+    val missing = new File(Files.createTempDirectory("pb").toFile, 
"does-not-exist.desc")
+      .getCanonicalFile.toURI.toString
+    checkError(
+      exception = intercept[AnalysisException](runFromProtobuf(missing)),
+      condition = "PROTOBUF_DESCRIPTOR_FILE_NOT_FOUND",
+      parameters = Map("filePath" -> missing))
+  }
+
+  test("to_protobuf with an empty descriptor path raises 
CANNOT_PARSE_PROTOBUF_DESCRIPTOR") {
+    // An empty path (new Path("") throws) must surface as 
CANNOT_PARSE_PROTOBUF_DESCRIPTOR,
+    // not a raw internal error. to_protobuf, unlike from_protobuf, has no 
empty-path guard.
+    spark.range(1)
+      .selectExpr("named_struct('id', id) AS value")
+      .createOrReplaceTempView("t_pb_struct_empty")
+    checkError(
+      exception = intercept[org.apache.spark.sql.AnalysisException] {
+        spark.sql(
+          s"SELECT to_protobuf(value, '$messageName', '', map()) FROM 
t_pb_struct_empty"
+        ).head()
+      },
+      condition = "CANNOT_PARSE_PROTOBUF_DESCRIPTOR")
+  }
+
+  test("invalid descriptor bytes raise CANNOT_PARSE_PROTOBUF_DESCRIPTOR") {
+    // A .proto source file is not a serialized FileDescriptorSet, so 
buildDescriptor rejects it.
+    // Asserted at the buildDescriptor layer (not via SQL) because the SQL 
path defers the parse to
+    // execution, where it surfaces as SparkException rather than 
AnalysisException.
+    val bogusBytes =
+      Files.readAllBytes(new 
File("src/test/resources/protobuf/catalyst_types.proto").toPath)
+    checkError(
+      exception = intercept[AnalysisException](
+        ConnectorProtobufUtils.buildDescriptor("SomeMessage", 
Some(bogusBytes))),
+      condition = "CANNOT_PARSE_PROTOBUF_DESCRIPTOR")
+  }
+
+  test("invalid descriptor bytes read via a file: path raise CANNOT_PARSE 
through SQL") {
+    // Complements the buildDescriptor-layer test above: this drives the full 
SQL path with a real
+    // file: descriptor path, so the bytes are actually read by the reader and 
then handed to
+    // buildDescriptor -- which rejects the non-FileDescriptorSet bytes with
+    // CANNOT_PARSE_PROTOBUF_DESCRIPTOR. This proves the Hadoop-read bytes 
reach buildDescriptor.
+    // A non-NULL input value is used so the descriptor is actually evaluated 
(with NULL input the
+    // descriptor-building lazy val is never forced and the error would not 
surface).
+    val bogus = new File("src/test/resources/protobuf/catalyst_types.proto")
+      .getCanonicalFile.toURI.toString
+    spark.range(1).selectExpr("CAST(X'00' AS BINARY) AS 
value").createOrReplaceTempView("t_pb_bad")
+    checkError(
+      exception = intercept[AnalysisException] {
+        spark.sql(
+          s"SELECT from_protobuf(value, '$messageName', '$bogus', map()) FROM 
t_pb_bad").collect()
+      },
+      condition = "CANNOT_PARSE_PROTOBUF_DESCRIPTOR")
+  }
+
+  test("reader routes a missing file: path to 
PROTOBUF_DESCRIPTOR_FILE_NOT_FOUND") {
+    // Direct unit test of the reader's FileNotFoundException routing for an 
explicit-scheme path
+    // (no fallback: an explicit scheme is not eligible for the local read).
+    val missing = new File(Files.createTempDirectory("pb").toFile, "nope.desc")
+      .getCanonicalFile.toURI.toString
+    checkError(
+      exception = intercept[AnalysisException](
+        ProtobufDescriptorFileReader.readDescriptorFileContent(missing)),
+      condition = "PROTOBUF_DESCRIPTOR_FILE_NOT_FOUND",
+      parameters = Map("filePath" -> missing))
+  }
+
+  test("reader does NOT fall back for an explicit-scheme path; the real error 
is chained") {
+    // The mockfs FileSystem (registered via sparkConf) throws on open. A 
mockfs:// path is
+    // explicit-scheme, so the reader must NOT retry a local NIO read of the 
bogus path; it surfaces
+    // CANNOT_PARSE_PROTOBUF_DESCRIPTOR and preserves the underlying 
IOException as the cause.
+    val ex = intercept[AnalysisException] {
+      
ProtobufDescriptorFileReader.readDescriptorFileContent(s"mockfs://$descPath")
+    }
+    checkError(exception = ex, condition = "CANNOT_PARSE_PROTOBUF_DESCRIPTOR")
+    // The underlying FileSystem exception must be preserved as the cause (not 
swallowed).
+    assert(ex.getCause != null)
+  }
+}
+
+/** A FileSystem for the `mockfs` scheme whose `open` always throws, to 
exercise read failures. */
+class ThrowingMockFileSystem extends RawLocalFileSystem {
+  override def getScheme: String = "mockfs"
+  override def getUri: URI = URI.create("mockfs:///")
+  override def open(f: Path, bufferSize: Int): 
org.apache.hadoop.fs.FSDataInputStream =
+    throw new IOException("mockfs open failure")
+}
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/toFromProtobufSqlFunctions.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/toFromProtobufSqlFunctions.scala
index b93b389ae23b..7d0fa35b3f0f 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/toFromProtobufSqlFunctions.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/toFromProtobufSqlFunctions.scala
@@ -19,11 +19,10 @@ package org.apache.spark.sql.catalyst.expressions
 
 import org.apache.spark.sql.catalyst.analysis.FunctionRegistry
 import org.apache.spark.sql.catalyst.analysis.TypeCheckResult
-import org.apache.spark.sql.catalyst.util.ArrayBasedMapData
+import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, 
ProtobufDescriptorFileReader}
 import org.apache.spark.sql.errors.DataTypeErrors.toSQLType
 import org.apache.spark.sql.errors.QueryCompilationErrors
 import org.apache.spark.sql.types.{BinaryType, MapType, NullType, StringType, 
StructType}
-import org.apache.spark.sql.util.ProtobufUtils
 import org.apache.spark.unsafe.types.UTF8String
 import org.apache.spark.util.Utils
 
@@ -142,7 +141,8 @@ case class FromProtobuf(
     }
     val descFilePathValue: Option[Array[Byte]] = descFilePath.eval() match {
       case s: UTF8String if s.toString.isEmpty => None
-      case s: UTF8String => 
Some(ProtobufUtils.readDescriptorFileContent(s.toString))
+      case s: UTF8String =>
+        
Some(ProtobufDescriptorFileReader.readDescriptorFileContent(s.toString))
       case bytes: Array[Byte] if bytes.isEmpty => None
       case bytes: Array[Byte] => Some(bytes)
       case null => None
@@ -292,7 +292,8 @@ case class ToProtobuf(
         s.toString
     }
     val descFilePathValue: Option[Array[Byte]] = descFilePath.eval() match {
-      case s: UTF8String => 
Some(ProtobufUtils.readDescriptorFileContent(s.toString))
+      case s: UTF8String =>
+        
Some(ProtobufDescriptorFileReader.readDescriptorFileContent(s.toString))
       case bytes: Array[Byte] => Some(bytes)
       case null => None
     }
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/ProtobufDescriptorFileReader.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/ProtobufDescriptorFileReader.scala
new file mode 100644
index 000000000000..a522806f4f46
--- /dev/null
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/ProtobufDescriptorFileReader.scala
@@ -0,0 +1,98 @@
+/*
+ * 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.catalyst.util
+
+import java.io.FileNotFoundException
+
+import scala.util.control.NonFatal
+
+import com.google.common.io.ByteStreams
+import org.apache.hadoop.fs.Path
+
+import org.apache.spark.SparkEnv
+import org.apache.spark.deploy.SparkHadoopUtil
+import org.apache.spark.internal.Logging
+import org.apache.spark.internal.LogKeys.PATH
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.util.{ProtobufUtils => ApiProtobufUtils}
+import org.apache.spark.util.Utils
+
+/**
+ * Reads a Protobuf descriptor file for the SQL `from_protobuf` / 
`to_protobuf` expressions via the
+ * Hadoop `FileSystem`, so paths on distributed file systems (e.g. `hdfs:`, 
`s3:`, `abfss:`)
+ * resolve in addition to local paths. Runs on the driver at plan time.
+ */
+object ProtobufDescriptorFileReader extends Logging {
+
+  /**
+   * Reads and returns the raw bytes of the descriptor file at `filePath`.
+   *
+   * The path is read through the Hadoop `FileSystem` resolved for its scheme, 
so paths on
+   * distributed file systems work. A scheme-less path that fails the Hadoop 
read falls back to a
+   * local-NIO read to preserve pre-existing behavior.
+   *
+   * Must be called on the driver: it relies on the active `SparkEnv` 
(`SparkEnv.get`), which is
+   * unavailable on executors.
+   *
+   * @param filePath the descriptor file path (a cloud URI, or a local path)
+   * @return the descriptor file contents as a byte array
+   * @throws org.apache.spark.sql.AnalysisException with condition
+   *         `PROTOBUF_DESCRIPTOR_FILE_NOT_FOUND` if the file does not exist, 
or
+   *         `CANNOT_PARSE_PROTOBUF_DESCRIPTOR` if it cannot otherwise be read
+   */
+  def readDescriptorFileContent(filePath: String): Array[Byte] = {
+    // A scheme-less path (including the malformed empty string, where `new 
Path` throws) may be a
+    // local file the old implementation could read, so it is eligible for the 
local fallback; a
+    // path with an explicit scheme is not.
+    val schemeless = try {
+      new Path(filePath).toUri.getScheme == null
+    } catch {
+      case NonFatal(_) => false
+    }
+    try {
+      val path = new Path(filePath)
+      val fs = 
path.getFileSystem(SparkHadoopUtil.get.newConfiguration(SparkEnv.get.conf))
+      Utils.tryWithResource(fs.open(path))(ByteStreams.toByteArray)
+    } catch {
+      case NonFatal(hadoopError) if schemeless =>
+        // The Hadoop route failed for a scheme-less path. Fall back to the 
local-NIO read.
+        // If the local read also fails, throw its error with the Hadoop error 
attached as a
+        // suppressed exception, so neither cause is lost.
+        logWarning(log"Failed to read Protobuf descriptor ${MDC(PATH, 
filePath)} via the Hadoop " +
+          log"FileSystem; falling back to a local filesystem read.", 
hadoopError)
+        try {
+          ApiProtobufUtils.readDescriptorFileContent(filePath)
+        } catch {
+          case NonFatal(localError) =>
+            localError.addSuppressed(hadoopError)
+            throw localError
+        }
+      case e: FileNotFoundException =>
+        // Explicit-scheme path that does not exist: surface the not-found 
error class.
+        logWarning(log"Protobuf descriptor file ${MDC(PATH, filePath)} was not 
found.", e)
+        throw QueryCompilationErrors.cannotFindDescriptorFileError(filePath, e)
+      case NonFatal(e) =>
+        // Explicit-scheme path that failed for another reason (e.g. 
permission denied, IO error).
+        // Do not fall back -- the local read could not serve this path either
+        logWarning(
+          log"Failed to read Protobuf descriptor ${MDC(PATH, filePath)} via 
the Hadoop FileSystem.",
+          e)
+        throw QueryCompilationErrors.descriptorParseError(e)
+    }
+  }
+}


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

Reply via email to