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

Hyshmily / hotkey / 28316658488

28 Jun 2026 08:34AM UTC coverage: 91.178% (-0.05%) from 91.227%
28316658488

push

github

Hyshmily
test :fix CI tests

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

1264 of 1445 branches covered (87.47%)

Branch coverage included in aggregate %.

3583 of 3871 relevant lines covered (92.56%)

4.21 hits per line

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

94.83
/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 io.github.hyshmily.hotkey.util.DelayUtil;
25
import io.github.hyshmily.hotkey.util.TimeSource;
26
import java.util.Optional;
27
import java.util.concurrent.*;
28
import java.util.function.Supplier;
29
import lombok.Getter;
30
import lombok.extern.slf4j.Slf4j;
31

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

42
  /** The underlying L1 Caffeine cache instance. */
43
  private final Cache<String, Object> caffeineCache;
44
  /** Async executor for background refresh tasks. */
45
  private final Executor executor;
46
  /** TTL configuration providing normal and hot-key TTL values. */
47
  private final HotKeyProperties ttlConfig;
48
  /** Semaphore limiting concurrent background refresh operations (null if soft expire disabled). */
49
  // null when soft expire is disabled; always guarded by isSoftExpireEnabled() check
50
  private final Semaphore refreshLimiter;
51
  /** Per-key dedup for background refreshes — prevents concurrent refresh for the same key. */
52
  private final ConcurrentHashMap<String, CompletableFuture<Void>> pendingRefreshes = new ConcurrentHashMap<>();
10✔
53
  /** Jitter ratio applied to TTLs to prevent cache stampedes (from config, default 0.05 = ±5%). */
54
  private final double defaultTtlJitterRatio;
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.defaultTtlJitterRatio = ttlConfig.getTtlJitterRatio();
4✔
79
  }
1✔
80

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

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

109
  public long computeNullExpireAt(long nullTtlMs) {
110
    long effective = nullTtlMs > 0 ? nullTtlMs : ttlConfig.effectiveNullTtlMs();
10✔
111
    return toHardExpireTimestamp(effective);
4✔
112
  }
113

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

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

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

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

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

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

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

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

196
  /**
197
   * Convert a TTL duration (ms) to an absolute epoch-ms expiration timestamp
198
   * using the configured default jitter ratio.
199
   * Propagates {@link Long#MAX_VALUE} unchanged — used to signal permanent entries
200
   * (pure logical expiry with no hard TTL eviction).
201
   */
202
  public long toHardExpireTimestamp(long hardTtlMs) {
203
    return toHardExpireTimestamp(hardTtlMs, defaultTtlJitterRatio);
6✔
204
  }
205

206
  /**
207
   * Convert a TTL duration (ms) to an absolute epoch-ms expiration timestamp
208
   * using the given jitter ratio instead of the configured default.
209
   * Propagates {@link Long#MAX_VALUE} unchanged.
210
   *
211
   * @param hardTtlMs      the hard TTL duration in milliseconds
212
   * @param ttlJitterRatio the jitter ratio to apply (0.0–1.0)
213
   * @return absolute epoch-ms timestamp for hard expiry
214
   */
215
  public long toHardExpireTimestamp(long hardTtlMs, double ttlJitterRatio) {
216
    if (hardTtlMs == Long.MAX_VALUE) {
4✔
217
      return Long.MAX_VALUE;
2✔
218
    }
219
    long jitter = DelayUtil.computeTtlJitter(hardTtlMs, ttlJitterRatio);
4✔
220

221
    return hardTtlMs > 0 ? TimeSource.currentTimeMillis() + Math.max(1, hardTtlMs + jitter) : Long.MAX_VALUE;
14✔
222
  }
223

224
  /**
225
   * Convert a soft TTL duration (ms) to an absolute epoch-ms expiration timestamp.
226
   * Applies configurable jitter (default ±5%) to prevent cache stampedes.
227
   * Returns 0 if soft expire is disabled or the TTL is non-positive.
228
   *
229
   * @param softTtlMs the soft TTL duration in milliseconds
230
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
231
   */
232
  public long toSoftExpireTimestamp(long softTtlMs) {
233
    return toSoftExpireTimestamp(softTtlMs, defaultTtlJitterRatio);
6✔
234
  }
235

236
  /**
237
   * Convert a soft TTL duration (ms) to an absolute epoch-ms expiration timestamp
238
   * using the given jitter ratio instead of the configured default.
239
   * Returns 0 if soft expire is disabled. Propagates {@link Long#MAX_VALUE} unchanged.
240
   *
241
   * @param softTtlMs      the soft TTL duration in milliseconds
242
   * @param ttlJitterRatio the jitter ratio to apply (0.0–1.0)
243
   * @return absolute epoch-ms timestamp for soft expiry, or 0 if disabled
244
   */
245
  public long toSoftExpireTimestamp(long softTtlMs, double ttlJitterRatio) {
246
    if (!isSoftExpireEnabled() || softTtlMs <= 0) {
7✔
247
      return 0L;
2✔
248
    }
249
    if (softTtlMs == Long.MAX_VALUE) {
4✔
250
      return Long.MAX_VALUE;
2✔
251
    }
252
    long jitter = DelayUtil.computeTtlJitter(softTtlMs, ttlJitterRatio);
4✔
253

254
    return TimeSource.currentTimeMillis() + Math.max(1, softTtlMs + jitter);
8✔
255
  }
256

257
  /**
258
   * Check whether the given key's soft TTL has expired.
259
   *
260
   * @return {@code true} if the entry's soft TTL has expired or the entry is absent
261
   * @throws IllegalStateException if soft expire is disabled
262
   */
263
  public boolean isSoftExpired(Object cacheEntry) {
264
    if (!isSoftExpireEnabled()) {
3✔
265
      throw new IllegalStateException(
5✔
266
        "CacheExpireManager soft expire is disabled, isSoftExpired() should not be called"
267
      );
268
    }
269
    if (cacheEntry instanceof CacheEntry ce) {
6✔
270
      long expireAt = ce.getSoftExpireAtMs();
3✔
271
      return expireAt <= 0 || expireAt < TimeSource.currentTimeMillis();
12✔
272
    }
273
    return true;
2✔
274
  }
275

276
  /**
277
   * Trigger an async background refresh for the given key.
278
   * The refreshed entry preserves the existing {@code dataVersion},
279
   * {@code isVersionDegraded}, and {@code decisionVersion} to avoid
280
   * overwriting newer data or Worker decisions.
281
   * <p>Per-key dedup via {@link #pendingRefreshes} prevents concurrent refreshes
282
   * for the same key (TOCTOU stampede protection).  Rate-limited by a global
283
   * semaphore to bound total concurrent refreshes across all keys.
284
   * Skipped silently if the refresh limiter is exhausted or soft expire is disabled.
285
   *
286
   * @param cacheKey  the key to refresh in the background
287
   * @param reader    the value supplier for generating the refreshed value
288
   * @param softTtlMs the soft TTL duration in milliseconds applied to the refreshed entry
289
   */
