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

Hyshmily / hotkey / 28762732885

06 Jul 2026 01:49AM UTC coverage: 90.336% (-0.1%) from 90.457%
28762732885

push

github

Hyshmily
fix: fix known bugs

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

1314 of 1518 branches covered (86.56%)

Branch coverage included in aggregate %.

72 of 80 new or added lines in 9 files covered. (90.0%)

2 existing lines in 2 files now uncovered.

3715 of 4049 relevant lines covered (91.75%)

4.18 hits per line

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

92.16
/common/src/main/java/io/github/hyshmily/hotkey/detection/impl/HotKeyStateMachineImpl.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.detection.impl;
17

18
import static io.github.hyshmily.hotkey.detection.HotKeyStateMachine.State.*;
19

20
import com.google.common.util.concurrent.Striped;
21
import io.github.hyshmily.hotkey.Internal;
22
import io.github.hyshmily.hotkey.detection.HotKeyStateMachine;
23
import io.github.hyshmily.hotkey.model.HotKeyDecision;
24
import java.util.Collections;
25
import java.util.Map;
26
import java.util.concurrent.ConcurrentHashMap;
27
import java.util.concurrent.locks.Lock;
28
import lombok.AllArgsConstructor;
29
import lombok.Getter;
30
import lombok.Setter;
31
import lombok.extern.slf4j.Slf4j;
32

33
/**
34
 * Per-key state machine that governs hot-key lifecycle transitions on the
35
 * Worker side.
36
 *
37
 * <p>Each Worker shard owns a subset of keys (determined by
38
 * {@link io.github.hyshmily.hotkey.sharding.ConsistentHashRing} routing)
39
 * and runs one {@code HotKeyStateMachine} instance per owned shard. The
40
 * state machine converts per-key sliding-window frequency observations
41
 * into lifecycle transitions — promoting COLD keys to CONFIRMED_HOT when
42
 * sustained traffic is detected, and demoting them back to COLD after a
43
 * prolonged cool-down.
44
 *
45
 * <h3>State diagram</h3>
46
 * <pre>
47
 *   COLD
48
 *    │  hotStreak >= confirmCount  (consecutive hot windows)
49
 *    ▼
50
 *   CONFIRMED_HOT   ─────────────────────────────┐
51
 *    │                                           │
52
 *    │  coolStreak >= (coolCount - grace)        │ hotStreak > 0 (revive during pre-cool)
53
 *    ▼                                           │
54
 *   PRE_COOLING ─────────────────────────────────┘
55
 *    │
56
 *    │  coolStreak >= coolCount  (fully cooled)
57
 *    ▼
58
 *   COLD
59
 * </pre>
60
 *
61
 * <h3>Design rationale</h3>
62
 * <ul>
63
 *   <li><b>Fast detection:</b> only {@code confirmCount} consecutive hot windows are
64
 *       needed to promote a key to {@code CONFIRMED_HOT}.</li>
65
 *   <li><b>Slow cool-down:</b> cooling requires many more consecutive cold windows,
66
 *       with an intermediate {@code PRE_COOLING} grace period.  If traffic resumes
67
 *       during this period the key silently returns to {@code CONFIRMED_HOT} without
68
 *       broadcasting (no COOL → HOT oscillation).</li>
69
 *   <li><b>Asymmetric thresholds:</b> protect aggressively, recover cautiously.
70
 *       This prevents flapping when traffic is bursty or when the cluster briefly
71
 *       stops reporting a key.</li>
72
 * </ul>
73
 *
74
 * <p>Thread-safe: per-key state is guarded by a {@link Striped} lock (1024
75
 * stripes). Evaluations of different keys proceed in parallel; evaluations
76
 * of the same key are serialized, eliminating the race window between
77
 * {@code hotStreak++} and the state transition check caused by concurrent
78
 * delivery of the same key across multiple consumer threads.
79
 *
80
 * <p>Designed for single-shard workers; each key is owned by exactly one worker
81
 * thanks to consistent-hash routing on the client side.</p>
82
 */
