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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativePredicates.scala:
##########
@@ -0,0 +1,92 @@
+/*
+ * 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.util.Base64
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.node.ObjectNode
+
+import org.apache.spark.sql.connector.expressions.{Expression, 
GeneralScalarExpression, Literal, NamedReference}
+import org.apache.spark.sql.connector.expressions.filter.Predicate
+import org.apache.spark.sql.types._
+
+/**
+ * Encodes Data Source V2 predicates as the JSON expression trees passed to
+ * `NativeBridge.pushPredicates`.
+ */
+object NativePredicates {
+  private val mapper = new ObjectMapper()
+
+  /** Returns the JSON of the predicate, or None if it cannot be expressed in 
JSON. */
+  def toJson(predicate: Predicate): Option[String] = {
+    encode(predicate).map(mapper.writeValueAsString)
+  }
+
+  private def encode(expression: Expression): Option[ObjectNode] = expression 
match {
+    case reference: NamedReference =>
+      val node = mapper.createObjectNode().put("type", "column")
+      val name = node.putArray("name")
+      reference.fieldNames().foreach(name.add)
+      Some(node)
+
+    case literal: Literal[_] =>
+      encodeLiteral(literal.value(), literal.dataType())
+
+    case function: GeneralScalarExpression =>
+      val children = function.children().map(encode)
+      if (children.forall(_.isDefined)) {
+        val node = mapper.createObjectNode()
+          .put("type", "function")
+          .put("name", function.name())
+        val array = node.putArray("children")
+        children.foreach(child => array.add(child.get))
+        Some(node)
+      } else {
+        None
+      }
+
+    case _ => None
+  }
+
+  private def encodeLiteral(value: Any, dataType: DataType): 
Option[ObjectNode] = {
+    val node = mapper.createObjectNode()
+      .put("type", "literal")
+      .put("dataType", dataType.catalogString)
+    if (value == null) {
+      Some(node.putNull("value"))
+    } else {
+      val encoded = (value, dataType) match {
+        case (b: Boolean, BooleanType) => Some(node.put("value", b))
+        case (n: Byte, ByteType) => Some(node.put("value", n.toInt))
+        case (n: Short, ShortType) => Some(node.put("value", n))
+        case (n: Int, IntegerType | DateType) => Some(node.put("value", n))
+        case (n: Long, LongType | TimestampType | TimestampNTZType) => 
Some(node.put("value", n))
+        case (n: Float, FloatType) if !n.isNaN && !n.isInfinite => 
Some(node.put("value", n))

Review Comment:
   Done both in 9dd4f88. `NativePredicates.toJson` now takes the scan schema 
and doesn't push a predicate that references a column (or nested field) 
containing strings with a non-binary collation, including inside structs and 
arrays. It also doesn't push one whose column is missing from the schema. For 
floating point, I documented the semantics in the `NativeBridge.pushPredicates` 
Javadoc rather than blocking pushdown on float columns, which would lose a 
common, valid pushdown. The Javadoc says NaN equals NaN and is greater than any 
other value including +Inf, -0.0 equals 0.0, `d > 1.0` is true for NaN, and 
comparisons with null are null. The docs page points to it too.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceRegistry.scala:
##########
@@ -0,0 +1,202 @@
+/*
+ * 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
+import java.nio.file.Paths
+import java.util.Locale
+
+import org.apache.spark.{SparkEnv, SparkFiles}
+import org.apache.spark.internal.Logging
+import org.apache.spark.internal.LogKeys.PATH
+import org.apache.spark.sql.Artifact
+import org.apache.spark.sql.classic.SparkSession
+import org.apache.spark.sql.datasource.NativeBridge
+import org.apache.spark.sql.errors.{QueryCompilationErrors, 
QueryExecutionErrors}
+import org.apache.spark.sql.internal.{SQLConf, StaticSQLConf}
+import org.apache.spark.util.Utils
+
+/**
+ * Finds the native data sources on the driver. Like the Python data sources 
installed in the
+ * Python path, the native data source libraries installed in the native 
library path are found
+ * automatically, by their file name: the library `spark_datasource_<name>`, 
such as
+ * `libspark_datasource_my_source.so` on Linux, implements the data source 
`<name>`. The active
+ * session can also add packages with `spark.addArtifact`, or with
+ * `spark.sql.dataSource.native.paths`, which take precedence over the 
installed libraries.
+ */
+object NativeDataSourceRegistry extends Logging {
+
+  /** The prefix of the names of the installed native data source libraries. */
+  val LIBRARY_NAME_PREFIX = "spark_datasource_"
+
+  /** Returns whether the native data source with the given name exists. */
+  def exists(name: String, conf: SQLConf): Boolean = lookup(name, 
conf).isDefined
+
+  /**
+   * Finds the package or the installed library that provides the native data 
source with the
+   * given name, which is case-insensitive. Returns the name of the data 
source, as listed in the
+   * manifest of the package or in lower case for an installed library, and 
where its library is.
+   */
+  def lookup(name: String, conf: SQLConf): Option[(String, 
NativeLibraryLocation)] = {
+    if (!conf.getConf(StaticSQLConf.NATIVE_DATA_SOURCE_ENABLED)) {
+      return None
+    }
+    lookupPackage(name, configuredPackageFiles(conf) ++ artifactPackageFiles())
+      .orElse(lookupInstalledLibrary(name))
+  }
+
+  private def lookupPackage(
+      name: String,
+      files: Seq[File]): Option[(String, NativeDataSourcePackage)] = {
+    val found = 
files.map(_.getCanonicalFile).distinct.map(NativeDataSourcePackage.read)
+      .flatMap(pkg => 
pkg.manifest.dataSources.find(_.equalsIgnoreCase(name)).map(_ -> pkg))
+      // The same package can be found more than once, for example when it is 
in a configured
+      // directory and was also added as an artifact.
+      .groupBy(_._2.sha256).values.map(_.head).toSeq
+    found match {
+      case Seq() => None
+      case Seq(result @ (_, pkg)) =>
+        if (pkg.manifest.abiVersion != NativeBridge.ABI_VERSION) {
+          throw QueryExecutionErrors.invalidNativeDataSourcePackageError(
+            pkg.path,
+            "UNSUPPORTED_ABI_VERSION",
+            Map(
+              "version" -> pkg.manifest.abiVersion.toString,
+              "supported" -> NativeBridge.ABI_VERSION.toString))
+        }
+        Some(result)
+      case _ =>
+        throw QueryCompilationErrors.nativeDataSourcePackageConflictError(
+          name, found.map(_._2.path).sorted)
+    }
+  }
+
+  private def lookupInstalledLibrary(name: String): Option[(String, 
InstalledNativeLibrary)] = {
+    // Other names, such as class names, are not the names of libraries.
+    if (!name.matches("[A-Za-z0-9_]+")) {
+      return None
+    }
+    val dataSource = name.toLowerCase(Locale.ROOT)
+    findInstalledLibrary(libraryFileName(dataSource))
+      .map(file => dataSource -> InstalledNativeLibrary(file.getPath))
+  }
+
+  /** Finds the library with the given file name in the native library path of 
this node. */
+  private[ffi] def findInstalledLibrary(fileName: String): Option[File] = {
+    librarySearchPath(System.getProperty("java.library.path"), 
System.getenv("PATH"))
+      .map(new File(_, fileName))
+      .find(_.isFile)
+  }
+
+  /**
+   * The file name of the installed library of the native data source with the 
given name, such
+   * as `libspark_datasource_my_source.so` on Linux, 
`libspark_datasource_my_source.dylib` on
+   * macOS and `spark_datasource_my_source.dll` on Windows.
+   */
+  def libraryFileName(name: String): String = {
+    System.mapLibraryName(LIBRARY_NAME_PREFIX + name.toLowerCase(Locale.ROOT))
+  }
+
+  /**
+   * The native library path: the directories where native data source 
libraries are installed,
+   * in the order they are searched. They are the directories of 
`java.library.path`, where the
+   * JVM finds native libraries, which include `LD_LIBRARY_PATH` on Linux, 
`DYLD_LIBRARY_PATH` on
+   * macOS and `PATH` on Windows, and then the `lib` directory of each 
installation prefix in
+   * `PATH`, such as `/usr/local/lib` for `/usr/local/bin`. Relative 
directories are ignored.
+   */
+  private[ffi] def librarySearchPath(javaLibraryPath: String, path: String): 
Seq[File] = {
+    def directories(value: String): Seq[File] = {
+      Option(value).toSeq.flatMap(_.split(File.pathSeparator)).map(new 
File(_)).filter(_.isAbsolute)
+    }
+    val prefixLibraries = directories(path)
+      .filter(_.getName == "bin")
+      .flatMap(bin => Option(bin.getParentFile))
+      .map(new File(_, "lib"))
+    (directories(javaLibraryPath) ++ prefixLibraries).distinct
+  }
+
+  /**
+   * Makes the library available to the executors of the active session. 
Returns where the
+   * executors find it. An installed library is already installed on every 
node.
+   */
+  def distribute(location: NativeLibraryLocation): NativeLibraryLocation = 
location match {
+    case pkg: NativeDataSourcePackage =>
+      SparkSession.getActiveSession match {
+        // In local mode, the executors run in the driver and read the package 
where it is.
+        case Some(session) if !session.sparkContext.isLocal => 
addToArtifacts(session, pkg)
+        case _ => pkg
+      }
+    case installed: InstalledNativeLibrary => installed
+  }
+
+  /** Adds the package as an artifact of the session, unless it already is 
one. */
+  private[ffi] def addToArtifacts(
+      session: SparkSession,
+      pkg: NativeDataSourcePackage): NativeDataSourcePackage = {
+    val artifactManager = session.artifactManager
+    val (added, artifactUUID) = artifactManager.getNativeDataSourcePackages
+    if (!added.exists(_.getName == pkg.fileName)) {

Review Comment:
   Fixed in 9dd4f88. `addToArtifacts` now compares checksums when the file 
names match. Identical content is a no-op; different content fails with a new 
`NATIVE_DATA_SOURCE_PACKAGE_FILE_NAME_CONFLICT` error that names the package 
path and the file name. Covered in `add packages with spark.addArtifact`.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -0,0 +1,617 @@
+/*
+ * 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
+import java.util.concurrent.atomic.AtomicLong
+
+import scala.jdk.CollectionConverters._
+import scala.util.control.NonFatal
+
+import org.apache.arrow.c.{ArrowArray, ArrowArrayStream, ArrowSchema, Data}
+import org.apache.arrow.vector.{FieldVector, VectorSchemaRoot}
+import org.apache.arrow.vector.ipc.ArrowReader
+import org.apache.arrow.vector.types.pojo.Field
+
+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.util.Utils
+
+/**
+ * A [[ColumnarDataSource]] implemented by a native library, through
+ * `org.apache.spark.sql.datasource.NativeBridge`.
+ *
+ * @param location where the library is
+ * @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 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(
+      location, name, schema, new NativeHandle(handle, library.closeReader))
+  }
+
+  override def streamReader(schema: StructType): ColumnarStreamReader = {
+    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(
+      location, name, schema, new NativeHandle(handle, 
library.closeStreamReader))
+  }
+
+  override def writer(schema: StructType, overwrite: Boolean): ColumnarWriter 
= {
+    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(location, name, handle, 
serializeWriter(library, handle))
+  }
+
+  override def streamWriter(schema: StructType, overwrite: Boolean): 
ColumnarStreamWriter = {
+    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)
+          }
+      },
+      library.closeWriter)
+    new NativeDataSourceStreamWriter(location, name, handle, 
serializeWriter(library, handle))
+  }
+
+  /** Runs `f` with a new native data source, which is closed afterwards. */
+  private def withDataSource[T](library: NativeLibrary, action: String)(f: 
Long => T): T = {
+    // The keys of a case-insensitive map are in lower case.
+    val entries = options.entrySet().asScala.toArray
+    val dataSource = call(name, "create") {
+      library.createDataSource(name, entries.map(_.getKey), 
entries.map(_.getValue))
+    }
+    Utils.tryWithSafeFinally(f(dataSource)) {
+      call(name, action)(library.closeDataSource(dataSource))
+    }
+  }
+
+  /** Creates a reader or a writer with `f`, which fails if the library does 
not implement it. */
+  private def create(library: NativeLibrary, action: String, unsupportedError: 
String)(
+      f: Long => Long): Long = {
+    withDataSource(library, action) { dataSource =>
+      call(name, action)(optional(f(dataSource))).getOrElse {
+        throw new SparkUnsupportedOperationException(unsupportedError, 
Map("description" -> name))
+      }
+    }
+  }
+
+  private def serializeWriter(library: NativeLibrary, handle: NativeHandle): 
Array[Byte] = {
+    try {
+      bytesOrEmpty(call(name, "plan a write 
to")(optional(library.serializeWriter(handle.get))))
+    } catch {
+      case e: Throwable =>
+        handle.close()
+        throw e
+    }
+  }
+}
+
+/** Reads a native data source for a batch scan. */
+class NativeDataSourceReader(
+    location: NativeLibraryLocation,
+    name: String,
+    schema: StructType,
+    @transient private val handle: NativeHandle)
+  extends ColumnarReader {
+  import NativeCalls._
+
+  // Set on the driver when the partitions are planned, and serialized to the 
executors.
+  private var readerState: Array[Byte] = Array.emptyByteArray
+  private var readSchema: StructType = schema
+  @transient private var plannedPartitions: Array[InputPartition] = _
+
+  @transient private lazy val library = NativeLibraries.get(location)
+
+  override def pushPredicates(predicates: Array[Predicate]): Array[Predicate] 
= {
+    // Only the predicates that can be expressed in JSON are passed to the 
library.
+    val encoded = predicates.zipWithIndex.flatMap { case (predicate, i) =>
+      NativePredicates.toJson(predicate).map(_ -> i)
+    }
+    val pushed = if (encoded.isEmpty) {
+      Set.empty[Int]
+    } else {
+      call(name, "plan a scan of") {
+        optional(library.pushPredicates(handle.get, encoded.map(_._1)))
+      } match {
+        case Some(flags) if flags != null && flags.length == encoded.length =>
+          encoded.map(_._2).zip(flags).collect { case (i, true) => i }.toSet
+        case Some(flags) =>
+          throw error(name, "plan a scan of", s"pushPredicates returned " +
+            s"${Option(flags).map(_.length).getOrElse(0)} values for 
${encoded.length} predicates.")
+        case None => Set.empty[Int]
+      }
+    }
+    predicates.zipWithIndex.collect { case (predicate, i) if 
!pushed.contains(i) => predicate }
+  }
+
+  override def pushLimit(limit: Int): Boolean = {
+    call(name, "plan a scan of")(optional(library.pushLimit(handle.get, 
limit))).getOrElse(false)
+  }
+
+  override def pruneColumns(requiredSchema: StructType): Boolean = {
+    val pruned = call(name, "plan a scan of") {
+      optional(library.pruneColumns(handle.get, requiredSchema.fieldNames))
+    }.getOrElse(false)
+    if (pruned) {
+      readSchema = requiredSchema
+    }
+    pruned
+  }
+
+  override def partitions(): Array[InputPartition] = synchronized {
+    if (plannedPartitions == null) {
+      try {
+        val partitions = call(name, "plan a scan 
of")(library.partitions(handle.get))
+        readerState = bytesOrEmpty(
+          call(name, "plan a scan 
of")(optional(library.serializeReader(handle.get))))
+        plannedPartitions = NativeInputPartition.from(partitions)
+      } finally {
+        // The executors only need the serialized state.
+        handle.close()
+      }
+    }
+    plannedPartitions
+  }
+
+  override def read(partition: InputPartition): PartitionReader[ColumnarBatch] 
= {
+    new NativePartitionReader(
+      location, name, readerState, 
partition.asInstanceOf[NativeInputPartition].bytes, readSchema)
+  }
+}
+
+/** Reads a native data source for a micro-batch streaming scan. */
+class NativeDataSourceStreamReader(
+    location: NativeLibraryLocation,
+    name: String,
+    schema: StructType,
+    @transient private val handle: NativeHandle)
+  extends ColumnarStreamReader {
+  import NativeCalls._
+
+  // Set on the driver whenever the partitions of a micro-batch are planned, 
and serialized to
+  // the executors with the partitions of that micro-batch.
+  private var readerState: Array[Byte] = Array.emptyByteArray
+
+  @transient private lazy val library = NativeLibraries.get(location)
+
+  override def initialOffset(): String = {
+    call(name, "plan a scan of")(library.initialOffset(handle.get))
+  }
+
+  override def latestOffset(): String = {
+    call(name, "plan a scan of")(library.latestOffset(handle.get))
+  }
+
+  override def partitions(start: String, end: String): Array[InputPartition] = 
{
+    val partitions = call(name, "plan a scan of") {
+      library.streamPartitions(handle.get, start, end)
+    }
+    readerState = bytesOrEmpty(
+      call(name, "plan a scan 
of")(optional(library.serializeStreamReader(handle.get))))
+    NativeInputPartition.from(partitions)
+  }
+
+  override def read(partition: InputPartition): PartitionReader[ColumnarBatch] 
= {
+    new NativePartitionReader(
+      location, name, readerState, 
partition.asInstanceOf[NativeInputPartition].bytes, schema)
+  }
+
+  override def commit(end: String): Unit = {
+    call(name, "commit an offset 
of")(optional(library.commitOffset(handle.get, end)))
+  }
+
+  override def stop(): Unit = handle.close()
+}
+
+/** Writes to a native data source for a batch write. */
+class NativeDataSourceWriter(
+    location: NativeLibraryLocation,
+    name: String,
+    @transient private val handle: NativeHandle,
+    writerState: Array[Byte])
+  extends ColumnarWriter {
+  import NativeCalls._
+
+  @transient private lazy val library = NativeLibraries.get(location)
+
+  override def createWriter(partitionId: Int, taskId: Long): 
DataWriter[ColumnarBatch] = {
+    new NativeDataWriter(location, name, writerState, partitionId, taskId, 
epochId = -1L)
+  }
+
+  override def commit(messages: Array[WriterCommitMessage]): Unit = {
+    call(name, "commit a write to") {
+      library.commit(handle.get, -1L, 
NativeWriterCommitMessage.toBytes(messages))
+    }
+    handle.close()
+  }
+
+  override def abort(messages: Array[WriterCommitMessage]): Unit = {
+    Utils.tryWithSafeFinally {
+      call(name, "abort a write to") {
+        library.abort(handle.get, -1L, 
NativeWriterCommitMessage.toBytes(messages))
+      }
+    }(handle.close())
+  }
+}
+
+/** Writes to a native data source for a streaming write. */
+class NativeDataSourceStreamWriter(

Review Comment:
   Addressed in 9dd4f88, within what `StreamingWrite` allows. A micro-batch 
query builds a new write for each micro-batch (`V2Writes` calls `toStreaming` 
per batch, after the previous batch committed or aborted), and a restarted 
query creates a new one. So `NativeStreamWriters` tracks the current stream 
writer for each query ID (`LogicalWriteInfo.queryId`) and closes the previous 
one when the query creates the next. That keeps at most one open writer per 
query. Closing right after commit/abort isn't safe because a continuous-mode 
sink reuses one writer across epochs, and I couldn't find a reliable way to 
detect continuous mode at `toStreaming`. The map holds weak references, so the 
last writer of a stopped query is still released by the `Cleaner`, or when the 
query restarts. I documented this in the `NativeBridge` lifecycle and Handles 
sections and on the docs page.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala:
##########
@@ -0,0 +1,617 @@
+/*
+ * 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
+import java.util.concurrent.atomic.AtomicLong
+
+import scala.jdk.CollectionConverters._
+import scala.util.control.NonFatal
+
+import org.apache.arrow.c.{ArrowArray, ArrowArrayStream, ArrowSchema, Data}
+import org.apache.arrow.vector.{FieldVector, VectorSchemaRoot}
+import org.apache.arrow.vector.ipc.ArrowReader
+import org.apache.arrow.vector.types.pojo.Field
+
+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.util.Utils
+
+/**
+ * A [[ColumnarDataSource]] implemented by a native library, through
+ * `org.apache.spark.sql.datasource.NativeBridge`.
+ *
+ * @param location where the library is
+ * @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 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(
+      location, name, schema, new NativeHandle(handle, library.closeReader))
+  }
+
+  override def streamReader(schema: StructType): ColumnarStreamReader = {
+    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(
+      location, name, schema, new NativeHandle(handle, 
library.closeStreamReader))
+  }
+
+  override def writer(schema: StructType, overwrite: Boolean): ColumnarWriter 
= {
+    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(location, name, handle, 
serializeWriter(library, handle))
+  }
+
+  override def streamWriter(schema: StructType, overwrite: Boolean): 
ColumnarStreamWriter = {
+    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)
+          }
+      },
+      library.closeWriter)
+    new NativeDataSourceStreamWriter(location, name, handle, 
serializeWriter(library, handle))
+  }
+
+  /** Runs `f` with a new native data source, which is closed afterwards. */
+  private def withDataSource[T](library: NativeLibrary, action: String)(f: 
Long => T): T = {
+    // The keys of a case-insensitive map are in lower case.
+    val entries = options.entrySet().asScala.toArray
+    val dataSource = call(name, "create") {
+      library.createDataSource(name, entries.map(_.getKey), 
entries.map(_.getValue))
+    }
+    Utils.tryWithSafeFinally(f(dataSource)) {
+      call(name, action)(library.closeDataSource(dataSource))
+    }
+  }
+
+  /** Creates a reader or a writer with `f`, which fails if the library does 
not implement it. */
+  private def create(library: NativeLibrary, action: String, unsupportedError: 
String)(
+      f: Long => Long): Long = {
+    withDataSource(library, action) { dataSource =>
+      call(name, action)(optional(f(dataSource))).getOrElse {
+        throw new SparkUnsupportedOperationException(unsupportedError, 
Map("description" -> name))
+      }
+    }
+  }
+
+  private def serializeWriter(library: NativeLibrary, handle: NativeHandle): 
Array[Byte] = {
+    try {
+      bytesOrEmpty(call(name, "plan a write 
to")(optional(library.serializeWriter(handle.get))))
+    } catch {
+      case e: Throwable =>
+        handle.close()
+        throw e
+    }
+  }
+}
+
+/** Reads a native data source for a batch scan. */
+class NativeDataSourceReader(
+    location: NativeLibraryLocation,
+    name: String,
+    schema: StructType,
+    @transient private val handle: NativeHandle)
+  extends ColumnarReader {
+  import NativeCalls._
+
+  // Set on the driver when the partitions are planned, and serialized to the 
executors.
+  private var readerState: Array[Byte] = Array.emptyByteArray
+  private var readSchema: StructType = schema
+  @transient private var plannedPartitions: Array[InputPartition] = _
+
+  @transient private lazy val library = NativeLibraries.get(location)
+
+  override def pushPredicates(predicates: Array[Predicate]): Array[Predicate] 
= {
+    // Only the predicates that can be expressed in JSON are passed to the 
library.
+    val encoded = predicates.zipWithIndex.flatMap { case (predicate, i) =>
+      NativePredicates.toJson(predicate).map(_ -> i)
+    }
+    val pushed = if (encoded.isEmpty) {
+      Set.empty[Int]
+    } else {
+      call(name, "plan a scan of") {
+        optional(library.pushPredicates(handle.get, encoded.map(_._1)))
+      } match {
+        case Some(flags) if flags != null && flags.length == encoded.length =>
+          encoded.map(_._2).zip(flags).collect { case (i, true) => i }.toSet
+        case Some(flags) =>
+          throw error(name, "plan a scan of", s"pushPredicates returned " +
+            s"${Option(flags).map(_.length).getOrElse(0)} values for 
${encoded.length} predicates.")
+        case None => Set.empty[Int]
+      }
+    }
+    predicates.zipWithIndex.collect { case (predicate, i) if 
!pushed.contains(i) => predicate }
+  }
+
+  override def pushLimit(limit: Int): Boolean = {
+    call(name, "plan a scan of")(optional(library.pushLimit(handle.get, 
limit))).getOrElse(false)
+  }
+
+  override def pruneColumns(requiredSchema: StructType): Boolean = {
+    val pruned = call(name, "plan a scan of") {
+      optional(library.pruneColumns(handle.get, requiredSchema.fieldNames))
+    }.getOrElse(false)
+    if (pruned) {
+      readSchema = requiredSchema
+    }
+    pruned
+  }
+
+  override def partitions(): Array[InputPartition] = synchronized {
+    if (plannedPartitions == null) {
+      try {
+        val partitions = call(name, "plan a scan 
of")(library.partitions(handle.get))
+        readerState = bytesOrEmpty(
+          call(name, "plan a scan 
of")(optional(library.serializeReader(handle.get))))
+        plannedPartitions = NativeInputPartition.from(partitions)
+      } finally {
+        // The executors only need the serialized state.
+        handle.close()
+      }
+    }
+    plannedPartitions
+  }
+
+  override def read(partition: InputPartition): PartitionReader[ColumnarBatch] 
= {
+    new NativePartitionReader(
+      location, name, readerState, 
partition.asInstanceOf[NativeInputPartition].bytes, readSchema)
+  }
+}
+
+/** Reads a native data source for a micro-batch streaming scan. */
+class NativeDataSourceStreamReader(
+    location: NativeLibraryLocation,
+    name: String,
+    schema: StructType,
+    @transient private val handle: NativeHandle)
+  extends ColumnarStreamReader {
+  import NativeCalls._
+
+  // Set on the driver whenever the partitions of a micro-batch are planned, 
and serialized to
+  // the executors with the partitions of that micro-batch.
+  private var readerState: Array[Byte] = Array.emptyByteArray
+
+  @transient private lazy val library = NativeLibraries.get(location)
+
+  override def initialOffset(): String = {
+    call(name, "plan a scan of")(library.initialOffset(handle.get))
+  }
+
+  override def latestOffset(): String = {
+    call(name, "plan a scan of")(library.latestOffset(handle.get))
+  }
+
+  override def partitions(start: String, end: String): Array[InputPartition] = 
{
+    val partitions = call(name, "plan a scan of") {
+      library.streamPartitions(handle.get, start, end)
+    }
+    readerState = bytesOrEmpty(
+      call(name, "plan a scan 
of")(optional(library.serializeStreamReader(handle.get))))
+    NativeInputPartition.from(partitions)
+  }
+
+  override def read(partition: InputPartition): PartitionReader[ColumnarBatch] 
= {
+    new NativePartitionReader(
+      location, name, readerState, 
partition.asInstanceOf[NativeInputPartition].bytes, schema)
+  }
+
+  override def commit(end: String): Unit = {
+    call(name, "commit an offset 
of")(optional(library.commitOffset(handle.get, end)))
+  }
+
+  override def stop(): Unit = handle.close()
+}
+
+/** Writes to a native data source for a batch write. */
+class NativeDataSourceWriter(
+    location: NativeLibraryLocation,
+    name: String,
+    @transient private val handle: NativeHandle,
+    writerState: Array[Byte])
+  extends ColumnarWriter {
+  import NativeCalls._
+
+  @transient private lazy val library = NativeLibraries.get(location)
+
+  override def createWriter(partitionId: Int, taskId: Long): 
DataWriter[ColumnarBatch] = {
+    new NativeDataWriter(location, name, writerState, partitionId, taskId, 
epochId = -1L)
+  }
+
+  override def commit(messages: Array[WriterCommitMessage]): Unit = {
+    call(name, "commit a write to") {
+      library.commit(handle.get, -1L, 
NativeWriterCommitMessage.toBytes(messages))
+    }
+    handle.close()
+  }
+
+  override def abort(messages: Array[WriterCommitMessage]): Unit = {
+    Utils.tryWithSafeFinally {
+      call(name, "abort a write to") {
+        library.abort(handle.get, -1L, 
NativeWriterCommitMessage.toBytes(messages))
+      }
+    }(handle.close())
+  }
+}
+
+/** Writes to a native data source for a streaming write. */
+class NativeDataSourceStreamWriter(
+    location: NativeLibraryLocation,
+    name: String,
+    @transient private val handle: NativeHandle,
+    writerState: Array[Byte])
+  extends ColumnarStreamWriter {
+  import NativeCalls._
+
+  @transient private lazy val library = NativeLibraries.get(location)
+
+  override def createWriter(
+      partitionId: Int,
+      taskId: Long,
+      epochId: Long): DataWriter[ColumnarBatch] = {
+    new NativeDataWriter(location, name, writerState, partitionId, taskId, 
epochId)
+  }
+
+  override def commit(epochId: Long, messages: Array[WriterCommitMessage]): 
Unit = {
+    call(name, "commit a write to") {
+      library.commit(handle.get, epochId, 
NativeWriterCommitMessage.toBytes(messages))
+    }
+  }
+
+  override def abort(epochId: Long, messages: Array[WriterCommitMessage]): 
Unit = {
+    call(name, "abort a write to") {
+      library.abort(handle.get, epochId, 
NativeWriterCommitMessage.toBytes(messages))
+    }
+  }
+}
+
+/** A partition planned by a native library. */
+case class NativeInputPartition(index: Int, bytes: Array[Byte]) extends 
InputPartition
+
+object NativeInputPartition {
+  def from(partitions: Array[Array[Byte]]): Array[InputPartition] = {
+    Option(partitions).getOrElse(Array.empty).zipWithIndex.map { case (bytes, 
i) =>
+      NativeInputPartition(i, Option(bytes).getOrElse(Array.emptyByteArray))
+    }
+  }
+}
+
+/** The message returned by the data writer of a task of a native library. */
+case class NativeWriterCommitMessage(bytes: Array[Byte]) extends 
WriterCommitMessage
+
+object NativeWriterCommitMessage {
+  def toBytes(messages: Array[WriterCommitMessage]): Array[Array[Byte]] = 
messages.map {
+    case message: NativeWriterCommitMessage => message.bytes
+    case null => null
+    case other =>
+      throw SparkException.internalError(s"Unexpected commit message: 
${other.getClass.getName}")
+  }
+}
+
+/**
+ * Reads a partition of a native data source: the Arrow stream exported by the 
library is
+ * imported into Arrow vectors that back the returned batch.
+ */
+class NativePartitionReader(
+    location: NativeLibraryLocation,
+    name: String,
+    readerState: Array[Byte],
+    partition: Array[Byte],
+    schema: StructType)
+  extends PartitionReader[ColumnarBatch] {
+  import NativeCalls._
+
+  private val allocator =
+    ArrowUtils.rootAllocator.newChildAllocator(s"native data source $name", 0, 
Long.MaxValue)
+  private var reader: ArrowReader = _
+  private var root: VectorSchemaRoot = _
+  private var batch: ColumnarBatch = _
+
+  try {
+    reader = openStream()
+    root = call(name, "read")(reader.getVectorSchemaRoot)
+    checkSchema()
+    batch = new ColumnarBatch(
+      root.getFieldVectors.asScala.map(new ArrowColumnVector(_): 
ColumnVector).toArray)
+  } catch {
+    case e: Throwable =>
+      close()
+      throw e
+  }
+
+  private def openStream(): ArrowReader = {
+    val library = NativeLibraries.get(location)
+    val stream = ArrowArrayStream.allocateNew(allocator)
+    try {
+      call(name, "read")(optional(library.read(readerState, partition, 
stream.memoryAddress())))
+        .getOrElse {
+          throw new SparkUnsupportedOperationException(
+            "DATA_SOURCE_BATCH_SCAN_NOT_SUPPORTED", Map("description" -> name))
+        }
+      Data.importArrayStream(allocator, stream)
+    } catch {
+      case e: Throwable =>
+        stream.release()
+        throw e
+    } finally {
+      stream.close()
+    }
+  }
+
+  private def checkSchema(): Unit = {
+    def isDictionaryEncoded(field: Field): Boolean = {
+      field.getDictionary != null || 
field.getChildren.asScala.exists(isDictionaryEncoded)
+    }
+    root.getSchema.getFields.asScala.find(isDictionaryEncoded).foreach { field 
=>
+      throw error(name, "read", s"The column ${field.getName} is 
dictionary-encoded, which " +
+        "is not supported.")
+    }
+    val actual = ArrowUtils.fromArrowSchema(root.getSchema)
+    val matches = actual.length == schema.length && actual.zip(schema).forall {
+      case (actualField, field) =>
+        actualField.name == field.name &&
+          DataType.equalsIgnoreNullability(actualField.dataType, 
field.dataType)
+    }
+    if (!matches) {
+      throw error(name, "read", s"The data has the schema ${actual.toDDL}, but 
the expected " +
+        s"schema is ${schema.toDDL}.")
+    }
+  }
+
+  override def next(): Boolean = {
+    val hasNext = call(name, "read")(reader.loadNextBatch())
+    if (hasNext) {
+      batch.setNumRows(root.getRowCount)
+    }
+    hasNext
+  }
+
+  override def get(): ColumnarBatch = batch
+
+  override def close(): Unit = {
+    Utils.tryWithSafeFinally {
+      if (reader != null) {
+        reader.close()
+      }
+    }(Utils.tryLogNonFatalError(allocator.close()))
+  }
+}
+
+/** Writes the batches of a task to the data writer of a native library. */
+class NativeDataWriter(
+    location: NativeLibraryLocation,
+    name: String,
+    writerState: Array[Byte],
+    partitionId: Int,
+    taskId: Long,
+    epochId: Long)
+  extends DataWriter[ColumnarBatch] {
+  import NativeCalls._
+
+  private val library = NativeLibraries.get(location)
+  // Zero once the data writer was committed or aborted, which releases it.
+  private var handle: Long = call(name, "write to") {
+    library.createDataWriter(writerState, partitionId, taskId, epochId)
+  }
+
+  override def write(batch: ColumnarBatch): Unit = {
+    if (handle == 0) {
+      throw SparkException.internalError("The data writer was already 
committed or aborted.")
+    }
+    val vectors = (0 until batch.numCols()).map { i =>
+      batch.column(i) match {
+        case vector: ArrowColumnVector => 
vector.getValueVector.asInstanceOf[FieldVector]
+        case vector => throw SparkException.internalError(
+          s"Expected an Arrow column vector, but got 
${vector.getClass.getName}.")
+      }
+    }
+    val root = new VectorSchemaRoot(
+      vectors.map(_.getField).asJava, vectors.asJava, batch.numRows())
+    val array = ArrowArray.allocateNew(ArrowUtils.rootAllocator)
+    val arrowSchema = ArrowSchema.allocateNew(ArrowUtils.rootAllocator)
+    try {
+      Data.exportVectorSchemaRoot(ArrowUtils.rootAllocator, root, null, array, 
arrowSchema)
+      call(name, "write to") {
+        library.write(handle, array.memoryAddress(), 
arrowSchema.memoryAddress())
+      }
+    } finally {
+      // Releases the batch unless the library moved it to take ownership.
+      array.release()
+      array.close()
+      arrowSchema.release()
+      arrowSchema.close()
+    }
+  }
+
+  override def commit(): WriterCommitMessage = {
+    val dataWriter = handle
+    handle = 0

Review Comment:
   Fixed in 9dd4f88 by changing the contract: `NativeDataWriter.commit` resets 
the handle only after `commitDataWriter` returns. If it throws, the handle 
stays valid and `DataWritingSparkTask` calls `abort()`, which calls 
`abortDataWriter` and releases the handle. The `NativeBridge.commitDataWriter` 
Javadoc now says this. The test library follows it and supports 
`fail=commitDataWriter`. The new test `abort a data writer whose commit failed` 
checks that the tasks' temporary files were removed by `abortDataWriter`.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceV2.scala:
##########
@@ -0,0 +1,67 @@
+/*
+ * 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.util
+
+import org.apache.spark.sql.connector.catalog.Table
+import org.apache.spark.sql.connector.expressions.Transform
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.execution.datasources.v2.NamedTableProvider
+import org.apache.spark.sql.execution.datasources.v2.columnar.ColumnarTable
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+/**
+ * The Data Source V2 provider of the native data sources: data sources 
implemented by a native
+ * library through `org.apache.spark.sql.datasource.NativeBridge`.
+ */
+class NativeDataSourceV2 extends NamedTableProvider {
+  private var shortName: String = _
+
+  override def setShortName(name: String): Unit = {
+    assert(shortName == null)
+    shortName = name
+  }
+
+  // The name of the data source, and where its library is. Resolved on first 
use, which is while
+  // the query is analyzed, with the configuration of its session.
+  private lazy val resolved: (String, NativeLibraryLocation) = {
+    assert(shortName != null)
+    val (name, location) = NativeDataSourceRegistry.lookup(shortName, 
SQLConf.get).getOrElse {

Review Comment:
   Fixed in 9dd4f88. `DataSource.lookupDataSource` now calls 
`NativeDataSourceRegistry.lookupForProvider`, which keeps the result in a 
thread-local keyed by the looked-up name. `NativeDataSourceV2.setShortName` 
takes it, so `resolved` uses the same lookup that found the class. Every site 
that creates the provider (`lookupDataSourceV2`, `ResolveDataSource`, 
`DataStreamWriter`) calls `lookupDataSource` and then `setShortName` on the 
same thread. A provider created any other way still falls back to its own 
lookup. The test `look up a native data source once` unsets 
`spark.sql.dataSource.native.paths` between the two steps and checks the 
provider still resolves from the first lookup.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala:
##########
@@ -682,6 +683,7 @@ object DataSource extends Logging {
     val serviceLoader = ServiceLoader.load(classOf[DataSourceRegister], loader)
     lazy val isUserDefinedDataSource = SparkSession.getActiveSession.exists(
       _.sessionState.dataSourceManager.dataSourceExists(provider))
+    lazy val isNativeDataSource = NativeDataSourceRegistry.exists(provider, 
conf)

Review Comment:
   Not intended, fixed in 9dd4f88. `checkDataSourceExists` now calls a new 
`lookupDataSource(name, conf, includeNativeDataSources = false)`, which matches 
the precedence (a Python data source wins over a native one). Covered by 
`Python data sources take precedence`, which installs 
`libspark_datasource_installed_native_range` and then registers a Python data 
source with that name.



-- 
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