290
  public void triggerBackgroundRefresh(String cacheKey, Supplier<?> reader, long softTtlMs) {
291
    if (!isSoftExpireEnabled()) {
3✔
292
      return;
1✔
293
    }
294

295
    // Per-key dedup: fast path — skip if a refresh is already in-flight for this key
296
    CompletableFuture<Void> inflight = pendingRefreshes.get(cacheKey);
6✔
297
    if (inflight != null && !inflight.isDone()) {
5!
298
      return;
1✔
299
    }
300

301
    // Pre-check pendingRefreshes again under the imminent putIfAbsent race window
302
    // to avoid wasting a semaphore permit on a loser of per-key dedup.
303
    CompletableFuture<Void> marker = new CompletableFuture<>();
4✔
304
    CompletableFuture<Void> race = pendingRefreshes.putIfAbsent(cacheKey, marker);
7✔
305
    if (race != null) {
2!
306
      return;
×
307
    }
308

309
    // refreshLimiter is null when soft expire is disabled; isSoftExpireEnabled() above
310
    // guarantees it is non-null here.
311
    if (!refreshLimiter.tryAcquire()) {
4✔
312
      pendingRefreshes.remove(cacheKey);
5✔
313
      marker.complete(null);
4✔
314
      log.debug("Refresh limiter blocked, skip background refresh: {}", cacheKey);
4✔
315
      return;
1✔
316
    }
317

318
    long refreshStartDataVersion = Optional.ofNullable(caffeineCache.getIfPresent(cacheKey))
7✔
319
      .filter(CacheEntry.class::isInstance)
6✔
320
      .map(CacheEntry.class::cast)
5✔
321
      .map(CacheEntry::getDataVersion)
2✔
322
      .orElse(VERSION_DEFAULT);
5✔
323

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

330
    try {
331
      completableFuture = CompletableFuture.supplyAsync(reader, executor).orTimeout(
8✔
332
        refreshTimeoutSeconds,
333
        TimeUnit.SECONDS
334
      );
335
    } catch (RejectedExecutionException e) {
1✔
336
      // Executor saturated — release the limiter permit and the pending-refresh
337
      // slot so the next read can retry immediately instead of being blocked forever.
338
      refreshLimiter.release();
3✔
339
      pendingRefreshes.remove(cacheKey);
5✔
340
      marker.complete(null);
4✔
341
      log.warn("Background refresh rejected by executor (saturated), key={}", cacheKey);
4✔
342
    }
1✔
343

344
    completableFuture.whenComplete((value, error) -> {
9✔
345
      try {
346
        if (error != null) {
2✔
347
          if (error instanceof TimeoutException) {
3!
348
            log.warn("Background soft refresh timed out after {}s: {}", refreshTimeoutSeconds, cacheKey);
×
349
          } else {
350
            log.warn("Background soft refresh failed: {}", cacheKey, error);
5✔
351
          }
352
          return;
1✔
353
        }
354
        if (value != null) {
2✔
355
          caffeineCache
2✔
356
            .asMap()
8✔
357
            .compute(cacheKey, (k, existing) ->
2✔
358
              Optional.ofNullable(existing)
5✔
359
                .filter(CacheEntry.class::isInstance)
6✔
360
                .map(CacheEntry.class::cast)
10✔
361
                .map(cacheEntry -> {
5✔
362
                  if (cacheEntry.getDataVersion() > refreshStartDataVersion) {
5✔
363
                    log.debug("Async refresh discarded: newer version exists: {}", cacheKey);
4✔
364
                    return cacheEntry;
2✔
365
                  }
366
                  return cacheEntry
2✔
367
                    .toBuilder()
2✔
368
                    .value(value)
2✔
369
                    .softTtlMs(softTtlMs)
3✔
370
                    .softExpireAtMs(computeSoftExpireAt(softTtlMs))
2✔
371
                    .build();
1✔
372
                })
373
                .orElseGet(() ->
1✔
374
                  CacheEntry.builder()
3✔
375
                    .value(value)
2✔
376
                    .dataVersion(VERSION_DEFAULT)
2✔
377
                    .isVersionDegraded(false)
2✔
378
                    .decisionVersion(0L)
2✔
379
                    .hardTtlMs(0L)
2✔
380
                    .hardExpireAtMs(Long.MAX_VALUE)
2✔
381
                    .softTtlMs(softTtlMs)
3✔
382
                    .softExpireAtMs(computeSoftExpireAt(softTtlMs))
3✔
383
                    .keyState(KeyState.NORMAL)
2✔
384
                    .normalHardTtlMs(0L)
2✔
385
                    .normalSoftTtlMs(0L)
1✔
386
                    .build()
1✔
387
                )
388
            );
389
        }
390
      } finally {
391
        refreshLimiter.release();
3✔
392
        pendingRefreshes.remove(cacheKey);
5✔
393
        marker.complete(null);
4✔
394
      }
395
    });
1✔
396
  }
1✔
397
}
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