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

Hyshmily / hotkey / 28637103998

03 Jul 2026 03:48AM UTC coverage: 90.399% (-0.2%) from 90.63%
28637103998

push

github

Hyshmily
fix: add packaging to gitignore, fix flaky jitter test, update docs references

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

1264 of 1461 branches covered (86.52%)

Branch coverage included in aggregate %.

3604 of 3924 relevant lines covered (91.85%)

4.19 hits per line

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

94.51
/common/src/main/java/io/github/hyshmily/hotkey/sharding/ClusterHealthView.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.sharding;
17

18
import io.github.hyshmily.hotkey.Internal;
19
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatMessage;
20
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatVerifier;
21
import java.util.Set;
22
import java.util.concurrent.ConcurrentHashMap;
23
import java.util.concurrent.ConcurrentMap;
24
import java.util.stream.Collectors;
25
import lombok.Getter;
26
import lombok.Setter;
27
import lombok.extern.slf4j.Slf4j;
28

29
/**
30
 * Cluster-wide view of Worker health, maintained by consuming periodic
31
 * {@link WorkerHeartbeatMessage} broadcasts from all Worker nodes.
32
 *
33
 * <p>This is the central health authority for the local app instance. It tracks
34
 * every known Worker by ID with its epoch (restart counter), heartbeat timestamps,
35
 * readiness flag, decision version high-water mark, and verification failure count.
36
 *
37
 * <p><b>Health judgment:</b> The cluster is considered healthy when a strict majority
38
 * ({@code > knownWorkerCount / 2}) of Workers are alive (ready, not stale, and within
39
 * the heartbeat timeout window). When the cluster becomes unhealthy, the system may
40
 * enter graceful degradation mode (see ADR-0009).
41
 *
42
 * <p><b>Restart detection:</b> When a Worker's epoch in a new heartbeat exceeds the
43
 * stored epoch, a new {@link WorkerHealthRecord} is created — all state from the
44
 * previous incarnation is discarded. This prevents stale health state from surviving
45
 * a Worker restart.
46
 *
47
 * <p><b>Thread safety:</b> All record mutations use {@link ConcurrentHashMap#compute}
48
 * and {@code computeIfPresent} for atomic per-Worker updates. The
49
 * {@code lastAnyHeartbeatTime} is a {@code volatile} field safe for
50
 * concurrent read/write.
51
 *
52
 * @see WorkerHeartbeatMessage
53
 * @see WorkerHeartbeatVerifier
54
 */
