• 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

93.48
/common/src/main/java/io/github/hyshmily/hotkey/detection/HotKeyStateMachine.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;
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.model.HotKeyDecision;
23
import java.util.Collections;
24
import java.util.Map;
25
import java.util.concurrent.ConcurrentHashMap;
26
import java.util.concurrent.locks.Lock;
27
import lombok.AllArgsConstructor;
28
import lombok.Getter;
29
import lombok.Setter;
30
import lombok.extern.slf4j.Slf4j;
31

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

87
  /**
88
   * Key-level state: current lifecycle stage within the hot-key state machine.
89
   *
90
   * <p>Transitions are governed by consecutive window observations
91
   * (see {@link HotKeyStateMachine#evaluate}).
92
   */
93
  public enum State {
3✔
94
    /**
95
     * Not currently tracked as hot. This is the initial state for all keys.
96
     * The key must be observed above the hot threshold for
97
     * {@code confirmCount} consecutive windows to transition to
98
     * {@link #CONFIRMED_HOT}.
99
     */
100
    COLD,
6✔
101
    /**
102
     * Confirmed hot — a HOT broadcast has been sent to the application
103
     * instances. The key's L1 TTL should be extended. If traffic subsides,
104
     * the key transitions to {@link #PRE_COOLING} after a grace period.
105
     */
106
    CONFIRMED_HOT,
6✔
107
    /**
108
     * Transitional stage between HOT and COLD.
109
     *
110
     * <p>No broadcast is sent when entering this stage. If the key becomes
111
     * hot again (observed above threshold during this window) it returns
112
     * to {@link #CONFIRMED_HOT} silently, avoiding unnecessary COOL→HOT
113
     * oscillations. If it remains cold for {@code coolCount} consecutive
114
     * windows, it transitions to {@link #COLD} and a COOL broadcast is
115
     * emitted.
116
     */
117
    PRE_COOLING,
6✔
118
  }
119

120
  /** Number of consecutive hot windows required to promote COLD → CONFIRMED_HOT. */
121
  @Getter
122
  @Setter
123
  private volatile int confirmCount;
124

125
  /**
126
   * Total number of consecutive cold windows required for a full cool-down
127
   * (CONFIRMED_HOT → PRE_COOLING → COLD).  Must be greater than
128
   * {@code preCoolGraceCount}.
129
   */
130
  @Getter
131
  @Setter
132
  private volatile int coolCount;
133

134
  /**
135
   * The number of cold windows that mark the entry into PRE_COOLING.
136
   * The remaining {@code coolCount - preCoolGraceCount} windows are the
137
   * grace period during which the key can revive without a broadcast.
138
   */
139
  @Getter
140
  @Setter
141
  private volatile int preCoolGraceCount;
142

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

146
  /**
147
   * Last evaluation timestamp for each key.  Used by {@link #evictStale(long)}
148
   * to garbage-collect keys that have not been reported for an extended period.
149
   */
150
  private final ConcurrentHashMap<String, Long> stateTimestamps = new ConcurrentHashMap<>();
151

152
  /**
153
   * Per-key striped lock — serializes evaluations of the same key when
154
   * multiple consumer threads process overlapping messages, preventing
155
   * lost increments on {@code hotStreak++} / {@code coolStreak++}.
156
   *
157
   * <p>1024 stripes keep collision probability below 0.1% at
158
   * {@code concurrency=8} while adding negligible memory overhead.
159
   */
160
  private final Striped<Lock> keyLocks = Striped.lazyWeakLock(1024);
161

162
  /**
163
   * Evaluates the current sliding-window observation for the given key and
164
   * returns a decision instructing the caller whether to broadcast a HOT or
165
   * COOL message.
166
   *
167
   * <p>This method implements the state transitions described in the class
168
   * Javadoc. It updates the per-key streak counters ({@code hotStreak} /
169
   * {@code coolStreak}) atomically (via {@link ConcurrentHashMap#computeIfAbsent})
170
   * and returns one of:
171
   * <ul>
172
   *   <li>{@link HotKeyDecision.DecisionType#HOT} — key just crossed the
173
   *       promotion threshold; broadcast HOT to application instances.</li>
174
   *   <li>{@link HotKeyDecision.DecisionType#COOL} — key has fully cooled
175
   *       down; broadcast COOL so apps revert to normal TTL.</li>
176
   *   <li>{@link HotKeyDecision.DecisionType#NONE} — no state transition
177
   *       occurred; no action required.</li>
178
   * </ul>
179
   *
180
   * <p>Silent revive (PRE_COOLING → CONFIRMED_HOT) returns NONE to
181
   * suppress unnecessary broadcasts and prevent HOT/COOL oscillation.
182
   *
183
   * @param key             the cache key (must not be {@code null})
184
   * @param isHotThisWindow {@code true} if the sliding-window frequency sum
185
   *                        exceeds the hot threshold during this evaluation
186
   *                        cycle; {@code false} otherwise
187
   * @return a non-null {@link HotKeyDecision} indicating what action the
188
   *         caller should take (HOT, COOL, or NONE)
189
   */
190
  public HotKeyDecision evaluate(String key, boolean isHotThisWindow) {
191
    // Fast path: never-before-seen key on a cold window — no state to
192
    // mutate (no accumulated hotStreak to reset), skip striped-lock
193
    // acquisition entirely.
194
    if (!isHotThisWindow && !states.containsKey(key)) {
7✔
195
      stateTimestamps.put(key, System.currentTimeMillis());
7✔
196
      return HotKeyDecision.none(key);
3✔
197
    }
198

199
    keyLocks.get(key).lock();
6✔
200
    try {
201
      // Touch timestamp for eviction tracking
202
      stateTimestamps.put(key, System.currentTimeMillis());
7✔
203

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

206
      if (isHotThisWindow) {
2✔
207
        state.hotStreak++;
6✔
208
        state.coolStreak = 0; // reset cold streak
3✔
209

210
        // COLD → CONFIRMED_HOT transition
211
        if (state.currentState == COLD && state.hotStreak >= confirmCount) {
9✔
212
          state.currentState = CONFIRMED_HOT;
3✔
213
          return HotKeyDecision.hot(key); // broadcast HOT
5✔
214
        }
215

216
        // PRE_COOLING → CONFIRMED_HOT (silent revive)
217
        if (state.currentState == PRE_COOLING) {
4✔
218
          state.currentState = CONFIRMED_HOT;
3✔
219
          return HotKeyDecision.none(key); // no broadcast — avoid oscillation
5✔
220
        }
221
      } else {
222
        state.coolStreak++;
6✔
223
        state.hotStreak = 0; // reset hot streak
3✔
224

225
        // CONFIRMED_HOT → PRE_COOLING transition (first cool phase)
226
        if (state.currentState == CONFIRMED_HOT && state.coolStreak >= coolCount - preCoolGraceCount) {
12✔
227
          state.currentState = PRE_COOLING;
3✔
228
          // no broadcast yet — wait for full cool-down
229
        }
230

231
        // PRE_COOLING → COLD transition (fully cooled)
232
        if (state.currentState == PRE_COOLING && state.coolStreak >= coolCount) {
9✔
233
          state.currentState = COLD;
3✔
234
          return HotKeyDecision.cool(key); // broadcast COOL
5✔
235
        }
236
      }
237

238
      return HotKeyDecision.none(key);
5✔
239
    } catch (Exception e) {
×
240
      log.warn("Unexpected StateMachine Exception for key {}", key, e);
×
241
      return HotKeyDecision.none(key);
×
242
    } finally {
243
      keyLocks.get(key).unlock();
6✔
244
    }
245
  }
246

