• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Hyshmily / hotkey / 28356107387

29 Jun 2026 07:35AM UTC coverage: 91.168% (-0.01%) from 91.178%
28356107387

push

github

Hyshmily
test : fix CI tests

Signed-off-by: Hyshmily <cxm8607@outlook.com>

1276 of 1461 branches covered (87.34%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 4 files covered. (100.0%)

117 existing lines in 15 files now uncovered.

3596 of 3883 relevant lines covered (92.61%)

4.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.43
/worker/src/main/java/io/github/hyshmily/hotkey/worker/dispatch/WorkerHeartbeatProducer.java
1
/*
2
 * Copyright 2026 Hyshmily. All Rights Reserved.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
package io.github.hyshmily.hotkey.worker.dispatch;
17

18
import static io.github.hyshmily.hotkey.constants.HotKeyConstants.ROUTING_KEY_HEARTBEAT;
19

20
import io.github.hyshmily.hotkey.detection.HotKeyStateMachine;
21
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatMessage;
22
import io.github.hyshmily.hotkey.util.HotKeyThreadFactory;
23
import jakarta.annotation.PostConstruct;
24
import jakarta.annotation.PreDestroy;
25
import java.lang.management.ManagementFactory;
26
import java.lang.management.OperatingSystemMXBean;
27
import java.nio.file.Files;
28
import java.nio.file.Path;
29
import java.util.concurrent.Executors;
30
import java.util.concurrent.ScheduledExecutorService;
31
import java.util.concurrent.ScheduledFuture;
32
import java.util.concurrent.TimeUnit;
33
import java.util.concurrent.atomic.AtomicLong;
34
import lombok.AccessLevel;
35
import lombok.RequiredArgsConstructor;
36
import lombok.extern.slf4j.Slf4j;
37
import org.springframework.amqp.rabbit.core.RabbitTemplate;
38
import org.springframework.data.redis.connection.RedisConnectionFactory;
39
import org.springframework.context.annotation.DependsOn;
40
import org.springframework.data.redis.core.StringRedisTemplate;
41

42
/**
43
 * Enhanced Worker heartbeat sender.
44
 *
45
 * <p>Replaces the old ping-only heartbeat approach with
46
 * structured heartbeats containing epoch, decisionVersionHwm, loadFactor,
47
 * and readyToServe flags. Broadcast on the dedicated heartbeat exchange.
48
 *
49
 * <p>Epoch is persisted to Redis (fallback to local file), incremented
50
 * atomically on each process start for restart detection by Apps.
51
 */
52
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
53
@Slf4j
4✔
54
@DependsOn("rabbitAdmin")
55
public class WorkerHeartbeatProducer {
56

57
  /** RabbitMQ template for publishing heartbeat messages. */
58
  private final RabbitTemplate rabbitTemplate;
59
  /** Target topic exchange for heartbeat messages. */
60
  private final String heartbeatExchange;
61
  /** Unique identity of this Worker node. */
62
  private final String workerId;
63
  /** State machine providing config-gossip fields (confirm/cool/grace counts). */
64
  private final HotKeyStateMachine stateMachine;
65
  /** Broadcaster for reading the current decision version watermark. */
66
  private final WorkerBroadcaster broadcaster;
67
  /** Monotonically increasing epoch, persisted across restarts. */
68
  private final long epoch;
69
  /** JVM start timestamp used for the ready-to-serve grace period. */
70
  private final long startTime;
71
  /** Scheduler for periodic heartbeat sends. */
72
  private final ScheduledExecutorService scheduler;
73
  /** Whether this instance owns the scheduler (self-created). */
74
  private final boolean ownsScheduler;
75
  /** Shared monotonic counter for config-change timestamps embedded in heartbeats. */
76
  private final AtomicLong configTimestampCounter;
77
  /** Interval between consecutive heartbeat sends (milliseconds). */
78
  private final long pingIntervalMs;
79
  /** Handle for the scheduled heartbeat task. */
80
  private ScheduledFuture<?> heartbeatTask;
81

82
  private static final String EPOCH_REDIS_KEY_PREFIX = "hotkey:worker:epoch:";
83

84
  /**
85
   * Creates a new heartbeat producer with a shared external scheduler.
86
   */
87
  public WorkerHeartbeatProducer(
88
    RabbitTemplate rabbitTemplate,
89
    String heartbeatExchange,
90
    String workerId,
91
    HotKeyStateMachine stateMachine,
92
    WorkerBroadcaster broadcaster,
93
    AtomicLong configTimestampCounter,
94
    RedisConnectionFactory redisConnectionFactory,
95
    long pingIntervalMs,
96
    ScheduledExecutorService scheduler
97
  ) {
98
    this(
9✔
99
      rabbitTemplate,
100
      heartbeatExchange,
101
      workerId,
102
      stateMachine,
103
      broadcaster,
104
      initEpoch(workerId, redisConnectionFactory),
1✔
105
      System.currentTimeMillis(),
5✔
106
      scheduler,
107
      false,
108
      configTimestampCounter,
109
      pingIntervalMs
110
    );
111
  }
1✔
112

113
  /**
114
   * Creates a new heartbeat producer with its own internal scheduler
115
   * (primarily for testing, with mocked Redis).
116
   */
117
  public WorkerHeartbeatProducer(
118
    RabbitTemplate rabbitTemplate,
119
    String heartbeatExchange,
120
    String workerId,
121
    HotKeyStateMachine stateMachine,
122
    WorkerBroadcaster broadcaster,
123
    AtomicLong configTimestampCounter,
124
    long pingIntervalMs
125
  ) {
126
    this(
9✔
127
      rabbitTemplate,
128
      heartbeatExchange,
129
      workerId,
130
      stateMachine,
131
      broadcaster,
132
      initEpoch(workerId, null),
1✔
133
      System.currentTimeMillis(),
5✔
134
      Executors.newSingleThreadScheduledExecutor(new HotKeyThreadFactory("hotkey-hb-producer")),
4✔
135
      true,
136
      configTimestampCounter,
137
      pingIntervalMs
138
    );
139
  }
1✔
140

141
  private static long initEpoch(String workerId, RedisConnectionFactory redisConnectionFactory) {
142
    StringRedisTemplate template = new StringRedisTemplate(redisConnectionFactory);
5✔
143
    template.afterPropertiesSet();
2✔
144
    return doInitEpoch(template, workerId);
4✔
145
  }
146

147
  /**
148
   * Initializes the epoch by atomically incrementing a Redis counter.
149
   * Falls back to a local file if Redis is unavailable.
150
   *
151
   * @param redis the Redis template (created ad-hoc for this one-time operation)
152
   * @return the new epoch value for this Worker incarnation
153
   */
154
  private static long doInitEpoch(StringRedisTemplate redis, String workerId) {
155
    try {
156
      String key = EPOCH_REDIS_KEY_PREFIX + workerId;
3✔
157
      String val = redis.opsForValue().get(key);
6✔
158
      long next = (val != null) ? Long.parseLong(val) + 1 : 1;
9✔
159
      redis.opsForValue().set(key, String.valueOf(next));
6✔
160
      log.info("Epoch initialized via Redis: workerId={}, epoch={}", workerId, next);
6✔
161
      return next;
2✔
162
    } catch (Exception e) {
1✔
163
      log.warn("Redis unavailable for epoch, using local file fallback", e);
4✔
164
      return initEpochFromLocalFile(workerId);
3✔
165
    }
166
  }
167

168
  /**
169
   * Fallback epoch initialization using a local temp file when Redis is
170
   * unavailable.  Reads the previous value, increments, and writes back.
171
   *
172
   * <p>If both Redis and local file fallback fail, falls back to
173
   * {@code System.currentTimeMillis()} as a last resort epoch value
174
   * (non-monotonic risk across restarts is logged at error level).
175
   *
176
   * @return the new epoch value, or {@code System.currentTimeMillis()} on complete failure
177
   */
178
  private static long initEpochFromLocalFile(String workerId) {
179
    try {
180
      Path path = Path.of(System.getProperty("java.io.tmpdir"), "hotkey-epoch-" + workerId);
11✔
181
      long next = 1;
2✔
182
      if (Files.exists(path)) {
5✔
183
        String content = Files.readString(path);
3✔
184
        next = Long.parseLong(content.trim()) + 1;
6✔
185
      }
186
      Files.writeString(path, String.valueOf(next));
7✔
187
      log.info("Epoch initialized via local file: workerId={}, epoch={}", workerId, next);
6✔
188
      return next;
2✔
189
    } catch (Exception e) {
1✔
190
      log.error("Epoch local file fallback failed, using System.currentTimeMillis()", e);
4✔
191
      return System.currentTimeMillis();
2✔
192
    }
193
  }
194

195
  /**
196
   * Computes the current CPU load factor (0.0 – 1.0) via the platform MXBean.
197
   *
198
   * @return CPU load clamped to [0, 1], or 0.0 if unavailable
199
   */
200
  private double computeLoadFactor() {
201
    double cpuLoad = 0.0;
2✔
202
    try {
203
      OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
2✔
204
      if (osBean instanceof com.sun.management.OperatingSystemMXBean sunOsBean) {
6!
205
        cpuLoad = sunOsBean.getCpuLoad();
3✔
206
      }
207
    } catch (Exception ignored) {}
1✔
208
    return Math.min(1.0, cpuLoad);
4✔
209
  }
210

211
  /**
212
   * Whether this Worker is ready to serve hot-key detection.
213
   * Returns {@code true} after a 3-second startup grace period or as soon as
214
   * the state machine has tracked at least one key.
215
   *
216
   * @return {@code true} if the Worker considers itself ready
217
   */
218
  private boolean isReadyToServe() {
219
    long uptime = System.currentTimeMillis() - startTime;
5✔
220
    return uptime > 3000 || stateMachine.getTrackedKeys() > 0;
12✔
221
  }
222

223
  /**
224
   * Computes an integer fingerprint of the current state-machine config
225
   * (confirm count, cool count, grace count) so receivers can quickly
226
   * detect config changes without comparing individual fields.
227
   *
228
   * @return config fingerprint hash
229
   */
230
  private int computeConfigFingerprint() {
231
    int hash = stateMachine.getConfirmCount();
4✔
232
    hash = 31 * hash + stateMachine.getCoolCount();
8✔
233
    hash = 31 * hash + stateMachine.getPreCoolGraceCount();
8✔
234
    return hash;
2✔
235
  }
236

237
  /**
238
   * Starts the periodic heartbeat sender at the configured ping interval.
239
   *
240
   * <p>First heartbeat is delayed by {@code pingIntervalMs} to allow the
241
   * RabbitMQ connection and exchange declarations (RabbitAdmin) to complete,
242
   * preventing channel-level NOT_FOUND errors when the heartbeat exchange
243
   * has not yet been declared.
244
   */
245
  @PostConstruct
246
  public void start() {
247
    try {
248
      heartbeatTask = scheduler.scheduleAtFixedRate(this::sendHeartbeat, pingIntervalMs, pingIntervalMs, TimeUnit.MILLISECONDS);
12✔
UNCOV
249
    } catch (Exception e) {
×
UNCOV
250
      log.error("Failed to start heartbeat scheduler; Worker heartbeat will not be sent. " +
×
251
          "Application continues but App instances may mark this Worker as dead.", e);
252
    }
1✔
253
  }
1✔
254

255
  /**
256
   * Gracefully stops the heartbeat sender.
257
   *
258
   * <p>Cancels the scheduled task; shuts down the scheduler only if owned.
259
   */
260
  @PreDestroy
261
  public void stop() {
262
    if (heartbeatTask != null) {
3✔
263
      heartbeatTask.cancel(false);
5✔
264
    }
265
    if (ownsScheduler) {
3✔
266
      scheduler.shutdown();
3✔
267
    }
268
  }
1✔
269

270
  /**
271
   * Builds and sends a single heartbeat message containing epoch, decision
272
   * version watermark, load factor, ready-to-serve flag, config fingerprint,
273
   * and state-machine config gossip fields.
274
   *
275
   * <p>Published to the configured heartbeat exchange with routing key
276
   * {@code heartbeat.<workerId>}.  Silently drops if the channel or
277
   * connection is unavailable (fire-and-forget).
278
   */
279
  void sendHeartbeat() {
280
    try {
281
      WorkerHeartbeatMessage hb = new WorkerHeartbeatMessage(
6✔
282
        workerId,
283
        epoch,
284
        System.currentTimeMillis(),
3✔
285
        broadcaster.getCurrentDecisionVersion(),
2✔
286
        computeLoadFactor(),
2✔
287
        isReadyToServe(),
2✔
288
        computeConfigFingerprint(),
3✔
289
        stateMachine.getConfirmCount(),
3✔
290
        stateMachine.getCoolCount(),
3✔
291
        stateMachine.getPreCoolGraceCount(),
3✔
292
        configTimestampCounter.get()
3✔
293
      );
294
      rabbitTemplate.send(heartbeatExchange, ROUTING_KEY_HEARTBEAT + workerId, hb.toMessage());
10✔
295
    } catch (Exception e) {
1✔
296
      log.error("Scheduled sendHeartbeat failed", e);
4✔
297
    }
1✔
298
  }
1✔
299
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc