dongjoon-hyun commented on code in PR #59947:
URL: https://github.com/apache/spark/pull/59947#discussion_r4420253883
##########
sql/connect/server/src/test/scala/org/apache/spark/sql/connect/NativeDataSourceConnectSuite.scala:
##########
@@ -92,6 +93,23 @@ class NativeDataSourceConnectSuite extends
SparkConnectServerTest {
}
}
+ nativeTest("reject a package that Spark cannot use when a session adds it") {
+ val invalid = new File(Utils.createTempDir(), "native_range.sparkpkg")
+ Files.writeString(invalid.toPath, "not a zip")
+ withNewSession { session =>
+ // The client gets the error of the server in the message of a
SparkException.
+ val e = intercept[SparkException](session.addArtifact(invalid.getPath))
Review Comment:
**2. [Low] A rejected package leaves a Spark Connect batch partly applied**
The same happens in `SparkConnectAddArtifactsHandler.flushStagedArtifacts`
([L215-L229](https://github.com/apache/spark/blob/40d50ada443137ce8b2bfa566206ea7673ab55ac/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectAddArtifactsHandler.scala#L215-L229)),
which also catches only `ARTIFACT_ALREADY_EXISTS`. Its comment says "Resolve
the complete ordered batch before mutating session state", and it resolves the
Maven dependencies first for that reason. A package that fails `checkAdded` now
fails in the middle of the loop:
- the artifacts before it in the same stream are already in the session,
- the ones after it are not, and
- the client gets the error without the artifact summaries, so it cannot
tell which ones were added.
For example, the Python client sends all the paths of
`spark.addArtifacts("dep.jar", "bad.sparkpkg", "util.jar")` in one stream.
Could the handler check the staged `files/*.sparkpkg` artifacts right after
`prepareArtifacts()`, before it adds any of them? Then this error leaves the
session unchanged. This test adds a single artifact, so a test that adds
several in one call would pin down whichever behavior is chosen.
##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -285,6 +290,19 @@ class ArtifactManager(session: SparkSession) extends
AutoCloseable with Logging
Map("normalizedRemoteRelativePath" ->
normalizedRemoteRelativePath.toString)
)
}
+ // A package is checked before it becomes an artifact of the session:
see checkAdded. A
+ // file that does not exist fails to transfer instead. Errors name the
file of the user, or
+ // the artifact if the file was staged for it, such as by a Spark
Connect client.
+ if (isNativeDataSourcePackage(normalizedRemoteRelativePath) &&
Review Comment:
**3. [Low] The check widens the window between `Files.exists(target)` and
`transferFile`**
`addArtifact` is not synchronized, so two calls that add the same
`files/x.sparkpkg` can both pass `Files.exists(target)` above. Before, the
window up to `transferFile` was short. Now it includes opening the zip and
hashing the whole file, so it grows with the size of the package.
When the second call loses, `Files.move` or `Files.copy` fails with
`FileAlreadyExistsException`. For the same content, that replaces the
idempotent success that `contentEquals` gives. It is not
`ARTIFACT_ALREADY_EXISTS` either, so `addLocalArtifacts` and
`flushStagedArtifacts` do not collect it. This can happen with two threads of
one session that add the same package, or with two Spark Connect clients of one
session.
`NativeDataSourceRegistry.addToArtifacts` already synchronizes on the
artifact manager for this reason. Could the existence check, the package check
and the transfer of a file artifact run under that lock too?
##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -285,6 +290,19 @@ class ArtifactManager(session: SparkSession) extends
AutoCloseable with Logging
Map("normalizedRemoteRelativePath" ->
normalizedRemoteRelativePath.toString)
)
}
+ // A package is checked before it becomes an artifact of the session:
see checkAdded. A
+ // file that does not exist fails to transfer instead. Errors name the
file of the user, or
+ // the artifact if the file was staged for it, such as by a Spark
Connect client.
+ if (isNativeDataSourcePackage(normalizedRemoteRelativePath) &&
+ session.conf.get(StaticSQLConf.NATIVE_DATA_SOURCE_ENABLED) &&
+ Files.exists(serverLocalStagingPath)) {
+ val path = if (deleteStagedFile) {
+ normalizedRemoteRelativePath.toString
+ } else {
+ serverLocalStagingPath.toString
+ }
+ NativeDataSourcePackage.checkAdded(serverLocalStagingPath.toFile, path)
+ }
transferFile(serverLocalStagingPath, target, deleteSource =
deleteStagedFile)
Review Comment:
**4. [Low] A local file is checked, and then copied again from the path of
the user**
With `deleteStagedFile = false`, `checkAdded` reads the file of the user,
and `transferFile` then copies it again from the same path. If the file changes
in between, the check passes on the old content and the session gets the new
content without a check. For example, a build may write a new `rs-2.0.sparkpkg`
while a notebook adds it. The lookup then ignores the package with a warning in
the log of the driver, which is the situation this PR prevents.
Copying the file first to a temporary file in the artifact directory of the
session, checking that copy, and then moving it to `target` would check exactly
the bytes that become the artifact. It would also read the file of the user
once instead of twice. The temporary file has another name, so an invalid
package is still never at the path of an artifact.
##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -285,6 +290,19 @@ class ArtifactManager(session: SparkSession) extends
AutoCloseable with Logging
Map("normalizedRemoteRelativePath" ->
normalizedRemoteRelativePath.toString)
)
}
+ // A package is checked before it becomes an artifact of the session:
see checkAdded. A
+ // file that does not exist fails to transfer instead. Errors name the
file of the user, or
+ // the artifact if the file was staged for it, such as by a Spark
Connect client.
+ if (isNativeDataSourcePackage(normalizedRemoteRelativePath) &&
+ session.conf.get(StaticSQLConf.NATIVE_DATA_SOURCE_ENABLED) &&
+ Files.exists(serverLocalStagingPath)) {
+ val path = if (deleteStagedFile) {
+ normalizedRemoteRelativePath.toString
+ } else {
+ serverLocalStagingPath.toString
+ }
+ NativeDataSourcePackage.checkAdded(serverLocalStagingPath.toFile, path)
Review Comment:
**1. [Low] Rejecting a package stops `addLocalArtifacts` and drops the
`ARTIFACT_ALREADY_EXISTS` errors that it collected**
`checkAdded` throws a `SparkRuntimeException` here, but `addLocalArtifacts`
only catches `ARTIFACT_ALREADY_EXISTS`
([L374-L377](https://github.com/apache/spark/blob/40d50ada443137ce8b2bfa566206ea7673ab55ac/sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala#L374-L377)).
The new error leaves the loop, so:
- the errors already collected in `failedArtifactExceptions` are lost, and
- the artifacts before the package are added, but the ones after it are not.
For example, if `a.jar` was already added with other content,
`spark.addArtifacts(aJar, badPackage, bJar)` only reports the package. The user
fixes the package, calls again, and only then learns about `a.jar`. The
description mentions the artifacts after the rejected package, but not the lost
errors.
`INVALID_NATIVE_DATA_SOURCE_PACKAGE` is a `SparkRuntimeException` too, so
could `addLocalArtifacts` collect it like `ARTIFACT_ALREADY_EXISTS`, and report
all of them with `mergeExceptionsWithSuppressed`? Another option is to check
every package of the call before adding any artifact, so that a rejected
package changes nothing.
##########
python/pyspark/sql/tests/test_artifact.py:
##########
@@ -134,6 +147,15 @@ def new_file(name):
spark.addArtifact("my_source:1.sparkpkg")
finally:
os.chdir(cwd)
+ # A package that Spark cannot read is rejected.
+ invalid = os.path.join(d, "invalid.sparkpkg")
+ with open(invalid, "wb") as f:
+ f.write(b"not a zip")
+ with self.assertRaises(SparkRuntimeException) as e:
+ spark.addArtifact(invalid)
Review Comment:
**5. [Low] In classic Python, a rejected package also stops the paths listed
before it**
Classic `addArtifacts` adds the `.sparkpkg` paths to the JVM one by one
before it handles any other path ([session.py
L2305-L2311](https://github.com/apache/spark/blob/40d50ada443137ce8b2bfa566206ea7673ab55ac/python/pyspark/sql/session.py#L2305-L2311)).
So a rejected package now also stops the paths before it in the call:
`spark.addArtifacts("a.txt", "bad.sparkpkg", "b.sparkpkg", file=True)` adds
neither `a.txt` nor `b.sparkpkg`.
The description only says that Scala, Java and Spark Connect skip the
artifacts after the rejected package, and this test adds the invalid package
alone. Could the description mention the Python case, or `addArtifacts` keep
the order of the paths?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala:
##########
@@ -205,7 +233,7 @@ object NativeDataSourcePackage extends Logging {
}
}
}
- manifest.map(NativeDataSourcePackage(file.getPath, sha256(file), _))
+ manifest.map(NativeDataSourcePackage(path, sha256(file), _))
} catch {
case e: IOException => invalid(s"The package is not a valid zip file:
$e", cause = e)
Review Comment:
**6. [Nit] A file that cannot be read is reported as an invalid zip file**
The check skips a file that does not exist, so that it still fails with
`NoSuchFileException`, but it does not skip other I/O errors. For a file
without read permission, `new ZipFile` fails with a `FileNotFoundException`
that says `Permission denied`. The user now gets `INVALID_MANIFEST` with "The
package is not a valid zip file", which suggests a broken package. Before,
`Files.copy` failed with `AccessDeniedException`.
Checking `Files.isReadable` along with `Files.exists` in `addArtifact` would
keep the earlier error. Another option is a reason here that only says "not a
valid zip file" for a `ZipException`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala:
##########
@@ -132,6 +132,33 @@ object NativeDataSourcePackage extends Logging {
/** 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)
+ /**
+ * Checks a package that a session adds with `spark.addArtifact`, in the
given file, before the
+ * session gets it: fails if Spark cannot read the package, or does not
support its version of
+ * the interface. The package that a session added last takes precedence,
and a session cannot
+ * remove an artifact, so Spark rejects such a package rather than ignore
it, as it ignores the
+ * configured ones, which would keep the session on the package that it
added before. The file
+ * is not the artifact itself, so the result is not cached, and errors call
it `path`.
+ */
+ def checkAdded(file: File, path: String): Unit = {
Review Comment:
**10. [Low] A package without a library for the platform of the driver still
leaves the session on a broken data source**
`checkAdded` accepts such a package, so it becomes the package that the
session added last. Every query on its data sources then fails with
`UNSUPPORTED_PLATFORM` until the session adds yet another package under a new
file name. For example, a session that uses `rs-1.0.sparkpkg` and adds
`rs-2.0.sparkpkg`, built only for `osx-aarch64`, cannot read `rs` on a Linux
driver anymore.
The error is visible now, unlike before, but the session cannot go back to
rs-1.0. That is the problem that the description gives for rejecting the other
packages. The driver loads the library of the data source to infer the schema,
so at this point Spark already knows that the package cannot work in this
application. Was there a reason to accept it? If so, could the doc of
`checkAdded` and the docs give it?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala:
##########
@@ -1555,6 +1555,70 @@ class NativeDataSourceSuite extends QueryTest with
SharedSparkSession {
assert(logged.forall(_.contains("for the data source native_range")),
logged)
}
+ nativeTest("reject a package that a session adds when Spark cannot use it") {
Review Comment:
**8. [Low] The new Scala tests are canceled in the PR builds**
This test and the new one in `NativeDataSourceConnectSuite` are
`nativeTest`s. They need a C++ compiler and Java 22 or later, since `toolchain`
requires `NativeLibrary.isSupported`. The PR builds use Java 17, so both were
canceled there, and the Python test is the only new test that ran. The Scala
tests only run in the scheduled Java 25 builds, after the merge.
Most of what this PR adds does not load a library:
- the rejection of a newer `abiVersion`, of a missing library, of a file
that is not a zip and of a directory,
- `ARTIFACT_ALREADY_EXISTS` coming first, and `NoSuchFileException` for a
missing file,
- the `pyfiles/` artifact that is not a package, and
- the name of the artifact in the error that a Spark Connect client gets.
These only need `createZip` and `manifestJson`. Could they move to a plain
`test`, so that the PR builds cover them? The parts that run a query can stay
in a `nativeTest`.
##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -285,6 +290,19 @@ class ArtifactManager(session: SparkSession) extends
AutoCloseable with Logging
Map("normalizedRemoteRelativePath" ->
normalizedRemoteRelativePath.toString)
)
}
+ // A package is checked before it becomes an artifact of the session:
see checkAdded. A
+ // file that does not exist fails to transfer instead. Errors name the
file of the user, or
+ // the artifact if the file was staged for it, such as by a Spark
Connect client.
+ if (isNativeDataSourcePackage(normalizedRemoteRelativePath) &&
+ session.conf.get(StaticSQLConf.NATIVE_DATA_SOURCE_ENABLED) &&
+ Files.exists(serverLocalStagingPath)) {
+ val path = if (deleteStagedFile) {
+ normalizedRemoteRelativePath.toString
Review Comment:
**7. [Nit] A rejected in-memory package leaves its temporary file**
When `deleteStagedFile` is true, only the move in `transferFile` removes the
staged file. For `spark.addArtifact(bytes, "x.sparkpkg")`, `addLocalArtifacts`
writes the bytes to a file in a new temporary directory
([L361-L370](https://github.com/apache/spark/blob/40d50ada443137ce8b2bfa566206ea7673ab55ac/sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala#L361-L370)),
and its `finallyBlock` only closes the stream. When `checkAdded` rejects the
package, the file stays until the JVM exits, since `Utils.createTempDir` only
registers the directory for deletion at shutdown. On a long-running driver,
each rejected call keeps a copy of the package on the disk.
Deleting the temporary directory in that `finallyBlock` would cover this,
and the existing `ARTIFACT_ALREADY_EXISTS` case too. Spark Connect is fine: its
handler deletes the staging directory on errors.
##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -285,6 +290,19 @@ class ArtifactManager(session: SparkSession) extends
AutoCloseable with Logging
Map("normalizedRemoteRelativePath" ->
normalizedRemoteRelativePath.toString)
)
}
+ // A package is checked before it becomes an artifact of the session:
see checkAdded. A
+ // file that does not exist fails to transfer instead. Errors name the
file of the user, or
+ // the artifact if the file was staged for it, such as by a Spark
Connect client.
+ if (isNativeDataSourcePackage(normalizedRemoteRelativePath) &&
+ session.conf.get(StaticSQLConf.NATIVE_DATA_SOURCE_ENABLED) &&
+ Files.exists(serverLocalStagingPath)) {
+ val path = if (deleteStagedFile) {
Review Comment:
**11. [Nit] `deleteStagedFile` decides which name the error shows**
`deleteStagedFile` says whether to move or copy the staged file, but here it
also decides whether the staged file is the file of the user. That holds for
the current callers: `LocalFile` copies, and `InMemory` and Spark Connect move.
But nothing ties the two together. A caller that moves a file of the user, or
copies a staged file, would show the wrong name, possibly a path on the server.
`addLocalArtifacts` already knows the kind of storage. Could it pass the
name to show, for example as an `Option[String]` parameter of `addArtifact`?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceRegistry.scala:
##########
@@ -146,8 +142,8 @@ object NativeDataSourceRegistry extends Logging {
packages: Seq[NativeDataSourcePackage]): Option[(String,
NativeDataSourcePackage)] = {
val found = packages
.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.
+ // The same package can be found more than once, for example when it is
listed in
+ // spark.sql.dataSource.native.paths both as a file and through its
directory.
Review Comment:
**9. [Nit] The test "conflicting packages" no longer tests what its comment
says**
This comment now says that `lookupPackage` only sees the configured
packages, which is right: the packages of a session go to `lookupAddedPackage`.
But the end of the test "conflicting packages" ([NativeDataSourceSuite.scala
L1525-L1528](https://github.com/apache/spark/blob/40d50ada443137ce8b2bfa566206ea7673ab55ac/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala#L1525-L1528))
still says that it checks the same package "in the configured paths and in the
artifacts of the session". That case goes through `lookupAddedPackage`, so it
never reaches the deduplication by `sha256` here, and the test still passes
without it.
No test lists the same file both directly and through its directory, which
is the case that this comment now gives. Could the test configure one package
both ways, with a directory that contains only that package, so that it fails
without the deduplication?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala:
##########
@@ -205,7 +233,7 @@ object NativeDataSourcePackage extends Logging {
}
}
}
- manifest.map(NativeDataSourcePackage(file.getPath, sha256(file), _))
+ manifest.map(NativeDataSourcePackage(path, sha256(file), _))
Review Comment:
**12. [Nit] `checkAdded` computes a SHA-256 checksum that it does not use**
`parse` always hashes the whole file, and `checkAdded` drops the result.
With the copy into the artifacts, and the first lookup, which reads the copy
again in `tryRead`, Spark now reads a package three times when a session adds
it. That can be a lot for a package with libraries for several platforms.
Could `parse` return the manifest, and `tryRead` compute the checksum? Then
`checkAdded` only reads the central directory and the manifest.
##########
docs/sql-native-data-sources.md:
##########
@@ -325,12 +325,15 @@ 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 package
+that Spark cannot read or whose version it does not support, so that a session
does not keep using
Review Comment:
**14. [Low] The API docs and the migration guide do not mention the new
failure**
This page now says that `spark.addArtifact` fails for such a package. But
the API docs of `addArtifact` ([SparkSession.scala
L571-L572](https://github.com/apache/spark/blob/40d50ada443137ce8b2bfa566206ea7673ab55ac/sql/api/src/main/scala/org/apache/spark/sql/SparkSession.scala#L571-L572))
and the docstring of `addArtifacts` in Python still only say that a package
makes its data sources available.
As the description notes, a file named `*.sparkpkg` that Spark 4.3 added as
a plain file, with `file=True` or from a Spark Connect client, is now rejected.
The only way to keep the old behavior is
`spark.sql.dataSource.native.enabled=false`, a static configuration that also
turns off all native data sources. A line in the section "Upgrading from Spark
SQL 4.3 to 4.4" of `sql-migration-guide.md` would help those users.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala:
##########
@@ -132,6 +132,33 @@ object NativeDataSourcePackage extends Logging {
/** 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)
+ /**
+ * Checks a package that a session adds with `spark.addArtifact`, in the
given file, before the
+ * session gets it: fails if Spark cannot read the package, or does not
support its version of
+ * the interface. The package that a session added last takes precedence,
and a session cannot
+ * remove an artifact, so Spark rejects such a package rather than ignore
it, as it ignores the
+ * configured ones, which would keep the session on the package that it
added before. The file
+ * is not the artifact itself, so the result is not cached, and errors call
it `path`.
+ */
+ def checkAdded(file: File, path: String): Unit = {
+ parse(file, path) match {
+ case Left(invalid) => throw invalid.error
+ case Right(pkg) if pkg.manifest.abiVersion > NativeLibrary.ABI_VERSION =>
Review Comment:
**13. [Nit] The check of the supported version is now in three places**
`pkg.manifest.abiVersion > NativeLibrary.ABI_VERSION` is here, in `tryRead`
([L182](https://github.com/apache/spark/blob/40d50ada443137ce8b2bfa566206ea7673ab55ac/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala#L182)),
and negated in `NativeDataSourceRegistry.lookup`
([L67-L70](https://github.com/apache/spark/blob/40d50ada443137ce8b2bfa566206ea7673ab55ac/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceRegistry.scala#L67-L70)).
This PR already moves the error into `unsupportedAbiVersionError` for the same
reason. A helper next to it, such as `isSupported(pkg)`, would keep the check
when a session adds a package and the check at lookup from drifting apart.
--
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]