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 0d63a20e4c [CELEBORN-2447] Add a /healthz endpoint to master and worker
0d63a20e4c is described below

commit 0d63a20e4c08b713e1d0cf69a9d03c67cb6db6af
Author: strelok89 <[email protected]>
AuthorDate: Mon Sep 14 19:10:22 2026 +0800

    [CELEBORN-2447] Add a /healthz endpoint to master and worker
    
    ### What changes were proposed in this pull request?
    
    Add a `GET /healthz` endpoint served by both master and worker, whose HTTP 
status code reflects whether the service is able to serve: `200` when healthy 
and `503` when not.
    
    - `HttpService#healthCheck` defaults to a shallow check that reports 
healthy once the HTTP service is available. This is what the master uses.
    - `Worker` overrides it to report healthy only when the worker is 
registered with the master and its state is `Normal`.
    - `/healthz` is added to the default HTTP authentication bypass paths.
    
    The endpoint is mounted at the root rather than under `/api/v1` so that a 
probe is not coupled to the API version, consistent with the other operational 
paths (`/ping`, `/metrics/prometheus`). The checked-in OpenAPI spec covers only 
`/api/v1`, so the generated client is unaffected.
    
    Example:
    
    ```
    $ curl -s -o /dev/null -w '%{http_code}\n' http://worker:9096/healthz
    200
    
    $ curl -s http://worker:9096/healthz
    {"service":"worker","healthy":false,"reason":"worker is not registered with 
master"}
    ```
    
    ### Why are the changes needed?
    
    Celeborn currently exposes no endpoint whose status code reflects whether 
the process can serve, so a meaningful Kubernetes probe cannot be written. 
`/ping` always returns `200` regardless of state, and `/api/v1/workers` returns 
`200` even when `isRegistered` is false, so operators have to fall back to an 
`exec` probe that shells out and greps the JSON body.
    
    The practical cost is rolling updates. Without a readiness signal, a 
StatefulSet marks a worker pod Ready as soon as its container process starts 
and immediately proceeds to the next ordinal, before the restarted worker has 
re-registered with the master. On a large cluster this can remove a meaningful 
fraction of workers from service before any of them rejoin.
    
    Two design points worth calling out for review:
    
    **The worker check mirrors the master's own definition.** 
`AbstractMetaManager#isWorkerAvailable` accepts only `Normal`, so a worker that 
is idle, decommissioning or exiting is already excluded from `availableWorkers` 
and never selected in `offerSlots`. Reporting such a worker as healthy would 
put `/healthz` at odds with the master. Whether `Idle` specifically should fail 
readiness is discussed on CELEBORN-2447 — it is reachable only through an 
explicit `DecommissionThenIdle` event, s [...]
    
    **The master check is deliberately shallow.** Masters are fronted by a 
headless Service that does not set `publishNotReadyAddresses`. A quorum-aware 
or leader-aware check would report every master as unhealthy during a cold 
start, withholding their DNS records and preventing them from discovering each 
other to form quorum. A follower is also a healthy replica. Quorum and 
leadership remain observable through `/api/v1/ratis` and `/api/v1/masters`.
    
    `/healthz` bypasses authentication by default because a kubelet cannot 
present credentials, and `celeborn.http.auth.bypass.api.paths` defaults to 
empty (CELEBORN-2278).
    
    Wiring probes into the Helm chart, which currently defines no 
`livenessProbe`, `readinessProbe` or `startupProbe` for either role, is 
intended as a follow-up so the two changes can be reviewed independently.
    
    ### Does this PR resolve a correctness bug?
    
    - [ ] Yes
    
    ### Does this PR introduce _any_ user-facing change?
    
    - [x] Yes
    
    A new `/healthz` endpoint on master and worker, documented in 
`docs/restapi.md`.
    
    ### How was this patch tested?
    
    New tests:
    
    - `ApiBaseResourceSuite`: `/healthz` returns `200` and reports the correct 
service name, for both master and worker.
    - `ApiBaseResourceAuthenticationSuite`: `/healthz` is reachable without 
credentials.
    - `ApiWorkerResourceSuite`: `/healthz` returns `503` when the worker is not 
registered.
    
    Verified locally on JDK 11: `celeborn-service/Test/compile` and 
`celeborn-worker/Test/compile` pass, `ApiMasterResourceSuite` (19/19) and 
`ApiMasterResourceAuthenticationSuite` (9/9) pass, and `spotless:check` passes 
for the `service` and `worker` modules.
    
    The worker-side suites were not run locally: they were developed on 