247
  /**
248
   * Immediately removes all tracked state for the given key, effectively
249
   * resetting it to {@link State#COLD}.
250
   *
251
   * <p>Called when the Worker fails to obtain a version from Redis and must
252
   * abort the current HOT decision (e.g. Redis is unreachable or the key
253
   * was not found in Redis). After reset, the next {@link #evaluate} call
254
   * for this key will start from scratch with fresh streak counters.
255
   *
256
   * <p>The last-touch timestamp is intentionally not removed to prevent
257
   * immediate re-creation churn in {@link #evictStale} — it will be cleaned
258
   * up lazily on the next eviction cycle.
259
   *
260
   * @param key the cache key to reset
261
   */
262
  public void reset(String key) {
263
    states.remove(key);
5✔
264
    // stateTimestamps is cleaned up lazily by evictStale();
265
    // keeping the timestamp for a while avoids immediate re-creation churn.
266
  }
1✔
267

268
  /**
269
   * Approximate number of keys currently tracked by the state machine.
270
   * <p>The returned value is approximate due to the underlying
271
   * {@link java.util.concurrent.ConcurrentHashMap#size()} semantics
272
   * — it reflects a snapshot and may not account for concurrent
273
   * insertions or removals at the exact moment of the call.</p>
274
   *
275
   * @return approximate count of keys currently tracked
276
   */
277
  public int getTrackedKeys() {
278
    return states.size();
4✔
279
  }
280

281
  /**
282
   * Garbage-collects state for keys that have not been evaluated within
283
   * {@code staleAfterMs} milliseconds.  Should be invoked periodically
284
   * (e.g. every 5 seconds) from a scheduled task.
285
   *
286
   * @param staleAfterMs maximum idle time in milliseconds before a key is evicted
287
   */
288
  public void evictStale(long staleAfterMs) {
289
    long now = System.currentTimeMillis();
2✔
290
    // Remove state entries whose last touch is older than the threshold
291
    states
2✔
292
      .keySet()
5✔
293
      .removeIf(key -> {
2✔
294
        Long last = stateTimestamps.get(key);
6✔
295
        return last != null && now - last > staleAfterMs;
12!
296
      });
297
    // Purge orphaned timestamps
298
    stateTimestamps.keySet().removeIf(k -> !states.containsKey(k));
15!
299
  }
1✔
300

301
  public Map<String, Object> getStateSnapshot(String key) {
302
    KeyState keyState = states.get(key);
6✔
303
    if (keyState == null) {
2✔
304
      return Collections.emptyMap();
2✔
305
    }
306
    return Map.of(
5✔
307
      "currentState",
308
      keyState.currentState.name(),
4✔
309
      "hotStreak",
310
      keyState.hotStreak,
4✔
311
      "coolStreak",
312
      keyState.coolStreak
1✔
313
    );
314
  }
315

316
  /**
317
   * Rolls back the per-key state to its previous value after a broadcast failure.
318
   * This allows the next evaluation window to re-emit the decision.
319
   *
320
   * @param key the key whose state machine should be rolled back
321
   */
322
  public void rollbackToPreviousState(String key, Map<String, Object> previousState) {
323
    if (previousState == null) {
2✔
324
      // No previous state; treat as reset
325
      reset(key);
3✔
326
      return;
1✔
327
    }
328

329
    KeyState keyState = states.computeIfAbsent(key, k -> new KeyState());
11✔
330
    keyState.currentState = State.valueOf((String) previousState.get("currentState"));
7✔
331
    keyState.hotStreak = (int) previousState.get("hotStreak");
7✔
332
    keyState.coolStreak = (int) previousState.get("coolStreak");
7✔
333
  }
1✔
334

335
  private static class KeyState {
2✔
336

337
    /** Current lifecycle stage. */
338
    volatile State currentState = COLD;
3✔
339

340
    /** Number of consecutive windows above the hot threshold. */
341
    volatile int hotStreak = 0;
3✔
342

343
    /** Number of consecutive windows below the hot threshold. */
344
    volatile int coolStreak = 0;
4✔
345
  }
346
}
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