dongjoon-hyun commented on code in PR #59947:
URL: https://github.com/apache/spark/pull/59947#discussion_r4433063716


##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -339,7 +451,8 @@ class ArtifactManager(session: SparkSession) extends 
AutoCloseable with Logging
               artifact.path,
               d.path,
               fragment = None,
-              deleteStagedFile = false)
+              deleteStagedFile = false,
+              sourceName = Some(d.path.toString))

Review Comment:
   **11. [Nit] A local file no longer goes through the public `addArtifact`**
   
   This follows item 11 of my first review 
([thread](https://github.com/apache/spark/pull/59947#discussion_r4420253942)). 
Before this PR, `addLocalArtifacts` called the public `addArtifact` for a 
`LocalFile` too. Now a `LocalFile` goes to the private overload, while an 
in-memory artifact (L462) and `SparkConnectAddArtifactsHandler` still call the 
public one. `ArtifactManager` is a public class with `protected` members, such 
as `artifactRootPath`, so a subclass that overrides the public `addArtifact`, 
for example to audit what a session adds, would no longer see the jars, files 
and packages that `spark.addArtifact(path)` adds, and nothing would tell it.
   
   If such a subclass is not a case to keep, this is fine. Otherwise, could 
`addLocalArtifacts` use the overload only for a native data source package, the 
only artifact whose errors use `sourceName`, and the public `addArtifact` for 
the others?



##########
docs/sql-native-data-sources.md:
##########
@@ -325,12 +325,26 @@ A session finds the packages in two places, and the 
executors get them automatic
 
 The packages of a session take precedence over the installed libraries, and 
the package that a
 session added last over its other packages, also the configured ones: a 
session cannot remove an
-artifact, or add a file with the same name again, so it upgrades a package by 
adding the new
-version under another file name, such as `rust_range-1.1.sparkpkg`. Distinct 
file names are also
-needed because the sessions that are not isolated share the files they add. A 
query fails if more
-than one configured package provides its data source. Spark ignores, and logs, 
a package that it
-cannot read or whose version it does not support, unless its manifest lists 
the data source that a
-query uses and no other package or installed library provides it.
+artifact, or add another file with the name of one that it added, so it 
upgrades a package by adding
+the new version under another file name, such as `rust_range-1.1.sparkpkg`. 
Distinct file names are
+also needed because the sessions that are not isolated share the files they 
add. A query fails if
+more than one configured package provides its data source. `spark.addArtifact` 
fails for a file that
+is not a valid package, or for a package whose version Spark does not support, 
or that has no
+library for the platform of the driver, which loads the library to plan a 
query, instead of adding a
+package that the session could not use: the session would ignore it and 
silently keep using the
+package or library that it used before, or fail every query on its data 
sources. After the failure,
+the session keeps the packages that it had, and a call that adds several 
artifacts in Scala or Java
+still adds the others. In Python, a rejected package stops the rest of the 
call: without Spark
+Connect, `spark.addArtifacts` adds the packages first, one at a time, so only 
the packages before it
+are added; with Spark Connect, it adds the paths one at a time, so the paths 
before the package are
+added. Spark does not load the library when it checks a package, so a library 
that fails to load
+fails the queries that use it. Nor does it check that the driver can run 
native data sources at all,
+which requires Java 22 or later and a platform where the C data interface of 
Arrow Java loads:
+there, the add succeeds, and the queries fail with 
`NATIVE_DATA_SOURCE_UNSUPPORTED_JAVA_VERSION` or

Review Comment:
   **10. [Low] On Linux with musl, the add fails, unlike what the docs say**
   
   This sentence, which came with item 5 
([thread](https://github.com/apache/spark/pull/59947#discussion_r4424226338)), 
says that where the C data interface of Arrow Java does not load, the add 
succeeds and the queries fail with `NATIVE_DATA_SOURCE_UNSUPPORTED_PLATFORM`. 
That holds for the version of Java, but usually not for the platform: 
`checkAdded` rejects a package without a library for `NativePlatform.current`, 
and on these platforms a package usually has none.
   
   - On Linux with musl, `NativePlatform.current` is `linux-<arch>-musl`, such 
as `linux-x86_64-musl` ([NativeDataSourcePackage.scala 
L379-L383](https://github.com/apache/spark/blob/465dbfc06f5ec1d2abae1784d7c6817690404895/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala#L379-L383)),
 where, as L47-L50 say, a query fails with 
`NATIVE_DATA_SOURCE_UNSUPPORTED_PLATFORM`. So a package built for 
`linux-x86_64` fails at the add with 
`INVALID_NATIVE_DATA_SOURCE_PACKAGE.UNSUPPORTED_PLATFORM`, not at the query.
   - On Linux on ppc64le or s390x, the same happens unless the package has a 
library for `linux-ppc64le` or `linux-s390x`.
   
   So there, the add only succeeds for a package with a library for the 
platform of the driver. The doc of `checkAdded` 
(NativeDataSourcePackage.scala:144-146) and the description say the same as 
this sentence. Could all three say that the add succeeds there only if the 
package has a library for that platform?



##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -204,6 +210,96 @@ class ArtifactManager(session: SparkSession) extends 
AutoCloseable with Logging
     // Convert the normalized string back to a Path object
     Paths.get(normalizedPathString).normalize()
   }
+
+  /**
+   * Returns true if a file artifact with the same content is at `target` 
already, which makes
+   * adding it idempotent, and fails if another file is there: an artifact is 
not overwritten.
+   */
+  private def artifactExists(
+      normalizedRemoteRelativePath: Path,
+      serverLocalStagingPath: Path,
+      target: Path): Boolean = {
+    if (Files.exists(target)) {
+      if (Utils.contentEquals(target.toFile, serverLocalStagingPath.toFile)) {
+        return true
+      }
+      throw artifactAlreadyExistsError(normalizedRemoteRelativePath)
+    }
+    false
+  }
+
+  private def artifactAlreadyExistsError(normalizedRemoteRelativePath: Path): 
Throwable = {
+    new SparkRuntimeException(
+      "ARTIFACT_ALREADY_EXISTS",
+      Map("normalizedRemoteRelativePath" -> 
normalizedRemoteRelativePath.toString)
+    )
+  }
+
+  /**
+   * Transfers a native data source package to `target` if Spark can use it, 
see
+   * [[NativeDataSourcePackage.checkAdded]], so that it never is at the path 
of an artifact
+   * otherwise. Returns false if the session has the same file already. A file 
of the user is
+   * compared with the artifact first, so that adding the same file again does 
not copy it. It is
+   * then copied to a temporary directory, outside of the artifacts, which the 
file server serves
+   * and a clone of the session copies, and the copy is checked and then 
moved, so that the session
+   * gets the bytes that are checked. Errors name the file of the user, or the 
artifact if Spark
+   * staged the file for it, such as for a Spark Connect client.
+   */
+  private def addNativeDataSourcePackage(
+      normalizedRemoteRelativePath: Path,
+      serverLocalStagingPath: Path,
+      target: Path,
+      deleteStagedFile: Boolean,
+      sourceName: Option[String]): Boolean = {
+    def exists(source: Path): Boolean = {
+      // Utils.contentEquals fails for a directory, which cannot be the same 
as a package anyway,
+      // so a directory with the name of an artifact, or an artifact that is a 
directory, such
+      // as files/x.sparkpkg after files/x.sparkpkg/y was added, fails as an 
existing artifact.
+      if (Files.exists(target) && (Files.isDirectory(source) || 
Files.isDirectory(target))) {
+        throw artifactAlreadyExistsError(normalizedRemoteRelativePath)
+      }
+      artifactExists(normalizedRemoteRelativePath, source, target)
+    }
+    if (!deleteStagedFile && exists(serverLocalStagingPath)) {

Review Comment:
   **12. [Nit] A staged package could also be compared with the artifact first**
   
   This follows items 6 and 8 
([thread](https://github.com/apache/spark/pull/59947#discussion_r4424226345), 
[thread](https://github.com/apache/spark/pull/59947#discussion_r4424226366)). 
Since item 6, a local file is compared with the artifact here, before the 
check, and again right before the transfer. So for a local file, 
`ARTIFACT_ALREADY_EXISTS` comes first through this comparison, and the `Try` 
only matters when another add of the same name races with this one. A staged 
file could do the same:
   
   1. `if (exists(serverLocalStagingPath)) return false` for both kinds,
   2. `checkAdded` on `staged`, without `Try`,
   3. `exists(staged)` right before the transfer, as now.
   
   The `Try` that I suggested in item 8, `check.get`, the comment at L273-L274 
and the `!deleteStagedFile &&` would then go. A Spark Connect client that sends 
a package again would also not have the zip opened for a check whose result is 
dropped. The only difference is an add that races with another add of the same 
name: the error of its check would come before `ARTIFACT_ALREADY_EXISTS`. If 
that is fine, could both kinds share the first comparison?



##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -204,6 +210,96 @@ class ArtifactManager(session: SparkSession) extends 
AutoCloseable with Logging
     // Convert the normalized string back to a Path object
     Paths.get(normalizedPathString).normalize()
   }
+
+  /**
+   * Returns true if a file artifact with the same content is at `target` 
already, which makes
+   * adding it idempotent, and fails if another file is there: an artifact is 
not overwritten.
+   */
+  private def artifactExists(
+      normalizedRemoteRelativePath: Path,
+      serverLocalStagingPath: Path,
+      target: Path): Boolean = {
+    if (Files.exists(target)) {
+      if (Utils.contentEquals(target.toFile, serverLocalStagingPath.toFile)) {
+        return true
+      }
+      throw artifactAlreadyExistsError(normalizedRemoteRelativePath)
+    }
+    false
+  }
+
+  private def artifactAlreadyExistsError(normalizedRemoteRelativePath: Path): 
Throwable = {
+    new SparkRuntimeException(
+      "ARTIFACT_ALREADY_EXISTS",
+      Map("normalizedRemoteRelativePath" -> 
normalizedRemoteRelativePath.toString)
+    )
+  }
+
+  /**
+   * Transfers a native data source package to `target` if Spark can use it, 
see
+   * [[NativeDataSourcePackage.checkAdded]], so that it never is at the path 
of an artifact
+   * otherwise. Returns false if the session has the same file already. A file 
of the user is
+   * compared with the artifact first, so that adding the same file again does 
not copy it. It is
+   * then copied to a temporary directory, outside of the artifacts, which the 
file server serves
+   * and a clone of the session copies, and the copy is checked and then 
moved, so that the session
+   * gets the bytes that are checked. Errors name the file of the user, or the 
artifact if Spark
+   * staged the file for it, such as for a Spark Connect client.
+   */
+  private def addNativeDataSourcePackage(
+      normalizedRemoteRelativePath: Path,
+      serverLocalStagingPath: Path,
+      target: Path,
+      deleteStagedFile: Boolean,
+      sourceName: Option[String]): Boolean = {
+    def exists(source: Path): Boolean = {

Review Comment:
   **13. [Nit] `exists` and `artifactExists` read as checks, but throw**
   
   Both return a `Boolean`, but both also throw `ARTIFACT_ALREADY_EXISTS` when 
another file is at `target` (L226, L259). At the call sites, `!deleteStagedFile 
&& exists(serverLocalStagingPath)` (L263) and `if (exists(staged))` (L277), 
nothing shows that the condition can throw, or, at L277, that its error comes 
before the error of the check, which is the order that L273-L274 describe. 
Could the names, or a short comment at these calls, say that they fail for 
another file?



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