HyukjinKwon commented on code in PR #59196:
URL: https://github.com/apache/spark/pull/59196#discussion_r4256497046
##########
common/utils/src/main/scala/org/apache/spark/util/MavenUtils.scala:
##########
@@ -62,32 +63,102 @@ private[spark] object MavenUtils extends Logging {
}
}
- private def acquireIvyLock(isCancelled: () => Boolean): Unit = {
- def checkCancelled(): Unit = {
- if (isCancelled() || Thread.currentThread().isInterrupted) {
- throw new CancellationException("Maven dependency resolution was
cancelled")
- }
+ private class IvyResolutionCancellation {
+ private val requested = new AtomicBoolean(false)
+
+ def isCancelled: Boolean = requested.get()
+
+ def cancel(resolutionThread: Thread): Unit = {
+ requested.set(true)
+ resolutionThread.interrupt()
+ }
+ }
+
+ private def checkCancelled(isCancelled: () => Boolean): Unit = {
+ if (isCancelled() || Thread.currentThread().isInterrupted) {
+ throw new CancellationException("Maven dependency resolution was
cancelled")
}
+ }
- checkCancelled()
+ private def acquireIvyLock(isCancelled: () => Boolean): Unit = {
+ checkCancelled(isCancelled)
try {
while (!ivyLock.tryLock(ivyLockPollIntervalMs, TimeUnit.MILLISECONDS)) {
- checkCancelled()
+ checkCancelled(isCancelled)
}
} catch {
case _: InterruptedException =>
Thread.currentThread().interrupt()
throw new CancellationException("Maven dependency resolution was
cancelled")
}
try {
- checkCancelled()
+ checkCancelled(isCancelled)
} catch {
case e: Throwable =>
ivyLock.unlock()
throw e
}
}
+ private def runWithCancellation[T](isCancelled: () => Boolean)(
+ body: IvyResolutionCancellation => T): T = {
+ checkCancelled(isCancelled)
+ val cancellation = new IvyResolutionCancellation
+ val task = new FutureTask[T](new Callable[T] {
+ override def call(): T = body(cancellation)
+ })
+ val resolutionThread = new Thread(task, "spark-ivy-resolution")
+ resolutionThread.setDaemon(true)
+ resolutionThread.start()
+
+ def cancelAndWait(): Unit = {
+ var wasInterrupted = Thread.interrupted()
+ try {
+ cancellation.cancel(resolutionThread)
+ while (resolutionThread.isAlive) {
+ try {
+ resolutionThread.join()
+ } catch {
+ case _: InterruptedException => wasInterrupted = true
+ }
+ }
+ } finally {
+ if (wasInterrupted) {
+ Thread.currentThread().interrupt()
+ }
+ }
+ }
+
+ try {
+ var result: T = null.asInstanceOf[T]
+ var completed = false
+ while (!completed) {
+ checkCancelled(isCancelled)
+ try {
+ result = task.get(ivyLockPollIntervalMs, TimeUnit.MILLISECONDS)
+ completed = true
+ } catch {
+ case _: TimeoutException =>
+ }
+ }
+ checkCancelled(isCancelled)
+ result
+ } catch {
+ case _: InterruptedException =>
Review Comment:
**Nit (P3):** `runWithCancellation` adds two exits besides the cancellation
flag, and neither has a test that would fail if it regressed. The first is the
worker-failure unwrapping (`case e: ExecutionException => throw e.getCause`,
lines 154-155). The second is this caller-interrupt branch, which cancels and
joins the worker, restores the caller's interrupt flag, and throws
`CancellationException`. Every new runtime-resolver test signals cancellation
only through `isCancelled` and asserts only `CancellationException`. The
existing Ivy failure tests call the synchronous `resolveMavenCoordinates`,
which never enters this routing, and the new AddArtifactsHandlerSuite failure
test is rejected by coordinate validation before MavenUtils runs.
Two regressions would pass the whole suite. If the `ExecutionException` arm
were dropped or reordered, classic `addArtifact(ivy://...)` and Connect callers
would receive a checked `ExecutionException` wrapper instead of Ivy's
unresolved-dependency `RuntimeException`. If the interrupt restore were
dropped, or the method returned before the join, an interrupted classic caller
would lose its interrupt status or return while the worker still holds
`ivyLock`. The current routing is correct; only regression coverage is missing.
**Verification:**
- **Regression:** Removing or reordering the ExecutionException unwrapping
makes the failure-propagation test fail.
- **Regression:** Dropping the interrupt-status restoration, or returning
before the worker is joined, makes the caller-interrupt test fail.
- **Behavior:** Both tests pass against the current implementation and leave
no executor or worker thread behind.
##########
common/utils/src/main/scala/org/apache/spark/util/MavenUtils.scala:
##########
@@ -62,32 +63,102 @@ private[spark] object MavenUtils extends Logging {
}
}
- private def acquireIvyLock(isCancelled: () => Boolean): Unit = {
- def checkCancelled(): Unit = {
- if (isCancelled() || Thread.currentThread().isInterrupted) {
- throw new CancellationException("Maven dependency resolution was
cancelled")
- }
+ private class IvyResolutionCancellation {
+ private val requested = new AtomicBoolean(false)
+
+ def isCancelled: Boolean = requested.get()
+
+ def cancel(resolutionThread: Thread): Unit = {
+ requested.set(true)
+ resolutionThread.interrupt()
Review Comment:
**Non-blocking (P2):** `IvyResolutionCancellation.cancel` interrupts the
worker in whatever phase it is in, including during `ivy.retrieve` /
`noCacheIvy.retrieve`. The new `checkCancelled` calls only bracket retrieve, so
they cannot stop an in-flight copy from being cut short. In Ivy 2.5.3,
`RetrieveEngine` copies each cached jar straight into `<ivyPath>/jars` with
`FileUtil.copy(File, File, null, true)`. When the thread is interrupted, the
copy loop throws `IOException("transfer interrupted")`, which skips both the
size-check-and-delete and the `setLastModified(src)` step. The partial jar
therefore stays in place with a fresh mtime. MavenUtils keeps the default
`"newer"` overwrite mode, under which `RetrieveEngine.upToDate` treats any
target at least as new as the cache source as current.
For runtime resolution, `ArtifactManager` uses a per-session
`<artifactPath>/.ivy-cache`. After a Connect client cancels, or its gRPC
deadline expires, while a jar is being retrieved, the next resolution of that
coordinate in the same session skips the copy. `resolveDependencyPaths` then
returns the truncated jar, and the AddArtifacts handler registers it on the
session classpath. Later class loading fails with confusing `ZipException` /
`ClassNotFoundException` errors until the session is recreated. A caller
interrupt during the synchronous retrieve could already do this on master. This
PR makes it reachable from ordinary flag-driven cancellation and deadline
expiry, which previously always let the copy finish. No test cancels during
retrieve. The synchronous `resolveMavenCoordinates` path is not affected by
this change.
**Recommended change:** On the cancellable path (cancellation is defined),
configure the retrieve options with RetrieveOptions.OVERWRITEMODE_DIFFERENT, so
a retrieved target counts as up to date only when its mtime equals the cached
artifact's. Leave the synchronous path's retrieve options unchanged. Add a
MavenUtilsSuite regression test that leaves a truncated, newer-mtime jar in the
runtime resolver's packages directory and checks that a following runtime
resolution returns a complete copy.
**Why this works:** Every complete Ivy retrieve copy stamps the destination
with the source's lastModified. A copy cut short by an interrupt, or by any
other mid-copy IOException, keeps the current time. With mtime-equality as the
up-to-date test, a partial file always fails the check, and the next retrieve
overwrites it with a full copy. Complete files from earlier resolutions keep
matching and are not recopied, and the cancellation and interrupt behavior is
unchanged.
**Scope:** Tighten the runtime (cancellable) resolution's retrieve
up-to-date policy so partial jars left by a cancelled retrieve are recopied
rather than reused, and cover it with a regression test.
**Compatibility:** Cancellation semantics, interrupt delivery, and the
synchronous resolution path are unchanged.
**Risks:** If a filesystem does not preserve millisecond lastModified across
setLastModified, complete jars may be recopied on each runtime resolution. That
costs extra local copying but is not incorrect.
**Constraints:** Must not change the synchronous resolveMavenCoordinates
retrieve behavior used by spark-submit --packages, SparkContext.addJar, and
Hive loading. Must keep the cooperative cancel-then-join lifecycle, the
cancellation checkpoints, and the existing URL handler and transport unchanged.
**Success:** After a cancelled runtime resolution leaves a partial jar in
the per-session packages directory, the next successful runtime resolution of
that artifact returns a complete jar identical to the cached artifact. Runtime
resolutions whose retrieved jars are already complete do not fail, and still
return the same paths. Synchronous resolveMavenCoordinates keeps Ivy's default
retrieve overwrite behavior.
**Verification:**
- **Regression:** A runtime resolution whose packages directory already
holds a truncated copy of the artifact, with an mtime newer than the cached
artifact, returns a jar whose size and content match the cached artifact.
- **Behavior:** Repeated successful runtime resolutions of the same
coordinate in one ivy path still succeed and return the same jar paths.
- **Compatibility:** Existing synchronous resolution and runtime
cancellation tests keep passing, confirming that only the cancellable path's
retrieve policy changed.
--
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]