This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new 2525797134 [fix] prevent duplicate dispatch when collector task times
out (#4342)
2525797134 is described below
commit 2525797134b1b3a7f4f0b99c1a3e4b2b1acbe813
Author: Tanay Paul <[email protected]>
AuthorDate: Sun Aug 23 13:39:12 2026 +0530
[fix] prevent duplicate dispatch when collector task times out (#4342)
---
.../collector/dispatch/CommonDispatcher.java | 34 ++--
.../collector/dispatch/MetricsCollect.java | 4 +
.../collector/dispatch/CommonDispatcherTest.java | 218 +++++++++++++++++++++
3 files changed, 244 insertions(+), 12 deletions(-)
diff --git
a/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/dispatch/CommonDispatcher.java
b/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/dispatch/CommonDispatcher.java
index 686d3fe6c1..7c01127df0 100644
---
a/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/dispatch/CommonDispatcher.java
+++
b/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/dispatch/CommonDispatcher.java
@@ -158,18 +158,8 @@ public class CommonDispatcher implements
MetricsTaskDispatch, CollectDataDispatc
for (Map.Entry<String, MetricsTime> entry :
metricsTimeoutMonitorMap.entrySet()) {
MetricsTime metricsTime = entry.getValue();
if (metricsTime.getStartTime() < deadline) {
- // Metrics collection timeout
- MetricsTime removedMetricsTime =
metricsTimeoutMonitorMap.remove(entry.getKey());
- if (removedMetricsTime == null) {
- continue;
- }
WheelTimerTask timerJob = (WheelTimerTask)
metricsTime.getTimeout().task();
Job job = timerJob.getJob();
- // timeout metrics
- if (metricsCollector != null) {
- long duration = System.currentTimeMillis() -
removedMetricsTime.getStartTime();
- metricsCollector.recordCollectMetrics(job, duration,
"timeout");
- }
CollectRep.MetricsData metricsData =
CollectRep.MetricsData.newBuilder()
.setId(job.getMonitorId())
@@ -184,7 +174,17 @@ public class CommonDispatcher implements
MetricsTaskDispatch, CollectDataDispatc
.setCode(CollectRep.Code.TIMEOUT).setMsg("collect
timeout").build();
log.error("[Collect Timeout]: \n{}", metricsData);
if (metricsData.getPriority() == 0) {
+ // dispatchCollectData removes the map entry as a
once-wins gate;
+ // cancel afterwards so cyclicJob() inside it still
fires normally.
dispatchCollectData(metricsTime.timeout,
metricsTime.getMetrics(), metricsData);
+ metricsTime.getTimeout().cancel();
+ } else {
+ // remove the stale entry and cancel the in-flight
collect so a
+ // late result does not produce a duplicate dispatch.
+ MetricsTime removed =
metricsTimeoutMonitorMap.remove(entry.getKey());
+ if (removed != null) {
+ metricsTime.getTimeout().cancel();
+ }
}
}
}
@@ -229,12 +229,22 @@ public class CommonDispatcher implements
MetricsTaskDispatch, CollectDataDispatc
}
MetricsTime metricsTime = metricsTimeoutMonitorMap.remove(monitorKey);
- // job completed metrics
if (metricsTime != null && metricsCollector != null) {
long duration = System.currentTimeMillis() -
metricsTime.getStartTime();
- String status = metricsData.getCode() == CollectRep.Code.SUCCESS ?
"success" : "fail";
+ String status;
+ if (metricsData.getCode() == CollectRep.Code.SUCCESS) {
+ status = "success";
+ } else if (metricsData.getCode() == CollectRep.Code.TIMEOUT) {
+ status = "timeout";
+ } else {
+ status = "fail";
+ }
metricsCollector.recordCollectMetrics(job, duration, status);
}
+ // if the entry was already removed by the timeout monitor, skip the
duplicate result.
+ if (metricsTime == null && !metrics.isHasSubTask() &&
metrics.getPrometheus() == null) {
+ return;
+ }
if (metrics.isHasSubTask()) {
boolean isLastTask = metrics.consumeSubTaskResponse(metricsData);
if (isLastTask) {
diff --git
a/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/dispatch/MetricsCollect.java
b/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/dispatch/MetricsCollect.java
index d896e1404b..670eb8c030 100644
---
a/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/dispatch/MetricsCollect.java
+++
b/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/dispatch/MetricsCollect.java
@@ -183,6 +183,10 @@ public class MetricsCollect implements Runnable,
Comparable<MetricsCollect> {
*/
metricsData =
PrometheusAutoCollectImpl.getInstance().collect(response, metrics);
validateResponse(metricsData == null ? null :
metricsData.stream().findFirst().orElse(null));
+ // if the timeout monitor already cancelled this cycle, skip the
late result.
+ if (fastFailed()) {
+ return;
+ }
collectDataDispatch.dispatchCollectData(timeout, metrics,
metricsData);
return;
}
diff --git
a/hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/dispatch/CommonDispatcherTest.java
b/hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/dispatch/CommonDispatcherTest.java
new file mode 100644
index 0000000000..7f0e5375a6
--- /dev/null
+++
b/hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/dispatch/CommonDispatcherTest.java
@@ -0,0 +1,218 @@
+/*
+ * 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.hertzbeat.collector.dispatch;
+
+import
org.apache.hertzbeat.collector.dispatch.entrance.internal.CollectJobService;
+import org.apache.hertzbeat.collector.timer.WheelTimerTask;
+import org.apache.hertzbeat.common.entity.job.Job;
+import org.apache.hertzbeat.common.entity.job.Metrics;
+import org.apache.hertzbeat.common.entity.message.CollectRep;
+import org.apache.hertzbeat.common.timer.Timeout;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test case for {@link CommonDispatcher}.
+ * Regression coverage for issue #4203 (duplicate dispatch on collection
timeout).
+ */
+class CommonDispatcherTest {
+
+ private static final long JOB_ID = 1L;
+
+ /**
+ * Minimal {@link Timeout} stub that tracks cancellation state and returns
+ * a mock {@link WheelTimerTask} backed by a real {@link Job}.
+ */
+ private static class CancellableTimeout implements Timeout {
+
+ private volatile boolean cancelled = false;
+
+ @Override
+ public boolean cancel() {
+ cancelled = true;
+ return true;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ @Override
+ public boolean isExpired() {
+ return false;
+ }
+
+ @Override
+ public org.apache.hertzbeat.common.timer.Timer timer() {
+ return null;
+ }
+
+ @Override
+ public org.apache.hertzbeat.common.timer.TimerTask task() {
+ WheelTimerTask task = mock(WheelTimerTask.class);
+ Job job = Job.builder()
+ .id(JOB_ID)
+ .monitorId(JOB_ID)
+ .tenantId(0L)
+ .app("test-app")
+ .labels(Collections.emptyMap())
+ .annotations(Collections.emptyMap())
+ .metadata(Collections.emptyMap())
+ .build();
+ when(task.getJob()).thenReturn(job);
+ return task;
+ }
+ }
+
+ private CancellableTimeout timeout;
+
+ @BeforeEach
+ void setUp() {
+ timeout = new CancellableTimeout();
+ }
+
+ @Test
+ void timeoutMonitor_priorityZero_dispatchesOnceAndCancels() throws
Exception {
+ long expiredStart = System.currentTimeMillis() - 300_000L;
+ Metrics availability =
Metrics.builder().name("availability").priority((byte) 0).build();
+ CommonDispatcher.MetricsTime entry =
+ new CommonDispatcher.MetricsTime(expiredStart, availability,
timeout);
+
+ AtomicInteger dispatchCount = new AtomicInteger(0);
+ Map<String, CommonDispatcher.MetricsTime> monitorMap = new
ConcurrentHashMap<>();
+ monitorMap.put(JOB_ID + "-availability", entry);
+
+ CommonDispatcher dispatcher = buildDispatcher(monitorMap,
dispatchCount);
+ invokeMonitorCollectTaskTimeout(dispatcher);
+
+ assertEquals(1, dispatchCount.get(), "dispatchCollectData must be
called exactly once");
+ assertTrue(timeout.isCancelled(), "Timeout must be cancelled after
dispatch");
+ assertTrue(monitorMap.isEmpty(), "Map must be empty after timeout is
handled");
+ }
+
+ @Test
+ void timeoutMonitor_secondScan_doesNotDoubleDispatch() throws Exception {
+ long expiredStart = System.currentTimeMillis() - 300_000L;
+ Metrics availability =
Metrics.builder().name("availability").priority((byte) 0).build();
+ CommonDispatcher.MetricsTime entry =
+ new CommonDispatcher.MetricsTime(expiredStart, availability,
timeout);
+
+ AtomicInteger dispatchCount = new AtomicInteger(0);
+ Map<String, CommonDispatcher.MetricsTime> monitorMap = new
ConcurrentHashMap<>();
+ monitorMap.put(JOB_ID + "-availability", entry);
+
+ CommonDispatcher dispatcher = buildDispatcher(monitorMap,
dispatchCount);
+
+ invokeMonitorCollectTaskTimeout(dispatcher);
+ assertEquals(1, dispatchCount.get(), "First scan must dispatch once");
+
+ invokeMonitorCollectTaskTimeout(dispatcher);
+ assertEquals(1, dispatchCount.get(), "Second scan must not produce a
duplicate dispatch");
+ }
+
+ @Test
+ void timeoutMonitor_nonZeroPriority_cancelsWithoutDispatching() throws
Exception {
+ long expiredStart = System.currentTimeMillis() - 300_000L;
+ Metrics cpu = Metrics.builder().name("cpu").priority((byte) 1).build();
+ CommonDispatcher.MetricsTime entry =
+ new CommonDispatcher.MetricsTime(expiredStart, cpu, timeout);
+
+ AtomicInteger dispatchCount = new AtomicInteger(0);
+ Map<String, CommonDispatcher.MetricsTime> monitorMap = new
ConcurrentHashMap<>();
+ monitorMap.put(JOB_ID + "-cpu", entry);
+
+ CommonDispatcher dispatcher = buildDispatcher(monitorMap,
dispatchCount);
+ invokeMonitorCollectTaskTimeout(dispatcher);
+
+ assertEquals(0, dispatchCount.get(), "Non-zero priority timeout must
not dispatch");
+ assertTrue(timeout.isCancelled(), "Timeout must be cancelled");
+ assertTrue(monitorMap.isEmpty(), "Map must be empty after timeout is
handled");
+ }
+
+ @Test
+ void timeoutMonitor_nonExpiredEntry_isLeftUntouched() throws Exception {
+ long recentStart = System.currentTimeMillis() - 60_000L;
+ Metrics availability =
Metrics.builder().name("availability").priority((byte) 0).build();
+ CommonDispatcher.MetricsTime entry =
+ new CommonDispatcher.MetricsTime(recentStart, availability,
timeout);
+
+ AtomicInteger dispatchCount = new AtomicInteger(0);
+ Map<String, CommonDispatcher.MetricsTime> monitorMap = new
ConcurrentHashMap<>();
+ monitorMap.put(JOB_ID + "-availability", entry);
+
+ CommonDispatcher dispatcher = buildDispatcher(monitorMap,
dispatchCount);
+ invokeMonitorCollectTaskTimeout(dispatcher);
+
+ assertEquals(0, dispatchCount.get(), "Non-expired entry must not be
dispatched");
+ assertFalse(timeout.isCancelled(), "Non-expired timeout must not be
cancelled");
+ assertFalse(monitorMap.isEmpty(), "Non-expired entry must remain in
the map");
+ }
+
+ private CommonDispatcher buildDispatcher(
+ Map<String, CommonDispatcher.MetricsTime> monitorMap,
+ AtomicInteger dispatchCount) throws Exception {
+
+ CollectJobService jobService = mock(CollectJobService.class);
+ when(jobService.getCollectorIdentity()).thenReturn("test-collector");
+ WorkerPool workerPool = mock(WorkerPool.class);
+
+ CommonDispatcher dispatcher = new CommonDispatcher(
+ null, null, null, workerPool, jobService, null) {
+
+ @Override
+ public void start() {
+ }
+
+ @Override
+ public void dispatchCollectData(Timeout t, Metrics m,
CollectRep.MetricsData data) {
+ WheelTimerTask task = (WheelTimerTask) t.task();
+ String key = task.getJob().getId() + "-" + m.getName();
+ if (monitorMap.remove(key) == null) {
+ return;
+ }
+ dispatchCount.incrementAndGet();
+ }
+ };
+
+ Field mapField =
CommonDispatcher.class.getDeclaredField("metricsTimeoutMonitorMap");
+ mapField.setAccessible(true);
+ mapField.set(dispatcher, monitorMap);
+
+ return dispatcher;
+ }
+
+ private void invokeMonitorCollectTaskTimeout(CommonDispatcher dispatcher)
throws Exception {
+ Method method =
CommonDispatcher.class.getDeclaredMethod("monitorCollectTaskTimeout");
+ method.setAccessible(true);
+ method.invoke(dispatcher);
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]