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

Hyshmily / hotkey / 28738321635

05 Jul 2026 10:51AM UTC coverage: 90.457% (+0.04%) from 90.421%
28738321635

push

github

Hyshmily
refactor: extract interfaces for 12 core classes (Batch 1+2)

Extract interfaces from concrete classes, impls moved to impl/ subpackages:

Batch 1 (HotPath):
- CircuitBreaker, ExpireManager, SingleFlight
- KeyReporter, RuleMatcher, HealthView

Batch 2 (Supporting):
- VersionController, RingManager, HotKeyStateMachine
- SystemLoadMonitor, BbrRateLimiter, SreRateLimiter

Pattern: interface at original package, impl named XxxImpl under impl/

1296 of 1498 branches covered (86.52%)

Branch coverage included in aggregate %.

448 of 480 new or added lines in 19 files covered. (93.33%)

3690 of 4014 relevant lines covered (91.93%)

4.19 hits per line

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

93.18
/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 io.github.hyshmily.hotkey.detection.HotKeyStateMachine;
21

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

250
  /**
251
   * Garbage-collects state for keys that have not been evaluated within
252
   * {@code staleAfterMs} milliseconds.  Should be invoked periodically
253
   * (e.g. every 5 seconds) from a scheduled task.
254
   *
255
   * @param staleAfterMs maximum idle time in milliseconds before a key is evicted
256
   */
257
  public void evictStale(long staleAfterMs) {
258
    long now = System.currentTimeMillis();
2✔
259
    // Remove state entries whose last touch is older than the threshold
260
    states
2✔
261
      .keySet()
5✔
262
      .removeIf(key -> {
2✔
263
        Long last = stateTimestamps.get(key);
6✔
264
        return last != null && now - last > staleAfterMs;
12!
265
      });
266
    // Purge orphaned timestamps
267
    stateTimestamps.keySet().removeIf(k -> !states.containsKey(k));
15!
268
  }
1✔
269

270
  public Map<String, Object> getStateSnapshot(String key) {
271
    KeyState keyState = states.get(key);
6✔
272
    if (keyState == null) {
2✔
273
      return Collections.emptyMap();
2✔
274
    }
275
    return Map.of(
5✔
276
      "currentState",
277
      keyState.currentState.name(),
4✔
278
      "hotStreak",
279
      keyState.hotStreak,
4✔
280
      "coolStreak",
281
      keyState.coolStreak
1✔
282
    );
283
  }
284

285
  /**
286
   * Rolls back the per-key state to its previous value after a broadcast failure.
287
   * This allows the next evaluation window to re-emit the decision.
288
   *
289
   * @param key the key whose state machine should be rolled back
290
   */
291
  public void rollbackToPreviousState(String key, Map<String, Object> previousState) {
292
    if (previousState == null) {
2✔
293
      // No previous state; treat as reset
294
      reset(key);
3✔
295
      return;
1✔
296
    }
297

298
    KeyState keyState = states.computeIfAbsent(key, k -> new KeyState());
11✔
299
    keyState.currentState = State.valueOf((String) previousState.get("currentState"));
7✔
300
    keyState.hotStreak = (int) previousState.get("hotStreak");
7✔
301
    keyState.coolStreak = (int) previousState.get("coolStreak");
7✔
302
  }
1✔
303

304
  private static class KeyState {
2✔
305

306
    /** Current lifecycle stage. */
307
    volatile State currentState = COLD;
3✔
308

309
    /** Number of consecutive windows above the hot threshold. */
310
    volatile int hotStreak = 0;
3✔
311

312
    /** Number of consecutive windows below the hot threshold. */
313
    volatile int coolStreak = 0;
4✔
314
  }
315
}
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