SteNicholas commented on code in PR #3737:
URL: https://github.com/apache/celeborn/pull/3737#discussion_r3500113552


##########
client/src/main/java/org/apache/celeborn/client/ShuffleClient.java:
##########
@@ -90,7 +93,7 @@ public static ShuffleClient get(
       CelebornConf conf,
       UserIdentifier userIdentifier,
       byte[] extension) {
-    if (null == _instance || !initialized) {
+    if (null == _instance || !initialized || !Objects.equals(appUniqueId, 
_appUniqueId)) {

Review Comment:
   `get()` is now app-aware, but the lock-free fast path still has a TOCTOU 
that can hand back the **wrong app's** client.
   
   The in-lock `return _instance` (134) is safe, but the fast-path `return 
_instance` (137) reads the static volatile without the lock. The publish-order 
comment (127-129) only closes the *new-id + stale-instance* direction; writing 
`_instance` (130) before `_appUniqueId` (131) opens the reverse window:
   
   - Thread A is in branch-3 for app A: it has run `_instance = A` (130) but 
not yet `_appUniqueId = A` (131), so `_appUniqueId` is still `B`.
   - Concurrent thread B calls `get(B)`: the guard here sees `_appUniqueId == 
B` → `Objects.equals(B,B)` true → guard false → B skips the lock and `return 
_instance` (137) returns **A's** client.
   
   B (the live app) then drives its shuffle against A's `LifecycleManager` → 
ArrayIndexOutOfBounds / CommitMetadata CRC mismatch, i.e. the cross-app 
corruption this PR is fixing. Only reachable in the multi-app spark-it JVM 
(prod uses one `appUniqueId` per JVM, so branch-3 never fires there) — but that 
is exactly the scenario branch-3 was added for.
   
   Fix: return the instance captured under the lock (each branch assigns a 
local `result`, then `return result;`), or hold `(appId, client)` in a single 
`AtomicReference` so id and instance can't be read torn.



##########
worker/src/test/scala/org/apache/celeborn/service/deploy/MiniClusterFeature.scala:
##########
@@ -225,17 +226,30 @@ trait MiniClusterFeature extends Logging {
             workerStarted = true
             worker.initialize()
           } catch {
+            case ie: InterruptedException =>
+              
Utils.tryLogNonFatalError(worker.stop(CelebornExitKind.EXIT_IMMEDIATELY))
+              Utils.tryLogNonFatalError(worker.rpcEnv.shutdown())
+              Thread.currentThread().interrupt()
+              throw ie
             case ex: Exception =>
-              if (workers(i - 1) != null) {
-                workers(i - 1).shutdownGracefully()
-              }
+              Utils.tryLogNonFatalError(worker.exitImmediately())
+              
Utils.tryLogNonFatalError(worker.stop(CelebornExitKind.EXIT_IMMEDIATELY))
+              Utils.tryLogNonFatalError(worker.rpcEnv.shutdown())
               workerStarted = false
               workerStartRetry += 1
               logError(s"cannot start worker $i, retrying: ", ex)
               if (workerStartRetry == maxRetries) {
                 logError(s"cannot start worker $i, reached to max retrying", 
ex)
                 throw ex
               }
+              try {
+                TimeUnit.SECONDS.sleep(Math.pow(2, workerStartRetry).toLong)

Review Comment:
   `Math.pow(2, workerStartRetry)` seconds is OK today (`maxRetries = 4` → 
2+4+8 = 14s before the final throw), but 14s of blind backoff plus the 
per-attempt stop/shutdown already eats a large slice of the 60s 
`workersWaitingTimeoutMs` window — so a worker that retries a couple of times 
can itself trip the startup timeout this PR is trying to harden. It's also 
brittle: raising `maxRetries` makes this grow as 2^(retry-1) s (e.g. 
`maxRetries = 10` → a 512s sleep). Consider clamping it, e.g. `min(Math.pow(2, 
workerStartRetry).toLong, <few seconds>)`.



##########
client/src/main/java/org/apache/celeborn/client/ShuffleClient.java:
##########
@@ -102,14 +105,33 @@ public static ShuffleClient get(
           _instance = new ShuffleClientImpl(appUniqueId, conf, userIdentifier);
           _instance.setupLifecycleManagerRef(driverHost, port);
           _instance.setExtension(extension);
+          _appUniqueId = appUniqueId;
           initialized = true;
         } else if (!initialized) {
           _instance.shutdown();
           _instance = new ShuffleClientImpl(appUniqueId, conf, userIdentifier);
           _instance.setupLifecycleManagerRef(driverHost, port);
           _instance.setExtension(extension);
+          _appUniqueId = appUniqueId;
+          initialized = true;
+        } else if (!Objects.equals(appUniqueId, _appUniqueId)) {
+          // Do NOT shutdown() the old _instance. Callers cache the reference 
returned by get(),
+          // and shutdown() is an immediate teardown that would terminate the 
RpcEnv/pools still in
+          // use, causing RejectedExecutionException. Teardown is owned by 
stop()->shutdown(). The
+          // orphan is bounded (one per appUniqueId) and unreachable in normal 
single-app JVMs.
+          // The spark-it suite runs multiple apps in one reused JVM with 
overlapping lifecycles, so
+          // a shutdown() here tears down an instance still in use by the 
previous app and fails.
+          ShuffleClientImpl newInstance = new ShuffleClientImpl(appUniqueId, 
conf, userIdentifier);

Review Comment:
   branch-3 replaces `_instance` without `shutdown()`-ing the old one, so its 
RpcEnv (bound port + dispatcher threads), Netty `dataClientFactory`, 
`pushDataRetryPool` and `reviveManager` leak until JVM exit.
   
   The comment calls the orphan "bounded (one per appUniqueId)", but under two 
apps alternately calling `get()`, every call observes a mismatch and rebuilds — 
so it's bounded by the number of A↔B switches, not by app count. `reset()` only 
nulls the reference (no `shutdown()`), so these are never reclaimed there 
either. In the long-lived serial spark-it JVM this accumulates 
threads/FDs/ports, feeding the same contention this PR lowers worker counts to 
fight. If teardown of an in-use instance is the concern, that's a signal the 
static singleton shouldn't be shared across apps at all (per-app map / injected 
client).



##########
worker/src/test/scala/org/apache/celeborn/service/deploy/MiniClusterFeature.scala:
##########
@@ -225,17 +226,30 @@ trait MiniClusterFeature extends Logging {
             workerStarted = true
             worker.initialize()
           } catch {
+            case ie: InterruptedException =>
+              
Utils.tryLogNonFatalError(worker.stop(CelebornExitKind.EXIT_IMMEDIATELY))
+              Utils.tryLogNonFatalError(worker.rpcEnv.shutdown())
+              Thread.currentThread().interrupt()
+              throw ie
             case ex: Exception =>
-              if (workers(i - 1) != null) {
-                workers(i - 1).shutdownGracefully()
-              }
+              Utils.tryLogNonFatalError(worker.exitImmediately())

Review Comment:
   The retry cleanup now calls `worker.exitImmediately()`, which is heavier 
than the old `shutdownGracefully()`: it issues a **blocking** 
`masterClient.askSync(WorkerLost(...))` (Worker.scala:1056) and adds the 
worker's host:ports to the master's excluded list.
   
   Two effects on the start-retry path:
   
   - The synchronous `WorkerLost` RPC sits in the hot retry loop. If the master 
is slow/contended at startup (the exact scenario this PR targets), it blocks up 
to the ask timeout per failed attempt, compounding the `2^retry` backoff 
against the 60s `workersWaitingTimeoutMs`.
   - If a worker briefly registered before failing, `WorkerLost` excludes it on 
the master; the recreated worker re-registers under new random ports, so stale 
host:port exclusions accumulate on the master across retries.
   
   A failed `initialize()` usually means the worker never came up, so 
`exitImmediately()` (which assumes a registered worker reporting itself lost) 
seems like the wrong teardown here — the `InterruptedException` branch above 
just does `stop()` + `rpcEnv.shutdown()`, which is probably what this branch 
wants too.



##########
worker/src/test/scala/org/apache/celeborn/service/deploy/MiniClusterFeature.scala:
##########
@@ -225,17 +226,30 @@ trait MiniClusterFeature extends Logging {
             workerStarted = true
             worker.initialize()
           } catch {
+            case ie: InterruptedException =>
+              
Utils.tryLogNonFatalError(worker.stop(CelebornExitKind.EXIT_IMMEDIATELY))
+              Utils.tryLogNonFatalError(worker.rpcEnv.shutdown())
+              Thread.currentThread().interrupt()
+              throw ie
             case ex: Exception =>
-              if (workers(i - 1) != null) {
-                workers(i - 1).shutdownGracefully()
-              }
+              Utils.tryLogNonFatalError(worker.exitImmediately())
+              
Utils.tryLogNonFatalError(worker.stop(CelebornExitKind.EXIT_IMMEDIATELY))
+              Utils.tryLogNonFatalError(worker.rpcEnv.shutdown())
               workerStarted = false
               workerStartRetry += 1
               logError(s"cannot start worker $i, retrying: ", ex)
               if (workerStartRetry == maxRetries) {
                 logError(s"cannot start worker $i, reached to max retrying", 
ex)
                 throw ex
               }
+              try {
+                TimeUnit.SECONDS.sleep(Math.pow(2, workerStartRetry).toLong)
+              } catch {
+                case ie: InterruptedException =>
+                  Thread.currentThread().interrupt()
+                  throw ie
+              }
+              worker = createWorker(workerConf)

Review Comment:
   Recreating the worker on retry leaks the **failed** worker into the returned 
cluster set.
   
   `workers(i-1) = worker` (224) is written *before* `initialize()`, and the 
main poll loop does `workerInfos.put(workers(i), …)` (270) *before* the 
`registered` check (272), with no removal anywhere. On a failed first attempt 
the main thread can observe the failed `worker` (during its `initialize()` / 
backoff window) and put it into `workerInfos`; this line then creates a **new** 
object, which is also put on a later poll. `Worker` overrides neither `equals` 
nor `hashCode` (identity equality), so the dead and live workers are distinct 
keys and **both** survive in `workerInfos.keySet`, which `setUpWorkers` 
returns. A suite iterating the set (e.g. `CelebornHashCheckDiskSuite` 
`workers.foreach { _.storageManager.updateDiskInfos() }`) then operates on a 
stopped worker → NPE/RejectedExecutionException, and `shutdownMiniCluster` 
double-stops it. The old `val worker` (single identity, idempotent put) didn't 
have this.
   
   Related: `workers` is written under `flagUpdateLock` (223-225) but read 
without it at 267 — a visibility race the new `var` reassignment makes easier 
to hit. And the `InterruptedException` branch (229) tears the worker down but 
does **not** recreate it, so an interrupt during `initialize()`/backoff leaves 
a stopped worker pinned in `workers(i-1)` that the poll loop then inserts and 
waits on until timeout. Suggest only inserting into `workerInfos` once the 
worker is `registered` (move the put after the check), and/or overwriting the 
prior failed entry for slot `i`.



##########
build/mvn:
##########
@@ -46,23 +47,44 @@ install_app() {
   wget_opts="--progress=bar:force ${wget_opts}"
 
   if [ -z "$3" -o ! -f "$binary" ]; then
-    # check if we already have the tarball
-    # check if we have curl installed
-    # download application
-    [ ! -f "${local_tarball}" ] && [ $(command -v curl) ] && \
-      echo "exec: curl ${curl_opts} ${remote_tarball}" 1>&2 && \
-      curl ${curl_opts} "${remote_tarball}" > "${local_tarball}"
-    # if the file still doesn't exist, lets try `wget` and cross our fingers
-    [ ! -f "${local_tarball}" ] && [ $(command -v wget) ] && \
-      echo "exec: wget ${wget_opts} ${remote_tarball}" 1>&2 && \
-      wget ${wget_opts} -O "${local_tarball}" "${remote_tarball}"
-    # if both were unsuccessful, exit
-    [ ! -f "${local_tarball}" ] && \
-      echo -n "ERROR: Cannot download $2 with cURL or wget; " && \
-      echo "please install manually and try again." && \
-      exit 2
-    cd "${_DIR}" && tar -xzf "$2"
-    rm -rf "$local_tarball"
+    local attempt=1
+    while [ "${attempt}" -le "${max_attempts}" ]; do
+      # remove any partial/corrupt download left over from a previous attempt
+      rm -f "${local_tarball}"
+
+      # download application with `curl`, falling back to `wget`
+      if command -v curl >/dev/null 2>&1; then
+        echo "exec: curl ${curl_opts} ${remote_tarball}" 1>&2
+        curl ${curl_opts} "${remote_tarball}" > "${local_tarball}"
+      elif command -v wget >/dev/null 2>&1; then

Review Comment:
   Three behavior changes in this rewrite worth a second look:
   
   - `rm -f "${local_tarball}"` at the top of every attempt drops the old 
"reuse an already-downloaded tarball" path. For an offline/air-gapped build 
that pre-stages the tarball (but not the extracted dir), this turns a working 
build into a hard failure. (The extracted-binary short-circuit at the top of 
`install_app` still works.)
   - curl `elif` wget means when curl is present, **wget is never tried** 
across all 3 attempts. The old script fell through to wget when curl produced 
no file; a curl-specific failure (proxy/TLS/CA) that wget would survive now 
fails outright.
   - In `install_mvn`, since `APACHE_MIRROR` now defaults to 
`archive.apache.org`, the "fall back to archive" block (124) is dead code for 
the default config (the `%/` compare is equal → straight to `exit 2`). Minor: 
`sleep 3` (82) also runs after the final failed attempt before giving up.



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

Reply via email to