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

Hyshmily / hotkey / 27925388821

22 Jun 2026 02:14AM UTC coverage: 92.403% (-1.2%) from 93.615%
27925388821

push

github

Hyshmily
fix:fix known bugs and promote performance

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

1143 of 1289 branches covered (88.67%)

Branch coverage included in aggregate %.

184 of 236 new or added lines in 19 files covered. (77.97%)

6 existing lines in 3 files now uncovered.

3321 of 3542 relevant lines covered (93.76%)

4.22 hits per line

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

96.55
/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 jakarta.annotation.PostConstruct;
23
import jakarta.annotation.PreDestroy;
24
import java.lang.management.ManagementFactory;
25
import java.lang.management.OperatingSystemMXBean;
26
import java.nio.file.Files;
27
import java.nio.file.Path;
28
import java.util.concurrent.Executors;
29
import java.util.concurrent.ScheduledExecutorService;
30
import java.util.concurrent.ScheduledFuture;
31
import java.util.concurrent.TimeUnit;
32
import java.util.concurrent.atomic.AtomicLong;
33
import lombok.AccessLevel;
34
import lombok.RequiredArgsConstructor;
35
import lombok.extern.slf4j.Slf4j;
36
import org.springframework.amqp.rabbit.core.RabbitTemplate;
37
import org.springframework.data.redis.connection.RedisConnectionFactory;
38
import org.springframework.data.redis.core.StringRedisTemplate;
39

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

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

79
  private static final String EPOCH_REDIS_KEY_PREFIX = "hotkey:worker:epoch:";
80

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

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

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

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

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

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

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

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

238
  /**
239
   * Starts the periodic heartbeat sender at the configured ping interval.
240
   *
241
   * <p>First heartbeat is sent immediately on startup (initial delay = 0).
242
   */
243
  @PostConstruct
244
  public void start() {
245
    try {
246
      heartbeatTask = scheduler.scheduleAtFixedRate(this::sendHeartbeat, 0, pingIntervalMs, TimeUnit.MILLISECONDS);
11✔
NEW
247
    } catch (Exception e) {
×
NEW
248
      log.error("Failed to start heartbeat scheduler; Worker heartbeat will not be sent. " +
×
249
          "Application continues but App instances may mark this Worker as dead.", e);
250
    }
1✔
251
  }
1✔
252

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

268
  /**
269
   * Builds and sends a single heartbeat message containing epoch, decision
270
   * version watermark, load factor, ready-to-serve flag, config fingerprint,
271
   * and state-machine config gossip fields.
272
   *
273
   * <p>Published to the configured heartbeat exchange with routing key
274
   * {@code heartbeat.<workerId>}.  Silently drops if the channel or
275
   * connection is unavailable (fire-and-forget).
276
   */
277
  void sendHeartbeat() {
278
    try {
279
      WorkerHeartbeatMessage hb = new WorkerHeartbeatMessage(
6✔
280
        workerId,
281
        epoch,
282
        System.currentTimeMillis(),
3✔
283
        broadcaster.getCurrentDecisionVersion(),
2✔
284
        computeLoadFactor(),
2✔
285
        isReadyToServe(),
2✔
286
        computeConfigFingerprint(),
3✔
287
        stateMachine.getConfirmCount(),
3✔
288
        stateMachine.getCoolCount(),
3✔
289
        stateMachine.getPreCoolGraceCount(),
3✔
290
        configTimestampCounter.get()
3✔
291
      );
292
      rabbitTemplate.send(heartbeatExchange, ROUTING_KEY_HEARTBEAT + workerId, hb.toMessage());
10✔
293
    } catch (Exception e) {
1✔
294
      log.error("Scheduled sendHeartbeat failed", e);
4✔
295
    }
1✔
296
  }
1✔
297
}
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