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


##########
client/src/main/scala/org/apache/celeborn/client/ApplicationHeartbeater.scala:
##########
@@ -85,10 +99,14 @@ class ApplicationHeartbeater(
                 tmpApplicationFallbackCounts.asJava,
                 workerStatusTracker.getNeedCheckedWorkers().toList.asJava,
                 ZERO_UUID,
-                true)
+                true,
+                if (appMetricLabels.isEmpty) 
java.util.Collections.emptyMap[String, ClientMetric]()
+                else clientMetrics(),
+                appMetricLabels)
             val response = requestHeartbeat(appHeartbeat)
             if (response.statusCode == StatusCode.SUCCESS) {
               logDebug("Successfully send app heartbeat.")
+              commitClientMetrics()

Review Comment:
   **[P1] Counter deltas can be double-counted after a lost heartbeat 
response.** `requestHeartbeat` converts a timeout/transport exception into 
`REQUEST_FAILED`, so this commit is skipped. But the master may already have 
applied `updateApplicationMetrics` before its response was lost; the next 
heartbeat then carries the same unacknowledged delta again and the master 
increments it twice. Since these heartbeats also use `ZERO_UUID`, there is no 
snapshot identity to deduplicate. Please retain a sequence/request ID with the 
pending snapshot and have the master ignore an already-applied snapshot (or use 
another protocol that is idempotent across ambiguous timeouts).



##########
client/src/main/scala/org/apache/celeborn/client/CelebornClientSource.scala:
##########
@@ -0,0 +1,92 @@
+/*
+ * 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.client
+
+import java.util.concurrent.ConcurrentHashMap
+
+import scala.collection.JavaConverters._
+
+import org.apache.celeborn.common.CelebornConf
+import org.apache.celeborn.common.metrics.{ClientMetric, MetricType}
+import org.apache.celeborn.common.metrics.source.{AbstractSource, Role}
+
+/**
+ * Metrics source for the Celeborn client
+ */
+class CelebornClientSource(conf: CelebornConf) extends AbstractSource(conf, 
Role.CLIENT) {
+  override val sourceName = "client"
+
+  import CelebornClientSource._
+
+  // Tracks the counter baselines that have been acknowledged by the master, 
so we can send
+  // deltas rather than cumulative counts.
+  private val counterPrev = new ConcurrentHashMap[String, java.lang.Long]()
+
+  // Counter values captured by the most recent getMetricsSnapshot() that have 
not yet been
+  // acknowledged.
+  private val pendingCounterValues = new ConcurrentHashMap[String, 
java.lang.Long]()
+
+  addCounter(REGISTER_SHUFFLE_COUNT)
+  addCounter(REGISTER_SHUFFLE_FAIL_COUNT)
+  addCounter(UNREGISTER_SHUFFLE_COUNT)
+  addCounter(REVIVE_REQUEST_COUNT)
+  addCounter(REVIVE_FAIL_COUNT)
+  addCounter(SLOT_RESERVATION_FAIL_COUNT)
+  addCounter(SHUFFLE_FETCH_FAILURE_COUNT)
+  addCounter(SHUFFLE_DATA_LOST_COUNT)
+
+  def getMetricsSnapshot(): Map[String, ClientMetric] = {
+    val counterMetrics = counters().flatMap { c =>
+      val current = c.counter.getCount
+      val prev = 
Option(counterPrev.get(c.name)).map(_.longValue()).getOrElse(0L)
+      val delta = current - prev
+      pendingCounterValues.put(c.name, current)
+      if (delta > 0) Some(c.name -> ClientMetric(delta, MetricType.Counter))
+      else None
+    }
+    // Gauges: send the latest value as-is.
+    val gaugeMetrics = gauges().map(g =>
+      g.name -> 
ClientMetric(g.gauge.getValue.asInstanceOf[Number].longValue(), 
MetricType.Gauge))
+    (counterMetrics ++ gaugeMetrics).toMap
+  }
+
+  def commitSnapshot(): Unit = {
+    pendingCounterValues.entrySet().asScala.foreach { entry =>
+      counterPrev.put(entry.getKey, entry.getValue)
+    }
+    pendingCounterValues.clear()
+  }
+
+  def start(): Unit = startCleaner()

Review Comment:
   **[P2] This starts a cleaner thread that can never clean anything for this 
source.** `startCleaner()` only scans `namedTimers`, while 
`CelebornClientSource` defines counters and gauges but no timers. Because 
`LifecycleManager` invokes `source.start()`, every metrics-enabled client gets 
a scheduled daemon thread that wakes forever to scan an empty timer map. Client 
metric snapshots do not require it, so please remove this scheduling path 
(while retaining lifecycle cleanup for any executor state that remains).



##########
master/src/main/scala/org/apache/celeborn/service/deploy/master/ApplicationMetricsSource.scala:
##########
@@ -0,0 +1,117 @@
+/*
+ * 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
+
+import java.util.{Map => JMap}
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+import scala.collection.JavaConverters._
+
+import org.apache.celeborn.common.CelebornConf
+import org.apache.celeborn.common.internal.Logging
+import org.apache.celeborn.common.metrics.{ClientMetric, MetricType}
+import org.apache.celeborn.common.metrics.source.{AbstractSource, Role}
+import org.apache.celeborn.common.util.{JavaUtils, Utils}
+
+class ApplicationMetricsSource(conf: CelebornConf)
+  extends AbstractSource(conf, Role.MASTER) with Logging {
+  override val sourceName = "application"
+
+  private val masterClientMetricsEnabled = conf.masterClientMetricsEnabled
+  private val removedAppRetentionMs = 
conf.masterClientMetricsRemovedAppRetentionMs
+
+  // Tracking applications that have been terminated
+  private val removedAppIds =
+    JavaUtils.newConcurrentHashMap[String, java.lang.Long]()
+
+  private val seriesCardinalityWarnThreshold =
+    conf.masterClientMetricsSeriesCardinalityWarnThreshold
+  private val seriesCardinalityWarned = new AtomicBoolean(false)
+
+  if (masterClientMetricsEnabled) {
+    startRemovedAppCleaner()
+  }
+
+  private def startRemovedAppCleaner(): Unit = {
+    val cleanTask: Runnable = new Runnable {
+      override def run(): Unit = Utils.tryLogNonFatalError {
+        val cutoff = System.currentTimeMillis() - removedAppRetentionMs
+        removedAppIds.entrySet().asScala.foreach { entry =>
+          if (entry.getValue < cutoff) {
+            removedAppIds.remove(entry.getKey, entry.getValue)
+          }
+        }
+      }
+    }
+    metricsCleaner.scheduleWithFixedDelay(
+      cleanTask,
+      removedAppRetentionMs,
+      removedAppRetentionMs,
+      TimeUnit.MILLISECONDS)
+  }
+
+  def updateApplicationMetrics(
+      appId: String,
+      metricLabels: Map[String, String],
+      metrics: JMap[String, ClientMetric]): Unit = {
+    if (!masterClientMetricsEnabled || metricLabels.isEmpty) {
+      return
+    }
+
+    if (removedAppIds.containsKey(appId)) {
+      return
+    }
+
+    metrics.asScala.foreach { case (name, metric) =>
+      metric.metricType match {
+        case MetricType.Gauge =>
+          addOrUpdateGaugeForApp(name, metricLabels, appId, metric.value)
+        case MetricType.Counter =>
+          addOrUpdateCounterForApp(name, metricLabels, appId, metric.value)
+      }
+    }
+
+    if (removedAppIds.containsKey(appId)) {
+      removeAppFromMetrics(appId)
+    }
+
+    warnIfSeriesCardinalityHigh()
+  }
+
+  def removeApplicationMetrics(appId: String): Unit = {
+    if (masterClientMetricsEnabled) {
+      removedAppIds.put(appId, System.currentTimeMillis())
+    }
+    removeAppFromMetrics(appId)
+  }
+
+  private def warnIfSeriesCardinalityHigh(): Unit = {
+    val trackedSeries = gauges().size + counters().size

Review Comment:
   **[P2] The one-time cardinality warning still scans and allocates every 
series on every heartbeat.** `gauges()` and `counters()` each convert the 
entire map to a Scala `List`, and this count is computed before 
`seriesCardinalityWarned` gates the log. Once the threshold is crossed—the 
exact high-cardinality case this protects—every subsequent heartbeat remains 
O(number of series) and allocates both lists. Return immediately when 
`seriesCardinalityWarned.get()` is true and use the underlying tracked-map 
sizes instead of materializing metric lists.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to