55
@Internal
56
@Slf4j
4✔
57
public class ClusterHealthView {
58

59
  /** Per-Worker health records keyed by Worker ID. Thread-safe via {@link ConcurrentHashMap}. */
60
  private final ConcurrentMap<String, WorkerHealthRecord> records = new ConcurrentHashMap<>();
5✔
61

62
  @Getter
63
  private volatile int knownWorkerCount;
64

65
  private final long heartbeatTimeoutMs;
66
  private final int degradeAfterFailures;
67

68
  @Setter
69
  private volatile int minAliveWorkers;
70

71
  @Getter
72
  private volatile long lastAnyHeartbeatTime;
73

74
  public ClusterHealthView(int knownWorkerCount, long heartbeatTimeoutMs, int degradeAfterFailures) {
2✔
75
    this.knownWorkerCount = knownWorkerCount;
3✔
76
    this.heartbeatTimeoutMs = heartbeatTimeoutMs;
3✔
77
    this.degradeAfterFailures = degradeAfterFailures;
3✔
78
  }
1✔
79

80
  /**
81
   * Updates the expected Worker count for majority-quorum health calculations.
82
   * <p>Called during initialization (from configuration) and dynamically
83
   * when the ring is reconciled from observed heartbeats.
84
   * <p>If the new count is 0, the cluster is considered healthy when at least
85
   * one alive Worker is observed. If the new count is greater than the previous, the quorum threshold
86
   * adjusts accordingly.
87
   *
88
   * @param count the new expected Worker count; must be {@code >= 0}
89
   */
90
  public void setKnownWorkerCount(int count) {
91
    if (count < 0) {
2✔
92
      log.warn("Invalid knownWorkerCount {}, ignoring update", count);
5✔
93
      return;
1✔
94
    }
95
    int old = this.knownWorkerCount;
3✔
96
    this.knownWorkerCount = count;
3✔
97
    if (old == 0 && count > 0) {
4!
98
      log.info("knownWorkerCount updated from 0 to {}; cluster health check now active", count);
5✔
99
    }
100
  }
1✔
101

102
  /**
103
   * Processes an incoming {@link WorkerHeartbeatMessage} from a Worker node and
104
   * updates the cluster health state accordingly.
105
   *
106
   * <p><b>New or restarted Worker:</b> If this is the first heartbeat from a Worker,
107
   * or if the heartbeat epoch exceeds the stored epoch (indicating a Worker restart),
108
   * a new {@link WorkerHealthRecord} is created with fresh timestamps and the old
109
   * decision watermark is discarded.
110
   *
111
   * <p><b>Known Worker:</b> Updates the last heartbeat timestamp, decision version
112
   * watermark (taking the max), load factor, and readiness flag. Clears any stale
113
   * or restarted flags.
114
   *
115
   * <p><b>Cluster recovery:</b> If the cluster was in degraded state and this heartbeat
116
   * brings the majority back to health, the degraded flag is automatically cleared.
117
   *
118
   * @param hb the incoming heartbeat message; must not be null
119
   */
120
  public void onHeartbeat(WorkerHeartbeatMessage hb) {
121
    records.compute(hb.workerId(), (id, existing) -> {
8✔
122
      long now = System.currentTimeMillis();
2✔
123

124
      if (existing == null || hb.epoch() > existing.epoch) {
8✔
125
        WorkerHealthRecord r = new WorkerHealthRecord();
4✔
126

127
        r.workerId = hb.workerId();
4✔
128
        r.epoch = hb.epoch();
4✔
129
        r.lastHeartbeatTime = now;
3✔
130
        r.readyToServe = hb.readyToServe();
4✔
131
        return r;
2✔
132
      }
133

134
      if (hb.epoch() < existing.epoch) {
6✔
135
        return existing;
2✔
136
      }
137

138
      existing.lastHeartbeatTime = now;
3✔
139
      existing.readyToServe = hb.readyToServe();
4✔
140
      existing.stale = false;
3✔
141
      return existing;
2✔
142
    });
143

144
    lastAnyHeartbeatTime = System.currentTimeMillis();
3✔
145
  }
1✔
146

147
  /**
148
   * Records a successful PONG response from a Worker during active verification
149
   * ({@link WorkerHeartbeatVerifier#verifySuspectedWorkers}).
150
   *
151
   * <p>Resets the Worker's verification failure count to zero and updates its
152
   * heartbeat timestamp to now, effectively restoring it to the alive set.
153
   *
154
   * @param workerId the Worker that responded with a PONG; must not be null
155
   */
156
  public void recordPong(String workerId) {
157
    records.computeIfPresent(workerId, (id, r) -> {
6✔
158
      r.lastHeartbeatTime = System.currentTimeMillis();
3✔
159
      r.verifyFailures = 0;
3✔
160
      return r;
2✔
161
    });
162
  }
1✔
163

164
  /**
165
   * Increments the verification failure count for a Worker and marks it stale
166
   * if the threshold is reached.
167
   *
168
   * <p>When the cumulative failure count reaches {@code degradeAfterFailures},
169
   * the Worker is marked as stale ({@code stale = true}). Stale Workers are
170
   * excluded from {@link #isClusterHealthy()} and {@link #getAliveWorkerIds()}
171
   * — they are effectively considered dead even if they were previously alive.
172
   *
173
   * <p>If the Worker ID is not present in the health view (never seen, or already
174
   * removed), this call is a no-op.
175
   *
176
   * @param workerId the Worker that failed active verification; must not be null
177
   */
178
  public void markVerificationFailed(String workerId) {
179
    records.computeIfPresent(workerId, (id, r) -> {
7✔
180
      r.verifyFailures++;
6✔
181
      if (r.verifyFailures >= degradeAfterFailures) {
5✔
182
        r.stale = true;
3✔
183
      }
184
      return r;
2✔
185
    });
186
  }
1✔
187

188
  /**
189
   * Returns the current verification failure count for a Worker.
190
   *
191
   * @param workerId the Worker to query; must not be null
192
   * @return the failure count, or 0 if the Worker is not tracked
193
   */
194
  public int getVerifyFailures(String workerId) {
195
    WorkerHealthRecord r = records.get(workerId);
6✔
196
    return r != null ? r.verifyFailures : 0;
6!
197
  }
198

199
  /**
200
   * Removes the health record for a Worker that has been confirmed dead.
201
   *
202
   * @param workerId the Worker to remove; must not be null
203
   */
204
  public void removeRecord(String workerId) {
205
    records.remove(workerId);
×
206
  }
×
207

208
  /**
209
   * Returns whether the cluster is currently considered healthy based on majority
210
   * of known Workers being alive.
211
   *
212
   * <p>A Worker is considered alive when all three conditions hold:
213
   * <ul>
214
   *   <li>{@code readyToServe == true} (Worker completed initialization)</li>
215
   *   <li>{@code stale == false} (Worker has not exceeded verification failure threshold)</li>
216
   *   <li>Time since last heartbeat {@code < heartbeatTimeoutMs}</li>
217
   * </ul>
218
   *
219
   * <p>The cluster is healthy when the count of alive Workers is strictly greater
220
   * than {@code knownWorkerCount / 2} (simple majority).
221
   *
222
   * <p>When {@code knownWorkerCount == 0} (no expected count configured), the
223
   * cluster is considered healthy if at least one alive Worker is observed via
224
   * heartbeats. This provides backward compatibility for deployments that rely
225
   * on dynamic Worker discovery rather than a fixed expected count.
226
   *
227
   * @return {@code true} if a strict majority of known Workers are alive and ready;
228
   *         {@code false} if no Workers are configured or too few are alive
229
   */
230
  public boolean isClusterHealthy() {
231
    int minAlive = minAliveWorkers;
3✔
232

233
    if (minAlive <= 0) {
2!
234
      minAlive = knownWorkerCount / 2 + 1;
7✔
235
    }
236
    long aliveCount = records
2✔
237
      .values()
1✔
238
      .stream()
3✔
239
      .filter(r -> r.isAlive(heartbeatTimeoutMs))
6✔
240
      .count();
2✔
241
    return aliveCount >= minAlive;
9✔
242
  }
243

244
  /**
245
   * Returns the set of Worker IDs that are currently alive (ready and within heartbeat timeout).
246
   *
247
   * @return a set of alive Worker IDs, never {@code null}
248
   */
249
  public Set<String> getAliveWorkerIds() {
250
    return records
3✔
251
      .values()
1✔
252
      .stream()
3✔
253
      .filter(r -> r.isAlive(heartbeatTimeoutMs))
7✔
254
      .map(WorkerHealthRecord::getWorkerId)
1✔
255
      .collect(Collectors.toSet());
3✔
256
  }
257

258
  /**
259
   * Returns the set of all Worker IDs that have ever been seen by this health view.
260
   *
261
   * @return a set of all known Worker IDs, never {@code null}
262
   */
263
  public Set<String> getAllWorkerIds() {
264
    return records.values().stream().map(WorkerHealthRecord::getWorkerId).collect(Collectors.toSet());
10✔
265
  }
266

267
  /**
268
   * Per-Worker health state tracked within the cluster health view.
269
   *
270
   * <p>Each record captures the Worker's current epoch, heartbeat timing,
271
   * readiness, load, and verification failure state. Records are created
272
   * on first heartbeat and updated (or replaced on epoch change) via
273
   * atomic {@code ConcurrentHashMap.compute} operations.
274
   *
275
   * <p>A Worker transitions through these states:
276
   * <ul>
277
   *   <li><b>Startup:</b> {@code readyToServe = false} until first detection cycle completes</li>
278
   *   <li><b>Healthy:</b> {@code readyToServe = true, stale = false}, heartbeats arriving within timeout</li>
279
   *   <li><b>Suspected:</b> Heartbeats timeout; {@link WorkerHeartbeatVerifier} probes actively</li>
280
   *   <li><b>Stale:</b> Verification failures reach threshold; excluded from health majority</li>
281
   *   <li><b>Restarted:</b> New epoch detected; old record replaced entirely</li>
282
   * </ul>
283
   */
284
  @Getter
285
  public static class WorkerHealthRecord {
3✔
286

287
    public volatile String workerId;
288
    public volatile long epoch;
289
    public volatile long lastHeartbeatTime;
290
    public volatile boolean readyToServe;
291
    public volatile boolean stale;
292
    public volatile int verifyFailures;
293

294
    /**
295
     * Returns whether this Worker is currently considered alive for health-majority
296
     * calculations.
297
     *
298
     * <p>All three conditions must hold:
299
     * <ul>
300
     *   <li>{@link #readyToServe} is {@code true} — the Worker has completed its
301
     *       initial detection cycle and is accepting requests</li>
302
     *   <li>{@link #stale} is {@code false} — the Worker has not exceeded the
303
     *       configured verification failure threshold</li>
304
     *   <li>The elapsed wall-clock time since {@link #lastHeartbeatTime} is less
305
     *       than {@code timeoutMs} — heartbeats are arriving within the expected
306
     *       interval</li>
307
     * </ul>
308
     *
309
     * @param timeoutMs the heartbeat timeout window in milliseconds; must be positive
310
     * @return {@code true} if this Worker is ready, not stale, and has sent a heartbeat
311
     *         within the timeout window
312
     */
313
    public boolean isAlive(long timeoutMs) {
314
      return readyToServe && !stale && System.currentTimeMillis() - lastHeartbeatTime < timeoutMs;
17✔
315
    }
316
  }
317
}
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