This is an automated email from the ASF dual-hosted git repository.
SteNicholas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/celeborn.git
The following commit(s) were added to refs/heads/main by this push:
new da8c1cec32 [CELEBORN-2362] Fix Flaky CI/CD
da8c1cec32 is described below
commit da8c1cec328110452458ee4174f03570ba8dddea
Author: afterincomparableyum
<[email protected]>
AuthorDate: Tue Jul 14 15:36:53 2026 +0800
[CELEBORN-2362] Fix Flaky CI/CD
### What changes were proposed in this pull request?
Address several independent root causes of CI/CD flakiness, spanning the
build tooling, JaCoCo instrumentation, the test mini-cluster, and the
process-wide shuffle client.
CI infrastructure
- build/mvn — resilient Maven bootstrap. Default to archive.apache.org
instead of the
closer.lua mirror redirector, and validate that each download is a real
gzip tarball before
extracting (closer.lua intermittently returns an HTML mirror-chooser page
with HTTP 200).
Retry up to 3 times, backing off only between attempts. A valid
pre-staged/previously-downloaded tarball is reused (keeps
offline/air-gapped builds working),
and curl falls through to wget when curl is absent or produced no valid
file (proxy/TLS/CA
failures wget would survive).
- pom.xml — exclude io/netty/** from JaCoCo. JaCoCo's agent rejects Netty
4.2's
already-enhanced JFR event classes, tearing down channels mid-write and
flaking the Flink
integration tests.
Process-wide ShuffleClient made per-application
The static single-slot ShuffleClient singleton couldn't represent the
multiple applications a
reused spark-it JVM runs with overlapping lifecycles: on an app switch it
evicted the
previous app's client without teardown, and a lock-free get() fast path
could hand a live app
the wrong app's client — surfacing as celebornShuffleId 0 corruption
(ArrayIndexOutOfBoundsException / CommitMetadata CRC mismatch).
- Replaced the single slot with a per-appUniqueId registry
(ConcurrentHashMap<String,
ShuffleClient>). Each application gets its own isolated client; only
fully-initialized
instances are ever published, closing the torn read.
- Added ShuffleClient.removeInstance(...), and SparkShuffleManager.stop()
now removes+shuts
down only its own app's client instead of calling the global reset()
(which would tear down
other live applications' clients in a multi-app JVM). This also
eliminates the
orphaned-client leak (RpcEnv, Netty factory, retry pool, reviveManager)
that previously
accumulated across app switches.
Spark-it lifecycle isolation
- SparkTestBase — stop any SparkSession/SparkContext still alive in the
JVM in afterAll
(triggering SparkShuffleManager.stop()) and reset the client, so a
straggler task from a
leaked context can't bind to a later suite's LifecycleManager through the
shared client.
Reduced worker count from 5 to 3 (the MiniCluster default): the suites
run serially in one
JVM, so each surplus worker multiplies the long-lived thread/CPU
footprint and, under CI
contention, starves RPC/fetch handlers past the 240s network timeout.
Cluster-setup test robustness
- MiniClusterFeature — retry worker startup with exponential backoff,
recreating the worker
each attempt. Record a worker in the returned set only once it has
registered, so a torn-down
failed worker (distinct identity — Worker has no equals/hashCode) doesn't
leak into the
returned set and get double-stopped. Tear failed workers down locally
(stop +
rpcEnv.shutdown) instead of exitImmediately(), which issues a blocking
WorkerLost master RPC
and pollutes the master's excluded list inside the retry loop.
- Random port selection — draw ports below the ephemeral floor
(Utils.selectRandomPort() /
MAX_SELECTABLE_PORT) to avoid a TOCTOU with OS-assigned/TIME_WAIT ports
that look free at
selection time but fail to bind. Consolidated the duplicated picker into
a shared
RandomPortSupport test trait.
- RatisMasterStatusSystemSuiteJ — re-point each server to a fresh storage
directory on every
start attempt; Ratis releases the directory lock asynchronously, so a
retry would otherwise
hit "directory is already locked".
- JVMQuake / JVMQuakeSuite — extract checkAndDump(...) so the
threshold/heap-dump logic can
be exercised deterministically without inducing real GC pressure.
- LifecycleManagerUnregisterShuffleSuite — shorten the expired-check
interval to 5s so the
shuffle unregister runs within the eventually() window with retry margin.
### Why are the changes needed?
CI/CD is always failing. With this, CI/CD rarely fails.
### Does this PR resolve a correctness bug?
- [ ] Yes
### Does this PR introduce _any_ user-facing change?
- [ ] Yes
### How was this patch tested?
CI/CD
Closes #3737 from afterincomparableyum/flaky-cicd.
Authored-by: afterincomparableyum
<[email protected]>
Signed-off-by: Nicholas Jiang <[email protected]>
---
build/mvn | 108 +++++++++++++++------
.../shuffle/celeborn/SparkShuffleManager.java | 5 +-
.../org/apache/celeborn/client/ShuffleClient.java | 106 +++++++++++++-------
.../apache/celeborn/client/ShuffleClientImpl.java | 6 +-
.../celeborn/client/WithShuffleClientSuite.scala | 7 +-
.../org/apache/celeborn/common/util/Utils.scala | 6 +-
.../org/apache/celeborn/RandomPortSupport.scala | 55 +++++++++++
.../ha/RatisMasterStatusSystemSuiteJ.java | 16 ++-
.../deploy/master/MasterClusterFeature.scala | 28 +-----
pom.xml | 18 ++++
.../tests/flink/HybridShuffleWordCountTest.scala | 18 +++-
.../ChangePartitionManagerUpdateWorkersSuite.scala | 1 +
.../LifecycleManagerUnregisterShuffleSuite.scala | 7 ++
.../tests/spark/CelebornHashCheckDiskSuite.scala | 6 +-
.../tests/spark/CelebornIntegrityCheckSuite.scala | 22 ++++-
.../tests/spark/ShuffleFallbackSuite.scala | 2 +-
.../celeborn/tests/spark/SparkTestBase.scala | 24 ++++-
.../fetch/failure/ShuffleReaderGetHooks.scala | 12 +--
.../tests/spark/memory/MemorySparkTestBase.scala | 6 +-
.../spark/shuffle/celeborn/SparkUtilsSuite.scala | 6 +-
.../service/deploy/worker/monitor/JVMQuake.scala | 16 ++-
.../service/deploy/MiniClusterFeature.scala | 68 ++++++-------
.../deploy/worker/monitor/JVMQuakeSuite.scala | 38 +++-----
23 files changed, 404 insertions(+), 177 deletions(-)
diff --git a/build/mvn b/build/mvn
index cd6c0c796d..15b30aea1c 100755
--- a/build/mvn
+++ b/build/mvn
@@ -38,6 +38,7 @@ install_app() {
local remote_tarball="$1/$2$4"
local local_tarball="${_DIR}/$2"
local binary="${_DIR}/$3"
+ local max_attempts=3
# setup `curl` and `wget` silent options if we're running on Jenkins
local curl_opts="-L"
@@ -46,23 +47,56 @@ 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
+ # Reuse an already-valid tarball (e.g. one pre-staged for an offline
build, or downloaded on
+ # a previous attempt); only (re)download when it is missing or not a
valid gzip tarball.
+ if [ ! -f "${local_tarball}" ] || ! tar -tzf "${local_tarball}"
>/dev/null 2>&1; then
+ if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null
2>&1; then
+ echo "ERROR: Cannot download $2: neither cURL nor wget is
installed." 1>&2
+ exit 2
+ fi
+
+ # Try curl first; remove its output if it fails so wget below gets a
turn.
+ rm -f "${local_tarball}"
+ 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}" || rm -f
"${local_tarball}"
+ fi
+
+ # Fall back to wget when curl is absent or produced no valid tarball
(e.g. a curl-specific
+ # proxy/TLS/CA failure that wget would survive).
+ if [ ! -f "${local_tarball}" ] || ! tar -tzf "${local_tarball}"
>/dev/null 2>&1; then
+ if command -v wget >/dev/null 2>&1; then
+ echo "exec: wget ${wget_opts} ${remote_tarball}" 1>&2
+ rm -f "${local_tarball}"
+ wget ${wget_opts} -O "${local_tarball}" "${remote_tarball}" || rm
-f "${local_tarball}"
+ fi
+ fi
+ fi
+
+ # Validate the download before trusting it. A flaky Apache mirror can
+ # return an HTML page (mirror chooser / error) with HTTP 200, which is
+ # not a gzip tarball; extracting it later would fail with a confusing
+ # exit code. `tar -tzf` lists the archive without extracting and
+ # exits non-zero on a non-tarball body.
+ if [ -f "${local_tarball}" ] && tar -tzf "${local_tarball}" >/dev/null
2>&1; then
+ if cd "${_DIR}" && tar -xzf "$2"; then
+ rm -rf "${local_tarball}"
+ return 0
+ fi
+ fi
+
+ # Drop the invalid download and retry, backing off only between attempts.
+ rm -f "${local_tarball}"
+ echo "WARN: Download of $2 from $1 was not a valid tarball" \
+ "(attempt ${attempt}/${max_attempts}); retrying..." 1>&2
+ attempt=$((attempt + 1))
+ [ "${attempt}" -le "${max_attempts}" ] && sleep 3
+ done
+
+ echo "WARN: Failed to download a valid $2 from $1 after ${max_attempts}
attempts." 1>&2
+ return 1
fi
}
@@ -77,25 +111,41 @@ install_mvn() {
# See simple version normalization:
http://stackoverflow.com/questions/16989598/bash-comparing-version-numbers
function version { echo "$@" | awk -F. '{ printf("%03d%03d%03d\n",
$1,$2,$3); }'; }
if [ $(version $MVN_DETECTED_VERSION) -ne $(version $MVN_VERSION) ]; then
- local
APACHE_MIRROR=${APACHE_MIRROR:-'https://www.apache.org/dyn/closer.lua'}
- local MIRROR_URL_QUERY="?action=download"
+ # Default to archive.apache.org: it serves the exact tarball
+ # deterministically, avoiding the closer.lua mirror redirector which
+ # intermittently routes to a mirror that returns an HTML page instead of
+ # the binary. Override with APACHE_MIRROR to use a closer mirror.
+ local APACHE_MIRROR=${APACHE_MIRROR:-'https://archive.apache.org/dist'}
local MVN_TARBALL="apache-maven-${MVN_VERSION}-bin.tar.gz"
local FILE_PATH="maven/maven-3/${MVN_VERSION}/binaries"
- if [ $(command -v curl) ]; then
- if ! curl -L --output /dev/null --silent --head --fail
"${APACHE_MIRROR}/${FILE_PATH}/${MVN_TARBALL}${MIRROR_URL_QUERY}" ; then
- # Fall back to archive.apache.org for older Maven
- echo "Falling back to archive.apache.org to download Maven"
- APACHE_MIRROR="https://archive.apache.org/dist"
- MIRROR_URL_QUERY=""
- fi
- fi
+ # closer.lua needs the ?action=download query to redirect to a mirror;
+ # archive.apache.org and most plain mirrors serve the file directly.
+ local MIRROR_URL_QUERY=""
+ case "${APACHE_MIRROR}" in
+ *closer.lua*) MIRROR_URL_QUERY="?action=download" ;;
+ esac
- install_app \
+ if ! install_app \
"${APACHE_MIRROR}/${FILE_PATH}" \
"${MVN_TARBALL}" \
"apache-maven-${MVN_VERSION}/bin/mvn" \
- "${MIRROR_URL_QUERY}"
+ "${MIRROR_URL_QUERY}"; then
+ # Last resort: fall back to archive.apache.org, which serves the exact
+ # tarball deterministically. Skip if it was already the chosen mirror.
+ local ARCHIVE_MIRROR="https://archive.apache.org/dist"
+ if [ "${APACHE_MIRROR%/}" != "${ARCHIVE_MIRROR}" ]; then
+ echo "WARN: falling back to ${ARCHIVE_MIRROR} to download Maven" 1>&2
+ install_app \
+ "${ARCHIVE_MIRROR}/${FILE_PATH}" \
+ "${MVN_TARBALL}" \
+ "apache-maven-${MVN_VERSION}/bin/mvn" \
+ "" || { echo "ERROR: Failed to download Maven; please install
manually." 1>&2; exit 2; }
+ else
+ echo "ERROR: Failed to download Maven; please install manually." 1>&2
+ exit 2
+ fi
+ fi
MVN_BIN="${_DIR}/apache-maven-${MVN_VERSION}/bin/mvn"
fi
diff --git
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
index f528934b39..71aae81f31 100644
---
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
+++
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
@@ -275,8 +275,9 @@ public class SparkShuffleManager implements ShuffleManager {
public void stop() {
sortShuffleIds.clear();
if (shuffleClient != null) {
- shuffleClient.shutdown();
- ShuffleClient.reset();
+ // Remove and shut down only THIS application's client. Do not call
ShuffleClient.reset():
+ // in a multi-app JVM that would also tear down other live applications'
clients.
+ ShuffleClient.removeInstance(shuffleClient);
shuffleClient = null;
}
if (lifecycleManager != null) {
diff --git a/client/src/main/java/org/apache/celeborn/client/ShuffleClient.java
b/client/src/main/java/org/apache/celeborn/client/ShuffleClient.java
index 8bc1a8911c..9fb15fdf7a 100644
--- a/client/src/main/java/org/apache/celeborn/client/ShuffleClient.java
+++ b/client/src/main/java/org/apache/celeborn/client/ShuffleClient.java
@@ -20,6 +20,7 @@ package org.apache.celeborn.client;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@@ -51,13 +52,18 @@ import
org.apache.celeborn.common.write.LocationPushFailedBatches;
import org.apache.celeborn.common.write.PushState;
/**
- * ShuffleClient may be a process singleton, the specific PartitionLocation
should be hidden in the
- * implementation
+ * ShuffleClient hides the specific PartitionLocation from callers. A process
holds one client per
+ * appUniqueId (see {@link #get}); most deployments run a single application
per JVM and therefore a
+ * single client, but a process that drives several applications (e.g. the
multi-app spark-it JVM)
+ * holds one isolated client per app.
*/
public abstract class ShuffleClient {
private static Logger logger = LoggerFactory.getLogger(ShuffleClient.class);
- private static volatile ShuffleClient _instance;
- private static volatile boolean initialized = false;
+ // One client per appUniqueId. Keying by appUniqueId keeps concurrent
applications isolated (each
+ // with its own LifecycleManager) and lets each be torn down independently
when its application
+ // stops, instead of a single static slot that would have to evict (and
orphan the resources of)
+ // the previous app's client on every switch.
+ private static final ConcurrentHashMap<String, ShuffleClient> clients = new
ConcurrentHashMap<>();
private static volatile Map<StorageInfo.Type, FileSystem> hadoopFs;
private static LongAdder totalReadCounter = new LongAdder();
private static LongAdder localShuffleReadCounter = new LongAdder();
@@ -68,9 +74,42 @@ public abstract class ShuffleClient {
// for testing
public static void reset() {
- _instance = null;
- initialized = false;
- hadoopFs = null;
+ List<ShuffleClient> toShutdown;
+ synchronized (ShuffleClient.class) {
+ toShutdown = new ArrayList<>(clients.values());
+ clients.clear();
+ hadoopFs = null;
+ }
+ // Shut down outside the lock: shutdown() tears down an RpcEnv and pools
and can block.
+ for (ShuffleClient client : toShutdown) {
+ try {
+ client.shutdown();
+ } catch (Throwable t) {
+ logger.warn("Failed to shutdown shuffle client during reset.", t);
+ }
+ }
+ }
+
+ /**
+ * Removes {@code client} from the registry and shuts it down exactly once,
reclaiming its RpcEnv,
+ * Netty data client factory, push-retry pool and reviveManager. An engine's
shuffle-manager calls
+ * this from its own stop() so a stopped application's client does not leak
until JVM exit, and
+ * (unlike {@link #reset()}) without touching other live applications'
clients. Keyed by the
+ * instance rather than appUniqueId because executor-side managers never
populate their
+ * appUniqueId field. No-op if the client was already removed (e.g. by a
concurrent {@link
+ * #reset()}), so the instance is never shut down twice.
+ */
+ public static void removeInstance(ShuffleClient client) {
+ if (client == null) {
+ return;
+ }
+ boolean removed;
+ synchronized (ShuffleClient.class) {
+ removed = clients.values().removeIf(existing -> existing == client);
+ }
+ if (removed) {
+ client.shutdown();
+ }
}
protected ShuffleClient() {}
@@ -103,38 +142,39 @@ public abstract class ShuffleClient {
UserIdentifier userIdentifier,
byte[] extension,
Optional<CryptoHandler> cryptoHandler) {
- if (null == _instance || !initialized) {
+ ShuffleClient client = clients.get(appUniqueId);
+ if (client == null) {
synchronized (ShuffleClient.class) {
- if (null == _instance) {
- // During the execution of Spark tasks, each task may be interrupted
due to speculative
- // tasks. If the Task is interrupted while obtaining the
ShuffleClient and the
- // ShuffleClient is building a singleton, it may cause the
LifecycleManagerEndpoint to not
- // be
- // assigned. An Executor will only construct a ShuffleClient
singleton once. At this time,
- // when communicating with LifecycleManager, it will cause a
NullPointerException.
- _instance = new ShuffleClientImpl(appUniqueId, conf, userIdentifier);
- _instance.setupLifecycleManagerRef(driverHost, port);
- _instance.setExtension(extension);
- _instance.setupCryptoHandler(cryptoHandler);
- initialized = true;
- } else if (!initialized) {
- _instance.shutdown();
- _instance = new ShuffleClientImpl(appUniqueId, conf, userIdentifier);
- _instance.setupLifecycleManagerRef(driverHost, port);
- _instance.setExtension(extension);
- _instance.setupCryptoHandler(cryptoHandler);
- initialized = true;
+ client = clients.get(appUniqueId);
+ if (client == null) {
+ // During Spark task execution a task may be interrupted (e.g. by
speculative execution)
+ // while this builds the client. Fully set the instance up before
publishing it into the
+ // registry, and tear a half-built instance down on failure, so a
later call rebuilds
+ // cleanly instead of returning a client with no LifecycleManagerRef
(which would NPE on
+ // first use). Because only fully-initialized instances are ever put
into the map, the
+ // lock-free clients.get() above can never observe a half-built or
wrong-app client.
+ ShuffleClientImpl instance = new ShuffleClientImpl(appUniqueId,
conf, userIdentifier);
+ try {
+ instance.setupLifecycleManagerRef(driverHost, port);
+ instance.setExtension(extension);
+ instance.setupCryptoHandler(cryptoHandler);
+ } catch (RuntimeException | Error e) {
+ instance.shutdown();
+ throw e;
+ }
+ clients.put(appUniqueId, instance);
+ client = instance;
}
}
}
- // Apply the crypto handler even when the singleton is already
initialized. This handles
- // the case where SparkEnv was transiently unavailable during the first
init call (causing
- // an empty handler to be stored), so that encryption is correctly applied
on retry.
- // setupCryptoHandler is a volatile write and safe to call without the
lock.
+ // Apply the crypto handler even when the client already exists. This
handles the case where
+ // SparkEnv was transiently unavailable during the first init call
(causing an empty handler to
+ // be stored), so that encryption is correctly applied on retry.
setupCryptoHandler is a
+ // volatile write and safe to call without the lock.
if (cryptoHandler != null && cryptoHandler.isPresent()) {
- _instance.setupCryptoHandler(cryptoHandler);
+ client.setupCryptoHandler(cryptoHandler);
}
- return _instance;
+ return client;
}
public static Map<StorageInfo.Type, FileSystem> getHadoopFs(CelebornConf
conf) {
diff --git
a/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
b/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
index 46c1189ca4..210151241f 100644
--- a/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
+++ b/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
@@ -924,8 +924,10 @@ public class ShuffleClientImpl extends ShuffleClient {
StatusCode statusCode = entry.getValue()._1();
if (entry.getValue()._2() != null) {
PartitionLocation oldLoc = oldLocMap.get(partitionId);
- // Currently, revive only check if main location available, here
won't remove peer loc.
- pushExcludedWorkers.remove(oldLoc.hostAndPushPort());
+ if (oldLoc != null) {
+ // Currently, revive only check if main location available, here
won't remove peer loc.
+ pushExcludedWorkers.remove(oldLoc.hostAndPushPort());
+ }
}
if (StatusCode.SUCCESS == statusCode) {
diff --git
a/client/src/test/scala/org/apache/celeborn/client/WithShuffleClientSuite.scala
b/client/src/test/scala/org/apache/celeborn/client/WithShuffleClientSuite.scala
index 263a2a9a64..850740f5ff 100644
---
a/client/src/test/scala/org/apache/celeborn/client/WithShuffleClientSuite.scala
+++
b/client/src/test/scala/org/apache/celeborn/client/WithShuffleClientSuite.scala
@@ -34,7 +34,7 @@ trait WithShuffleClientSuite extends CelebornFunSuite {
protected val celebornConf: CelebornConf = new CelebornConf()
- protected val APP = "app-1"
+ protected var APP: String = _
protected val userIdentifier: UserIdentifier = UserIdentifier("mock", "mock")
private val numMappers = 8
private val mapId = 1
@@ -49,6 +49,11 @@ trait WithShuffleClientSuite extends CelebornFunSuite {
_shuffleId
}
+ override protected def beforeEach(): Unit = {
+ super.beforeEach()
+ APP = s"app-${java.util.UUID.randomUUID()}"
+ }
+
override protected def afterEach() {
if (lifecycleManager != null) {
lifecycleManager.stop()
diff --git a/common/src/main/scala/org/apache/celeborn/common/util/Utils.scala
b/common/src/main/scala/org/apache/celeborn/common/util/Utils.scala
index 5b2dd6a109..9a634c77d0 100644
--- a/common/src/main/scala/org/apache/celeborn/common/util/Utils.scala
+++ b/common/src/main/scala/org/apache/celeborn/common/util/Utils.scala
@@ -255,9 +255,13 @@ object Utils extends Logging {
* @return a randomly selected integer within the range [from, until)
*/
def selectRandomInt(from: Int, until: Int): Int = {
- ScalaRandom.nextInt(until - 1 - from) + from
+ ScalaRandom.nextInt(until - from) + from
}
+ val MAX_SELECTABLE_PORT = 32768
+
+ def selectRandomPort(): Int = selectRandomInt(1024, MAX_SELECTABLE_PORT)
+
def startServiceOnPort[T](
startPort: Int,
startService: Int => (T, Int),
diff --git a/common/src/test/scala/org/apache/celeborn/RandomPortSupport.scala
b/common/src/test/scala/org/apache/celeborn/RandomPortSupport.scala
new file mode 100644
index 0000000000..b760c250d6
--- /dev/null
+++ b/common/src/test/scala/org/apache/celeborn/RandomPortSupport.scala
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.celeborn
+
+import java.io.IOException
+import java.net.{InetSocketAddress, Socket}
+
+import org.apache.celeborn.common.util.Utils
+
+/**
+ * Shared random-port picker for cluster-setup test traits. Draws ports below
the ephemeral floor
+ * (via [[Utils.selectRandomPort]]) and remembers the ports it has handed out
so repeated calls
+ * within a suite do not collide, retrying if a candidate is already used or
currently bound.
+ */
+trait RandomPortSupport {
+ val usedPorts = new java.util.HashSet[Integer]()
+
+ def portBounded(port: Int): Boolean = {
+ val socket = new Socket()
+ try {
+ socket.connect(new InetSocketAddress("localhost", port), 100)
+ true
+ } catch {
+ case _: IOException => false
+ } finally {
+ socket.close()
+ }
+ }
+
+ def selectRandomPort(): Int = synchronized {
+ val port = Utils.selectRandomPort()
+ val portUsed = usedPorts.contains(port) || portBounded(port)
+ usedPorts.add(port)
+ if (portUsed) {
+ selectRandomPort()
+ } else {
+ port
+ }
+ }
+}
diff --git
a/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/RatisMasterStatusSystemSuiteJ.java
b/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/RatisMasterStatusSystemSuiteJ.java
index 01a16a31ee..6038ee4e15 100644
---
a/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/RatisMasterStatusSystemSuiteJ.java
+++
b/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/RatisMasterStatusSystemSuiteJ.java
@@ -119,6 +119,19 @@ public class RatisMasterStatusSystemSuiteJ {
while (!serversStarted) {
try {
+ // Re-point each server to a fresh storage directory on retry. 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. Skip
this on the first
+ // attempt: callers already configure a fresh directory when building
conf1/2/3, so
+ // reconfiguring here would orphan that just-created (empty) directory.
+ if (retryCount > 0) {
+ configureServerConf(conf1, 1);
+ configureServerConf(conf2, 2);
+ configureServerConf(conf3, 3);
+ }
+
STATUSSYSTEM1 = new HAMasterMetaManager(mockRpcEnv, conf1);
STATUSSYSTEM2 = new HAMasterMetaManager(mockRpcEnv, conf2);
STATUSSYSTEM3 = new HAMasterMetaManager(mockRpcEnv, conf3);
@@ -131,7 +144,8 @@ public class RatisMasterStatusSystemSuiteJ {
String id2 = UUID.randomUUID().toString();
String id3 = UUID.randomUUID().toString();
- int ratisPort1 = Utils$.MODULE$.selectRandomInt(1024, 65535);
+ int ratisPort1 =
+ Utils$.MODULE$.selectRandomInt(1024,
Utils$.MODULE$.MAX_SELECTABLE_PORT() - 2);
int ratisPort2 = ratisPort1 + 1;
int ratisPort3 = ratisPort2 + 1;
diff --git
a/master/src/test/scala/org/apache/celeborn/service/deploy/master/MasterClusterFeature.scala
b/master/src/test/scala/org/apache/celeborn/service/deploy/master/MasterClusterFeature.scala
index 65995bfc3d..5f6bd60f1c 100644
---
a/master/src/test/scala/org/apache/celeborn/service/deploy/master/MasterClusterFeature.scala
+++
b/master/src/test/scala/org/apache/celeborn/service/deploy/master/MasterClusterFeature.scala
@@ -18,14 +18,15 @@
package org.apache.celeborn.service.deploy.master
import java.io.IOException
-import java.net.{BindException, InetSocketAddress, Socket}
+import java.net.BindException
import java.util.concurrent.TimeUnit
+import org.apache.celeborn.RandomPortSupport
import org.apache.celeborn.common.CelebornConf
import org.apache.celeborn.common.internal.Logging
import org.apache.celeborn.common.util.{CelebornExitKind, Utils}
-trait MasterClusterFeature extends Logging {
+trait MasterClusterFeature extends Logging with RandomPortSupport {
var masterInfo: (Master, Thread) = _
val maxRetries = 3
@@ -37,29 +38,6 @@ trait MasterClusterFeature extends Logging {
}
}
- val usedPorts = new java.util.HashSet[Integer]()
- def portBounded(port: Int): Boolean = {
- val socket = new Socket()
- try {
- socket.connect(new InetSocketAddress("localhost", port), 100)
- true
- } catch {
- case _: IOException => false
- } finally {
- socket.close()
- }
- }
- def selectRandomPort(): Int = synchronized {
- val port = Utils.selectRandomInt(1024, 65535)
- val portUsed = usedPorts.contains(port) || portBounded(port)
- usedPorts.add(port)
- if (portUsed) {
- selectRandomPort()
- } else {
- port
- }
- }
-
def withRetryOnPortBindException(f: () => Unit): Unit = {
var retryCount = 0
var pass = false
diff --git a/pom.xml b/pom.xml
index 2e608aa1c0..f8738df863 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1315,6 +1315,24 @@
<goals>
<goal>prepare-agent</goal>
</goals>
+ <configuration>
+ <!--
+ Netty 4.2 fires JFR events (e.g.
io.netty.buffer.FreeBufferEvent,
+ FreeChunkEvent) by lazily loading event classes that extend
+ jdk.jfr.Event. Those classes are already bytecode-enhanced,
so
+ JaCoCo's on-the-fly agent rejects them with
+ "Cannot process instrumented class ..." and throws an
+ IllegalClassFormatException from defineClass. When this
happens on
+ a Netty I/O thread mid-write (e.g. during buffer
deallocation while
+ the worker streams shuffle data to the Flink read client),
the
+ channel is torn down and the client sees "Client is lost",
flaking
+ the Flink integration tests. Netty is a third-party
dependency we do
+ not measure coverage for, so exclude it from instrumentation.
+ -->
+ <excludes>
+ <exclude>io/netty/**</exclude>
+ </excludes>
+ </configuration>
</execution>
<execution>
<id>report</id>
diff --git
a/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/HybridShuffleWordCountTest.scala
b/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/HybridShuffleWordCountTest.scala
index 6794f23dfc..ef71860d8c 100644
---
a/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/HybridShuffleWordCountTest.scala
+++
b/tests/flink-it/src/test/scala/org/apache/celeborn/tests/flink/HybridShuffleWordCountTest.scala
@@ -27,7 +27,9 @@ import org.apache.flink.runtime.jobgraph.JobType
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment
import org.apache.flink.streaming.api.graph.StreamingJobGraphGenerator
import org.scalatest.BeforeAndAfterAll
+import org.scalatest.concurrent.Eventually._
import org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.time.SpanSugar._
import org.apache.celeborn.common.CelebornConf
import org.apache.celeborn.common.internal.Logging
@@ -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)) {
+ workers.map(worker => {
+ worker.storageManager.workingDirWriters.values().asScala.map(writers
=> {
+ writers.forEach((fileName, fileWriter) => {
+ assert(new File(fileName).length() ==
fileWriter.getDiskFileInfo.getFileLength)
+ })
})
})
- })
+ }
}
}
diff --git
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/ChangePartitionManagerUpdateWorkersSuite.scala
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/ChangePartitionManagerUpdateWorkersSuite.scala
index 3c0303f406..6e4b081230 100644
---
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/ChangePartitionManagerUpdateWorkersSuite.scala
+++
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/ChangePartitionManagerUpdateWorkersSuite.scala
@@ -42,6 +42,7 @@ class ChangePartitionManagerUpdateWorkersSuite extends
WithShuffleClientSuite
}
override def beforeEach(): Unit = {
+ super.beforeEach()
val testConf = Map(
s"${CelebornConf.CLIENT_PUSH_MAX_REVIVE_TIMES.key}" -> "3")
val (master, _) = setupMiniClusterWithRandomPorts(testConf, testConf,
workerNum = 1)
diff --git
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerUnregisterShuffleSuite.scala
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerUnregisterShuffleSuite.scala
index d689ead7e0..c5082bd732 100644
---
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerUnregisterShuffleSuite.scala
+++
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerUnregisterShuffleSuite.scala
@@ -36,6 +36,13 @@ class LifecycleManagerUnregisterShuffleSuite extends
WithShuffleClientSuite
celebornConf
.set(CelebornConf.CLIENT_PUSH_REPLICATE_ENABLED.key, "true")
.set(CelebornConf.CLIENT_PUSH_BUFFER_MAX_SIZE.key, "256K")
+ // The default expired-check interval is 60s. removeExpiredShuffle only
+ // unregisters a shuffle once `unregisterTime < now - checkInterval` and
runs
+ // on a fixed-rate timer at that interval, so with 60s the master side
cannot
+ // be cleared until the second tick (~120s) -- exactly the eventually()
window
+ // below, leaving no margin and no retry if an RPC briefly fails under
load.
+ // Use a short interval so the unregister runs promptly with ample retries.
+ .set(CelebornConf.SHUFFLE_EXPIRED_CHECK_INTERVAL.key, "5s")
override def beforeAll(): Unit = {
super.beforeAll()
diff --git
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/CelebornHashCheckDiskSuite.scala
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/CelebornHashCheckDiskSuite.scala
index 4f22982bb9..b4c8e61529 100644
---
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/CelebornHashCheckDiskSuite.scala
+++
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/CelebornHashCheckDiskSuite.scala
@@ -24,7 +24,6 @@ import org.apache.spark.sql.SparkSession
import org.scalatest.concurrent.Eventually._
import org.scalatest.time.SpanSugar.convertIntToGrainOfTime
-import org.apache.celeborn.client.ShuffleClient
import org.apache.celeborn.common.CelebornConf
import org.apache.celeborn.common.protocol.ShuffleMode
import org.apache.celeborn.service.deploy.worker.Worker
@@ -43,10 +42,11 @@ class CelebornHashCheckDiskSuite extends SparkTestBase {
}
override def beforeEach(): Unit = {
- ShuffleClient.reset()
+ stopActiveSparkSessions()
}
override def afterEach(): Unit = {
+ stopActiveSparkSessions()
System.gc()
}
@@ -59,7 +59,7 @@ class CelebornHashCheckDiskSuite extends SparkTestBase {
val combineResult = combine(sparkSession)
val groupByResult = groupBy(sparkSession)
val repartitionResult = repartition(sparkSession)
- sparkSession.stop()
+ stopActiveSparkSessions()
val sparkSessionEnableCeleborn = SparkSession.builder()
.config(updateSparkConf(sparkConf, ShuffleMode.HASH))
diff --git
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/CelebornIntegrityCheckSuite.scala
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/CelebornIntegrityCheckSuite.scala
index ef382d75c5..daea52ac1d 100644
---
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/CelebornIntegrityCheckSuite.scala
+++
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/CelebornIntegrityCheckSuite.scala
@@ -110,8 +110,28 @@ class CelebornIntegrityCheckSuite extends AnyFunSuite
// verify that the app fails
case e: Throwable => {
logger.error("Expected exception, logging the full exception", e)
+ // The contract under test is that corrupted data makes the app abort
+ // rather than silently return wrong results.
assert(e.getMessage.contains("Job aborted"))
- assert(e.getMessage.contains("CommitMetadata mismatch"))
+ // A single-bit flip is planted at a random position within a random
+ // data record, so the corruption is caught in one of two ways
+ // depending on where it lands:
+ // 1. Celeborn's integrity CRC, which is validated only at the end
of
+ // the partition (CelebornInputStream#validateIntegrity),
surfaces
+ // as "CommitMetadata mismatch".
+ // 2. The downstream deserializer (Kryo) choking on a structurally
+ // invalid byte (e.g. a bogus reference id or length) before the
+ // partition end is reached, so validateIntegrity() never runs
and
+ // a deserialization error (e.g. IndexOutOfBoundsException) is
the
+ // most-recent failure reported by Spark.
+ // Both are valid "fail instead of returning wrong data" outcomes and
+ // which one surfaces is non-deterministic, so accept either rather
than
+ // asserting on the integrity-specific message (which made this
flaky).
+ assert(
+ e.getMessage.contains("CommitMetadata mismatch") ||
+ e.getMessage.contains("IndexOutOfBounds") ||
+ e.getMessage.contains("com.esotericsoftware.kryo"),
+ s"App aborted for an unexpected reason: ${e.getMessage}")
}
} finally {
sparkSession.stop()
diff --git
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/ShuffleFallbackSuite.scala
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/ShuffleFallbackSuite.scala
index 059e7d0337..a1c6468c2f 100644
---
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/ShuffleFallbackSuite.scala
+++
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/ShuffleFallbackSuite.scala
@@ -51,7 +51,7 @@ class ShuffleFallbackSuite extends AnyFunSuite
}
test(s"celeborn spark integration test - fallback") {
- setupMiniClusterWithRandomPorts(workerNum = 5)
+ setupMiniClusterWithRandomPorts(workerNum = 3)
val sparkConf = new SparkConf().setAppName("celeborn-demo")
.setMaster("local[2]")
.set(s"spark.${CelebornConf.SPARK_SHUFFLE_FORCE_FALLBACK_ENABLED.key}",
"true")
diff --git
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/SparkTestBase.scala
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/SparkTestBase.scala
index c857bd67b0..58b94fef1f 100644
---
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/SparkTestBase.scala
+++
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/SparkTestBase.scala
@@ -26,6 +26,7 @@ import org.apache.spark.sql.internal.SQLConf
import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
import org.scalatest.funsuite.AnyFunSuite
+import org.apache.celeborn.client.ShuffleClient
import org.apache.celeborn.common.CelebornConf
import org.apache.celeborn.common.CelebornConf._
import org.apache.celeborn.common.internal.Logging
@@ -48,14 +49,35 @@ trait SparkTestBase extends AnyFunSuite
override def beforeAll(): Unit = {
logInfo("test initialized , setup Celeborn mini cluster")
- setupMiniClusterWithRandomPorts(workerNum = 5)
+ // Use 3 workers (the MiniClusterFeature default) rather than 5. The
spark-it suites run
+ // serially in a single JVM (scalatest forkMode=once), so every extra
worker multiplies the
+ // long-lived thread/CPU footprint across the whole module. Under CPU
contention on CI runners
+ // that surplus starves RPC handlers long enough to blow the 240s network
timeout, which then
+ // amplifies through read retries and Spark stage reattempts into
multi-minute hangs. 3 workers
+ // still exercises replication and slot spreading while cutting that
footprint.
+ setupMiniClusterWithRandomPorts(workerNum = 3)
}
override def afterAll(): Unit = {
logInfo("all test complete , stop Celeborn mini cluster")
+ // Tear down any SparkSession/SparkContext still alive in this JVM before
the next suite runs.
+ // Spark integration suites run sequentially (parallelExecution = false),
but a context that is
+ // not stopped keeps its LifecycleManager and the process-wide static
ShuffleClient alive. A
+ // straggler task from such a leaked context can then bind to a later
suite's LifecycleManager
+ // through the shared client and corrupt celebornShuffleId 0
(ArrayIndexOutOfBoundsException or
+ // CommitMetadata CRC mismatch). Stopping the context here triggers
SparkShuffleManager.stop(),
+ // which shuts the client down and stops the LifecycleManager.
+ stopActiveSparkSessions()
shutdownMiniCluster()
}
+ protected def stopActiveSparkSessions(): Unit = {
+
SparkSession.getActiveSession.orElse(SparkSession.getDefaultSession).foreach(_.stop())
+ SparkSession.clearActiveSession()
+ SparkSession.clearDefaultSession()
+ ShuffleClient.reset()
+ }
+
var workerDirs: Seq[String] = Seq.empty
def getOneWorker(): Worker = {
diff --git
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/fetch/failure/ShuffleReaderGetHooks.scala
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/fetch/failure/ShuffleReaderGetHooks.scala
index adac14242b..09d9efe623 100644
---
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/fetch/failure/ShuffleReaderGetHooks.scala
+++
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/fetch/failure/ShuffleReaderGetHooks.scala
@@ -38,17 +38,15 @@ class ShuffleReaderGetHooks(
val lock = new Object
private def deleteDataFile(appUniqueId: String, celebornShuffleId: Int):
Unit = {
- val datafile =
+ val dataFiles =
workerDirs.map(dir => {
new
File(s"$dir/celeborn-worker/shuffle_data/$appUniqueId/$celebornShuffleId")
}).filter(_.exists())
- .flatMap(_.listFiles().iterator).headOption
- datafile match {
- case Some(file) => {
- file.delete()
- }
- case None => throw new RuntimeException("unexpected, there must be some
data file")
+ .flatMap(_.listFiles().iterator)
+ if (dataFiles.isEmpty) {
+ throw new RuntimeException("unexpected, there must be some data file")
}
+ dataFiles.foreach(_.delete())
}
override def exec(
diff --git
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/memory/MemorySparkTestBase.scala
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/memory/MemorySparkTestBase.scala
index 6d4b1e7119..6770d21e2f 100644
---
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/memory/MemorySparkTestBase.scala
+++
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/spark/memory/MemorySparkTestBase.scala
@@ -35,11 +35,15 @@ 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 = {
logInfo("all test complete , stop Celeborn mini cluster")
+ stopActiveSparkSessions()
shutdownMiniCluster()
}
diff --git
a/tests/spark-it/src/test/scala/org/apache/spark/shuffle/celeborn/SparkUtilsSuite.scala
b/tests/spark-it/src/test/scala/org/apache/spark/shuffle/celeborn/SparkUtilsSuite.scala
index b86244636b..24e604fd27 100644
---
a/tests/spark-it/src/test/scala/org/apache/spark/shuffle/celeborn/SparkUtilsSuite.scala
+++
b/tests/spark-it/src/test/scala/org/apache/spark/shuffle/celeborn/SparkUtilsSuite.scala
@@ -70,12 +70,12 @@ class SparkUtilsSuite extends AnyFunSuite
val jobThread = new Thread {
override def run(): Unit = {
try {
- val value = Range(1, 10000).mkString(",")
+ val value = Range(1, 100).mkString(",")
sc.parallelize(1 to 10000, 2)
.map { i => (i, value) }
- .groupByKey(10)
+ .groupByKey(2)
.mapPartitions { iter =>
- Thread.sleep(3000)
+ Thread.sleep(500)
iter
}.collect()
} catch {
diff --git
a/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/monitor/JVMQuake.scala
b/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/monitor/JVMQuake.scala
index 477b1ad2f7..822ba5faa0 100644
---
a/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/monitor/JVMQuake.scala
+++
b/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/monitor/JVMQuake.scala
@@ -91,9 +91,19 @@ class JVMQuake(conf: CelebornConf, uniqueId: String =
UUID.randomUUID().toString
val runTimeTicks = currentExitTime - lastExitTime - gcTimeTicks
// JVMStat time monitors are reported in ticks. Convert deltas to nanos
before comparing
// them against JVMQuake thresholds, which are stored as nanos.
- val gcTime = ticksToNanos(gcTimeTicks)
- val runTime = ticksToNanos(runTimeTicks)
+ checkAndDump(ticksToNanos(gcTimeTicks), ticksToNanos(runTimeTicks))
+ lastExitTime = currentExitTime
+ lastGCTime = currentGCTime
+ }
+ /**
+ * Updates the GC "deficit" bucket with the latest GC and execution time
deltas (in nanos) and
+ * heap dumps or kills the JVM once the configured thresholds are crossed.
Separated from the
+ * jvmstat counter reads in [[run]] so the threshold logic can be exercised
deterministically
+ * without inducing real GC pressure.
+ */
+ @VisibleForTesting
+ private[monitor] def checkAndDump(gcTime: Long, runTime: Long): Unit = {
bucket = Math.max(0, bucket + gcTime - (BigDecimal(runTime) *
BigDecimal(runtimeWeight)).toLong)
logDebug(s"Time: (gc time: ${Utils.nanoDurationToString(gcTime)},
execution time: ${Utils.nanoDurationToString(runTime)})")
logDebug(
@@ -110,8 +120,6 @@ class JVMQuake(conf: CelebornConf, uniqueId: String =
UUID.randomUUID().toString
System.exit(exitCode)
}
}
- lastExitTime = currentExitTime
- lastGCTime = currentGCTime
}
def shouldHeapDump: Boolean = {
diff --git
a/worker/src/test/scala/org/apache/celeborn/service/deploy/MiniClusterFeature.scala
b/worker/src/test/scala/org/apache/celeborn/service/deploy/MiniClusterFeature.scala
index 2fab21e718..e4b26a1c51 100644
---
a/worker/src/test/scala/org/apache/celeborn/service/deploy/MiniClusterFeature.scala
+++
b/worker/src/test/scala/org/apache/celeborn/service/deploy/MiniClusterFeature.scala
@@ -18,7 +18,7 @@
package org.apache.celeborn.service.deploy
import java.io.IOException
-import java.net.{BindException, InetSocketAddress, Socket}
+import java.net.BindException
import java.nio.file.Files
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
@@ -27,6 +27,7 @@ import scala.collection.mutable
import org.apache.commons.lang3.StringUtils
+import org.apache.celeborn.RandomPortSupport
import org.apache.celeborn.common.CelebornConf
import org.apache.celeborn.common.internal.Logging
import org.apache.celeborn.common.util.{CelebornExitKind, Utils}
@@ -34,7 +35,7 @@ import org.apache.celeborn.service.deploy.master.{Master,
MasterArguments}
import org.apache.celeborn.service.deploy.worker.{Worker, WorkerArguments}
import org.apache.celeborn.service.deploy.worker.memory.MemoryManager
-trait MiniClusterFeature extends Logging {
+trait MiniClusterFeature extends Logging with RandomPortSupport {
var masterInfo: (Master, Thread) = _
val workerInfos = new mutable.HashMap[Worker, Thread]()
@@ -51,29 +52,6 @@ trait MiniClusterFeature extends Logging {
}
}
- val usedPorts = new java.util.HashSet[Integer]()
- def portBounded(port: Int): Boolean = {
- val socket = new Socket()
- try {
- socket.connect(new InetSocketAddress("localhost", port), 100)
- true
- } catch {
- case _: IOException => false
- } finally {
- socket.close()
- }
- }
- def selectRandomPort(): Int = synchronized {
- val port = Utils.selectRandomInt(1024, 65535)
- val portUsed = usedPorts.contains(port) || portBounded(port)
- usedPorts.add(port)
- if (portUsed) {
- selectRandomPort()
- } else {
- port
- }
- }
-
def setupMiniClusterWithRandomPorts(
masterConf: Map[String, String] = Map(),
workerConf: Map[String, String] = Map(),
@@ -213,7 +191,7 @@ trait MiniClusterFeature extends Logging {
val workers = new Array[Worker](workerNum)
val flagUpdateLock = new ReentrantLock()
val threads = (1 to workerNum).map { i =>
- val worker = createWorker(workerConf)
+ var worker = createWorker(workerConf)
val workerThread = new RunnerWrap({
var workerStartRetry = 0
var workerStarted = false
@@ -225,10 +203,20 @@ 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()
- }
+ // Tear the failed worker down locally, mirroring the
InterruptedException branch
+ // above. Do NOT call exitImmediately() here: a worker that
failed to start usually
+ // never registered, and exitImmediately() issues a blocking
WorkerLost RPC to the
+ // master and adds this worker's host:ports to the master's
excluded list. In this
+ // retry loop that blocks up to the ask timeout per attempt and
leaks stale exclusions
+ // as each recreated worker re-registers under new random ports.
+
Utils.tryLogNonFatalError(worker.stop(CelebornExitKind.EXIT_IMMEDIATELY))
+ Utils.tryLogNonFatalError(worker.rpcEnv.shutdown())
workerStarted = false
workerStartRetry += 1
logError(s"cannot start worker $i, retrying: ", ex)
@@ -236,6 +224,14 @@ trait MiniClusterFeature extends Logging {
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)
}
}
})
@@ -250,14 +246,20 @@ trait MiniClusterFeature extends Logging {
try {
(0 until workerNum).foreach { i =>
{
- if (workers(i) == null) {
+ // Snapshot the slot under the same lock that guards its write, so
a worker reassigned
+ // on retry is visible here.
+ flagUpdateLock.lock()
+ val worker = workers(i)
+ flagUpdateLock.unlock()
+ if (worker == null) {
throw new IllegalStateException(s"worker $i hasn't been
initialized")
- } else if (!workerInfos.contains(workers(i))) {
- workerInfos.put(workers(i), threads(i))
}
- if (!workers(i).registered.get()) {
+ if (!worker.registered.get()) {
throw new IllegalStateException(s"worker $i hasn't been
registered")
}
+ if (!workerInfos.contains(worker)) {
+ workerInfos.put(worker, threads(i))
+ }
}
}
allWorkersStarted = true
diff --git
a/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/monitor/JVMQuakeSuite.scala
b/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/monitor/JVMQuakeSuite.scala
index fa3b7f36be..94b86811c0 100644
---
a/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/monitor/JVMQuakeSuite.scala
+++
b/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/monitor/JVMQuakeSuite.scala
@@ -18,8 +18,7 @@
package org.apache.celeborn.service.deploy.worker.monitor
import java.io.File
-
-import scala.collection.mutable.ArrayBuffer
+import java.util.concurrent.TimeUnit
import org.junit.Assert.assertTrue
@@ -30,13 +29,6 @@ import org.apache.celeborn.common.util.JavaUtils
class JVMQuakeSuite extends CelebornFunSuite {
- private val allocation = new ArrayBuffer[Array[Byte]]()
-
- override def afterEach(): Unit = {
- allocation.clear()
- System.gc()
- }
-
test("Convert JVMStat timer ticks to nanoseconds") {
assert(JVMQuake.ticksToNanos(1L, 1000000000L) === 1L)
assert(JVMQuake.ticksToNanos(1000L, 1000L) === 1000000000L)
@@ -53,9 +45,14 @@ 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)
assertTrue(quake.heapDumped)
val heapDump = new File(s"${quake.getHeapDumpSavePath}/${quake.dumpFile}")
@@ -64,17 +61,10 @@ class JVMQuakeSuite extends CelebornFunSuite {
JavaUtils.deleteRecursively(new File(quake.getHeapDumpLinkPath))
}
- def allocateMemory(quake: JVMQuake): Unit = {
- val capacity = 1024 * 100
- while (allocation.size * capacity < Runtime.getRuntime.maxMemory / 4) {
- val bytes = new Array[Byte](capacity)
- allocation.append(bytes)
- }
- while (quake.shouldHeapDump) {
- for (index <- allocation.indices) {
- val bytes = new Array[Byte](capacity)
- allocation(index) = bytes
- }
- }
+ test("start() schedules monitoring and stop() tears it down without
dumping") {
+ val quake = new JVMQuake(new
CelebornConf().set(WORKER_JVM_QUAKE_ENABLED.key, "true"))
+ quake.start()
+ quake.stop()
+ assert(!quake.heapDumped)
}
}