sarutak commented on code in PR #59947:
URL: https://github.com/apache/spark/pull/59947#discussion_r4429230724
##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -204,6 +210,75 @@ 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
+ * first 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 = {
+ val copyDir = if (deleteStagedFile) None else
Some(Utils.createTempDir().toPath)
+ Utils.tryWithSafeFinally {
+ val staged = copyDir.map { dir =>
+ // Fails as for the other artifacts if the file does not exist or
cannot be read.
+ Files.copy(serverLocalStagingPath,
dir.resolve(normalizedRemoteRelativePath.getFileName))
+ }.getOrElse(serverLocalStagingPath)
+ // The package is checked before the check for an existing artifact,
which then runs right
+ // before the transfer, but its error is reported after that check.
+ val rejection = Try(NativeDataSourcePackage.checkAdded(
+ staged.toFile,
sourceName.getOrElse(normalizedRemoteRelativePath.toString))).failed
+ // Utils.contentEquals fails for a directory, which cannot be the same
as the artifact
+ // anyway, so a directory with the name of an artifact fails as one.
+ if (Files.isDirectory(staged) && Files.exists(target)) {
+ throw artifactAlreadyExistsError(normalizedRemoteRelativePath)
+ }
+ if (artifactExists(normalizedRemoteRelativePath, staged, target)) {
+ false
+ } else {
+ rejection.foreach(e => throw e)
+ transferFile(staged, target, deleteSource = true)
+ true
+ }
+ } {
+ // Only deletes the temporary copy, so failing to delete it must not
replace the result or
+ // the error of the add. Utils.createTempDir deletes it at shutdown
anyway.
+ copyDir.foreach(dir =>
Utils.tryLogNonFatalError(Utils.deleteRecursively(dir.toFile)))
Review Comment:
Thanks, fixed in 6456e6aeb22. Both temporary directories, the one of the
copy of a local package and the one of the file of an in-memory artifact, are
now deleted with `Utils.deleteQuietly`, which deletes in Java and neither
starts a process nor throws, and then removed from `ShutdownHookManager` only
if they are gone, so a directory that could not be deleted is still deleted at
shutdown; the add logs a warning then. This also removes the `rm` process for
each in-memory artifact.
This removes the failure point that this PR added between the transfer and
the registration, but not the window as a whole. `deleteQuietly` keeps the
interrupt status, and `SparkContext.addFile` then fetches the file from the
file server of the driver through an interruptible channel, so an interrupt, or
an I/O error there, can still leave the file at the path of the artifact,
recorded by the `SparkContext` (whose `addFile` records it before it fetches
it) but not by the session, and a retry then returns early. That has been the
case for file, Python file and archive artifacts, which `SparkContext` fetches
when it adds them, since SPARK-45486, which made an add whose content is at the
path already return without registering it. I would rather fix it separately,
since it touches those kinds of artifacts too, for example by making that early
return add the artifact to the session if it is not there yet (`addFile` would
then do nothing, since it recorded the file already, so the copy
of the file on the driver would need a look too). No test covers the
interrupt, since its timing cannot be controlled, and the local runs on macOS
take the Java path of `deleteRecursively` anyway.
##########
sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala:
##########
@@ -348,13 +440,16 @@ class ArtifactManager(session: SparkSession) extends
AutoCloseable with Logging
d.stream.transferTo(outStream)
addArtifact(artifact.path, tempFile, fragment = None)
}(finallyBlock = {
- outStream.close()
+ // addArtifact moves the file only when it adds the artifact, so
delete what is left.
+ Utils.tryWithSafeFinally(outStream.close()) {
+
Utils.tryLogNonFatalError(Utils.deleteRecursively(tempDir.toFile))
+ }
})
case _ =>
throw SparkException.internalError(s"Unsupported artifact storage:
${artifact.storage}")
}
} catch {
- case e: SparkRuntimeException if e.getCondition ==
"ARTIFACT_ALREADY_EXISTS" =>
+ case e: SparkRuntimeException if ArtifactManager.isRejectedArtifact(e)
=>
Review Comment:
Right, the docs promised more. In 6456e6aeb22 I kept the behavior, which is
the same as for the other artifacts, and fixed the docs: the Scaladoc of
`addArtifacts` and the guide now say that a file that does not exist or cannot
be read stops the call, as for the other artifacts, and the description says
that it stops the call (except a file with the name of an artifact: with item 6
it is compared with the artifact first again, so a missing one, or an
unreadable one of another size, fails with `ARTIFACT_ALREADY_EXISTS`, which is
collected). I also replaced "a package that Spark cannot read", which suggested
exactly this case, with "a file that is not a valid package" in the Scaladoc of
the `addArtifact` overloads, the Python docstring and the guide. As for the
other artifacts, such an I/O error also replaces the errors that the call
collected before it, such as the `INVALID_MANIFEST` of `notAZip` in your
example. I did not collect these I/O errors: packages would then be the only a
rtifacts that continue past a missing file, and a Spark Connect client reads
the file itself, so the server never sees a missing one.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala:
##########
@@ -177,17 +228,40 @@ object NativeDataSourcePackage extends Logging {
packages.asMap().keySet().asScala.exists(_.path == canonicalPath)
}
- private def invalidManifestError(file: File, reason: String, cause:
Throwable = null) = {
+ private def invalidManifestError(path: String, reason: String, cause:
Throwable = null) = {
QueryExecutionErrors.invalidNativeDataSourcePackageError(
- file.getPath, "INVALID_MANIFEST", Map("reason" -> reason), cause)
+ path, "INVALID_MANIFEST", Map("reason" -> reason), cause)
}
- private def parse(file: File): ReadResult = {
+ /** Reads the package in the given file, which errors call `path`. */
+ private def parse(file: File, path: String): ReadResult = {
+ readManifest(file, path).flatMap { manifest =>
+ try {
+ Right(NativeDataSourcePackage(path, sha256(file), manifest))
+ } catch {
+ case e: IOException => Left(InvalidNativeDataSourcePackage(
+ invalidManifestError(path, s"The package cannot be read: $e", e),
manifest.dataSources))
+ }
+ }
+ }
+
+ /**
+ * Reads the manifest of the package in the given file, which errors call
`path`, and checks
+ * that the package contains the libraries that it lists. Only reads the
central directory of
Review Comment:
Good catch, thanks for checking it. Fixed in 6456e6aeb22: `readManifest` now
treats a library whose entry is missing or is a directory entry as missing,
with `Option(zip.getEntry(library)).forall(_.isDirectory)`. The rejection test
adds a package whose manifest maps the platform to `lib`, with an explicit
`lib/` directory entry (`ZipOutputStream` does not write one by itself), and
expects `INVALID_MANIFEST` with "The package does not contain the library
lib."; it fails with the old `== null` check. The lookup of the manifest had
the same problem: `getEntry` also finds a directory entry
`spark-native-datasource.json/`, which was then rejected with the misleading
reason "The manifest must be a JSON object."; it is now treated as a missing
manifest, which the test also checks. The guide now says that `libraries` maps
each platform to the path of the library file. Since `readManifest` also reads
the configured packages, such a configured package is now invalid when it is
read too, rat
her than failing with `INVALID_LIBRARY` when a query loads the empty file; the
description says so.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala:
##########
@@ -1555,6 +1559,112 @@ 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") {
+ // The package that a session added last takes precedence, so Spark
rejects a package that it
+ // cannot use when the session adds it, rather than keep using what the
session used before.
+ val session = newSession(defaultPackage)
+ def ids(): DataFrame = range(session, "end" -> "3").select("id")
+ val dir = Utils.createTempDir()
+ def check(pkg: File, subClass: String, params: Map[String, String]): Unit
= {
+ // Also when the same file is added again.
Review Comment:
Added in 6456e6aeb22: after the valid `range-2.0.sparkpkg` is added, the
test adds it again from its file and from the same bytes, checks that both
calls succeed, that `getNativeDataSourcePackages` lists it once, and that
queries still use it. With item 6, the two re-adds take different paths: the
one from the file ends at the new comparison of the file of the user, before
any copy, and the one from bytes, which is staged and not copied, reaches the
comparison right before the transfer, the branch you pointed to. A mutant that
transfers the package even when the comparison before the transfer finds the
same content fails the re-add from bytes. Dropping the comparison before the
copy fails the test at a file that does not exist with the name of an artifact;
skipping only its early return would just copy the file and compare it again,
which no test can see, and the description lists it as not covered.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala:
##########
@@ -132,6 +132,57 @@ 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, does not support
its version of the
+ * interface, or if it has no library for the platform of the driver, which
loads the library to
+ * plan a query. 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 or library
that it used before,
+ * or add a package that fails every query on its data sources. The library
is not loaded, so a
+ * library that fails to load fails the queries that use it. 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:
Agreed to keep it. In 6456e6aeb22 the doc of `checkAdded`, the guide and the
description say that the add does not check that the driver can run native data
sources at all (Java 22 or later, and a platform where the C data interface of
Arrow Java loads), and that there the add succeeds and the queries fail; the
guide names `NATIVE_DATA_SOURCE_UNSUPPORTED_JAVA_VERSION` and
`NATIVE_DATA_SOURCE_UNSUPPORTED_PLATFORM`.
--
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]