This is an automated email from the ASF dual-hosted git repository.

zaynt4606 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 d8a4c71c50 [CELEBORN-2429][CIP-23] Batch worker/application heartbeats 
into aggregated raft log entries
d8a4c71c50 is described below

commit d8a4c71c50a46c62b3ab6b667f689d8b4f27e284
Author: yew1eb <[email protected]>
AuthorDate: Sun Oct 25 17:16:20 2026 +0800

    [CELEBORN-2429][CIP-23] Batch worker/application heartbeats into aggregated 
raft log entries
    
    ### What changes were proposed in this pull request?
    In HA mode, aggregate worker/application heartbeats on the master leader  
over a short time window (default 1s) and replicate them as ONE 
`BatchHeartbeat` raft log entry, instead of one entry per heartbeat.
    - New `HeartbeatAggregator` on the leader: pending heartbeats are kept in 
per-worker/per-app maps. Worker snapshots keep the latest heartbeat; 
application heartbeat deltas (written bytes, file/shuffle/application counts, 
and fallback counts) are accumulated, while timestamps, gauges, and metric 
labels keep the latest snapshot. A scheduled thread flushes one batch per 
window; heartbeat RPCs are enqueued asynchronously and replied immediately from 
leader-local memory.
    - `AppLost`, `WorkerLost`, and `RegisterWorker` serialize submission with 
batch flushing and flush the affected pending heartbeat first, preserving 
ordering with both buffered and already-drained batches. Master shutdown 
performs a best-effort final flush before stopping Raft.
    - New internal protobuf messages `BatchHeartbeatRequest` / 
`PbBatchHeartbeatRequest` (`Type.BatchHeartbeat = 31`); `MetaHandler` expands 
the batch and applies each child heartbeat through the same 
`handleWorkerHeartbeat` / `handleAppHeartbeat` helpers as the single-entry path.
    - Fully additive and off by default:
           `celeborn.master.ha.heartbeat.batch.enabled` (false),
           `celeborn.master.ha.heartbeat.batch.interval` (1s).
    
    Design doc (CIP-23 Batched Heartbeat Raft Writes): 
<https://docs.google.com/document/d/1YXDCjk_kR_5jyRGmUz1DqImjnAtsr7oFEwpYKK1NxAE/edit?usp=sharing>
    
    ### Why are the changes needed?
    Heartbeats are the most frequent metadata writes: A/10 + W/30 raft 
entries/s for A apps and W workers. On one of our production clusters at peak 
(900+ running apps + 200 workers, ~97 heartbeat entries/s), follower apply lag 
(`RatisApplyCompletedIndexDiff`) grows in lockstep with 
`RunningApplicationCount` (~50 -> 1000). A follower master flame graph shows 
the cost is the per-entry raft pipeline (appendEntries ~35%, log flush/fsync 
~20%), not apply compute (~7.7%).
    <img width="1942" height="701" alt="image" 
src="https://github.com/user-attachments/assets/7712bfea-9008-4e67-8423-528e1351298b";
 />
    
    <img width="1732" height="902" alt="image" 
src="https://github.com/user-attachments/assets/feb1834d-3668-49f9-ae90-639bd909e04d";
 />
    
    Heartbeat liveness and worker snapshots are refreshed periodically, and 
replies are built from leader-local memory. Batching avoids a synchronous Raft 
commit for each heartbeat and can cut Raft write volume by ~100x at the 
reported peak workload.
    
    Known trade-offs when batching is enabled:
    
    - Heartbeat replies do not wait for replication. If a batch is lost during 
failover or submission fails, liveness/snapshot state can converge on later 
heartbeats, but delta counters in the lost batch are not replayed.
    - A lost or timed-out response can cause the client to retry the same 
heartbeat with the same request ID. Batched submissions use new Raft request 
IDs, so this path does not preserve the original heartbeat retry deduplication. 
Repeated deltas can overcount cumulative shuffle/application/fallback metrics 
and temporarily bias partition-size estimation. Cumulative metric overcounts do 
not automatically self-correct; partition-size inputs are reset each estimation 
cycle. This is an accept [...]
    
    JIRA: https://issues.apache.org/jira/browse/CELEBORN-2429
    
    ### Does this PR resolve a correctness bug?
     - [ ] Yes
    
    ### Does this PR introduce _any_ user-facing change?
     - [x] Yes
    
    Adds the two configuration options above. Existing synchronous heartbeat 
submission remains the default.
    
    ### How was this patch tested?
    - `HeartbeatAggregatorSuiteJ` (single-node Raft, full offer → submit → 
replicate → apply chain): verifies aggregation, empty windows, application 
delta accumulation with the latest timestamp, and ordering before `AppLost` / 
`WorkerLost` / `RegisterWorker`. All 5 tests passed in JDK 17 CI on `39542082`.
    - `MasterStateMachineSuiteJ#testBatchHeartbeat`: batched entry applies 
correctly and survives the `ResourceRequest` → `PbMetaRequest` wire round-trip.
    - `MasterStateMachineSuiteJ`: all 6 tests passed in the same JDK 17 CI run. 
All 109 PR checks passed on `39542082`.
    
    Closes #3810 from yew1eb/CELEBORN-2429.
    
    Authored-by: yew1eb <[email protected]>
    Signed-off-by: zhengtao <[email protected]>
    
    Co-Authored-By: Codex <[email protected]>
    AI-Model: gpt-6
    AI-Contributed/Feature: 0/553
    AI-Contributed/UT: 0/434
---
 common/src/main/proto/TransportMessages.proto      |   7 +
 .../org/apache/celeborn/common/CelebornConf.scala  |  26 ++
 docs/configuration/ha.md                           |   2 +
 .../master/clustermeta/ha/HAMasterMetaManager.java | 115 +++++---
 .../master/clustermeta/ha/HeartbeatAggregator.java | 248 ++++++++++++++++
 .../deploy/master/clustermeta/ha/MetaHandler.java  | 138 +++++----
 master/src/main/proto/Resource.proto               |   8 +
 .../celeborn/service/deploy/master/Master.scala    |   9 +
 .../clustermeta/ha/HeartbeatAggregatorSuiteJ.java  | 321 +++++++++++++++++++++
 .../clustermeta/ha/MasterStateMachineSuiteJ.java   | 110 +++++++
 .../master/clustermeta/ha/RatisBaseSuiteJ.java     |   3 +-
 11 files changed, 891 insertions(+), 96 deletions(-)

diff --git a/common/src/main/proto/TransportMessages.proto 
b/common/src/main/proto/TransportMessages.proto
index dd671914a0..b7b04d6cf2 100644
--- a/common/src/main/proto/TransportMessages.proto
+++ b/common/src/main/proto/TransportMessages.proto
@@ -490,6 +490,11 @@ message PbMetaBatchUnregisterShuffles {
   repeated string shuffleKeys = 1;
 }
 
+message PbBatchHeartbeatRequest {
+  repeated PbMetaWorkerHeartbeatRequest workerHeartbeats = 1;
+  repeated PbMetaAppHeartbeatRequest appHeartbeats = 2;
+}
+
 message PbUnregisterShuffleResponse {
   int32 status = 1;
 }