Windows, where any `MiniClusterFeature` test fails during setup because 
`CelebornConf#workerBaseDirs` splits each storage dir on `:` and rejects 
`C:\...` paths. That is pre-existing and unrelated to this change, and those 
suites are covered by CI.
    
    Closes #3832 from strelok89/CELEBORN-2447.
    
    Authored-by: strelok89 <[email protected]>
    Signed-off-by: Nicholas Jiang <[email protected]>
---
 docs/restapi.md                                    | 32 ++++++++++
 .../celeborn/server/common/HttpService.scala       | 11 ++++
 .../server/common/http/api/HealthResource.scala    | 71 ++++++++++++++++++++++
 .../http/authentication/AuthenticationFilter.scala |  2 +-
 .../http/ApiBaseResourceAuthenticationSuite.scala  |  7 +++
 .../server/common/http/ApiBaseResourceSuite.scala  | 10 +++
 .../celeborn/service/deploy/worker/Worker.scala    | 33 ++++++++++
 .../deploy/worker/WorkerStatusManager.scala        |  4 +-
 .../service/deploy/MiniClusterFeature.scala        |  3 +
 .../service/deploy/worker/WorkerSuite.scala        | 49 ++++++++++++++-
 .../worker/http/api/ApiWorkerResourceSuite.scala   | 65 ++++++++++++++++++++
 11 files changed, 284 insertions(+), 3 deletions(-)

diff --git a/docs/restapi.md b/docs/restapi.md
index 4f2892aec4..73e00b9a45 100644
--- a/docs/restapi.md
+++ b/docs/restapi.md
@@ -34,6 +34,38 @@ The configuration of `<master-http-host>`, 
`<master-http-port>`, `<worker-http-h
 | celeborn.worker.http.host | 0.0.0.0 | Worker's http host. | 0.4.0 |
 | celeborn.worker.http.port | 9096    | Worker's http port. | 0.4.0 |
 
+### Health Check API (Since 1.0.0)
+
+Both master and worker serve a health check endpoint at `/healthz`, intended 
to back a Kubernetes
+readiness probe. It responds `200` when the service is able to serve and `503` 
when it is not,
+so that it can be consumed with a plain `httpGet` probe.
+
+| Path       | Method | Meaning                                                
                           |
+|------------|--------|-----------------------------------------------------------------------------------|
+| `/healthz` | GET    | Whether the service is able to serve. Returns `200` 
when healthy, `503` when not. |
+
+The response body reports the reason when the service is not able to serve:
+
+```json
+{"service":"worker","healthy":false,"reason":"worker is not registered with 
master"}
+```
+
+The check differs by role:
+
+- **Master**: healthy once its HTTP service is available. The check is 
deliberately shallow and
+  does not depend on Ratis quorum or leadership, since a follower master is a 
healthy replica and
+  a quorum-aware check would report every master as unhealthy during a cold 
start. Use
+  `/api/v1/ratis` and `/api/v1/masters` to monitor quorum and leadership.
+- **Worker**: healthy only when it is registered with the master and its state 
is `Normal`. This
+  matches the master's own definition of an available worker, so a worker that 
is idle,
+  decommissioning or exiting is reported as not able to serve, consistent with 
the fact that the
+  master no longer offers slots to it. A worker also reports not able to serve 
while it is
+  re-registering after a heartbeat response told it that the master no longer 
knows about it.
+  Note that the worker keeps serving push and fetch requests throughout that 
window, since it
+  still holds the data clients hold locations for; only readiness is withheld.
+
+`/healthz` bypasses HTTP authentication by default, since a kubelet cannot 
present credentials.
+
 ### Deprecated REST APIs
 
 Since 0.6.0, the legacy REST APIs are deprecated and will be removed in the 
future.
diff --git 
a/service/src/main/scala/org/apache/celeborn/server/common/HttpService.scala 
b/service/src/main/scala/org/apache/celeborn/server/common/HttpService.scala
index a66f53305d..4f06075986 100644
--- a/service/src/main/scala/org/apache/celeborn/server/common/HttpService.scala
+++ b/service/src/main/scala/org/apache/celeborn/server/common/HttpService.scala
@@ -185,6 +185,17 @@ abstract class HttpService extends Service with Logging {
 
   def exit(exitType: String): String = throw new 
UnsupportedOperationException()
 
+  /**
+   * Whether the service is able to serve, along with the reason when it is 
not. Intended to back
+   * the `/healthz` endpoint consumed by readiness probes.
+   *
+   * The default is a shallow check that only reports that the HTTP service is 
available, which is
+   * what the master needs: a follower master is a healthy replica, and gating 
on Ratis quorum or
+   * leadership would leave every master unhealthy during a cold start. The 
worker overrides this
+   * with a check of its registration and state.
+   */
+  def healthCheck(): HandleResponse = (true, "")
+
   def handleWorkerEvent(
       workerEventType: WorkerEventType,
       workers: Seq[WorkerInfo]): HandleResponse =
diff --git 
a/service/src/main/scala/org/apache/celeborn/server/common/http/api/HealthResource.scala
 
b/service/src/main/scala/org/apache/celeborn/server/common/http/api/HealthResource.scala
new file mode 100644
index 0000000000..51d7ea8dc3
--- /dev/null
+++ 
b/service/src/main/scala/org/apache/celeborn/server/common/http/api/HealthResource.scala
@@ -0,0 +1,71 @@
+/*
+ * 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.server.common.http.api
+
+import javax.ws.rs.{GET, Path, Produces}
+import javax.ws.rs.core.{MediaType, Response}
+
+import io.swagger.v3.oas.annotations.Operation
+import io.swagger.v3.oas.annotations.media.{Content, Schema}
+import io.swagger.v3.oas.annotations.responses.ApiResponse
+import io.swagger.v3.oas.annotations.tags.Tag
+
+/**
+ * The response of the health check, see [[HealthResource]].
+ *
+ * @param service the name of the service, either `master` or `worker`.
+ * @param healthy whether the service is able to serve.
+ * @param reason  the reason why the service is not able to serve, empty when 
healthy.
+ */
+case class HealthCheckResponse(service: String, healthy: Boolean, reason: 
String)
+
+@Tag(name = "Health")
+@Path("/healthz")
+@Produces(Array(MediaType.APPLICATION_JSON))
+private[api] class HealthResource extends ApiRequestContext {
+
+  @Operation(description =
+    "Check whether the service is able to serve. Returns 200 when the service 
is healthy " +
+      "and 503 otherwise, which is intended to be consumed by a readiness 
probe. " +
+      "The master reports healthy once its HTTP service is available, 
including when it is " +
+      "a follower. The worker reports healthy only when it is registered with 
the master and " +
+      "its state is Normal, so a worker that is decommissioning, idle or 
exiting is reported " +
+      "as not able to serve.")
+  @ApiResponse(
+    responseCode = "200",
+    description = "The service is able to serve.",
+    content = Array(new Content(
+      mediaType = MediaType.APPLICATION_JSON,
+      schema = new Schema(implementation = classOf[HealthCheckResponse]))))
+  @ApiResponse(
+    responseCode = "503",
+    description = "The service is not able to serve.",
+    content = Array(new Content(
+      mediaType = MediaType.APPLICATION_JSON,
+      schema = new Schema(implementation = classOf[HealthCheckResponse]))))
+  @GET
+  def health(): Response = {
+    val (healthy, reason) = httpService.healthCheck()
+    val status =
+      if (healthy) Response.Status.OK else Response.Status.SERVICE_UNAVAILABLE
+    Response.status(status)
+      .`type`(MediaType.APPLICATION_JSON)
+      .entity(HealthCheckResponse(httpService.serviceName, healthy, reason))
+      .build()
+  }
+}
diff --git 
a/service/src/main/scala/org/apache/celeborn/server/common/http/authentication/AuthenticationFilter.scala
 
b/service/src/main/scala/org/apache/celeborn/server/common/http/authentication/AuthenticationFilter.scala
index 55274fc8a4..9f994ccebc 100644
--- 
a/service/src/main/scala/org/apache/celeborn/server/common/http/authentication/AuthenticationFilter.scala
+++ 
b/service/src/main/scala/org/apache/celeborn/server/common/http/authentication/AuthenticationFilter.scala
@@ -221,7 +221,7 @@ class AuthenticationFilter(conf: CelebornConf, serviceName: 
String) extends Filt
 }
 
 object AuthenticationFilter {
-  private val BYPASS_DEFAULT_API_PATHS = Set("/openapi.json", "/openapi.yaml")
+  private val BYPASS_DEFAULT_API_PATHS = Set("/healthz", "/openapi.json", 
"/openapi.yaml")
 
   final val HTTP_CLIENT_IP_ADDRESS = new ThreadLocal[String]() {
     override protected def initialValue: String = null
diff --git 
a/service/src/test/scala/org/apache/celeborn/server/common/http/ApiBaseResourceAuthenticationSuite.scala
 
b/service/src/test/scala/org/apache/celeborn/server/common/http/ApiBaseResourceAuthenticationSuite.scala
index 7e62a331d4..8fb95c7ef7 100644
--- 
a/service/src/test/scala/org/apache/celeborn/server/common/http/ApiBaseResourceAuthenticationSuite.scala
+++ 
b/service/src/test/scala/org/apache/celeborn/server/common/http/ApiBaseResourceAuthenticationSuite.scala
@@ -26,6 +26,7 @@ import org.apache.celeborn.common.CelebornConf
 import org.apache.celeborn.common.authentication.HttpAuthSchemes
 import org.apache.celeborn.common.network.TestHelper
 import 
org.apache.celeborn.server.common.http.HttpAuthUtils.AUTHORIZATION_HEADER
+import org.apache.celeborn.server.common.http.api.HealthCheckResponse
 import 
org.apache.celeborn.server.common.http.authentication.{UserDefinedPasswordAuthenticationProviderImpl,
 UserDefineTokenAuthenticationProviderImpl}
 
 abstract class ApiBaseResourceAuthenticationSuite extends HttpTestHelper {
@@ -93,6 +94,12 @@ abstract class ApiBaseResourceAuthenticationSuite extends 
HttpTestHelper {
     }
   }
 
+  test("health api does not need authentication") {
+    val response = 
webTarget.path("healthz").request(MediaType.APPLICATION_JSON).get()
+    assert(HttpServletResponse.SC_OK == response.getStatus)
+    assert(response.readEntity(classOf[HealthCheckResponse]).healthy)
+  }
+
   test("swagger api do not need authentication") {
     Seq("swagger", "docs", "help").foreach { path =>
       val response = webTarget.path(path).request(MediaType.TEXT_HTML).get()
diff --git 
a/service/src/test/scala/org/apache/celeborn/server/common/http/ApiBaseResourceSuite.scala
 
b/service/src/test/scala/org/apache/celeborn/server/common/http/ApiBaseResourceSuite.scala
index 49d5138517..22e77d7a71 100644
--- 
a/service/src/test/scala/org/apache/celeborn/server/common/http/ApiBaseResourceSuite.scala
+++ 
b/service/src/test/scala/org/apache/celeborn/server/common/http/ApiBaseResourceSuite.scala
@@ -22,6 +22,7 @@ import javax.ws.rs.core.MediaType
 
 import org.apache.celeborn.common.CelebornConf
 import org.apache.celeborn.common.network.TestHelper
+import org.apache.celeborn.server.common.http.api.HealthCheckResponse
 
 abstract class ApiBaseResourceSuite extends HttpTestHelper {
   celebornConf.set(CelebornConf.METRICS_ENABLED.key, "true")
@@ -35,6 +36,15 @@ abstract class ApiBaseResourceSuite extends HttpTestHelper {
     assert(response.readEntity(classOf[String]) == "pong")
   }
 
+  test("health") {
+    val response = 
webTarget.path("healthz").request(MediaType.APPLICATION_JSON).get()
+    assert(HttpServletResponse.SC_OK == response.getStatus)
+    val health = response.readEntity(classOf[HealthCheckResponse])
+    assert(health.healthy)
+    assert(health.reason.isEmpty)
+    assert(health.service == httpService.serviceName)
+  }
+
   test("conf") {
     val response = webTarget.path("conf").request(MediaType.TEXT_PLAIN).get()
     assert(HttpServletResponse.SC_OK == response.getStatus)
diff --git 
a/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/Worker.scala 
b/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/Worker.scala
index 09999bad98..79f3f20b32 100644
--- 
a/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/Worker.scala
+++ 
b/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/Worker.scala
@@ -302,6 +302,15 @@ private[celeborn] class Worker(
 
   // whether this Worker registered to Master successfully
   val registered = new AtomicBoolean(false)
+
+  // Whether the master still knows about this worker, per the last heartbeat 
response. This is
+  // separate from `registered`, which gates RPC serving and stays true across 
a re-registration
+  // so that clients can keep fetching data this worker still holds.
+  val registeredInMasterView = new AtomicBoolean(true)
+
+  // Whether `initialize()` finished wiring up the push/fetch/replicate 
handlers and the
+  // controller endpoint. Registration alone is not enough to serve traffic, 
see the health check.
+  val initialized = new AtomicBoolean(false)
   val shuffleMapperAttempts: ConcurrentHashMap[String, AtomicIntegerArray] =
     JavaUtils.newConcurrentHashMap[String, AtomicIntegerArray]()
   val shufflePartitionType: ConcurrentHashMap[String, PartitionType] =
@@ -537,6 +546,10 @@ private[celeborn] class Worker(
         highWorkload,
         workerStatusManager.currentWorkerStatus),
       classOf[HeartbeatFromWorkerResponse])
+    handleHeartbeatResponse(response)
+  }
+
+  private[worker] def handleHeartbeatResponse(response: 
HeartbeatFromWorkerResponse): Unit = {
     response.expiredShuffleKeys.asScala.foreach(shuffleKey => 
workerInfo.releaseSlots(shuffleKey))
     cleanTaskQueue.put(response.expiredShuffleKeys)
 
@@ -544,6 +557,7 @@ private[celeborn] class Worker(
     workerStatusManager.doTransition(workerEvent)
     if (!response.registered) {
       logError("Worker not registered in master, clean expired shuffle data 
and register again.")
+      registeredInMasterView.set(false)
       try {
         registerWithMaster()
       } catch {
@@ -612,6 +626,7 @@ private[celeborn] class Worker(
 
     controller.init(this)
     rpcEnv.setupEndpoint(RpcNameConstants.WORKER_EP, controller)
+    initialized.set(true)
 
     logInfo("Worker started.")
     rpcEnv.awaitTermination()
@@ -724,6 +739,7 @@ private[celeborn] class Worker(
       // Register successfully
       if (null != resp && resp.getSuccess) {
         registered.set(true)
+        registeredInMasterView.set(true)
         logInfo("Register worker successfully.")
         return
       }
@@ -935,6 +951,23 @@ private[celeborn] class Worker(
     sb.toString()
   }
 
+  override def healthCheck(): HandleResponse = {
+    val state = workerStatusManager.currentWorkerStatus.getState
+    if (!initialized.get()) {
+      // The HTTP server starts and registration completes before the 
push/fetch handlers and the
+      // controller endpoint are set up, so a probe in that interval must not 
report healthy.
+      (false, "worker is still initializing")
+    } else if (!registered.get() || !registeredInMasterView.get()) {
+      (false, "worker is not registered with master")
+    } else if (state != State.Normal) {
+      // Only workers in Normal state are selected when the master offers 
slots, see
+      // AbstractMetaManager#isWorkerAvailable.
+      (false, s"worker state is $state instead of ${State.Normal}")
+    } else {
+      (true, "")
+    }
+  }
+
   override def listPartitionLocationInfo: String = {
     val sb = new StringBuilder
     sb.append("==================== Partition Location Info 
=========================\n")
diff --git 
a/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/WorkerStatusManager.scala
 
b/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/WorkerStatusManager.scala
index e31fcf576a..52dfe075ac 100644
--- 
a/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/WorkerStatusManager.scala
+++ 
b/worker/src/main/scala/org/apache/celeborn/service/deploy/worker/WorkerStatusManager.scala
@@ -34,7 +34,9 @@ import 
org.apache.celeborn.service.deploy.worker.storage.StorageManager
 
 private[celeborn] class WorkerStatusManager(conf: CelebornConf) extends 
Logging {
 
-  var currentWorkerStatus = WorkerStatus.normalWorkerStatus()
+  // Written under this manager's monitor but read without it, for example by 
the /healthz
+  // endpoint on an HTTP thread, so it is published volatile.
+  @volatile var currentWorkerStatus = WorkerStatus.normalWorkerStatus()
   var exitEventType = WorkerEventType.Immediately
   private var worker: Worker = _
   private var shutdown: AtomicBoolean = _
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 e4b26a1c51..7904f6bb71 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
@@ -257,6 +257,9 @@ trait MiniClusterFeature extends Logging with 
RandomPortSupport {
             if (!worker.registered.get()) {
               throw new IllegalStateException(s"worker $i hasn't been 
registered")
             }
+            if (!worker.initialized.get()) {
+              throw new IllegalStateException(s"worker $i hasn't finished 
initialization")
+            }
             if (!workerInfos.contains(worker)) {
               workerInfos.put(worker, threads(i))
             }
diff --git 
a/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/WorkerSuite.scala
 
b/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/WorkerSuite.scala
index 26a1cb1b6d..09c4c78b51 100644
--- 
a/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/WorkerSuite.scala
+++ 
b/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/WorkerSuite.scala
@@ -21,6 +21,7 @@ import java.io.File
 import java.nio.file.{Files, Paths}
 import java.util
 import java.util.{HashSet => JHashSet}
+import java.util.concurrent.atomic.AtomicBoolean
 
 import scala.collection.JavaConverters._
 
@@ -29,6 +30,8 @@ import org.mockito.{ArgumentCaptor, ArgumentMatchers, 
MockedConstruction, Mockit
 import org.mockito.MockedConstruction.MockInitializer
 import org.mockito.Mockito.mockConstruction
 import org.mockito.MockitoSugar._
+import org.mockito.invocation.InvocationOnMock
+import org.mockito.stubbing.Answer
 import org.scalatest.BeforeAndAfterEach
 import org.scalatest.funsuite.AnyFunSuite
 
@@ -36,7 +39,7 @@ import org.apache.celeborn.common.CelebornConf
 import org.apache.celeborn.common.client.MasterClient
 import org.apache.celeborn.common.identity.UserIdentifier
 import org.apache.celeborn.common.protocol._
-import 
org.apache.celeborn.common.protocol.message.ControlMessages.CommitFilesResponse
+import 
org.apache.celeborn.common.protocol.message.ControlMessages.{CommitFilesResponse,
 HeartbeatFromWorkerResponse}
 import org.apache.celeborn.common.protocol.message.StatusCode
 import org.apache.celeborn.common.quota.ResourceConsumption
 import org.apache.celeborn.common.rpc.RpcCallContext
@@ -307,6 +310,50 @@ class WorkerSuite extends AnyFunSuite with 
BeforeAndAfterEach with MiniClusterFe
     assert(epochCommitMap.get(epoch2).response.status == StatusCode.SUCCESS)
   }
 
+  test("CELEBORN-2447: heartbeat reporting not-registered clears the 
master-view flag " +
+    "before re-registering") {
+    conf.set(CelebornConf.WORKER_STORAGE_DIRS.key, "/tmp")
+    // Recorded at the moment the re-registration RPC is issued, so the test 
asserts the
+    // ordering of the flag clear rather than only its final value.
+    val flagWhenReRegistering = new AtomicBoolean(true)
+    val mockInitializer = {
+      // Old syntax needed for scala 2.11
+      new MockInitializer[MasterClient] {
+        override def prepare(instance: MasterClient, context: 
MockedConstruction.Context): Unit = {
+          val answer = new Answer[PbRegisterWorkerResponse] {
+            override def answer(invocation: InvocationOnMock): 
PbRegisterWorkerResponse = {
+              flagWhenReRegistering.set(worker.registeredInMasterView.get())
+              PbRegisterWorkerResponse.newBuilder().setSuccess(true).build()
+            }
+          }
+          Mockito.doAnswer(answer)
+            .when(instance)
+            .askSync(
+              ArgumentMatchers.any(classOf[PbRegisterWorker]),
+              ArgumentMatchers.eq(classOf[PbRegisterWorkerResponse]))
+        }
+      }
+    }
+    val mockedMasterClient = mockConstruction(classOf[MasterClient], 
mockInitializer)
+    try {
+      worker = new Worker(conf, workerArgs)
+      worker.registered.set(true)
+      assert(worker.registeredInMasterView.get())
+
+      // WorkerEventType.None keeps doTransition a no-op; other events spawn 
an exit thread.
+      worker.handleHeartbeatResponse(
+        HeartbeatFromWorkerResponse(new JHashSet[String](), registered = 
false))
+
+      assert(!flagWhenReRegistering.get(), "master-view flag must be cleared 
before re-registering")
+      // `registered` gates RPC serving and must survive the re-registration.
+      assert(worker.registered.get())
+      // Re-registration succeeded, so readiness is restored.
+      assert(worker.registeredInMasterView.get())
+    } finally {
+      mockedMasterClient.close()
+    }
+  }
+
   test("CELEBORN-2257: Properly reports remote disks on worker registration") {
     val mockInitializer = {
       // Old syntax needed for scala 2.11
diff --git 
a/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/http/api/ApiWorkerResourceSuite.scala
 
b/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/http/api/ApiWorkerResourceSuite.scala
index 408e394360..1f8c548ddf 100644
--- 
a/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/http/api/ApiWorkerResourceSuite.scala
+++ 
b/worker/src/test/scala/org/apache/celeborn/service/deploy/worker/http/api/ApiWorkerResourceSuite.scala
@@ -20,8 +20,11 @@ package org.apache.celeborn.service.deploy.worker.http.api
 import javax.servlet.http.HttpServletResponse
 import javax.ws.rs.core.MediaType
 
+import org.apache.celeborn.common.meta.WorkerStatus
+import org.apache.celeborn.common.protocol.PbWorkerStatus.State
 import org.apache.celeborn.server.common.HttpService
 import org.apache.celeborn.server.common.http.ApiBaseResourceSuite
+import org.apache.celeborn.server.common.http.api.HealthCheckResponse
 import org.apache.celeborn.service.deploy.MiniClusterFeature
 import org.apache.celeborn.service.deploy.worker.Worker
 
@@ -43,6 +46,68 @@ class ApiWorkerResourceSuite extends ApiBaseResourceSuite 
with MiniClusterFeatur
     shutdownMiniCluster()
   }
 
+  test("health reports unavailable during the startup interval before 
initialization completes") {
+    // `initialize()` starts the HTTP server and registers with the master 
before the push/fetch
+    // handlers and the controller endpoint are set up. Clearing the flag 
reproduces that interval:
+    // the worker is registered and in Normal state, but cannot serve traffic 
yet.
+    assert(worker.registered.get())
+    assert(worker.registeredInMasterView.get())
+    assert(worker.workerStatusManager.getWorkerState() == State.Normal)
+    worker.initialized.set(false)
+    try {
+      val response = 
webTarget.path("healthz").request(MediaType.APPLICATION_JSON).get()
+      assert(HttpServletResponse.SC_SERVICE_UNAVAILABLE == response.getStatus)
+      val health = response.readEntity(classOf[HealthCheckResponse])
+      assert(!health.healthy)
+      assert(health.reason.contains("still initializing"))
+    } finally {
+      worker.initialized.set(true)
+    }
+  }
+
+  test("health reports unavailable when the worker is not registered") {
+    worker.registered.set(false)
+    try {
+      val response = 
webTarget.path("healthz").request(MediaType.APPLICATION_JSON).get()
+      assert(HttpServletResponse.SC_SERVICE_UNAVAILABLE == response.getStatus)
+      val health = response.readEntity(classOf[HealthCheckResponse])
+      assert(!health.healthy)
+      assert(health.reason.contains("not registered"))
+    } finally {
+      worker.registered.set(true)
+    }
+  }
+
+  test("health reports unavailable when the master no longer knows the 
worker") {
+    worker.registeredInMasterView.set(false)
+    try {
+      val response = 
webTarget.path("healthz").request(MediaType.APPLICATION_JSON).get()
+      assert(HttpServletResponse.SC_SERVICE_UNAVAILABLE == response.getStatus)
+      val health = response.readEntity(classOf[HealthCheckResponse])
+      assert(!health.healthy)
+      assert(health.reason.contains("not registered"))
+    } finally {
+      worker.registeredInMasterView.set(true)
+    }
+  }
+
+  test("health reports unavailable when the worker state is not Normal") {
+    worker.workerStatusManager.transitionState(State.InDecommission)
+    try {
+      assert(worker.workerStatusManager.getWorkerState() == 
State.InDecommission)
+      val response = 
webTarget.path("healthz").request(MediaType.APPLICATION_JSON).get()
+      assert(HttpServletResponse.SC_SERVICE_UNAVAILABLE == response.getStatus)
+      val health = response.readEntity(classOf[HealthCheckResponse])
+      assert(!health.healthy)
+      assert(health.reason.contains(State.InDecommission.toString))
+    } finally {
+      // InDecommission may only transition to Exit, so restore Normal 
directly.
+      val normal = WorkerStatus.normalWorkerStatus()
+      worker.workerStatusManager.currentWorkerStatus = normal
+      worker.workerInfo.setWorkerStatus(normal)
+    }
+  }
+
   test("listPartitionLocationInfo") {
     val response = 
webTarget.path("listPartitionLocationInfo").request(MediaType.TEXT_PLAIN).get()
     assert(HttpServletResponse.SC_OK == response.getStatus)

Reply via email to