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


##########
client/src/main/java/org/apache/celeborn/client/ShuffleClient.java:
##########
@@ -102,12 +105,30 @@ 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)) {

Review Comment:
   **[blocker] `get()` can return another app's client — data race on the 
trailing `return _instance`.**
   
   Ordering the volatile writes (`_instance` before `_appUniqueId`) correctly 
stops the *outer* lock-free guard from handing back a stale instance. But the 
method still ends with `return _instance` **outside** the `synchronized` block. 
Between this thread releasing the lock and reaching that return, another thread 
with a different `appUniqueId` can re-enter this branch and overwrite 
`_instance`:
   
   ```
   Thread A (app "A")              Thread B (app "B")
     _instance = newInstanceA
     _appUniqueId = "A"
     <exit synchronized>
                                   <enter synchronized>
                                   _instance = newInstanceB
     return _instance  // hands B's client to A
   ```
   
   A then resolves `celebornShuffleId` against B's `LifecycleManager` → the 
`ArrayIndexOutOfBoundsException` / CommitMetadata CRC-mismatch / cross-app read 
this PR is trying to eliminate. Fix: return the instance you just built — 
capture it in a local and `return` that (or return from inside the lock) 
instead of re-reading the shared field.
   
   Two lower-severity points on the same branch:
   - **Singleton ping-pong:** with the outer guard now `|| 
!Objects.equals(appUniqueId, _appUniqueId)`, every `get()` carrying a different 
id re-enters and swaps the static `_instance`. In a JVM with two 
concurrently-live apps (this test harness; also Flink session-cluster 
TaskManagers in prod), A's and B's tasks flip the shared singleton back and 
forth, so a holder can observe a client with the wrong/empty registration 
state. Single-app executors never reach this branch.
   - **Orphan leak (intentional — flagging for sign-off):** the replaced 
instance is deliberately not `shutdown()` (per the comment, to avoid 
`RejectedExecutionException`), leaking one `RpcEnv` + thread pools per distinct 
`appUniqueId` for the JVM's life. Bounded and fine for single-app JVMs, but 
worth a reviewer explicitly ratifying.



##########
client/src/main/scala/org/apache/celeborn/client/ApplicationHeartbeater.scala:
##########
@@ -126,7 +126,7 @@ class ApplicationHeartbeater(
           }
         }
       },
-      0,
+      appHeartbeatIntervalMs,

Review Comment:
   This is a **production behavior change inside a "Fix Flaky CI/CD" PR**, with 
