HyukjinKwon commented on code in PR #59313:
URL: https://github.com/apache/spark/pull/59313#discussion_r4265012881


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -0,0 +1,794 @@
+/*
+ * 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.execution.datasources.v2.ffi
+
+import java.lang.ref.{Cleaner, WeakReference}
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicLong
+
+import scala.jdk.CollectionConverters._
+import scala.util.control.NonFatal
+
+import org.apache.arrow.c.{ArrowArray, ArrowArrayStream, ArrowSchema, 
CDataDictionaryProvider, Data}
+import org.apache.arrow.vector.{FieldVector, VectorSchemaRoot}
+import org.apache.arrow.vector.complex.MapVector
+import org.apache.arrow.vector.types.pojo.{ArrowType, Field, Schema}
+
+import org.apache.spark.{SparkException, SparkThrowable, 
SparkUnsupportedOperationException}
+import org.apache.spark.internal.Logging
+import org.apache.spark.sql.connector.expressions.filter.Predicate
+import org.apache.spark.sql.connector.read.{InputPartition, PartitionReader}
+import org.apache.spark.sql.connector.write.{DataWriter, WriterCommitMessage}
+import org.apache.spark.sql.errors.{QueryCompilationErrors, 
QueryExecutionErrors}
+import 
org.apache.spark.sql.execution.datasources.v2.columnar.{ColumnarDataSource, 
ColumnarReader, ColumnarStreamReader, ColumnarStreamWriter, ColumnarWriter}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{DataType, StructType}
+import org.apache.spark.sql.util.{ArrowUtils, CaseInsensitiveStringMap}
+import org.apache.spark.sql.vectorized.{ArrowColumnVector, ColumnarBatch, 
ColumnVector}
+import org.apache.spark.unsafe.Platform
+import org.apache.spark.util.Utils
+
+/**
+ * A [[ColumnarDataSource]] implemented by a native library, through
+ * `org.apache.spark.sql.datasource.NativeBridge`.
+ *
+ * @param location where the library is on the driver. The readers and the 
writers distribute it
+ *                 to the executors of the active session when they are 
created.
+ * @param name the name of the data source, as listed in the manifest of its 
package or in lower
+ *             case for an installed library
+ */
+class NativeDataSource(
+    location: NativeLibraryLocation,
+    name: String,
+    options: CaseInsensitiveStringMap)
+  extends ColumnarDataSource {
+  import NativeCalls._
+
+  override def schema(): StructType = {
+    val library = NativeLibraries.get(location)
+    withDataSource(library, "infer the schema of") { dataSource =>
+      call(name, "infer the schema of") {
+        optional(NativeArrow.importSchema(address => 
library.schema(dataSource, address)))
+      }.getOrElse(throw 
QueryCompilationErrors.dataSchemaNotSpecifiedError(name))
+    }
+  }
+
+  override def reader(schema: StructType): ColumnarReader = {
+    val executorLocation = NativeDataSourceRegistry.distribute(location)
+    val library = NativeLibraries.get(location)
+    val handle = create(library, "plan a scan of", 
"DATA_SOURCE_BATCH_SCAN_NOT_SUPPORTED") {
+      dataSource =>
+        NativeArrow.exportSchema(schema)(address => 
library.createReader(dataSource, address))
+    }
+    new NativeDataSourceReader(
+      executorLocation, name, schema, new NativeHandle(handle, 
library.closeReader))
+  }
+
+  override def streamReader(schema: StructType): ColumnarStreamReader = {
+    val executorLocation = NativeDataSourceRegistry.distribute(location)
+    val library = NativeLibraries.get(location)
+    val handle = create(library, "plan a scan of", 
"DATA_SOURCE_MICRO_BATCH_SCAN_NOT_SUPPORTED") {
+      dataSource =>
+        NativeArrow.exportSchema(schema)(address => 
library.createStreamReader(dataSource, address))
+    }
+    new NativeDataSourceStreamReader(
+      executorLocation, name, schema, new NativeHandle(handle, 
library.closeStreamReader))
+  }
+
+  override def writer(schema: StructType, overwrite: Boolean): ColumnarWriter 
= {
+    val executorLocation = NativeDataSourceRegistry.distribute(location)
+    val library = NativeLibraries.get(location)
+    val handle = new NativeHandle(
+      create(library, "plan a write to", 
"DATA_SOURCE_BATCH_WRITE_NOT_SUPPORTED") { dataSource =>
+        NativeArrow.exportSchema(schema) { address =>
+          library.createWriter(dataSource, address, overwrite)
+        }
+      },
+      library.closeWriter)
+    new NativeDataSourceWriter(executorLocation, name, handle, 
serializeWriter(library, handle))
+  }
+
+  override def streamWriter(
+      schema: StructType,
+      overwrite: Boolean,
+      queryId: String): ColumnarStreamWriter = {
+    val executorLocation = NativeDataSourceRegistry.distribute(location)
+    val library = NativeLibraries.get(location)
+    val handle = new NativeHandle(
+      create(library, "plan a write to", 
"DATA_SOURCE_STREAMING_WRITE_NOT_SUPPORTED") {
+        dataSource =>
+          NativeArrow.exportSchema(schema) { address =>
+            library.createStreamWriter(dataSource, address, overwrite)

Review Comment:
   Thanks, done in aa0f3ba4dd77: `createStreamWriter(dataSource, schemaAddress, 
overwrite, queryId)` now gets the ID of the query. The Javadoc says that the ID 
stays the same when the query restarts from its checkpoint, so that the query 
ID and the epoch ID identify the output of a micro-batch, also when other 
queries write to the same destination. New test: "pass the ID of a streaming 
query to its stream writers", which restarts the query from its checkpoint, and 
checks that the library gets the same ID, not the run ID.



##########
python/pyspark/sql/session.py:
##########
@@ -2292,6 +2298,17 @@ def addArtifacts(
                     "condition": "True together",
                 },
             )
+        # Native data source packages are artifacts of the session in the JVM, 
whatever the
+        # flags, as with Spark Connect. The JVM resolves URIs, such as 
file:///tmp/a.sparkpkg,
+        # but resolves a relative path against its own working directory, so 
it gets an absolute
+        # path instead. A Windows path, such as C:/a.sparkpkg, is a path too.
+        for p in path:
+            if p.endswith(".sparkpkg"):
+                if urlparse(p).scheme and not PureWindowsPath(p).drive:

Review Comment:
   Thanks, done in aa0f3ba4dd77: only `file:` URIs are passed as they are now, 
and anything else goes through `os.path.abspath`, which covers Windows paths 
and file names with colons. The test adds a relative `my_source:1.sparkpkg`.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala:
##########
@@ -0,0 +1,421 @@
+/*
+ * 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.execution.datasources.v2.ffi
+
+import java.io.{File, FileInputStream, IOException}
+import java.nio.charset.StandardCharsets
+import java.nio.file.{Files, StandardCopyOption}
+import java.security.MessageDigest
+import java.util.{HexFormat, Locale}
+import java.util.concurrent.ConcurrentHashMap
+import java.util.zip.ZipFile
+
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
+import com.google.common.cache.{Cache, CacheBuilder}
+
+import org.apache.spark.internal.Logging
+import org.apache.spark.internal.LogKeys.PATH
+import org.apache.spark.sql.Artifact
+import org.apache.spark.sql.datasource.NativeBridge
+import org.apache.spark.sql.errors.QueryExecutionErrors
+import org.apache.spark.util.Utils
+
+/**
+ * The manifest of a native data source package, 
`spark-native-datasource.json`:
+ * {{{
+ *   {
+ *     "abiVersion": 1,
+ *     "dataSources": ["my_source"],
+ *     "libraries": {
+ *       "linux-x86_64": "linux-x86_64/libmy_source.so",
+ *       "osx-aarch64": "osx-aarch64/libmy_source.dylib"
+ *     }
+ *   }
+ * }}}
+ *
+ * @param abiVersion the version of [[NativeBridge]] that the libraries 
implement
+ * @param dataSources the names of the data sources that the libraries 
implement
+ * @param libraries the path of the library in the package for each platform, 
as returned by
+ *                  [[NativePlatform.current]]
+ */
+case class NativeDataSourceManifest(
+    abiVersion: Int,
+    dataSources: Seq[String],
+    libraries: Map[String, String])
+
+/**
+ * Where the library of a native data source is: in a 
[[NativeDataSourcePackage]], or installed in
+ * the native library path, as an [[InstalledNativeLibrary]]. It is sent to 
the executors, which
+ * load the library with [[NativeLibraries.get]].
+ */
+sealed trait NativeLibraryLocation extends Serializable
+
+/**
+ * A native data source package: a zip file that contains a 
[[NativeDataSourceManifest]] and the
+ * libraries it lists. The SHA-256 checksum of the file identifies the 
package, so that an
+ * executor can check that it loads the same package as the driver.
+ *
+ * @param path the path of the package file on the driver
+ * @param artifactUUID the UUID of the session whose artifacts contain the 
package, if the session
+ *                     is isolated. Executors store the artifacts of such a 
session separately.
+ * @param distributedFileName the name of the copy of the package in the 
artifacts of the session,
+ *                            if the package is not itself an artifact of the 
session
+ */
+case class NativeDataSourcePackage(
+    path: String,
+    sha256: String,
+    manifest: NativeDataSourceManifest,
+    artifactUUID: Option[String] = None,
+    distributedFileName: Option[String] = None) extends NativeLibraryLocation {
+  def fileName: String = new File(path).getName
+}
+
+/**
+ * A package file that is not a valid native data source package.
+ *
+ * @param error the error that explains why the package is invalid
+ * @param dataSources the data sources listed in the manifest of the package, 
if it can be read
+ */
+case class InvalidNativeDataSourcePackage(error: Throwable, dataSources: 
Seq[String])
+
+object NativeDataSourcePackage extends Logging {
+  val FILE_EXTENSION: String = Artifact.NATIVE_DATA_SOURCE_PACKAGE_EXTENSION
+  val MANIFEST_NAME = "spark-native-datasource.json"
+
+  private case class CacheKey(path: String, length: Long, lastModified: Long)
+
+  private[ffi] type ReadResult = Either[InvalidNativeDataSourcePackage, 
NativeDataSourcePackage]
+
+  // Bounded, because each session stores its artifacts in a directory of its 
own, so a
+  // long-running server where many sessions add packages reads many different 
files.
+  private val MAX_CACHED_PACKAGES = 1000
+
+  private val packages: Cache[CacheKey, ReadResult] =
+    CacheBuilder.newBuilder().maximumSize(MAX_CACHED_PACKAGES).build[CacheKey, 
ReadResult]()
+  private val mapper = new ObjectMapper()
+
+  /** Reads the package in the given file. The result is cached until the file 
changes. */
+  def read(file: File): NativeDataSourcePackage = tryRead(file).fold(p => 
throw p.error, identity)
+
+  /**
+   * Reads the package in the given file, or returns why it is invalid. The 
result is cached until
+   * the file changes, and an invalid package is logged when it is read.
+   */
+  private[ffi] def tryRead(file: File): ReadResult = {
+    val canonical = try {
+      file.getCanonicalFile
+    } catch {
+      case e: IOException =>
+        return Left(InvalidNativeDataSourcePackage(
+          invalidManifestError(file, s"The package cannot be read: $e", e), 
Nil))
+    }
+    val key = CacheKey(canonical.getPath, canonical.length(), 
canonical.lastModified())
+    Option(packages.getIfPresent(key)).getOrElse {
+      val result = parse(canonical)
+      result.swap.foreach { invalid =>
+        logWarning(log"Invalid native data source package ${MDC(PATH, 
canonical)}.",
+          invalid.error)
+      }
+      packages.put(key, result)
+      result
+    }
+  }
+
+  /**
+   * Forgets the packages read from the files under the given directory, such 
as the artifacts of
+   * a session that is cleaned up.
+   */
+  def forgetPackagesIn(dir: File): Unit = {
+    val prefix = dir.getCanonicalPath + File.separator
+    packages.asMap().keySet().removeIf(_.path.startsWith(prefix))
+  }
+
+  /** Returns whether a package read from the file at the given canonical path 
is cached. */
+  private[ffi] def isCached(canonicalPath: String): Boolean = {
+    packages.asMap().keySet().asScala.exists(_.path == canonicalPath)
+  }
+
+  private def invalidManifestError(file: File, reason: String, cause: 
Throwable = null) = {
+    QueryExecutionErrors.invalidNativeDataSourcePackageError(
+      file.getPath, "INVALID_MANIFEST", Map("reason" -> reason), cause)
+  }
+
+  private def parse(file: File): ReadResult = {
+    def invalid(reason: String, dataSources: Seq[String] = Nil, cause: 
Throwable = null) = {
+      Left(InvalidNativeDataSourcePackage(invalidManifestError(file, reason, 
cause), dataSources))
+    }
+    try {
+      val manifest = Utils.tryWithResource(new ZipFile(file)) { zip =>
+        Option(zip.getEntry(MANIFEST_NAME)) match {
+          case None => invalid(s"The package does not contain the manifest 
$MANIFEST_NAME.")
+          case Some(entry) =>
+            val json = Utils.tryWithResource(zip.getInputStream(entry)) { in =>
+              new String(in.readAllBytes(), StandardCharsets.UTF_8)
+            }
+            parseManifest(json) match {
+              case Left(reason) => invalid(reason)
+              case Right(manifest) =>
+                manifest.libraries.values.find(zip.getEntry(_) == null) match {
+                  case Some(library) => invalid(
+                    s"The package does not contain the library $library.", 
manifest.dataSources)
+                  case None => Right(manifest)
+                }
+            }
+        }
+      }
+      manifest.map(NativeDataSourcePackage(file.getPath, sha256(file), _))
+    } catch {
+      case e: IOException => invalid(s"The package is not a valid zip file: 
$e", cause = e)
+    }
+  }
+
+  private[ffi] def parseManifest(json: String): Either[String, 
NativeDataSourceManifest] = {
+    val root: JsonNode = try {
+      mapper.readTree(json)
+    } catch {
+      case e: IOException => return Left(s"The manifest is not valid JSON: 
${e.getMessage}")
+    }
+    if (root == null || !root.isObject) {
+      return Left("The manifest must be a JSON object.")
+    }
+    val abiVersion = root.get("abiVersion")
+    if (abiVersion == null || !abiVersion.isInt) {
+      return Left("The manifest must have an integer field 'abiVersion'.")
+    }
+    val dataSources = root.get("dataSources")
+    if (dataSources == null || !dataSources.isArray || dataSources.isEmpty ||
+        !dataSources.elements().asScala.forall(n => n.isTextual && 
n.asText().nonEmpty)) {
+      return Left("The manifest must have a field 'dataSources' with a 
non-empty array of " +
+        "data source names.")
+    }
+    val libraries = root.get("libraries")
+    if (libraries == null || !libraries.isObject || libraries.isEmpty ||
+        !libraries.properties().asScala.forall(e => e.getValue.isTextual)) {
+      return Left("The manifest must have a field 'libraries' with an object 
that maps " +
+        "platforms to the paths of the libraries in the package.")
+    }
+    Right(NativeDataSourceManifest(
+      abiVersion.asInt(),
+      dataSources.elements().asScala.map(_.asText()).toSeq,
+      libraries.properties().asScala.map(e => e.getKey -> 
e.getValue.asText()).toMap))
+  }
+
+  private[ffi] def sha256(file: File): String = {
+    val digest = MessageDigest.getInstance("SHA-256")
+    Utils.tryWithResource(new FileInputStream(file)) { in =>
+      val buffer = new Array[Byte](64 * 1024)
+      var read = in.read(buffer)
+      while (read != -1) {
+        digest.update(buffer, 0, read)
+        read = in.read(buffer)
+      }
+    }
+    HexFormat.of().formatHex(digest.digest())
+  }
+}
+
+/**
+ * A native data source library installed in the native library path, where
+ * [[NativeDataSourceRegistry]] finds it by its file name. It must be 
installed on every node. The
+ * SHA-256 checksum of the file identifies the build of the library, so that 
an executor can check
+ * that it loads the same build as the driver: the state that the driver 
serializes is only
+ * meant for the same build.
+ *
+ * @param path the path of the library on the driver
+ * @param sha256 the SHA-256 checksum of the library on the driver
+ */
+case class InstalledNativeLibrary(path: String, sha256: String) extends 
NativeLibraryLocation
+
+object InstalledNativeLibrary {
+  private case class CacheKey(path: String, length: Long, lastModified: Long)
+
+  // Bounded, like the cache of the packages, although few libraries are 
installed.
+  private val checksums: Cache[CacheKey, String] =
+    CacheBuilder.newBuilder().maximumSize(1000).build[CacheKey, String]()
+
+  /** The installed library in the given file. */
+  def apply(file: File): InstalledNativeLibrary = 
InstalledNativeLibrary(file.getPath, sha256(file))
+
+  /** The SHA-256 checksum of the given file. The result is cached until the 
file changes. */
+  private[ffi] def sha256(file: File): String = {
+    val canonical = file.getCanonicalFile
+    val key = CacheKey(canonical.getPath, canonical.length(), 
canonical.lastModified())

Review Comment:
   Thanks, done in aa0f3ba4dd77. Both caches, of the packages and of the 
checksums of installed libraries, now key a file by its canonical path, length, 
modification time and file key (`BasicFileAttributes.fileKey`, the inode on 
Linux). So a new file at the same path is read again, even with the same length 
and modification time. This also makes the new `REPLACED` check above notice 
such a replacement.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to