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

Hyshmily / hotkey / 28216525937

26 Jun 2026 04:08AM UTC coverage: 91.258% (-1.4%) from 92.707%
28216525937

push

github

Hyshmily
feat: add circuit breaker, null-value caching, and heartbeat refinements

- Introduce HotKeyCircuitBreaker with sliding-window failure detection
- Cache null/miss results with configurable TTL (null-value-ttl-seconds)
- Add per-Worker exponential backoff to heartbeat verifier (verify-max-backoff-ms)
- Support min-alive-workers config for cluster health threshold
- Migrate HotKeyEndpoint from Actuator @Endpoint to Spring MVC @RestController
- Add NumberFormatException handling in StateMachineEndpoint

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

1226 of 1419 branches covered (86.4%)

Branch coverage included in aggregate %.

181 of 229 new or added lines in 12 files covered. (79.04%)

1 existing line in 1 file now uncovered.

3503 of 3763 relevant lines covered (93.09%)

4.22 hits per line

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

92.31
/common/src/main/java/io/github/hyshmily/hotkey/cache/CacheExpireManager.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.cache;
17

18
import static io.github.hyshmily.hotkey.constants.HotKeyConstants.VERSION_DEFAULT;
19

20
import com.github.benmanes.caffeine.cache.Cache;
21
import io.github.hyshmily.hotkey.autoconfigure.HotKeyProperties;
22
import io.github.hyshmily.hotkey.model.CacheEntry;
23
import io.github.hyshmily.hotkey.model.KeyState;
24
import java.util.Optional;
25
import java.util.concurrent.*;
26
import java.util.function.Supplier;
27
import lombok.Getter;
28
import lombok.extern.slf4j.Slf4j;
29

30
/**
31
 * Manages hard and soft TTL computation for {@link CacheEntry} instances.
32
 * <p>
33
 * Hard TTL controls Caffeine eviction; soft TTL controls stale-while-revalidate background refresh.
34
 * Each has a normal-key and hot-key variant, with an optional override taking precedence over the default.
35
 */