@@ -1036,6 +1041,7 @@ enum PbMetaRequestType {
   BatchUnRegisterShuffle = 28;
   ReviseLostShuffles = 29;
   RegisterApplicationInfo = 30;
+  BatchHeartbeat = 31;
 }
 
 message PbMetaRequest {
@@ -1057,6 +1063,7 @@ message PbMetaRequest {
   PbReportWorkerDecommission reportWorkerDecommissionRequest = 24;
   PbMetaBatchUnregisterShuffles batchUnregisterShuffleRequest = 25;
   PbRegisterApplicationInfo registerApplicationInfoRequest = 26;
+  PbBatchHeartbeatRequest batchHeartbeatRequest = 27;
   PbReviseLostShuffles reviseLostShufflesRequest = 102;
 }
 
diff --git 
a/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala 
b/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
index 5f535fb344..c1dc8aed87 100644
--- a/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
+++ b/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
@@ -822,6 +822,8 @@ class CelebornConf(loadDefaults: Boolean) extends Cloneable 
with Logging with Se
   def haMasterRatisSnapshotAutoTriggerThreshold: Long =
     get(HA_MASTER_RATIS_SNAPSHOT_AUTO_TRIGGER_THRESHOLD)
   def haMasterRatisSnapshotRetentionFileNum: Int = 
get(HA_MASTER_RATIS_SNAPSHOT_RETENTION_FILE_NUM)
+  def masterHaHeartbeatBatchEnabled: Boolean = 
get(MASTER_HA_HEARTBEAT_BATCH_ENABLED)
+  def masterHaHeartbeatBatchIntervalMs: Long = 
get(MASTER_HA_HEARTBEAT_BATCH_INTERVAL)
 
   def masterPersistWorkerNetworkLocation: Boolean = 
get(MASTER_PERSIST_WORKER_NETWORK_LOCATION)
   def haRatisCustomConfigs: JMap[String, String] = {
@@ -3138,6 +3140,30 @@ object CelebornConf extends Logging {
       .intConf
       .createWithDefault(3)
 
+  val MASTER_HA_HEARTBEAT_BATCH_ENABLED: ConfigEntry[Boolean] =
+    buildConf("celeborn.master.ha.heartbeat.batch.enabled")
+      .categories("ha")
+      .version("1.0.0")
+      .doc(
+        "Whether to aggregate worker/app heartbeats on the raft leader and 
flush them " +
+          "periodically as a single BatchHeartbeat raft log entry, reducing 
raft log entries, " +
+          "fsyncs and state machine applies by roughly the number of 
heartbeats per flush window.")
+      .booleanConf
+      .createWithDefault(false)
+
+  val MASTER_HA_HEARTBEAT_BATCH_INTERVAL: ConfigEntry[Long] =
+    buildConf("celeborn.master.ha.heartbeat.batch.interval")
+      .categories("ha")
+      .version("1.0.0")
+      .doc(
+        "The interval at which aggregated heartbeats are flushed as a single 
raft log entry. " +
+          "Heartbeat replies do not wait for raft replication when batching is 
enabled, so a " +
+          "larger interval only means the leader may lose at most one interval 
of heartbeat " +
+          "metadata on failover, which is negligible compared to the 
worker/app heartbeat " +
+          "timeouts (120s/300s by default).")
+      .timeConf(TimeUnit.MILLISECONDS)
+      .createWithDefaultString("1s")
+
   val MASTER_PERSIST_WORKER_NETWORK_LOCATION: ConfigEntry[Boolean] =
     buildConf("celeborn.master.persist.workerNetworkLocation")
       .categories("master")
diff --git a/docs/configuration/ha.md b/docs/configuration/ha.md
index 51007ed055..bac7348775 100644
--- a/docs/configuration/ha.md
+++ b/docs/configuration/ha.md
@@ -22,6 +22,8 @@ license: |
 | celeborn.master.ha.enabled | false | false | When true, master nodes run as 
Raft cluster mode. | 0.3.0 | celeborn.ha.enabled | 
 | celeborn.master.ha.graceful.shutdown.enabled | false | false | When true, 
the master will transfer Raft leadership before shutting down gracefully. This 
reduces chances of client side failures by avoiding the Raft election window 
where no leader is available. | 0.7.0 |  | 
 | celeborn.master.ha.graceful.shutdown.timeout | 30s | false | Timeout for the 
master graceful shutdown process including Raft leadership transfer. Used as 
the shutdown hook timeout and the transfer-leadership request timeout. | 0.7.0 
|  | 
+| celeborn.master.ha.heartbeat.batch.enabled | false | false | Whether to 
aggregate worker/app heartbeats on the raft leader and flush them periodically 
as a single BatchHeartbeat raft log entry, reducing raft log entries, fsyncs 
and state machine applies by roughly the number of heartbeats per flush window. 
| 1.0.0 |  | 
+| celeborn.master.ha.heartbeat.batch.interval | 1s | false | The interval at 
which aggregated heartbeats are flushed as a single raft log entry. Heartbeat 
replies do not wait for raft replication when batching is enabled, so a larger 
interval only means the leader may lose at most one interval of heartbeat 
metadata on failover, which is negligible compared to the worker/app heartbeat 
timeouts (120s/300s by default). | 1.0.0 |  | 
 | celeborn.master.ha.node.&lt;id&gt;.host | &lt;required&gt; | false | Host to 
bind of master node <id> in HA mode. | 0.3.0 | 
celeborn.ha.master.node.&lt;id&gt;.host | 
 | celeborn.master.ha.node.&lt;id&gt;.internal.port | 8097 | false | Internal 
port for the workers and other masters to bind to a master node <id> in HA 
mode. | 0.5.0 |  | 
 | celeborn.master.ha.node.&lt;id&gt;.port | 9097 | false | Port to bind of 
master node <id> in HA mode. | 0.3.0 | celeborn.ha.master.node.&lt;id&gt;.port 
| 
diff --git 
a/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HAMasterMetaManager.java
 
b/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HAMasterMetaManager.java
index 55f717d16f..5631467257 100644
--- 
a/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HAMasterMetaManager.java
+++ 
b/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HAMasterMetaManager.java
@@ -47,6 +47,8 @@ public class HAMasterMetaManager extends AbstractMetaManager {
 
   protected HARaftServer ratisServer;
 
+  private HeartbeatAggregator heartbeatAggregator;
+
   public HAMasterMetaManager(RpcEnv rpcEnv, CelebornConf conf) {
     this(rpcEnv, conf, new CelebornRackResolver(conf));
   }
@@ -78,6 +80,13 @@ public class HAMasterMetaManager extends AbstractMetaManager 
{
 
   public void setRatisServer(HARaftServer ratisServer) {
     this.ratisServer = ratisServer;
+    if (conf.masterHaHeartbeatBatchEnabled()) {
+      this.heartbeatAggregator = new HeartbeatAggregator(ratisServer, conf);
+    }
+  }
+
+  public HeartbeatAggregator getHeartbeatAggregator() {
+    return heartbeatAggregator;
   }
 
   @Override
@@ -180,24 +189,29 @@ public class HAMasterMetaManager extends 
AbstractMetaManager {
       Map<String, Long> clientGauges,
       Map<String, String> clientMetricLabels,
       String requestId) {
+    ResourceProtos.AppHeartbeatRequest appHeartbeatRequest =
+        ResourceProtos.AppHeartbeatRequest.newBuilder()
+            .setAppId(appId)
+            .setTime(time)
+            .setTotalWritten(totalWritten)
+            .setFileCount(fileCount)
+            .setShuffleCount(shuffleCount)
+            .setApplicationCount(applicationCount)
+            .putAllShuffleFallbackCounts(shuffleFallbackCounts)
+            .putAllApplicationFallbackCounts(applicationFallbackCounts)
+            .putAllClientGauges(clientGauges)
+            .putAllClientMetricLabels(clientMetricLabels)
+            .build();
+    if (heartbeatAggregator != null) {
+      heartbeatAggregator.offerAppHeartbeat(appHeartbeatRequest);
+      return;
+    }
     try {
       ratisServer.submitRequest(
           ResourceRequest.newBuilder()
               .setCmdType(Type.AppHeartbeat)
               .setRequestId(requestId)
-              .setAppHeartbeatRequest(
-                  ResourceProtos.AppHeartbeatRequest.newBuilder()
-                      .setAppId(appId)
-                      .setTime(time)
-                      .setTotalWritten(totalWritten)
-                      .setFileCount(fileCount)
-                      .setShuffleCount(shuffleCount)
-                      .setApplicationCount(applicationCount)
-                      .putAllShuffleFallbackCounts(shuffleFallbackCounts)
-                      
.putAllApplicationFallbackCounts(applicationFallbackCounts)
-                      .putAllClientGauges(clientGauges)
-                      .putAllClientMetricLabels(clientMetricLabels)
-                      .build())
+              .setAppHeartbeatRequest(appHeartbeatRequest)
               .build());
     } catch (CelebornRuntimeException e) {
       LOG.error("Handle heartbeat for {} failed!", appId, e);
@@ -207,13 +221,19 @@ public class HAMasterMetaManager extends 
AbstractMetaManager {
 
   @Override
   public void handleAppLost(String appId, String requestId) {
+    ResourceRequest request =
+        ResourceRequest.newBuilder()
+            .setCmdType(Type.AppLost)
+            .setRequestId(requestId)
+            
.setAppLostRequest(ResourceProtos.AppLostRequest.newBuilder().setAppId(appId).build())
+            .build();
     try {
-      ratisServer.submitRequest(
-          ResourceRequest.newBuilder()
-              .setCmdType(Type.AppLost)
-              .setRequestId(requestId)
-              
.setAppLostRequest(ResourceProtos.AppLostRequest.newBuilder().setAppId(appId).build())
-              .build());
+      if (heartbeatAggregator != null) {
+        // Flush any pending heartbeat of this app first, so it is not applied 
after AppLost.
+        heartbeatAggregator.submitAppLifecycle(appId, request);
+      } else {
+        ratisServer.submitRequest(request);
+      }
     } catch (CelebornRuntimeException e) {
       LOG.error("Handle app lost for {} failed!", appId, e);
       throw e;
@@ -273,7 +293,7 @@ public class HAMasterMetaManager extends 
AbstractMetaManager {
   public void handleWorkerLost(
       String host, int rpcPort, int pushPort, int fetchPort, int 
replicatePort, String requestId) {
     try {
-      ratisServer.submitRequest(
+      ResourceRequest request =
           ResourceRequest.newBuilder()
               .setCmdType(Type.WorkerLost)
               .setRequestId(requestId)
@@ -285,7 +305,16 @@ public class HAMasterMetaManager extends 
AbstractMetaManager {
                       .setFetchPort(fetchPort)
                       .setReplicatePort(replicatePort)
                       .build())
-              .build());
+              .build();
+      if (heartbeatAggregator != null) {
+        // Flush any pending heartbeat of this worker first, so it is not 
applied after
+        // WorkerLost.
+        heartbeatAggregator.submitWorkerLifecycle(
+            HeartbeatAggregator.workerKey(host, rpcPort, pushPort, fetchPort, 
replicatePort),
+            request);
+      } else {
+        ratisServer.submitRequest(request);
+      }
     } catch (CelebornRuntimeException e) {
       LOG.error("Handle worker lost for {} failed!", host, e);
       throw e;
@@ -326,23 +355,30 @@ public class HAMasterMetaManager extends 
AbstractMetaManager {
       boolean highWorkload,
       WorkerStatus workerStatus,
       String requestId) {
+    ResourceProtos.WorkerHeartbeatRequest workerHeartbeatRequest =
+        ResourceProtos.WorkerHeartbeatRequest.newBuilder()
+            .setHost(host)
+            .setRpcPort(rpcPort)
+            .setPushPort(pushPort)
+            .setFetchPort(fetchPort)
+            .setReplicatePort(replicatePort)
+            .putAllDisks(MetaUtil.toPbDiskInfos(disks))
+            .setWorkerStatus(MetaUtil.toPbWorkerStatus(workerStatus))
+            .setTime(time)
+            .setHighWorkload(highWorkload)
+            .build();
+    if (heartbeatAggregator != null) {
+      heartbeatAggregator.offerWorkerHeartbeat(workerHeartbeatRequest);
+      updateWorkerResourceConsumptions(
+          host, rpcPort, pushPort, fetchPort, replicatePort, 
userResourceConsumption);
+      return;
+    }
     try {
       ratisServer.submitRequest(
           ResourceRequest.newBuilder()
               .setCmdType(Type.WorkerHeartbeat)
               .setRequestId(requestId)
-              .setWorkerHeartbeatRequest(
-                  ResourceProtos.WorkerHeartbeatRequest.newBuilder()
-                      .setHost(host)
-                      .setRpcPort(rpcPort)
-                      .setPushPort(pushPort)
-                      .setFetchPort(fetchPort)
-                      .setReplicatePort(replicatePort)
-                      .putAllDisks(MetaUtil.toPbDiskInfos(disks))
-                      .setWorkerStatus(MetaUtil.toPbWorkerStatus(workerStatus))
-                      .setTime(time)
-                      .setHighWorkload(highWorkload)
-                      .build())
+              .setWorkerHeartbeatRequest(workerHeartbeatRequest)
               .build());
       updateWorkerResourceConsumptions(
           host, rpcPort, pushPort, fetchPort, replicatePort, 
userResourceConsumption);
@@ -365,7 +401,7 @@ public class HAMasterMetaManager extends 
AbstractMetaManager {
       Map<UserIdentifier, ResourceConsumption> userResourceConsumption,
       String requestId) {
     try {
-      ratisServer.submitRequest(
+      ResourceRequest request =
           ResourceRequest.newBuilder()
               .setCmdType(Type.RegisterWorker)
               .setRequestId(requestId)
@@ -380,7 +416,16 @@ public class HAMasterMetaManager extends 
AbstractMetaManager {
                       .setNetworkLocation(networkLocation)
                       .putAllDisks(MetaUtil.toPbDiskInfos(disks))
                       .build())
-              .build());
+              .build();
+      if (heartbeatAggregator != null) {
+        // Flush any pending heartbeat of this worker first, so a stale 
heartbeat is not
+        // applied after the fresh registration.
+        heartbeatAggregator.submitWorkerLifecycle(
+            HeartbeatAggregator.workerKey(host, rpcPort, pushPort, fetchPort, 
replicatePort),
+            request);
+      } else {
+        ratisServer.submitRequest(request);
+      }
       updateWorkerResourceConsumptions(
           host, rpcPort, pushPort, fetchPort, replicatePort, 
userResourceConsumption);
     } catch (CelebornRuntimeException e) {
diff --git 
a/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HeartbeatAggregator.java
 
b/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HeartbeatAggregator.java
new file mode 100644
index 0000000000..1f45db8bf4
--- /dev/null
+++ 
b/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HeartbeatAggregator.java
@@ -0,0 +1,248 @@
+/*
+ * 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.service.deploy.master.clustermeta.ha;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.celeborn.common.CelebornConf;
+import org.apache.celeborn.common.client.MasterClient;
+import org.apache.celeborn.common.util.ThreadUtils;
+import org.apache.celeborn.service.deploy.master.clustermeta.ResourceProtos;
+import 
org.apache.celeborn.service.deploy.master.clustermeta.ResourceProtos.ResourceRequest;
+import 
org.apache.celeborn.service.deploy.master.clustermeta.ResourceProtos.Type;
+
+public class HeartbeatAggregator {
+  private static final Logger LOG = 
LoggerFactory.getLogger(HeartbeatAggregator.class);
+
+  private final HARaftServer ratisServer;
+  private final long batchIntervalMs;
+
+  private final ReentrantLock pendingLock = new ReentrantLock();
+  private final Map<String, ResourceProtos.WorkerHeartbeatRequest> 
workerHeartbeats =
+      new HashMap<>();
+  private final Map<String, ResourceProtos.AppHeartbeatRequest> appHeartbeats 
= new HashMap<>();
+
+  // Serializes raft submissions so a lifecycle request is never applied 
before heartbeats
+  // accepted earlier.
+  private final ReentrantLock submissionLock = new ReentrantLock();
+
+  private final ScheduledExecutorService flushExecutor;
+
+  public HeartbeatAggregator(HARaftServer ratisServer, CelebornConf conf) {
+    this.ratisServer = ratisServer;
+    this.batchIntervalMs = conf.masterHaHeartbeatBatchIntervalMs();
+    this.flushExecutor =
+        
ThreadUtils.newDaemonSingleThreadScheduledExecutor("master-heartbeat-aggregator");
+    this.flushExecutor.scheduleWithFixedDelay(
+        this::flushSafely, batchIntervalMs, batchIntervalMs, 
TimeUnit.MILLISECONDS);
+    LOG.info("HeartbeatAggregator started, flush interval {} ms.", 
batchIntervalMs);
+  }
+
+  public void offerWorkerHeartbeat(ResourceProtos.WorkerHeartbeatRequest 
heartbeat) {
+    pendingLock.lock();
+    try {
+      workerHeartbeats.put(
+          workerKey(
+              heartbeat.getHost(),
+              heartbeat.getRpcPort(),
+              heartbeat.getPushPort(),
+              heartbeat.getFetchPort(),
+              heartbeat.getReplicatePort()),
+          heartbeat);
+    } finally {
+      pendingLock.unlock();
+    }
+  }
+
+  public void offerAppHeartbeat(ResourceProtos.AppHeartbeatRequest heartbeat) {
+    pendingLock.lock();
+    try {
+      appHeartbeats.merge(heartbeat.getAppId(), heartbeat, 
HeartbeatAggregator::mergeAppHeartbeat);
+    } finally {
+      pendingLock.unlock();
+    }
+  }
+
+  // Counter fields are client-side deltas and must be accumulated when 
coalescing; time and
+  // client gauges are snapshots where the newest value wins.
+  private static ResourceProtos.AppHeartbeatRequest mergeAppHeartbeat(
+      ResourceProtos.AppHeartbeatRequest pending, 
ResourceProtos.AppHeartbeatRequest latest) {
+    return latest
+        .toBuilder()
+        .setTotalWritten(pending.getTotalWritten() + latest.getTotalWritten())
+        .setFileCount(pending.getFileCount() + latest.getFileCount())
+        .setShuffleCount(pending.getShuffleCount() + latest.getShuffleCount())
+        .setApplicationCount(pending.getApplicationCount() + 
latest.getApplicationCount())
+        .putAllShuffleFallbackCounts(
+            mergeCounts(
+                pending.getShuffleFallbackCountsMap(), 
latest.getShuffleFallbackCountsMap()))
+        .putAllApplicationFallbackCounts(
+            mergeCounts(
+                pending.getApplicationFallbackCountsMap(),
+                latest.getApplicationFallbackCountsMap()))
+        .build();
+  }
+
+  private static Map<String, Long> mergeCounts(Map<String, Long> a, 
Map<String, Long> b) {
+    if (a.isEmpty()) {
+      return b;
+    }
+    if (b.isEmpty()) {
+      return a;
+    }
+    Map<String, Long> merged = new HashMap<>(a);
+    b.forEach((key, value) -> merged.merge(key, value, Long::sum));
+    return merged;
+  }
+
+  static String workerKey(
+      String host, int rpcPort, int pushPort, int fetchPort, int 
replicatePort) {
+    return host + ":" + rpcPort + ":" + pushPort + ":" + fetchPort + ":" + 
replicatePort;
+  }
+
+  public void stop() {
+    flushSafely();
+    flushExecutor.shutdownNow();
+  }
+
+  private void flushSafely() {
+    try {
+      flush();
+    } catch (Throwable t) {
+      // Dropped batches self-heal next interval.
+      LOG.error("Failed to flush aggregated heartbeats, dropping this batch.", 
t);
+    }
+  }
+
+  private void flush() {
+    submissionLock.lock();
+    try {
+      List<ResourceProtos.WorkerHeartbeatRequest> drainedWorkers;
+      List<ResourceProtos.AppHeartbeatRequest> drainedApps;
+      pendingLock.lock();
+      try {
+        if (workerHeartbeats.isEmpty() && appHeartbeats.isEmpty()) {
+          return;
+        }
+        drainedWorkers = new ArrayList<>(workerHeartbeats.values());
+        workerHeartbeats.clear();
+        drainedApps = new ArrayList<>(appHeartbeats.values());
+        appHeartbeats.clear();
+      } finally {
+        pendingLock.unlock();
+      }
+      submitBatch(drainedWorkers, drainedApps);
+    } finally {
+      submissionLock.unlock();
+    }
+  }
+
+  /** AppLost etc.: flush the app's pending heartbeat before submitting. */
+  public void submitAppLifecycle(String appId, ResourceRequest 
lifecycleRequest) {
+    submitWithOrdering(Collections.singletonList(appId), 
Collections.emptyList(), lifecycleRequest);
+  }
+
+  /** WorkerLost/RegisterWorker etc.: flush the worker's pending heartbeat 
before submitting. */
+  public void submitWorkerLifecycle(String workerKey, ResourceRequest 
lifecycleRequest) {
+    submitWithOrdering(
+        Collections.emptyList(), Collections.singletonList(workerKey), 
lifecycleRequest);
+  }
+
+  // Flushes pending heartbeats of the affected apps/workers first, then 
submits the lifecycle
+  // request, all under submissionLock so earlier accepted heartbeats apply 
first.
+  private void submitWithOrdering(
+      Collection<String> appIds, Collection<String> workerKeys, 
ResourceRequest lifecycleRequest) {
+    submissionLock.lock();
+    try {
+      List<ResourceProtos.WorkerHeartbeatRequest> drainedWorkers = new 
ArrayList<>();
+      List<ResourceProtos.AppHeartbeatRequest> drainedApps = new ArrayList<>();
+      pendingLock.lock();
+      try {
+        for (String appId : appIds) {
+          ResourceProtos.AppHeartbeatRequest heartbeat = 
appHeartbeats.remove(appId);
+          if (heartbeat != null) {
+            drainedApps.add(heartbeat);
+          }
+        }
+        for (String workerKey : workerKeys) {
+          ResourceProtos.WorkerHeartbeatRequest heartbeat = 
workerHeartbeats.remove(workerKey);
+          if (heartbeat != null) {
+            drainedWorkers.add(heartbeat);
+          }
+        }
+      } finally {
+        pendingLock.unlock();
+      }
+      if (!drainedWorkers.isEmpty() || !drainedApps.isEmpty()) {
+        submitBatch(drainedWorkers, drainedApps);
+      }
+      ratisServer.submitRequest(lifecycleRequest);
+    } finally {
+      submissionLock.unlock();
+    }
+  }
+
+  // Caller must hold submissionLock.
+  private void submitBatch(
+      List<ResourceProtos.WorkerHeartbeatRequest> drainedWorkers,
+      List<ResourceProtos.AppHeartbeatRequest> drainedApps) {
+    if (!ratisServer.isLeader()) {
+      return;
+    }
+
+    ResourceRequest batchRequest =
+        ResourceRequest.newBuilder()
+            .setCmdType(Type.BatchHeartbeat)
+            .setRequestId(MasterClient.genRequestId())
+            .setBatchHeartbeatRequest(
+                ResourceProtos.BatchHeartbeatRequest.newBuilder()
+                    .addAllWorkerHeartbeats(drainedWorkers)
+                    .addAllAppHeartbeats(drainedApps)
+                    .build())
+            .build();
+    long startNs = System.nanoTime();
+    ratisServer.submitRequest(batchRequest);
+    long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
startNs);
+    if (elapsedMs > batchIntervalMs) {
+      LOG.warn(
+          "Submitting aggregated heartbeats ({} worker, {} app) took {} ms; 
raft commits are "
+              + "slower than the flush interval {} ms.",
+          drainedWorkers.size(),
+          drainedApps.size(),
+          elapsedMs,
+          batchIntervalMs);
+    }
+    if (LOG.isDebugEnabled()) {
+      LOG.debug(
+          "Flushed aggregated heartbeats, {} worker heartbeats, {} app 
heartbeats.",
+          drainedWorkers.size(),
+          drainedApps.size());
+    }
+  }
+}
diff --git 
a/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/MetaHandler.java
 
b/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/MetaHandler.java
index 834b201086..825047c0b2 100644
--- 
a/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/MetaHandler.java
+++ 
b/master/src/main/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/MetaHandler.java
@@ -156,34 +156,24 @@ public class MetaHandler {
           break;
 
         case AppHeartbeat:
-          appId = request.getAppHeartbeatRequest().getAppId();
-          long time = request.getAppHeartbeatRequest().getTime();
-          long totalWritten = 
request.getAppHeartbeatRequest().getTotalWritten();
-          long fileCount = request.getAppHeartbeatRequest().getFileCount();
-          long shuffleCount = 
request.getAppHeartbeatRequest().getShuffleCount();
-          long applicationCount = 
request.getAppHeartbeatRequest().getApplicationCount();
-          LOG.debug("Handle app heartbeat for {} with shuffle count {}", 
appId, shuffleCount);
-          Map<String, Long> shuffleFallbackCounts =
-              request.getAppHeartbeatRequest().getShuffleFallbackCountsMap();
-          if (CollectionUtils.isNotEmpty(shuffleFallbackCounts)) {
-            LOG.warn(
-                "{} shuffle fallbacks in app {}",
-                shuffleFallbackCounts.values().stream().mapToLong(v -> 
v).sum(),
-                appId);
+          handleAppHeartbeat(request.getAppHeartbeatRequest());
+          break;
+
+        case BatchHeartbeat:
+          List<PbMetaWorkerHeartbeatRequest> workerHeartbeats =
+              request.getBatchHeartbeatRequest().getWorkerHeartbeatsList();
+          List<PbMetaAppHeartbeatRequest> appHeartbeats =
+              request.getBatchHeartbeatRequest().getAppHeartbeatsList();
+          LOG.debug(
+              "Handle batch heartbeat with {} worker heartbeats and {} app 
heartbeats.",
+              workerHeartbeats.size(),
+              appHeartbeats.size());
+          for (PbMetaWorkerHeartbeatRequest workerHeartbeat : 
workerHeartbeats) {
+            handleWorkerHeartbeat(workerHeartbeat);
+          }
+          for (PbMetaAppHeartbeatRequest appHeartbeat : appHeartbeats) {
+            handleAppHeartbeat(appHeartbeat);
           }
-          Map<String, Long> applicationFallbackCounts =
-              
request.getAppHeartbeatRequest().getApplicationFallbackCountsMap();
-          metaSystem.updateAppHeartbeatMeta(
-              appId,
-              time,
-              totalWritten,
-              fileCount,
-              shuffleCount,
-              applicationCount,
-              shuffleFallbackCounts,
-              applicationFallbackCounts,
-              request.getAppHeartbeatRequest().getClientGaugesMap(),
-              request.getAppHeartbeatRequest().getClientMetricLabelsMap());
           break;
 
         case AppLost:
@@ -215,39 +205,7 @@ public class MetaHandler {
           break;
 
         case WorkerHeartbeat:
-          host = request.getWorkerHeartbeatRequest().getHost();
-          rpcPort = request.getWorkerHeartbeatRequest().getRpcPort();
-          pushPort = request.getWorkerHeartbeatRequest().getPushPort();
-          fetchPort = request.getWorkerHeartbeatRequest().getFetchPort();
-          Map<String, PbDiskInfo> pbDiskInfoMap = 
request.getWorkerHeartbeatRequest().getDisksMap();
-          diskInfos = MetaUtil.fromPbDiskInfoMap(pbDiskInfoMap);
-          replicatePort = 
request.getWorkerHeartbeatRequest().getReplicatePort();
-          boolean highWorkload = 
request.getWorkerHeartbeatRequest().getHighWorkload();
-          if (request.getWorkerHeartbeatRequest().hasWorkerStatus()) {
-            workerStatus =
-                
MetaUtil.fromPbWorkerStatus(request.getWorkerHeartbeatRequest().getWorkerStatus());
-          } else {
-            workerStatus = WorkerStatus.normalWorkerStatus();
-          }
-
-          LOG.debug(
-              "Handle worker heartbeat for {} {} {} {} {} {}",
-              host,
-              rpcPort,
-              pushPort,
-              fetchPort,
-              replicatePort,
-              diskInfos);
-          metaSystem.updateWorkerHeartbeatMeta(
-              host,
-              rpcPort,
-              pushPort,
-              fetchPort,
-              replicatePort,
-              diskInfos,
-              request.getWorkerHeartbeatRequest().getTime(),
-              workerStatus,
-              highWorkload);
+          handleWorkerHeartbeat(request.getWorkerHeartbeatRequest());
           break;
 
         case RegisterWorker:
@@ -337,6 +295,66 @@ public class MetaHandler {
     return responseBuilder.build();
   }
 
+  private void handleWorkerHeartbeat(PbMetaWorkerHeartbeatRequest 
workerHeartbeat) {
+    String host = workerHeartbeat.getHost();
+    int rpcPort = workerHeartbeat.getRpcPort();
+    int pushPort = workerHeartbeat.getPushPort();
+    int fetchPort = workerHeartbeat.getFetchPort();
+    int replicatePort = workerHeartbeat.getReplicatePort();
+    Map<String, DiskInfo> diskInfos = 
MetaUtil.fromPbDiskInfoMap(workerHeartbeat.getDisksMap());
+    boolean highWorkload = workerHeartbeat.getHighWorkload();
+    WorkerStatus workerStatus;
+    if (workerHeartbeat.hasWorkerStatus()) {
+      workerStatus = 
MetaUtil.fromPbWorkerStatus(workerHeartbeat.getWorkerStatus());
+    } else {
+      workerStatus = WorkerStatus.normalWorkerStatus();
+    }
+
+    LOG.debug(
+        "Handle worker heartbeat for {} {} {} {} {} {}",
+        host,
+        rpcPort,
+        pushPort,
+        fetchPort,
+        replicatePort,
+        diskInfos);
+    metaSystem.updateWorkerHeartbeatMeta(
+        host,
+        rpcPort,
+        pushPort,
+        fetchPort,
+        replicatePort,
+        diskInfos,
+        workerHeartbeat.getTime(),
+        workerStatus,
+        highWorkload);
+  }
+
+  private void handleAppHeartbeat(PbMetaAppHeartbeatRequest appHeartbeat) {
+    String appId = appHeartbeat.getAppId();
+    long shuffleCount = appHeartbeat.getShuffleCount();
+    LOG.debug("Handle app heartbeat for {} with shuffle count {}", appId, 
shuffleCount);
+    Map<String, Long> shuffleFallbackCounts = 
appHeartbeat.getShuffleFallbackCountsMap();
+    if (CollectionUtils.isNotEmpty(shuffleFallbackCounts)) {
+      LOG.warn(
+          "{} shuffle fallbacks in app {}",
+          shuffleFallbackCounts.values().stream().mapToLong(v -> v).sum(),
+          appId);
+    }
+    Map<String, Long> applicationFallbackCounts = 
appHeartbeat.getApplicationFallbackCountsMap();
+    metaSystem.updateAppHeartbeatMeta(
+        appId,
+        appHeartbeat.getTime(),
+        appHeartbeat.getTotalWritten(),
+        appHeartbeat.getFileCount(),
+        shuffleCount,
+        appHeartbeat.getApplicationCount(),
+        shuffleFallbackCounts,
+        applicationFallbackCounts,
+        appHeartbeat.getClientGaugesMap(),
+        appHeartbeat.getClientMetricLabelsMap());
+  }
+
   public void writeToSnapShot(File file) throws IOException {
     try {
       metaSystem.writeMetaInfoToFile(file);
diff --git a/master/src/main/proto/Resource.proto 
b/master/src/main/proto/Resource.proto
index 51eab97715..d3d71d9390 100644
--- a/master/src/main/proto/Resource.proto
+++ b/master/src/main/proto/Resource.proto
@@ -42,6 +42,8 @@ enum Type {
   ReviseLostShuffles = 29;
 
   RegisterApplicationInfo = 30;
+
+  BatchHeartbeat = 31;
 }
 
 enum WorkerEventType {
@@ -74,6 +76,7 @@ message ResourceRequest {
   optional ReportWorkerDecommissionRequest reportWorkerDecommissionRequest = 
24;
   optional BatchUnregisterShuffleRequest batchUnregisterShuffleRequest = 25;
   optional RegisterApplicationInfoRequest registerApplicationInfoRequest = 26;
+  optional BatchHeartbeatRequest batchHeartbeatRequest = 27;
   optional ReviseLostShufflesRequest reviseLostShufflesRequest = 102;
 }
 
@@ -106,6 +109,11 @@ message BatchUnregisterShuffleRequest {
   repeated string shuffleKeys = 1;
 }
 
+message BatchHeartbeatRequest {
+  repeated WorkerHeartbeatRequest workerHeartbeats = 1;
+  repeated AppHeartbeatRequest appHeartbeats = 2;
+}
+
 message HeartbeatInfo {
   string appId = 1;
   int64 totalWritten = 2;
diff --git 
a/master/src/main/scala/org/apache/celeborn/service/deploy/master/Master.scala 
b/master/src/main/scala/org/apache/celeborn/service/deploy/master/Master.scala
index de2540e867..d74a3711d5 100644
--- 
a/master/src/main/scala/org/apache/celeborn/service/deploy/master/Master.scala
+++ 
b/master/src/main/scala/org/apache/celeborn/service/deploy/master/Master.scala
@@ -1709,6 +1709,15 @@ private[celeborn] class Master(
       val transferLeadership = conf.haMasterGracefulShutdownEnabled
       statusSystem match {
         case ha: HAMasterMetaManager =>
+          val heartbeatAggregator = ha.getHeartbeatAggregator
+          if (heartbeatAggregator != null) {
+            try {
+              heartbeatAggregator.stop()
+            } catch {
+              case e: Exception =>
+                logError("Failed to stop heartbeat aggregator during Master 
shutdown.", e)
+            }
+          }
           val ratisServer = ha.getRatisServer
           if (ratisServer != null) {
             try {
diff --git 
a/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HeartbeatAggregatorSuiteJ.java
 
b/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HeartbeatAggregatorSuiteJ.java
new file mode 100644
index 0000000000..276c5cf3b3
--- /dev/null
+++ 
b/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/HeartbeatAggregatorSuiteJ.java
@@ -0,0 +1,321 @@
+/*
+ * 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.service.deploy.master.clustermeta.ha;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.UUID;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import org.apache.celeborn.common.CelebornConf;
+import org.apache.celeborn.common.meta.WorkerInfo;
+import org.apache.celeborn.common.meta.WorkerStatus;
+
+public class HeartbeatAggregatorSuiteJ {
+  private HARaftServer ratisServer;
+  private HAMasterMetaManager metaSystem;
+
+  @Before
+  public void init() throws Exception {
+    CelebornConf conf = new CelebornConf();
+    conf.set(CelebornConf.MASTER_HA_HEARTBEAT_BATCH_ENABLED().key(), "true");
+    // Use a 1s window (not shorter): on oversubscribed CI runners the handler 
calls below can
+    // be scheduled far apart, and a short window would split the offers into 
several batches.
+    conf.set(CelebornConf.MASTER_HA_HEARTBEAT_BATCH_INTERVAL().key(), "1s");
+    metaSystem = new HAMasterMetaManager(null, conf);
+    MetaHandler handler = new MetaHandler(metaSystem);
+    File tmpDir = File.createTempFile("celeborn-ratis-tmp", "for-test-only");
+    tmpDir.delete();
+    tmpDir.mkdirs();
+    conf.set(CelebornConf.HA_MASTER_RATIS_STORAGE_DIR().key(), 
tmpDir.getAbsolutePath());
+    String id = UUID.randomUUID().toString();
+    MasterNode masterNode =
+        new 
MasterNode.Builder().setNodeId(id).setHost("localhost").setRatisPort(9998).build();
+    ratisServer =
+        HARaftServer.newMasterRatisServer(handler, conf, masterNode, 
Collections.emptyList());
+    metaSystem.setRatisServer(ratisServer);
+    ratisServer.start();
+    waitForLeader();
+  }
+
+  private void waitForLeader() throws InterruptedException {
+    // Wait for isLeaderReady(), not just isLeader(): a newly elected leader 
rejects writes
+    // with LeaderNotReadyException until its no-op entry for the new term is 
committed.
+    for (int i = 0; i < 100; i++) {
+      if (ratisServer.isLeader()) {
+        try {
+          if (ratisServer
+              .getServer()
+              .getDivision(ratisServer.getGroupId())
+              .getInfo()
+              .isLeaderReady()) {
+            return;
+          }
+        } catch (IOException e) {
+          // Division not available yet; keep polling.
+        }
+      }
+      Thread.sleep(200);
+    }
+    Assert.fail("Raft server did not become ready leader in time.");
+  }
+
+  @After
+  public void shutdown() {
+    if (metaSystem.getHeartbeatAggregator() != null) {
+      metaSystem.getHeartbeatAggregator().stop();
+    }
+    if (ratisServer != null) {
+      ratisServer.stop();
+    }
+  }
+
+  @Test
+  public void testBatchHeartbeatAggregation() throws Exception {
+    Assert.assertNotNull(metaSystem.getHeartbeatAggregator());
+
+    metaSystem.handleRegisterWorker(
+        "host1",
+        1,
+        2,
+        3,
+        4,
+        5,
+        "networkLocation1",
+        new HashMap<>(),
+        new HashMap<>(),
+        UUID.randomUUID().toString() + "#1");
+    Thread.sleep(2000);
+    Assert.assertEquals(1, metaSystem.workersMap.size());
+    long appliedIndexAfterRegister = lastAppliedIndex();
+
+    // 4 heartbeat offers: 3 for the same worker (collapsed to the newest), 1 
for an app.
+    long time1 = System.currentTimeMillis();
+    long time2 = time1 + 10;
+    metaSystem.handleWorkerHeartbeat(
+        "host1",
+        1,
+        2,
+        3,
+        4,
+        new HashMap<>(),
+        new HashMap<>(),
+        time1,
+        false,
+        WorkerStatus.normalWorkerStatus(),
+        UUID.randomUUID().toString() + "#2");
+    metaSystem.handleWorkerHeartbeat(
+        "host1",
+        1,
+        2,
+        3,
+        4,
+        new HashMap<>(),
+        new HashMap<>(),
+        time1,
+        false,
+        WorkerStatus.normalWorkerStatus(),
+        UUID.randomUUID().toString() + "#3");
+    metaSystem.handleAppHeartbeat(
+        "app-1",
+        100,
+        10,
+        1,
+        1,
+        new HashMap<>(),
+        new HashMap<>(),
+        time2,
+        new HashMap<>(),
+        new HashMap<>(),
+        UUID.randomUUID().toString() + "#4");
+    metaSystem.handleWorkerHeartbeat(
+        "host1",
+        1,
+        2,
+        3,
+        4,
+        new HashMap<>(),
+        new HashMap<>(),
+        time2,
+        false,
+        WorkerStatus.normalWorkerStatus(),
+        UUID.randomUUID().toString() + "#5");
+
+    // Wait comfortably past one 1s flush window plus the blocking 
submit/apply.
+    Thread.sleep(4000);
+
+    // The 4 offers must have been flushed as (far) fewer raft log entries.
+    long newEntries = lastAppliedIndex() - appliedIndexAfterRegister;
+    Assert.assertTrue(
+        "Expected heartbeats to be merged into few raft log entries, but got " 
+ newEntries,
+        newEntries >= 1 && newEntries <= 2);
+
+    // The newest heartbeat wins.
+    WorkerInfo workerInfo = metaSystem.workersMap.values().iterator().next();
+    Assert.assertEquals("host1", workerInfo.host());
+    Assert.assertEquals(time2, workerInfo.lastHeartbeat());
+    Assert.assertEquals(Long.valueOf(time2), 
metaSystem.appHeartbeatTime.get("app-1"));
+  }
+
+  @Test
+  public void testEmptyWindowProducesNoRaftLog() throws Exception {
+    Thread.sleep(2000);
+    long appliedIndex = lastAppliedIndex();
+    Thread.sleep(500);
+    Assert.assertEquals(appliedIndex, lastAppliedIndex());
+  }
+
+  @Test
+  public void testDuplicateAppHeartbeatAccumulatesDeltas() throws Exception {
+    // Duplicate heartbeats of the same app within one window are coalesced: 
delta counters
+    // are accumulated, while snapshots (time, client gauges) keep the newest 
value.
+    java.util.Map<String, Long> fallback1 = new HashMap<>();
+    fallback1.put("shuffle-1", 1L);
+    java.util.Map<String, Long> fallback2 = new HashMap<>();
+    fallback2.put("shuffle-1", 2L);
+    java.util.Map<String, Long> gauges1 = new HashMap<>();
+    gauges1.put("gauge-1", 1L);
+    java.util.Map<String, Long> gauges2 = new HashMap<>();
+    gauges2.put("gauge-1", 2L);
+    long time1 = System.currentTimeMillis();
+    long time2 = time1 + 10;
+    metaSystem.handleAppHeartbeat(
+        "app-merge",
+        100,
+        10,
+        1,
+        1,
+        fallback1,
+        new HashMap<>(),
+        time1,
+        gauges1,
+        new HashMap<>(),
+        UUID.randomUUID().toString() + "#1");
+    metaSystem.handleAppHeartbeat(
+        "app-merge",
+        200,
+        20,
+        2,
+        3,
+        fallback2,
+        new HashMap<>(),
+        time2,
+        gauges2,
+        new HashMap<>(),
+        UUID.randomUUID().toString() + "#2");
+
+    Thread.sleep(4000);
+
+    Assert.assertEquals(300, metaSystem.partitionTotalWritten.sum());
+    Assert.assertEquals(30, metaSystem.partitionTotalFileCount.sum());
+    Assert.assertEquals(3, metaSystem.shuffleTotalCount.sum());
+    Assert.assertEquals(4, metaSystem.applicationTotalCount.sum());
+    Assert.assertEquals(Long.valueOf(3), 
metaSystem.shuffleFallbackCounts.get("shuffle-1"));
+    Assert.assertEquals(Long.valueOf(time2), 
metaSystem.appHeartbeatTime.get("app-merge"));
+  }
+
+  @Test
+  public void testAppLostFlushesPendingAppHeartbeatFirst() throws Exception {
+    // A buffered app heartbeat accepted before AppLost must be applied before 
the AppLost,
+    // so the app is not resurrected by a heartbeat flushed afterwards.
+    long time1 = System.currentTimeMillis();
+    metaSystem.handleAppHeartbeat(
+        "app-lost",
+        100,
+        10,
+        1,
+        1,
+        new HashMap<>(),
+        new HashMap<>(),
+        time1,
+        new HashMap<>(),
+        new HashMap<>(),
+        UUID.randomUUID().toString() + "#1");
+    metaSystem.handleAppLost("app-lost", UUID.randomUUID().toString() + "#2");
+
+    Thread.sleep(2000);
+
+    Assert.assertFalse(metaSystem.appHeartbeatTime.containsKey("app-lost"));
+    // The pending heartbeat is flushed before AppLost, so its delta counters 
are not lost.
+    Assert.assertEquals(100, metaSystem.partitionTotalWritten.sum());
+  }
+
+  @Test
+  public void 
testWorkerLostAndRegisterWorkerFlushPendingWorkerHeartbeatFirst() throws 
Exception {
+    metaSystem.handleRegisterWorker(
+        "host1",
+        1,
+        2,
+        3,
+        4,
+        5,
+        "networkLocation1",
+        new HashMap<>(),
+        new HashMap<>(),
+        UUID.randomUUID().toString() + "#1");
+    Thread.sleep(2000);
+    Assert.assertEquals(1, metaSystem.workersMap.size());
+
+    // A stale buffered heartbeat followed by WorkerLost and RegisterWorker 
for the same
+    // worker: the heartbeat must be applied before the lifecycle requests, so 
it cannot
+    // overwrite the freshly registered worker state.
+    long staleTime = 1000L;
+    metaSystem.handleWorkerHeartbeat(
+        "host1",
+        1,
+        2,
+        3,
+        4,
+        new HashMap<>(),
+        new HashMap<>(),
+        staleTime,
+        false,
+        WorkerStatus.normalWorkerStatus(),
+        UUID.randomUUID().toString() + "#2");
+    metaSystem.handleWorkerLost("host1", 1, 2, 3, 4, 
UUID.randomUUID().toString() + "#3");
+    metaSystem.handleRegisterWorker(
+        "host1",
+        1,
+        2,
+        3,
+        4,
+        5,
+        "networkLocation1",
+        new HashMap<>(),
+        new HashMap<>(),
+        UUID.randomUUID().toString() + "#4");
+
+    Thread.sleep(2000);
+
+    Assert.assertEquals(1, metaSystem.workersMap.size());
+    WorkerInfo workerInfo = metaSystem.workersMap.values().iterator().next();
+    Assert.assertTrue(
+        "Stale heartbeat should not overwrite the registered worker state",
+        workerInfo.lastHeartbeat() > staleTime);
+  }
+
+  private long lastAppliedIndex() {
+    return 
ratisServer.getMasterStateMachine().getLastAppliedTermIndex().getIndex();
+  }
+}
diff --git 
a/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/MasterStateMachineSuiteJ.java
 
b/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/MasterStateMachineSuiteJ.java
index f272153664..bc9b46670b 100644
--- 
a/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/MasterStateMachineSuiteJ.java
+++ 
b/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/MasterStateMachineSuiteJ.java
@@ -581,4 +581,114 @@ public class MasterStateMachineSuiteJ extends 
RatisBaseSuiteJ {
             .getIndex());
     stopRaftServers(raftServers);
   }
+
+  @Test
+  public void testBatchHeartbeat() throws InvalidProtocolBufferException {
+    StateMachine stateMachine = ratisServer.getMasterStateMachine();
+
+    // Register two workers first, heartbeat apply is a no-op for unregistered 
workers.
+    for (int i = 1; i <= 2; i++) {
+      PbMetaRequest registerRequest =
+          PbMetaRequest.newBuilder()
+              .setMetaRequestType(
+                  
org.apache.celeborn.common.protocol.PbMetaRequestType.RegisterWorker)
+              .setRequestId(UUID.randomUUID().toString())
+              .setRegisterWorkerRequest(
+                  
org.apache.celeborn.common.protocol.PbMetaRegisterWorkerRequest.newBuilder()
+                      .setHost("host" + i)
+                      .setRpcPort(1)
+                      .setPushPort(2)
+                      .setFetchPort(3)
+                      .setReplicatePort(4)
+                      .setInternalPort(5)
+                      .build())
+              .build();
+      Assert.assertTrue(stateMachine.runCommand(registerRequest, 
-1).getSuccess());
+    }
+    Assert.assertEquals(2, metaSystem.workersMap.size());
+
+    long heartbeatTime = System.currentTimeMillis();
+    org.apache.celeborn.common.protocol.PbMetaWorkerHeartbeatRequest 
workerHeartbeat1 =
+        
org.apache.celeborn.common.protocol.PbMetaWorkerHeartbeatRequest.newBuilder()
+            .setHost("host1")
+            .setRpcPort(1)
+            .setPushPort(2)
+            .setFetchPort(3)
+            .setReplicatePort(4)
+            .setTime(heartbeatTime)
+            .build();
+    org.apache.celeborn.common.protocol.PbMetaWorkerHeartbeatRequest 
workerHeartbeat2 =
+        
org.apache.celeborn.common.protocol.PbMetaWorkerHeartbeatRequest.newBuilder()
+            .setHost("host2")
+            .setRpcPort(1)
+            .setPushPort(2)
+            .setFetchPort(3)
+            .setReplicatePort(4)
+            .setTime(heartbeatTime)
+            .build();
+    org.apache.celeborn.common.protocol.PbMetaAppHeartbeatRequest appHeartbeat 
=
+        
org.apache.celeborn.common.protocol.PbMetaAppHeartbeatRequest.newBuilder()
+            .setAppId("app-1")
+            .setTime(heartbeatTime)
+            .setTotalWritten(100)
+            .setFileCount(10)
+            .build();
+
+    PbMetaRequest batchRequest =
+        PbMetaRequest.newBuilder()
+            .setMetaRequestType(
+                
org.apache.celeborn.common.protocol.PbMetaRequestType.BatchHeartbeat)
+            .setRequestId(UUID.randomUUID().toString())
+            .setBatchHeartbeatRequest(
+                
org.apache.celeborn.common.protocol.PbBatchHeartbeatRequest.newBuilder()
+                    .addWorkerHeartbeats(workerHeartbeat1)
+                    .addWorkerHeartbeats(workerHeartbeat2)
+                    .addAppHeartbeats(appHeartbeat)
+                    .build())
+            .build();
+
+    PbMetaRequestResponse response = stateMachine.runCommand(batchRequest, -1);
+    Assert.assertTrue(response.getSuccess());
+
+    // Each worker heartbeat must be applied with its own time field (see 
CELEBORN-2399).
+    for (WorkerInfo workerInfo : metaSystem.workersMap.values()) {
+      Assert.assertEquals(heartbeatTime, workerInfo.lastHeartbeat());
+    }
+    Assert.assertEquals(Long.valueOf(heartbeatTime), 
metaSystem.appHeartbeatTime.get("app-1"));
+
+    // The batched log entry written by the leader (ResourceRequest) must stay 
wire-compatible
+    // with the apply side (PbMetaRequest).
+    ResourceProtos.ResourceRequest resourceRequest =
+        ResourceProtos.ResourceRequest.newBuilder()
+            .setCmdType(ResourceProtos.Type.BatchHeartbeat)
+            .setRequestId(UUID.randomUUID().toString())
+            .setBatchHeartbeatRequest(
+                ResourceProtos.BatchHeartbeatRequest.newBuilder()
+                    .addWorkerHeartbeats(
+                        ResourceProtos.WorkerHeartbeatRequest.newBuilder()
+                            .setHost("host1")
+                            .setRpcPort(1)
+                            .setPushPort(2)
+                            .setFetchPort(3)
+                            .setReplicatePort(4)
+                            .setTime(heartbeatTime)
+                            .build())
+                    .addAppHeartbeats(
+                        ResourceProtos.AppHeartbeatRequest.newBuilder()
+                            .setAppId("app-1")
+                            .setTime(heartbeatTime)
+                            .setTotalWritten(100)
+                            .setFileCount(10)
+                            .build())
+                    .build())
+            .build();
+    PbMetaRequest parsed = 
PbMetaRequest.parseFrom(resourceRequest.toByteString());
+    Assert.assertEquals(
+        org.apache.celeborn.common.protocol.PbMetaRequestType.BatchHeartbeat,
+        parsed.getMetaRequestType());
+    Assert.assertEquals(1, 
parsed.getBatchHeartbeatRequest().getWorkerHeartbeatsCount());
+    Assert.assertEquals(1, 
parsed.getBatchHeartbeatRequest().getAppHeartbeatsCount());
+    Assert.assertEquals(
+        heartbeatTime, 
parsed.getBatchHeartbeatRequest().getWorkerHeartbeats(0).getTime());
+  }
 }
diff --git 
a/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/RatisBaseSuiteJ.java
 
b/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/RatisBaseSuiteJ.java
index b727758e4a..3a03c63618 100644
--- 
a/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/RatisBaseSuiteJ.java
+++ 
b/master/src/test/java/org/apache/celeborn/service/deploy/master/clustermeta/ha/RatisBaseSuiteJ.java
@@ -28,11 +28,12 @@ import org.apache.celeborn.common.CelebornConf;
 
 public class RatisBaseSuiteJ {
   HARaftServer ratisServer;
+  HAMasterMetaManager metaSystem;
 
   @Before
   public void init() throws Exception {
     CelebornConf conf = new CelebornConf();
-    HAMasterMetaManager metaSystem = new HAMasterMetaManager(null, conf);
+    metaSystem = new HAMasterMetaManager(null, conf);
     MetaHandler handler = new MetaHandler(metaSystem);
     File tmpDir1 = File.createTempFile("celeborn-ratis-tmp", "for-test-only");
     tmpDir1.delete();

Reply via email to