83
@Internal
84
@Slf4j
4✔
85
@AllArgsConstructor
86
public class HotKeyStateMachineImpl implements HotKeyStateMachine {
87

88
  /** Number of consecutive hot windows required to promote COLD → CONFIRMED_HOT. */
89
  @Getter
90
  @Setter
91
  private volatile int confirmCount;
92

93
  /**
94
   * Total number of consecutive cold windows required for a full cool-down
95
   * (CONFIRMED_HOT → PRE_COOLING → COLD).  Must be greater than
96
   * {@code preCoolGraceCount}.
97
   */
98
  @Getter
99
  @Setter
100
  private volatile int coolCount;
101

102
  /**
103
   * The number of cold windows that mark the entry into PRE_COOLING.
104
   * The remaining {@code coolCount - preCoolGraceCount} windows are the
105
   * grace period during which the key can revive without a broadcast.
106
   */
107
  @Getter
108
  @Setter
109
  private volatile int preCoolGraceCount;
110

111
  /** Current state + streak counters, keyed by cache key. */
112
  private final ConcurrentHashMap<String, KeyState> states = new ConcurrentHashMap<>();
113

114
  /**
115
   * Last evaluation timestamp for each key.  Used by {@link #evictStale(long)}
116
   * to garbage-collect keys that have not been reported for an extended period.
117
   */
118
  private final ConcurrentHashMap<String, Long> stateTimestamps = new ConcurrentHashMap<>();
119

120
  /**
121
   * Per-key striped lock — serializes evaluations of the same key when
122
   * multiple consumer threads process overlapping messages, preventing
123
   * lost increments on {@code hotStreak++} / {@code coolStreak++}.
124
   *
125
   * <p>1024 stripes keep collision probability below 0.1% at
126
   * {@code concurrency=8} while adding negligible memory overhead.
127
   */
128
  private final Striped<Lock> keyLocks = Striped.lazyWeakLock(1024);
129

130
  /**
131
   * Evaluates the current sliding-window observation for the given key and
132
   * returns a decision instructing the caller whether to broadcast a HOT or
133
   * COOL message.
134
   *
135
   * <p>This method implements the state transitions described in the class
136
   * Javadoc. It updates the per-key streak counters ({@code hotStreak} /
137
   * {@code coolStreak}) atomically (via {@link ConcurrentHashMap#computeIfAbsent})
138
   * and returns one of:
139
   * <ul>
140
   *   <li>{@link HotKeyDecision.DecisionType#HOT} — key just crossed the
141
   *       promotion threshold; broadcast HOT to application instances.</li>
142
   *   <li>{@link HotKeyDecision.DecisionType#COOL} — key has fully cooled
143
   *       down; broadcast COOL so apps revert to normal TTL.</li>
144
   *   <li>{@link HotKeyDecision.DecisionType#NONE} — no state transition
145
   *       occurred; no action required.</li>
146
   * </ul>
147
   *
148
   * <p>Silent revive (PRE_COOLING → CONFIRMED_HOT) returns NONE to
149
   * suppress unnecessary broadcasts and prevent HOT/COOL oscillation.
150
   *
151
   * @param key             the cache key (must not be {@code null})
152
   * @param isHotThisWindow {@code true} if the sliding-window frequency sum
153
   *                        exceeds the hot threshold during this evaluation
154
   *                        cycle; {@code false} otherwise
155
   * @return a non-null {@link HotKeyDecision} indicating what action the
156
   *         caller should take (HOT, COOL, or NONE)
157
   */
158
  public HotKeyDecision evaluate(String key, boolean isHotThisWindow) {
159
    // Fast path: never-before-seen key on a cold window — no state to
160
    // mutate (no accumulated hotStreak to reset), skip striped-lock
161
    // acquisition entirely.
162
    if (!isHotThisWindow && !states.containsKey(key)) {
7✔
163
      return HotKeyDecision.none(key);
3✔
164
    }
165

166
    Lock lock = keyLocks.get(key);
6✔
167
    lock.lock();
2✔
168
    try {
169
      // Touch timestamp for eviction tracking
170
      stateTimestamps.put(key, System.currentTimeMillis());
7✔
171

172
      KeyState state = states.computeIfAbsent(key, k -> new KeyState());
11✔
173

174
      if (isHotThisWindow) {
2✔
175
        state.hotStreak++;
6✔
176
        state.coolStreak = 0; // reset cold streak
3✔
177

178
        // COLD → CONFIRMED_HOT transition
179
        if (state.currentState == COLD && state.hotStreak >= confirmCount) {
9✔
180
          state.currentState = CONFIRMED_HOT;
3✔
181
          return HotKeyDecision.hot(key); // broadcast HOT
5✔
182
        }
183

184
        // PRE_COOLING → CONFIRMED_HOT (silent revive)
185
        if (state.currentState == PRE_COOLING) {
4✔
186
          state.currentState = CONFIRMED_HOT;
3✔
187
          return HotKeyDecision.none(key); // no broadcast — avoid oscillation
5✔
188
        }
189
      } else {
190
        state.coolStreak++;
6✔
191
        state.hotStreak = 0; // reset hot streak
3✔
192

193
        // CONFIRMED_HOT → PRE_COOLING transition (first cool phase)
194
        if (state.currentState == CONFIRMED_HOT && state.coolStreak >= Math.max(1, coolCount - preCoolGraceCount)) {
14✔
195
          state.currentState = PRE_COOLING;
3✔
196
          // no broadcast yet — wait for full cool-down
197
        }
198

199
        // PRE_COOLING → COLD transition (fully cooled)
200
        if (state.currentState == PRE_COOLING && state.coolStreak >= coolCount) {
9✔
201
          state.currentState = COLD;
3✔
202
          return HotKeyDecision.cool(key); // broadcast COOL
5✔
203
        }
204
      }
205

206
      return HotKeyDecision.none(key);
5✔
207
    } catch (Exception e) {
×
208
      log.warn("Unexpected StateMachine Exception for key {}", key, e);
×
209
      return HotKeyDecision.none(key);
×
210
    } finally {
211
      lock.unlock();
2✔
212
    }
213
  }
214

215
  /**
216
   * Immediately removes all tracked state for the given key, effectively
217
   * resetting it to {@link State#COLD}.
218
   *
219
   * <p>Called when the Worker fails to obtain a version from Redis and must
220
   * abort the current HOT decision (e.g. Redis is unreachable or the key
221
   * was not found in Redis). After reset, the next {@link #evaluate} call
222
   * for this key will start from scratch with fresh streak counters.
223
   *
224
   * <p>The last-touch timestamp is intentionally not removed to prevent
225
   * immediate re-creation churn in {@link #evictStale} — it will be cleaned
226
   * up lazily on the next eviction cycle.
227
   *
228
   * @param key the cache key to reset
229
   */
230
  public void reset(String key) {
231
    states.remove(key);
5✔
232
    // stateTimestamps is cleaned up lazily by evictStale();
233
    // keeping the timestamp for a while avoids immediate re-creation churn.
234
  }
1✔
235

236
  /**
237
   * Approximate number of keys currently tracked by the state machine.
238
   * <p>The returned value is approximate due to the underlying
239
   * {@link java.util.concurrent.ConcurrentHashMap#size()} semantics
240
   * — it reflects a snapshot and may not account for concurrent
241
   * insertions or removals at the exact moment of the call.</p>
242
   *
243
   * @return approximate count of keys currently tracked
244
   */
245
  public int getTrackedKeys() {
246
    return states.size();
4✔
247
  }
248

249
  /**
250
   * Garbage-collects state for keys that have not been evaluated within
251
   * {@code staleAfterMs} milliseconds.  Should be invoked periodically
252
   * (e.g. every 5 seconds) from a scheduled task.
253
   *
254
   * @param staleAfterMs maximum idle time in milliseconds before a key is evicted
255
   */
256
  public void evictStale(long staleAfterMs) {
257
    long now = System.currentTimeMillis();
2✔
258
    // Single pass: remove stale state and its timestamp together
259
    states
2✔
260
      .keySet()
5✔
261
      .removeIf(key -> {
2✔
262
        Long last = stateTimestamps.get(key);
6✔
263
        if (last == null) {
2!
NEW
264
          return false;
×
265
        }
266
        if (now - last > staleAfterMs) {
7!
267
          stateTimestamps.remove(key);
5✔
268
          return true;
2✔
269
        }
NEW
270
        return false;
×
271
      });
272
    // Orphaned timestamps from reset() — only scan when needed
273
    if (states.size() < stateTimestamps.size()) {
7✔
274
      stateTimestamps.keySet().removeIf(k -> !states.containsKey(k));
15!
275
    }
276
  }
1✔
277

278
  public Map<String, Object> getStateSnapshot(String key) {
279
    Lock lock = keyLocks.get(key);
6✔
280
    lock.lock();
2✔
281
    try {
282
      KeyState keyState = states.get(key);
6✔
283
      if (keyState == null) {
2✔
284
        return Collections.emptyMap();
4✔
285
      }
286
      return Map.of(
7✔
287
        "currentState",
288
        keyState.currentState.name(),
4✔
289
        "hotStreak",
290
        keyState.hotStreak,
4✔
291
        "coolStreak",
292
        keyState.coolStreak
1✔
293
      );
294
    } finally {
295
      lock.unlock();
2✔
296
    }
297
  }
298

299
  /**
300
   * Rolls back the per-key state to its previous value after a broadcast failure.
301
   * This allows the next evaluation window to re-emit the decision.
302
   *
303
   * @param key the key whose state machine should be rolled back
304
   */
305
  public void rollbackToPreviousState(String key, Map<String, Object> previousState) {
306
    Lock lock = keyLocks.get(key);
6✔
307
    lock.lock();
2✔
308
    try {
309
      if (previousState == null) {
2✔
310
        // No previous state; treat as reset
311
        reset(key);
3✔
312
        return;
1✔
313
      }
314

315
      KeyState keyState = states.computeIfAbsent(key, k -> new KeyState());
11✔
316
      keyState.currentState = State.valueOf((String) previousState.get("currentState"));
7✔
317
      keyState.hotStreak = (int) previousState.get("hotStreak");
7✔
318
      keyState.coolStreak = (int) previousState.get("coolStreak");
7✔
319
    } finally {
320
      lock.unlock();
2✔
321
    }
322
  }
1✔
323

324
  private static class KeyState {
2✔
325

326
    /** Current lifecycle stage. */
327
    volatile State currentState = COLD;
3✔
328

329
    /** Number of consecutive windows above the hot threshold. */
330
    volatile int hotStreak = 0;
3✔
331

332
    /** Number of consecutive windows below the hot threshold. */
333
    volatile int coolStreak = 0;
4✔
334
  }
335
}
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