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


##########
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:
   **3. [Low] A directory entry passes the check that the package contains its 
library**
   
   `readManifest` checks each library with `zip.getEntry(_) == null` 
([L274](https://github.com/apache/spark/blob/3c422f37c57f6e24ca24140360fde1fdd96eaf6c/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala#L274)),
 but `ZipFile.getEntry(name)` also finds the entry `name/`. I checked this with 
`jshell` on Java 25: in a zip with the entry `linux-x86_64/`, 
`getEntry("linux-x86_64")` returns that directory entry, and its stream has 0 
bytes.
   
   So a manifest with `"linux-x86_64": "linux-x86_64"`, a path that is easy to 
write by mistake, passes `checkAdded`. `extract` then writes an empty file, and 
every query on the data sources of the package fails with `INVALID_LIBRARY`. 
The session cannot go back to the package that it used before until it adds 
another one under a new file name, which is the case that this PR rejects for a 
missing library.
   
   The line is not new, but `checkAdded` now relies on it. 
`Option(zip.getEntry(library)).forall(_.isDirectory)` would treat a directory 
entry as missing.



##########
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:
   **4. [Low] No test adds a valid package twice**
   
   This loop repeats the add only for rejected packages, and the other tests 
add a valid package once to each session. So no test reaches the branch of 
`addNativeDataSourcePackage` that returns false 
([L268-L269](https://github.com/apache/spark/blob/3c422f37c57f6e24ca24140360fde1fdd96eaf6c/sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala#L268-L269)).
 That path changed for a local package, which is now copied first, and the copy 
is compared with the artifact.
   
   Could this test add a valid package again, from its file and from the same 
bytes, and check that both calls succeed and that `getNativeDataSourcePackages` 
lists it once?



##########
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:
   **2. [Low] A package that cannot be read still stops `addArtifacts`, unlike 
what the docs say**
   
   This catch now collects a rejected package, but the `AccessDeniedException` 
or `NoSuchFileException` of the copy 
([L257](https://github.com/apache/spark/blob/3c422f37c57f6e24ca24140360fde1fdd96eaf6c/sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala#L257))
 is not a `SparkRuntimeException`, so it leaves the loop. For example, with 
`spark.addArtifacts(notAZip, unreadable, valid)`:
   
   - `valid` is not added, and
   - the `INVALID_MANIFEST` error of `notAZip` is lost.
   
   Other artifacts behave the same way, so the behavior itself may be fine. But 
the new docs promise more:
   
   - The [Scaladoc of 
`addArtifacts`](https://github.com/apache/spark/blob/3c422f37c57f6e24ca24140360fde1fdd96eaf6c/sql/api/src/main/scala/org/apache/spark/sql/SparkSession.scala#L661-L664)
 says "A package that Spark cannot read ... is rejected: the session does not 
get it, and the call fails after it adds the other artifacts".
   - The 
[guide](https://github.com/apache/spark/blob/3c422f37c57f6e24ca24140360fde1fdd96eaf6c/docs/sql-native-data-sources.md?plain=1#L331-L336)
 says that `spark.addArtifact` fails for a package "that Spark cannot read", 
and that a call "still adds the others".
   
   Could the docs say that a file that does not exist or cannot be read stops 
the call, or could `addLocalArtifacts` also collect these I/O errors for a 
package?



##########
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:
   **1. [Low] An interrupt while the copy is deleted leaves the package 
unregistered**
   
   `Utils.deleteRecursively` runs `rm -rf` on Unix and rethrows the 
`InterruptedException` of `process.waitFor()` ([JavaUtils.java 
L340-L344](https://github.com/apache/spark/blob/3c422f37c57f6e24ca24140360fde1fdd96eaf6c/common/utils-java/src/main/java/org/apache/spark/network/util/JavaUtils.java#L340-L344)).
 `tryLogNonFatalError` only catches non-fatal errors, so this exception leaves 
the `finally` block, unlike what the comment above says.
   
   At this point `transferFile` has moved the package to `target`, but 
`addFile` and `sparkContextRelativePaths.add` have not run. If the thread is 
interrupted here:
   
   1. `spark.addArtifact` fails with `InterruptedException`, and the package 
stays at the path of the artifact without being registered.
   2. Adding the same file again finds the same content at `target`, so 
`artifactExists` returns true and the call succeeds without registering 
anything.
   3. The session does not get the package under that file name, and no error 
says so.
   
   I found this by reading the code and did not reproduce it, since it depends 
on when the interrupt comes.
   
   Could this use `Utils.deleteQuietly` instead? It deletes in Java, so it 
neither starts a process nor throws. It leaves the path registered in 
`ShutdownHookManager`, so `removeShutdownDeleteDir` would be needed too.
   
   The in-memory case at 
[L445](https://github.com/apache/spark/blob/3c422f37c57f6e24ca24140360fde1fdd96eaf6c/sql/core/src/main/scala/org/apache/spark/sql/artifact/ArtifactManager.scala#L445)
 makes the same call. There the artifact is already registered, but the 
exception still replaces the result and stops the loop. That call also starts 
one `rm` process for each in-memory artifact of any kind, which is new in this 
PR.



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

Review Comment:
   **6. [Nit] Adding the same package again copies the whole file**
   
   The copy runs before the check for an existing artifact. So when a notebook 
runs again and adds the same large package, Spark copies it, checks the copy, 
compares the copy with the artifact, and deletes the copy. Before this PR, that 
call only compared the two files.
   
   `artifactExists` could run on the file of the user first, so that the call 
ends there if the artifact exists. It would still run again on the copy, right 
before the transfer. With item 7, this also covers a directory.



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

Review Comment:
   **8. [Nit] `Try(...).failed` could be a plain `Try`**
   
   This turns the result of the check into a `Try[Throwable]` with `.failed`, 
and rethrows it later with `foreach`. Keeping `val check = Try(...)` and 
calling `check.get` before the transfer does the same and is easier to read. It 
also avoids the `UnsupportedOperationException`, with its stack trace, that 
`Success.failed` creates for every valid package.



##########
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:
   **5. [Nit] A package is still accepted where no native data source can run**
   
   On a driver with Java before 22, or on a platform where the C data interface 
of Arrow Java does not load, `spark.addArtifact` succeeds. Every query on the 
data sources of the package then fails with 
`NATIVE_DATA_SOURCE_UNSUPPORTED_JAVA_VERSION` or 
`NATIVE_DATA_SOURCE_UNSUPPORTED_PLATFORM`.
   
   This does less harm than the cases that this PR rejects: no package can run 
there, so the session does not lose a working one, and the error is clear. 
`test_add_native_data_source_package` also relies on it in the Java 17 PR 
builds. So keeping it seems fine, but the title says "when Spark cannot use 
it". Could the doc of `checkAdded` and the guide mention this case, so that a 
reader does not expect it to be rejected?



##########
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 = {

Review Comment:
   **9. [Nit] `parse` does not need `path`**
   
   Its only caller is `parse(canonical, canonical.getPath)` 
([L201](https://github.com/apache/spark/blob/3c422f37c57f6e24ca24140360fde1fdd96eaf6c/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourcePackage.scala#L201)).
 Only `readManifest` needs a name that can differ from the file, for 
`checkAdded`. `parse(file)` could use `file.getPath`, which also shows that the 
package never gets the name of another file.



##########
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)) {

Review Comment:
   **7. [Nit] Only packages get this check for a directory**
   
   `artifactExists` is shared, but the directory case is handled only here. A 
directory named `a.jar`, added when `jars/a.jar` exists, still fails with the 
`IllegalArgumentException` of `Utils.contentEquals`. Moving this check into 
`artifactExists` would give every artifact that goes through it 
`ARTIFACT_ALREADY_EXISTS`, and remove the special case.



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