36
@Getter
37
@Slf4j
4✔
38
public class CacheExpireManager {
39

40
  /** The underlying L1 Caffeine cache instance. */
41
  private final Cache<String, Object> caffeineCache;
42
  /** Async executor for background refresh tasks. */
43
  private final Executor executor;
44
  /** TTL configuration providing normal and hot-key TTL values. */
45
  private final HotKeyProperties ttlConfig;
46
  /** Semaphore limiting concurrent background refresh operations (null if soft expire disabled). */
47
  // null when soft expire is disabled; always guarded by isSoftExpireEnabled() check
48
  private final Semaphore refreshLimiter;
49
  /** Per-key dedup for background refreshes — prevents concurrent refresh for the same key. */
50
  private final ConcurrentHashMap<String, CompletableFuture<Void>> pendingRefreshes = new ConcurrentHashMap<>();
10✔
51
  /** Whether TTL jitter is enabled (from config). */
52
  private final boolean ttlJitterEnabled;
53
  /** Jitter ratio applied to TTLs to prevent cache stampedes (from config, e.g. 0.1 = ±10%). */
54
  private final double ttlJitterRatio;
55

56
  private static final long refreshTimeoutSeconds = 30;
57

58
  /**
59
   * Creates a CacheExpireManager with the given Caffeine cache, executor, and TTL config.
60
   *
61
   * @param caffeineCache   the underlying L1 Caffeine cache
62
   * @param executor        async executor for background refresh
63
   * @param ttlConfig       TTL configuration (normal and hot-key variants)
64
   * @param refreshMaxPools maximum concurrent background refreshes (capped at 100)
65
   */
66
  public CacheExpireManager(
67
    Cache<String, Object> caffeineCache,
68
    Executor executor,
69
    HotKeyProperties ttlConfig,
70
    int refreshMaxPools
71
  ) {
2✔
72
    this.caffeineCache = caffeineCache;
3✔
73
    this.executor = executor;
3✔
74
    this.ttlConfig = ttlConfig;
3✔
75
    this.refreshLimiter = ttlConfig.isSoftExpireEnabled()
4✔
76
      ? new Semaphore(refreshMaxPools > 0 ? refreshMaxPools : 100)
8!
77
      : null;
2✔
78
    this.ttlJitterEnabled = ttlConfig.isTtlJitterEnabled();
4✔
79
    this.ttlJitterRatio = ttlConfig.getTtlJitterRatio();
4✔
80
  }
1✔
81

82
  /**
83
   * Create a CacheExpireManager with explicit jitter control (for testing).
84
   */
85
  CacheExpireManager(
86
    Cache<String, Object> caffeineCache,
87
    Executor executor,
88
    HotKeyProperties ttlConfig,
89
    int refreshMaxPools,
90
    boolean ttlJitterEnabled,
91
    double ttlJitterRatio
92
  ) {
2✔
93
    this.caffeineCache = caffeineCache;
3✔
94
    this.executor = executor;
3✔
95
    this.ttlConfig = ttlConfig;
3✔
96
    this.refreshLimiter = ttlConfig.isSoftExpireEnabled()
4!
97
      ? new Semaphore(refreshMaxPools > 0 ? refreshMaxPools : 100)
8!
98
      : null;
1✔
99
    this.ttlJitterEnabled = ttlJitterEnabled;
3✔
100
    this.ttlJitterRatio = ttlJitterRatio;
3✔
101
  }
1✔
102

103
  /**
104
   * Whether any soft TTL is configured (normal or hot).
105
   *
106
   * @return {@code true} if soft expire is enabled in the configuration
107
   */
108
  public boolean isSoftExpireEnabled() {
109
    return ttlConfig.isSoftExpireEnabled();
4✔
110
  }
111

112
  public long computeNullExpireAt(long nullTtlMs) {
NEW
113
    long effective = nullTtlMs > 0 ? nullTtlMs : ttlConfig.effectiveNullTtlMs();
×
NEW
114
    return toHardExpireTimestamp(effective);
×
115
  }
116

117
  /**
118
   * Hard expire timestamp from an explicit TTL duration.
119
   * Falls back to the normal-key default if {@code hardTtlMs <= 0}.
120
   *
121
   * @param hardTtlMs the hard TTL duration in milliseconds (&lt;= 0 uses configured default)
122
   * @return absolute epoch-ms timestamp for hard expiry
123
   */
124
  public long computeHardExpireAt(long hardTtlMs) {
125
    long effective = hardTtlMs > 0 ? hardTtlMs : ttlConfig.effectiveHardTtlMs();
10✔
126
    return toHardExpireTimestamp(effective);
4✔
127
  }
128

129
  /**
130
   * Hard expire timestamp for hot keys, using {@code default-hot-hard-ttl} / {@code hot-hard-ttl}.
131
   * Returns {@code Long.MAX_VALUE} if hot hard expire is disabled (TTL &lt;= 0).
132
   *
133
   * @return absolute epoch-ms timestamp for hot-key hard expiry
134
   */
135
  public long computeHotHardExpireAt() {
136
    return toHardExpireTimestamp(ttlConfig.effectiveHotHardTtlMs());
6✔
137
  }
138

139
  /**
140
   * Soft expire timestamp for hot keys, using {@code default-hot-soft-ttl} / {@code hot-soft-ttl}.
141
   *
142
   * @return absolute epoch-ms timestamp for hot-key soft expiry, or 0 if disabled
143
   */
144
  public long computeHotSoftExpireAt() {
145
    return toSoftExpireTimestamp(ttlConfig.effectiveHotSoftTtlMs());
6✔
146
  }
147

148
  /**
149
   * Soft expire timestamp from an explicit TTL duration.
150
   * Falls back to the normal-key default if {@code softTtlMs <= 0}. Returns 0 if soft expire is disabled.
151
   *
152
   * @param softTtlMs the soft TTL duration in milliseconds (&lt;= 0 uses configured default)
153
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
154
   */
155
  public long computeSoftExpireAt(long softTtlMs) {
156
    if (!isSoftExpireEnabled()) {
3✔
157
      return 0L;
2✔
158
    }
159
    long effective = softTtlMs > 0 ? softTtlMs : ttlConfig.effectiveSoftTtlMs();
7!
160
    return toSoftExpireTimestamp(effective);
4✔
161
  }
162

163
  /**
164
   * Effective hard TTL for normal keys (override > default).
165
   *
166
   * @return effective hard TTL duration in milliseconds
167
   */
168
  public long getEffectiveHardTtlMs() {
169
    return ttlConfig.effectiveHardTtlMs();
4✔
170
  }
171

172
  /**
173
   * Effective hard TTL for hot keys (override > default).
174
   *
175
   * @return effective hot hard TTL duration in milliseconds
176
   */
177
  public long getEffectiveHotHardTtlMs() {
178
    return ttlConfig.effectiveHotHardTtlMs();
4✔
179
  }
180

181
  /**
182
   * Effective soft TTL for normal keys (override > default).
183
   *
184
   * @return effective soft TTL duration in milliseconds
185
   */
186
  public long getEffectiveSoftTtlMs() {
187
    return ttlConfig.effectiveSoftTtlMs();
4✔
188
  }
189

190
  /**
191
   * Effective soft TTL for hot keys (override > default).
192
   *
193
   * @return effective hot soft TTL duration in milliseconds
194
   */
195
  public long getEffectiveHotSoftTtlMs() {
196
    return ttlConfig.effectiveHotSoftTtlMs();
4✔
197
  }
198

199
  /**
200
   * Convert a TTL duration (ms) to an absolute epoch-ms expiration timestamp.
201
   * Propagates {@link Long#MAX_VALUE} unchanged — used to signal permanent entries
202
   * (pure logical expiry with no hard TTL eviction).
203
   */
204
  private long toHardExpireTimestamp(long hardTtlMs) {
205
    if (hardTtlMs == Long.MAX_VALUE) {
4✔
206
      return Long.MAX_VALUE;
2✔
207
    }
208
    long jitter = ttlJitterEnabled
3✔
209
      ? (long) (hardTtlMs * ttlJitterRatio * ThreadLocalRandom.current().nextDouble(-1.0, 1.0))
12✔
210
      : 0L;
2✔
211

212
    return hardTtlMs > 0 ? System.currentTimeMillis() + Math.max(1, hardTtlMs + jitter) : Long.MAX_VALUE;
14✔
213
  }
214

215
  /**
216
   * Convert a soft TTL duration (ms) to an absolute epoch-ms expiration timestamp.
217
   * Applies configurable jitter (default ±10%) to prevent cache stampedes.
218
   * Returns 0 if soft expire is disabled or the TTL is non-positive.
219
   *
220
   * @param softTtlMs the soft TTL duration in milliseconds
221
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
222
   */
223
  private long toSoftExpireTimestamp(long softTtlMs) {
224
    if (!isSoftExpireEnabled() || softTtlMs <= 0) {
7!
225
      return 0L;
2✔
226
    }
227
    if (softTtlMs == Long.MAX_VALUE) {
4✔
228
      return Long.MAX_VALUE;
2✔
229
    }
230
    long jitter = ttlJitterEnabled
3✔
231
      ? (long) (softTtlMs * ttlJitterRatio * ThreadLocalRandom.current().nextDouble(-1.0, 1.0))
12✔
232
      : 0L;
2✔
233

234
    return System.currentTimeMillis() + Math.max(1, softTtlMs + jitter);
8✔
235
  }
236

237
  /**
238
   * Check whether the given key's soft TTL has expired.
239
   *
240
   * @return {@code true} if the entry's soft TTL has expired or the entry is absent
241
   * @throws IllegalStateException if soft expire is disabled
242
   */
243
  public boolean isSoftExpired(Object cacheEntry) {
244
    if (!isSoftExpireEnabled()) {
3✔
245
      throw new IllegalStateException(
5✔
246
        "CacheExpireManager soft expire is disabled, isSoftExpired() should not be called"
247
      );
248
    }
249
    if (cacheEntry instanceof CacheEntry ce) {
6✔
250
      long expireAt = ce.getSoftExpireAtMs();
3✔
251
      return expireAt <= 0 || expireAt < System.currentTimeMillis();
12✔
252
    }
253
    return true;
2✔
254
  }
255

256
  /**
257
   * Trigger an async background refresh for the given key.
258
   * The refreshed entry preserves the existing {@code dataVersion},
259
   * {@code isVersionDegraded}, and {@code decisionVersion} to avoid
260
   * overwriting newer data or Worker decisions.
261
   * <p>Per-key dedup via {@link #pendingRefreshes} prevents concurrent refreshes
262
   * for the same key (TOCTOU stampede protection).  Rate-limited by a global
263
   * semaphore to bound total concurrent refreshes across all keys.
264
   * Skipped silently if the refresh limiter is exhausted or soft expire is disabled.
265
   *
266
   * @param cacheKey  the key to refresh in the background
267
   * @param reader    the value supplier for generating the refreshed value
268
   * @param softTtlMs the soft TTL duration in milliseconds applied to the refreshed entry
269
   */
270
  public void triggerBackgroundRefresh(String cacheKey, Supplier<?> reader, long softTtlMs) {
271
    if (!isSoftExpireEnabled()) {
3✔
272
      return;
1✔
273
    }
274

275
    // Per-key dedup: fast path — skip if a refresh is already in-flight for this key
276
    CompletableFuture<Void> inflight = pendingRefreshes.get(cacheKey);
6✔
277
    if (inflight != null && !inflight.isDone()) {
5!
278
      return;
1✔
279
    }
280

281
    // Pre-check pendingRefreshes again under the imminent putIfAbsent race window
282
    // to avoid wasting a semaphore permit on a loser of per-key dedup.
283
    CompletableFuture<Void> marker = new CompletableFuture<>();
4✔
284
    CompletableFuture<Void> race = pendingRefreshes.putIfAbsent(cacheKey, marker);
7✔
285
    if (race != null) {
2!
286
      return;
×
287
    }
288

289
    // refreshLimiter is null when soft expire is disabled; isSoftExpireEnabled() above
290
    // guarantees it is non-null here.
291
    if (!refreshLimiter.tryAcquire()) {
4✔
292
      pendingRefreshes.remove(cacheKey);
5✔
293
      marker.complete(null);
4✔
294
      log.debug("Refresh limiter blocked, skip background refresh: {}", cacheKey);
4✔
295
      return;
1✔
296
    }
297

298
    long refreshStartDataVersion = Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
7✔
299
      .filter(CacheEntry.class::isInstance)
6✔
300
      .map(CacheEntry.class::cast)
5✔
301
      .map(CacheEntry::getDataVersion)
2✔
302
      .orElse(VERSION_DEFAULT);
5✔
303

304
    // Wrap the async refresh with a timeout guard so a stuck supplier never leaks
305
    // a pending refresh marker. The orTimeout future is always wrapped because the
306
    // orTimeout call itself may throw RejectedExecutionException when the executor
307
    // is saturated — we catch that separately below.
308
    CompletableFuture<?> completableFuture = new CompletableFuture<>();
4✔
309

310
    try {
311
      completableFuture = CompletableFuture.supplyAsync(reader, executor).orTimeout(
8✔
312
        refreshTimeoutSeconds,
313
        TimeUnit.SECONDS
314
      );
315
    } catch (RejectedExecutionException e) {
1✔
316
      // Executor saturated — release the limiter permit and the pending-refresh
317
      // slot so the next read can retry immediately instead of being blocked forever.
318
      refreshLimiter.release();
3✔
319
      pendingRefreshes.remove(cacheKey);
5✔
320
      marker.complete(null);
4✔
321
      log.warn("Background refresh rejected by executor (saturated), key={}", cacheKey);
4✔
322
    }
1✔
323

324
    completableFuture.whenComplete((value, error) -> {
9✔
325
      try {
326
        if (error != null) {
2✔
327
          if (error instanceof TimeoutException) {
3!
328
            log.warn("Background soft refresh timed out after {}s: {}", refreshTimeoutSeconds, cacheKey);
×
329
          } else {
330
            log.warn("Background soft refresh failed: {}", cacheKey, error);
5✔
331
          }
332
          return;
1✔
333
        }
334
        if (value != null) {
2✔
335
          caffeineCache
2✔
336
            .asMap()
8✔
337
            .compute(cacheKey, (k, existing) ->
2✔
338
              Optional.ofNullable(existing)
5✔
339
                .filter(CacheEntry.class::isInstance)
6✔
340
                .map(CacheEntry.class::cast)
10✔
341
                .map(cacheEntry -> {
5✔
342
                  if (cacheEntry.getDataVersion() > refreshStartDataVersion) {
5✔
343
                    log.debug("Async refresh discarded: newer version exists: {}", cacheKey);
4✔
344
                    return cacheEntry;
2✔
345
                  }
346
                  return cacheEntry
2✔
347
                    .toBuilder()
2✔
348
                    .value(value)
2✔
349
                    .softTtlMs(softTtlMs)
3✔
350
                    .softExpireAtMs(computeSoftExpireAt(softTtlMs))
2✔
351
                    .build();
1✔
352
                })
353
                .orElseGet(() ->
1✔
354
                  CacheEntry.builder()
3✔
355
                    .value(value)
2✔
356
                    .dataVersion(VERSION_DEFAULT)
2✔
357
                    .isVersionDegraded(false)
2✔
358
                    .decisionVersion(0L)
2✔
359
                    .hardTtlMs(0L)
2✔
360
                    .hardExpireAtMs(Long.MAX_VALUE)
2✔
361
                    .softTtlMs(softTtlMs)
3✔
362
                    .softExpireAtMs(computeSoftExpireAt(softTtlMs))
3✔
363
                    .keyState(KeyState.NORMAL)
2✔
364
                    .normalHardTtlMs(0L)
2✔
365
                    .normalSoftTtlMs(0L)
1✔
366
                    .build()
1✔
367
                )
368
            );
369
        }
370
      } finally {
371
        refreshLimiter.release();
3✔
372
        pendingRefreshes.remove(cacheKey);
5✔
373
        marker.complete(null);
4✔
374
      }
375
    });
1✔
376
  }
1✔
377
}
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