no comment explaining it. The initial delay went `0 -> appHeartbeatIntervalMs`, 
so the first app heartbeat — which also drives `ReviseLostShuffles` and app 
liveness on the master — now fires a full interval later for *every* real 
deployment, just to settle a test's timing. Prefer fixing this at the test 
layer (widen the test's wait/`eventually` window); if the delay really is 
needed in prod, please add a comment justifying it.



##########
master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/RatisMasterStatusSystemSuiteJ.java:
##########
@@ -119,6 +119,15 @@ public static void resetRaftServer(
 
     while (!serversStarted) {
       try {
+        // Re-point each server to a fresh storage directory on every attempt. 
Ratis releases the
+        // storage directory lock asynchronously on close(), so a failed 
attempt (e.g. a random
+        // ratis port collision) can leave the previous directory locked. 
Reusing the same
+        // directory on retry then fails with "directory is already locked"; 
allocating a clean
+        // directory each time avoids contending for a lock that has not been 
released yet.
+        configureServerConf(conf1, 1);

Review Comment:
   `configureServerConf` is already invoked by the callers when they build 
`conf1/2/3` (e.g. `init()`), so on the **first** loop iteration it now runs 
twice per server on the same conf — the storage dir the caller just created is 
immediately overwritten and orphaned (an empty `celeborn-ratis*` temp dir, 
never cleaned), once per server on every `resetRaftServer`. The stated intent 
("fresh dir only on retry") would be served by skipping this on the first 
attempt, e.g. guard on a retry counter.



##########
tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/memory/MemorySparkTestBase.scala:
##########
@@ -35,7 +35,10 @@ trait MemorySparkTestBase extends AnyFunSuite
   override def beforeAll(): Unit = {
     logInfo("test initialized , setup Celeborn mini cluster")
     val workerConfs = 
Map("celeborn.worker.directMemoryRatioForMemoryFileStorage" -> "0.2")
-    setupMiniClusterWithRandomPorts(workerConf = workerConfs, workerNum = 5)
+    // 3 workers (the MiniClusterFeature default) instead of 5: these 
memory-storage suites run in
+    // the same shared, serial spark-it JVM, so trimming the per-suite worker 
footprint reduces the
+    // CPU contention that otherwise starves a worker's fetch handler past the 
240s network timeout.
+    setupMiniClusterWithRandomPorts(workerConf = workerConfs, workerNum = 3)
   }
 
   override def afterAll(): Unit = {

Review Comment:
   This pre-existing `afterAll` calls only `shutdownMiniCluster()` — no 
`super.afterAll()` and no `stopActiveSparkSessions()`. The point of the new 
`SparkTestBase.afterAll` cleanup in this PR is to stop a leaked 
`SparkSession`/`SparkContext` (and run `ShuffleClient.reset()`) before the next 
suite runs in the shared serial JVM. Since this override isn't updated, 
memory-storage suites skip that cleanup and can still leave a stale 
`LifecycleManager` behind — exactly the flakiness the PR targets. Please call 
`super.afterAll()` (or `stopActiveSparkSessions()`) here.



##########
worker/src/test/scala/org/apache/celeborn/service/deploy/MiniClusterFeature.scala:
##########
@@ -225,17 +236,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 every retry (`var worker = 
createWorker(workerConf)`) instead of re-`initialize()`-ing the same instance 
has two side effects:
   
   - **Leaked JVM shutdown hooks:** `Worker`'s constructor unconditionally 
registers a JVM shutdown hook and nothing ever removes it, so each failed 
attempt permanently leaks a hook bound to a now-dead worker. In the shared 
serial spark-it/flink-it JVM these accumulate; at JVM exit they all fire 
`askSync(WorkerLost)` against a dead master, each blocking on the RPC ask 
timeout (slow/noisy shutdown), and they keep the dead `Worker` objects 
reachable for the whole run.
   - **A dead worker can be published:** with `var worker` reassigned, the 
registration-waiting thread may `workerInfos.put` the first (torn-down) attempt 
before the replacement is assigned, leaving a dead worker in the map that 
`getOneWorker()` / `shutdownMiniCluster()` can later pick — NPE/teardown 
flakiness in the very retry path this PR targets.
   
   Consider retrying `initialize()` on a single instance (as before), or 
removing the shutdown hook on teardown.



##########
worker/src/test/scala/org/apache/celeborn/service/deploy/worker/monitor/JVMQuakeSuite.scala:
##########
@@ -53,28 +45,19 @@ class JVMQuakeSuite extends CelebornFunSuite {
       .set(WORKER_JVM_QUAKE_RUNTIME_WEIGHT.key, "1")
       .set(WORKER_JVM_QUAKE_DUMP_THRESHOLD.key, "1s")
       .set(WORKER_JVM_QUAKE_KILL_THRESHOLD.key, "2s"))
-    quake.start()
-    allocateMemory(quake)
-    quake.stop()
+
+    // Drive the GC "deficit" bucket deterministically rather than inducing 
real GC pressure:
+    // feed a GC-time delta above the 1s dump threshold with no offsetting 
execution time, so the
+    // heap dump is triggered exactly once. The previous version spun until 
real GC happened to
+    // trip the threshold, which could (and did) hang indefinitely when the 
runner had enough
+    // headroom that GC pauses never dominated runtime.
+    assert(!quake.heapDumped)
+    quake.checkAndDump(TimeUnit.SECONDS.toNanos(2), 0L)

Review Comment:
   Calling `checkAndDump(...)` directly fixes the hang, but drops all coverage 
of `start()/stop()` scheduling and the production `run()` path (jvmstat counter 
read + ticks->nanos conversion + the scheduler actually invoking `run()`). A 
regression there — bad delta, bad conversion, scheduler never firing — would no 
longer be caught by CI, and a worker stuck in GC thrash would silently never 
self-terminate. Consider keeping a lightweight test that exercises `run()` (or 
at least the scheduling wiring) alongside this deterministic `checkAndDump` 
assertion.



##########
worker/src/test/scala/org/apache/celeborn/service/deploy/MiniClusterFeature.scala:
##########
@@ -225,17 +236,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:
   `TimeUnit.SECONDS.sleep(Math.pow(2, workerStartRetry))` on the bootstrap 
thread adds up to ~2+4+8 = 14s of pure sleep per flaky worker to suite setup 
(maxRetries=4). For a transient bind error a much smaller backoff suffices — 
consider capping it, e.g. `min(2^n, 2)` seconds.



##########
master/src/test/scala/org/apache/celeborn/service/deploy/master/MasterClusterFeature.scala:
##########
@@ -50,7 +50,7 @@ trait MasterClusterFeature extends Logging {
     }
   }
   def selectRandomPort(): Int = synchronized {
-    val port = Utils.selectRandomInt(1024, 65535)
+    val port = Utils.selectRandomInt(1024, 32768)

Review Comment:
   The ephemeral-port-floor fix lives only in `MiniClusterFeature` (the named 
`maxSelectablePort` constant + the rationale comment). This copy carries a bare 
`32768` literal, and `RatisMasterStatusSystemSuiteJ` carries yet another 
(`32766`). Three near-identical `selectRandomPort` blocks now diverge — the 
next person who tweaks the floor in one place will silently leave the others 
wrong and the BindException flakiness reappears in the master suites. Extract 
one shared helper/constant and call it from all three.



##########
tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/HybridShuffleWordCountTest.scala:
##########
@@ -186,12 +188,18 @@ class HybridShuffleWordCountTest extends AnyFunSuite with 
Logging with MiniClust
   }
 
   private def checkFlushingFileLength(): Unit = {
-    workers.map(worker => {
-      worker.storageManager.workingDirWriters.values().asScala.map(writers => {
-        writers.forEach((fileName, fileWriter) => {
-          assert(new File(fileName).length() == 
fileWriter.getDiskFileInfo.getFileLength)
+    // getDiskFileInfo.getFileLength is the logical byte count accounted as 
data is written, while
+    // the physical file is grown asynchronously by the LocalFlusher. Right 
after the job finishes
+    // the flusher may not have drained the last buffers yet, so the on-disk 
length can lag (briefly
+    // even 0). Wait for the flush to catch up before asserting equality 
instead of reading mid-flush.
+    eventually(timeout(30.seconds), interval(500.milliseconds)) {

Review Comment:
   Minor: each 500ms poll re-walks every worker's full `workingDirWriters` map 
and issues a fresh `File.length()` syscall per file for up to 30s, so a single 
lagging file causes every already-matching file to be re-stat'd repeatedly. 
Could poll only the not-yet-matching writers, or drain/await the flush once 
before a single assertion pass.



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